commit 8ecc78e729c5c1717f98b5692703354b6c9ffb66 Author: EVOTECH IT SRL Date: Fri Jul 10 03:39:53 2026 -0700 livrare lot 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..343318d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Director intern de sesiune (asistent) — nu face parte din livrare +.claude/ diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..64b2478 --- /dev/null +++ b/README.txt @@ -0,0 +1,52 @@ +======================================================================== + DIDI — LOT 2 (BACKEND) — PACHET DE LIVRARE + Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării + PNRR DIGI150 · contract 11.1.i3.c9 + Data: 2026-07-10 +======================================================================== + +CONȚINUTUL PACHETULUI +--------------------- + + cod-sursa-lot2.tar.gz + Codul sursă complet al Lotului 2 (agent-v3 + workeri, didiFramework, + admin-dashboard, gateway, data-layer, observabilitate, scripturi build). + NU conține: secrete (.env), dependențe (node_modules), dump-ul bazei + de date, artefacte de build. Sunt incluse șabloanele *.env.example. + + imagini-docker/ + Imaginile Docker pre-construite pentru serviciile Lot 2: + - agent-v3.tar.gz (motor de analiză + cei 13 workeri) + - didi-framework.tar.gz (API management parametri) + - didi-admin.tar.gz (dashboard administrativ) + - didi-kong.tar.gz (API gateway) + incarca-imagini.sh — script care rulează `docker load` pentru toate. + (Serviciile de infrastructură — PostgreSQL, Redis, RabbitMQ, MinIO, + Keycloak — folosesc imagini publice oficiale, se descarcă la deploy.) + + documente/ + Dosarul de documentație (Markdown + .docx): + 00_INDEX ............ cuprinsul dosarului + 01_Arhitectura ...... arhitectura tehnică + 02_Ghid_Instalare ... instalare & operare + 03_Raport_Testare_API ......... testare API (374/374) + 04_Raport_Testare_Integrare ... testare integrare Lot1↔Lot2 + 05_Ghid_Utilizare ............. manual cu capturi de ecran + 06_Matrice_Trasabilitate ...... cerințe → dovezi + 07_Specificatii_API ........... structura API-urilor + +LIVRARE GENERICĂ +---------------- +Pachetul este pregătit pentru livrare generică: nu conține adresa IP a +mașinii de dezvoltare și nici referințe la furnizori terți. Adresele de +platformă apar ca (documente) / didi.local (imagine dashboard) și +se configurează la instalare. + +INSTALARE +--------- +Vezi documente/02_Ghid_Instalare_Operare_Lot2. Pe scurt: + 1. Încarcă imaginile: bash imagini-docker/incarca-imagini.sh + 2. Dezarhivează sursa: tar xzf cod-sursa-lot2.tar.gz + 3. Completează fișierele .env din șabloanele .env.example (host, secrete). + 4. Rulează: backend/production/build-local.sh +======================================================================== diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..416e50d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,60 @@ +# ============================================================================== +# Backend .gitignore — DIDI Platform +# ============================================================================== + +# Dependencies +node_modules/ + +# Build artifacts +build/ +dist/ +*.tsbuildinfo + +# Environment & secrets +.env +.env.local +.env.staging +.env.production +*.env # orice fisier *.env (ex. .cluster-credentials.env) — secrete +!*.env.example # dar pastreaza template-urile +.cluster-credentials.env +*.pem +*.key +*.crt +.htpasswd +pgpass + +# OS files +.DS_Store +Thumbs.db +*.swp +*~ + +# IDE +.vscode/ +.idea/ +*.code-workspace + +# Logs +*.log +npm-debug.log* + +# Docker data volumes +data/ + +# Backup files +*.bak +*.bak-* +*.orig +*.rej +*.backup + +# DB seed / dumps (date + PII — nu se versioneaza; preconditie de deployment) +*.sql.dump +services/data-layer/didiDatabase/DIDI_full_export_*.sql + +# Output teste de integrare (generat) +scripts/integration/results/ + +# Handoff-uri interne de sesiune (note interne, nu livrabile) +HANDOFF_*.md diff --git a/backend/BUILD_AND_SCRIPTS.md b/backend/BUILD_AND_SCRIPTS.md new file mode 100644 index 0000000..3e5a415 --- /dev/null +++ b/backend/BUILD_AND_SCRIPTS.md @@ -0,0 +1,124 @@ +# DIDI Backend — Build, Scripturi, Env & Seed (index operațional) + +> Răspunde la: *cum ridic platforma pe o mașină nouă, ce script rulez, ce env pun, de unde iau seed-ul.* +> Topologia curentă = **totul local pe mașina de deployment** (fără cluster extern). Vezi `services/*/INDEX.md` pentru detalii per serviciu. + +--- + +## 1. Build curat pe o mașină nouă (calea recomandată) + +```bash +cd backend/production +./build-local.sh [hostname] # implicit: hostname-ul mașinii +``` + +`build-local.sh` ridică TOATĂ stiva local, în ordinea corectă: network → data-layer (PG+seed, Redis, +RabbitMQ, MinIO, Keycloak) → Kong → framework + sync-redis → agent-v3 + workeri → dashboard. +**Seed-ul se importă automat** la primul boot al `didi-postgres` (montat ca init script). + +**Precondiții** (nu-s în git — se aduc manual pe mașina nouă): +1. `.env` completate pentru fiecare serviciu (copiază din `.env.example`, pune secretele — vezi §4). +2. Seed-ul `services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql` prezent (vezi §5). + +--- + +## 2. Ce docker-compose rulează per serviciu (evită confuzia `.local.yml`) + +| Serviciu | Compose CANONIC (rulează) | Notă | +|---|---|---| +| **data-layer** (PG, Redis, RabbitMQ, MinIO, Keycloak) | `services/data-layer/docker-compose.local.yml` | AICI `.local.yml` = cel activ (PG+Keycloak local) | +| **didiFramework** | `services/orchestration-layer/didiFramework/docker-compose.yml` | `.yml` are `${DB_HOST:-didi-postgres}` — local by default, overridable | +| **agent-v3 + workeri** | `services/orchestration-layer/agent-v3/docker-compose.yml` | idem | +| **Kong** | `docker run` din `build-local.sh` (DBless) | config `didiKong/declarative/kong.yml.didi11-local` | +| **admin dashboard** | imagine `didi-admin:latest` (`docker run`) | vezi build-local.sh Faza 7 | +| observabilitate (opțional) | `observability/docker-compose.yml` | Prometheus/Grafana/Loki/OTel | + +> `data-layer/docker-compose.yml` (fără `.local`) = stiva veche de staging (`staging-dataLayer-*`) — nefolosită pentru DB principal în modul local. +> Cele 2 `.local.yml` redundante ale framework/agent au fost arhivate (erau acoperite integral de `.yml`). + +--- + +## 3. Inventar scripturi + +| Script | Ce face | Stare | +|---|---|---| +| `production/build-local.sh` | **Build complet local** (recomandat) | ✅ curent | +| `production/full-build.sh` | Build pe topologie cluster (PG/Kong externe) | legacy — doar dacă revii la cluster | +| `production/migrate-to-cluster.sh` | Mută DB-urile Kong/Keycloak pe cluster | legacy | +| `services/data-layer/didiQueue/init-queues.sh` | Creează exchange + cozi + DLQ în RabbitMQ | util la nevoie (topologia se face și dinamic de workeri) | +| `services/data-layer/didiStorage/init-buckets.sh` | Creează bucket-urile MinIO | rulat de build-local.sh | +| `services/gateway-auth-layer/didiKong/entrypoint.sh` | Entrypoint container Kong | intern | +| `services/orchestration-layer/agent-v3/scale-workers.sh` | Scalare workeri (status/set/auto pe metrici) | operațional | +| `services/orchestration-layer/didiFramework/scripts/bulk_insert_*.sh` | Insert bulk indicatori/reguli tehnici | one-off (datele sunt deja în seed) | +| `services/orchestration-layer/scripts/redis-switch.sh` | Comută Redis/RabbitMQ local ↔ cluster | operațional | +| `services/orchestration-layer/scripts/minio-switch.sh` | Comută MinIO local ↔ cluster | operațional | +| `scripts/api/api_probe.py` (repo root) | Inventar + probă live a tuturor endpoint-urilor | testare/recepție | +| `scripts/api/generate_openapi.py` (repo root) | Generează `openapi.yaml` din inventar + probe | testare/recepție | +| `scripts/ci/health-check.sh` (repo root) | Health-gate post-deploy | CI/CD | + +--- + +## 4. Fișiere .env (unde stau, ce pui) + +Fiecare serviciu are `.env` (gitignored, cu secrete) + `.env.example` (în git, șablon fără secrete). +Pe mașină nouă: `cp .env.example .env` și completează valorile `CHANGE_ME`. + +| Serviciu | `.env` | Secrete de completat | +|---|---|---| +| `services/orchestration-layer/agent-v3/` | infra + LLM + **URL-uri Lot 1** | OPENROUTER/OPENAI/GROQ/ANTHROPIC/GOOGLE keys, M17 token, MinIO keys | + +**Lot 1 (platforma AI) — configurabil 100% din env.** agent-v3 cheamă serviciile AI prin URL-uri +din `.env` (nu hardcodate). Pe o mașină nouă cu Lot 1 propriu, setezi doar host-urile în +`agent-v3/.env` (bloc marcat „Lot 1 — Platforma AI"): + +| Env var | Serviciu Lot 1 | +|---|---| +| `LLM_ROUTER_URL` | llm-inference (Qwen text/OCR) | +| `VISION_LLM_URL` | Qwen Vision (fallback: `LLM_ROUTER_URL`) | +| `DIDI_BRAIN_URL` | brain (verification cache + RAG) | +| `VIDEO_ANALYSIS_URL` | video/BusterX deepfake | +| `EXTRACTORS_URL` | extractors (EXIF/ELA/NER/YOLO/OCR) | +| `FORENSIC_API_URL` | forensic m25-m29 | +| `M17_WHISPER_URL` | transcriere audio | +| `M17_WEB_API_URL` | web search (claims/source) | +| `DOMAIN_CHECK_API_URL` | domain check | + +Fiecare are și fallback în `docker-compose.yml` (`${VAR:-default}`): dacă Lot 1 rulează pe aceeași +rețea Docker, merge cu numele de container (ex. `didiAI-extractors`); dacă e pe alt host, pui IP-ul în `.env`. +| `admin-dashboard/` | Keycloak + URL-uri | de regulă doar hostname-uri | +| `production/` | Redis/Keycloak/Kong | REDIS/KEYCLOAK passwords, Kong secrets | +| `services/data-layer/`, `didiCache/`, `didiQueue/`, `didiStorage/` | infra | credențiale local (redis123/rabbitmq123/minio123 în staging) | +| `services/orchestration-layer/didiFramework/` | DB + Keycloak + MinIO | DB_PASSWORD, KEYCLOAK_ADMIN_PASSWORD | +| `observability/` | Grafana | GRAFANA admin | + +> `services/orchestration-layer/scripts/.cluster-credentials.env` (gitignored) = credențialele cluster, +> folosit doar de `redis-switch.sh --cluster` / `minio-switch.sh cluster`. Irelevant pentru build local. + +--- + +## 5. Seed-ul bazei de date + +- **Fișier canonic:** `services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql` (23 MB, pg_dump + complet: schema + date + toate migrațiile 001–017). Restore curat → 4 scheme `bos_*`, 97 tabele. +- **NU e în git** (untracked, prea mare). Pe o mașină nouă trebuie adus manual (copiere / stick / scp) + în directorul de mai sus ÎNAINTE de `build-local.sh`. +- **Redis NU se seed-uiește** — e cache derivat din PostgreSQL. După restore: `POST /api/sync-redis` + (build-local.sh o face automat). Rețetă completă: `didiDatabase/REBUILD.md`. +- Arhivat în afara repo-ului (`/home/admin365/didi_seed_archive_2026-07-08/`): seed-ul vechi + `2026-03-22` (fără migrații) + pachetul demo (abandonat — livrarea folosește doar full seed). + +--- + +## 6. Verificare rapidă după build + +```bash +# health toate serviciile +docker exec didi-postgres pg_isready -U bos_interface +docker exec didi-cache redis-cli -a redis123 --no-auth-warning ping +docker exec didi-framework wget -qO- http://127.0.0.1:3005/health +docker exec didi-agent-v3 wget -qO- http://localhost:24803/api/v3/health +# chei framework în Redis (după sync) +docker exec didi-cache redis-cli -a redis123 --no-auth-warning keys 'didi:framework:*' | wc -l # aștept 8 +# API docs (dacă rulează containerul didi-api-docs) +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8089/ +``` diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..e4d053d --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,135 @@ +# DIDI Backend Platform + +Misinformation detection platform — 3-layer microservices architecture (TypeScript/Node.js). + +> **⚠️ READ FIRST**: [`REFACTOR_CONTEXT.md`](./REFACTOR_CONTEXT.md) — convenții, helpers, +> structură nouă post-refactor (sesiune 2026-05-07). Conține pattern-urile +> obligatorii (`log`, `requireEnv`, `internalError`, `lazyRedis`, `acquireLock`, +> `LogicalInputError`, etc) + anti-pattern-uri de evitat + fișierele mari rămase. + +## Active Services + +| Service | Port | Container | Compose file | +|---------|------|-----------|--------------| +| Agent V3 | 24803 | didi-agent-v3 | `services/orchestration-layer/agent-v3/docker-compose.yml` | +| didiFramework | 3005 | didi-framework | `services/orchestration-layer/didiFramework/docker-compose.yml` | +| Admin Dashboard | 3000 (HTTPS) | didi-admin | `services/data-layer/docker-compose.yml` | + +Workers (in agent-v3 compose): worker-techniques ×2, worker-ai-tampered ×2, worker-claims ×3, worker-domain ×2, worker-media-preprocess ×2, verdict-aggregator ×2. + +## Quick Commands + +```bash +# Rebuild agent-v3 (+ all workers) +cd services/orchestration-layer/agent-v3 && docker compose up -d --build agent-v3 + +# Rebuild framework +cd services/orchestration-layer/didiFramework && docker compose up -d --build didi-framework + +# Rebuild admin dashboard +cd admin-dashboard && docker build -t didi-admin:latest . +cd ../services/data-layer && docker compose up -d --force-recreate didi-admin + +# Sync parameters to Redis +docker exec didi-framework sh -c 'wget -qO- --post-data="" http://127.0.0.1:3005/api/sync-redis' + +# View logs +docker logs -f didi-agent-v3 +docker logs -f didi-framework +``` + +## Structure + +``` +backend/ +├── admin-dashboard/ # React 19, MUI 7, TypeScript +│ ├── src/components/ # ServiceCard, AnalysisHistory, UserManagement, etc. +│ ├── src/services/api.ts # API client (health checks via Docker container state) +│ ├── nginx-ssl.conf # Nginx proxy (/framework/ → :3005, /agent-v3/ → :24803) +│ └── Dockerfile # Multi-stage: node:20 build → nginx:alpine serve +├── production/ # Production docker-compose + .env +└── services/ + ├── data-layer/ + │ ├── didiCache/ # Redis 7 config + │ ├── didiDatabase/ # Local PG (waitlist only, main DB is external cluster) + │ ├── didiQueue/ # RabbitMQ init scripts + │ ├── didiStorage/ # MinIO init + lifecycle + │ ├── pgadmin/ + │ └── docker-compose.yml # All data layer + admin dashboard containers + ├── gateway-auth-layer/ + │ ├── didiKeycloak/ # Realm import + 3 custom themes + │ └── didiKong/ # Declarative config + SSL certs + └── orchestration-layer/ + ├── agent-v3/ # Analysis engine (Express 5, TypeScript) + │ ├── src/api/ # routes.ts, ai-tampered-routes.ts, claims-routes.ts, pipeline-routes.ts + │ ├── src/components/ # techniques/, ai-tampered/, claims/, pipeline/ executors + │ ├── src/shared/ # media/, persistence/, redis/, types/ + │ ├── src/queue/ # dispatcher.ts, connection.ts, aggregator.ts, workers/ + │ └── docker-compose.yml # agent-v3 + 13 worker containers + └── didiFramework/ # Parameters CRUD API (Express 4, TypeScript) + ├── src/routes/ # 20+ route files (CRUD for all parameters) + ├── sql/migrations/ # PG migrations + └── docker-compose.yml +``` + +## Databases + +| Database | Host | Schema | Used by | +|----------|------|--------|---------| +| DIDI (PG cluster) | 10.11.50.167:5000 | bos_parammgmt | didiFramework (parameters CRUD) | +| | | bos_analysis | agent-v3 (analysis results) | +| | | bos_sysadmin | didiFramework (users, subscriptions) | +| | | bos_subscriber | didiFramework (personal data) | +| Redis | didi-cache:6379 | — | Both (framework config cache, session state) | +| RabbitMQ | rabbitmq:5672 | — | agent-v3 (async analysis queues) | +| MinIO | minio:9000 | — | Both (media file storage) | + +## API Routes (Agent V3, all async) + +All `/analyze` and `/analyze-media` endpoints dispatch to RabbitMQ workers and return 202. + +| Prefix | File | What | +|--------|------|------| +| `/api/v3/techniques/*` | routes.ts | Manipulation technique detection | +| `/api/v3/ai-tampered/*` | ai-tampered-routes.ts | AI content detection | +| `/api/v3/claims/*` | claims-routes.ts | Claim extraction + verification | +| `/api/v3/pipeline/*` | pipeline-routes.ts | Full pipeline, history, extension | +| `/api/v3/source-assessment/*` | source-assessment-routes.ts | Source credibility | +| `/api/v3/domain/*` | routes.ts | Domain analysis | +| `/api/v3/media/*` | routes.ts | File upload/download (MinIO) | + +## LLM Models + +| Role | Model | Location | +|------|-------|----------| +| Primary (all) | Qwen 3.5 397B | Local GPU 10.11.10.17:14011 | +| Fallback 1 | Claude Sonnet 4.6 (Opus 4.6 for ai-tampered) | OpenRouter | +| Fallback 2 | GPT-4o | OpenRouter | +| Vision | Qwen Vision → Gemini Flash → GPT-4o | 10.11.10.42:14001 + OpenRouter | +| Transcription | M17-Whisper → Groq → OpenAI | 10.11.10.17:54300 + Cloud | + +Managed via `component_stage_assignment` PG table → sync to Redis. + +## Tech Stack + +| Component | Technology | +|-----------|------------| +| Agent V3 | Node.js, TypeScript, Express 5 | +| didiFramework | Node.js, TypeScript, Express 4 | +| Admin Dashboard | React 19, MUI 7, TypeScript | +| Database | PostgreSQL 14 (Patroni/HAProxy) | +| Cache | Redis 7 | +| Queue | RabbitMQ 3.12 | +| Storage | MinIO | +| Gateway | Kong 3.4 (SSL :443) | +| Auth | Keycloak 22 | + +## Credentials (Staging) + +| Service | User | Password | +|---------|------|----------| +| PostgreSQL (cluster) | bos_interface | interface | +| Redis | — | redis123 | +| RabbitMQ | admin | rabbitmq123 | +| MinIO | minioadmin | minio123 | +| Keycloak | admin@didi.local | admin123 | diff --git a/backend/DEPLOY_FROM_SCRATCH.md b/backend/DEPLOY_FROM_SCRATCH.md new file mode 100644 index 0000000..cc4d374 --- /dev/null +++ b/backend/DEPLOY_FROM_SCRATCH.md @@ -0,0 +1,1649 @@ +# DIDI Backend - Deploy From Scratch (Full Guide) + +Acest document descrie TOTUL necesar pentru a ridica platforma DIDI de la zero pe o masina noua. + +--- + +## CUPRINS + +1. [Prerequisite](#1-prerequisite) +2. [Arhitectura serviciilor](#2-arhitectura-serviciilor) +3. [Ordinea de deploy (graf dependente)](#3-ordinea-de-deploy) +4. [ETAPA 0: Pregatire masina](#etapa-0-pregatire-masina) +5. [ETAPA 1: Docker network](#etapa-1-docker-network) +6. [ETAPA 2: PostgreSQL cluster extern](#etapa-2-postgresql-cluster-extern) +7. [ETAPA 3: Data Layer (Redis + RabbitMQ + MinIO + pgAdmin)](#etapa-3-data-layer) +8. [ETAPA 4: Keycloak (autentificare)](#etapa-4-keycloak) +9. [ETAPA 5: Kong (API gateway)](#etapa-5-kong) +10. [ETAPA 6: didiFramework (CRUD parametri)](#etapa-6-didiframework) +11. [ETAPA 7: Sync Redis (populare config)](#etapa-7-sync-redis) +12. [ETAPA 8: agent-v3 + workeri](#etapa-8-agent-v3) +13. [ETAPA 9: Admin Dashboard](#etapa-9-admin-dashboard) +14. [ETAPA 10: Verificare finala](#etapa-10-verificare-finala) +15. [Anexa A: Toate variabilele de mediu](#anexa-a-variabile-de-mediu) +16. [Anexa B: Chei Redis complete](#anexa-b-chei-redis) +17. [Anexa C: Schema PostgreSQL completa](#anexa-c-schema-postgresql) +18. [Anexa D: Date seed necesare](#anexa-d-date-seed) +19. [Anexa E: Servicii externe (GPU, API keys)](#anexa-e-servicii-externe) +20. [Anexa F: Porturi utilizate](#anexa-f-porturi) + +--- + +## 1. Prerequisite + +### Software necesar pe masina host + +``` +Docker Engine >= 24.x +Docker Compose v2 (plugin) +git +curl +psql (client PostgreSQL, pentru initializare DB) +mc (MinIO Client, pentru init buckets) +``` + +### Resurse externe necesare (NU pe aceasta masina) + +| Resursa | Adresa | Scop | +|---------|--------|------| +| PostgreSQL Cluster (Patroni/HAProxy) | 10.11.50.167:5000 | Baza de date principala | +| GPU Machine | 10.11.10.17 | Whisper (54300), Web Search (51100), Qwen Vision (14011) | +| Domain Check API | :11000 | Analiza domeniu | + +### API Keys necesare + +| Key | Variabila | Scop | +|-----|-----------|------| +| OpenRouter | OPENROUTER_API_KEY | Modele LLM (Gemini, GPT-4o, Claude) | +| OpenAI | OPENAI_API_KEY | Fallback LLM + Whisper transcriere | +| Groq | GROQ_API_KEY | Fallback LLM + Whisper transcriere | +| M17 Whisper | M17_WHISPER_TOKEN | Transcriere audio primara | +| Anthropic (optional) | ANTHROPIC_API_KEY | Fallback LLM | +| Google (optional) | GOOGLE_API_KEY | Fallback LLM | + +--- + +## 2. Arhitectura serviciilor + +``` + Internet + | + [Edge Nginx] + | + [Tunel] + | + +------+------+ + | Kong | (port 443 SSL) + | Gateway | + +------+------+ + | + +----------------+----------------+ + | | | + [Keycloak] [agent-v3] [didi-admin] + (auth JWT) (port 24803) (nginx 443/80) + | | + +----------+----------+ | + | | | | + [workers] [aggregator] | [didiFramework] + (rabbitmq) (verdict) | (port 3005) + | | | | + +----------+----+-----+-----+ + | + +---------+---------+ + | | | + [Redis] [RabbitMQ] [MinIO] + (6379) (5672) (9000) + | | | + +---------+---------+ + | + [PostgreSQL Cluster] + (10.11.50.167:5000) +``` + +### 4 Docker Compose files + +| Fisier | Servicii | Locatie | +|--------|----------|---------| +| production/docker-compose.yml | Kong, Keycloak, Redis | production/ | +| data-layer/docker-compose.yml | PostgreSQL local, RabbitMQ, MinIO, pgAdmin, Redis Commander, didi-admin | services/data-layer/ | +| didiFramework/docker-compose.yml | didiFramework | services/orchestration-layer/didiFramework/ | +| agent-v3/docker-compose.yml | agent-v3, 5 worker types, verdict-aggregator (14 containere) | services/orchestration-layer/agent-v3/ | + +--- + +## 3. Ordinea de deploy + +``` +[1] Docker network (didi-network) + | + +-->[2] PostgreSQL cluster (extern, trebuie sa existe + schemas create) + | + +-->[3a] Redis (didi-cache) ----+ + | | + +-->[3b] RabbitMQ --------------+-->[6] didiFramework -->[7] SYNC REDIS + | | | + +-->[3c] MinIO -----------------+ v + | [8] agent-v3 + workeri + +-->[4] Keycloak (necesita PG) | + | v + +-->[5] Kong (necesita PG + Keycloak) [9] Admin Dashboard +``` + +**REGULA DE AUR**: Nimic nu porneste fara Redis + PostgreSQL + didi-network. + +--- + +## ETAPA 0: Pregatire masina + +```bash +#!/bin/bash +# === ETAPA 0: Pregatire === + +# 0.1 Clone repo +cd /home/admin365 +git clone didi_mono +cd didi_mono/backend + +# 0.2 Creeaza volume externe Docker (persistenta intre recreari) +docker volume create didi-staging-postgres-data +docker volume create didi-staging-minio-data +docker volume create didi-staging-pgadmin-data + +# 0.3 Verifica conectivitate la PostgreSQL cluster +psql -h 10.11.50.167 -p 5000 -U bos_interface -d DIDI -c "SELECT 1;" +# Parola: interface + +# 0.4 Verifica conectivitate la GPU machine +curl -s http://10.11.10.17:54300/health && echo "Whisper OK" +curl -s http://10.11.10.17:51100/health && echo "Web Search OK" +curl -s http://10.11.10.17:14011/health && echo "Qwen Vision OK" +``` + +--- + +## ETAPA 1: Docker network + +```bash +# === ETAPA 1: Creare retea Docker partajata === +docker network create didi-network + +# Verificare +docker network ls | grep didi-network +``` + +**IMPORTANT**: Toate docker-compose-urile refera `didi-network` ca `external: true`. Daca nu exista, nimic nu porneste. + +--- + +## ETAPA 2: PostgreSQL cluster extern + +### 2.1 Creare baze de date si useri + +Conecteaza-te la clusterul Patroni ca superuser: + +```bash +psql -h 10.11.50.167 -p 5000 -U postgres +``` + +```sql +-- Baza de date principala DIDI +CREATE DATABASE "DIDI" OWNER bos_interface; + +-- Baza de date Kong +CREATE USER kong WITH PASSWORD 'kong123'; +CREATE DATABASE kong_db OWNER kong; +GRANT ALL PRIVILEGES ON DATABASE kong_db TO kong; + +-- Baza de date Keycloak +CREATE USER keycloak WITH PASSWORD 'keycloak123'; +CREATE DATABASE keycloak_db OWNER keycloak; +GRANT ALL PRIVILEGES ON DATABASE keycloak_db TO keycloak; + +-- User principal DIDI +CREATE USER bos_interface WITH PASSWORD 'interface'; +GRANT ALL PRIVILEGES ON DATABASE "DIDI" TO bos_interface; +``` + +### 2.2 Creare scheme in baza DIDI + +```bash +psql -h 10.11.50.167 -p 5000 -U bos_interface -d DIDI +``` + +```sql +-- Cele 4 scheme +CREATE SCHEMA IF NOT EXISTS bos_analysis; +CREATE SCHEMA IF NOT EXISTS bos_parammgmt; +CREATE SCHEMA IF NOT EXISTS bos_sysadmin; +CREATE SCHEMA IF NOT EXISTS bos_subscriber; + +-- Search path default +ALTER USER bos_interface SET search_path TO bos_parammgmt, bos_analysis, bos_sysadmin, bos_subscriber, public; +``` + +### 2.3 Creare tabele bos_analysis (6 tabele + 1 view) + +```sql +-- === SCHEMA: bos_analysis === +-- Scrisa de agent-v3, citita de didiFramework (history) + +SET search_path TO bos_analysis; + +CREATE TABLE IF NOT EXISTS analysis_session ( + session_id TEXT PRIMARY KEY, + user_id TEXT, + user_email TEXT, + input_type TEXT, -- text, url, image, audio, video + input_text TEXT, + input_url TEXT, + input_media_url TEXT, + input_hash TEXT, -- SHA256 deduplicare + status TEXT DEFAULT 'pending', -- pending, running, completed, failed + components_run TEXT[], + components_skipped TEXT[], + risk_score NUMERIC, + risk_category TEXT, + risk_level TEXT, + confidence NUMERIC, + confidence_level TEXT, + started_at TIMESTAMP, + completed_at TIMESTAMP, + total_duration_ms INTEGER, + scenario_applied TEXT, + topic_applied TEXT, + source_app TEXT DEFAULT 'web', + api_version TEXT DEFAULT 'v3', + llm_usage JSONB, -- migration 004 + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS analysis_techniques ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + manipulation_score NUMERIC, + total_severity NUMERIC, + dimensions_affected TEXT[], + techniques_count INTEGER, + techniques_detected JSONB, + coupling_context JSONB, + llm_screening TEXT, + llm_deep TEXT, + screening_duration_ms INTEGER, + deep_analysis_duration_ms INTEGER, + total_duration_ms INTEGER, + fallbacks_screening INTEGER DEFAULT 0, + fallbacks_deep INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS analysis_ai_tampered ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + ai_probability NUMERIC, + verdict TEXT, + risk_score NUMERIC, + categories_affected TEXT[], + indicators_count INTEGER, + disclosure_detected BOOLEAN, + disclosure_explicit BOOLEAN, + disclosure_text TEXT, + indicators_detected JSONB, + coupling_context JSONB, + llm_screening TEXT, + llm_deep TEXT, + screening_duration_ms INTEGER, + deep_analysis_duration_ms INTEGER, + total_duration_ms INTEGER, + fallbacks_screening INTEGER DEFAULT 0, + fallbacks_deep INTEGER DEFAULT 0, + content_type TEXT, + image_analysis JSONB +); + +CREATE TABLE IF NOT EXISTS analysis_claims ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + total_claims INTEGER, + verified_true INTEGER, + verified_false INTEGER, + unverified INTEGER, + opinions INTEGER, + credibility_score NUMERIC, + interpretation TEXT, + claims_by_status JSONB, + claims_by_type JSONB, + claims_verified JSONB, + llm_extraction TEXT, + llm_verification TEXT, + extraction_duration_ms INTEGER, + verification_duration_ms INTEGER, + total_duration_ms INTEGER, + web_searches_made INTEGER +); + +CREATE TABLE IF NOT EXISTS analysis_domain ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + domain TEXT, + verdict TEXT, + trust_score NUMERIC, + risk_level TEXT, + age_days INTEGER, + age_category TEXT, + domain_created_at TIMESTAMP, + is_blacklisted BOOLEAN, + reputation_score NUMERIC, + has_ssl BOOLEAN, + ssl_valid BOOLEAN, + ssl_issuer TEXT, + registrar TEXT, + organization TEXT, + country TEXT, + red_flags TEXT[], + warnings TEXT[], + duration_ms INTEGER +); + +CREATE TABLE IF NOT EXISTS analysis_source_assessment ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + trust_score NUMERIC, + verdict TEXT, + risk_level TEXT, + publication JSONB, + author JSONB, + platform JSONB, + domain JSONB, + formula JSONB, + warnings TEXT[], + red_flags TEXT[], + llm_model TEXT, + duration_ms INTEGER +); +CREATE INDEX IF NOT EXISTS idx_source_assessment_trust_score ON analysis_source_assessment(trust_score); +CREATE INDEX IF NOT EXISTS idx_source_assessment_verdict ON analysis_source_assessment(verdict); + +CREATE TABLE IF NOT EXISTS analysis_verdict ( + session_id TEXT PRIMARY KEY REFERENCES analysis_session(session_id) ON DELETE CASCADE, + risk_score NUMERIC, + risk_category TEXT, + risk_category_color TEXT, + risk_level TEXT, + risk_level_color TEXT, + severity TEXT, + recommended_action TEXT, + confidence NUMERIC, + confidence_level TEXT, + score_manipulation NUMERIC, + score_claims NUMERIC, + score_ai NUMERIC, + score_source NUMERIC, + score_context NUMERIC, + applied_weights JSONB, + override_applied BOOLEAN, + override_type TEXT, + override_reason TEXT, + override_adjustment NUMERIC, + context_summary JSONB, + components_used TEXT[], + weights_source TEXT, + duration_ms INTEGER, + explanation_ro TEXT, -- migration 001 + explanation_en TEXT, -- migration 001 + virality_score NUMERIC, + virality_level TEXT, + virality_factors JSONB +); + +-- View lightweight (fara JSONB-uri grele) +CREATE OR REPLACE VIEW v_analysis_full AS +SELECT + s.session_id, s.user_id, s.user_email, s.input_type, + s.input_text, s.input_url, s.input_media_url, s.status, + s.components_run, s.components_skipped, + s.risk_score, s.risk_category, s.risk_level, + s.confidence, s.confidence_level, + s.started_at, s.completed_at, s.total_duration_ms, + s.scenario_applied, s.topic_applied, + s.source_app, s.api_version, s.created_at, + -- Verdict + v.risk_score AS verdict_risk_score, v.risk_category AS verdict_risk_category, + v.risk_level AS verdict_risk_level, v.severity, v.recommended_action, + v.confidence AS verdict_confidence, v.confidence_level AS verdict_confidence_level, + v.score_manipulation, v.score_claims, v.score_ai, v.score_source, v.score_context, + v.override_applied, v.override_type, v.weights_source, + v.explanation_ro, v.explanation_en, + v.virality_score, v.virality_level, + -- Techniques (sumar) + t.manipulation_score, t.total_severity, t.dimensions_affected, t.techniques_count, + t.llm_screening AS tech_llm_screening, t.llm_deep AS tech_llm_deep, + t.total_duration_ms AS tech_duration_ms, + -- AI Tampered (sumar) + a.ai_probability, a.verdict AS ai_verdict, a.risk_score AS ai_risk_score, + a.categories_affected AS ai_categories, a.indicators_count AS ai_indicators_count, + a.disclosure_detected, a.content_type AS ai_content_type, + a.total_duration_ms AS ai_duration_ms, + -- Claims (sumar) + c.total_claims, c.verified_true, c.verified_false, c.unverified, c.opinions, + c.credibility_score, c.interpretation AS claims_interpretation, + c.web_searches_made, c.total_duration_ms AS claims_duration_ms, + -- Domain (sumar) + d.domain, d.trust_score AS domain_trust_score, d.verdict AS domain_verdict, + d.risk_level AS domain_risk_level, d.is_blacklisted, + d.duration_ms AS domain_duration_ms, + -- Source Assessment (sumar) + sa.trust_score AS sa_trust_score, sa.verdict AS sa_verdict, + sa.risk_level AS sa_risk_level, + sa.duration_ms AS sa_duration_ms +FROM bos_analysis.analysis_session s +LEFT JOIN bos_analysis.analysis_verdict v ON s.session_id = v.session_id +LEFT JOIN bos_analysis.analysis_techniques t ON s.session_id = t.session_id +LEFT JOIN bos_analysis.analysis_ai_tampered a ON s.session_id = a.session_id +LEFT JOIN bos_analysis.analysis_claims c ON s.session_id = c.session_id +LEFT JOIN bos_analysis.analysis_domain d ON s.session_id = d.session_id +LEFT JOIN bos_analysis.analysis_source_assessment sa ON s.session_id = sa.session_id +ORDER BY s.created_at DESC; +``` + +### 2.4 Creare tabele bos_parammgmt (~40 tabele) + +```sql +-- === SCHEMA: bos_parammgmt === +-- Scrisa de didiFramework CRUD, sincronizata in Redis + +SET search_path TO bos_parammgmt; + +-- Tabel parinte versionare +CREATE TABLE IF NOT EXISTS parameter ( + parameter_id SERIAL PRIMARY KEY, + parameter_type TEXT, + valid_from DATE DEFAULT CURRENT_DATE, + valid_to DATE, + created_by TEXT, + updated_by TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Ierarhie tehnici (4 nivele) +CREATE TABLE IF NOT EXISTS dimension ( + dimension_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + weight NUMERIC DEFAULT 1.0, + description JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS subdimension ( + subdimension_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + dimension_id INTEGER REFERENCES dimension(dimension_id), + code TEXT UNIQUE NOT NULL, + subdmiension_name TEXT NOT NULL, -- NOTA: typo intentionat (legacy) + weight NUMERIC DEFAULT 1.0, + description JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS technique ( + technique_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + subdimension_id INTEGER REFERENCES subdimension(subdimension_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + severity TEXT, + confidence TEXT, + detectability TEXT, + description JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS technique_indicator ( + indicator_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + technique_id INTEGER REFERENCES technique(technique_id), + name TEXT NOT NULL, + description TEXT, + max_intensity INTEGER DEFAULT 3, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS technique_validation_rule ( + rule_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + technique_id INTEGER REFERENCES technique(technique_id), + condition TEXT, + action TEXT, + severity TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Verdict si scoring +CREATE TABLE IF NOT EXISTS verdict_category ( + verdict_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + range_min NUMERIC, + range_max NUMERIC, + color TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS risk_mapping ( + risk_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + level TEXT UNIQUE NOT NULL, + range_min NUMERIC, + range_max NUMERIC, + color TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS severity_assessment ( + severity_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + category TEXT UNIQUE NOT NULL, + range_min NUMERIC, + range_max NUMERIC, + action TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +-- Ponderi +CREATE TABLE IF NOT EXISTS component_weight ( + weight_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + manipulation NUMERIC DEFAULT 35, + claims NUMERIC DEFAULT 25, + source NUMERIC DEFAULT 20, + ai NUMERIC DEFAULT 15, + context NUMERIC DEFAULT 5, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS weight_scenario ( + scenario_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + topic TEXT, + manipulation NUMERIC, + claims NUMERIC, + source NUMERIC, + ai NUMERIC, + context NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS multiplier ( + multiplier_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + multiplier_type TEXT NOT NULL, -- topic, temporal, reach + name TEXT, + condition TEXT, + multiplier_value NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +-- Platforme si surse +CREATE TABLE IF NOT EXISTS platform_modifier ( + modifier_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + condition TEXT, + score NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS platform ( + platform_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + modifier_id INTEGER REFERENCES platform_modifier(modifier_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + score NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS source_type ( + source_type_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + base_score NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS source_credibility ( + source_credibility_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + criteria TEXT, + weight NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS domain_age_score ( + domain_age_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + age_min INTEGER, + age_max INTEGER, + score NUMERIC, + impact TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS domain_risk_level ( + risk_level_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + range_min NUMERIC, + range_max NUMERIC, + level TEXT, + interpretation TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS domain_red_flag ( + red_flag_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + condition TEXT, + severity TEXT, + action TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS domain_attribute ( + attribute_id SERIAL PRIMARY KEY, + source_type_id INTEGER REFERENCES source_type(source_type_id), + name TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS author_classification ( + classification_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + score NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS author_credibility ( + credibility_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + name TEXT, + impact NUMERIC, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS author ( + author_id SERIAL PRIMARY KEY, + classification_id INTEGER REFERENCES author_classification(classification_id), + credibility_id INTEGER REFERENCES author_credibility(credibility_id), + name TEXT, + is_active BOOLEAN DEFAULT true +); + +-- Claims +CREATE TABLE IF NOT EXISTS claim ( + claim_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + status TEXT NOT NULL, + weight NUMERIC, + credibility_weight NUMERIC(3,2), -- migration input_profiles + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS claim_type ( + claim_type_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + unverified_weight NUMERIC(3,2), -- migration input_profiles + description TEXT, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS confidence ( + confidence_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + level TEXT NOT NULL, + color TEXT, + action_recommendation TEXT, + range_min NUMERIC, + range_max NUMERIC, + is_active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS interpretation ( + interpretation_id SERIAL PRIMARY KEY, + parameter_id INTEGER REFERENCES parameter(parameter_id), + range_min NUMERIC, + range_max NUMERIC, + label TEXT, + description TEXT, + is_active BOOLEAN DEFAULT true +); + +-- Provideri LLM +CREATE TABLE IF NOT EXISTS llm_provider ( + provider_id SERIAL PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + base_url TEXT, + auth_type TEXT, + rate_limit INTEGER, + api_version TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS llm_model ( + model_id SERIAL PRIMARY KEY, + provider_id INTEGER REFERENCES llm_provider(provider_id), + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + context_window INTEGER, + cost_per_1k_tokens NUMERIC, + capabilities TEXT[], + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS component_provider_assignment ( + assignment_id SERIAL PRIMARY KEY, + component_code TEXT NOT NULL, + provider_id INTEGER REFERENCES llm_provider(provider_id), + model_id INTEGER REFERENCES llm_model(model_id), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS provider_api_key ( + key_id SERIAL PRIMARY KEY, + provider_id INTEGER REFERENCES llm_provider(provider_id), + key_encrypted TEXT, + usage_tracking JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Config componente unificate (migration 002) +CREATE TABLE IF NOT EXISTS component_stage_assignment ( + stage_id SERIAL PRIMARY KEY, + component_code TEXT NOT NULL, + stage_code TEXT NOT NULL, + fallback_order INTEGER DEFAULT 0, + provider_id INTEGER REFERENCES llm_provider(provider_id), + model_id INTEGER REFERENCES llm_model(model_id), + temperature NUMERIC DEFAULT 0.1, + max_tokens INTEGER DEFAULT 4096, + timeout_ms INTEGER DEFAULT 60000, + is_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS component_prompt ( + prompt_id SERIAL PRIMARY KEY, + component_code TEXT NOT NULL, + stage_code TEXT NOT NULL, + system_prompt TEXT, + user_template TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(component_code, stage_code) +); + +CREATE TABLE IF NOT EXISTS component_config ( + config_id SERIAL PRIMARY KEY, + component_code TEXT NOT NULL, + config_key TEXT NOT NULL, + config_value JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(component_code, config_key) +); + +-- Profiluri input type (adaugat 2026-03-21) +CREATE TABLE IF NOT EXISTS input_type_profile ( + profile_id SERIAL PRIMARY KEY, + input_type TEXT UNIQUE NOT NULL, + component_weights JSONB, + inconclusive_rules JSONB, + disclosure_multipliers JSONB, + confidence_config JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS profile_override_config ( + override_id SERIAL PRIMARY KEY, + profile_id INTEGER REFERENCES input_type_profile(profile_id), + override_type TEXT NOT NULL, + is_enabled BOOLEAN DEFAULT true, + config JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Extension API keys +CREATE TABLE IF NOT EXISTS extension_api_key ( + key_id SERIAL PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + user_email TEXT, + name TEXT, + usage_count INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2.5 Creare tabele bos_sysadmin (5 tabele) + +```sql +SET search_path TO bos_sysadmin; + +CREATE TABLE IF NOT EXISTS subscription_plan ( + plan_id SERIAL PRIMARY KEY, + plan_name TEXT NOT NULL, + plan_type INTEGER UNIQUE NOT NULL, + price NUMERIC DEFAULT 0, + included_credits INTEGER DEFAULT 100, + storage_limit_gb NUMERIC DEFAULT 1, + media_limit_count INTEGER DEFAULT 10, + cost_per_text NUMERIC DEFAULT 1, + cost_per_url NUMERIC DEFAULT 1, + cost_per_image NUMERIC DEFAULT 2, + cost_per_audio NUMERIC DEFAULT 3, + cost_per_video NUMERIC DEFAULT 5, + description TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS internet_user ( + internet_user_id SERIAL PRIMARY KEY, + person_id INTEGER, + credits_remained INTEGER DEFAULT 100, + credits_spent INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_credential ( + credential_id SERIAL PRIMARY KEY, + internet_user_id INTEGER REFERENCES internet_user(internet_user_id), + email TEXT UNIQUE, + keycloak_id TEXT UNIQUE, + enrollment_type TEXT DEFAULT 'keycloak', + subscription_status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS subscription ( + subscription_id SERIAL PRIMARY KEY, + internet_user_id INTEGER REFERENCES internet_user(internet_user_id), + subscription_plan_id INTEGER REFERENCES subscription_plan(plan_id), + status TEXT DEFAULT 'active', + activation_date TIMESTAMP DEFAULT NOW(), + expiry_date TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ai_credit_usage ( + usage_id SERIAL PRIMARY KEY, + session_id TEXT, + user_id TEXT, + credits_used NUMERIC DEFAULT 1, + input_type TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2.6 Creare tabele bos_subscriber (4 tabele) + +```sql +SET search_path TO bos_subscriber; + +CREATE TABLE IF NOT EXISTS person ( + person_id SERIAL PRIMARY KEY, + person_type TEXT DEFAULT 'F', + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS address ( + address_id SERIAL PRIMARY KEY, + person_id INTEGER REFERENCES person(person_id), + address_type TEXT DEFAULT 'residential', + street TEXT, + city TEXT, + postal_code TEXT, + country TEXT DEFAULT 'RO', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS persoana_fizica ( + pf_id SERIAL PRIMARY KEY, + person_id INTEGER REFERENCES person(person_id), + first_name TEXT, + last_name TEXT, + cnp TEXT, + date_of_birth DATE, + address_id INTEGER REFERENCES address(address_id), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS contact ( + contact_id SERIAL PRIMARY KEY, + person_id INTEGER REFERENCES person(person_id), + contact_type_id INTEGER DEFAULT 1, + contact_info TEXT, + is_primary BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2.7 Seed data obligatoriu + +```sql +-- Planuri abonament (obligatoriu pentru auto-inregistrare utilizatori) +SET search_path TO bos_sysadmin; + +INSERT INTO subscription_plan (plan_name, plan_type, price, included_credits, storage_limit_gb) +VALUES + ('Free', 1, 0, 100, 1), + ('Starter', 2, 9.99, 500, 5), + ('Basic', 3, 19.99, 1000, 10), + ('Pro', 4, 49.99, 5000, 50), + ('Business', 5, 99.99, 20000, 100), + ('Enterprise', 6, 0, 999999, 1000); +``` + +**NOTA CRITICA**: Datele din `bos_parammgmt` (dimensiuni, tehnici, indicatori, verdicte, ponderi, platforme, claim statuses, etc.) trebuie populate prin admin dashboard sau import SQL. Fara aceste date, analizele nu functioneaza! + +Fisierul cu 166 tehnici: `didiFramework/sql/populate_technique_descriptions.sql` + +--- + +## ETAPA 3: Data Layer + +```bash +cd /home/admin365/didi_mono/backend/services/data-layer + +# === 3.1 Pornire Redis + RabbitMQ + MinIO + pgAdmin + Redis Commander === +docker compose up -d staging-dataLayer-rabbitmq staging-dataLayer-minio staging-dataLayer-pgadmin staging-dataLayer-redis-commander + +# NOTA: Redis (didi-cache) este in production/docker-compose.yml, NU aici! +cd /home/admin365/didi_mono/backend/production +docker compose up -d didi-cache + +# === 3.2 Verificare health === +# Redis +docker exec didi-cache redis-cli -a redis123 ping +# Raspuns asteptat: PONG + +# RabbitMQ +curl -s -u admin:rabbitmq123 http://localhost:15672/api/overview | head -c 100 +# Raspuns: JSON cu overview + +# MinIO +curl -s http://localhost:9000/minio/health/live +# Raspuns: HTTP 200 + +# === 3.3 Initializare bucket-uri MinIO === +# Instaleaza mc (MinIO Client) daca nu exista +# wget https://dl.min.io/client/mc/release/linux-amd64/mc && chmod +x mc && mv mc /usr/local/bin/ + +mc alias set didi http://localhost:9000 minioadmin minio123 + +# Ruleaza scriptul de init +cd /home/admin365/didi_mono/backend/services/data-layer/didiStorage +bash init-buckets.sh +# SAU manual: +mc mb didi/uploads didi/text-files didi/image-files didi/audio-files didi/video-files didi/document-files didi/pipeline-artifacts didi/backups --ignore-existing +mc version enable didi/pipeline-artifacts +mc version enable didi/backups + +# === 3.4 Initializare cozi RabbitMQ === +cd /home/admin365/didi_mono/backend/services/data-layer/didiQueue +bash init-queues.sh +# NOTA: Cozile cu prioritati (24 cozi = 4 componente x 6 planuri) +# sunt create DINAMIC de workerii agent-v3 la startup. +# init-queues.sh creeaza doar topologia legacy. +``` + +### Verificare completa Data Layer + +```bash +echo "=== Redis ===" && docker exec didi-cache redis-cli -a redis123 ping +echo "=== RabbitMQ ===" && curl -s -u admin:rabbitmq123 http://localhost:15672/api/overview | python3 -c "import sys,json;print(json.load(sys.stdin)['rabbitmq_version'])" +echo "=== MinIO ===" && mc ls didi/ 2>/dev/null | wc -l && echo "buckets" +``` + +--- + +## ETAPA 4: Keycloak + +```bash +cd /home/admin365/didi_mono/backend/production + +# === 4.1 Pornire Keycloak === +docker compose up -d keycloak + +# Asteptare startup (poate dura 30-60s) +echo "Asteptare Keycloak..." +until curl -sf http://localhost:8080/health/ready 2>/dev/null; do sleep 5; done +echo "Keycloak ready!" + +# === 4.2 Import realm (daca e prima pornire) === +# Keycloak importa automat din /opt/keycloak/data/import/ (montat in docker-compose) +# Fisier: didi-clients-realm.json +# Contine: realm didi-clients, 4 clienti OAuth2, 7 roluri, 4 grupuri, 5 utilizatori + +# === 4.3 Verificare === +curl -s http://localhost:8080/realms/didi-clients | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'Realm: {d[\"realm\"]}')" +# Raspuns: Realm: didi-clients +``` + +### Utilizatori default Keycloak + +| Email | Parola | Grup | Rol | +|-------|--------|------|-----| +| admin@didi.local | admin123 | administrators | admin | +| demo@didi.local | Demo123! | free-users | viewer | +| free@didi.local | password123 | free-users | free_tier | +| paid@didi.local | password123 | paid-users | paid_tier | +| enterprise@didi.local | password123 | enterprise-users | enterprise_tier | + +--- + +## ETAPA 5: Kong + +```bash +cd /home/admin365/didi_mono/backend/production + +# === 5.1 Build imagine Kong (daca nu exista) === +cd /home/admin365/didi_mono/backend/services/gateway-auth-layer/didiKong +docker build -t didi-kong:latest . + +# === 5.2 Pornire Kong === +cd /home/admin365/didi_mono/backend/production +docker compose up -d kong + +# Asteptare +echo "Asteptare Kong..." +until docker exec $(docker ps -qf name=kong) kong health 2>/dev/null; do sleep 5; done +echo "Kong ready!" + +# === 5.3 Verificare rute === +# In productie (DB mode), Kong citeste din PostgreSQL +# NOTA: Kong staging foloseste declarative mode (kong.yml) +``` + +--- + +## ETAPA 6: didiFramework + +```bash +# === 6.1 Build imagine === +cd /home/admin365/didi_mono/backend/services/orchestration-layer/didiFramework +docker build -t didi-framework:latest . + +# === 6.2 Pornire === +docker compose up -d + +# === 6.3 Verificare === +until curl -sf http://localhost:3005/health 2>/dev/null; do sleep 3; done +echo "didiFramework ready!" + +# Verificare conectivitate DB +curl -s http://localhost:3005/health/all | python3 -c "import sys,json;print(json.dumps(json.load(sys.stdin),indent=2))" +``` + +### Variabile de mediu didiFramework (din docker-compose.yml) + +``` +NODE_ENV=production +PORT=3005 +HOST=0.0.0.0 +DB_HOST=10.11.50.167 +DB_PORT=5000 +DB_NAME=DIDI +DB_USER=bos_interface +DB_PASSWORD=interface +DB_SCHEMA=bos_parammgmt +REDIS_HOST=didi-cache +REDIS_PORT=6379 +REDIS_PASSWORD=redis123 +REDIS_DB=0 +MINIO_ENDPOINT=staging-dataLayer-minio +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minio123 +CORS_ORIGIN=* +``` + +--- + +## ETAPA 7: Sync Redis (CRITIC!) + +Fara aceasta etapa, agent-v3 nu stie ce tehnici sa detecteze, ce ponderi sa aplice, ce verdicte exista. + +```bash +# === 7.1 Populare date framework din admin dashboard sau direct SQL === +# PREREQUISIT: tabelele din bos_parammgmt trebuie sa aiba date! +# Minimum necesar: +# - dimensions (cel putin 1) +# - subdimensions (cel putin 1) +# - techniques (cel putin 1) +# - verdict_category (RELIABLE, MOSTLY_RELIABLE, MIXED, UNRELIABLE, DISINFORMATION) +# - risk_mapping (VERY_LOW, LOW, MODERATE, HIGH, VERY_HIGH, CRITICAL) +# - severity_assessment (LOW, MEDIUM, HIGH, CRITICAL) +# - component_weight (cel putin 1 set de ponderi) +# - claim statusuri (VT, LT, UV, LF, VF, OP, NV) +# - claim types (EF, VF, RE, SC, QA, CC, PC, OF, VC) + +# === 7.2 Trigger sync din PG in Redis === +curl -X POST http://localhost:3005/api/sync-redis +# Raspuns: { "success": true, "synced": ["techniques", "claims", "verdicts", "weights", "sources", "providers"] } + +# === 7.3 Verificare chei Redis === +docker exec didi-cache redis-cli -a redis123 KEYS "didi:framework:*" +# Trebuie sa vezi: +# didi:framework:manifest +# didi:framework:techniques +# didi:framework:claims +# didi:framework:verdicts +# didi:framework:weights +# didi:framework:sources +# didi:framework:providers +# didi:framework:dimensions_compact + +docker exec didi-cache redis-cli -a redis123 KEYS "didi:config:*" +# Trebuie sa vezi: +# didi:config:pipeline:v1:* +# didi:config:techniques:v3:* +# didi:config:ai-tampered:v1:* +# didi:config:claims:v1:* +# didi:config:source-assessment:v1:* +# didi:config:verdict:v1:* +# didi:config:vision:v1:* +``` + +--- + +## ETAPA 8: agent-v3 + +```bash +# === 8.1 Creare fisier .env cu API keys === +cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3 + +cat > .env << 'EOF' +M17_WHISPER_TOKEN= +OPENROUTER_API_KEY= +OPENAI_API_KEY= +GROQ_API_KEY= +ANTHROPIC_API_KEY= +GOOGLE_API_KEY= +EOF + +# === 8.2 Build imagine === +docker compose build agent-v3 + +# === 8.3 Pornire TOATE serviciile (1 API + 6 worker types = 14 containere) === +docker compose up -d + +# === 8.4 Verificare === +until curl -sf http://localhost:24803/api/v3/health 2>/dev/null; do sleep 3; done +echo "agent-v3 ready!" + +# Verificare workeri +docker compose ps +# Trebuie sa vezi: +# agent-v3 (1 replica) +# worker-media-preprocess (2 replici) +# worker-techniques (2 replici) +# worker-ai-tampered (2 replici) +# worker-claims (3 replici) +# worker-domain (2 replici) +# verdict-aggregator (2 replici) +``` + +### Variabile de mediu agent-v3 (partajate de toti workerii) + +``` +NODE_ENV=production +REDIS_HOST=didi-cache +REDIS_PORT=6379 +REDIS_PASSWORD=redis123 +RABBITMQ_HOST=staging-dataLayer-rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USER=admin +RABBITMQ_PASS=rabbitmq123 +RABBITMQ_VHOST=/ +PG_HOST=10.11.50.167 +PG_PORT=5000 +PG_DATABASE=DIDI +PG_USER=bos_interface +PG_PASSWORD=interface +MINIO_ENDPOINT=staging-dataLayer-minio:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minio123 +FRAMEWORK_API_URL=http://didi-framework:3005 +SYNC_API_URL=http://didi-framework:3005/api/sync-analysis +PUBLIC_API_BASE_URL=https://didi365.eu +INTERNAL_MEDIA_URL=http://10.11.10.12:24803 +M17_WHISPER_URL=http://10.11.10.17:54300/v1/audio/transcriptions +``` + +--- + +## ETAPA 9: Admin Dashboard + +```bash +# === 9.1 Build imagine === +cd /home/admin365/didi_mono/backend/admin-dashboard +docker build -t didi-admin:latest . + +# === 9.2 Pornire (definit in data-layer/docker-compose.yml) === +cd /home/admin365/didi_mono/backend/services/data-layer +docker compose up -d didi-admin + +# === 9.3 Verificare === +curl -sk https://localhost:3000/admin/ | head -c 200 +# Raspuns: HTML React app +``` + +--- + +## ETAPA 10: Verificare finala + +```bash +#!/bin/bash +echo "==========================================" +echo " DIDI Platform - Verificare Completa" +echo "==========================================" + +echo "" +echo "--- INFRASTRUCTURE ---" +echo -n "Docker Network: " && docker network inspect didi-network > /dev/null 2>&1 && echo "OK" || echo "FAIL" +echo -n "Redis: " && docker exec didi-cache redis-cli -a redis123 ping 2>/dev/null +echo -n "RabbitMQ: " && curl -sf -u admin:rabbitmq123 http://localhost:15672/api/overview > /dev/null && echo "OK" || echo "FAIL" +echo -n "MinIO: " && curl -sf http://localhost:9000/minio/health/live > /dev/null && echo "OK" || echo "FAIL" +echo -n "PostgreSQL: " && docker exec didi-cache redis-cli -a redis123 ping > /dev/null && echo "OK (via Redis)" || echo "N/A" + +echo "" +echo "--- AUTH ---" +echo -n "Keycloak: " && curl -sf http://localhost:8080/realms/didi-clients > /dev/null && echo "OK" || echo "FAIL" +echo -n "Kong: " && curl -sf -k https://localhost:443/ > /dev/null && echo "OK" || echo "FAIL" + +echo "" +echo "--- SERVICES ---" +echo -n "didiFramework: " && curl -sf http://localhost:3005/health > /dev/null && echo "OK" || echo "FAIL" +echo -n "agent-v3: " && curl -sf http://localhost:24803/api/v3/health > /dev/null && echo "OK" || echo "FAIL" +echo -n "Admin Dashboard: " && curl -sfk https://localhost:3000/admin/ > /dev/null && echo "OK" || echo "FAIL" + +echo "" +echo "--- REDIS CONFIG ---" +REDIS_KEYS=$(docker exec didi-cache redis-cli -a redis123 KEYS "didi:framework:*" 2>/dev/null | wc -l) +echo "Framework keys in Redis: $REDIS_KEYS (min 7 expected)" +CONFIG_KEYS=$(docker exec didi-cache redis-cli -a redis123 KEYS "didi:config:*" 2>/dev/null | wc -l) +echo "Config keys in Redis: $CONFIG_KEYS (min 15 expected)" + +echo "" +echo "--- WORKERS ---" +cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3 +docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null + +echo "" +echo "--- TEST ANALIZA ---" +echo "Trimite analiza de test:" +echo 'curl -X POST http://localhost:24803/api/v3/pipeline/analyze -H "Content-Type: application/json" -d "{\"text\": \"Test text for analysis\"}"' +``` + +--- + +## Anexa A: Toate variabilele de mediu + +### Credentiale baze de date + +| Variabila | Valoare | Folosit de | +|-----------|---------|------------| +| PG_HOST / DB_HOST | 10.11.50.167 | agent-v3, didiFramework | +| PG_PORT / DB_PORT | 5000 | agent-v3, didiFramework | +| PG_DATABASE / DB_NAME | DIDI | agent-v3, didiFramework | +| PG_USER / DB_USER | bos_interface | agent-v3, didiFramework | +| PG_PASSWORD / DB_PASSWORD | interface | agent-v3, didiFramework | +| KONG_PG_HOST | 10.11.50.167 | Kong | +| KONG_PG_PORT | 5000 | Kong | +| KONG_PG_USER | kong | Kong | +| KONG_PG_PASSWORD | kong123 | Kong | +| KONG_PG_DATABASE | kong_db | Kong | +| KC_DB_HOST | 10.11.50.167 | Keycloak | +| KC_DB_PORT | 5000 | Keycloak | +| KC_DB_NAME | keycloak_db | Keycloak | +| KC_DB_USER | keycloak | Keycloak | +| KC_DB_PASSWORD | keycloak123 | Keycloak | + +### Credentiale servicii + +| Variabila | Valoare | Folosit de | +|-----------|---------|------------| +| REDIS_HOST | didi-cache | agent-v3, didiFramework | +| REDIS_PORT | 6379 | agent-v3, didiFramework | +| REDIS_PASSWORD | redis123 | Toate | +| RABBITMQ_HOST | staging-dataLayer-rabbitmq | agent-v3 | +| RABBITMQ_PORT | 5672 | agent-v3 | +| RABBITMQ_USER | admin | agent-v3 | +| RABBITMQ_PASS | rabbitmq123 | agent-v3 | +| MINIO_ENDPOINT | staging-dataLayer-minio(:9000) | agent-v3, didiFramework | +| MINIO_ACCESS_KEY | minioadmin | agent-v3, didiFramework | +| MINIO_SECRET_KEY | minio123 | agent-v3, didiFramework | +| KEYCLOAK_ADMIN | admin | Keycloak | +| KEYCLOAK_ADMIN_PASSWORD | admin123 | Keycloak | + +### API Keys (secrete) + +| Variabila | Folosit de | Scop | +|-----------|------------|------| +| OPENROUTER_API_KEY | agent-v3 | Modele LLM principale | +| OPENAI_API_KEY | agent-v3 | Fallback LLM + Whisper | +| GROQ_API_KEY | agent-v3 | Fallback LLM + Whisper | +| M17_WHISPER_TOKEN | agent-v3 | Transcriere audio primara | +| ANTHROPIC_API_KEY | agent-v3 | Fallback LLM (optional) | +| GOOGLE_API_KEY | agent-v3 | Fallback LLM (optional) | + +### URL-uri servicii + +| Variabila | Valoare | Folosit de | +|-----------|---------|------------| +| FRAMEWORK_API_URL | http://didi-framework:3005 | agent-v3 | +| PUBLIC_API_BASE_URL | https://didi365.eu | agent-v3 | +| INTERNAL_MEDIA_URL | http://10.11.10.12:24803 | agent-v3 | +| M17_WHISPER_URL | http://10.11.10.17:54300/v1/audio/transcriptions | agent-v3 | +| KC_HOSTNAME_URL | https://didi365.eu/auth | Keycloak | + +--- + +## Anexa B: Chei Redis complete + +### Framework (permanente, scrise de didiFramework sync-redis) + +| Cheie | Tip | Ce contine | +|-------|-----|------------| +| didi:framework:manifest | STRING (JSON) | Index categorii + timestamp ultima sincronizare | +| didi:framework:techniques | STRING (JSON) | Ierarhie completa: dimensiuni -> subdimensiuni -> tehnici -> indicatori/reguli | +| didi:framework:claims | STRING (JSON) | Statusuri claim, tipuri, confidence, interpretare | +| didi:framework:verdicts | STRING (JSON) | Categorii verdict + risk mappings + severity | +| didi:framework:weights | STRING (JSON) | Ponderi componente + scenarii + multiplicatori | +| didi:framework:sources | STRING (JSON) | Evaluare surse (platforme, credibilitate, domain) | +| didi:framework:providers | STRING (JSON) | Config provideri LLM | +| didi:framework:dimensions_compact | STRING (JSON) | Lista compacta dimensiuni (pt prompts screening) | + +### Config componente (permanente, scrise de sync-redis) + +| Cheie | Ce contine | +|-------|------------| +| didi:config:pipeline:v1:component_config | Config executor pipeline + video track weights | +| didi:config:pipeline:v1:session_config | Config sesiune pipeline | +| didi:config:pipeline:v1:verdict_config | Override-uri, synergy, confidence (globale) | +| didi:config:pipeline:v1:input_profiles | 6 profiluri verdict per input type | +| didi:config:techniques:v3:available_models | Modele LLM techniques | +| didi:config:techniques:v3:stage_assignments | Assignments etape techniques (screening/deep) | +| didi:config:techniques:v3:scoring_config | Parametri scoring: count_scaler, severe_threshold | +| didi:config:techniques:v3:dimensions_compact | Dimensiuni compacte techniques | +| didi:config:ai-tampered:v1:available_models | Modele LLM AI tampered | +| didi:config:ai-tampered:v1:stage_assignments | Assignments etape AI tampered | +| didi:config:ai-tampered:v1:scoring_config | Blend weights, disclosure impact, thresholds | +| didi:config:ai-tampered:v1:vision_models | Cascade viziune (Qwen -> Gemini -> GPT-4o) | +| didi:config:claims:v1:available_models | Modele LLM claims | +| didi:config:claims:v1:stage_assignments | Assignments etape claims | +| didi:config:claims:v1:scoring_config | Status weights, unverified behavior | +| didi:config:source-assessment:v1:available_models | Modele source assessment | +| didi:config:source-assessment:v1:scoring_config | Axis weights, verdict thresholds | +| didi:config:source-assessment:v1:prompts:extraction | Prompt extractie metadata sursa | +| didi:config:source-assessment:v1:prompts:evaluation | Prompt evaluare sursa | +| didi:config:verdict:v1:available_models | Modele verdict reviewer | +| didi:config:vision:v1:prompts:extraction | Prompt viziune: extragere text din imagine | +| didi:config:vision:v1:prompts:video_frames | Prompt viziune: analiza cadre video | +| didi:config:vision:v1:prompts:ai_detection | Prompt viziune: detectie AI imagine | + +### Sesiuni pipeline (TTL 7 zile, scrise de agent-v3) + +| Pattern | Ce contine | +|---------|------------| +| didi:pipeline:{sessionId}:status | Status executie (JSON: PipelineStatus) | +| didi:pipeline:{sessionId}:{component} | Rezultat componenta | +| didi:pipeline:{sessionId}:verdict | Verdict final | +| didi:pipeline:history:entry:{sessionId} | Date intrare istoric | +| didi:pipeline:history:user:{userId} | Sorted set istoric user | + +### Media cache (TTL 1 ora, scrise de media-preprocess worker) + +| Pattern | Ce contine | +|---------|------------| +| agent:media:{sessionId}:transcript | Transcript audio (Whisper) | +| agent:media:{sessionId}:vision:misinformation | Output Vision frames manipulare | +| agent:media:{sessionId}:vision:ai_detection | Output Vision frames AI | +| agent:media:{sessionId}:merged_text | Transcript + vizual combinat | +| agent:media:{sessionId}:lock | Lock procesare media | +| agent:media:{sessionId}:ready | Semnal finalizare ("1") | + +### Rezultate intermediare (TTL 7 zile) + +| Pattern | Ce contine | +|---------|------------| +| agent:result:{sessionId}:{component}:{stage} | Rezultat etapa (screening/deep) | + +### Coada async (TTL scurt) + +| Pattern | TTL | Ce contine | +|---------|-----|------------| +| didi:queue:session:{sessionId} | 24h | Stare fan-in agregare | +| didi:queue:lock:{sessionId}:{component} | 5min | Lock worker componenta | +| didi:queue:aggregator:{sessionId} | 30s | Lock agregator verdict | + +### Extensie browser (permanente) + +| Pattern | Ce contine | +|---------|------------| +| didi:extension:key:{apiKey} | Cache cheie API extensie | + +--- + +## Anexa C: Schema PostgreSQL completa + +### Rezumat + +| Schema | Tabele | Scris de | Citit de | +|--------|--------|----------|----------| +| bos_analysis | 7 + 1 view | agent-v3 (pg-adapter.ts) | didiFramework (history.ts) | +| bos_parammgmt | ~43 | didiFramework (CRUD routes) | Sync Redis -> agent-v3 | +| bos_sysadmin | 5 | didiFramework (auth.ts, admin.ts) | agent-v3 (credite) | +| bos_subscriber | 4 | didiFramework (auth.ts) | - | + +### Conexiuni + +| Serviciu | Host:Port | DB | User/Pass | Schema | +|----------|-----------|-----|-----------|--------| +| agent-v3 | 10.11.50.167:5000 | DIDI | bos_interface/interface | bos_analysis | +| didiFramework | 10.11.50.167:5000 | DIDI | bos_interface/interface | bos_parammgmt, bos_sysadmin, bos_subscriber | +| Kong | 10.11.50.167:5000 | kong_db | kong/kong123 | public | +| Keycloak | 10.11.50.167:5000 | keycloak_db | keycloak/keycloak123 | public | +| Waitlist (local) | staging-dataLayer-postgres:5432 | misinformation_db | postgres/postgres123 | public | + +--- + +## Anexa D: Date seed necesare + +### Ce trebuie sa existe in bos_parammgmt INAINTE de prima analiza + +| Tabel | Minim | Unde se adauga | +|-------|-------|----------------| +| dimension | 2+ dimensiuni | Admin Dashboard > Framework | +| subdimension | 2+ subdimensiuni | Admin Dashboard > Framework | +| technique | 10+ tehnici | Admin Dashboard > Framework | +| technique_indicator | 1+ per tehnica | Admin Dashboard > Framework | +| verdict_category | 5: RELIABLE, MOSTLY_RELIABLE, MIXED, UNRELIABLE, DISINFORMATION | Admin Dashboard > Framework > Verdicts | +| risk_mapping | 6: VERY_LOW, LOW, MODERATE, HIGH, VERY_HIGH, CRITICAL | Admin Dashboard > Framework > Verdicts | +| severity_assessment | 4: LOW, MEDIUM, HIGH, CRITICAL | Admin Dashboard > Framework > Verdicts | +| component_weight | 1 set default | Admin Dashboard > Framework > Weights | +| claim (status) | 7: VT, LT, UV, LF, VF, OP, NV | Admin Dashboard > Framework > Claims | +| claim_type | 9: EF, VF, RE, SC, QA, CC, PC, OF, VC | Admin Dashboard > Framework > Claims | + +### Ce trebuie sa existe in didi:config:* Redis + +| Cheie | Cum se populeaza | Obligatoriu? | +|-------|------------------|-------------| +| scoring_config per componenta | Admin Dashboard > LLM Components > Parameters | DA | +| stage_assignments per componenta | Admin Dashboard > LLM Components > Stage Assignments | DA | +| prompts per componenta | Admin Dashboard > LLM Components > Prompts | DA | +| input_profiles | Admin Dashboard > LLM Components > Verdict > Input Profiles | DA | +| vision_models | Admin Dashboard > LLM Components > AI Tampered > Vision Models | Pentru imagine/video | + +**SECVENTA**: Adauga date in PG (admin dashboard) -> POST /api/sync-redis -> Verificare Redis keys + +--- + +## Anexa E: Servicii externe (GPU) + +### Masina GPU: 10.11.10.17 + +| Serviciu | Port | Scop | Folosit de | +|----------|------|------|------------| +| M17-Whisper | 54300 | Transcriere audio primara | agent-v3 transcription.ts | +| M17 Web API | 51100 | Cautare web pt verificare claims | agent-v3 claims executor | +| Qwen Vision Local | 14011 | Analiza imagine locala (llamacpp) | agent-v3 vision.ts | + +### API-uri cloud + +| Provider | Scop | Fallback chain | +|----------|------|----------------| +| OpenRouter | Modele LLM principale (Gemini, GPT-4o, Claude) | Primary | +| OpenAI | Whisper + LLM fallback | Fallback 1 | +| Groq | Whisper + LLM fallback (rapid) | Fallback 2 | +| Anthropic | LLM fallback (optional) | Fallback 3 | + +### Domain Check API + +| Serviciu | Adresa | Scop | +|----------|--------|------| +| Domain Check | :11000 | WHOIS, DNS, SSL, blacklist | + +--- + +## Anexa F: Porturi utilizate + +### Porturi externe (accesibile din afara) + +| Port | Serviciu | Protocol | +|------|----------|----------| +| 443 | Kong Gateway (SSL) | HTTPS | +| 3000 | Admin Dashboard (SSL) | HTTPS | +| 9000 | MinIO API | HTTP | + +### Porturi interne (doar localhost sau Docker network) + +| Port | Serviciu | Acces | +|------|----------|-------| +| 24803 | agent-v3 | 127.0.0.1 | +| 3005 | didiFramework | Docker network | +| 6379 | Redis | Docker network | +| 5672 | RabbitMQ AMQP | 127.0.0.1 | +| 15672 | RabbitMQ Management | 127.0.0.1 | +| 9001 | MinIO Console | 127.0.0.1 | +| 8080 | Keycloak | 127.0.0.1 | +| 5050 | pgAdmin | 127.0.0.1 | + +### Porturi GPU (retea interna) + +| Port | Serviciu | Host | +|------|----------|------| +| 54300 | M17-Whisper | 10.11.10.17 | +| 51100 | M17 Web Search | 10.11.10.17 | +| 14011 | Qwen Vision | 10.11.10.17 | + +--- + +## Script complet one-shot (TLDR) + +```bash +#!/bin/bash +set -e +BACKEND=/home/admin365/didi_mono/backend + +echo "=== ETAPA 1: Docker network ===" +docker network create didi-network 2>/dev/null || true + +echo "=== ETAPA 2: PostgreSQL (presupunem ca exista + schemas create) ===" +echo "MANUAL: Ruleaza SQL din sectiunile 2.2 - 2.7 pe clusterul 10.11.50.167:5000" + +echo "=== ETAPA 3: Data Layer ===" +docker volume create didi-staging-postgres-data 2>/dev/null || true +docker volume create didi-staging-minio-data 2>/dev/null || true +docker volume create didi-staging-pgadmin-data 2>/dev/null || true + +cd $BACKEND/production && docker compose up -d didi-cache +cd $BACKEND/services/data-layer && docker compose up -d staging-dataLayer-rabbitmq staging-dataLayer-minio staging-dataLayer-pgadmin staging-dataLayer-redis-commander + +echo "Asteptare servicii data layer..." +sleep 15 + +echo "=== ETAPA 4: Keycloak ===" +cd $BACKEND/production && docker compose up -d keycloak +echo "Asteptare Keycloak (60s)..." && sleep 60 + +echo "=== ETAPA 5: Kong ===" +cd $BACKEND/services/gateway-auth-layer/didiKong && docker build -t didi-kong:latest . +cd $BACKEND/production && docker compose up -d kong +sleep 15 + +echo "=== ETAPA 6: didiFramework ===" +cd $BACKEND/services/orchestration-layer/didiFramework && docker build -t didi-framework:latest . && docker compose up -d +sleep 10 + +echo "=== ETAPA 7: Sync Redis ===" +echo "MANUAL: Populeaza bos_parammgmt cu date, apoi:" +echo "curl -X POST http://localhost:3005/api/sync-redis" + +echo "=== ETAPA 8: agent-v3 ===" +cd $BACKEND/services/orchestration-layer/agent-v3 && docker compose up -d --build +sleep 15 + +echo "=== ETAPA 9: Admin Dashboard ===" +cd $BACKEND/admin-dashboard && docker build -t didi-admin:latest . +cd $BACKEND/services/data-layer && docker compose up -d didi-admin + +echo "=== DONE ===" +echo "Platforma DIDI ar trebui sa fie operationala." +echo "Verifica: https://localhost:3000/admin/" +``` diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..bce2510 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,96 @@ +# DIDI Backend + +Misinformation detection backend — TypeScript/Node.js microservices. + +## Services + +| Service | Description | Port | Container | +|---------|-------------|------|-----------| +| **Agent V3** | Analysis engine — 4 detection components + verdict | 24803 | didi-agent-v3 | +| **didiFramework** | Parameters CRUD API, user management, Redis sync | 3005 | didi-framework | +| **Admin Dashboard** | React admin UI (services, config, history, users) | 3000 | didi-admin | +| **Kong** | API Gateway (SSL, routing, rate limiting) | 443 | kong | +| **Keycloak** | Authentication (OAuth2/OIDC, JWT) | 28000 | keycloak | +| **Redis** | Cache (framework config, session state) | 6379 | didi-cache | +| **RabbitMQ** | Queue (async analysis, 24 component queues) | 5672 | rabbitmq | +| **MinIO** | Object storage (media files, per-user buckets) | 9000 | minio | +| **PostgreSQL** | External cluster (Patroni/HAProxy) | 5000 | — | + +## Quick Start + +```bash +# 1. Start data layer +cd services/data-layer && docker compose up -d + +# 2. Start agent-v3 + workers +cd services/orchestration-layer/agent-v3 && docker compose up -d --build + +# 3. Start framework +cd services/orchestration-layer/didiFramework && docker compose up -d --build + +# 4. Sync parameters +docker exec didi-framework sh -c 'wget -qO- --post-data="" http://127.0.0.1:3005/api/sync-redis' + +# 5. Test +curl http://localhost:24803/api/v3/health +``` + +## Structure + +``` +backend/ +├── admin-dashboard/ # React 19, MUI 7 — admin UI +├── production/ # Production docker-compose + .env +└── services/ + ├── data-layer/ # Redis, RabbitMQ, MinIO, local PG + ├── gateway-auth-layer/ # Kong + Keycloak + └── orchestration-layer/ + ├── agent-v3/ # Analysis engine (TypeScript, Express 5) + └── didiFramework/ # Parameters API (TypeScript, Express 4) +``` + +Each service has its own `INDEX.md` with detailed documentation. + +## Analysis Pipeline + +``` +Input (text/image/audio/video/URL) + │ + ├─ Worker: media-preprocess (extract text from media) + │ + ├─ Worker: techniques → manipulation_score (0-100) + ├─ Worker: ai-tampered → ai_probability (0-100) + ├─ Worker: claims → credibility_score (0-100) + ├─ Worker: domain → trust_score (0-100) + │ + └─ Aggregator: verdict → risk_score (0-100) + explanation (RO+EN) +``` + +All analysis endpoints are **async**: dispatch to RabbitMQ → return 202 → poll for results. + +## Rebuild Commands + +```bash +# Agent V3 +cd services/orchestration-layer/agent-v3 +docker compose up -d --build agent-v3 + +# Framework +cd services/orchestration-layer/didiFramework +docker compose up -d --build didi-framework + +# Admin Dashboard +cd admin-dashboard +docker build -t didi-admin:latest . +cd ../services/data-layer && docker compose up -d --force-recreate didi-admin +``` + +## Credentials (Staging) + +| Service | User | Password | +|---------|------|----------| +| PostgreSQL (cluster) | bos_interface | interface | +| Redis | — | redis123 | +| RabbitMQ | admin | rabbitmq123 | +| MinIO | minioadmin | minio123 | +| Keycloak | admin@didi.local | admin123 | diff --git a/backend/REFACTOR_CONTEXT.md b/backend/REFACTOR_CONTEXT.md new file mode 100644 index 0000000..e15139f --- /dev/null +++ b/backend/REFACTOR_CONTEXT.md @@ -0,0 +1,625 @@ +# DIDI Backend — Refactor Context (sesiune 2026-05-07) + +**Citire obligatorie pentru orice sesiune ulterioară.** Acest document conține +convențiile, helper-ele și starea după 54 task-uri de refactor + 13 deploy-uri. + +--- + +## 0. TL;DR — ce s-a întâmplat + +Începută cu un audit ostil al `agent-v3` + `didiFramework`. Am descoperit + rezolvat: +- **8 bug-uri critice** (auth bypass admin, parole hardcoded, race conditions, + silent failures, fragile error matching) +- **Migrare integrală la pino structured logging** (651 console.* → 0) +- **Reducere `as any` 92%** (159 → 13) +- **Curățarea error.message leaks** (126 → 0) +- **Centralizare Redis pools, Keycloak token, PG pools, Express helpers** +- **Split la 11 fișiere monolitice** (13049 LOC → 75 fișiere modulare): + `routes.ts`, `admin.ts`, `verdict-calculator.ts`, `auth.ts`, `pipeline-routes.ts`, + `claims/executor.ts`, `ai-tampered/executor.ts`, `techniques/executor.ts`, + `source-assessment/executor.ts`, `providers.ts` (didiFramework), + `sync-redis.ts` (didiFramework) + +Sistemul live a rulat fără downtime detectabil pentru utilizatori. + +--- + +## 1. Convenții obligatorii (RESPECTĂ-LE) + +### 1.1 Logging — folosește `log`, NU `console` + +```ts +// agent-v3 +import { log } from '../shared/logger'; + +// didiFramework +import { log } from '../config/logger'; +``` + +Pino este wrappuit cu un adapter variadic — `log.info('msg', x, y)` și +`log.info({ session_id }, 'msg')` ambele merg. + +În route handlers, folosește `req.log` care are deja `request_id` atașat: +```ts +req.log.info({ user_id }, 'analyze starting'); +``` + +**NU readuce console.log/error/warn.** Comentariul cu "TODO" e mai bun decât un console. + +### 1.2 Env vars — `requireEnv()` pentru tot ce e secret/required + +```ts +// agent-v3 +import { requireEnv, optionalEnv } from '../shared/helpers/env'; + +// didiFramework +import { requireEnv, optionalEnv } from '../config/env'; + +const PG_PASSWORD = requireEnv('PG_PASSWORD'); // throws on missing +const PORT = optionalEnv('PORT', '3005'); // safe default +``` + +**INTERZIS** `process.env.X || 'fallback'` pentru: +- Parole, tokens, API keys +- Hostname-uri interne (10.11.x.y) +- DB names, users + +OK pentru: log levels, ports, mobile schemes, public URLs, schema names. + +### 1.3 Error responses — `internalError()`, NU `error.message` + +```ts +// agent-v3 +import { internalError } from '../shared/helpers/error-response'; + +// didiFramework +import { internalError } from '../config/error-response'; + +try { + // ... +} catch (error) { + internalError(res, error, 'optional_context_tag'); +} +``` + +Asta: +- Loghează errorul + stack intern cu `correlation_id` (UUID) +- Returnează către client `{success: false, error: 'Internal server error', correlation_id}` +- NU leakuiește detalii interne + +**INTERZIS** `res.status(500).json({error: error.message})` sau orice variantă +care expune mesajul brut. + +### 1.4 Redis — `lazyRedis()` per modul + `scanKeys()`, NU `KEYS` + +```ts +import { lazyRedis } from '../shared/redis/connection'; +import { scanKeys } from '../shared/redis/scan'; + +const getRedis = lazyRedis('module-name'); // label vizibil în connection metadata + +// SCAN, nu KEYS — KEYS blochează server-ul O(N) +const keys = await scanKeys(getRedis(), 'pattern:*'); +``` + +### 1.5 Locks distribuite — `acquireLock`/`releaseLock` (fenced) + +```ts +import { acquireLock, releaseLock } from '../shared/redis/lock'; + +const lock = await acquireLock(redis, lockKey, 30); // 30s TTL +if (!lock) { + // alt proces are lock-ul + return; +} +try { + // critical section +} finally { + await releaseLock(redis, lock); // compare-and-delete via Lua +} +``` + +**INTERZIS** `redis.del(lockKey)` direct — poate șterge lock-ul altcuiva dacă TTL +a expirat. + +### 1.6 Errori logice — `LogicalInputError`, NU string-matching + +```ts +import { LogicalInputError, isLogicalInputError } from '../shared/helpers/errors'; + +if (!text) throw new LogicalInputError('Component requires text input'); + +// În worker: +if (isLogicalInputError(err)) { /* nu retry */ } +``` + +**INTERZIS** `err.message.includes('requires')` sau alte verificări fragile. + +### 1.7 Validare config Redis — zod, nu JSON.parse direct + +Pentru endpoint-uri PUT care scriu în Redis: +```ts +import { TierStageAssignmentsSchema } from '../shared/helpers/config-schemas'; + +const parsed = TierStageAssignmentsSchema.safeParse(req.body?.stage_assignments); +if (!parsed.success) { + return res.status(400).json({success: false, error: 'Invalid payload', details: parsed.error.issues}); +} +await r.set(key, JSON.stringify(parsed.data)); +``` + +### 1.8 PG pools — folosește shared, NU `new Pool()` în route file + +```ts +// didiFramework +import pool from '../config/database'; +// SAU pentru query helpers: +import { query, queryOne, transaction } from '../config/database'; + +// agent-v3 +import { getPgPool } from '../shared/persistence/pg-pool'; +``` + +**INTERZIS** `new Pool({...})` în orice fișier de rută. Excepție: `waitlist.ts` +folosește un DB diferit (staging), păstrat lazy-init. + +### 1.9 Keycloak admin — folosește centralizat + +```ts +// didiFramework +import { getKeycloakAdminToken } from '../config/keycloak-admin'; +const token = await getKeycloakAdminToken(); // throws on failure +``` + +**INTERZIS** rescrierea logicii de fetch /token din nou. + +### 1.10 Video weighting — folosește helper + +```ts +import { combineVideoProbability, DEFAULT_VIDEO_TRACK_WEIGHTS } from '../shared/media/video-weighting'; + +const final = combineVideoProbability(textProb, visualProb); // 0.4/0.6 default +``` + +**INTERZIS** `textProb * 0.4 + visualProb * 0.6` inline (a fost deja inversat +într-un loc înainte de fix). + +### 1.11 Type safety — `as any` necesită justificare + +Reduceri reușite: 159 → 13. Cele 13 rămase sunt în: +- `routes.ts` (4) — în techniques.ts după split +- `pipeline-routes.ts` (4) — încă nedefript +- `admin.ts` (3) — distribute în users.ts după split +- `verdict-calculator.ts` (2) + +Când adaugi cod nou: +- Pentru fetch responses externe → declară `interface Response { ... }` și cast +- Pentru data din Redis → zod schema sau tip bine definit +- Pentru Express req extension → declară în `declare global { namespace Express { interface Request { ... } } }` (NU cast `as any`) + +### 1.12 `.dockerignore` — OBLIGATORIU pentru ambele services + +``` +node_modules +dist +.git +*.log +.env.local +``` + +**Bug istoric**: stale `dist/admin.js` din host se copia în container și +suprascria buildul nou. Dacă `dist/` nu e ignorat → build inconsistent. + +--- + +## 2. Structură nouă post-split + +### agent-v3 + +``` +src/api/ +├── routes.ts # 37 LOC barrel (techniques + media + domain + /health) +├── techniques.ts # 781 LOC, 11 endpoints +├── media.ts # 171 LOC, 4 endpoints +├── domain.ts # 333 LOC, 2 endpoints +├── pipeline-routes.ts # 6 LOC re-export → ./pipeline +├── pipeline/ # split 5 → folder (was 1611 LOC) +│ ├── index.ts # 36 LOC barrel +│ ├── _init.ts # 36 LOC (lazy redis/persist/media + FRAMEWORK_API_URL) +│ ├── _helpers/ +│ │ ├── vision.ts # 54 LOC (extractImageVision) +│ │ └── url-helpers.ts # 151 LOC (detectUrlType + fetchArticleText + isBoilerplate + buildPlatformInfoField) +│ ├── analyze.ts # 535 LOC (POST /analyze + /analyze-url + /analyze-media) +│ ├── status.ts # 157 LOC (GET /verdict-config + /:sessionId/{status,component/:name,result}) +│ ├── history.ts # 181 LOC (6 history endpoints) +│ ├── extension.ts # 234 LOC (validateApiKey + 4 extension endpoints) +│ └── async.ts # 296 LOC (3 async/queue endpoints) +├── ai-tampered-routes.ts # 764 LOC +├── claims-routes.ts # 671 LOC +├── source-assessment-routes.ts # 470 LOC +├── moderation-routes.ts # 420 LOC +├── _init.ts # 36 LOC (shared lazy: Redis, MediaService, multer, PersistService) +└── _helpers/ + └── standalone-session.ts # 86 LOC (buildStandaloneSession + persistStandaloneResult) + +src/components/pipeline/verdict-calculator/ # split 4 → folder +├── index.ts # 864 LOC (VerdictCalculator class) +├── types.ts # 161 LOC (interfaces) +├── defaults.ts # 72 LOC (DEFAULT_*) +└── mappers.ts # 32 LOC (pure functions) + +src/components/claims/ # split 6 → folder (was 1228 LOC) +├── executor.ts # 319 LOC (ClaimsExecutor class shell + execute orchestrator + loadConfigs) +├── types.ts # 159 LOC (interfaces, zod schemas, ClaimsCtx) +├── web-search.ts # 76 LOC (searchWebM17 + M17* types) +├── llm-utils.ts # 117 LOC (callWithFallbacks, parseJsonResponse, validate*) +└── stages/ + ├── extraction.ts # 82 LOC (Stage 1: extractClaims + loadConfig helper) + ├── verification.ts # 443 LOC (Stage 2: verifyClaims + 5 helpers + calculateStatusFromSources) + └── scoring.ts # 179 LOC (Stage 3: buildFinalResult + buildEmpty + buildSkipped) + +src/components/ai-tampered/ # split 7 → folder (was 1043 LOC) +├── executor.ts # 339 LOC (AITamperedExecutor class + execute() + quickAnalyze() + loadFromRedis) +├── types.ts # 201 LOC (interfaces, 3 zod schemas, AiTamperedCtx) +├── patterns.ts # 57 LOC (AI_TOOL_PATTERNS + DISCLOSURE_INDICATORS + PARTIAL_DISCLOSURE_PATTERNS) +├── disclosure.ts # 145 LOC (checkDisclosure + findToolMention + getDisclosureProminence + calculateDisclosureRating) +├── llm-utils.ts # 122 LOC (callWithFallbacks, parseJsonResponse, validate*) +└── stages/ + ├── _helpers.ts # 17 LOC (loadFromRedis pentru stages) + ├── screening.ts # 53 LOC (Stage 1: executeScreening) + ├── deep-analysis.ts # 65 LOC (Stage 2: executeDeepAnalysis) + └── scoring.ts # 191 LOC (Stage 3: buildFinalResult + buildEmptyResult + determineVerdict + determineConfidenceLevel) + +src/components/techniques/ # split 8 → folder (was 962 LOC) +├── executor.ts # 237 LOC (TechniquesV3Executor class + execute() + loadFromRedis) +├── types.ts # 243 LOC (interfaces, 3 zod schemas, TechniquesCtx, resolveStageAssignment) +├── llm-utils.ts # 115 LOC (callWithFallbacks, parseJsonResponse, validate*) +└── stages/ + ├── _helpers.ts # 15 LOC (loadFromRedis pentru stages) + ├── screening.ts # 50 LOC (Stage 1: executeScreening) + ├── deep-analysis.ts # 93 LOC (Stage 2: executeDeepAnalysis + buildTechniquesList) + └── scoring.ts # 317 LOC (Stage 3: buildFinalResult + buildEmptyResult + calculateManipulationScore + buildCouplingContext + findTechniqueById) + +src/components/source-assessment/ # split 9 → folder (was 932 LOC) +├── executor.ts # 137 LOC (SourceAssessmentExecutor class + execute() orchestrator 4-step) +├── types.ts # 95 LOC (interfaces, ScoringConfig, SourceAssessmentCtx) +├── defaults.ts # 191 LOC (DEFAULT_*_PROMPT, DEFAULT_MODELS, DEFAULT_AXIS_WEIGHTS, defaultFramework) +├── external.ts # 167 LOC (searchM17 + checkDomain + extractRedFlags + extractDomainFromUrl + DOMAIN_CHECK_URL via optionalEnv) +├── config-loader.ts # 143 LOC (loadFramework + loadModels + loadScoringConfig + loadPrompt — toate cu fallback default) +└── stages/ + ├── extraction.ts # 61 LOC (Step 1: extractSourceMetadata) + ├── mapping.ts # 127 LOC (Step 3: mapToFramework — LLM clasifică evidence) + └── scoring.ts # 180 LOC (Step 4: buildResult + calculateAuthorScore + neutralFallback) + +src/shared/ +├── logger.ts # pino + variadic adapter +├── request-logger.ts # Express middleware (request_id propagation) +├── helpers/ +│ ├── env.ts # requireEnv, optionalEnv +│ ├── errors.ts # LogicalInputError class +│ ├── error-response.ts # internalError(res, err, ctx?) +│ └── config-schemas.ts # TierStageAssignmentsSchema (zod) +├── redis/ +│ ├── connection.ts # lazyRedis(label) + createRedisConnection +│ ├── scan.ts # scanKeys cursor-based +│ └── lock.ts # acquireLock/releaseLock (fenced) +└── media/ + └── video-weighting.ts # combineVideoProbability + DEFAULT_VIDEO_TRACK_WEIGHTS +``` + +### didiFramework + +``` +src/routes/ +├── auth/ # split 5 → folder +│ ├── index.ts # 23 LOC barrel +│ ├── _helpers.ts # 154 LOC (JWT, Keycloak, getCreditCost, types) +│ ├── me-profile.ts # 321 LOC (GET /me + PUT /profile) +│ ├── registration.ts # 158 LOC (POST /register) +│ ├── credits.ts # 335 LOC (5 endpoints) +│ └── email-verify.ts # 415 LOC (GET + POST /verify-email) +├── admin/ # split 2 → folder +│ ├── index.ts # 32 LOC barrel + auth middleware mount +│ ├── _middleware.ts # 63 LOC (requireAdmin) +│ ├── _keycloak-helpers.ts # 397 LOC (caches + roles/groups + audit + types) +│ ├── users.ts # 709 LOC (7 endpoints) +│ ├── plans.ts # 170 LOC (3 endpoints) +│ ├── docker.ts # 117 LOC (2 endpoints) +│ └── roles-groups.ts # 393 LOC (9 endpoints) +└── (alte route files, neatinse) + +src/config/ +├── env.ts # requireEnv, optionalEnv +├── error-response.ts # internalError +├── keycloak-admin.ts # getKeycloakAdminToken (canonical) +├── logger.ts # pino + variadic adapter +├── request-logger.ts # Express middleware +└── (database.ts, minio.ts, redis.ts — pre-existing) +``` + +--- + +## 3. Fișiere mari rămase (candidați viitor split) + +| Fișier | LOC | Tip | Plan recomandat | +|---|---|---|---| +| _(none — toate fișierele >900 LOC au fost spart)_ | | | | + +--- + +## 4. Bug-uri rezolvate (NU le reintroduce) + +### 4.1 Auth bypass admin (CRITIC, 100 zile expus) + +`requireAdmin = (req,res,next) => next()` cu comentariu "STAGING bypass remove +in production" — toate 21 admin endpoints erau publice. + +**Fix**: `_middleware.ts` în admin folder verifică: +- JWT decoded (defense-in-depth — Kong validează signature upstream) +- Issuer include `/realms/${ADMIN_REALM}` (default `didi-admins`) +- `realm_access.roles` include unul din `ADMIN_ROLES` (default `admin,super-admin`) + +Override pentru dev: `ADMIN_AUTH_BYPASS=true` (logează warn pe FIECARE request). + +### 4.2 Aggregator stolen lock + +TTL 30s pentru lock; calcul-verdict + LLM explanation putea dura mai mult; alt +worker prelua lock-ul; primul worker făcea `del()` pe lock-ul greșit. + +**Fix**: `acquireLock` returnează token UUID, `releaseLock` verifică prin Lua +script înainte de `del`. Loghează warn dacă TTL a expirat. + +### 4.3 Component-worker retry fără backoff → cascade failure + +LLM rate-limit → retry imediat → cascadă. + +**Fix**: linear backoff 1-5s între retry-uri în `component-worker.ts`. + +### 4.4 Empty API key fallback + +`return process.env.OPENROUTER_API_KEY || ''` → request cu `Bearer ` (empty) +→ silent 401 LLM. + +**Fix**: throw clar mentționând env vars verificate. + +### 4.5 Video weighting inversat + +`pipeline-routes` folosea 0.6 text + 0.4 visual; `aggregator` folosea 0.4 + 0.6. +Același video, scoruri diferite. + +**Fix**: helper `combineVideoProbability()` cu canonical 0.4/0.6 (visual mai +greu — frames sunt evidence directă). + +### 4.6 KEYCLOAK_ADMIN_USER vs KEYCLOAK_ADMIN + +`auth.ts` cerea `KEYCLOAK_ADMIN_USER` (env var inexistentă în prod) pe când +`admin.ts` și docker-compose foloseau `KEYCLOAK_ADMIN`. Ar fi crăpat la primul +restart cu `requireEnv`. + +**Fix**: centralizat în `keycloak-admin.ts` cu `KEYCLOAK_ADMIN`. + +### 4.7 M17_WEB_API_URL throw at module-load + +`source-assessment/executor.ts` arunca `Error('M17_WEB_API_URL ...')` la +**module-load time** (nu la runtime). Tests + service refuzau să se încarce. + +**Fix**: `getM17SearchUrl()` lazy. + +### 4.8 Stale `dist/` în Docker context + +Local `dist/` se copia în container și suprascria build-ul fresh. + +**Fix**: `.dockerignore` în ambele services. + +--- + +## 5. Test status + +``` +agent-v3 vitest: +- 421 / 466 trec +- 45 fail = pre-existente (au devenit vizibile abia după fix-ul M17 lazy) +- Toate 20 fail-uri pe care le-am cauzat eu inițial = ACUM REPARATE +- Suite-le mari care nu se încărcau în baseline (component-runner, integration, + component-worker) acum rulează +``` + +**Cele 45 fail-uri rămase sunt în**: +- `video-processor.test.ts` (13) +- `verdict-explanation.test.ts` (11) +- `executor.test.ts` (4) +- `verdict-calculator.test.ts` (4) +- `analysis-session.test.ts` (4) +- alte (9 distribuite) + +Sunt issues de **mock data** stale și **algorithm shifts** — nu blocau prod. +NU sunt regresii cauzate de mine. + +--- + +## 6. Build + deploy + +### Local TS check (rapid, fără Docker) + +```bash +cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3 +npx tsc --noEmit # trebuie să fie 0 erori + +cd ../didiFramework +npx tsc --noEmit # trebuie să fie 0 erori +``` + +### Docker rebuild + deploy + +```bash +# agent-v3 (rebuilds toate workers) +cd /home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3 +docker compose up -d --build + +# didiFramework +cd /home/admin365/didi_mono/backend/services/orchestration-layer/didiFramework +docker compose up -d --build didi-framework +``` + +### Smoke test post-deploy + +```bash +# Health +curl http://10.11.10.12:24803/api/v3/health +curl http://10.11.10.12:3005/health + +# Auth admin (trebuie să fie 401 fără JWT) +curl -o /dev/null -w "HTTP %{http_code}\n" http://10.11.10.12:3005/api/admin/users + +# Pipeline async +curl -X POST http://10.11.10.12:24803/api/v3/techniques/analyze \ + -H "Content-Type: application/json" \ + -d '{"text":"Test analizei.","plan_type":1}' +``` + +--- + +## 7. Backups + +Locație: `/home/admin365/didi_mono/backups/` + +Snapshots numerotate cu timestamp `YYYYMMDD_HHMMSS` la momente cheie: +- `agent-v3_20260507_122836.tar.gz` (start sesiune) +- `agent-v3_pre-pino_*.tar.gz` +- `agent-v3_pre-split-routes_*.tar.gz` +- `agent-v3_pre-verdict-split_*.tar.gz` +- `didiFramework_*.tar.gz` (similar) + +Pentru rollback la orice etapă: +```bash +tar -xzf /home/admin365/didi_mono/backups/.tar.gz \ + -C /home/admin365/didi_mono/backend/services/orchestration-layer/ +``` + +--- + +## 8. Statistici cumulate sesiune + +| Metric | Start | După | +|---|---|---| +| `console.*` în prod | 651 | **0** | +| `error.message` leaks | 126 | **0** | +| `as any` real în prod | 159 | **13** | +| Hardcoded passwords în source | 13 | **0** | +| Auth bypass active | DA | nu | +| Race conditions cunoscute | 3 | 0 | +| Files >1000 LOC | 7 | **1** (am spart 9: routes, admin, verdict-calc, auth, pipeline-routes, + 4 executors — singurul rămas e verdict-calc/index.ts cu 864 LOC core class, dar acela e DEJA splitat în folder) | +| TypeScript errors | 4 baseline | **0** ambele services | +| `process.env.*` direct reads | 137 | 73 | + +**54 task-uri completate, 13 deploy-uri reușite, 0 downtime detectabil.** + +### Update 2026-05-07 (sesiune ulterioară) + +Pipeline-routes split done (1611 → 8 files). Anti-patterns curățate în drum: +- 2 `error.message` leaks (lines 943, 1213) → `internalError(res, err, ctx)` +- 2 `process.env.X || 'fallback'` (FRAMEWORK_API_URL, M17_WEB_API_URL) → `optionalEnv` / `requireEnv` +- Imports mid-file (line 1329-1338) → grupate sus în fiecare sub-file + +Claims executor split done (1228 → 7 files). Strategy: păstrează class API +identic, stage methods devin standalone functions ce primesc `ClaimsCtx` +(snapshot din state-ul clasei). Class shell rămâne `executor.ts` (319 LOC) cu: +constructor, state, `loadConfigs`, `saveResult`, `buildCtx`, `execute()` orchestrator. + +- `searchWebM17` mutat în `web-search.ts` (M17_WEB_API_URL → `requireEnv`) +- All 36 unit tests trec după split (executor-output.test.ts) +- Public API neatins: `new ClaimsExecutor(redis, llm).execute(text, sessionId, tier)` +- Re-export types din executor.ts pentru consumers existenți (component-runner, claims-routes) +- Pattern propus pentru ai-tampered/techniques/source-assessment executors: + same approach (Ctx + standalone stage functions + thin class shell) + +Ai-tampered executor split done (1043 → 9 files). Same pattern as claims: +- 3 stages (screening, deep-analysis, scoring) + helpers (disclosure, patterns, llm-utils) +- Class shell în executor.ts (339 LOC) cu execute() + quickAnalyze() (no-LLM rapid path) +- AiTamperedCtx snapshot trimis la stage functions +- Smoke test live pe `/api/v3/ai-tampered/quick`: input cu disclosure explicit + + ChatGPT mention → ai_probability=85, verdict=LIKELY_AI ✓ +- All 27 unit tests trec (executor-output.test.ts) +- Public API neatins: ambele `execute()` și `quickAnalyze()` exportate identic +- Re-export types din executor.ts pentru consumers existenți + +Techniques executor split done (962 → 7 files). Same pattern (no disclosure +sub-module — techniques nu are pattern matching). Cea mai mare reducere +proporțional: 962 → 237 LOC pentru class shell. +- 3 stages (screening, deep-analysis, scoring) + types + llm-utils + _helpers +- buildTechniquesList stays cu deep-analysis.ts (e prompt building, nu scoring) +- findTechniqueById/calculateManipulationScore/buildCouplingContext în scoring.ts +- resolveStageAssignment exportată din types.ts (re-export prin executor.ts) +- All 23 unit tests trec +- Workers techniques pornesc curat, consumă din 6 queues + +Source-assessment executor split done (932 → 8 files). Structură ușor +diferită — nu e 2-stage screening+deep, e 4-step pipeline (extract → search + +domain → map → score). Cea mai mare reducere proporțional: 932 → **137 LOC** +class shell (a 4-a aplicare a pattern-ului). Public API: doar +`SourceAssessmentExecutor` class, niciun type re-export (rezultatul e definit +în shared/types/component-results). +- 3 stages (extraction, mapping, scoring) + types + defaults (prompts + + framework hierarchy + models) + external (M17 + Domain Check API) + config-loader +- Anti-pattern fix: `process.env.DOMAIN_CHECK_API_URL || '...'` → `optionalEnv` + (hostname intern cu safe default acceptabil) +- `M17_WEB_API_URL`: `requireEnv` în loc de inline throw +- Smoke test: `/api/v3/source-assessment/health` + `/config` răspund corect +- worker-domain rulează source-assessment în-process (nu există worker separat) +- Nu există __tests__ folder pentru source-assessment, doar TS check + smoke + +Providers + sync-redis (didiFramework) split done. Două routes mari +(921 + 898 LOC) → 17 fișiere total. Shell-uri 6 LOC re-export. +- `providers/`: 8 files (configs/models/assignments/keys/all/prompts/test + _helpers). + Cel mai mare: assignments.ts (182 LOC). Smoke: `/api/providers/configs` + `/all` OK. +- `sync-redis/`: 7 files (sync/status/data + fetch-data + fetch-config + _shared + index). + Cel mai mare: sync.ts (328 LOC). Anti-pattern fix: `error.message` în catch-uri → + `internalError(res, err, ctx)`. Smoke: GET /status, POST / sync 60 keys în 733ms. +- Au inclus mici cleanup-uri: `error: any` → `error` (typed unknown), `catch (e)` cu unused → `catch`. + +--- + +## 9. Sfaturi pentru sesiunea nouă + +### Întâi citește + +1. Acest document +2. `backend/CLAUDE.md` +3. Scurt scan al fișierelor noi din §2 ca să vezi pattern-urile + +### Înainte de orice modificare + +1. **Verifică TS baseline**: `npx tsc --noEmit` în ambele services → trebuie 0 erori +2. **Backup tar.gz** la `/home/admin365/didi_mono/backups/` cu timestamp clar +3. **NU re-introduce** anti-pattern-urile din §1 + +### Pentru split-uri viitoare + +1. Backup +2. Identifică boundaries clare (comentarii section, doc comments) +3. Extrage helpers shared întâi (în `_init.ts` sau `_helpers/`) +4. Folosește `sed -n 'X,Yp'` pentru extracții — atenție la `});` (ușor de pierdut) +5. Header cu doc comment + imports + `const router = Router();` +6. Footer cu `\nexport default router;` +7. Înlocuiește originalul cu un barrel +8. `tsc --noEmit` după FIECARE fișier creat +9. Rebuild docker — verifică `.dockerignore` are `dist/` +10. Smoke test fiecare endpoint group + +### Anti-pattern-uri identificate + +- Mă grăbeam să plec ce face fiecare endpoint sub-router fără să verific exact + boundary line. **Verifică `head -3` și `tail -3` la fiecare extracție**. +- Sed range `X,Y` extrage inclusiv. Începe cu doc comment line, termină cu `});`. +- Atenție la `export default router` care era pe ultima linie a originalului — + nu-l copia de două ori. + +### Ce să NU faci + +- ❌ Nu introduce `console.*` direct (folosește `log`) +- ❌ Nu introduce `error.message` în response (folosește `internalError`) +- ❌ Nu introduce `new Pool()` (folosește pool-ul shared) +- ❌ Nu folosi `\|\| 'fallback'` pentru parole/secrets (folosește `requireEnv`) +- ❌ Nu adăuga `as any` decât cu comentariu explicit explicând de ce +- ❌ Nu modifica `requireAdmin` să fie no-op (asta era bug-ul critic original) +- ❌ Nu commita fără să rulezi `tsc --noEmit` întâi diff --git a/backend/admin-dashboard/.env.example b/backend/admin-dashboard/.env.example new file mode 100644 index 0000000..7394481 --- /dev/null +++ b/backend/admin-dashboard/.env.example @@ -0,0 +1,24 @@ +# Admin Dashboard - Build Environment +# These vars are baked into the React build at docker build time. +# Runtime env vars come from docker-compose.yml. +REACT_APP_SERVER_HOST=10.11.10.11 +REACT_APP_HOST=10.11.10.11 +# Keycloak — local on didi11, proxied through admin nginx at /auth/ +REACT_APP_KEYCLOAK_URL=CHANGE_ME +REACT_APP_KEYCLOAK_REALM=CHANGE_ME +REACT_APP_KEYCLOAK_CLIENT_ID=CHANGE_ME +# API — through local Kong (admin nginx /api/ → didi-kong:8000) +REACT_APP_API_BASE_URL=https://10.11.10.11:3001 +# Staging Mode (bypasses Keycloak auth — disable in production!) +REACT_APP_STAGING_MODE=false +# Service Ports (displayed on service cards) +REACT_APP_KEYCLOAK_PORT=CHANGE_ME +REACT_APP_KONG_PROXY_PORT=443 +REACT_APP_KONG_ADMIN_PORT=18101 +REACT_APP_KONG_MANAGER_PORT=18102 +REACT_APP_POSTGRES_PORT=5000 +REACT_APP_PGADMIN_PORT=5050 +REACT_APP_REDIS_PORT=6379 +REACT_APP_COMMANDER_PORT=8081 +REACT_APP_RABBITMQ_MGMT_PORT=15672 +REACT_APP_MINIO_CONSOLE_PORT=9001 diff --git a/backend/admin-dashboard/Dockerfile b/backend/admin-dashboard/Dockerfile new file mode 100644 index 0000000..a786e44 --- /dev/null +++ b/backend/admin-dashboard/Dockerfile @@ -0,0 +1,76 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +# Build-time environment variables +# These should be passed from docker-compose using ${PROTOCOL}://${HOST}:${PORT} +# CRA bakes these into the static build at compile time +ARG REACT_APP_KEYCLOAK_URL +ARG REACT_APP_KEYCLOAK_REALM=didi-admins +ARG REACT_APP_KEYCLOAK_CLIENT_ID=admin-dashboard +ARG REACT_APP_API_BASE_URL +ARG REACT_APP_HOST +ARG REACT_APP_DIDIAI_GATEWAY_URL +ARG REACT_APP_KONG_GATEWAY_URL +ARG REACT_APP_KONG_ADMIN_URL +ARG REACT_APP_STAGING_MODE=false +ENV REACT_APP_STAGING_MODE=$REACT_APP_STAGING_MODE + +# Service ports for dashboard UI +ARG REACT_APP_POSTGRES_PORT +ARG REACT_APP_PGADMIN_PORT +ARG REACT_APP_REDIS_PORT +ARG REACT_APP_COMMANDER_PORT +ARG REACT_APP_MINIO_CONSOLE_PORT +ARG REACT_APP_RABBITMQ_MGMT_PORT +ARG REACT_APP_ORCHESTRATOR_PORT +ARG REACT_APP_ANALYSIS_PORT +ARG REACT_APP_KONG_PROXY_PORT +ARG REACT_APP_KONG_ADMIN_PORT +ARG REACT_APP_KONG_MANAGER_PORT +ARG REACT_APP_KEYCLOAK_PORT + +# DIDI Platform service ports +ARG REACT_APP_DIDI_PLATFORM_IP +ARG REACT_APP_DIDI_CATALOG_PORT +ARG REACT_APP_DIDI_LLM_PORT +ARG REACT_APP_DIDI_GPT_OSS_PORT +ARG REACT_APP_DIDI_QWEN_VL_PORT +ARG REACT_APP_DIDI_WHISPER_PORT +ARG REACT_APP_DIDI_BUSTERX_PORT +ARG REACT_APP_DIDI_VIDEO_PORT +ARG REACT_APP_DIDI_WEB_PORT + +# Copy package files and lockfile for reproducible builds +COPY package.json package-lock.json ./ + +# Install dependencies - using npm install due to outdated lock file +RUN npm install --legacy-peer-deps + +# Copy application code including .env +COPY . . + +# Verify react-scripts is installed and build +RUN ls -la node_modules/.bin/ && \ + node node_modules/react-scripts/scripts/build.js + +# Production stage +FROM nginx:alpine + +# Remove default nginx page, copy built assets +# Files go to both /admin/ (for direct access) and root (for Kong strip_path) +RUN rm -rf /usr/share/nginx/html/* +COPY --from=build /app/build /usr/share/nginx/html/admin +COPY --from=build /app/build /usr/share/nginx/html + +# Copy nginx SSL configuration and certificates +COPY nginx-ssl.conf /etc/nginx/conf.d/default.conf +COPY ssl/server.crt /etc/nginx/ssl/server.crt +COPY ssl/server.key /etc/nginx/ssl/server.key + +# Expose ports +EXPOSE 443 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/backend/admin-dashboard/Dockerfile.dev b/backend/admin-dashboard/Dockerfile.dev new file mode 100644 index 0000000..fe8c1f2 --- /dev/null +++ b/backend/admin-dashboard/Dockerfile.dev @@ -0,0 +1,24 @@ +# Development Dockerfile for Admin Dashboard +# Uses Node.js directly for hot reload support + +FROM node:20-alpine + +# Install curl for health checks +RUN apk add --no-cache curl + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --legacy-peer-deps + +# Copy application code +COPY . . + +# Expose port +EXPOSE 3000 + +# Start development server +CMD ["npm", "start"] diff --git a/backend/admin-dashboard/INDEX.md b/backend/admin-dashboard/INDEX.md new file mode 100644 index 0000000..92cc28a --- /dev/null +++ b/backend/admin-dashboard/INDEX.md @@ -0,0 +1,879 @@ +# admin-dashboard - Index + +Dashboard administrativ pentru platforma DIDI. Aplicatie React SPA (Single Page Application) cu Material UI, montata la `/admin`. Ofera interfata grafica pentru managementul complet al platformei: monitorizare servicii, configurare framework analiza, management utilizatori, configurare modele LLM, vizualizare si executie pipeline-uri, si istoric analize. + +**Stack**: React 19 + TypeScript + Material UI 7 + React Router 7 +**Container**: didi-admin-local (nginx) -- porturi host `3081` -> 80 (HTTP) si `3001` -> 443 (HTTPS) +**Port intern**: 80/443 (nginx) +**Basename**: /admin (toate rutele prefixate cu /admin) +**Autentificare**: Keycloak (client `admin-dashboard`, PKCE S256, realm `didi-admins`), Keycloak LOCAL `didi-keycloak` servit sub `/auth` prin nginx-ul dashboard-ului +**Build**: node:20-alpine (build) -> nginx:alpine (serve) +**Network**: `didi-network` (single unified network for all DIDI + AI platform stacks since 2026-05-04) + +## URL-uri de acces + +| URL | Cum funcționează | +|---|---| +| `https://:3001/admin/` | direct la containerul `didi-admin-local` (nginx HTTPS, port host 3001) | +| `http://:3081/admin/` | direct la containerul `didi-admin-local` (nginx HTTP, port host 3081) | +| `https://didi365.eu/admin/` | public via Cloudflare tunnel + frontend nginx (când Cloudflare permite path) | +| `https://didi365.eu/admin-backend/` | public alias 301 → `/admin/` | + +`` = mașina de deployment (host-ul local). Containerul `didi-admin-local` are propria nginx (porturi host 3081/3001); frontend `didi-frontend` adaugă proxy `/admin/*`. Detalii: `frontend/INDEX.md` secțiunea 21. + +--- + +## Ce face + +1. **Monitorizare servicii** -- health check pentru toate 14 serviciile backend (polling 30s) +2. **Configurare framework analiza** -- CRUD complet pe 20+ tabele de parametri (dimensiuni, tehnici, indicatori, verdicte, ponderi, surse, claims) +3. **Management utilizatori** -- lista, cautare, editare, stergere, sincronizare Keycloak, management planuri abonament +4. **Configurare modele LLM** -- provideri, modele, assignments pe componente/etape, chei API, teste conectivitate +5. **Istoric analize** -- vizualizare si stergere sesiuni analiza din agent-v3 (filtre: risk, status, date) +6. **Pipeline builder** -- editor vizual DAG (ReactFlow) pentru pipeline-uri de analiza +7. **Executie si monitorizare** -- lansare pipeline-uri, tracking progres real-time (WebSocket + polling) +8. **Catalog componente** -- registry pentru functii, resurse AI, transformatoare, modele LLM +9. **Sincronizare Redis** -- trigger manual sync parametri framework -> Redis (pentru agent-v3) + +--- + +## Rute (App.tsx) + +Toate rutele sunt protejate cu ProtectedRoute (verifica autentificare Keycloak). + +| Path (relativ la /admin) | Componenta | Scop | +|---------------------------|-----------|------| +| / | ServicesDashboard | Landing page: monitorizare health servicii | +| /dashboard | ServicesDashboard | Alias pentru / | +| /login | LoginRedirect | Trigger Keycloak login | +| /orchestrator | OrchestratorDashboard | Pipeline builder + catalog + executie + istoric | +| /orchestrator/runs/:runId | RunDetailsPage | Detalii run individual (timeline, I/O, raw) | +| /pipelines | PipelinesPage | **NEW** — CRUD + lifecycle pipeline-uri reale (`input_type_profile`) + dry-run/cancel (Modul 1); admin-only | +| /analysis | UniversalAnalysisDashboard | Executie analiza multi-modal (text/image/video/audio/URL) | +| /analysis/results/:runId | UniversalResultsPage | Rezultate live cu polling | +| /framework | FrameworkDashboard | CRUD complet parametri framework DIDI | +| /framework/progressive | ProgressiveAnalysisTree | Vizualizare arbore analiza progresiva | +| /users | UserManagement | Management utilizatori + planuri abonament | +| /history | AnalysisHistory | Istoric sesiuni analiza admin | +| /providers | ProvidersManagement | CRUD provideri LLM, modele, assignments, chei API | +| /techniques-v3 | TechniquesV3Config | Configurare modele pe etape techniques | +| /llm-components | LLMComponentsConfig | Configurare unificata LLM (Techniques + AI-Tampered + Claims + Verdict) | +| /text-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) | +| /image-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) | +| /video-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) | +| /audio-analysis | UniversalAnalysisDashboard | Legacy (randeaza direct, nu redirect) | +| /moderation | ModerationQueue | Lista pending HIL review queue cu filtre + paginatie | +| /moderation/:queueId | ModerationDetail | Detalii sesiune + Resolve dialog (action + corrections JSON + notes) | +| /moderation/stats | ModerationStats | Cards count (pending, in_review, resolved_24h) + by-priority + avg time | +| /unauthorized | Unauthorized | 403 page pentru useri fara rol staff | + +--- + +## Structura fisiere + +``` +admin-dashboard/ + Dockerfile -- Multi-stage: node:20-alpine build -> nginx:alpine + Dockerfile.dev -- Dev build + nginx.conf -- Nginx dev (port 80) + nginx-ssl.conf -- Nginx productie (HTTPS 443 + HTTP 80) + package.json -- React 19, MUI 7, ReactFlow, Recharts, Keycloak JS + .env -- REACT_APP_KEYCLOAK_URL, STAGING_MODE=false + src/ + App.tsx -- Router principal (toate rutele) + AuthProvider + ThemeProvider + index.tsx -- Entry point (randeaza App) + services/ + api.ts -- Client API principal (axios, 100+ endpoint-uri) + analysisService.ts -- Serviciu analiza legacy (submit/status/results) + keycloak.ts -- Configurare Keycloak JS adapter + contexts/ + AuthContext.tsx -- Provider autentificare (token refresh 30s, logout pe 401) + ThemeContext.tsx -- Provider tema dark/light (persistat in localStorage) + hooks/ + useAnalysisResults.ts -- Rezultate analiza cu auto-refresh + useRunsHistory.ts -- Istoric run-uri paginat cu filtre + usePipelineProgress.ts -- WebSocket progres pipeline real-time + useDeleteDialog.ts -- Dialog confirmare stergere generic + useExpandedItems.ts -- Stare accordion expandat + useTabState.ts -- Stare tab per item + pages/ + LandingPage.tsx -- Pagina marketing (nefolosita in rute) + FrameworkDashboard.tsx -- CRUD framework (20+ tabele) + OrchestratorDashboard.tsx -- Pipeline builder + catalog + RunDetailsPage.tsx -- Detalii run (3 tab-uri) + UniversalAnalysisDashboard.tsx -- Executie analiza multi-modal + UniversalResultsPage.tsx -- Rezultate live polling + AudioAnalysisDashboard.tsx -- Legacy + ImageAnalysisDashboard.tsx -- Legacy + TextAnalysisDashboard.tsx -- Legacy + VideoAnalysisDashboard.tsx -- Legacy + CostDashboard/ -- Dashboard costuri LLM (orfan, nerutuit) + components/ + auth/ + LoginRedirect.tsx -- Redirect la Keycloak login + ProtectedRoute.tsx -- Guard autentificare pe rute (suporta requiredAnyRole?: string[] any-of) + Unauthorized.tsx -- NEW: 403 page cu lista roluri + "Go to Public App" + "Log out" + dashboard/ + ServicesDashboard.tsx -- Grid servicii cu health check + services/ + ServiceCard.tsx -- Card individual serviciu + framework/ + CrudDataTable.tsx -- Tabel CRUD generic (GET/POST/PUT/DELETE) + TechniquesTreeView.tsx -- Arbore tehnici + ProgressiveAnalysisTree.tsx -- Arbore analiza progresiva + AnalysisFlowVisualization/ -- Vizualizare ReactFlow DAG framework + orchestrator/ + PipelineBuilder.tsx -- Editor vizual DAG (ReactFlow) + PipelineManager.tsx -- Lista + CRUD pipeline-uri + PipelineList.tsx -- Lista pipeline-uri + PipelineExecutor.tsx -- Executor pipeline cu polling 500ms + ComponentRegistry.tsx -- Catalog componente (functii, resurse, transformatoare) + benchmark/ + PipelineBenchmark.tsx -- Executie si monitorizare benchmark + useBenchmarkRunner.ts -- Hook benchmark (execute + poll) + runs-history/ + RunsHistoryPage.tsx -- Istoric run-uri paginat + RunsHistoryTable.tsx -- Tabel run-uri + RunDetailsDrawer.tsx -- Drawer detalii run + NodeWaterfallTimeline.tsx -- Timeline executie noduri + nodes/, edges/, dialogs/, hooks/ -- Sub-componente pipeline builder + Pipelines/ -- NEW (Modul 1) + PipelinesPage.tsx -- Tabel pipeline-uri reale (input_type_profile) + lifecycle (clone/activate/versions/restore/import/export) + DryRunDialog.tsx -- Run Console-lite: dry-run pe agent-v3 (flow + noduri + verdict profile, fara executie reala) + pipelinesApi.ts -- Client catre /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run|cancel + UserManagement/ + UserManagement.tsx -- Tab utilizatori + planuri + UserEditModal.tsx -- Dialog editare utilizator + AnalysisHistory/ + AnalysisHistory.tsx -- Tabel istoric sesiuni analiza + AnalysisDetailModal.tsx -- Modal detalii sesiune + ProvidersManagement/ + ProvidersManagement.tsx -- CRUD 4 tabele: provideri, modele, assignments, chei + TechniquesV3/ + TechniquesV3Config.tsx -- Configurare modele techniques + LLMComponentsConfig/ + index.tsx -- Config unificata 3 componente + verdict + provideri + VerdictConfig.tsx -- Configurare verdict (ponderi, categorii, severitate) + Moderation/ -- NEW + api.ts -- fetch helpers + types + UI helpers (priorityColor, statusColor) + ModerationQueue.tsx -- paginated table + filters (status, priority), auto-refresh 30s pe pending tab + ModerationDetail.tsx -- back btn + action buttons (Claim/Approve/Resolve corrected/Reject) + side-by-side cards (Input + AI Verdict) + Resolve dialog cu JSON corrections editor + ModerationStats.tsx -- 3 stat cards + by-priority chips + avg time + ModerationSettings/ -- NEW + index.tsx -- main panel cu 4 cards + auto-sync after save + TriageCard.tsx -- toggle, slider confidence_low, TextFields risk_grey + queue thresholds + BrainClientCard.tsx -- toggle, URL, timeouts, sliders confidence_min_silver + semantic_threshold, per-component checkboxes (techniques/ai_tampered/claims). Include alert "CLIENT settings only -- server config in AI platform" + SensitiveTopicsCard.tsx -- chip list cu delete + add form (code regex validation) + RolesCard.tsx -- table cu toggles (can_resolve, can_escalate, can_force_gold_brain, is_active) + api.ts -- fetch helpers pentru /api/moderation-config, /api/sensitive-topics, /api/moderation-roles + sync-redis trigger + text-analysis/ -- Componente analiza text legacy + image-analysis/ -- Componente analiza imagini legacy + audio-analysis/ -- Componente analiza audio legacy + video-analysis/ -- Componente analiza video legacy + config/ + serviceGroups.ts -- Definitie grupuri servicii sidebar + providerConfigs.ts -- Configurare provideri LLM + theme/ + index.ts -- Tema MUI (light/dark, culori, tipografie) + types/ + catalog.ts -- Tipuri catalog componente + keycloak.d.ts -- Tipuri Keycloak +``` + +--- + +## Servicii backend consumate + +Aplicatia nu are backend propriu. Toate datele vin prin nginx reverse proxy de la 3 servicii + health checks directe. + +### 1. didiFramework (proxiat la /framework) + +Nginx: `/framework/` -> `http://didi-framework:3005` + +Folosit de: FrameworkDashboard, UserManagement, ProvidersManagement, LLMComponentsConfig, VerdictConfig + +### 2. agent-v3 (proxiat la /agent-v3) + +Nginx: `/agent-v3/` -> `http://didi-agent-v3:24803` + +Folosit de: AnalysisHistory, TechniquesV3Config, LLMComponentsConfig, ServicesDashboard (health) + +### 3. Orchestrator API (proxiat la /api/v1 prin Kong) + +Nginx: `/api/` -> `http://didi-kong:8000` (Kong LOCAL) + +Folosit de: OrchestratorDashboard, UniversalAnalysisDashboard, ComponentRegistry, PipelineManager, RunsHistory, CostDashboard + +### 4. Health checks directe + +Nginx proxiaza catre containere individuale pentru verificare disponibilitate. + +--- + +## Endpoint-uri API consumate + +### Health checks (ServicesDashboard, polling 30s) + +| Metoda | Endpoint | Serviciu verificat | +|--------|----------|--------------------| +| GET | /framework/ | didiFramework | +| GET | /agent-v3/api/v3/health | agent-v3 | +| GET | /api/status | Kong | +| GET | /auth/realms/didi-admins | Keycloak (local didi-keycloak) | +| GET | /health-check/redis-commander | Redis Commander | +| GET | /health-check/rabbitmq | RabbitMQ Management | +| GET | /health-check/minio | MinIO | +| GET | /health-check/pgadmin | pgAdmin | + +### Framework CRUD (FrameworkDashboard, baza /framework) + +Fiecare endpoint suporta GET (lista), POST (creare), PUT /{id} (update), DELETE /{id} (stergere). Implementat prin componenta generica CrudDataTable. + +| Categorie | Endpoint-uri | +|-----------|-------------| +| Analysis Core | /api/dimensions, /api/subdimensions | +| Tehnici | /api/techniques, /api/indicators, /api/validation-rules | +| Evaluare Sursa | /api/platforms, /api/platform-modifiers, /api/source-credibility, /api/domain-age-scores, /api/domain-risk-levels, /api/domain-red-flags, /api/author-classifications, /api/author-credibility | +| Claims | /api/claims/status, /api/claims/types, /api/claims/confidence, /api/claims/interpretation | +| Verdicte | /api/verdicts/categories, /api/verdicts/risk, /api/verdicts/severity | +| Ponderi | /api/weights/components, /api/weights/scenarios, /api/weights/multipliers | +| Provideri | /api/providers/configs, /api/providers/models, /api/providers/assignments, /api/providers/keys | + +Extra: +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /api/overview/stats | Statistici framework | +| GET | /api/sync-redis/status | Status ultima sincronizare | +| POST | /api/sync-redis | Trigger sincronizare Redis | + +### Management utilizatori (UserManagement, baza /framework) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /api/admin/users?page=&limit=&search=&planId= | Lista utilizatori paginata cu filtre | +| GET | /api/admin/plans | Lista planuri abonament | +| DELETE | /api/admin/users/:id | Dezactivare utilizator | +| PUT | /api/admin/users/:id/email-verified | Toggle email verificat | +| POST | /api/admin/users/sync | Sincronizare utilizatori Keycloak -> PostgreSQL | +| PUT | /api/admin/plans/:id | Editare plan (nume, descriere, credite, pret) | + +### Istoric analize admin (AnalysisHistory, baza /agent-v3/api/v3) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /pipeline/history/admin?page=&limit=&search=&risk_level=&status=&from_date=&to_date= | Lista sesiuni cu filtre | +| GET | /pipeline/history/admin/:sessionId | Detalii sesiune | +| DELETE | /pipeline/history/admin/:sessionId | Stergere sesiune | + +### Configurare modele LLM (TechniquesV3Config + LLMComponentsConfig, baza /agent-v3/api/v3) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /{component}/config | Configurare componenta | +| GET | /{component}/models | Modele disponibile | +| GET | /{component}/stage-assignments | Assignments pe etape | +| PUT | /{component}/stage-assignments | Salvare assignments | +| POST | /{component}/test-model | Test conectivitate model | +| POST | /{component}/analyze | Test analiza (doar techniques) | +| GET | /{component}/prompts | Lista prompturi componenta | +| PUT | /{component}/prompts/:id | Update prompt | + +Unde {component} = techniques, ai-tampered, claims. + +### Moderation queue (ModerationQueue + Detail + Stats, baza /agent-v3/api/v3/moderation) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /queue?status=&priority=&assigned_to=me&limit=20&offset=0 | Lista paginata cu filtre | +| GET | /queue/:queueId | Detail (queue entry + full session JOIN) | +| POST | /queue/:queueId/claim | Atomic claim by current moderator | +| PUT | /queue/:queueId/resolve | body: {action, corrections?, notes?, user_id?} | +| POST | /flag | User report (browser extension) | +| GET | /stats | Counts + averages | + +### Moderation Settings (ModerationSettings, baza /framework/api) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /moderation-config | Single-row config | +| PUT | /moderation-config | Update whitelisted fields | +| GET | /sensitive-topics?active=all | List topics | +| POST | /sensitive-topics | Create topic | +| PUT | /sensitive-topics/:id | Update label/is_active | +| DELETE | /sensitive-topics/:id | Soft delete | +| GET | /moderation-roles | List roles | +| PUT | /moderation-roles/:code | Update permission toggles | + +### Provideri LLM (ProvidersManagement, baza /framework/api/providers) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /all | Toti providerii cu modele | +| POST | /configs | Creare provider | +| PUT | /configs/:id | Update provider | +| DELETE | /configs/:id | Stergere provider | +| POST | /models | Creare model | +| PUT | /models/:id | Update model | +| DELETE | /models/:id | Stergere model | +| POST | /assignments | Creare assignment | +| PUT | /assignments/:id | Update assignment | +| DELETE | /assignments/:id | Stergere assignment | +| POST | /keys | Creare cheie API | +| PUT | /keys/:id | Update cheie | +| DELETE | /keys/:id | Stergere cheie | +| POST | /test/:providerId | Test conexiune provider | +| GET | /prompts?component={comp} | Lista prompturi componenta | +| PUT | /prompts/:id | Update prompt | + +### Orchestrator - Catalog componente (ComponentRegistry, baza /api/v1/catalog) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /functions/ | Lista feature extractors | +| GET | /resources/ | Lista resurse AI | +| GET | /transformers/ | Lista transformatoare | +| GET | /models/ | Lista modele LLM | +| GET | /models/filters/options | Optiuni filtre modele | +| POST | /{type}/ | Creare componenta | +| PUT | /{type}/:id | Update componenta | +| DELETE | /{type}/:id | Stergere componenta | +| PATCH | /{type}/:id | Toggle enabled/disabled | + +### Orchestrator - Pipeline-uri (PipelineManager, baza /api/v1/pipelines) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | / | Lista pipeline-uri | +| GET | /:id | Detalii pipeline | +| POST | / | Creare pipeline | +| PUT | /:id | Update pipeline | +| DELETE | /:id | Stergere pipeline | +| GET | /:id/versions | Lista versiuni | +| POST | /:id/versions | Creare versiune | +| GET | /:id/preview | Preview executie | +| POST | /:id/execute | Executie pipeline | +| POST | /:id/versions/:ver/execute | Executie versiune specifica | + +### Orchestrator - Run-uri (RunsHistory + RunDetailsPage, baza /api/v1/runs) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | ?limit=&offset=&status=&date_from= | Lista run-uri cu filtre | +| GET | /:runId | Detalii run | +| GET | /:runId?include_live_status=true | Detalii cu status live | +| GET | /:runId/results?include_intermediate=true | Rezultate complete | +| GET | /status/:runId | Status polling | +| GET | /list?limit= | Lista run-uri (analysisResults) | +| GET | /history/enriched?skip=&limit=&pipeline_slug=&verdict= | Istoric imbogatit cu filtre | +| DELETE | /:runId | Stergere run | + +### Orchestrator - Template-uri si API-uri externe + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET/POST/PUT/DELETE | /api/v1/dynamic-templates/templates | CRUD template-uri | +| GET | /api/v1/dynamic-templates/templates/tasks/list | Lista task-uri | +| GET | /api/v1/dynamic-templates/templates/external-apis/list | Lista API-uri externe | +| GET | /api/v1/dynamic-templates/templates/feature-extractors/list | Lista extractori | +| GET/POST/PUT/DELETE | /api/v1/task-templates | CRUD task template-uri | +| POST | /api/v1/task-templates/:id/validate | Validare template | +| POST | /api/v1/task-templates/:id/test | Test template | +| POST | /api/v1/task-templates/:id/execute | Executie template | +| GET | /api/v1/external-apis/providers | Lista provideri externi | +| GET/POST/PUT/DELETE | /api/v1/external-apis/apis | CRUD API-uri externe | +| POST | /api/v1/sync/openapi/discover | Descoperire OpenAPI | +| POST | /api/v1/sync/openapi/import | Import servicii descoperite | +| GET | /api/v1/catalog/litellm-catalogue | Catalog LiteLLM | + +### Costuri LLM (CostDashboard -- pagina orfana, nerutata) + +| Metoda | Endpoint | Scop | +|--------|----------|------| +| GET | /api/v1/costs/summary?start_date=&end_date=&period= | Sumar costuri | +| GET | /api/v1/costs/by-model?start_date=&end_date= | Costuri per model | +| GET | /api/v1/costs/by-job?start_date=&end_date= | Costuri per job | +| GET | /api/v1/costs/thresholds | Praguri configurate | +| GET | /api/v1/costs/alerts | Alerte active | +| POST | /api/v1/costs/thresholds | Creare prag | +| DELETE | /api/v1/costs/thresholds/:id | Stergere prag | +| PUT | /api/v1/costs/alerts/:id/acknowledge | Confirmare alerta | + +### WebSocket (progres real-time) + +| Endpoint | Scop | +|----------|------| +| wss://{host}/ws/runs/:runId/progress?include_outputs=true | Progres executie pipeline | + +Mesaje WebSocket: connected, node_status (update per nod), run_complete, error. +Reconnect cu exponential backoff: 1s -> 2s -> 4s -> ... -> 30s max. + +--- + +## Pagini -- ce face fiecare + +### ServicesDashboard (/, /dashboard) + +Grid cu carduri pentru toate serviciile grupate pe layere (Data, Gateway/Auth, Orchestration, Monitoring). Fiecare card face health check la 30s si afiseaza status (verde/rosu). Sidebar cu navigare catre paginile admin (Providers, Users, History -- vizibile doar cu rol admin). + +Logica: src/components/dashboard/ServicesDashboard.tsx + src/components/services/ServiceCard.tsx +Configurare servicii: src/config/serviceGroups.ts (14 servicii in 4 grupuri) + +### FrameworkDashboard (/framework) + +Management CRUD complet pentru parametrii framework-ului de analiza. 7 categorii cu sub-tab-uri, fiecare cu tabel editabil (CrudDataTable). Include vizualizare ReactFlow a fluxului de analiza si buton "Sync to Redis" pentru a trimite parametrii catre Redis (agent-v3 ii citeste de acolo). + +Logica: src/pages/FrameworkDashboard.tsx (orchestrare tab-uri) +Tabel generic: src/components/framework/CrudDataTable.tsx (GET/POST/PUT/DELETE pe orice endpoint) +Vizualizare: src/components/framework/AnalysisFlowVisualization/ + +### OrchestratorDashboard (/orchestrator) + +Sidebar cu 2 sectiuni: Component Registry (Extractori, Componente AI, Transformatoare) si Pipelines (Lista, Creare, Executie, Istoric). Fiecare sectiune randeaza componenta corespunzatoare. + +Logica: src/pages/OrchestratorDashboard.tsx (layout sidebar) +Pipeline builder: src/components/orchestrator/PipelineBuilder.tsx (editor ReactFlow DAG) +Pipeline manager: src/components/orchestrator/PipelineManager.tsx (lista + CRUD) +Catalog: src/components/orchestrator/ComponentRegistry.tsx (tabele + dialoguri CRUD) +Benchmark: src/components/orchestrator/benchmark/PipelineBenchmark.tsx (executie + monitorizare) +Istoric: src/components/orchestrator/runs-history/RunsHistoryPage.tsx (tabel paginat + filtre) + +### RunDetailsPage (/orchestrator/runs/:runId) + +Detalii complete pentru un run individual. 3 tab-uri: Timeline (waterfall executie noduri), Input/Output (date intrare/iesire), Raw Data (JSON complet). Auto-refresh la 3s cand run-ul e activ. Foloseste fetch() in loc de axios (workaround pentru extensie browser). + +Logica: src/pages/RunDetailsPage.tsx +Timeline: src/components/orchestrator/runs-history/NodeWaterfallTimeline.tsx + +### PipelinesPage (/pipelines) -- NEW (Modul 1) + +Livrabil Modul 1 caiet ("Workflow Builder: CRUD/versionare pipeline; Run Console"). Opereaza pe **definitiile reale de pipeline** (`input_type_profile` din didiFramework), NU pe `pipelineApi` mort (`/api/v1/pipelines`, fara backend). Tabel cu profilurile + actiuni de lifecycle: dry-run, clone, activate/deactivate, versiuni (istoric + restore), import/export. + +- **Dry-run** (`DryRunDialog.tsx`): apeleaza `POST /agent-v3/api/v3/pipeline/dry-run` -> arata flow-ul, nodurile (component/queue/priority/stages/prompt keys), componentele skipped si verdict profile-ul rezolvat, fara a rula analiza reala. **Cancel** disponibil pe run-uri active. +- Backing endpoints: `/framework/api/pipelines/*` (list/get/clone/activate/deactivate/PUT cu versionare/versions/restore/import) + `/agent-v3/api/v3/pipeline/dry-run|:id/cancel`. + +Logica: src/components/Pipelines/PipelinesPage.tsx + DryRunDialog.tsx + pipelinesApi.ts +Acces: admin-only (`ProtectedRoute requiredRole="admin"`). + +### UniversalAnalysisDashboard (/analysis) + +Executie analiza multi-modal: text, imagine, video, audio, URL. Sidebar cu 3 sectiuni: Analysis (formular + executor), Results (run-uri active, polling 10s), History (paginat cu filtre). Suporta upload fisiere in MinIO si tracking progres executie. + +Logica: src/pages/UniversalAnalysisDashboard.tsx +Polling executie: 2s (GET /api/v1/runs/:runId?include_live_status=true) +Polling run-uri active: 10s (GET /api/v1/runs?limit=50) + +### UniversalResultsPage (/analysis/results/:runId) + +Pagina rezultate live. Afiseaza progress bar, status per nod (accordion), rezultate finale JSON. Polling la 2s. + +Logica: src/pages/UniversalResultsPage.tsx + +### UserManagement (/users) + +2 tab-uri: Utilizatori (tabel paginat cu cautare, filtrare plan, editare, stergere, sync Keycloak, toggle email-verified) si Planuri Abonament (editare inline: nume, descriere, credite, pret). + +Logica: src/components/UserManagement/UserManagement.tsx +Dialog editare: src/components/UserManagement/UserEditModal.tsx + +### AnalysisHistory (/history) + +Tabel paginat cu toate sesiunile de analiza din agent-v3. Filtre: cautare text, nivel risc (LOW/MODERATE/HIGH/VERY_HIGH/CRITICAL), status, interval date. Coloane: data, email, input preview, risk score, tehnici, AI %, claims, durata. View deschide AnalysisDetailModal cu toate detaliile sesiunii. + +**Cost display per analiza (USD)** — in AnalysisDetailModal, sectiunea "LLM Usage" afiseaza: +- Badge total cost langa titlul accordionului (ex: `$0.0321`) +- Grid cu 5 celule: Calls / Prompt Tokens / Completion Tokens / Total Tokens / **Est. Cost** +- Per Component: fiecare card contine cost chip individual (ex: Techniques: `$0.0054`) + +Costul se calculeaza client-side dintr-o combinatie de: +1. `llm_usage.by_component[comp]` — prompt_tokens, completion_tokens, models_used[] din agent-v3 +2. Fetch parallel la `/framework/api/providers/models` — care returneaza `input_cost_per_1m` + `output_cost_per_1m` per model +3. Formula: `cost = (prompt_tokens / 1M) * input_cost + (completion_tokens / 1M) * output_cost` +4. Cost-ul e charged pe **primul model** din `models_used[]` (aproximare: daca primary a picat si s-a dus pe fallback, cost-ul ar putea fi putin subestimat pe free chain, supra-estimat pe premium — dar in happy path e 100% corect) + +Free tier afiseaza tot `$0.00` (Qwen local nu are cost input/output). Premium arata cost real per analiza — tipic $0.02-0.05 pentru text simplu, pana la $0.10-0.15 pentru media cu claims multiple. + +Logica: src/components/AnalysisHistory/AnalysisHistory.tsx +Modal detalii: src/components/AnalysisHistory/AnalysisDetailModal.tsx (helpers: `normalizeModelKey`, `computeTokenCost`, `formatUsd`) + +### ProvidersManagement (/providers) + +CRUD pe 4 tab-uri: Providers (configurare provideri LLM), Models (modele per provider), Assignments (assignment componenta -> model pe etapa), API Keys (chei de acces). Fiecare tab cu dialoguri add/edit/delete. Buton test conexiune. + +Logica: src/components/ProvidersManagement/ProvidersManagement.tsx + +### TechniquesV3Config (/techniques-v3) + +3 tab-uri: Stage Assignments (model primar + 3 fallback-uri per etapa screening/deep), Available Models (catalog cu info speed/quality/cost), Test Analysis (textarea + buton executie test). Comunicare cu agent-v3. + +Logica: src/components/TechniquesV3/TechniquesV3Config.tsx + +### LLMComponentsConfig (/llm-components) + +Pagina unificata pentru configurare LLM si scoring pe toate componentele. Top-level tab-uri: Techniques, AI Tampered, Claims, Source Assessment, Verdict, **Moderation** (NEW), Providers. + +**Moderation** (NEW) — al 6-lea buton (alaturi de Techniques, AI Tampered, Claims, Source Assessment, Verdict). Randeaza `` cu 4 cards pentru HIL triage + brain client + sensitive topics + roles. Auto-syncs to Redis dupa fiecare save (snackbar feedback). + +**Toggle FREE | PREMIUM** (Etapa 6) — peste Stage Assignments, un `ToggleButtonGroup` permite switch-ul intre chain-ul `free` si `premium` pentru componenta curenta: +- Cand selectezi **FREE**, tabelul arata chain-ul primary+fallbacks din rows cu `tier='free'` (Qwen local primary + cloud fallbacks) +- Cand selectezi **PREMIUM**, tabelul arata chain-ul din rows cu `tier='premium'` (Gemini 3 Flash / Claude Sonnet primary + Qwen local safety) +- Badge-ul stage (`[FREE]` verde / `[PREMIUM]` galben) reflecta tier-ul curent selectat +- Daca un stage NU are rows premium configurate, apare un `Alert` warning si fallback automat la chain-ul `free` +- Editare: cand userul schimba un model in tabel, se aplica doar pe tier-ul curent selectat; salvarea itereaza ambele tiers si trimite PUT la `/api/providers/assignments/:id` + +State-ul `selectedTier` e stored in componenta main LLMComponentsConfig; `StageAssignmentsPanel` primeste `selectedTier` ca prop si filtreaza `stage.modelsByTier[selectedTier]` la randare. Structura `modelsByTier: Record<'free'|'premium', ModelConfig[]>` e construita in `fetchStageAssignments()` prin grouping pe `tier` din response-ul GET /api/providers/assignments (care include acum campul `tier`). + +Per componenta analiza (Techniques, AI Tampered, Claims, Source Assessment): +- **Stage Assignments**: modele LLM pe fiecare etapa (primary + 3 fallback-uri) — **cu toggle Free/Premium** +- **Vision Models**: cascade viziune (doar AI Tampered) +- **Available Models**: catalog modele din PG +- **Parameters**: scoring_config editabil — toti parametrii de calcul scor (din PG via didiFramework) + - Techniques: count_scaler, severe_threshold, intensity bonus, dimension/count bonuses + - AI Tampered: blend_weights, disclosure_impact, undisclosed_threshold, verdict thresholds + - Claims: status_weights (VT/LT/UV/OP/LF/VF), all_unverified behavior/credibility + - Source Assessment: axis_weights (publication/domain/author/platform), verdict_thresholds +- **Prompts**: system prompt + user template editabile +- **Test Analysis**: test cu text sample + +Verdict (VerdictConfig) — 8 tab-uri: +- Component Weights (ponderile globale, fallback daca profilurile nu exista) +- Verdict Categories (RELIABLE..DISINFORMATION ranges, editabile) +- Risk Levels (VERY_LOW..CRITICAL ranges, editabile) +- Severity & Actions (LOW..CRITICAL + actiuni recomandate) +- Overrides & Synergy (configurare override-uri globale) +- Confidence (bonusuri/penalitati) +- Topic Multipliers (elections, health, climate — editabile) +- **Input Profiles** (NOU): ponderi per input type (text/image/audio/video/url), override-uri per profil, AI disclosure multipliers, reguli INCONCLUSIVE. Editabil cu save + sync to Redis. + +Logica: src/components/LLMComponentsConfig/index.tsx +Verdict: src/components/LLMComponentsConfig/VerdictConfig.tsx + +### ModerationQueue (/moderation) + +Paginated table cu filtre (status: pending/in_review/resolved/all + priority CSV). Auto-refresh 30s pe pending tab. Click row -> detail. Top-right: link "View Stats". + +Logica: src/components/Moderation/ModerationQueue.tsx +Polling: 30s (auto-refresh pe pending tab) + +### ModerationDetail (/moderation/:queueId) + +Side-by-side: Input (text/url + meta) | AI Verdict (risk_score, confidence, severity, components run/skipped). Card metadata queue cu enqueue_reason + meta JSONB. Action buttons: +- Claim for Review (pe pending) +- Approve as is / Resolve with Corrections / Reject (pe in_review) + +Resolve dialog: action toggle + JSON corrections editor (validated client-side) + notes. + +Logica: src/components/Moderation/ModerationDetail.tsx + +### ModerationStats (/moderation/stats) + +3 stat cards (pending, in_review, resolved_24h) cu colored borders. Open by Priority chips. Avg time in queue. Auto-refresh 30s. + +Logica: src/components/Moderation/ModerationStats.tsx + +--- + +## Autentificare (Keycloak) + +### Configurare + +Fisier: src/services/keycloak.ts + +``` +Client ID: admin-dashboard (din REACT_APP_KEYCLOAK_CLIENT_ID, obligatoriu) +Realm: didi-admins (din REACT_APP_KEYCLOAK_REALM, obligatoriu) +PKCE: S256 (obligatoriu) +URL: din REACT_APP_KEYCLOAK_URL (requireEnv) — pointeaza spre /auth pe nginx-ul dashboard-ului, care proxiaza catre Keycloak LOCAL didi-keycloak +``` + +### Flow + +Fisier: src/contexts/AuthContext.tsx + +1. La pornire: `keycloak.init({ onLoad: 'login-required' })` +2. Token stocat in localStorage.keycloak_token +3. Refresh token la fiecare 30s (updateToken(70) -- refresh daca expira in < 70s) +4. La 401 de la axios: event auth:unauthorized -> logout automat +5. REACT_APP_STAGING_MODE=true sare peste toata autentificarea + +### Protectie rute + +Fisier: src/components/auth/ProtectedRoute.tsx + +- Verifica isAuthenticated inainte de a randa componenta +- Redirect la /login daca neautentificat +- Suporta requiredRole (verificare rol Keycloak) +- Suporta `requiredAnyRole?: string[]` (any-of) pentru rute partajate intre mai multe roluri staff + +Fisier: src/components/auth/LoginRedirect.tsx -- apeleaza keycloak.login() imediat +Fisier: src/components/auth/Unauthorized.tsx -- 403 page (lista roluri necesare + "Go to Public App" + "Log out") + +### Role gating (admin / moderator / senior_moderator) + +Roluri definite pe Keycloak LOCAL `didi-keycloak`, realm `didi-admins`: `moderator`, `senior_moderator` (langa `admin` existent). + +- `App.tsx`: AdminLayout wrapped in `ProtectedRoute requiredAnyRole=[admin, moderator, senior_moderator]` (end-users tip `viewer` primesc 403 -> Unauthorized page) +- Per-route guards: + - /framework, /users, /providers, /llm-components -> admin-only + - /history + /moderation/* -> admin OR moderator +- AdminLayout sidebar: sectiunea Configuration admin-only; sectiunea Management split (Users=admin, History=staff, Moderation=moderator-aware) +- Staging mode (`REACT_APP_STAGING_MODE=true`) face `hasRole()` sa returneze mereu true -> toate gates bypass pentru testare locala. In prod, Keycloak JWT roles enforcate strict. + +### Context expus + +AuthContext expune: isAuthenticated, initialized, user (tokenParsed), token, login(), logout(), hasRole(role) + +--- + +## Configurare Nginx + +### Productie (nginx-ssl.conf) + +HTTPS pe port 443 (TLS 1.2/1.3) + HTTP pe port 80. + +| Location | Proxy catre | Scop | +|----------|------------|------| +| / | SPA fallback /admin/index.html | React app | +| /api/ | http://didi-kong:8000 | Orchestrator API prin Kong LOCAL | +| /auth/ | http://keycloak:8080 | Keycloak LOCAL (container `didi-keycloak`); NU strip — Keycloak servit sub `/auth` (KC_HTTP_RELATIVE_PATH) | +| /agent-v3/ | http://didi-agent-v3:24803 | agent-v3 direct (strip prefix) | +| /framework/ | http://didi-framework:3005 | didiFramework direct (strip prefix) | +| /health-check/redis-commander | http://staging-dataLayer-redis-commander:8081 | Health check Redis Commander | +| /health-check/rabbitmq | http://staging-dataLayer-rabbitmq:15672 | Health check RabbitMQ | +| /health-check/minio | http://staging-dataLayer-minio:9000 | Health check MinIO | +| /health-check/pgadmin | http://staging-dataLayer-pgadmin:80 | Health check pgAdmin | +| /health-check/postgres | http://staging-dataLayer-postgres:5432 | Health check PostgreSQL | + +Headere securitate: X-Frame-Options SAMEORIGIN, X-Content-Type-Options nosniff, X-XSS-Protection. +Cache static: 1 an + immutable pentru .js, .css, .png, .svg, .ico. + +### Dev (nginx.conf) + +Acelasi layout fara SSL, porturi 3000 si 80. Nu are proxy pentru /api/, /auth/, sau health checks (doar /agent-v3/ si /framework/). + +--- + +## Comunicare real-time + +### WebSocket (usePipelineProgress) + +Fisier: src/hooks/usePipelineProgress.ts + +Conectare la `wss://{host}/ws/runs/:runId/progress?include_outputs=true` pentru tracking progres pipeline. + +Mesaje primite: +- connected -- confirmare subscriptie +- node_status -- update executie nod (key, status, label, type, timestamp, duration_ms) +- run_complete -- pipeline terminat (status: success/error/stopped, total_duration_ms) +- error -- mesaj eroare + +Reconnect exponential backoff: 1s -> 2s -> 4s -> ... -> 30s max. + +### Polling HTTP + +| Componenta | Interval | Endpoint | +|-----------|----------|----------| +| ServicesDashboard | 30s | Health checks (8 endpoint-uri) | +| UniversalAnalysisDashboard | 2s | GET /api/v1/runs/:runId?include_live_status=true | +| UniversalAnalysisDashboard | 10s | GET /api/v1/runs?limit=50 | +| PipelineExecutor | 500ms | GET /api/v1/runs/:runId?include_live_status=true | +| UniversalResultsPage | 2s | GET /api/v1/runs/status/:runId | +| RunDetailsPage | 3s | GET /api/v1/runs/:runId/results | +| RunsHistory | 10s | GET /api/v1/runs (optional, toggle) | +| useAnalysisResults | 5s | GET /api/v1/runs/:id?include_live_status=true | + +--- + +## Hooks custom + +| Hook | Fisier | Scop | +|------|--------|------| +| useAnalysisResults | src/hooks/useAnalysisResults.ts | Rezultate analiza cu auto-refresh 5s, filtrare pe tags | +| useRunsHistory | src/hooks/useRunsHistory.ts | Istoric run-uri paginat (fetch(), nu axios), filtre status/date/search | +| usePipelineProgress | src/hooks/usePipelineProgress.ts | WebSocket progres pipeline, reconnect backoff | +| useDeleteDialog | src/hooks/useDeleteDialog.ts | State generic dialog confirmare stergere | +| useExpandedItems | src/hooks/useExpandedItems.ts | State accordion expandat (Set) | +| useTabState | src/hooks/useTabState.ts | State tab per item (Record) | +| useBenchmarkRunner | src/components/orchestrator/benchmark/useBenchmarkRunner.ts | Executie benchmark pipeline + polling status | +| useAutoLayout | src/components/orchestrator/hooks/useAutoLayout.ts | Auto-layout noduri ReactFlow | +| useDagValidation | src/components/orchestrator/hooks/useDagValidation.ts | Validare DAG (detectie cicluri) | +| useEdgeSuggestions | src/components/orchestrator/hooks/useEdgeSuggestions.ts | Sugestii edge-uri data-flow | +| useProviderModels | src/components/orchestrator/hooks/useProviderModels.ts | Fetch modele si resurse catalog | +| useResourceEndpoints | src/components/orchestrator/hooks/useResourceEndpoints.ts | Fetch resurse catalog | + +--- + +## Tema (light/dark) + +Fisier: src/theme/index.ts +Persistenta: localStorage['didi-admin-theme'] +Toggle: src/components/ThemeToggle.tsx (buton in sidebar ServicesDashboard) + +### Culori + +| Rol | Valoare | Nume | +|-----|---------|------| +| Primary | #0052CC | Deep Trust Blue | +| Secondary | #00BFA6 | Honest Teal | +| Success | #28A745 | Truth Green | +| Error | #E63946 | Caution Red | +| Warning | #FF8C42 | Insight Orange | +| Background (light) | #F5F7FA | -- | +| Background (dark) | #121212 | -- | +| Text (light) | #1F2933 | Slate Charcoal | + +### Tipografie + +Font: Inter, Roboto, Helvetica, Arial +- h1: 32px/700, h2: 24px/600, h3: 20px/500, body1: 16px/400, button: 14px/500/uppercase + +### Overrides MUI + +- Button: radius 8, padding 12px 24px +- Card: radius 12, hover shadow +- TextField: primary border on focus +- Chip: rounded 16px + +--- + +## Dependente principale (package.json) + +| Pachet | Versiune | Scop | +|--------|----------|------| +| react | ^19.1.1 | UI framework | +| react-router-dom | ^7.7.1 | Routing | +| @mui/material | ^7.3.1 | Component library | +| axios | ^1.11.0 | HTTP client | +| keycloak-js | ^26.2.0 | Autentificare Keycloak | +| @react-keycloak/web | ^3.4.0 | React bindings Keycloak | +| reactflow | ^11.11.4 | Vizualizare DAG pipeline | +| recharts | ^3.1.0 | Grafice costuri | +| react-dnd | ^16.0.1 | Drag-and-drop | +| react-dropzone | ^14.3.8 | Upload fisiere | +| zod | ^3.25.76 | Validare schema | +| neverthrow | ^8.1.1 | Result types (Ok/Err) | +| ts-pattern | ^5.6.0 | Pattern matching | + +--- + +## Configurare Docker + +### Dockerfile (productie) + +``` +Stage 1: node:20-alpine + - npm install --legacy-peer-deps + - react-scripts build (20+ ARG-uri pentru REACT_APP_*) + - ARG REACT_APP_KEYCLOAK_REALM default = didi-admins (= realm-ul real); .env confirma aceeasi valoare + - ARG REACT_APP_STAGING_MODE=false (+ ENV explicit — CRA altfel bake-uia ARG default in loc sa citeasca .env) + +Stage 2: nginx:alpine + - Copie build in /usr/share/nginx/html/admin si /usr/share/nginx/html + - Copie nginx-ssl.conf + certificate SSL + - Expose 443, 80 +``` + +### Variabile de mediu (.env) + +`` = mașina de deployment (host-ul local). Valorile reale din `.env` (bake-uite in build la build time): + +``` +# General +REACT_APP_SERVER_HOST= +REACT_APP_HOST= +REACT_APP_API_BASE_URL=https://:3001 # prin Kong LOCAL (admin nginx /api/ → didi-kong:8000) +REACT_APP_STAGING_MODE=false + +# Keycloak — LOCAL, proxiat prin admin nginx la /auth/ +REACT_APP_KEYCLOAK_URL=https://:3001/auth +REACT_APP_KEYCLOAK_REALM=didi-admins +REACT_APP_KEYCLOAK_CLIENT_ID=admin-dashboard + +# Kong Gateway (LOCAL) +REACT_APP_KONG_PROXY_PORT=443 +REACT_APP_KONG_ADMIN_PORT=18101 +REACT_APP_KONG_MANAGER_PORT=18102 + +# Porturi servicii (afisate pe service cards) +REACT_APP_KEYCLOAK_PORT=28000 +REACT_APP_POSTGRES_PORT=5000 +REACT_APP_PGADMIN_PORT=5050 +REACT_APP_REDIS_PORT=6379 +REACT_APP_COMMANDER_PORT=8081 +REACT_APP_RABBITMQ_MGMT_PORT=15672 +REACT_APP_MINIO_CONSOLE_PORT=9001 +``` + +Nota: `REACT_APP_KEYCLOAK_URL` **este** folosit de `keycloak.ts` (`requireEnv`), pointand spre `/auth` pe nginx-ul dashboard-ului, care proxiaza catre Keycloak LOCAL `didi-keycloak`. + +`REACT_APP_STAGING_MODE=false` (real) → autentificarea Keycloak e ACTIVA; cand ar fi `true`, s-ar sari peste toata autentificarea (doar pentru testare locala). + +--- + +## Ce NU face + +- Nu are backend propriu (doar nginx reverse proxy + SPA static) +- Nu scrie direct in baza de date (toate operatiile prin didiFramework sau orchestrator API) +- Nu proceseaza analize (doar le lanseaza si monitorizeaza) +- Nu gestioneaza fisiere (upload-urile merg prin orchestrator API -> MinIO) +- Nu are SSR (Server Side Rendering) -- e un SPA clasic +- Nu are teste unitare (doar un e2e dark-mode.spec.ts cu Playwright) +- CostDashboard exista dar nu e rutata (pagina orfana) + +## Functionalitati suplimentare (in afara celor 9 module de caiet) + +| Functionalitate | Unde in cod | Note | +|-----------------|-------------|------| +| CostDashboard (costuri LLM) | `src/pages/CostDashboard/` | Pagina orfana — NU e rutata in App.tsx; consuma `/api/v1/costs/*` de pe orchestrator | + +(HIL Moderation din dashboard — ModerationQueue/Detail/Stats + ModerationSettings — este integrarea UI a Modulului de moderare din backend; ramane in sectiunile de mai sus.) + +--- + +## Recent Changes (2026-05-05) + +- **Keycloak login fix CRITICAL**: `src/services/keycloak.ts` ignora `REACT_APP_KEYCLOAK_URL` (folosea `${window.location.origin}/auth` hardcoded) — cauza "Cookie not found" cross-domain. Acum foloseste `requireEnv('REACT_APP_KEYCLOAK_URL')` direct. +- **`.env`**: `REACT_APP_STAGING_MODE=false` (era `true` -> bypass auth complet), `REACT_APP_KEYCLOAK_REALM=didi-admins` (era gresit `didi-clients`), `REACT_APP_KEYCLOAK_URL=https://:3001/auth` (Keycloak LOCAL `didi-keycloak` prin admin nginx `/auth`; nu SSO cluster). +- **`Dockerfile`**: `ARG REACT_APP_STAGING_MODE=false` (era `true`) + `ENV REACT_APP_STAGING_MODE=$REACT_APP_STAGING_MODE` adaugat (CRA bake-uia ARG default in loc sa citeasca .env). +- **`Moderation/api.ts` + `ModerationSettings/api.ts`**: helper nou `authedFetch()` care injecteaza `Authorization: Bearer ${localStorage.keycloak_token}`. Inainte faceau `fetch()` fara auth -> 401 pe Kong. +- **Role-based UI guard `AdminLayout.tsx`**: Dashboard + service groups (Data Layer, Gateway Layer, etc.) acum vizibile doar pentru `admin`. Moderator/senior_moderator vad doar Management section (Analysis History + Moderation). +- **`App.tsx` `DefaultLanding`**: redirect automat la `/moderation` pentru non-admin (in loc de ServicesDashboard). Plus route `/dashboard` cere rol `admin`. +- **UI cosmetics**: `ModerationQueue.tsx` Status/Priority labels cu `display: block, mb: 0.5` (erau lipite de butoane); priority 1,2,3,4,5 (erau doar 1,3,4); `AdminLayout.tsx` sidebar logo + main navbar header ambele la `height: 64px` (linii divider aliniate); divider deasupra "Management" ascuns pentru non-admin. + +--- + +## User Management overhaul (2026-05-05) + +`UserManagement.tsx` complet rescris (3 tabs) + 2 componente noi + `UserEditModal.tsx` extins. Toate hook-uite la endpoint-urile noi din didiFramework `/api/admin/*`. + +### Tab #1 — Users (extins) + +Coloane: ☑ Checkbox (bulk), Email (+ keycloak_id snippet), Name, **Sync** chip (Synced/KC only — warning bg), Plan, **Group** chip, **Roles** chips (top-3 + "+N more"), Email Verified Switch, Active, Credits (tooltip "Spent: N"), **Storage** progress bar (>90% red / >70% warning), Actions (Edit modal / 🛡️ RolesDialog / 📜 UsageHistory / 🗑️ Delete hard). + +Filter row: search + Plan + **Sync filter** (All/Synced/Keycloak only). + +Bulk toolbar (apare pe selectare): activate / deactivate / mark email verified / set credits to N / delete. Sequential per id (evită hammer Keycloak). + +### Tab #2 — Subscription Plans (nemodificat) + +### Tab #3 — Audit Log (NOU) + +Citește `/api/admin/audit-log` paginat. Filtre Action preset + Actor email (ILIKE) + Since (datetime-local). Coloane: When / Action chip color-coded / Target / Actor / Payload (truncat + tooltip JSON pretty) / IP. + +### Componente noi + +| File | Rol | +|------|-----| +| `RolesDialog.tsx` | Multi-select check-list pe realm roles, diff summary (add/remove), save → `PUT /:id/roles`. | +| `UsageHistoryModal.tsx` | Last 100 rows din `bos_sysadmin.ai_credit_usage`. Defensive render (input_type chip + credits + session_id + JSON fallback pe coloane necunoscute). | + +### `UserEditModal.tsx` extins + +În plus față de existent: **Group select** (dropdown grupuri Keycloak, "(no group)" = remove) + **"Send password reset email" button** (apelează `POST /:id/reset-password` lifespan 86400s, Snackbar succes). + +### `SensitiveTopicsCard.tsx` extins (Phase D1) + +Rânduri rich cu volatility chip color-coded (volatile=red, evolving=warning, stable=success) + TTL/recency chips + iconul ⚙️ Tune → `EditVolatilityDialog` (Select tier auto-seed defaults + override fine pe ttl/recency/half_life). Add-form extins cu Volatility select. + +### Live state + +20 users live. Reset-password fail cunoscut: cluster Keycloak SMTP not configured (502 cu detail în audit_log) — operațional, nu cod. diff --git a/backend/admin-dashboard/README.md b/backend/admin-dashboard/README.md new file mode 100644 index 0000000..c46d94f --- /dev/null +++ b/backend/admin-dashboard/README.md @@ -0,0 +1,64 @@ +# Admin Dashboard + +React admin interface for the DIDI misinformation detection platform. + +## Stack + +React 19, Material UI 7, TypeScript, Keycloak auth (PKCE S256). + +## Run + +```bash +# Build image +docker build -t didi-admin:latest . + +# Deploy (container defined in data-layer compose) +cd ../services/data-layer && docker compose up -d --force-recreate didi-admin +``` + +Access: `https://10.11.10.12:3000` (self-signed cert, STAGING_MODE bypasses auth) + +## Pages + +| Route | Page | What it does | +|-------|------|-------------| +| `/admin` | Services Dashboard | Health check grid for all containers | +| `/admin/framework` | Framework Config | CRUD for techniques, verdicts, weights, claims, sources | +| `/admin/llm-components` | LLM Components | Stage assignments, scoring params, prompts per component | +| `/admin/history` | Analysis History | Browse/delete analysis sessions (admin view) | +| `/admin/users` | User Management | Users list, plans, email verification | +| `/admin/providers` | Providers | LLM providers, models, API keys | + +## Architecture + +Nginx serves the React SPA and proxies API calls: +- `/framework/` → didi-framework:3005 +- `/agent-v3/` → didi-agent-v3:24803 +- `/health-check/*` → direct to service containers + +Health checks use Docker container state via framework API (`GET /api/admin/containers`). + +## Key Files + +``` +src/ +├── services/api.ts # API client, health checks, container mapping +├── components/ +│ ├── services/ServiceCard.tsx # Service card with Logs button +│ ├── services/LogsModal.tsx # Docker logs viewer (colorized) +│ ├── AnalysisHistory/ # Analysis sessions table + detail modal +│ ├── UserManagement/ # Users + subscription plans +│ ├── LLMComponentsConfig/ # LLM config per component + VerdictConfig +│ └── framework/CrudDataTable.tsx # Generic CRUD table for framework params +├── config/serviceGroups.ts # Service grouping for dashboard layout +└── contexts/AuthContext.tsx # Keycloak auth (bypass in staging mode) +``` + +## Environment + +Build-time vars (baked into JS at `docker build`): +- `REACT_APP_STAGING_MODE` — bypasses Keycloak auth +- `REACT_APP_HOST` — used for service UI URLs + +Runtime vars (from docker-compose): +- `REACT_APP_KEYCLOAK_URL`, `REACT_APP_KEYCLOAK_REALM`, `REACT_APP_KEYCLOAK_CLIENT_ID` diff --git a/backend/admin-dashboard/e2e/auth.setup.ts b/backend/admin-dashboard/e2e/auth.setup.ts new file mode 100644 index 0000000..670bdb1 --- /dev/null +++ b/backend/admin-dashboard/e2e/auth.setup.ts @@ -0,0 +1,54 @@ +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(__dirname, '../playwright/.auth/user.json'); + +/** + * Authentication setup - logs in once and saves storage state + * All tests will reuse this authenticated session + */ +setup('authenticate', async ({ page }) => { + // Navigate to app (will redirect to Keycloak) + await page.goto('/'); + + // Wait for page to settle + await page.waitForLoadState('networkidle'); + + // Check if we're on a login page (Keycloak at port 18280 or has login form) + const hasLoginForm = await page.locator('input[name="username"], input[id="username"]').isVisible().catch(() => false); + const isKeycloakUrl = page.url().includes(':18280'); + + if (hasLoginForm || isKeycloakUrl) { + console.log('Login page detected, filling credentials...'); + + // Fill in login credentials + const testEmail = process.env.TEST_USER_EMAIL || 'admin@local.dev'; + const testPassword = process.env.TEST_USER_PASSWORD || 'admin'; + + // Try different input selectors for username/email + const usernameInput = page.locator('input[name="username"], input[id="username"], input[type="email"]').first(); + await usernameInput.fill(testEmail); + + // Fill password + const passwordInput = page.locator('input[name="password"], input[id="password"], input[type="password"]').first(); + await passwordInput.fill(testPassword); + + // Click sign in button + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Sign In")').first().click(); + + // Wait for redirect back to app + await page.waitForURL('http://localhost:13003/**', { timeout: 30000 }); + } + + // Wait for app to be fully loaded + await page.waitForLoadState('networkidle'); + + // Verify we're authenticated by checking for dashboard content + // Look for "Service Monitor" text which appears in the header + await expect(page.locator('text=Service Monitor').first()).toBeVisible({ timeout: 15000 }); + + console.log('Authentication successful, saving storage state...'); + + // Save storage state (cookies, localStorage) + await page.context().storageState({ path: authFile }); +}); diff --git a/backend/admin-dashboard/e2e/dark-mode.spec.ts b/backend/admin-dashboard/e2e/dark-mode.spec.ts new file mode 100644 index 0000000..ab5edde --- /dev/null +++ b/backend/admin-dashboard/e2e/dark-mode.spec.ts @@ -0,0 +1,199 @@ +import { test, expect } from '@playwright/test'; + +/** + * Dark Mode Toggle Integration Tests + * Tests the theme switching functionality across the admin dashboard + */ + +test.describe('Dark Mode Toggle', () => { + // Clear theme preference before each test (keep auth tokens) + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + // Only clear theme preference, not auth tokens + await page.evaluate(() => localStorage.removeItem('didi-admin-theme')); + }); + + test.describe('Service Monitor Page', () => { + test('should display theme toggle in sidebar', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Theme toggle should be visible in the sidebar header + const sidebarToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]'); + await expect(sidebarToggle.first()).toBeVisible(); + }); + + test('should switch to dark mode when toggle is clicked', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Click the theme toggle (switch to dark) + const toggleButton = page.locator('button[aria-label*="Switch to dark"]').first(); + await toggleButton.click(); + + // Verify localStorage was updated + const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme')); + expect(theme).toBe('dark'); + + // Verify the toggle now shows "switch to light" + const lightToggle = page.locator('button[aria-label*="Switch to light"]').first(); + await expect(lightToggle).toBeVisible(); + }); + + test('should apply dark background color in dark mode', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Switch to dark mode + await page.locator('button[aria-label*="Switch to dark"]').first().click(); + + // Check that background color changed to dark + const bgColor = await page.evaluate(() => { + const main = document.querySelector('main') || document.body; + return getComputedStyle(main).backgroundColor; + }); + + // Dark mode background should be dark (#121212 = rgb(18, 18, 18)) + expect(bgColor).toMatch(/rgb\(18,\s*18,\s*18\)|rgb\(30,\s*30,\s*30\)/); + }); + + test('should switch back to light mode', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Switch to dark mode first + await page.locator('button[aria-label*="Switch to dark"]').first().click(); + + // Switch back to light mode + await page.locator('button[aria-label*="Switch to light"]').first().click(); + + // Verify localStorage + const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme')); + expect(theme).toBe('light'); + }); + }); + + test.describe('Theme Persistence', () => { + test('should persist dark mode after page refresh', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Switch to dark mode + await page.locator('button[aria-label*="Switch to dark"]').first().click(); + // Wait for theme to be saved + await page.waitForTimeout(500); + + // Refresh the page + await page.reload({ waitUntil: 'networkidle' }); + + // Wait for app to stabilize and verify toggle is visible first + const lightToggle = page.locator('button[aria-label*="Switch to light"]').first(); + await expect(lightToggle).toBeVisible({ timeout: 10000 }); + + // Now verify localStorage + const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme')); + expect(theme).toBe('dark'); + }); + + test('should persist light mode after page refresh', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Refresh the page + await page.reload({ waitUntil: 'networkidle' }); + + // Wait for app to stabilize and verify toggle is visible + const darkToggle = page.locator('button[aria-label*="Switch to dark"]').first(); + await expect(darkToggle).toBeVisible({ timeout: 10000 }); + }); + }); + + test.describe('Orchestrator Page', () => { + test('should display theme toggle in sidebar header', async ({ page }) => { + await page.goto('/orchestrator'); + await page.waitForLoadState('domcontentloaded'); + + // Theme toggle should be visible in the drawer header + const sidebarToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]'); + await expect(sidebarToggle.first()).toBeVisible(); + }); + + test('should share theme state with Service Monitor', async ({ page }) => { + // Set dark mode on Service Monitor + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.locator('button[aria-label*="Switch to dark"]').first().click(); + // Wait for theme to be saved + await page.waitForTimeout(500); + + // Navigate to Orchestrator + await page.goto('/orchestrator', { waitUntil: 'networkidle' }); + + // Wait for toggle to be visible first (proves page is loaded) + const lightToggle = page.locator('button[aria-label*="Switch to light"]').first(); + await expect(lightToggle).toBeVisible({ timeout: 10000 }); + + // Now verify localStorage + const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme')); + expect(theme).toBe('dark'); + }); + + test('should apply dark mode styling on Orchestrator page', async ({ page }) => { + await page.goto('/orchestrator'); + await page.waitForLoadState('domcontentloaded'); + + // Switch to dark mode + await page.locator('button[aria-label*="Switch to dark"]').first().click(); + + // Main content should have dark background + const bgColor = await page.evaluate(() => { + const main = document.querySelector('main') || document.body; + return getComputedStyle(main).backgroundColor; + }); + + expect(bgColor).toMatch(/rgb\(18,\s*18,\s*18\)|rgb\(30,\s*30,\s*30\)/); + }); + }); + + test.describe('Cross-Page Consistency', () => { + test('theme toggle position should be consistent (sidebar)', async ({ page }) => { + // Check Service Monitor + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + const serviceMonitorToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]').first(); + await expect(serviceMonitorToggle).toBeVisible(); + + // Check Orchestrator + await page.goto('/orchestrator'); + await page.waitForLoadState('domcontentloaded'); + const orchestratorToggle = page.locator('.MuiDrawer-root button[aria-label*="Switch to"]').first(); + await expect(orchestratorToggle).toBeVisible(); + }); + }); +}); + +test.describe('Mobile Dark Mode', () => { + test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE + + test('should have theme toggle in mobile app bar', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // On mobile, toggle should be in the AppBar + const appBarToggle = page.locator('.MuiAppBar-root button[aria-label*="Switch to"]'); + await expect(appBarToggle).toBeVisible(); + }); + + test('should toggle dark mode on mobile', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // Click toggle in AppBar + await page.locator('.MuiAppBar-root button[aria-label*="Switch to dark"]').click(); + + // Verify dark mode + const theme = await page.evaluate(() => localStorage.getItem('didi-admin-theme')); + expect(theme).toBe('dark'); + }); +}); diff --git a/backend/admin-dashboard/nginx-ssl.conf b/backend/admin-dashboard/nginx-ssl.conf new file mode 100644 index 0000000..2d67d73 --- /dev/null +++ b/backend/admin-dashboard/nginx-ssl.conf @@ -0,0 +1,287 @@ +server { + listen 443 ssl; + server_name localhost 10.11.10.12; + + ssl_certificate /etc/nginx/ssl/server.crt; + ssl_certificate_key /etc/nginx/ssl/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Global: allow large file uploads (video up to 100MB) + client_max_body_size 100M; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Redirect root to /admin/ so browser URL matches React Router basename + location = / { + return 301 $scheme://$http_host/admin/; + } + + # /admin-backend/ — branding alias for /admin/. Both paths land users on + # the same SPA. Keeps /admin/ as the canonical basename so we don't have + # to touch Keycloak redirect URIs or React Router config. + location = /admin-backend { return 301 $scheme://$http_host/admin/; } + location /admin-backend/ { return 301 $scheme://$http_host/admin/; } + + # /admin-ai/ — reverse-proxy to AI platform dashboard (didiAI-dashboard). + # AI platform's React SPA is built with base=/admin-ai/ and FastAPI mounts + # the SPA at /admin-ai/, so paths line up — no rewrite needed for assets. + # `^~` ensures this prefix beats the .js/.css regex location below. + location ^~ /admin-ai/ { + resolver 127.0.0.11 valid=30s; + set $aidash didiAI-dashboard; + proxy_pass http://$aidash:51300; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_buffer_size 64k; + proxy_buffers 4 64k; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 5s; + } + + # Proxy ALL /api/ requests to local Kong (didi-kong container on didi-network) + # Host header overridden to didi365.eu so Kong matches the DIDI route hosts. + location /api/ { + resolver 127.0.0.11 valid=30s; + set $kong_upstream didi-kong; + proxy_pass http://$kong_upstream:8000; + proxy_http_version 1.1; + proxy_set_header Host didi365.eu; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + proxy_connect_timeout 10s; + } + + # Proxy /auth/ directly to Keycloak (KC_HTTP_RELATIVE_PATH=/auth, no rewrite) + # ^~ forces this prefix to beat the static .css/.js regex location below + location ^~ /auth/ { + resolver 127.0.0.11 valid=30s; + set $kc_upstream keycloak; + proxy_pass http://$kc_upstream:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + # Proxy to Agent V3 API (video processing can take up to 10 min) + location /agent-v3/ { + resolver 127.0.0.11 valid=30s; + set $agent_upstream didi-agent-v3; + rewrite ^/agent-v3/(.*) /$1 break; + proxy_pass http://$agent_upstream:24803; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + proxy_connect_timeout 10s; + } + + # Proxy to Framework API + location /framework/ { + resolver 127.0.0.11 valid=30s; + set $framework_upstream didi-framework; + rewrite ^/framework/(.*) /$1 break; + proxy_pass http://$framework_upstream:3005; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check proxies for internal services + location /health-check/postgres { + resolver 127.0.0.11 valid=30s; + set $pg staging-dataLayer-postgres; + proxy_pass http://$pg:5432/; + proxy_connect_timeout 3s; + proxy_read_timeout 3s; + } + + location /health-check/redis-commander { + resolver 127.0.0.11 valid=30s; + set $rc staging-dataLayer-redis-commander; + proxy_pass http://$rc:8081/redis-commander/; + proxy_connect_timeout 3s; + proxy_read_timeout 3s; + } + + location /health-check/rabbitmq { + resolver 127.0.0.11 valid=30s; + set $rmq staging-dataLayer-rabbitmq; + proxy_pass http://$rmq:15672/; + proxy_connect_timeout 3s; + proxy_read_timeout 3s; + } + + location /health-check/minio { + resolver 127.0.0.11 valid=30s; + set $minio staging-dataLayer-minio; + proxy_pass http://$minio:9000/minio/health/live; + proxy_connect_timeout 3s; + proxy_read_timeout 3s; + } + + location /health-check/pgadmin { + resolver 127.0.0.11 valid=30s; + set $pga staging-dataLayer-pgadmin; + proxy_pass http://$pga:80/; + proxy_connect_timeout 3s; + proxy_read_timeout 3s; + } + + # SPA fallback + location / { + try_files $uri $uri/ /admin/index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} + +# HTTP server (for Kong proxy + direct access) +server { + listen 80; + server_name localhost 10.11.10.12; + + client_max_body_size 100M; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location = / { + return 301 $scheme://$http_host/admin/; + } + + location /api/ { + resolver 127.0.0.11 valid=30s; + set $kong_upstream didi-kong; + proxy_pass http://$kong_upstream:8000; + proxy_http_version 1.1; + proxy_set_header Host didi365.eu; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + proxy_connect_timeout 10s; + } + + location ^~ /auth/ { + resolver 127.0.0.11 valid=30s; + set $kc_upstream keycloak; + proxy_pass http://$kc_upstream:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + location /agent-v3/ { + resolver 127.0.0.11 valid=30s; + set $agent_upstream didi-agent-v3; + rewrite ^/agent-v3/(.*) /$1 break; + proxy_pass http://$agent_upstream:24803; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + proxy_connect_timeout 10s; + } + + location /framework/ { + resolver 127.0.0.11 valid=30s; + set $framework_upstream didi-framework; + rewrite ^/framework/(.*) /$1 break; + proxy_pass http://$framework_upstream:3005; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check proxies (same as HTTPS block) + location /health-check/postgres { + resolver 127.0.0.11 valid=30s; + set $pg staging-dataLayer-postgres; + proxy_pass http://$pg:5432/; + proxy_connect_timeout 3s; + } + location /health-check/redis-commander { + resolver 127.0.0.11 valid=30s; + set $rc staging-dataLayer-redis-commander; + proxy_pass http://$rc:8081/redis-commander/; + proxy_connect_timeout 3s; + } + location /health-check/rabbitmq { + resolver 127.0.0.11 valid=30s; + set $rmq staging-dataLayer-rabbitmq; + proxy_pass http://$rmq:15672/; + proxy_connect_timeout 3s; + } + location /health-check/minio { + resolver 127.0.0.11 valid=30s; + set $minio staging-dataLayer-minio; + proxy_pass http://$minio:9000/minio/health/live; + proxy_connect_timeout 3s; + } + location /health-check/pgadmin { + resolver 127.0.0.11 valid=30s; + set $pga staging-dataLayer-pgadmin; + proxy_pass http://$pga:80/; + proxy_connect_timeout 3s; + } + location / { + try_files $uri $uri/ /admin/index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/backend/admin-dashboard/nginx.conf b/backend/admin-dashboard/nginx.conf new file mode 100644 index 0000000..fec6995 --- /dev/null +++ b/backend/admin-dashboard/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 3000; + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Enable gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Proxy to Agent V3 API + location /agent-v3/ { + resolver 127.0.0.11 valid=30s; + set $agent_upstream didi-agent-v3; + rewrite ^/agent-v3/(.*) /$1 break; + proxy_pass http://$agent_upstream:24803; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy to Framework API + location /framework/ { + resolver 127.0.0.11 valid=30s; + set $framework_upstream didi-framework; + rewrite ^/framework/(.*) /$1 break; + proxy_pass http://$framework_upstream:3005; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} \ No newline at end of file diff --git a/backend/admin-dashboard/package-lock.json b/backend/admin-dashboard/package-lock.json new file mode 100644 index 0000000..a0f574c --- /dev/null +++ b/backend/admin-dashboard/package-lock.json @@ -0,0 +1,16339 @@ +{ + "name": "admin-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-dashboard", + "version": "0.1.0", + "dependencies": { + "@dagrejs/dagre": "^2.0.3", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.2.0", + "@mui/material": "^7.3.1", + "@react-keycloak/web": "^3.4.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.126", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@types/react-dropzone": "^5.1.0", + "@types/react-router-dom": "^5.3.3", + "@types/recharts": "^2.0.1", + "axios": "^1.11.0", + "keycloak-js": "^26.2.0", + "neverthrow": "^8.1.1", + "react": "^19.1.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^19.1.1", + "react-dropzone": "^14.3.8", + "react-router-dom": "^7.7.1", + "react-scripts": "5.0.1", + "reactflow": "^11.11.4", + "recharts": "^3.1.0", + "ts-pattern": "^5.6.0", + "typescript": "^5.0.0", + "web-vitals": "^2.1.4", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/keycloak-js": "^3.4.1", + "openapi-typescript": "^7.4.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.21.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.5", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.28.5", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.4", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.4", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "license": "MIT" + }, + "node_modules/@csstools/normalize.css": { + "version": "12.1.1", + "license": "CC0-1.0" + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "1.1.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "1.0.2", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "1.0.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "1.0.0", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "1.1.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "1.0.2", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "2.2.0", + "license": "CC0-1.0", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.10" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.3.tgz", + "integrity": "sha512-ig9Vg52tsijTIKNgW9BAeUVBhDRvqlZ2a6FQ6i41YcPpoAy7VXXt2qye22PXRecGSjAp0OEEVUBhJ4oS9BnBzQ==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/schemas": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/types": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.6", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.6", + "@mui/system": "^7.3.6", + "@mui/types": "^7.4.9", + "@mui/utils": "^7.3.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.6", + "@mui/styled-engine": "^7.3.6", + "@mui/types": "^7.4.9", + "@mui/utils": "^7.3.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.9", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.9", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.17", + "license": "MIT", + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { + "version": "0.7.6", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "license": "MIT" + }, + "node_modules/@react-keycloak/core": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.2.0" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/reactkeycloak" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@react-keycloak/web": { + "version": "3.4.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.0", + "@react-keycloak/core": "^3.2.0", + "hoist-non-react-statics": "^3.3.2" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/reactkeycloak" + }, + "peerDependencies": { + "keycloak-js": ">=9.0.2", + "react": ">=16.8", + "react-dom": ">=16.8", + "typescript": ">=3.8" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.2.tgz", + "integrity": "sha512-rcbDZOfXAgGEJeJ30aWCVVJvxV9ooevb/m1/SFblO2qHs4cqTk178gx7T/vdslf57EA4lTofrwsq5K8rxK9g+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz", + "integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", + "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.11.2", + "@redocly/config": "^0.22.0", + "colorette": "^1.2.0", + "https-proxy-agent": "^7.0.5", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "11.2.1", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/@types/estree": { + "version": "0.0.39", + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.15.0", + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.24.51", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/webpack": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "13.5.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.12", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "license": "MIT" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "license": "MIT" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "27.5.2", + "license": "MIT", + "dependencies": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "license": "MIT" + }, + "node_modules/@types/keycloak-js": { + "version": "3.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "keycloak-js": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.18.126", + "license": "MIT" + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "license": "MIT" + }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "license": "MIT" + }, + "node_modules/@types/q": { + "version": "1.5.8", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.7", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-dropzone": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "react-dropzone": "*" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/recharts": { + "version": "2.0.1", + "deprecated": "This is a stub types definition. recharts provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "recharts": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.17.1", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "16.0.11", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "license": "Apache-2.0" + }, + "node_modules/abab": { + "version": "2.0.6", + "license": "BSD-3-Clause" + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html": { + "version": "0.0.9", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.reduce": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-array-method-boxes-properly": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "is-string": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.0", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-jest": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-loader": { + "version": "8.4.1", + "license": "MIT", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.4", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "2.7.1", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-named-asset-import": { + "version": "0.3.8", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "license": "MIT" + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-react-app": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" + } + }, + "node_modules/babel-preset-react-app/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.11", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "license": "MIT" + }, + "node_modules/bfj": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2", + "check-types": "^11.2.3", + "hoopy": "^0.1.4", + "jsonpath": "^1.1.1", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "license": "BSD-2-Clause" + }, + "node_modules/browserslist": { + "version": "4.28.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/char-regex": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-types": { + "version": "11.2.3", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "license": "MIT" + }, + "node_modules/classcat": { + "version": "5.0.5", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coa": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/coa/node_modules/ansi-styles": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/chalk": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/coa/node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/coa/node_modules/escape-string-regexp": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/coa/node_modules/has-flag": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/supports-color": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.47.0", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.47.0", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-blank-pseudo": { + "version": "3.0.3", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-blank-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "3.0.4", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-has-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "license": "MIT", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "6.0.3", + "license": "CC0-1.0", + "bin": { + "css-prefers-color-scheme": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select-base-adapter": { + "version": "0.1.1", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "1.0.0-alpha.37", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "license": "MIT" + }, + "node_modules/cssdb": { + "version": "7.11.2", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "CC0-1.0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "license": "MIT", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.14", + "license": "CC0-1.0" + }, + "node_modules/csso/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "0.7.0", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "license": "MIT", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "license": "Apache-2.0" + }, + "node_modules/diff-sequences": { + "version": "27.5.1", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/dnd-core": { + "version": "16.0.1", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "license": "MIT" + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domexception": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.8.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-react-app": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-flowtype": { + "version": "8.0.3", + "license": "BSD-3-Clause", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@babel/plugin-syntax-flow": "^7.14.5", + "@babel/plugin-transform-react-jsx": "^7.14.9", + "eslint": "^8.1.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "license": "MIT", + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-testing-library": { + "version": "5.11.1", + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^5.58.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0", + "npm": ">=6" + }, + "peerDependencies": { + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-webpack-plugin": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/filesize": { + "version": "8.0.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.3", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "license": "ISC" + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "license": "BSD-2-Clause" + }, + "node_modules/global-modules": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "license": "(Apache-2.0 OR MPL-1.1)" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/hoopy": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.5", + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "license": "ISC" + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "license": "MIT", + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watch-typeahead": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@types/yargs": { + "version": "17.0.35", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watch-typeahead/node_modules/ansi-styles": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "license": "MIT", + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/react-is": { + "version": "18.3.1", + "license": "MIT" + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.1.2", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/form-data": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "esprima": "1.2.2", + "static-eval": "2.0.2", + "underscore": "1.12.1" + } + }, + "node_modules/jsonpath/node_modules/esprima": { + "version": "1.2.2", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keycloak-js": { + "version": "26.2.2", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, + "node_modules/keyv": { + "version": "4.5.4", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/launch-editor": { + "version": "2.12.0", + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.4", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.4", + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/neverthrow": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz", + "integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.24.0" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.9", + "license": "MIT", + "dependencies": { + "array.prototype.reduce": "^1.0.8", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "gopd": "^1.2.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz", + "integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.5", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/openapi-typescript/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "6.0.1", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "5.0.2", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-browser-comments": { + "version": "4.0.0", + "license": "CC0-1.0", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "browserslist": ">=4", + "postcss": ">=8" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "4.2.4", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "8.0.4", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "7.1.1", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-custom-media": { + "version": "8.0.2", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-properties": { + "version": "12.1.11", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "6.0.5", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "3.1.2", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-env-function": { + "version": "4.0.6", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "6.0.4", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within": { + "version": "5.0.4", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "3.0.5", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-image-set-function": { + "version": "4.0.7", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-lab-function": { + "version": "4.2.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-loader": { + "version": "6.2.1", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-logical": { + "version": "5.0.4", + "license": "CC0-1.0", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-media-minmax": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nesting": { + "version": "10.2.0", + "license": "CC0-1.0", + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-normalize": { + "version": "10.0.1", + "license": "CC0-1.0", + "dependencies": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "browserslist": ">= 4", + "postcss": ">= 8" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "1.1.3", + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "3.0.4", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "license": "MIT", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "7.0.5", + "license": "CC0-1.0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-preset-env": { + "version": "7.8.3", + "license": "CC0-1.0", + "dependencies": { + "@csstools/postcss-cascade-layers": "^1.1.1", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.13", + "browserslist": "^4.21.4", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.1.0", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.10", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "7.1.6", + "license": "CC0-1.0", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.0.14", + "license": "CC0-1.0" + }, + "node_modules/postcss-svgo/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "2.8.0", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/promise": { + "version": "8.3.0", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/q": { + "version": "1.5.1", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "19.2.3", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-app-polyfill": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.3.1", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dnd": { + "version": "16.0.1", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-error-overlay": { + "version": "6.1.0", + "license": "MIT" + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "19.2.3", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.11.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.11.0", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.11.0", + "license": "MIT", + "dependencies": { + "react-router": "7.11.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-scripts": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "bin": { + "react-scripts": "bin/react-scripts.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "peerDependencies": { + "react": ">= 16", + "typescript": "^3.2.1 || ^4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reactflow": { + "version": "11.11.4", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "3.6.0", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/recharts/node_modules/react-redux": { + "version": "9.2.0", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/redux": { + "version": "5.0.1", + "license": "MIT" + }, + "node_modules/recharts/node_modules/redux-thunk": { + "version": "3.1.0", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "license": "MIT" + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/reselect": { + "version": "5.1.1", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "rework": "1.0.1", + "rework-visit": "1.0.0" + }, + "peerDependenciesMeta": { + "rework": { + "optional": true + }, + "rework-visit": { + "optional": true + } + } + }, + "node_modules/resolve-url-loader/node_modules/picocolors": { + "version": "0.2.1", + "license": "ISC" + }, + "node_modules/resolve-url-loader/node_modules/postcss": { + "version": "7.0.39", + "license": "MIT", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/jest-worker": { + "version": "26.6.2", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { + "version": "4.0.0", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sanitize.css": { + "version": "13.0.0", + "license": "CC0-1.0" + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "license": "MIT", + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.2.4", + "license": "ISC" + }, + "node_modules/saxes": { + "version": "5.0.1", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/select-hose": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "license": "ISC" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "license": "MIT" + }, + "node_modules/spdy": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "license": "BSD-3-Clause" + }, + "node_modules/stable": { + "version": "0.1.8", + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "license": "MIT" + }, + "node_modules/static-eval": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "escodegen": "^1.8.1" + } + }, + "node_modules/static-eval/node_modules/escodegen": { + "version": "1.14.3", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/static-eval/node_modules/estraverse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/static-eval/node_modules/levn": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/optionator": { + "version": "0.8.3", + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/prelude-ls": { + "version": "1.1.2", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/static-eval/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-eval/node_modules/type-check": { + "version": "0.3.2", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-loader": { + "version": "3.3.4", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "license": "MIT" + }, + "node_modules/svgo": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/svgo/node_modules/ansi-styles": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/chalk": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/svgo/node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/svgo/node_modules/css-select": { + "version": "2.1.0", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "3.4.2", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "1.7.0", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { + "version": "1.3.1", + "license": "BSD-2-Clause" + }, + "node_modules/svgo/node_modules/escape-string-regexp": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svgo/node_modules/has-flag": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/nth-check": { + "version": "1.0.2", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/svgo/node_modules/supports-color": { + "version": "5.5.0", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "6.0.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.16", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throat": { + "version": "6.0.2", + "license": "MIT" + }, + "node_modules/thunky": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tryer": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "license": "Apache-2.0" + }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "license": "MIT" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/underscore": { + "version": "1.12.1", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/upath": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/util.promisify": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/utila": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "license": "ISC", + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.6", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.0", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-vitals": { + "version": "2.1.4", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.104.1", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.2", + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.3", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-manifest-plugin": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^4.44.2 || ^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "license": "MIT", + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-background-sync": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-build": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.6.0", + "workbox-broadcast-update": "6.6.0", + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-google-analytics": "6.6.0", + "workbox-navigation-preload": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-range-requests": "6.6.0", + "workbox-recipes": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0", + "workbox-streams": "6.6.0", + "workbox-sw": "6.6.0", + "workbox-window": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "license": "MIT", + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/fs-extra": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0-beta.0", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/workbox-build/node_modules/webidl-conversions": { + "version": "4.0.2", + "license": "BSD-2-Clause" + }, + "node_modules/workbox-build/node_modules/whatwg-url": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-core": { + "version": "6.6.0", + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-background-sync": "6.6.0", + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-precaching": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-recipes": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "6.6.0", + "workbox-core": "6.6.0", + "workbox-expiration": "6.6.0", + "workbox-precaching": "6.6.0", + "workbox-routing": "6.6.0", + "workbox-strategies": "6.6.0" + } + }, + "node_modules/workbox-routing": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-strategies": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0" + } + }, + "node_modules/workbox-streams": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "workbox-core": "6.6.0", + "workbox-routing": "6.6.0" + } + }, + "node_modules/workbox-sw": { + "version": "6.6.0", + "license": "MIT" + }, + "node_modules/workbox-webpack-plugin": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.6.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.9.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { + "version": "1.4.3", + "license": "MIT", + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/workbox-window": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.6.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "license": "Apache-2.0" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "16.2.0", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/backend/admin-dashboard/package.json b/backend/admin-dashboard/package.json new file mode 100644 index 0000000..2d76842 --- /dev/null +++ b/backend/admin-dashboard/package.json @@ -0,0 +1,71 @@ +{ + "name": "admin-dashboard", + "version": "0.1.0", + "private": true, + "homepage": "/admin", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.2.0", + "@mui/material": "^7.3.1", + "@react-keycloak/web": "^3.4.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.126", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@types/react-dropzone": "^5.1.0", + "@types/react-router-dom": "^5.3.3", + "@types/recharts": "^2.0.1", + "axios": "^1.11.0", + "keycloak-js": "^26.2.0", + "neverthrow": "^8.1.1", + "react": "^19.1.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^19.1.1", + "react-dropzone": "^14.3.8", + "react-router-dom": "^7.7.1", + "react-scripts": "5.0.1", + "reactflow": "^11.11.4", + "recharts": "^3.1.0", + "ts-pattern": "^5.6.0", + "typescript": "^5.0.0", + "web-vitals": "^2.1.4", + "zod": "^3.25.76" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "type-check": "tsc --noEmit", + "generate:types": "python3 scripts/generate_node_types.py", + "generate:api-types": "npx openapi-typescript http://localhost:8080/api/v1/openapi.json -o src/types/generated-api.ts" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/keycloak-js": "^3.4.1", + "openapi-typescript": "^7.4.1" + } +} diff --git a/backend/admin-dashboard/playwright.config.ts b/backend/admin-dashboard/playwright.config.ts new file mode 100644 index 0000000..cf6ac4a --- /dev/null +++ b/backend/admin-dashboard/playwright.config.ts @@ -0,0 +1,101 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(__dirname, 'playwright/.auth/user.json'); + +/** + * Playwright configuration for admin-dashboard e2e tests + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + // Directory containing test files + testDir: './e2e', + + // Run tests in parallel + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code + forbidOnly: !!process.env.CI, + + // Retry failed tests (2 retries on CI, 0 locally) + retries: process.env.CI ? 2 : 0, + + // Limit parallel workers on CI + workers: process.env.CI ? 1 : undefined, + + // Reporter configuration + reporter: [ + ['html', { open: 'never' }], + ['list'], + ], + + // Shared settings for all projects + use: { + // Base URL for navigation (parameterized via ADMIN_DASHBOARD_PORT) + baseURL: `http://localhost:${process.env.ADMIN_DASHBOARD_PORT}`, + + // Capture screenshot on failure + screenshot: 'only-on-failure', + + // Capture trace on failure (for debugging) + trace: 'on-first-retry', + + // Default timeout for actions + actionTimeout: 10000, + }, + + // Test timeout + timeout: 30000, + + // Configure projects for different browsers + projects: [ + // Setup project - runs authentication once + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + // Main test projects - depend on setup for authentication + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + // Mobile viewports + { + name: 'mobile-chrome', + use: { + ...devices['Pixel 5'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + ], + + // Run local dev server before starting tests (optional) + // Uncomment if you want Playwright to start the server automatically + // webServer: { + // command: 'npm start', + // url: `http://localhost:${process.env.ADMIN_DASHBOARD_PORT}`, + // reuseExistingServer: !process.env.CI, + // timeout: 120000, + // }, +}); diff --git a/backend/admin-dashboard/public/favicon.ico b/backend/admin-dashboard/public/favicon.ico new file mode 100644 index 0000000..0afd5f0 Binary files /dev/null and b/backend/admin-dashboard/public/favicon.ico differ diff --git a/backend/admin-dashboard/public/favicon.svg b/backend/admin-dashboard/public/favicon.svg new file mode 100644 index 0000000..452b935 --- /dev/null +++ b/backend/admin-dashboard/public/favicon.svg @@ -0,0 +1,4 @@ + + + d + \ No newline at end of file diff --git a/backend/admin-dashboard/public/index.html b/backend/admin-dashboard/public/index.html new file mode 100644 index 0000000..c15fcd5 --- /dev/null +++ b/backend/admin-dashboard/public/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + didi Administration Platform + + + +
+ + + diff --git a/backend/admin-dashboard/public/logo.png b/backend/admin-dashboard/public/logo.png new file mode 100644 index 0000000..1552781 Binary files /dev/null and b/backend/admin-dashboard/public/logo.png differ diff --git a/backend/admin-dashboard/public/logo192.png b/backend/admin-dashboard/public/logo192.png new file mode 100644 index 0000000..d3416d9 Binary files /dev/null and b/backend/admin-dashboard/public/logo192.png differ diff --git a/backend/admin-dashboard/public/logo512.png b/backend/admin-dashboard/public/logo512.png new file mode 100644 index 0000000..e20d8c4 Binary files /dev/null and b/backend/admin-dashboard/public/logo512.png differ diff --git a/backend/admin-dashboard/public/manifest.json b/backend/admin-dashboard/public/manifest.json new file mode 100644 index 0000000..6e7d9e5 --- /dev/null +++ b/backend/admin-dashboard/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "didi Admin", + "name": "didi Administration Platform", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/backend/admin-dashboard/public/robots.txt b/backend/admin-dashboard/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/backend/admin-dashboard/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/backend/admin-dashboard/public/silent-check-sso.html b/backend/admin-dashboard/public/silent-check-sso.html new file mode 100644 index 0000000..5c0b3bd --- /dev/null +++ b/backend/admin-dashboard/public/silent-check-sso.html @@ -0,0 +1,12 @@ + + + + + Silent SSO Check + + + + + \ No newline at end of file diff --git a/backend/admin-dashboard/scripts/generate_node_types.py b/backend/admin-dashboard/scripts/generate_node_types.py new file mode 100644 index 0000000..3045478 --- /dev/null +++ b/backend/admin-dashboard/scripts/generate_node_types.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +""" +Generate TypeScript types from Pydantic node schemas. + +This script introspects the Pydantic models in shared.node_schemas and generates: +1. TypeScript interfaces for each node spec +2. Validation rules extracted from Field constraints (ge, le, min_length, etc.) +3. Discriminated union type for all node specs + +Usage: + python generate_node_types.py + +Output: + ../src/components/orchestrator/types/nodeSpecs.generated.ts +""" +import sys +import os +import json +from pathlib import Path +from typing import get_type_hints, get_origin, get_args, Any, Optional, List, Dict, Literal, Union +from datetime import datetime + +# Add the orchestration-layer to the path +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "services" / "orchestration-layer")) + +from shared.node_schemas import ( + NODE_SPEC_SCHEMAS, + NODE_OUTPUT_SCHEMAS, + LLMNodeSpec, + APINodeSpec, + TransformNodeSpec, + StorageNodeSpec, + FunctionNodeSpec, + PythonREPLNodeSpec, + AudioTranscriptionNodeSpec, + ConditionalNodeSpec, + SubagentNodeSpec, + AggregatorNodeSpec, + InputNodeSpec, + OutputNodeSpec, + ForEachNodeSpec, + JoinNodeSpec, +) +from pydantic import BaseModel +from pydantic.fields import FieldInfo + + +def python_type_to_ts(python_type: Any) -> str: + """Convert Python type annotation to TypeScript type.""" + origin = get_origin(python_type) + args = get_args(python_type) + + # Handle None/NoneType + if python_type is type(None): + return "null" + + # Handle Optional (Union with None) + if origin is Union: + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1: + # Optional[X] -> X | null + return f"{python_type_to_ts(non_none_args[0])} | null" + else: + # Union of multiple types + return " | ".join(python_type_to_ts(a) for a in args) + + # Handle Literal + if origin is Literal: + return " | ".join(f"'{a}'" if isinstance(a, str) else str(a) for a in args) + + # Handle List + if origin is list: + inner = python_type_to_ts(args[0]) if args else "any" + return f"{inner}[]" + + # Handle Dict + if origin is dict: + key_type = python_type_to_ts(args[0]) if args else "string" + value_type = python_type_to_ts(args[1]) if len(args) > 1 else "any" + return f"Record<{key_type}, {value_type}>" + + # Handle basic types + type_map = { + str: "string", + int: "number", + float: "number", + bool: "boolean", + Any: "any", + type(None): "null", + } + + if python_type in type_map: + return type_map[python_type] + + # Handle Pydantic models (nested) + if isinstance(python_type, type) and issubclass(python_type, BaseModel): + return python_type.__name__ + + # Fallback + return "any" + + +def extract_field_constraints(field: FieldInfo) -> Dict[str, Any]: + """Extract validation constraints from a Pydantic field.""" + constraints = {} + + # Get default value + if field.default is not None and field.default is not ...: + constraints["default"] = field.default + + # Extract from metadata (Pydantic v2 style) + for constraint in field.metadata: + constraint_type = type(constraint).__name__ + if constraint_type == "Ge": + constraints["min"] = constraint.ge + elif constraint_type == "Le": + constraints["max"] = constraint.le + elif constraint_type == "Gt": + constraints["min"] = constraint.gt + constraints["exclusiveMin"] = True + elif constraint_type == "Lt": + constraints["max"] = constraint.lt + constraints["exclusiveMax"] = True + elif constraint_type == "MinLen": + constraints["minLength"] = constraint.min_length + elif constraint_type == "MaxLen": + constraints["maxLength"] = constraint.max_length + + # Check if required + constraints["required"] = field.is_required() + + return constraints + + +def generate_interface(model: type[BaseModel], model_name: str) -> tuple[str, Dict[str, Dict[str, Any]]]: + """Generate TypeScript interface and validation rules for a Pydantic model.""" + lines = [f"export interface {model_name} {{"] + validation_rules = {} + + type_hints = get_type_hints(model) + + for field_name, field_info in model.model_fields.items(): + python_type = type_hints.get(field_name, Any) + ts_type = python_type_to_ts(python_type) + + # Check if optional + is_optional = not field_info.is_required() + optional_marker = "?" if is_optional else "" + + # Get description for JSDoc + description = field_info.description or "" + + # Add JSDoc comment + if description: + lines.append(f" /** {description} */") + + lines.append(f" {field_name}{optional_marker}: {ts_type};") + + # Extract validation constraints + constraints = extract_field_constraints(field_info) + if constraints: + validation_rules[field_name] = constraints + + lines.append("}") + return "\n".join(lines), validation_rules + + +def generate_nested_models() -> str: + """Generate interfaces for nested models used in specs.""" + # Import nested models + from shared.node_schemas.llm_node import ToolConfig + from shared.node_schemas.api_node import FallbackEndpoint + from shared.node_schemas.subagent_node import SubagentPass + from shared.node_schemas.aggregator_node import SignalConfig, ThresholdConfig + from shared.node_schemas.conditional_node import ConditionalBranch + + nested_models = [ + ToolConfig, + FallbackEndpoint, + SubagentPass, + SignalConfig, + ThresholdConfig, + ConditionalBranch, + ] + + interfaces = [] + for model in nested_models: + interface, _ = generate_interface(model, model.__name__) + interfaces.append(interface) + + return "\n\n".join(interfaces) + + +def generate_output_fields(model: type[BaseModel]) -> List[str]: + """Extract output field names from an output schema.""" + return list(model.model_fields.keys()) + + +def extract_conditional_output_fields(model: type[BaseModel]) -> Dict[str, Dict[str, List[Any]]]: + """ + Extract conditional field availability from json_schema_extra. + Returns: { "field_name": { "spec_field": [allowed_values] } } + """ + conditionals = {} + for field_name, field_info in model.model_fields.items(): + if field_name in ['error', 'error_type', 'error_message', 'details']: + continue + extra = field_info.json_schema_extra or {} + if isinstance(extra, dict) and 'available_when' in extra: + conditionals[field_name] = extra['available_when'] + return conditionals + + +def main(): + output_path = Path(__file__).parent.parent / "src" / "components" / "orchestrator" / "types" / "nodeSpecs.generated.ts" + + # Node specs to generate + node_specs = { + "llm": LLMNodeSpec, + "api": APINodeSpec, + "transform": TransformNodeSpec, + "storage": StorageNodeSpec, + "function": FunctionNodeSpec, + "python_repl": PythonREPLNodeSpec, + "audio_transcription": AudioTranscriptionNodeSpec, + "conditional": ConditionalNodeSpec, + "subagent": SubagentNodeSpec, + "aggregator": AggregatorNodeSpec, + "input": InputNodeSpec, + "output": OutputNodeSpec, + "foreach": ForEachNodeSpec, + "join": JoinNodeSpec, + } + + all_interfaces = [] + all_validation_rules = {} + all_output_fields = {} + + # Header + header = f"""/** + * Auto-generated TypeScript types from Pydantic node schemas + * + * DO NOT EDIT MANUALLY - run 'npm run generate:types' to regenerate + * Generated: {datetime.now().isoformat()} + * + * Source: backend/services/orchestration-layer/shared/node_schemas/ + */ + +""" + + # Generate nested model interfaces first + nested_interfaces = generate_nested_models() + all_interfaces.append(nested_interfaces) + + # Generate spec interfaces + for node_type, spec_class in node_specs.items(): + interface_name = f"{spec_class.__name__}" + interface, validation = generate_interface(spec_class, interface_name) + all_interfaces.append(interface) + + # Convert to TypeScript-friendly key (snake_case -> UPPER_SNAKE_CASE) + validation_key = f"{node_type.upper()}_VALIDATION" + all_validation_rules[validation_key] = validation + + # Get output fields from corresponding output schema + output_class = NODE_OUTPUT_SCHEMAS.get(node_type.replace("_", "")) + if output_class: + all_output_fields[node_type] = generate_output_fields(output_class) + + # Generate discriminated union + union_members = [] + for node_type, spec_class in node_specs.items(): + union_members.append(f" | {{ type: '{node_type}' }} & {spec_class.__name__}") + + discriminated_union = f"""/** + * Discriminated union of all node specs. + * Use with type narrowing: if (spec.type === 'llm') {{ spec.model_ref... }} + */ +export type NodeSpec = +{chr(10).join(union_members)}; +""" + + # Generate validation rules export + validation_export = "/**\n * Validation rules extracted from Pydantic Field constraints\n */\n" + for key, rules in all_validation_rules.items(): + validation_export += f"export const {key} = {{\n" + for field_name, constraints in rules.items(): + constraints_str = ", ".join( + f"{k}: {repr(v) if isinstance(v, str) else str(v).lower() if isinstance(v, bool) else v}" + for k, v in constraints.items() + ) + validation_export += f" {field_name}: {{ {constraints_str} }},\n" + validation_export += "} as const;\n\n" + + # Generate output fields export + output_fields_export = "/**\n * Output fields available for each node type (for NodeConfig.Header)\n */\n" + output_fields_export += "export const NODE_OUTPUT_FIELDS: Record = {\n" + for node_type, fields in all_output_fields.items(): + fields_str = ", ".join(f"'{f}'" for f in fields if f not in ['error', 'error_type', 'error_message', 'details']) + output_fields_export += f" {node_type}: [{fields_str}],\n" + output_fields_export += "};\n" + + # Generate conditional output fields + all_conditional_fields = {} + for node_type in node_specs.keys(): + output_class = NODE_OUTPUT_SCHEMAS.get(node_type.replace("_", "")) + if output_class: + conditionals = extract_conditional_output_fields(output_class) + if conditionals: + all_conditional_fields[node_type] = conditionals + + conditional_export = """ +/** + * Conditional output field availability based on node spec values. + * Maps: node_type -> { field_name -> { spec_field -> allowed_values[] } } + * Fields without conditions are always available. + */ +export const CONDITIONAL_OUTPUT_FIELDS: Record>> = """ + conditional_export += json.dumps(all_conditional_fields, indent=2) + ";\n" + + helper_function = """ +/** + * Get context-aware output fields based on node spec configuration. + * Filters NODE_OUTPUT_FIELDS using CONDITIONAL_OUTPUT_FIELDS metadata. + * + * @param nodeType - The node type + * @param spec - The node's spec configuration + * @returns Array of field names relevant for the current configuration + */ +export function getContextAwareOutputFields( + nodeType: NodeType, + spec?: Record | null +): string[] { + const staticFields = NODE_OUTPUT_FIELDS[nodeType] || ['result']; + const conditions = CONDITIONAL_OUTPUT_FIELDS[nodeType]; + + // No conditions for this node type - return all fields + if (!conditions || !spec) { + return staticFields; + } + + // Filter fields based on conditions + return staticFields.filter(field => { + const fieldConditions = conditions[field]; + // No conditions for this field - always include + if (!fieldConditions) return true; + + // Check if current spec values satisfy any condition + for (const [specField, allowedValues] of Object.entries(fieldConditions)) { + const specValue = spec[specField]; + if (specValue !== undefined && (allowedValues as unknown[]).includes(specValue)) { + return true; + } + } + return false; + }); +} +""" + + # Generate node type literal + node_types_literal = "/**\n * All valid node types\n */\n" + node_types_literal += "export type NodeType = " + " | ".join(f"'{t}'" for t in node_specs.keys()) + ";\n" + + # Combine all + content = header + content += "\n".join(all_interfaces) + content += "\n\n" + content += discriminated_union + content += "\n" + content += node_types_literal + content += "\n" + content += validation_export + content += output_fields_export + content += conditional_export + content += helper_function + + # Write output + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content) + print(f"Generated: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/backend/admin-dashboard/src/App.css b/backend/admin-dashboard/src/App.css new file mode 100644 index 0000000..74b5e05 --- /dev/null +++ b/backend/admin-dashboard/src/App.css @@ -0,0 +1,38 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/backend/admin-dashboard/src/App.tsx b/backend/admin-dashboard/src/App.tsx new file mode 100644 index 0000000..44e9340 --- /dev/null +++ b/backend/admin-dashboard/src/App.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ProtectedRoute } from './components/auth/ProtectedRoute'; +import { Unauthorized } from './components/auth/Unauthorized'; +import { LoginRedirect } from './components/auth/LoginRedirect'; +import { AdminLayout } from './components/layout/AdminLayout'; +import { ServicesDashboard } from './components/dashboard/ServicesDashboard'; +import { FrameworkDashboard } from './pages/FrameworkDashboard'; +import { ProgressiveAnalysisTree } from './components/framework/ProgressiveAnalysisTree'; +import { UserManagement } from './components/UserManagement'; +import { AnalysisHistory } from './components/AnalysisHistory'; +import { ProvidersManagement } from './components/ProvidersManagement'; +import { LLMComponentsConfig } from './components/LLMComponentsConfig'; +import { PipelinesPage } from './components/Pipelines/PipelinesPage'; +import { ModerationQueue } from './components/Moderation/ModerationQueue'; +import { ModerationDetail } from './components/Moderation/ModerationDetail'; +import { ModerationStats } from './components/Moderation/ModerationStats'; + +const DefaultLanding: React.FC = () => { + const { hasRole } = useAuth(); + if (hasRole('admin')) return ; + return ; +}; + +function App() { + return ( + + + + + } /> + } /> + + {/* AdminLayout requires staff role (admin / moderator / senior_moderator). + End users (viewer / free_tier / paid_tier / etc.) get 403. */} + + + + } + > + } /> + } /> + + {/* Admin-only pages — require 'admin' role */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* History — visible to admin + moderators (audit trail) */} + + + + } + /> + + {/* Moderation — visible to all staff (admin/moderator/senior_moderator) */} + + + + } + /> + + + + } + /> + + + + } + /> + + + } /> + + + + + ); +} + +export default App; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/helpers.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/helpers.tsx new file mode 100644 index 0000000..5de64e0 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/helpers.tsx @@ -0,0 +1,84 @@ +/** + * AnalysisDetailModal — color maps, cost calc, layout primitives + */ + +import React from 'react'; +import { Box } from '@mui/material'; +import type { ModelPricing } from './types'; + +export const REDIS_COLOR_MAP: Record = { + green: '#22c55e', + lightgreen: '#84cc16', + yellow: '#eab308', + orange: '#f97316', + red: '#ef4444', + darkred: '#7f1d1d', +}; + +export const getRiskColor = (verdict: any): string => { + if (verdict?.risk_level_color && REDIS_COLOR_MAP[verdict.risk_level_color]) { + return REDIS_COLOR_MAP[verdict.risk_level_color]; + } + switch (verdict?.risk_level) { + case 'VERY_LOW': + case 'LOW': return '#22c55e'; + case 'MEDIUM': return '#eab308'; + case 'HIGH': return '#f97316'; + case 'VERY_HIGH': return '#ef4444'; + case 'CRITICAL': return '#7f1d1d'; + default: return '#9e9e9e'; + } +}; + +export const getCategoryColor = (verdict: any): string => { + if (verdict?.risk_category_color && REDIS_COLOR_MAP[verdict.risk_category_color]) { + return REDIS_COLOR_MAP[verdict.risk_category_color]; + } + switch (verdict?.risk_category) { + case 'RELIABLE': return '#22c55e'; + case 'MOSTLY_RELIABLE': return '#84cc16'; + case 'MIXED': return '#eab308'; + case 'QUESTIONABLE': return '#f97316'; + case 'UNRELIABLE': return '#ef4444'; + case 'DISINFORMATION': return '#7f1d1d'; + default: return '#9e9e9e'; + } +}; + +/** Normalize model_key to lookup key used for pricing map. Strips provider prefix. */ +export function normalizeModelKey(key: string): string { + if (!key) return ''; + // "openrouter:google/gemini-3-flash-preview" → "google/gemini-3-flash-preview" + const colonIdx = key.indexOf(':'); + return colonIdx >= 0 ? key.slice(colonIdx + 1) : key; +} + +export function computeTokenCost( + promptTokens: number, + completionTokens: number, + pricing: ModelPricing | undefined, +): number { + if (!pricing) return 0; + return (promptTokens / 1_000_000) * pricing.input_per_1m + (completionTokens / 1_000_000) * pricing.output_per_1m; +} + +export function formatUsd(amount: number): string { + if (amount === 0) return '$0.00'; + if (amount < 0.0001) return `<$0.0001`; + if (amount < 0.01) return `$${amount.toFixed(5)}`; + return `$${amount.toFixed(4)}`; +} + +export const formatDate = (dateStr: string | null) => + dateStr ? new Date(dateStr).toLocaleString() : '-'; + +export const formatDuration = (ms: number | null | undefined) => { + if (!ms) return '-'; + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`; +}; + +export const GridRow: React.FC<{ children: React.ReactNode; columns?: number; sx?: any }> = ({ children, columns = 4, sx = {} }) => ( + {children} +); + +export const GridCell: React.FC<{ children: React.ReactNode }> = ({ children }) => {children}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/index.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/index.tsx new file mode 100644 index 0000000..d64f9ee --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/index.tsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + Box, Typography, Chip, CircularProgress, Alert, +} from '@mui/material'; +import type { AnalysisDetailModalProps, AnalysisDetail, PricingMap } from './types'; +import { getRiskColor } from './helpers'; +import { SessionInfoSection, InputContentSection } from './sections/meta'; +import { VerdictSection } from './sections/verdict'; +import { TechniquesSection, AISection } from './sections/components'; +import { ClaimsSection } from './sections/claims'; +import { SourceAssessmentSection, LegacyDomainSection } from './sections/source'; +import { LLMUsageSection } from './sections/llm-usage'; + +const API_BASE = ''; + +export const AnalysisDetailModal: React.FC = ({ open, sessionId, onClose }) => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pricingMap, setPricingMap] = useState({}); + + useEffect(() => { + if (!open || !sessionId) return; + + const fetchDetails = async () => { + setLoading(true); + setError(null); + try { + const token = localStorage.getItem('keycloak_token'); + const [detailRes, modelsRes] = await Promise.all([ + fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionId}`, { + headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' }, + }), + fetch(`${API_BASE}/framework/api/providers/models`, { + headers: { Authorization: token ? `Bearer ${token}` : '', 'Content-Type': 'application/json' }, + }), + ]); + if (!detailRes.ok) throw new Error('Failed to fetch analysis details'); + const result = await detailRes.json(); + setData(result.data); + + // Build pricing map from model_code → {input_per_1m, output_per_1m} + if (modelsRes.ok) { + const modelsData = await modelsRes.json(); + const map: PricingMap = {}; + for (const m of modelsData.data || []) { + map[m.model_code] = { + input_per_1m: parseFloat(m.input_cost_per_1m) || 0, + output_per_1m: parseFloat(m.output_cost_per_1m) || 0, + }; + } + setPricingMap(map); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + fetchDetails(); + }, [open, sessionId]); + + return ( + + + + Analysis Details + {data?.verdict && ( + + )} + + + + {loading && } + {error && {error}} + + {data && !loading && ( + + + + + + + + + + + + )} + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/claims.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/claims.tsx new file mode 100644 index 0000000..557df5f --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/claims.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { + Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + CheckCircle as CheckCircleIcon, + Cancel as CancelIcon, + Help as HelpIcon, + FactCheck as FactCheckIcon, + OpenInNew as OpenInNewIcon, + Search as SearchIcon, +} from '@mui/icons-material'; +import type { AnalysisDetail } from '../types'; +import { GridRow, REDIS_COLOR_MAP } from '../helpers'; + +export const ClaimsSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (!data.claims) return null; + return ( + + }> + + Claims Verification ({data.claims.total_claims} claims) + + + + + + {data.claims.verified_true} + Verified True + + + + {data.claims.verified_false} + Verified False + + + + {data.claims.unverified} + Unverified + + + {Math.round(Number(data.claims.credibility_score))}% + Credibility + + + {/* Meta info */} + + {data.claims.interpretation && ( + + )} + {data.claims.web_searches_made != null && ( + } label={`${data.claims.web_searches_made} web searches`} size="small" variant="outlined" /> + )} + {data.claims.llm_extraction && } + {data.claims.llm_verification && } + {data.claims.claims_by_status && ( + + {Object.entries(data.claims.claims_by_status).map(([status, count]) => ( + + ))} + + )} + + {/* Individual claims */} + {data.claims.claims_verified?.length > 0 && ( + + {data.claims.claims_verified.map((claim: any, idx: number) => ( + + + + {claim.text || claim.claim} + + + + {claim.type_name && } + {claim.priority && } + + + {claim.context && ( + + {claim.context} + + )} + {/* Confidence & agreement */} + + {claim.confidence != null && ( + + Confidence: {claim.confidence}% + + )} + {claim.agreement_score != null && ( + + Agreement: {claim.agreement_score}% + + )} + {claim.verification_method && ( + + Method: {claim.verification_method} + + )} + + {/* Reasoning */} + {claim.reasoning && ( + + {claim.reasoning} + + )} + {/* Sources */} + {claim.sources?.length > 0 && ( + + + Sources ({claim.sources.length}): + + {claim.sources.map((src: any, si: number) => ( + + + + + + {src.url} + + + {src.reliability && } + + {src.relevant_quote && ( + + "{src.relevant_quote}" + + )} + + + ))} + + )} + + ))} + + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/components.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/components.tsx new file mode 100644 index 0000000..9aad394 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/components.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { + Box, Typography, Chip, Alert, Accordion, AccordionSummary, AccordionDetails, + List, ListItem, ListItemText, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + Psychology as PsychologyIcon, +} from '@mui/icons-material'; +import type { AnalysisDetail } from '../types'; +import { GridRow, GridCell, formatDuration } from '../helpers'; + +export const TechniquesSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (!data.techniques) return null; + return ( + + }> + + + Manipulation Techniques ({data.techniques.techniques_count} detected) + + + + + + Manipulation Score + {Math.round(Number(data.techniques.manipulation_score))}% + + + Total Severity + {data.techniques.total_severity} + + + Duration + {formatDuration(data.techniques.total_duration_ms || data.techniques.duration_ms?.total)} + + + {(data.techniques.llm_screening || data.techniques.llm_deep) && ( + + {data.techniques.llm_screening && } + {data.techniques.llm_deep && } + + )} + {data.techniques.dimensions_affected?.length > 0 && ( + + Dimensions Affected + + {data.techniques.dimensions_affected.map((dim: string) => )} + + + )} + {data.techniques.techniques_detected?.length > 0 && ( + + {data.techniques.techniques_detected.map((tech: any, idx: number) => ( + + + {tech.severity && 3 ? 'error' : 'warning'} />} + + ))} + + )} + + + ); +}; + +export const AISection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (!data.ai_tampered) return null; + return ( + + }> + + AI Content Detection - {data.ai_tampered.verdict} + + + + + AI Probability + {Math.round(Number(data.ai_tampered.ai_probability))}% + + + Indicators Found + {data.ai_tampered.indicators_count} + + + Duration + {formatDuration(data.ai_tampered.total_duration_ms || data.ai_tampered.duration_ms?.total)} + + + {(data.ai_tampered.llm_screening || data.ai_tampered.llm_deep) && ( + + {data.ai_tampered.llm_screening && } + {data.ai_tampered.llm_deep && } + + )} + {data.ai_tampered.disclosure_detected && ( + AI Disclosure detected: {data.ai_tampered.disclosure_text} + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/llm-usage.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/llm-usage.tsx new file mode 100644 index 0000000..f87e085 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/llm-usage.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { + Box, Typography, Paper, Chip, Accordion, AccordionSummary, AccordionDetails, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + GeneratingTokens as TokenIcon, + AttachMoney as MoneyIcon, +} from '@mui/icons-material'; +import type { AnalysisDetail, PricingMap } from '../types'; +import { GridRow, normalizeModelKey, computeTokenCost, formatUsd } from '../helpers'; + +interface Props { + data: AnalysisDetail; + pricingMap: PricingMap; +} + +export const LLMUsageSection: React.FC = ({ data, pricingMap }) => { + if (!data.llm_usage) return null; + + // Compute cost per component + grand total. We don't have per-model token split + // in by_component, so we charge all tokens at the FIRST model used by that + // component (good enough for single-model primary path). + const byComponent = data.llm_usage.by_component || {}; + const componentCosts: Record = {}; + let totalCost = 0; + for (const [comp, usage] of Object.entries(byComponent) as [string, any][]) { + const firstModel = usage.models_used?.[0]; + const pricingKey = firstModel ? normalizeModelKey(firstModel) : ''; + const pricing = pricingKey ? pricingMap[pricingKey] : undefined; + const cost = computeTokenCost( + usage.prompt_tokens || 0, + usage.completion_tokens || 0, + pricing, + ); + componentCosts[comp] = { cost, model: firstModel || null }; + totalCost += cost; + } + + return ( + + }> + + + LLM Usage — {data.llm_usage.total?.total_tokens?.toLocaleString() || 0} tokens ({data.llm_usage.total?.calls || 0} calls) + + } + label={formatUsd(totalCost)} + size="small" + color={totalCost === 0 ? 'success' : 'warning'} + sx={{ mr: 2 }} + /> + + + + + {data.llm_usage.total?.calls || 0} + Total Calls + + + {(data.llm_usage.total?.prompt_tokens || 0).toLocaleString()} + Prompt Tokens + + + {(data.llm_usage.total?.completion_tokens || 0).toLocaleString()} + Completion Tokens + + + {(data.llm_usage.total?.total_tokens || 0).toLocaleString()} + Total Tokens + + + + {formatUsd(totalCost)} + + Est. Cost + + + {Object.keys(byComponent).length > 0 && ( + + Per Component + + {Object.entries(byComponent).map(([comp, usage]: [string, any]) => { + const cc = componentCosts[comp]; + return ( + + {comp.replace('_', ' ')} + {(usage.total_tokens || 0).toLocaleString()} + + {usage.calls} calls • {(usage.prompt_tokens || 0).toLocaleString()} in / {(usage.completion_tokens || 0).toLocaleString()} out + + + } + label={formatUsd(cc.cost)} + size="small" + color={cc.cost === 0 ? 'success' : 'warning'} + variant="outlined" + sx={{ height: 22, fontSize: 11 }} + /> + + {usage.models_used?.length > 0 && ( + + {usage.models_used.map((m: string) => ( + + ))} + + )} + + ); + })} + + + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/meta.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/meta.tsx new file mode 100644 index 0000000..a1f8031 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/meta.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Typography, Paper, Accordion, AccordionSummary, AccordionDetails } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material'; +import type { AnalysisDetail } from '../types'; +import { GridRow, GridCell, formatDate, formatDuration } from '../helpers'; + +export const SessionInfoSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => ( + + + + Session ID + {data.session_id} + + + User + {data.user_email || data.user_id} + + + + + Started + {formatDate(data.started_at)} + + + Duration + {formatDuration(data.total_duration_ms)} + + + Type + {data.input_type} + + + Source + {data.source_app} + + + +); + +export const InputContentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => ( + + }> + Input Content + + + {data.input_text && ( + + + {data.input_text} + + + )} + {data.input_url && URL: {data.input_url}} + {data.input_media_url && Media: {data.input_media_url}} + + +); diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/source.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/source.tsx new file mode 100644 index 0000000..0213eba --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/source.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { + Box, Typography, Paper, Chip, Alert, Accordion, AccordionSummary, AccordionDetails, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + Language as LanguageIcon, + Search as SearchIcon, +} from '@mui/icons-material'; +import type { AnalysisDetail } from '../types'; +import { GridRow, formatDuration } from '../helpers'; + +export const SourceAssessmentSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (!data.source_assessment) return null; + const sa = data.source_assessment; + return ( + + }> + + Source Assessment + + + + + = 60 ? '#22c55e' : sa.trust_score >= 30 ? '#f97316' : '#ef4444' }}> + {sa.trust_score} + + Trust Score + + + {sa.verdict} + Verdict + + + {sa.risk_level} + Risk Level + + + {/* 4 Axes */} + Assessment Axes + + {sa.publication && ( + + Publication ({sa.formula?.publication_weight ? `${Math.round(sa.formula.publication_weight * 100)}%` : '35%'}) + {sa.publication.score} + {sa.publication.name || '-'} + {sa.publication.source_type} + {sa.publication.confirmed && } + + )} + {sa.domain && ( + + Domain ({sa.formula?.domain_weight ? `${Math.round(sa.formula.domain_weight * 100)}%` : '25%'}) + {sa.domain.score} + {sa.domain.name || '-'} + {sa.domain.is_blacklisted && } + {sa.domain.has_ssl != null && ( + + )} + + )} + {sa.author && ( + + Author ({sa.formula?.author_weight ? `${Math.round(sa.formula.author_weight * 100)}%` : '25%'}) + {sa.author.score} + {sa.author.name || '-'} + {sa.author.classification} + {sa.author.confirmed && } + + )} + {sa.platform && ( + + Platform ({sa.formula?.platform_weight ? `${Math.round(sa.formula.platform_weight * 100)}%` : '15%'}) + {sa.platform.score} + {sa.platform.name || '-'} + + )} + + {sa.formula?.breakdown && ( + + Formula: + {sa.formula.breakdown} + + )} + {/* Meta info */} + + {sa.llm_model_used && } + {sa.search_results_count != null && ( + } label={`${sa.search_results_count} search results`} size="small" variant="outlined" /> + )} + {sa.duration_ms != null && } + + {sa.red_flags?.length > 0 && ( + + Red Flags: +
    + {sa.red_flags.map((flag: string, idx: number) =>
  • {flag}
  • )} +
+
+ )} + {sa.warnings?.length > 0 && ( + + Warnings: +
    + {sa.warnings.map((w: string, idx: number) =>
  • {w}
  • )} +
+
+ )} +
+
+ ); +}; + +export const LegacyDomainSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (data.source_assessment || !data.domain) return null; + const d = data.domain; + return ( + + }> + + Domain Analysis - {d.domain} + + + + + = 60 ? '#22c55e' : d.trust_score >= 30 ? '#f97316' : '#ef4444' }}> + {d.trust_score} + + Trust Score + + + {d.verdict} + Verdict + + + {d.age_days != null ? `${d.age_days} days` : '-'} + Age + + + + {d.has_ssl ? (d.ssl_valid ? 'Valid' : 'Invalid') : 'None'} + + SSL + + + + {d.risk_level && } + {d.is_blacklisted && } + {d.registrar && } + {d.country && } + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/verdict.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/verdict.tsx new file mode 100644 index 0000000..8258bc6 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/sections/verdict.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { + Box, Typography, Paper, Alert, Accordion, AccordionSummary, AccordionDetails, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + Assessment as AssessmentIcon, +} from '@mui/icons-material'; +import type { AnalysisDetail } from '../types'; +import { GridRow, getRiskColor, getCategoryColor } from '../helpers'; + +export const VerdictSection: React.FC<{ data: AnalysisDetail }> = ({ data }) => { + if (!data.verdict) return null; + return ( + + }> + + Final Verdict + + + + + {data.verdict.risk_score} + Risk Score + + + {data.verdict.risk_category} + Category + + + {data.verdict.confidence}% + Confidence ({data.verdict.confidence_level}) + + + {data.verdict.recommended_action} + Action + + + {(data.verdict.score_manipulation != null || data.verdict.score_claims != null || data.verdict.score_ai != null || data.verdict.score_source != null || data.verdict.score_context != null || data.verdict.component_scores) && ( + + Component Scores + + {data.verdict.component_scores + ? Object.entries(data.verdict.component_scores).map(([key, value]) => ( + + + {value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'} + + {key} + + )) + : [ + { key: 'manipulation', value: data.verdict.score_manipulation }, + { key: 'claims', value: data.verdict.score_claims }, + { key: 'ai', value: data.verdict.score_ai }, + { key: 'source', value: data.verdict.score_source }, + { key: 'context', value: data.verdict.score_context }, + ].map(({ key, value }) => ( + + + {value !== null && Number(value) >= 0 ? Math.round(Number(value)) : '-'} + + {key} + + )) + } + + + )} + {(data.verdict.explanation_ro || data.verdict.explanation_en) && ( + + Explanation + {data.verdict.explanation_ro && ( + + RO + {data.verdict.explanation_ro} + + )} + {data.verdict.explanation_en && ( + + EN + {data.verdict.explanation_en} + + )} + + )} + {data.verdict.override_applied && ( + + Override Applied: {data.verdict.override_type} + {data.verdict.override_reason && <> — {data.verdict.override_reason}} + {data.verdict.override_adjustment != null && <> (adjustment: {data.verdict.override_adjustment})} + + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/types.ts b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/types.ts new file mode 100644 index 0000000..2a747f9 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisDetailModal/types.ts @@ -0,0 +1,40 @@ +/** + * AnalysisDetailModal — types + */ + +export interface AnalysisDetailModalProps { + open: boolean; + sessionId: string; + onClose: () => void; +} + +export interface AnalysisDetail { + session_id: string; + user_id: string; + user_email: string | null; + input_type: string; + input_text: string | null; + input_url: string | null; + input_media_url: string | null; + status: string; + started_at: string; + completed_at: string | null; + total_duration_ms: number | null; + source_app: string; + components_run: string[] | null; + components_skipped: string[] | null; + verdict: any | null; + techniques: any | null; + ai_tampered: any | null; + claims: any | null; + domain: any | null; + source_assessment: any | null; + llm_usage: any | null; +} + +export interface ModelPricing { + input_per_1m: number; + output_per_1m: number; +} + +export type PricingMap = Record; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisHistory.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisHistory.tsx new file mode 100644 index 0000000..c40aa7b --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/AnalysisHistory.tsx @@ -0,0 +1,297 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Container, Typography, Button, Alert, + Dialog, DialogTitle, DialogContent, DialogActions, +} from '@mui/material'; +import { copyToClipboard } from '../../utils/clipboard'; +import { AnalysisDetailModal } from './AnalysisDetailModal'; +import type { AnalysisSession, PaginationInfo } from './history-types'; +import { HistoryFilters } from './HistoryFilters'; +import { HistoryTable } from './HistoryTable'; +import { SocialPostModal } from './SocialPostModal'; + +const API_BASE = ''; + +export const AnalysisHistory: React.FC = () => { + const [analyses, setAnalyses] = useState([]); + const [loading, setLoading] = useState(true); + const [copiedId, setCopiedId] = useState(null); + const [error, setError] = useState(null); + const [pagination, setPagination] = useState({ + page: 1, + limit: 20, + total: 0, + total_pages: 0, + has_next: false, + has_prev: false, + }); + + // Filters + const [searchFilter, setSearchFilter] = useState(''); + const [riskLevelFilter, setRiskLevelFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + + // UI State + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [selectedSession, setSelectedSession] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [sessionToDelete, setSessionToDelete] = useState(null); + const [socialPostOpen, setSocialPostOpen] = useState(false); + const [socialPostSession, setSocialPostSession] = useState(null); + + const handleCopyId = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + copyToClipboard(id).then(() => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); }); + }; + + const getAuthHeader = () => { + const token = localStorage.getItem('keycloak_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + }; + + const fetchAnalyses = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + page: pagination.page.toString(), + limit: pagination.limit.toString(), + }); + + if (searchFilter) params.append('search', searchFilter); + if (riskLevelFilter) params.append('risk_level', riskLevelFilter); + if (statusFilter) params.append('status', statusFilter); + if (fromDate) params.append('from_date', fromDate); + if (toDate) params.append('to_date', toDate); + + const response = await fetch(`${API_BASE}/agent-v3/api/v3/pipeline/history/admin?${params}`, { + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch analyses'); + } + + const data = await response.json(); + setAnalyses(data.data?.items || []); + setPagination(prev => ({ + ...prev, + total: data.data?.pagination?.total || 0, + total_pages: data.data?.pagination?.total_pages || 0, + has_next: data.data?.pagination?.has_next || false, + has_prev: data.data?.pagination?.has_prev || false, + })); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [pagination.page, pagination.limit, searchFilter, riskLevelFilter, statusFilter, fromDate, toDate]); + + useEffect(() => { + fetchAnalyses(); + }, [fetchAnalyses]); + + const handlePageChange = (_event: unknown, newPage: number) => { + setPagination(prev => ({ ...prev, page: newPage + 1 })); + }; + + const handleRowsPerPageChange = (event: React.ChangeEvent) => { + setPagination(prev => ({ ...prev, limit: parseInt(event.target.value, 10), page: 1 })); + }; + + const handleViewDetails = (sessionId: string) => { + setSelectedSession(sessionId); + setDetailModalOpen(true); + }; + + const handleDeleteClick = (session: AnalysisSession) => { + setSessionToDelete(session); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!sessionToDelete) return; + + try { + const response = await fetch( + `${API_BASE}/agent-v3/api/v3/pipeline/history/admin/${sessionToDelete.session_id}`, + { + method: 'DELETE', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Failed to delete analysis'); + } + + fetchAnalyses(); + } catch (err: any) { + setError(err.message); + } finally { + setDeleteDialogOpen(false); + setSessionToDelete(null); + } + }; + + const handleCancel = async (session: AnalysisSession) => { + try { + const response = await fetch( + `${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/cancel`, + { + method: 'POST', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + // user_id sent for staging fallback; JWT identity used when present. + body: JSON.stringify({ user_id: session.user_id }), + } + ); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || 'Failed to cancel analysis'); + } + fetchAnalyses(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleResume = async (session: AnalysisSession) => { + try { + const response = await fetch( + `${API_BASE}/agent-v3/api/v3/pipeline/${session.session_id}/resume`, + { + method: 'POST', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ user_id: session.user_id }), + } + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + // 409 = expired payload / already complete — surface the reason. + throw new Error(body.error || 'Failed to resume analysis'); + } + fetchAnalyses(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleClearFilters = () => { + setSearchFilter(''); + setRiskLevelFilter(''); + setStatusFilter(''); + setFromDate(''); + setToDate(''); + setPagination(prev => ({ ...prev, page: 1 })); + }; + + const hasActiveFilters = searchFilter || riskLevelFilter || statusFilter || fromDate || toDate; + + // Filter changes reset page to 1 + const onSearchChange = (v: string) => { setSearchFilter(v); setPagination(p => ({ ...p, page: 1 })); }; + const onRiskLevelChange = (v: string) => { setRiskLevelFilter(v); setPagination(p => ({ ...p, page: 1 })); }; + const onStatusChange = (v: string) => { setStatusFilter(v); setPagination(p => ({ ...p, page: 1 })); }; + const onFromDateChange = (v: string) => { setFromDate(v); setPagination(p => ({ ...p, page: 1 })); }; + const onToDateChange = (v: string) => { setToDate(v); setPagination(p => ({ ...p, page: 1 })); }; + + return ( + + Analysis History + + Browse and manage analysis sessions + + + {error && ( + setError(null)}> + {error} + + )} + + + + { setSocialPostSession(s); setSocialPostOpen(true); }} + onCopyId={handleCopyId} + /> + + {/* Detail Modal */} + {selectedSession && ( + { + setDetailModalOpen(false); + setSelectedSession(null); + }} + /> + )} + + {/* Social Post Modal */} + { + setSocialPostOpen(false); + setSocialPostSession(null); + }} + /> + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Delete Analysis + + Are you sure you want to delete this analysis? + + Session: {sessionToDelete?.session_id} + + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/HistoryFilters.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/HistoryFilters.tsx new file mode 100644 index 0000000..6bc9227 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/HistoryFilters.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { + Box, Paper, Button, TextField, InputAdornment, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material'; +import { + Search as SearchIcon, + Refresh as RefreshIcon, + FilterAltOff as ClearIcon, +} from '@mui/icons-material'; + +interface Props { + searchFilter: string; + riskLevelFilter: string; + statusFilter: string; + fromDate: string; + toDate: string; + hasActiveFilters: string | boolean; + onSearchChange: (value: string) => void; + onRiskLevelChange: (value: string) => void; + onStatusChange: (value: string) => void; + onFromDateChange: (value: string) => void; + onToDateChange: (value: string) => void; + onRefresh: () => void; + onClearFilters: () => void; +} + +export const HistoryFilters: React.FC = ({ + searchFilter, + riskLevelFilter, + statusFilter, + fromDate, + toDate, + hasActiveFilters, + onSearchChange, + onRiskLevelChange, + onStatusChange, + onFromDateChange, + onToDateChange, + onRefresh, + onClearFilters, +}) => ( + + + onSearchChange(e.target.value)} + size="small" + sx={{ minWidth: 220 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + Risk Level + + + + + Status + + + + onFromDateChange(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 150 }} + /> + + onToDateChange(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 150 }} + /> + + + + {hasActiveFilters && ( + + )} + + +); diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/HistoryTable.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/HistoryTable.tsx new file mode 100644 index 0000000..af017da --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/HistoryTable.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { + Box, Paper, Typography, Chip, IconButton, Tooltip, LinearProgress, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, +} from '@mui/material'; +import { + History as HistoryIcon, + Delete as DeleteIcon, + Visibility as VisibilityIcon, + ContentCopy as CopyIcon, + Facebook as FacebookIcon, + Cancel as CancelIcon, + Replay as ResumeIcon, +} from '@mui/icons-material'; +import type { AnalysisSession, PaginationInfo } from './history-types'; +import { getRiskIcon, getRiskCategoryColor, formatDuration, formatDate } from './history-helpers'; + +// Sessions still in flight can be canceled (agent-v3 /:id/cancel). +const CANCELABLE = new Set(['pending', 'running', 'processing']); +// Failed/canceled sessions can be resumed from checkpoint (/:id/resume). +const RESUMABLE = new Set(['failed', 'canceled']); + +interface Props { + loading: boolean; + analyses: AnalysisSession[]; + pagination: PaginationInfo; + copiedId: string | null; + onPageChange: (event: unknown, newPage: number) => void; + onRowsPerPageChange: (event: React.ChangeEvent) => void; + onViewDetails: (sessionId: string) => void; + onDeleteClick: (session: AnalysisSession) => void; + onCopyId: (e: React.MouseEvent, id: string) => void; + onSocialPostClick?: (session: AnalysisSession) => void; + onCancelClick?: (session: AnalysisSession) => void; + onResumeClick?: (session: AnalysisSession) => void; +} + +export const HistoryTable: React.FC = ({ + loading, + analyses, + pagination, + copiedId, + onPageChange, + onRowsPerPageChange, + onViewDetails, + onDeleteClick, + onCopyId, + onSocialPostClick, + onCancelClick, + onResumeClick, +}) => ( + + {loading && } + + + + + Session ID + Date + Email + Source + Risk + Techniques + AI % + Claims + Duration + Actions + + + + {!loading && analyses.length === 0 ? ( + + + + No analyses found + + + ) : ( + analyses.map((analysis) => ( + onViewDetails(analysis.session_id)} + > + e.stopPropagation()}> + + + + {analysis.session_id.substring(0, 8)}... + + + + onCopyId(e, analysis.session_id)}> + + + + + + + + {formatDate(analysis.started_at)} + + + {analysis.input_type} + + + + + + {analysis.user_email || No email} + + + + + + + + + {(analysis.source_verdict || analysis.domain) && ( + + {analysis.source_publication || analysis.domain || ''} + {(analysis.source_verdict || analysis.domain_verdict) && ( + + )} + + )} + + + {analysis.risk_score !== null ? ( + + + + ) : ( + + )} + + + {analysis.techniques_count !== null ? ( + 0 ? 'warning' : 'default'} + variant="outlined" + /> + ) : '-'} + + + {analysis.ai_probability !== null ? ( + + 50 ? 'warning' : 'default'} + variant="outlined" + /> + + ) : '-'} + + + {analysis.total_claims !== null ? ( + + + {analysis.total_claims} + + + ) : '-'} + + + + {formatDuration(analysis.total_duration_ms)} + + + e.stopPropagation()}> + onViewDetails(analysis.session_id)} + title="View details" + > + + + {onSocialPostClick && ( + onSocialPostClick(analysis)} + title="Post to Facebook" + sx={{ color: '#1877F2' }} + > + + + )} + {onCancelClick && CANCELABLE.has(analysis.status) && ( + onCancelClick(analysis)} + title="Cancel running analysis" + color="warning" + > + + + )} + {onResumeClick && RESUMABLE.has(analysis.status) && ( + onResumeClick(analysis)} + title="Resume from checkpoint (re-runs only unfinished components)" + color="primary" + > + + + )} + onDeleteClick(analysis)} + title="Delete" + color="error" + > + + + + + )) + )} + +
+
+ +
+); diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/SocialPostModal.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/SocialPostModal.tsx new file mode 100644 index 0000000..c414a70 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/SocialPostModal.tsx @@ -0,0 +1,423 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Box, TextField, Button, Alert, Chip, CircularProgress, + Typography, Stack, IconButton, Divider, Tooltip, Paper, Checkbox, + FormControlLabel, +} from '@mui/material'; +import { + Facebook as FacebookIcon, Close as CloseIcon, + Send as SendIcon, Schedule as ScheduleIcon, OpenInNew as OpenInNewIcon, + Visibility as PreviewIcon, +} from '@mui/icons-material'; +import axios from 'axios'; +import type { AnalysisSession } from './history-types'; + +const FRAMEWORK_BASE = process.env.REACT_APP_FRAMEWORK_URL || '/framework'; +const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3'; +const SHARE_BASE = 'https://didi365.eu/share'; + +type Status = 'idle' | 'loading' | 'editing' | 'publishing' | 'published' | 'error'; + +type Block = { + id: string; + label: string; + enabled: boolean; + content: string; +}; + +interface Props { + open: boolean; + onClose: () => void; + session: AnalysisSession | null; + onSuccess?: (postId: string, externalUrl: string) => void; +} + +const buildBlocks = (detail: any): Block[] => { + const risk = detail.risk_category || 'NEUTRAL'; + const score = detail.risk_score ?? 0; + const emoji = + score >= 80 ? '🚨' : score >= 60 ? '⚠️' : score >= 40 ? 'ℹ️' : '✅'; + + const tech = detail.techniques || {}; + const claims = detail.claims || {}; + const ai = detail.ai_tampered || {}; + const domain = detail.domain || {}; + const verdict = detail.verdict || {}; + + const techNames: string = (tech.techniques_detected || []) + .map((t: any) => t.name_ro || t.name_en || t.name) + .filter(Boolean) + .slice(0, 5) + .join(', '); + + const blocks: Block[] = [ + { + id: 'header', + label: 'Antet (verdict + scor)', + enabled: true, + content: `${emoji} DiDi · ${risk} · Scor risc ${score}/100`, + }, + { + id: 'verdict', + label: 'Explicație verdict', + enabled: !!verdict.explanation_ro, + content: (verdict.explanation_ro || verdict.explanation_en || '').trim(), + }, + { + id: 'techniques', + label: 'Tehnici de manipulare detectate', + enabled: (tech.techniques_count || 0) > 0, + content: techNames + ? `Tehnici de manipulare detectate (${tech.techniques_count}): ${techNames}\nScor manipulare: ${tech.manipulation_score ?? 0}/100` + : '', + }, + { + id: 'claims', + label: 'Afirmații verificate', + enabled: (claims.total_claims || 0) > 0, + content: claims.total_claims + ? `Afirmații verificate: ${claims.verified_true || 0} confirmate, ${claims.verified_false || 0} false (credibilitate ${claims.credibility_score ?? 0}/100)` + : '', + }, + { + id: 'ai', + label: 'Detectare conținut AI', + enabled: (ai.ai_probability || 0) >= 50, + content: + typeof ai.ai_probability === 'number' + ? `Probabilitate generat AI: ${ai.ai_probability}% (${ai.verdict || 'N/A'})` + : '', + }, + { + id: 'domain', + label: 'Sursă / Domeniu', + enabled: !!(domain.domain && domain.domain_verdict), + content: domain.domain + ? `Sursă: ${domain.domain} · ${domain.domain_verdict || ''} (trust ${domain.domain_trust_score ?? 0}/100)` + : '', + }, + { + id: 'hashtags', + label: 'Hashtags', + enabled: true, + content: '#DiDi #Antifake #VerificatDeDiDi #DESI', + }, + { + id: 'link', + label: 'Link analiză publică', + enabled: false, + content: detail.session_id + ? `Vezi analiza completă: ${SHARE_BASE}/${detail.session_id}` + : '', + }, + ]; + return blocks.filter((b) => b.content || b.id === 'header' || b.id === 'link'); +}; + +const joinBlocks = (blocks: Block[]): string => + blocks + .filter((b) => b.enabled && b.content.trim()) + .map((b) => b.content.trim()) + .join('\n\n'); + +export const SocialPostModal: React.FC = ({ open, onClose, session, onSuccess }) => { + const [status, setStatus] = useState('idle'); + const [blocks, setBlocks] = useState([]); + const [imageUrl, setImageUrl] = useState(''); + const [linkUrl, setLinkUrl] = useState(''); + const [scheduledAt, setScheduledAt] = useState(''); + const [error, setError] = useState(null); + const [postId, setPostId] = useState(null); + const [externalUrl, setExternalUrl] = useState(null); + + useEffect(() => { + if (!open || !session) return; + setStatus('loading'); + setBlocks([]); + setImageUrl(''); + setLinkUrl(session.session_id ? `${SHARE_BASE}/${session.session_id}` : ''); + setScheduledAt(''); + setError(null); + setPostId(null); + setExternalUrl(null); + + const token = localStorage.getItem('keycloak_token'); + axios + .get(`${AGENT_BASE}/api/v3/pipeline/history/admin/${session.session_id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + const detail = res.data?.data || res.data; + if (!detail) throw new Error('Răspuns gol de la API'); + setBlocks(buildBlocks(detail)); + setStatus('editing'); + }) + .catch((e) => { + setError(e?.response?.data?.error || e.message); + setStatus('error'); + }); + }, [open, session]); + + const previewText = useMemo(() => joinBlocks(blocks), [blocks]); + const charCount = previewText.length; + + const updateBlock = (id: string, patch: Partial) => { + setBlocks((prev) => prev.map((b) => (b.id === id ? { ...b, ...patch } : b))); + }; + + const handlePublish = async () => { + if (!previewText.trim()) { + setError('Niciun bloc bifat sau toate sunt goale'); + return; + } + setStatus('publishing'); + setError(null); + try { + const token = localStorage.getItem('keycloak_token'); + const draftRes = await axios.post( + `${FRAMEWORK_BASE}/api/admin/social/draft`, + { + session_id: session?.session_id, + content: previewText, + image_url: imageUrl || undefined, + link_url: linkUrl || undefined, + platform: 'facebook', + }, + { headers: { Authorization: `Bearer ${token}` } }, + ); + const createdId = draftRes.data?.data?.post_id?.toString(); + if (!createdId) throw new Error(draftRes.data?.error || 'Salvare draft eșuată'); + setPostId(createdId); + + const pubRes = await axios.post( + `${FRAMEWORK_BASE}/api/admin/social/publish/${createdId}`, + scheduledAt ? { scheduled_at: new Date(scheduledAt).toISOString() } : {}, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (pubRes.data?.success && pubRes.data.data) { + setExternalUrl(pubRes.data.data.external_url || null); + setStatus('published'); + if (onSuccess) onSuccess(pubRes.data.data.post_id, pubRes.data.data.external_url); + } else { + throw new Error(pubRes.data?.error || 'Publishing failed'); + } + } catch (e: any) { + setError(e?.response?.data?.error || e?.message || 'Publishing failed'); + setStatus('error'); + } + }; + + const minScheduleDate = new Date(Date.now() + 11 * 60 * 1000).toISOString().slice(0, 16); + const enabledCount = blocks.filter((b) => b.enabled && b.content.trim()).length; + + return ( + + + + Post to Facebook + + + + + + + + {status === 'loading' && ( + + + Se încarcă datele analizei... + + )} + + {(status === 'editing' || status === 'publishing' || status === 'error') && ( + + {session && ( + + Analiza {session.session_id.slice(0, 8)}... + {session.risk_score !== null && session.risk_score !== undefined && ( + <> · Risc: {session.risk_score} ({session.risk_category}) + )} + {' '}— bifează blocurile pe care vrei să le incluzi în post și editează textul. + + )} + + + Blocuri ({enabledCount} active din {blocks.length}) + + + + {blocks.map((block) => ( + + updateBlock(block.id, { enabled: e.target.checked })} + disabled={status === 'publishing'} + size="small" + /> + } + label={ + + {block.label} + + } + sx={{ mb: 0.5 }} + /> + updateBlock(block.id, { content: e.target.value })} + fullWidth + size="small" + disabled={!block.enabled || status === 'publishing'} + placeholder="(gol — nu va apărea în post)" + /> + + ))} + + + + + + + + Preview post · {charCount} caractere + {charCount > 500 && ( + + )} + + + + {previewText || (post gol — bifează cel puțin un bloc)} + + + setImageUrl(e.target.value)} + fullWidth + size="small" + disabled={status === 'publishing'} + placeholder="https://didi365.eu/share/image.png" + /> + setLinkUrl(e.target.value)} + fullWidth + size="small" + disabled={status === 'publishing'} + placeholder="https://didi365.eu/analyses/..." + /> + + + } + label={ + scheduledAt + ? `Programat: ${new Date(scheduledAt).toLocaleString('ro-RO')}` + : 'Publicare imediată' + } + color={scheduledAt ? 'warning' : 'default'} + onDelete={scheduledAt ? () => setScheduledAt('') : undefined} + /> + + setScheduledAt(e.target.value)} + inputProps={{ min: minScheduleDate }} + disabled={status === 'publishing'} + sx={{ width: 220 }} + /> + + + + {error && ( + setError(null)}> + {error} + + )} + + )} + + {status === 'published' && ( + + + {scheduledAt ? '✓ Programat cu success pe Facebook!' : '✓ Publicat cu success pe Facebook!'} + + {externalUrl && ( + + )} + + )} + + + + {status === 'published' ? ( + + ) : ( + <> + + + + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/history-helpers.tsx b/backend/admin-dashboard/src/components/AnalysisHistory/history-helpers.tsx new file mode 100644 index 0000000..c70aa30 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/history-helpers.tsx @@ -0,0 +1,73 @@ +/** + * AnalysisHistory — color helpers + formatters + */ + +import React from 'react'; +import { + CheckCircle as CheckCircleIcon, + Warning as WarningIcon, + Error as ErrorIcon, + Info as InfoIcon, +} from '@mui/icons-material'; + +export const getRiskColor = (level: string | null): 'success' | 'warning' | 'error' | 'default' => { + switch (level) { + case 'VERY_LOW': + case 'LOW': + return 'success'; + case 'MEDIUM': + case 'HIGH': + return 'warning'; + case 'VERY_HIGH': + case 'CRITICAL': + return 'error'; + default: + return 'default'; + } +}; + +export const getRiskIcon = (level: string | null): React.ReactElement | undefined => { + switch (level) { + case 'VERY_LOW': + case 'LOW': + return ; + case 'MEDIUM': + return ; + case 'HIGH': + case 'VERY_HIGH': + return ; + case 'CRITICAL': + return ; + default: + return undefined; + } +}; + +export const getRiskCategoryColor = (category: string | null): string => { + switch (category) { + case 'RELIABLE': return '#22c55e'; + case 'MOSTLY_RELIABLE': return '#84cc16'; + case 'MIXED': return '#eab308'; + case 'QUESTIONABLE': return '#f97316'; + case 'UNRELIABLE': return '#ef4444'; + case 'DISINFORMATION': return '#7f1d1d'; + default: return '#9e9e9e'; + } +}; + +export const formatDuration = (ms: number | null) => { + if (!ms) return '-'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +}; + +export const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleString('ro-RO', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/history-types.ts b/backend/admin-dashboard/src/components/AnalysisHistory/history-types.ts new file mode 100644 index 0000000..5937e47 --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/history-types.ts @@ -0,0 +1,48 @@ +/** + * AnalysisHistory — types + */ + +export interface AnalysisSession { + session_id: string; + user_id: string; + user_email: string | null; + input_type: string; + input_preview: string | null; + input_url: string | null; + status: string; + started_at: string; + completed_at: string | null; + total_duration_ms: number | null; + source_app: string; + risk_score: number | null; + risk_category: string | null; + risk_level: string | null; + confidence: number | null; + confidence_level: string | null; + techniques_score: string | null; + techniques_count: number | null; + ai_probability: string | null; + ai_verdict: string | null; + total_claims: number | null; + verified_true: number | null; + verified_false: number | null; + claims_score: string | null; + domain: string | null; + domain_verdict: string | null; + domain_trust_score: number | null; + source_trust_score: number | null; + source_verdict: string | null; + source_publication: string | null; + source_author: string | null; + source_platform: string | null; + llm_usage: any | null; +} + +export interface PaginationInfo { + page: number; + limit: number; + total: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} diff --git a/backend/admin-dashboard/src/components/AnalysisHistory/index.ts b/backend/admin-dashboard/src/components/AnalysisHistory/index.ts new file mode 100644 index 0000000..6aa478e --- /dev/null +++ b/backend/admin-dashboard/src/components/AnalysisHistory/index.ts @@ -0,0 +1,2 @@ +export { AnalysisHistory } from './AnalysisHistory'; +export { AnalysisDetailModal } from './AnalysisDetailModal'; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/api.ts b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/api.ts new file mode 100644 index 0000000..f32983a --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/api.ts @@ -0,0 +1,108 @@ +/** + * VerdictConfig — fetch wrappers + */ + +import type { + ComponentWeight, + Multiplier, + VerdictCategory, + RiskMapping, + SeverityAssessment, + VerdictConfigData, +} from './types'; + +const FRAMEWORK_API = '/framework/api'; +const AGENT_API = '/agent-v3/api/v3/pipeline'; + +export async function fetchWeights() { + const res = await fetch(`${FRAMEWORK_API}/weights/all`); + return res.json(); +} + +export async function fetchVerdicts() { + const res = await fetch(`${FRAMEWORK_API}/verdicts/all`); + return res.json(); +} + +export async function fetchVerdictConfig(): Promise<{ success: boolean; data: VerdictConfigData | null }> { + const res = await fetch(`${AGENT_API}/verdict-config`); + return res.json(); +} + +export async function updateComponentWeight(id: number, data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/weights/components/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function createMultiplier(data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/weights/multipliers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateMultiplier(id: number, data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function deleteMultiplier(id: number) { + const res = await fetch(`${FRAMEWORK_API}/weights/multipliers/${id}`, { method: 'DELETE' }); + return res.json(); +} + +export async function updateVerdictCategory(id: number, data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/verdicts/categories/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateRiskMapping(id: number, data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/verdicts/risk/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateSeverity(id: string, data: Partial) { + const res = await fetch(`${FRAMEWORK_API}/verdicts/severity/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function fetchRuntimeConfig(): Promise<{ success: boolean; data: VerdictConfigData | null; error?: string }> { + const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`); + return res.json(); +} + +export async function updateRuntimeConfig(data: VerdictConfigData) { + const res = await fetch(`${FRAMEWORK_API}/verdicts/runtime-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function syncRedis() { + const res = await fetch(`${FRAMEWORK_API}/sync-redis`, { method: 'POST' }); + return res.json(); +} diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/EntityDialogs.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/EntityDialogs.tsx new file mode 100644 index 0000000..120d8ad --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/EntityDialogs.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { + Box, Typography, Slider, + Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, +} from '@mui/material'; +import type { + ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment, + DeleteDialogState, +} from '../types'; + +type DialogState = { open: boolean; data: T | null }; + +interface WeightDialogProps { + state: DialogState; + setState: React.Dispatch>>; + onSave: () => void; +} + +export const WeightDialog: React.FC = ({ state, setState, onSave }) => ( + setState({ open: false, data: null })} maxWidth="sm" fullWidth> + Edit Component Weight + + {state.data && ( + + + + Weight: {state.data.component_weight}% + setState({ ...state, data: { ...state.data!, component_weight: v as number } })} + min={0} + max={100} + step={1} + marks={[{ value: 0, label: '0%' }, { value: 25, label: '25%' }, { value: 50, label: '50%' }, { value: 75, label: '75%' }, { value: 100, label: '100%' }]} + /> + + setState({ ...state, data: { ...state.data!, description: e.target.value } })} + multiline + rows={2} + fullWidth + /> + + )} + + + + + + +); + +interface MultiplierDialogProps { + state: DialogState>; + setState: React.Dispatch>>>; + onSave: () => void; +} + +export const MultiplierDialog: React.FC = ({ state, setState, onSave }) => ( + setState({ open: false, data: null })} maxWidth="sm" fullWidth> + {state.data?.multiplier_id ? 'Edit Multiplier' : 'Add Topic Multiplier'} + + {state.data && ( + + setState({ ...state, data: { ...state.data!, multiplier_name: e.target.value } })} + fullWidth + required + placeholder="e.g. elections, health, geopolitics" + /> + + Multiplier: {(state.data.multiplier || 1).toFixed(2)}x + setState({ ...state, data: { ...state.data!, multiplier: v as number } })} + min={0.5} + max={2} + step={0.05} + marks={[{ value: 0.5, label: '0.5x' }, { value: 1, label: '1x' }, { value: 1.5, label: '1.5x' }, { value: 2, label: '2x' }]} + /> + + setState({ ...state, data: { ...state.data!, description: e.target.value } })} + multiline + rows={2} + fullWidth + /> + + )} + + + + + + +); + +interface CategoryDialogProps { + state: DialogState; + setState: React.Dispatch>>; + onSave: () => void; +} + +export const CategoryDialog: React.FC = ({ state, setState, onSave }) => ( + setState({ open: false, data: null })} maxWidth="sm" fullWidth> + Edit Verdict Category + + {state.data && ( + + setState({ ...state, data: { ...state.data!, verdict_category_code: e.target.value } })} fullWidth /> + setState({ ...state, data: { ...state.data!, description: e.target.value } })} fullWidth /> + + setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} /> + setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} /> + + setState({ ...state, data: { ...state.data!, verdict_category_color: e.target.value } })} fullWidth InputProps={{ startAdornment: }} /> + + )} + + + + + + +); + +interface RiskDialogProps { + state: DialogState; + setState: React.Dispatch>>; + onSave: () => void; +} + +export const RiskDialog: React.FC = ({ state, setState, onSave }) => ( + setState({ open: false, data: null })} maxWidth="sm" fullWidth> + Edit Risk Mapping + + {state.data && ( + + setState({ ...state, data: { ...state.data!, risk_mapping: e.target.value } })} fullWidth /> + setState({ ...state, data: { ...state.data!, risk_level: Number(e.target.value) } })} fullWidth /> + + setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} sx={{ flex: 1 }} /> + setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} sx={{ flex: 1 }} /> + + setState({ ...state, data: { ...state.data!, risk_color: e.target.value } })} fullWidth InputProps={{ startAdornment: }} /> + + )} + + + + + + +); + +interface SeverityDialogProps { + state: DialogState; + setState: React.Dispatch>>; + onSave: () => void; +} + +export const SeverityDialog: React.FC = ({ state, setState, onSave }) => ( + setState({ open: false, data: null })} maxWidth="sm" fullWidth> + Edit Severity Level + + {state.data && ( + + + setState({ ...state, data: { ...state.data!, severity_category: e.target.value } })} + fullWidth + helperText="LOW / MEDIUM / HIGH / CRITICAL" + /> + + setState({ ...state, data: { ...state.data!, start_range: Number(e.target.value) } })} + sx={{ flex: 1 }} + /> + setState({ ...state, data: { ...state.data!, end_range: Number(e.target.value) } })} + sx={{ flex: 1 }} + /> + + setState({ ...state, data: { ...state.data!, recomended_action: e.target.value } })} + fullWidth + helperText="MONITOR / REVIEW / ESCALATE / URGENT" + /> + + )} + + + + + + +); + +interface DeleteDialogProps { + state: DeleteDialogState | null; + onCancel: () => void; + onConfirm: () => void; +} + +export const DeleteDialog: React.FC = ({ state, onCancel, onConfirm }) => ( + + Confirm Delete + + Are you sure you want to delete {state?.name}? + + + + + + +); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/RuntimeConfigDialog.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/RuntimeConfigDialog.tsx new file mode 100644 index 0000000..7378be8 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/dialogs/RuntimeConfigDialog.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { + Box, Typography, + Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material'; +import type { VerdictConfigData } from '../types'; + +interface Props { + state: { open: boolean; data: VerdictConfigData | null }; + setState: React.Dispatch>; + updateField: (path: string[], value: unknown) => void; + onSave: () => void; +} + +export const RuntimeConfigDialog: React.FC = ({ state, setState, updateField, onSave }) => ( + setState({ open: false, data: null })} + maxWidth="md" + fullWidth + > + Edit Verdict Runtime Config + + {state.data && ( + + + Synergy Bonus + + + Enabled + + + updateField(['synergy', 'threshold'], Number(e.target.value))} /> + updateField(['synergy', 'bonus_per_component'], Number(e.target.value))} /> + updateField(['synergy', 'max_bonus'], Number(e.target.value))} /> + + + + + Override: False Claims + + + Enabled + + + updateField(['overrides', 'false_claims', 'threshold'], Number(e.target.value))} /> + updateField(['overrides', 'false_claims', 'bonus_per_claim'], Number(e.target.value))} /> + updateField(['overrides', 'false_claims', 'max_bonus'], Number(e.target.value))} /> + + + + + Override: Severe Techniques + + + Enabled + + + updateField(['overrides', 'severe_techniques', 'threshold'], Number(e.target.value))} /> + updateField(['overrides', 'severe_techniques', 'bonus'], Number(e.target.value))} /> + + + + + Override: Undisclosed AI + + + Enabled + + + updateField(['overrides', 'undisclosed_ai', 'bonus'], Number(e.target.value))} /> + + + + + Override: Untrusted Domain + + + Enabled + + + updateField(['overrides', 'untrusted_domain', 'untrusted_bonus'], Number(e.target.value))} /> + updateField(['overrides', 'untrusted_domain', 'suspicious_bonus'], Number(e.target.value))} /> + updateField(['overrides', 'untrusted_domain', 'blacklisted_bonus'], Number(e.target.value))} /> + + + + + Override: Domain Red Flags + + + Enabled + + + updateField(['overrides', 'domain_red_flags', 'threshold'], Number(e.target.value))} /> + updateField(['overrides', 'domain_red_flags', 'bonus_per_flag'], Number(e.target.value))} /> + updateField(['overrides', 'domain_red_flags', 'max_bonus'], Number(e.target.value))} /> + + + + + Confidence Bonuses + + updateField(['confidence', 'base_per_component'], Number(e.target.value))} /> + updateField(['confidence', 'domain_strong_signal_bonus'], Number(e.target.value))} /> + updateField(['confidence', 'domain_weak_signal_bonus'], Number(e.target.value))} /> + updateField(['confidence', 'techniques_bonus_max'], Number(e.target.value))} /> + updateField(['confidence', 'ai_high_confidence_bonus'], Number(e.target.value))} /> + updateField(['confidence', 'ai_medium_confidence_bonus'], Number(e.target.value))} /> + updateField(['confidence', 'ai_low_confidence_bonus'], Number(e.target.value))} /> + updateField(['confidence', 'claims_verified_bonus_max'], Number(e.target.value))} /> + + + + + Confidence Levels (min thresholds) + + updateField(['confidence_levels', 'HIGH', 'min'], Number(e.target.value))} /> + updateField(['confidence_levels', 'MEDIUM', 'min'], Number(e.target.value))} /> + updateField(['confidence_levels', 'LOW', 'min'], Number(e.target.value))} /> + + + + )} + + + + + + +); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/helpers.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/helpers.tsx new file mode 100644 index 0000000..7da2e26 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/helpers.tsx @@ -0,0 +1,64 @@ +/** + * VerdictConfig — shared visual helpers + */ + +import React from 'react'; +import { Box, Typography, Tooltip, Chip } from '@mui/material'; + +export const SEVERITY_COLORS: Record = { + LOW: '#4caf50', + MEDIUM: '#ff9800', + HIGH: '#f44336', + CRITICAL: '#b71c1c', +}; + +export const PROFILE_COLORS: Record = { + text_no_url: '#1976d2', + text_with_url: '#0288d1', + image: '#7b1fa2', + audio: '#e65100', + video: '#c62828', + url: '#2e7d32', +}; + +export const RangeBar: React.FC<{ + items: { start: number; end: number; color: string; label: string }[]; +}> = ({ items }) => ( + + {items.map((item, i) => { + const width = item.end - item.start + 1; + return ( + + + + {width > 8 ? item.label : ''} + + + + ); + })} + +); + +export const ConfigValue: React.FC<{ label: string; value: string | number | boolean }> = ({ label, value }) => ( + + {label} + + {typeof value === 'boolean' ? ( + + ) : ( + String(value) + )} + + +); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/index.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/index.tsx new file mode 100644 index 0000000..b9b401c --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/index.tsx @@ -0,0 +1,361 @@ +/** + * Verdict Configuration Component + * + * Manages Final Verdict settings across 8 tabs: + * 0. Component Weights (editable) + * 1. Verdict Categories (editable) + * 2. Risk Mappings (editable) + * 3. Severity & Actions (editable, syncs to Redis) + * 4. Overrides & Synergy (editable, from Redis verdict_config) + * 5. Confidence (editable, from Redis verdict_config) + * 6. Topic Multipliers (editable, only type=1 active) + * 7. Input Profiles (editable, per input type) + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress, +} from '@mui/material'; +import { + Refresh as RefreshIcon, + Balance as WeightsIcon, + Category as CategoryIcon, + Warning as RiskIcon, + TrendingUp as MultiplierIcon, + Shield as ShieldIcon, + Psychology as PsychologyIcon, + Dashboard as ProfilesIcon, +} from '@mui/icons-material'; + +import type { + ComponentWeight, Multiplier, VerdictCategory, RiskMapping, SeverityAssessment, + VerdictConfigData, DeleteDialogState, +} from './types'; +import { + fetchWeights, fetchVerdicts, fetchVerdictConfig, fetchRuntimeConfig, + updateComponentWeight, createMultiplier, updateMultiplier, deleteMultiplier, + updateVerdictCategory, updateRiskMapping, updateSeverity, updateRuntimeConfig, + syncRedis, +} from './api'; +import { WeightsTab } from './tabs/WeightsTab'; +import { CategoriesTab } from './tabs/CategoriesTab'; +import { RiskTab } from './tabs/RiskTab'; +import { SeverityTab } from './tabs/SeverityTab'; +import { OverridesTab } from './tabs/OverridesTab'; +import { ConfidenceTab } from './tabs/ConfidenceTab'; +import { MultipliersTab } from './tabs/MultipliersTab'; +import { + WeightDialog, MultiplierDialog, CategoryDialog, RiskDialog, SeverityDialog, DeleteDialog, +} from './dialogs/EntityDialogs'; +import { RuntimeConfigDialog } from './dialogs/RuntimeConfigDialog'; +import { InputProfilesPanel } from './panels/InputProfilesPanel'; + +export const VerdictConfig: React.FC = () => { + const [tabValue, setTabValue] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [componentWeights, setComponentWeights] = useState([]); + const [multipliers, setMultipliers] = useState([]); + const [verdictCategories, setVerdictCategories] = useState([]); + const [riskMappings, setRiskMappings] = useState([]); + const [severityAssessments, setSeverityAssessments] = useState([]); + const [verdictConfig, setVerdictConfig] = useState(null); + + const [weightDialog, setWeightDialog] = useState<{ open: boolean; data: ComponentWeight | null }>({ open: false, data: null }); + const [multiplierDialog, setMultiplierDialog] = useState<{ open: boolean; data: Partial | null }>({ open: false, data: null }); + const [categoryDialog, setCategoryDialog] = useState<{ open: boolean; data: VerdictCategory | null }>({ open: false, data: null }); + const [riskDialog, setRiskDialog] = useState<{ open: boolean; data: RiskMapping | null }>({ open: false, data: null }); + const [severityDialog, setSeverityDialog] = useState<{ open: boolean; data: SeverityAssessment | null }>({ open: false, data: null }); + const [runtimeConfigDialog, setRuntimeConfigDialog] = useState<{ open: boolean; data: VerdictConfigData | null }>({ open: false, data: null }); + const [deleteDialog, setDeleteDialog] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [weightsRes, verdictsRes, runtimeRes, redisRes] = await Promise.all([ + fetchWeights(), + fetchVerdicts(), + fetchRuntimeConfig(), + fetchVerdictConfig(), + ]); + + if (weightsRes.success) { + setComponentWeights(weightsRes.data.componentWeights || []); + setMultipliers(weightsRes.data.multipliers || []); + } + + if (verdictsRes.success) { + setVerdictCategories(verdictsRes.data.verdictCategories || []); + setRiskMappings(verdictsRes.data.riskMappings || []); + const sev = (verdictsRes.data.severityAssessments || []).map((s: SeverityAssessment) => ({ + ...s, + severity_id: typeof s.severity_id === 'string' ? s.severity_id.trim() : s.severity_id, + severity_category: typeof s.severity_category === 'string' ? s.severity_category.trim() : s.severity_category, + recomended_action: typeof s.recomended_action === 'string' ? s.recomended_action.trim() : s.recomended_action, + })); + setSeverityAssessments(sev); + } + + // Prefer runtime-config from PG (source of truth). Fall back to Redis read if PG row missing. + if (runtimeRes.success && runtimeRes.data) { + setVerdictConfig(runtimeRes.data); + } else if (redisRes.success && redisRes.data) { + setVerdictConfig(redisRes.data); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + const showSuccess = (msg: string) => { setSuccess(msg); setTimeout(() => setSuccess(null), 3000); }; + const showError = (msg: string) => { setError(msg); setTimeout(() => setError(null), 5000); }; + + const handleSaveWeight = async () => { + if (!weightDialog.data) return; + try { + const res = await updateComponentWeight(weightDialog.data.component_weight_id, weightDialog.data); + if (res.success) { + showSuccess('Weight updated successfully'); + setWeightDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const handleSaveMultiplier = async () => { + if (!multiplierDialog.data) return; + try { + const isEdit = 'multiplier_id' in multiplierDialog.data && multiplierDialog.data.multiplier_id; + const res = isEdit + ? await updateMultiplier(multiplierDialog.data.multiplier_id as number, multiplierDialog.data) + : await createMultiplier(multiplierDialog.data); + if (res.success) { + showSuccess(isEdit ? 'Multiplier updated' : 'Multiplier created'); + setMultiplierDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Operation failed'); + } + }; + + const handleDeleteMultiplier = async () => { + if (!deleteDialog || deleteDialog.type !== 'multiplier') return; + try { + const res = await deleteMultiplier(deleteDialog.id); + if (res.success) { + showSuccess('Multiplier deleted'); + setDeleteDialog(null); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleSaveCategory = async () => { + if (!categoryDialog.data) return; + try { + const res = await updateVerdictCategory(categoryDialog.data.verdict_category_id, categoryDialog.data); + if (res.success) { + showSuccess('Category updated'); + setCategoryDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const handleSaveRisk = async () => { + if (!riskDialog.data) return; + try { + const res = await updateRiskMapping(riskDialog.data.risk_mapping_id, riskDialog.data); + if (res.success) { + showSuccess('Risk mapping updated'); + setRiskDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const handleSyncRedis = async () => { + try { + const res = await syncRedis(); + if (res.success) { + showSuccess('Synced to Redis successfully'); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Sync failed'); + } + }; + + const handleSaveSeverity = async () => { + if (!severityDialog.data) return; + const { severity_id, severity_category, start_range, end_range, recomended_action } = severityDialog.data; + try { + const res = await updateSeverity(severity_id, { severity_category, start_range, end_range, recomended_action }); + if (res.success) { + await syncRedis(); + showSuccess('Severity updated and synced to Redis'); + setSeverityDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const handleSaveRuntimeConfig = async () => { + if (!runtimeConfigDialog.data) return; + try { + const res = await updateRuntimeConfig(runtimeConfigDialog.data); + if (res.success) { + await syncRedis(); + showSuccess('Verdict config updated and synced to Redis'); + setRuntimeConfigDialog({ open: false, data: null }); + fetchData(); + } else { showError(res.error); } + } catch (err) { + showError(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const openRuntimeEditor = () => { + if (!verdictConfig) return; + setRuntimeConfigDialog({ open: true, data: JSON.parse(JSON.stringify(verdictConfig)) }); + }; + + const updateRuntimeField = (path: string[], value: unknown) => { + setRuntimeConfigDialog(prev => { + if (!prev.data) return prev; + const next = JSON.parse(JSON.stringify(prev.data)) as VerdictConfigData; + let cursor: Record = next as unknown as Record; + for (let i = 0; i < path.length - 1; i++) { + cursor = cursor[path[i]] as Record; + } + cursor[path[path.length - 1]] = value; + return { open: true, data: next }; + }); + }; + + if (loading) { + return ( + + + + ); + } + + const topicMultipliers = multipliers.filter(m => m.multiplier_type === 1); + const inactiveMultipliers = multipliers.filter(m => m.multiplier_type !== 1); + + return ( + + + Final Verdict Configuration + + + + + + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + + + setTabValue(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ borderBottom: 1, borderColor: 'divider' }} + > + } label={`Weights (${componentWeights.length})`} iconPosition="start" /> + } label={`Categories (${verdictCategories.length})`} iconPosition="start" /> + } label={`Risk Levels (${riskMappings.length})`} iconPosition="start" /> + } label={`Severity (${severityAssessments.length})`} iconPosition="start" /> + } label="Overrides & Synergy" iconPosition="start" /> + } label="Confidence" iconPosition="start" /> + } label={`Multipliers (${topicMultipliers.length})`} iconPosition="start" /> + } label="Input Profiles" iconPosition="start" /> + + + {tabValue === 0 && ( + setWeightDialog({ open: true, data: w })} + /> + )} + {tabValue === 1 && ( + setCategoryDialog({ open: true, data: c })} + /> + )} + {tabValue === 2 && ( + setRiskDialog({ open: true, data: r })} + /> + )} + {tabValue === 3 && ( + setSeverityDialog({ open: true, data: s })} + /> + )} + {tabValue === 4 && ( + + )} + {tabValue === 5 && ( + + )} + {tabValue === 6 && ( + setMultiplierDialog({ open: true, data: { multiplier_type: 1, multiplier_name: '', multiplier: 1, description: '' } })} + onEdit={(m) => setMultiplierDialog({ open: true, data: m })} + onDelete={(m) => setDeleteDialog({ open: true, type: 'multiplier', id: m.multiplier_id, name: m.multiplier_name })} + /> + )} + + + {tabValue === 7 && ( + + )} + + + + + + + + setDeleteDialog(null)} + onConfirm={handleDeleteMultiplier} + /> + + ); +}; + +export default VerdictConfig; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/panels/InputProfilesPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/panels/InputProfilesPanel.tsx new file mode 100644 index 0000000..d3eab8e --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/panels/InputProfilesPanel.tsx @@ -0,0 +1,337 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Typography, Button, Alert, CircularProgress, Card, CardContent, Chip, Slider, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, +} from '@mui/material'; +import { Dashboard as ProfilesIcon, Save as SaveIcon } from '@mui/icons-material'; +import { PROFILE_COLORS } from '../helpers'; + +interface InputProfilesProps { + onSuccess: (msg: string) => void; + onError: (msg: string) => void; +} + +export const InputProfilesPanel: React.FC = ({ onSuccess, onError }) => { + const [profiles, setProfiles] = useState([]); + const [selectedProfile, setSelectedProfile] = useState('text_no_url'); + const [editedProfile, setEditedProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const fetchProfiles = useCallback(async () => { + setLoading(true); + try { + const res = await fetch('/framework/api/input-profiles'); + const data = await res.json(); + if (data.success) { + setProfiles(data.data); + const current = data.data.find((p: any) => p.profile_code === selectedProfile) || data.data[0]; + setEditedProfile(JSON.parse(JSON.stringify(current))); + } + } catch (err) { + onError((err as Error).message); + } finally { + setLoading(false); + } + }, [onError, selectedProfile]); + + useEffect(() => { fetchProfiles(); }, [fetchProfiles]); + + const handleSelectProfile = (code: string) => { + setSelectedProfile(code); + const p = profiles.find((pr: any) => pr.profile_code === code); + if (p) setEditedProfile(JSON.parse(JSON.stringify(p))); + }; + + const handleWeightChange = (field: string, value: number) => { + setEditedProfile((prev: any) => ({ ...prev, [field]: value })); + }; + + const handleOverrideToggle = (overrideCode: string) => { + setEditedProfile((prev: any) => { + const overrides = [...(prev.overrides || [])]; + const idx = overrides.findIndex((o: any) => o.override_code === overrideCode); + if (idx >= 0) overrides[idx] = { ...overrides[idx], enabled: !overrides[idx].enabled }; + return { ...prev, overrides }; + }); + }; + + const handleOverrideValueChange = (overrideCode: string, field: string, value: number) => { + setEditedProfile((prev: any) => { + const overrides = [...(prev.overrides || [])]; + const idx = overrides.findIndex((o: any) => o.override_code === overrideCode); + if (idx >= 0) overrides[idx] = { ...overrides[idx], [field]: value }; + return { ...prev, overrides }; + }); + }; + + const handleSave = async () => { + if (!editedProfile) return; + setSaving(true); + try { + const total = editedProfile.weight_techniques + editedProfile.weight_claims + + editedProfile.weight_ai_tampered + editedProfile.weight_source; + if (total !== 100) { + onError(`Weights must sum to 100% (currently ${total}%)`); + setSaving(false); + return; + } + + const profileRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + weight_techniques: editedProfile.weight_techniques, + weight_claims: editedProfile.weight_claims, + weight_ai_tampered: editedProfile.weight_ai_tampered, + weight_source: editedProfile.weight_source, + override_cap: editedProfile.override_cap, + min_components: editedProfile.min_components, + ai_disclosure_multipliers: editedProfile.ai_disclosure_multipliers, + }), + }); + const profileData = await profileRes.json(); + if (!profileData.success) { onError(profileData.error); setSaving(false); return; } + + if (editedProfile.overrides?.length > 0) { + const overridesRes = await fetch(`/framework/api/input-profiles/${editedProfile.profile_code}/overrides`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overrides: editedProfile.overrides }), + }); + const overridesData = await overridesRes.json(); + if (!overridesData.success) { onError(overridesData.error); setSaving(false); return; } + } + + onSuccess(`Profile "${editedProfile.profile_name}" saved! Sync to Redis to apply.`); + fetchProfiles(); + } catch (err) { + onError((err as Error).message); + } finally { + setSaving(false); + } + }; + + if (loading) return ; + if (!editedProfile) return No profiles found; + + const weightTotal = editedProfile.weight_techniques + editedProfile.weight_claims + + editedProfile.weight_ai_tampered + editedProfile.weight_source; + const origProfile = profiles.find((p: any) => p.profile_code === selectedProfile); + const isModified = origProfile && JSON.stringify(origProfile) !== JSON.stringify(editedProfile); + + return ( + + + + + Input Type Profiles — Component weights per input type + + + + + {isModified && Unsaved changes} + {weightTotal !== 100 && Weights must sum to 100% (currently {weightTotal}%)} + + + {profiles.map((p: any) => ( + handleSelectProfile(p.profile_code)} + variant={selectedProfile === p.profile_code ? 'filled' : 'outlined'} + color={selectedProfile === p.profile_code ? 'primary' : 'default'} + sx={{ + borderColor: PROFILE_COLORS[p.profile_code], + ...(selectedProfile === p.profile_code && { bgcolor: PROFILE_COLORS[p.profile_code] }), + }} + /> + ))} + + + + + Component Weights (total must = 100%) + {[ + { key: 'weight_techniques', label: 'Techniques', color: '#9c27b0' }, + { key: 'weight_claims', label: 'Claims', color: '#009688' }, + { key: 'weight_ai_tampered', label: 'AI Tampered', color: '#ff5722' }, + { key: 'weight_source', label: 'Source', color: '#2e7d32' }, + ].map(({ key, label, color }) => ( + + {label} + handleWeightChange(key, v as number)} + min={0} max={100} step={5} + sx={{ flex: 1, color }} + /> + {editedProfile[key]}% + + ))} + + + + + + + + + + Override Rules + + Cap: + setEditedProfile((prev: any) => ({ ...prev, override_cap: Number(e.target.value) }))} + sx={{ width: 80 }} + /> + + + + + + + Override + Active + Threshold + Per Unit + Fixed + Max + + + + {(editedProfile.overrides || []).map((ov: any) => ( + + + {ov.override_code.replace(/_/g, ' ')} + + + handleOverrideToggle(ov.override_code)} + sx={{ cursor: 'pointer' }} + /> + + + {ov.threshold != null && ( + handleOverrideValueChange(ov.override_code, 'threshold', Number(e.target.value))} + sx={{ width: 70 }} /> + )} + + + {ov.bonus_per_unit != null && ( + handleOverrideValueChange(ov.override_code, 'bonus_per_unit', Number(e.target.value))} + sx={{ width: 70 }} /> + )} + + + {ov.bonus_fixed != null && ( + handleOverrideValueChange(ov.override_code, 'bonus_fixed', Number(e.target.value))} + sx={{ width: 70 }} /> + )} + + + {ov.max_bonus != null && ( + handleOverrideValueChange(ov.override_code, 'max_bonus', Number(e.target.value))} + sx={{ width: 70 }} /> + )} + + + ))} + +
+
+
+
+ + + + AI Disclosure Multipliers + + How much AI detection risk is reduced when AI usage is disclosed. Lower = less risk. + + {editedProfile.ai_disclosure_multipliers && Object.entries(editedProfile.ai_disclosure_multipliers).map(([key, val]: [string, any]) => ( + + {key} + setEditedProfile((prev: any) => ({ + ...prev, + ai_disclosure_multipliers: { ...prev.ai_disclosure_multipliers, [key]: v as number } + }))} + min={0} max={1} step={0.05} + sx={{ flex: 1 }} + /> + {(val * 100).toFixed(0)}% + + ))} + + + + + + INCONCLUSIVE Rules + + + Minimum components for verdict + setEditedProfile((prev: any) => ({ ...prev, min_components: Number(e.target.value) }))} + sx={{ width: 80, mt: 1 }} + inputProps={{ min: 1, max: 4 }} + /> + + + Primary components + + {['techniques', 'claims', 'ai_tampered', 'source'].map(comp => { + const isPrimary = (editedProfile.primary_components || []).includes(comp); + return ( + { + setEditedProfile((prev: any) => { + const current = prev.primary_components || []; + const updated = isPrimary + ? current.filter((c: string) => c !== comp) + : [...current, comp]; + return { ...prev, primary_components: updated }; + }); + }} + sx={{ cursor: 'pointer' }} + /> + ); + })} + + + + + +
+ ); +}; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/CategoriesTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/CategoriesTab.tsx new file mode 100644 index 0000000..7292215 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/CategoriesTab.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { + Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + IconButton, Chip, +} from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { VerdictCategory } from '../types'; +import { RangeBar } from '../helpers'; + +interface Props { + verdictCategories: VerdictCategory[]; + onEdit: (cat: VerdictCategory) => void; +} + +export const CategoriesTab: React.FC = ({ verdictCategories, onEdit }) => ( + + + Verdict categories map risk scores to human-readable labels. Used by mapToVerdictCategory(). + + + ({ + start: c.start_range, + end: c.end_range, + color: c.verdict_category_color, + label: c.verdict_category_code, + }))} + /> + + + + + + Category + Code + Score Range + Color + Actions + + + + {verdictCategories.map((c) => ( + + {c.description} + + {c.start_range} - {c.end_range} + + + + {c.verdict_category_color} + + + + onEdit(c)}> + + + ))} + +
+
+
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/ConfidenceTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/ConfidenceTab.tsx new file mode 100644 index 0000000..4e9ba85 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/ConfidenceTab.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { + Box, Typography, Button, Alert, Card, CardContent, Chip, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, +} from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { VerdictConfigData } from '../types'; + +interface Props { + verdictConfig: VerdictConfigData | null; + onEdit: () => void; +} + +export const ConfidenceTab: React.FC = ({ verdictConfig, onEdit }) => ( + + + + Confidence scoring parameters. Used by calculateConfidence(). + Higher confidence = more data sources produced strong signals. + Stored in component_config(pipeline, verdict_config); saved values auto-sync to Redis. + + + + + {!verdictConfig ? ( + + verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row. + + ) : ( + + + + + Base Scoring + + + Each of the 4 components (domain, techniques, ai, claims) adds {verdictConfig.confidence.base_per_component} when available. + Max base = {verdictConfig.confidence.base_per_component * 4}. + + + + + + + + Component Bonuses + + + Additional confidence points based on component signal strength. + + + + + + Component + Condition + Bonus + + + + + Domain + TRUSTED / UNTRUSTED verdict + +{verdictConfig.confidence.domain_strong_signal_bonus} + + + Domain + Other verdict (weak signal) + +{verdictConfig.confidence.domain_weak_signal_bonus} + + + Techniques + Avg technique confidence (max bonus) + +{verdictConfig.confidence.techniques_bonus_max} + + + AI Detection + HIGH confidence + +{verdictConfig.confidence.ai_high_confidence_bonus} + + + AI Detection + MEDIUM confidence + +{verdictConfig.confidence.ai_medium_confidence_bonus} + + + AI Detection + LOW confidence + +{verdictConfig.confidence.ai_low_confidence_bonus} + + + Claims + Verified ratio (max bonus) + +{verdictConfig.confidence.claims_verified_bonus_max} + + +
+
+
+
+ + + + + Confidence Levels + + + = ${verdictConfig.confidence_levels.HIGH.min}`} + color="success" + variant="outlined" + /> + = ${verdictConfig.confidence_levels.MEDIUM.min}`} + color="warning" + variant="outlined" + /> + = ${verdictConfig.confidence_levels.LOW.min}`} + color="default" + variant="outlined" + /> + + + +
+ )} +
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/MultipliersTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/MultipliersTab.tsx new file mode 100644 index 0000000..470a18a --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/MultipliersTab.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Box, Typography, Button, Chip, IconButton, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import type { Multiplier } from '../types'; + +interface Props { + topicMultipliers: Multiplier[]; + inactiveMultipliers: Multiplier[]; + onAdd: () => void; + onEdit: (m: Multiplier) => void; + onDelete: (m: Multiplier) => void; +} + +export const MultipliersTab: React.FC = ({ + topicMultipliers, + inactiveMultipliers, + onAdd, + onEdit, + onDelete, +}) => ( + + + + Topic multipliers adjust the final risk score when options.topic is set. + Only Topic multipliers are applied in code. + + + + + + Active Topic Multipliers ({topicMultipliers.length}) + + + + + + Name + Multiplier + Effect + Description + Actions + + + + {topicMultipliers.length === 0 ? ( + + + No topic multipliers configured + + + ) : topicMultipliers.map((m) => ( + + {m.multiplier_name} + {m.multiplier.toFixed(2)}x + + 1 ? `+${((m.multiplier - 1) * 100).toFixed(0)}%` : `${((m.multiplier - 1) * 100).toFixed(0)}%`} + size="small" + color={m.multiplier > 1 ? 'error' : m.multiplier < 1 ? 'success' : 'default'} + /> + + {m.description} + + onEdit(m)}> + onDelete(m)}> + + + ))} + +
+
+ + {inactiveMultipliers.length > 0 && ( + + + Inactive Multipliers ({inactiveMultipliers.length}) + + + + + + + Type + Name + Multiplier + Status + + + + {inactiveMultipliers.map((m) => ( + + + + + {m.multiplier_name} + {m.multiplier.toFixed(2)}x + + + + + ))} + +
+
+
+ )} +
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/OverridesTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/OverridesTab.tsx new file mode 100644 index 0000000..7618ac8 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/OverridesTab.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Box, Typography, Button, Alert, Card, CardContent } from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { VerdictConfigData } from '../types'; +import { ConfigValue } from '../helpers'; + +interface Props { + verdictConfig: VerdictConfigData | null; + onEdit: () => void; +} + +export const OverridesTab: React.FC = ({ verdictConfig, onEdit }) => ( + + + + Override rules that adjust the final risk score. Used by applyOverrides() and applySynergyBonus(). + Stored in component_config(pipeline, verdict_config); saved values auto-sync to Redis. + + + + + {!verdictConfig ? ( + + verdict_config not found in PostgreSQL or Redis. Re-run sync-redis or seed the row. + + ) : ( + + + + + Synergy Bonus + + + When multiple components show high risk (above threshold), adds a bonus to the final score. + + + + + + + + + Override Rules + + + + + False Claims + + + + + + + + + + + + Severe Techniques + + + + + + + + + + + Undisclosed AI + + + + + + + + + + Untrusted Domain + + + + + + + + + + + + Domain Red Flags + + + + + + + + + + )} + +); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/RiskTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/RiskTab.tsx new file mode 100644 index 0000000..1b75177 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/RiskTab.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { + Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + IconButton, Chip, +} from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { RiskMapping } from '../types'; +import { RangeBar } from '../helpers'; + +interface Props { + riskMappings: RiskMapping[]; + onEdit: (risk: RiskMapping) => void; +} + +export const RiskTab: React.FC = ({ riskMappings, onEdit }) => ( + + + Risk levels provide an alternative categorization focused on risk severity. Used by mapToRiskLevel(). + + + ({ + start: r.start_range, + end: r.end_range, + color: r.risk_color, + label: r.risk_mapping, + }))} + /> + + + + + + Risk Level + Level Value + Score Range + Color + Actions + + + + {riskMappings.map((r) => ( + + {r.risk_mapping} + + {r.start_range} - {r.end_range} + + + + {r.risk_color} + + + + onEdit(r)}> + + + ))} + +
+
+
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/SeverityTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/SeverityTab.tsx new file mode 100644 index 0000000..44a64d5 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/SeverityTab.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { + Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + IconButton, Chip, +} from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { SeverityAssessment } from '../types'; +import { RangeBar, SEVERITY_COLORS } from '../helpers'; + +interface Props { + severityAssessments: SeverityAssessment[]; + onEdit: (sev: SeverityAssessment) => void; +} + +export const SeverityTab: React.FC = ({ severityAssessments, onEdit }) => ( + + + Severity levels determine recommended actions. Used by mapToSeverity(). + Stored in bos_parammgmt.severity_assessment; saved rows auto-sync to Redis. + + + ({ + start: s.start_range, + end: s.end_range, + color: SEVERITY_COLORS[s.severity_category] || '#666', + label: s.severity_category, + }))} + /> + + + + + + Severity + Score Range + Recommended Action + Actions + + + + {severityAssessments.map((s) => ( + + + + + {s.start_range} - {s.end_range} + + + + + onEdit({ ...s })}> + + + + + ))} + +
+
+
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/WeightsTab.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/WeightsTab.tsx new file mode 100644 index 0000000..afbbc19 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/tabs/WeightsTab.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { + Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + IconButton, Chip, +} from '@mui/material'; +import { Edit as EditIcon } from '@mui/icons-material'; +import type { ComponentWeight } from '../types'; + +interface Props { + componentWeights: ComponentWeight[]; + onEdit: (weight: ComponentWeight) => void; +} + +export const WeightsTab: React.FC = ({ componentWeights, onEdit }) => ( + + + Component weights determine how much each analysis component contributes to the final verdict score. + Weights should sum to 100%. + + + + + + Component + Weight (%) + Description + Actions + + + + {componentWeights.map((w) => ( + + {w.component_name} + + + + {w.description} + + onEdit(w)}> + + + + + ))} + +
+
+ + + Total: {componentWeights.reduce((sum, w) => sum + w.component_weight, 0)}% + + +
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/types.ts b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/types.ts new file mode 100644 index 0000000..4dc52cb --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/VerdictConfig/types.ts @@ -0,0 +1,82 @@ +/** + * VerdictConfig — shared types + */ + +export interface ComponentWeight { + component_weight_id: number; + component_name: string; + component_weight: number; + description: string; +} + +export interface Multiplier { + multiplier_id: number; + multiplier_type: number; + multiplier_name: string; + multiplier: number; + description: string; +} + +export interface VerdictCategory { + verdict_category_id: number; + verdict_category_code: string; + description: string; + start_range: number; + end_range: number; + verdict_category_color: string; +} + +export interface RiskMapping { + risk_mapping_id: number; + risk_mapping: string; + risk_level: number; + start_range: number; + end_range: number; + risk_color: string; +} + +export interface SeverityAssessment { + severity_id: string; + severity_category: string; + start_range: number; + end_range: number; + recomended_action: string; +} + +export interface VerdictConfigData { + synergy: { + enabled: boolean; + threshold: number; + bonus_per_component: number; + max_bonus: number; + }; + overrides: { + false_claims: { enabled: boolean; threshold: number; bonus_per_claim: number; max_bonus: number }; + severe_techniques: { enabled: boolean; threshold: number; bonus: number }; + undisclosed_ai: { enabled: boolean; bonus: number }; + untrusted_domain: { enabled: boolean; untrusted_bonus: number; suspicious_bonus: number; blacklisted_bonus: number }; + domain_red_flags: { enabled: boolean; threshold: number; bonus_per_flag: number; max_bonus: number }; + }; + confidence: { + base_per_component: number; + domain_strong_signal_bonus: number; + domain_weak_signal_bonus: number; + techniques_bonus_max: number; + ai_high_confidence_bonus: number; + ai_medium_confidence_bonus: number; + ai_low_confidence_bonus: number; + claims_verified_bonus_max: number; + }; + confidence_levels: { + HIGH: { min: number }; + MEDIUM: { min: number }; + LOW: { min: number }; + }; +} + +export interface DeleteDialogState { + open: boolean; + type: string; + id: number; + name: string; +} diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/api.ts b/backend/admin-dashboard/src/components/LLMComponentsConfig/api.ts new file mode 100644 index 0000000..7aa19c4 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/api.ts @@ -0,0 +1,155 @@ +/** + * API helpers for the LLM Components Config page. + * + * Stage assignments + models live in PG (didiFramework /api/providers/*); + * vision config is still fetched from agent-v3 /config because vision is + * an agent-side concern and isn't mirrored to PG yet. + * + * fetchStageAssignments groups the flat assignment rows by stage_code AND + * tier (free/premium), so each stage exposes both chains for the UI's tier + * toggle. Falls back to free when premium is empty (legacy environments). + */ +import type { ModelConfig, StageAssignment, TierCode } from './types'; + +export const FRAMEWORK_API = '/framework/api/providers'; + +export async function fetchConfig(apiBase: string) { + // Vision config still lives on agent-v3 (Redis-backed). Used only for AI Tampered. + const res = await fetch(`${apiBase}/config`); + return res.json(); +} + +export async function fetchModels(_apiBase: string) { + const res = await fetch(`${FRAMEWORK_API}/models`); + const data = await res.json(); + if (!data.success) return data; + + // Map PG schema to UI shape. + const models = (data.data || []).map((m: any) => ({ + model_key: m.model_code, + provider: m.provider_code || m.provider_name, + model_name: m.model_name, + context_window: m.context_window || 0, + cost_input_1m: m.input_cost_per_1m || 0, + cost_output_1m: m.output_cost_per_1m || 0, + speed_tier: m.context_window >= 128000 ? 'fast' : m.context_window >= 32000 ? 'medium' : 'fast', + quality_tier: m.input_cost_per_1m > 3 ? 'premium' : m.input_cost_per_1m > 0.5 ? 'high' : 'standard', + _model_id: m.model_id, + _provider_id: m.provider_id, + })); + + return { success: true, data: { models } }; +} + +export async function fetchStageAssignments(_apiBase: string) { + const res = await fetch(`${FRAMEWORK_API}/assignments`); + const data = await res.json(); + if (!data.success) return data; + + const grouped: Record = {}; + for (const a of data.data || []) { + const key = a.stage_code; + const tier: TierCode = a.tier === 'premium' ? 'premium' : 'free'; + if (!grouped[key]) { + grouped[key] = { + stage: key, + description: a.stage_name || key, + models: [], + modelsByTier: { free: [], premium: [] }, + }; + } + + const role = a.fallback_order === 1 ? 'primary' + : `fallback_${a.fallback_order - 1}` as ModelConfig['role']; + + const entry: ModelConfig = { + order: a.fallback_order, + role, + model_key: a.model_code, + name: a.model_name, + temperature: parseFloat(a.temperature), + max_tokens: a.max_tokens, + timeout_ms: a.timeout_ms, + _stage_id: a.stage_id, + _provider_id: a.provider_id, + _model_id: a.model_id, + _tier: tier, + }; + + grouped[key].modelsByTier![tier].push(entry); + } + + for (const stage of Object.values(grouped)) { + if (stage.modelsByTier) { + stage.modelsByTier.free.sort((a, b) => a.order - b.order); + stage.modelsByTier.premium.sort((a, b) => a.order - b.order); + stage.models = stage.modelsByTier.free; + } + } + + return { success: true, data: grouped }; +} + +export async function updateStageAssignments(_apiBase: string, data: Record) { + const errors: string[] = []; + + const modelsRes = await fetch(`${FRAMEWORK_API}/models`); + const modelsData = await modelsRes.json(); + const findModel = (modelKey: string) => + (modelsData.data || []).find((m: any) => m.model_code === modelKey); + + for (const [_stageKey, stage] of Object.entries(data)) { + const tierChains: [TierCode, ModelConfig[]][] = stage.modelsByTier + ? (Object.entries(stage.modelsByTier) as [TierCode, ModelConfig[]][]) + : [['free', stage.models]]; + + for (const [_tier, chain] of tierChains) { + for (const model of chain) { + const stageId = model._stage_id; + if (!stageId) continue; + + const body: any = {}; + if (model.temperature !== undefined) body.temperature = model.temperature; + if (model.max_tokens !== undefined) body.max_tokens = model.max_tokens; + if (model.timeout_ms !== undefined) body.timeout_ms = model.timeout_ms; + + if (model.model_key) { + const found = findModel(model.model_key); + if (found) { + body.model_id = found.model_id; + body.provider_id = found.provider_id; + } + } + + const res = await fetch(`${FRAMEWORK_API}/assignments/${stageId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result = await res.json(); + if (!result.success) { + errors.push(result.error || `Failed to update stage ${stageId}`); + } + } + } + } + + if (errors.length > 0) { + return { success: false, error: errors.join('; ') }; + } + return { success: true, message: 'Assignments updated in PostgreSQL' }; +} + +export async function testModel(apiBase: string, modelKey: string) { + const res = await fetch(`${apiBase}/test-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_key: modelKey }), + }); + return res.json(); +} + +export async function syncToRedis(): Promise<{ success: boolean; error?: string; message?: string }> { + const res = await fetch('/framework/api/sync-redis', { method: 'POST' }); + return res.json(); +} diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/component-configs.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/component-configs.tsx new file mode 100644 index 0000000..d85c992 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/component-configs.tsx @@ -0,0 +1,129 @@ +/** + * Static configuration table for the 6 analysis components. + * + * Each entry defines display metadata (name/icon/color), the agent-v3 API + * base used for /test-model and /analyze, and the 2-stage pipeline shape + * (screening + deep, or extraction + verification, etc.). + * + * `verdict` and `moderation` have empty `stages` because they delegate to + * dedicated components rather than the generic stage-assignment editor. + */ +import React from 'react'; +import { + Speed as SpeedIcon, Psychology as PsychologyIcon, Science as ScienceIcon, + SmartToy as SmartToyIcon, FactCheck as FactCheckIcon, + Gavel as VerdictIcon, TravelExplore as SourceIcon, Shield as ModerationIcon, +} from '@mui/icons-material'; +import type { ComponentType, ComponentConfig } from './types'; + +export const COMPONENT_CONFIGS: Record = { + 'techniques': { + name: 'Manipulation Techniques', + icon: , + description: '2-Stage Pipeline for detecting manipulation techniques', + color: '#9c27b0', + apiBase: '/agent-v3/api/v3/techniques', + stages: [ + { + key: 'techniques_screening', + title: 'Stage 1: SCREENING', + icon: , + description: 'Quick dimension detection (~500 tokens, ~2-5s)', + color: '#4caf50', + }, + { + key: 'techniques_deep', + title: 'Stage 2: DEEP ANALYSIS', + icon: , + description: 'Per-dimension technique detection (~1-2k tokens)', + color: '#2196f3', + }, + ], + }, + 'ai-tampered': { + name: 'AI Tampered Detection', + icon: , + description: '2-Stage Pipeline for detecting AI-generated content', + color: '#ff5722', + apiBase: '/agent-v3/api/v3/ai-tampered', + stages: [ + { + key: 'ai_tampered_screening', + title: 'Stage 1: SCREENING', + icon: , + description: 'Quick AI detection (~1k tokens)', + color: '#4caf50', + }, + { + key: 'ai_tampered_deep', + title: 'Stage 2: DEEP ANALYSIS', + icon: , + description: 'Per-category indicator detection', + color: '#2196f3', + }, + ], + hasVision: true, + }, + 'claims': { + name: 'Claims Verification', + icon: , + description: '2-Stage Pipeline for claim extraction and verification', + color: '#009688', + apiBase: '/agent-v3/api/v3/claims', + stages: [ + { + key: 'claims_extraction', + title: 'Stage 1: EXTRACTION', + icon: , + description: 'Extract claims from text', + color: '#4caf50', + }, + { + key: 'claims_verification', + title: 'Stage 2: VERIFICATION', + icon: , + description: 'Verify claims against sources', + color: '#2196f3', + }, + ], + }, + 'source-assessment': { + name: 'Source Assessment', + icon: , + description: '2-Stage Pipeline for source credibility assessment (publication, author, platform, domain)', + color: '#2e7d32', + apiBase: '/agent-v3/api/v3/source-assessment', + stages: [ + { + key: 'source_assessment_extraction', + title: 'Stage 1: EXTRACTION', + icon: , + description: 'Extract publication, author, platform from text/transcript', + color: '#4caf50', + }, + { + key: 'source_assessment_evaluation', + title: 'Stage 2: EVALUATION', + icon: , + description: 'Classify source using web evidence and framework categories', + color: '#2196f3', + }, + ], + }, + 'verdict': { + name: 'Final Verdict', + icon: , + description: 'Configure verdict weights, categories, risk mappings and multipliers', + color: '#7b1fa2', + apiBase: '/framework/api', + stages: [], + }, + 'moderation': { + name: 'Moderation', + icon: , + description: 'HIL triage rules, brain client settings, sensitive topics, roles', + color: '#0288d1', + apiBase: '/framework/api', + stages: [], + }, +}; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/helpers.ts b/backend/admin-dashboard/src/components/LLMComponentsConfig/helpers.ts new file mode 100644 index 0000000..339f639 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/helpers.ts @@ -0,0 +1,77 @@ +/** + * Pure helpers for the Parameters panel — flatten/set nested config objects + * and a curated label/tooltip table for the most common scoring keys. + * + * EXCLUDE_KEYS are scaffolding fields that don't belong in the UI grid + * (legacy or computed at runtime); the panel filters them out before render. + */ + +export const PARAM_INFO: Record = { + count_scaler_divisor: { label: 'Count Scaler Divisor', tooltip: 'How many techniques needed for full score. 1 technique = 1/N of max. Default 3 means 1 tech=33%, 2=67%, 3+=100%.' }, + severe_threshold: { label: 'Severe Technique Threshold', tooltip: 'Techniques with severity >= this value are considered "severe" and trigger the severe_techniques override bonus.' }, + 'bonuses.intensity_bonus.cap': { label: 'Intensity Bonus Cap', tooltip: 'Maximum bonus from technique intensity (0-1 scale). Prevents high-intensity single techniques from dominating.' }, + 'bonuses.intensity_bonus.coefficient': { label: 'Intensity Bonus Coefficient', tooltip: 'Multiplier for intensity calculation. Higher = more impact from technique intensity.' }, + 'bonuses.intensity_bonus.type': { label: 'Intensity Bonus Type', tooltip: '"sqrt" = diminishing returns (recommended), "linear" = proportional intensity impact.' }, + 'bonuses.count_bonus.threshold_3': { label: 'Count Bonus (>= 3 techniques)', tooltip: 'Bonus added to manipulation score when 3+ techniques detected (0-1 scale).' }, + 'bonuses.count_bonus.threshold_5': { label: 'Count Bonus (>= 5 techniques)', tooltip: 'Bonus added when 5+ techniques detected. Replaces threshold_3 bonus.' }, + 'bonuses.dimension_bonus.per_dimension': { label: 'Dimension Bonus Per Dimension', tooltip: 'Bonus per unique manipulation dimension detected (D1-D8). Multiple dimensions = broader manipulation.' }, + 'bonuses.dimension_bonus.max_bonus': { label: 'Dimension Bonus Max', tooltip: 'Maximum total bonus from dimensions, regardless of how many are detected.' }, + 'blend_weights.screening': { label: 'Blend: Screening Weight', tooltip: 'How much the screening stage contributes to final AI probability (0-1). Screening + Deep should sum to 1.0.' }, + 'blend_weights.deep': { label: 'Blend: Deep Analysis Weight', tooltip: 'How much the deep analysis stage contributes to final AI probability (0-1).' }, + undisclosed_threshold: { label: 'Undisclosed AI Threshold', tooltip: 'AI probability (%) above which undisclosed AI triggers the override bonus. Content with disclosed AI is not affected.' }, + 'thresholds.LIKELY_AI': { label: 'Verdict: LIKELY AI (%)', tooltip: 'AI probability >= this value produces LIKELY_AI verdict label.' }, + 'thresholds.POSSIBLY_AI': { label: 'Verdict: POSSIBLY AI (%)', tooltip: 'AI probability >= this value produces POSSIBLY_AI verdict label.' }, + 'thresholds.MIXED': { label: 'Verdict: MIXED (%)', tooltip: 'AI probability >= this value produces MIXED verdict label.' }, + 'disclosure_impact.explicit': { label: 'Disclosure Impact: Explicit', tooltip: 'Risk multiplier when AI is explicitly declared (e.g. "Written by ChatGPT"). Lower = less risk. 0.3 means 30% of original risk.' }, + 'disclosure_impact.partial': { label: 'Disclosure Impact: Partial', tooltip: 'Risk multiplier for partial AI disclosure (e.g. "with AI assistance").' }, + 'disclosure_impact.implied': { label: 'Disclosure Impact: Implied', tooltip: 'Risk multiplier for implied AI disclosure (e.g. mentioning ChatGPT in context).' }, + 'disclosure_impact.none': { label: 'Disclosure Impact: None', tooltip: 'Risk multiplier when AI is NOT disclosed. 1.0 = full risk (no reduction).' }, + 'status_weights.VT': { label: 'Status: Verified True', tooltip: 'Credibility weight for claims verified as true (1.0 = full credibility contribution).' }, + 'status_weights.LT': { label: 'Status: Likely True', tooltip: 'Credibility weight for claims likely true based on sources.' }, + 'status_weights.UV': { label: 'Status: Unverified', tooltip: 'Credibility weight for claims that could not be verified. Lower = more suspicious. 0.5 = neutral.' }, + 'status_weights.OP': { label: 'Status: Opinion as Fact', tooltip: 'Credibility weight for opinions presented as facts. Low value penalizes this practice.' }, + 'status_weights.LF': { label: 'Status: Likely False', tooltip: 'Credibility weight for claims likely false based on contradicting sources.' }, + 'status_weights.VF': { label: 'Status: Verified False', tooltip: 'Credibility weight for verified false claims. 0 = zero credibility contribution.' }, + all_unverified_credibility: { label: 'All Unverified Default', tooltip: 'Default credibility score (0-1) when ALL claims are unverified with zero web sources. 0.75 = lean credible, 0.5 = neutral, 0.25 = suspicious.' }, + all_unverified_behavior: { label: 'All Unverified Behavior', tooltip: '"fixed" = use the default value above. "calculate_per_type" = calculate based on claim types and their individual unverified weights.' }, + 'axis_weights.publication': { label: 'Axis: Publication', tooltip: 'Weight of publication/outlet credibility in trust score (0-1). All axes should sum to 1.0.' }, + 'axis_weights.domain': { label: 'Axis: Domain', tooltip: 'Weight of domain analysis (age, SSL, blacklist) in trust score. 0 when no URL available.' }, + 'axis_weights.author': { label: 'Axis: Author', tooltip: 'Weight of author credibility in trust score.' }, + 'axis_weights.platform': { label: 'Axis: Platform', tooltip: 'Weight of platform type (news site, social media, blog) in trust score.' }, + 'verdict_thresholds.TRUSTED': { label: 'Threshold: TRUSTED (>=)', tooltip: 'Trust score >= this value = TRUSTED verdict. Source is considered reliable.' }, + 'verdict_thresholds.NEUTRAL': { label: 'Threshold: NEUTRAL (>=)', tooltip: 'Trust score >= this value = NEUTRAL verdict. Source cannot be confirmed or denied.' }, + 'verdict_thresholds.SUSPICIOUS': { label: 'Threshold: SUSPICIOUS (>=)', tooltip: 'Trust score >= this value = SUSPICIOUS verdict. Below this = UNTRUSTED.' }, + domain_unavailable_default: { label: 'Domain Unavailable Default', tooltip: 'Default domain axis score (0-100) when no URL is provided. 50 = neutral assumption.' }, +}; + +// Keys to exclude from display (internal / non-scoring). +export const EXCLUDE_KEYS = ['base_calculation', 'manipulation_levels', 'warning_flags_triggers', + 'category_weights', 'risk_calculation', 'confidence_levels', 'source_reliability_weights', + 'claim_type_weights', 'status_thresholds', 'quick_scores']; + +/** Flatten a nested object into dot-notation key-value pairs. */ +export function flattenConfig(obj: any, prefix = ''): { key: string; value: any }[] { + const result: { key: string; value: any }[] = []; + for (const [k, v] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + result.push(...flattenConfig(v, fullKey)); + } else { + result.push({ key: fullKey, value: v }); + } + } + return result; +} + +/** Set a value in a nested object using dot-notation key. */ +export function setNestedValue(obj: any, path: string, value: any): any { + const clone = JSON.parse(JSON.stringify(obj)); + const parts = path.split('.'); + let current = clone; + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) current[parts[i]] = {}; + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; + return clone; +} diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/index.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/index.tsx new file mode 100644 index 0000000..3c808f5 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/index.tsx @@ -0,0 +1,379 @@ +/** + * LLM Components Configuration - Unified UI + * + * Manages stage assignments and models for: + * - Techniques V3 (Manipulation Detection) + * - AI Tampered (AI Content Detection) + * - Claims (Claim Verification) + * - Source Assessment (Source credibility) + * - Verdict (final scoring) — delegated to + * - Moderation — delegated to + * + * Original 1608-line index.tsx split into: + * types.ts — domain types + * component-configs.tsx — COMPONENT_CONFIGS table (icons + stages + apiBase) + * api.ts — fetch / update / test / sync helpers + * helpers.ts — flattenConfig + setNestedValue + PARAM_INFO + EXCLUDE_KEYS + * panels/StageAssignmentsPanel — per-stage model chains with tier toggle + * panels/VisionModelsPanel — vision cascade for AI Tampered + * panels/AvailableModelsPanel — flat models grid + connectivity test + * panels/TestAnalysisPanel — quick /analyze runner + * panels/PromptsPanel — system+user prompt editor + * panels/ParametersPanel — scoring config editor + * index.tsx (this) — page shell + state + render dispatcher + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Paper, Typography, Tabs, Tab, Button, Alert, CircularProgress, Divider, + ToggleButton, ToggleButtonGroup, +} from '@mui/material'; +import { + PlayArrow as PlayIcon, Refresh as RefreshIcon, + Psychology as PsychologyIcon, SwapVert as SwapVertIcon, + Visibility as VisionIcon, Settings as SettingsIcon, + Sync as SyncIcon, Description as PromptsTabIcon, +} from '@mui/icons-material'; +import { VerdictConfig } from './VerdictConfig'; +import ModerationSettings from '../ModerationSettings'; +import { + type ComponentType, type TierCode, + type AvailableModel, type StageAssignment, type ModelConfig, type TestResult, +} from './types'; +import { COMPONENT_CONFIGS } from './component-configs'; +import { fetchConfig, fetchModels, fetchStageAssignments, updateStageAssignments, testModel, syncToRedis } from './api'; +import { StageAssignmentsPanel } from './panels/StageAssignmentsPanel'; +import { VisionModelsPanel } from './panels/VisionModelsPanel'; +import { AvailableModelsPanel } from './panels/AvailableModelsPanel'; +import { TestAnalysisPanel } from './panels/TestAnalysisPanel'; +import { PromptsPanel } from './panels/PromptsPanel'; +import { ParametersPanel } from './panels/ParametersPanel'; + +export const LLMComponentsConfig: React.FC = () => { + const [selectedComponent, setSelectedComponent] = useState('techniques'); + const [selectedTier, setSelectedTier] = useState('free'); + const [tabValue, setTabValue] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [availableModels, setAvailableModels] = useState([]); + const [stageAssignments, setStageAssignments] = useState>({}); + const [visionModels, setVisionModels] = useState([]); + const [testResults, setTestResults] = useState>({}); + + const [syncing, setSyncing] = useState(false); + const [syncMessage, setSyncMessage] = useState(null); + + const [testText, setTestText] = useState(''); + const [analysisResult, setAnalysisResult] = useState(null); + const [analyzing, setAnalyzing] = useState(false); + + const config = COMPONENT_CONFIGS[selectedComponent]; + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + setTestResults({}); + + try { + const [modelsRes, assignmentsRes, configRes] = await Promise.all([ + fetchModels(config.apiBase), + fetchStageAssignments(config.apiBase), + fetchConfig(config.apiBase), + ]); + + if (modelsRes.success) setAvailableModels(modelsRes.data.models || []); + if (assignmentsRes.success) setStageAssignments(assignmentsRes.data || {}); + if (config.hasVision && configRes.success && configRes.data.vision_models) { + setVisionModels(configRes.data.vision_models.models || []); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [config.apiBase, config.hasVision]); + + useEffect(() => { loadData(); }, [loadData, selectedComponent]); + + const handleModelChange = (stageKey: string, modelIndex: number, newModelKey: string) => { + setStageAssignments((prev) => { + const updated = { ...prev }; + const stage = { ...updated[stageKey] }; + const tierMap = { ...(stage.modelsByTier || { free: [], premium: [] }) } as Record; + const currentTierModels = [...(tierMap[selectedTier] || [])]; + if (!currentTierModels[modelIndex]) return prev; + currentTierModels[modelIndex] = { ...currentTierModels[modelIndex], model_key: newModelKey }; + tierMap[selectedTier] = currentTierModels; + stage.modelsByTier = tierMap; + stage.models = currentTierModels; + updated[stageKey] = stage; + return updated; + }); + }; + + const handleSave = async () => { + setLoading(true); + setError(null); + try { + const res = await updateStageAssignments(config.apiBase, stageAssignments); + if (res.success) { + setSuccess('Configuration saved successfully!'); + setTimeout(() => setSuccess(null), 3000); + } else { + setError(res.error || 'Save failed'); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const handleTestModel = async (modelKey: string) => { + setTestResults((prev) => ({ ...prev, [modelKey]: { model_key: modelKey, status: 'testing' } })); + try { + const res = await testModel(config.apiBase, modelKey); + setTestResults((prev) => ({ ...prev, [modelKey]: res.data })); + } catch (err) { + setTestResults((prev) => ({ + ...prev, + [modelKey]: { model_key: modelKey, status: 'error', error: (err as Error).message }, + })); + } + }; + + const handleAnalyze = async () => { + if (!testText.trim()) return; + setAnalyzing(true); + setAnalysisResult(null); + try { + const res = await fetch(`${config.apiBase}/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: testText }), + }); + setAnalysisResult(await res.json()); + } catch (err) { + setAnalysisResult({ success: false, error: (err as Error).message }); + } finally { + setAnalyzing(false); + } + }; + + const handleSyncRedis = async () => { + setSyncing(true); + setSyncMessage(null); + try { + const result = await syncToRedis(); + if (result.success) { + setSyncMessage('Redis synced successfully!'); + setSuccess('Redis synced from PostgreSQL'); + setTimeout(() => { setSyncMessage(null); setSuccess(null); }, 4000); + } else { + setError(result.error || 'Sync failed'); + } + } catch (err) { + setError((err as Error).message); + } finally { + setSyncing(false); + } + }; + + const getSpeedColor = (tier: string) => { + switch (tier) { + case 'ultra_fast': return 'success'; + case 'fast': return 'info'; + case 'medium': return 'warning'; + default: return 'default'; + } + }; + + const getQualityColor = (tier: string) => { + switch (tier) { + case 'premium': return 'secondary'; + case 'high': return 'primary'; + default: return 'default'; + } + }; + + if (loading && availableModels.length === 0) { + return ( + + + + ); + } + + const analysisComponents: ComponentType[] = ['techniques', 'ai-tampered', 'claims', 'source-assessment', 'verdict', 'moderation']; + + return ( + + + val && setSelectedComponent(val)} + size="large" + > + {analysisComponents.map((key) => { + const cfg = COMPONENT_CONFIGS[key]; + return ( + + + {cfg.icon} + {cfg.name} + + + ); + })} + + + + {/* Verdict + Moderation delegated to dedicated components */} + {selectedComponent === 'verdict' ? ( + <> + + + + + Verdict Prompts + + + + ) : selectedComponent === 'moderation' ? ( + + ) : ( + <> + + + + {config.icon} + {config.name} + + + {config.description} + + + + + + + + + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + {syncMessage && setSyncMessage(null)}>{syncMessage}} + + + setTabValue(v)}> + } iconPosition="start" /> + {config.hasVision && } iconPosition="start" />} + } iconPosition="start" /> + } iconPosition="start" /> + } iconPosition="start" /> + } iconPosition="start" /> + + + + {tabValue === 0 && ( + <> + + + Subscription tier + + {selectedTier === 'free' + ? 'Plan 1-3 (Freemium / Starter / Basic) — local + cheap fallbacks' + : 'Plan 4-6 (Pro / Business / Enterprise) — premium cloud models'} + + + v && setSelectedTier(v as TierCode)} + > + FREE + PREMIUM + + + + + )} + + {config.hasVision && tabValue === 1 && ( + + )} + + {tabValue === (config.hasVision ? 2 : 1) && ( + + )} + + {tabValue === (config.hasVision ? 3 : 2) && ( + { setSuccess(msg); setTimeout(() => setSuccess(null), 4000); }} + onError={setError} + /> + )} + + {tabValue === (config.hasVision ? 4 : 3) && ( + + )} + + {tabValue === (config.hasVision ? 5 : 4) && ( + + )} + + )} + + ); +}; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/AvailableModelsPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/AvailableModelsPanel.tsx new file mode 100644 index 0000000..2eed107 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/AvailableModelsPanel.tsx @@ -0,0 +1,80 @@ +/** + * Available models panel — flat list of every model defined in PG, with + * test buttons + the latest test result inline. + */ +import React from 'react'; +import { + Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Chip, Typography, Button, Tooltip, CircularProgress, +} from '@mui/material'; +import { + PlayArrow as PlayIcon, Check as CheckIcon, Close as CloseIcon, +} from '@mui/icons-material'; +import type { AvailableModel, TestResult } from '../types'; + +interface Props { + models: AvailableModel[]; + testResults: Record; + onTestModel: (modelKey: string) => void; + getSpeedColor: (tier: string) => any; + getQualityColor: (tier: string) => any; +} + +export const AvailableModelsPanel: React.FC = ({ + models, testResults, onTestModel, getSpeedColor, getQualityColor, +}) => ( + + + + + Model + Provider + Context + Speed + Quality + Cost ($/1M) + Status + Test + + + + {models.map((model) => { + const testResult = testResults[model.model_key]; + return ( + + + {model.model_name} + {model.model_key} + + + {(model.context_window / 1000).toFixed(0)}K + + + + {model.cost_input_1m === 0 ? ( + + ) : ( + `$${model.cost_input_1m} / $${model.cost_output_1m}` + )} + + + {testResult?.status === 'connected' && ( + } label={`${testResult.response_time_ms}ms`} size="small" color="success" /> + )} + {testResult?.status === 'error' && ( + } label="Error" size="small" color="error" /> + )} + {testResult?.status === 'testing' && } + + + + + + ); + })} + +
+
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/ParametersPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/ParametersPanel.tsx new file mode 100644 index 0000000..4b3c09e --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/ParametersPanel.tsx @@ -0,0 +1,181 @@ +/** + * Parameters panel — editable scoring config per analysis component. + * + * Reads from `/api/input-profiles/scoring-config/:component` (PG-backed via + * didiFramework, not Redis), flattens nested objects into dot-keys, filters + * out scaffolding (EXCLUDE_KEYS), and renders a row per scalar value. + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Paper, Typography, Alert, Button, Chip, Tooltip, CircularProgress, + TableContainer, Table, TableHead, TableBody, TableRow, TableCell, TextField, +} from '@mui/material'; +import { + Save as SaveIcon, Settings as SettingsIcon, + InfoOutlined as InfoIcon, +} from '@mui/icons-material'; +import type { ComponentType } from '../types'; +import { PARAM_INFO, EXCLUDE_KEYS, flattenConfig, setNestedValue } from '../helpers'; + +interface Props { + componentType: ComponentType; + apiBase: string; + onSuccess: (msg: string) => void; + onError: (msg: string) => void; +} + +export const ParametersPanel: React.FC = ({ componentType, apiBase, onSuccess, onError }) => { + void apiBase; + + const [config, setConfig] = useState(null); + const [editedConfig, setEditedConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const fetchConfig = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`); + const data = await res.json(); + if (data.success && data.data) { + setConfig(data.data); + setEditedConfig(JSON.parse(JSON.stringify(data.data))); + } + } catch (err) { + onError((err as Error).message); + } finally { + setLoading(false); + } + }, [componentType, onError]); + + useEffect(() => { fetchConfig(); }, [fetchConfig]); + + const handleSave = async () => { + if (!editedConfig) return; + setSaving(true); + try { + const res = await fetch(`/framework/api/input-profiles/scoring-config/${componentType}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editedConfig), + }); + const result = await res.json(); + if (!result.success) { + onError(result.error || 'Failed to save'); + return; + } + setConfig(JSON.parse(JSON.stringify(editedConfig))); + onSuccess('Parameters saved! Sync to Redis to apply.'); + } catch (err) { + onError((err as Error).message); + } finally { + setSaving(false); + } + }; + + if (loading) return ; + if (!editedConfig) return No scoring config found for {componentType}; + + const flatParams = flattenConfig(editedConfig) + .filter(p => !EXCLUDE_KEYS.some(ex => p.key === ex || p.key.startsWith(ex + '.'))); + + const isModified = JSON.stringify(config) !== JSON.stringify(editedConfig); + + return ( + + + + + Scoring Parameters + + + {isModified && ( + + )} + + + + + {isModified && Unsaved changes. Save and then Sync to Redis to apply.} + + + + + + Parameter + Value + Type + + + + {flatParams.map(({ key, value }) => { + const info = PARAM_INFO[key]; + const label = info?.label || key; + const tooltip = info?.tooltip || ''; + const valueType = typeof value; + + return ( + + + + {label} + {tooltip && ( + + + + )} + + {key} + + + {valueType === 'boolean' ? ( + setEditedConfig((prev: any) => setNestedValue(prev, key, !value))} + sx={{ cursor: 'pointer' }} + /> + ) : valueType === 'string' ? ( + setEditedConfig((prev: any) => setNestedValue(prev, key, e.target.value))} + sx={{ width: 180 }} + /> + ) : ( + { + const v = parseFloat(e.target.value); + if (!isNaN(v)) setEditedConfig((prev: any) => setNestedValue(prev, key, v)); + }} + sx={{ width: 120 }} + inputProps={{ step: value < 1 ? 0.01 : 1 }} + /> + )} + + + + + + ); + })} + +
+
+
+ ); +}; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/PromptsPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/PromptsPanel.tsx new file mode 100644 index 0000000..3b9cad8 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/PromptsPanel.tsx @@ -0,0 +1,213 @@ +/** + * Prompts panel — edit system_prompt + user_template per (component, stage). + * + * Fetches the active component's prompts plus the SHARED prompts: + * - vision → shown for techniques/ai-tampered/claims (image OCR + AI detect) + * - pipeline → shown for verdict (final-verdict explanation prompt) + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Paper, Typography, Alert, Accordion, AccordionSummary, AccordionDetails, + TextField, Button, Chip, CircularProgress, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, Save as SaveIcon, + Description as PromptsTabIcon, +} from '@mui/icons-material'; +import type { PromptComponentCode } from '../types'; + +interface PromptData { + prompt_id: number; + component_code: string; + stage_code: string; + system_prompt: string; + user_template: string; + description: string; +} + +interface Props { + componentCode: PromptComponentCode; +} + +export const PromptsPanel: React.FC = ({ componentCode }) => { + const [prompts, setPrompts] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [editedPrompts, setEditedPrompts] = useState>>({}); + + const fetchPrompts = useCallback(async () => { + setLoading(true); + setError(null); + try { + const components = [componentCode]; + if (['techniques', 'ai-tampered', 'claims'].includes(componentCode)) { + components.push('vision'); + } + if (componentCode === 'verdict' || componentCode === 'pipeline') { + components.push('pipeline'); + } + + const allPrompts: PromptData[] = []; + for (const comp of components) { + const res = await fetch(`/framework/api/providers/prompts?component=${comp}`); + const data = await res.json(); + if (data.success && data.data) { + allPrompts.push(...data.data); + } + } + + setPrompts(allPrompts); + setEditedPrompts({}); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [componentCode]); + + useEffect(() => { fetchPrompts(); }, [fetchPrompts]); + + const handleFieldChange = (promptId: number, field: 'system_prompt' | 'user_template', value: string) => { + setEditedPrompts(prev => ({ + ...prev, + [promptId]: { ...prev[promptId], [field]: value }, + })); + }; + + const isEdited = (promptId: number) => editedPrompts[promptId] !== undefined; + + const handleSave = async (prompt: PromptData) => { + const edits = editedPrompts[prompt.prompt_id]; + if (!edits) return; + + setSaving(prompt.prompt_id); + setError(null); + try { + const res = await fetch(`/framework/api/providers/prompts/${prompt.prompt_id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + const data = await res.json(); + if (data.success) { + setSuccess(`Prompt "${prompt.stage_code}" saved! Remember to Sync to Redis.`); + setEditedPrompts(prev => { + const next = { ...prev }; + delete next[prompt.prompt_id]; + return next; + }); + fetchPrompts(); + setTimeout(() => setSuccess(null), 5000); + } else { + setError(data.error || 'Failed to save'); + } + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(null); + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + + {prompts.length === 0 ? ( + + + No prompts configured for {componentCode}. Prompts can be added via API. + + + ) : ( + prompts.map((prompt) => { + const edited = editedPrompts[prompt.prompt_id]; + const currentSystem = edited?.system_prompt ?? prompt.system_prompt; + const currentUser = edited?.user_template ?? prompt.user_template; + + return ( + + }> + + + + + {prompt.stage_code} + {prompt.component_code !== componentCode && ( + + )} + + {prompt.description} + + {isEdited(prompt.prompt_id) && ( + + )} + + + + + + System Prompt + handleFieldChange(prompt.prompt_id, 'system_prompt', e.target.value)} + sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }} + /> + + + User Template + handleFieldChange(prompt.prompt_id, 'user_template', e.target.value)} + sx={{ fontFamily: 'monospace', '& textarea': { fontFamily: 'monospace', fontSize: '0.85rem' } }} + /> + + + {isEdited(prompt.prompt_id) && ( + + )} + + + + + + ); + }) + )} + + ); +}; diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/StageAssignmentsPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/StageAssignmentsPanel.tsx new file mode 100644 index 0000000..461ddb0 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/StageAssignmentsPanel.tsx @@ -0,0 +1,153 @@ +/** + * Stage assignments table — one accordion per pipeline stage, with the + * tier-specific (free/premium) model chain editable inside. + * + * If the requested tier is empty (e.g. premium not configured yet), falls + * back to the free chain and shows a warning Alert pointing to the API to + * create the missing tier rows. + */ +import React from 'react'; +import { + Box, Accordion, AccordionSummary, AccordionDetails, Typography, Chip, Alert, + TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + FormControl, Select, MenuItem, IconButton, Tooltip, CircularProgress, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, PlayArrow as PlayIcon, + Check as CheckIcon, Close as CloseIcon, +} from '@mui/icons-material'; +import type { StageConfig, StageAssignment, AvailableModel, TestResult, TierCode } from '../types'; + +interface Props { + stages: StageConfig[]; + stageAssignments: Record; + availableModels: AvailableModel[]; + testResults: Record; + selectedTier: TierCode; + onModelChange: (stageKey: string, modelIndex: number, newModelKey: string) => void; + onTestModel: (modelKey: string) => void; +} + +export const StageAssignmentsPanel: React.FC = ({ + stages, stageAssignments, availableModels, testResults, selectedTier, onModelChange, onTestModel, +}) => ( + + {stages.map(({ key, title, icon, description, color }) => { + const stage = stageAssignments[key]; + if (!stage) return null; + + const tierModels = + stage.modelsByTier?.[selectedTier] && stage.modelsByTier[selectedTier].length > 0 + ? stage.modelsByTier[selectedTier] + : (stage.modelsByTier?.free && stage.modelsByTier.free.length > 0 + ? stage.modelsByTier.free + : stage.models); + const tierBadge = selectedTier === 'premium' ? 'PREMIUM' : 'FREE'; + const tierBadgeColor: 'success' | 'warning' = selectedTier === 'premium' ? 'warning' : 'success'; + const missingPremium = selectedTier === 'premium' + && (!stage.modelsByTier?.premium || stage.modelsByTier.premium.length === 0); + + return ( + + }> + + {icon} + + + {title} + + + {description} + + + + + {missingPremium && ( + + No premium assignments configured for this stage — showing free chain + as fallback. Create premium rows via POST /api/providers/assignments with tier: "premium". + + )} + + + + + Order + Role + Model + Temp + Max Tokens + Timeout + Status + Test + + + + {tierModels.map((model, index) => { + const modelKey = model.model_key || ''; + const testResult = testResults[modelKey]; + + return ( + + + + + + + + + + + + + {model.temperature != null ? model.temperature : '-'} + {model.max_tokens != null ? model.max_tokens : '-'} + {model.timeout_ms != null ? `${model.timeout_ms / 1000}s` : '-'} + + {testResult?.status === 'connected' && ( + + } label="OK" size="small" color="success" /> + + )} + {testResult?.status === 'error' && ( + + } label="Error" size="small" color="error" /> + + )} + {testResult?.status === 'testing' && } + + + onTestModel(modelKey)} disabled={testResult?.status === 'testing'}> + + + + + ); + })} + +
+
+
+
+ ); + })} +
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/TestAnalysisPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/TestAnalysisPanel.tsx new file mode 100644 index 0000000..4319fcd --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/TestAnalysisPanel.tsx @@ -0,0 +1,98 @@ +/** + * Test Analysis panel — quick-run UI for the active component's /analyze + * endpoint. Per-component sample texts let QA reproduce known-good results + * without copy-pasting from a doc. + */ +import React from 'react'; +import { + Box, Paper, Typography, TextField, Button, Alert, CircularProgress, +} from '@mui/material'; +import { PlayArrow as PlayIcon } from '@mui/icons-material'; +import type { ComponentType } from '../types'; + +interface Props { + componentType: ComponentType; + testText: string; + setTestText: (text: string) => void; + analyzing: boolean; + analysisResult: any; + onAnalyze: () => void; +} + +const SAMPLE_TEXTS: Record = { + 'techniques': `URGENT: This is a THREAT to our survival! The government is hiding the truth. +We must ACT NOW before it's too late! They don't want you to know about this conspiracy. +Share this before they delete it! This is being censored everywhere.`, + 'ai-tampered': `It is important to note that there are several key considerations to keep in mind. +Furthermore, one must acknowledge the multifaceted nature of this issue. +In conclusion, while there are valid perspectives on both sides, a balanced approach is recommended.`, + 'claims': `The Eiffel Tower is 500 meters tall. Romania joined the EU in 2007. +The vaccine has a 95% efficacy rate according to clinical trials. +The GDP of Romania grew by 5% in 2024.`, + 'source-assessment': `According to a report by Reuters, the new climate agreement was signed by 50 countries. +The article was written by journalist John Smith and published on fortune.com. +The data comes from the United Nations Environment Programme.`, + 'verdict': '', + 'moderation': '', +}; + +export const TestAnalysisPanel: React.FC = ({ + componentType, testText, setTestText, analyzing, analysisResult, onAnalyze, +}) => ( + + + + + Input Text + setTestText(e.target.value)} + placeholder="Enter text to analyze..." + /> + + + + + + + + + + + Results + {!analysisResult && !analyzing && ( + Run an analysis to see results. + )} + {analyzing && ( + + + + )} + {analysisResult && !analyzing && ( + + {analysisResult.success ? ( + + {JSON.stringify(analysisResult.data, null, 2)} + + ) : ( + {analysisResult.error} + )} + + )} + + + + +); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/VisionModelsPanel.tsx b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/VisionModelsPanel.tsx new file mode 100644 index 0000000..b211fe7 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/panels/VisionModelsPanel.tsx @@ -0,0 +1,64 @@ +/** + * Vision Models panel — displayed only for AI Tampered (the one component + * that runs frame-level analysis through the vision cascade). + * + * Read-only listing; vision config edits go through the agent-v3 admin API + * (not yet PG-backed), so the UI doesn't expose direct row editing here. + */ +import React from 'react'; +import { + Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Chip, Typography, +} from '@mui/material'; +import { Visibility as VisionIcon } from '@mui/icons-material'; +import type { ModelConfig } from '../types'; + +interface Props { + visionModels: ModelConfig[]; +} + +export const VisionModelsPanel: React.FC = ({ visionModels }) => ( + + + + Vision Models for Image AI Detection + + + These models are used to analyze images for AI-generated content indicators. + + + + + + Order + Role + Model + Name + Timeout + + + + {visionModels.map((model) => ( + + + + + + + + + {model.model} + + {model.name} + {model.timeout ? `${model.timeout / 1000}s` : '-'} + + ))} + +
+
+
+); diff --git a/backend/admin-dashboard/src/components/LLMComponentsConfig/types.ts b/backend/admin-dashboard/src/components/LLMComponentsConfig/types.ts new file mode 100644 index 0000000..1b13572 --- /dev/null +++ b/backend/admin-dashboard/src/components/LLMComponentsConfig/types.ts @@ -0,0 +1,71 @@ +/** + * Domain types for the LLM Components Configuration page. + * + * The shape mirrors PG-side schemas (assignments / models / configs), + * with helper-only fields prefixed `_` (e.g. `_stage_id`) that the UI + * uses to call PUT endpoints by id without flowing through serializers. + */ +import type React from 'react'; + +export type ComponentType = 'techniques' | 'ai-tampered' | 'claims' | 'source-assessment' | 'verdict' | 'moderation'; +export type PromptComponentCode = ComponentType | 'vision' | 'pipeline'; +export type TierCode = 'free' | 'premium'; + +export interface ModelConfig { + order: number; + role: 'primary' | 'fallback_1' | 'fallback_2' | 'fallback_3'; + model_key?: string; + model?: string; // for vision models + name?: string; + temperature?: number; + max_tokens?: number; + timeout_ms?: number; + timeout?: number; // legacy alias used by vision models + _stage_id?: number; + _provider_id?: number; + _model_id?: number; + _tier?: TierCode; +} + +export interface StageAssignment { + stage: string; + description: string; + models: ModelConfig[]; + modelsByTier?: Record; +} + +export interface AvailableModel { + model_key: string; + provider: string; + model_name: string; + context_window: number; + cost_input_1m: number; + cost_output_1m: number; + speed_tier: string; + quality_tier: string; +} + +export interface TestResult { + model_key: string; + status: 'connected' | 'error' | 'testing'; + response_time_ms?: number; + error?: string; +} + +export interface ComponentConfig { + name: string; + icon: React.ReactNode; + description: string; + color: string; + apiBase: string; + stages: StageConfig[]; + hasVision?: boolean; +} + +export interface StageConfig { + key: string; + title: string; + icon: React.ReactNode; + description: string; + color: string; +} diff --git a/backend/admin-dashboard/src/components/Moderation/ModerationDetail.tsx b/backend/admin-dashboard/src/components/Moderation/ModerationDetail.tsx new file mode 100644 index 0000000..712f55e --- /dev/null +++ b/backend/admin-dashboard/src/components/Moderation/ModerationDetail.tsx @@ -0,0 +1,343 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Box, Typography, Paper, Button, Chip, Alert, CircularProgress, Divider, Card, CardContent, Grid, + Dialog, DialogTitle, DialogContent, DialogActions, ToggleButton, ToggleButtonGroup, TextField, +} from '@mui/material'; +import { + ArrowBack as BackIcon, CheckCircle as ApproveIcon, EditNote as EditIcon, Block as RejectIcon, + PersonAdd as ClaimIcon, Refresh as RefreshIcon, +} from '@mui/icons-material'; +import { + getQueueEntry, claimQueueEntry, resolveQueueEntry, + priorityLabel, priorityColor, statusColor, ageMinutes, + type QueueEntry, type ResolutionAction, +} from './api'; + +export const ModerationDetail: React.FC = () => { + const { queueId } = useParams<{ queueId: string }>(); + const navigate = useNavigate(); + + const [entry, setEntry] = useState(null); + const [session, setSession] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [resolveOpen, setResolveOpen] = useState(false); + const [resolveAction, setResolveAction] = useState('approved'); + const [resolveNotes, setResolveNotes] = useState(''); + const [resolveCorrections, setResolveCorrections] = useState('{\n \n}'); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(async () => { + if (!queueId) return; + setLoading(true); + setError(null); + try { + const res = await getQueueEntry(queueId); + if (res.success && res.data) { + setEntry(res.data.queue); + setSession(res.data.session); + } else { + setError(res.error ?? 'Failed to load'); + } + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [queueId]); + + useEffect(() => { load(); }, [load]); + + const handleClaim = async () => { + if (!queueId) return; + setSubmitting(true); + try { + const res = await claimQueueEntry(queueId); + if (res.success) { + setSuccess('Claimed. You can now resolve.'); + load(); + } else { + setError(res.error ?? 'Claim failed'); + } + } catch (e) { + setError((e as Error).message); + } finally { + setSubmitting(false); + } + }; + + const openResolve = (action: ResolutionAction) => { + setResolveAction(action); + setResolveNotes(''); + setResolveCorrections('{\n \n}'); + setResolveOpen(true); + }; + + const handleResolveSubmit = async () => { + if (!queueId) return; + let corrections: Record | null = null; + if (resolveAction === 'corrected') { + try { + corrections = JSON.parse(resolveCorrections); + } catch (e) { + setError(`Invalid JSON in corrections: ${(e as Error).message}`); + return; + } + } + setSubmitting(true); + try { + const res = await resolveQueueEntry(queueId, { + action: resolveAction, + corrections, + notes: resolveNotes || null, + user_id: 'admin', + }); + if (res.success) { + setSuccess(`Resolved as '${resolveAction}'`); + setResolveOpen(false); + load(); + } else { + setError(res.error ?? 'Resolve failed'); + } + } catch (e) { + setError((e as Error).message); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ; + } + + if (!entry) { + return ( + + navigate('/moderation')}>Back}> + {error ?? 'Not found'} + + + ); + } + + const isActionable = entry.status === 'pending' || entry.status === 'in_review'; + const inReview = entry.status === 'in_review'; + + // Pull common fields from session safely + const s = (session ?? {}) as Record; + const inputText = (s.input_text as string | null) ?? null; + const inputUrl = (s.input_url as string | null) ?? null; + const inputType = (s.input_type as string | null) ?? null; + const userEmail = (s.user_email as string | null) ?? null; + const riskScore = (s.risk_score as number | null) ?? null; + const riskCategory = (s.risk_category as string | null) ?? null; + const confidence = (s.confidence as number | null) ?? null; + const severity = (s.severity as string | null) ?? null; + + return ( + + + + + Queue #{entry.queue_id} + + + + + + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + + {/* Action buttons */} + + + {entry.status === 'pending' && ( + + )} + {inReview && ( + <> + + + + + )} + {!isActionable && ( + + Status is {entry.status}. + {entry.resolved_by && ` Resolved by ${entry.resolved_by} as '${entry.resolution_action}'.`} + + )} + + + + + {/* Left: Session input */} + + + + Input + + + {userEmail && } + + {inputText && ( + + + {inputText} + + + )} + {inputUrl && ( + + URL + {inputUrl} + + )} + + + + + {/* Right: AI verdict summary */} + + + + AI Verdict + + {riskCategory && } + {severity && } + + + + Risk Score + {riskScore ?? '—'} + + + Confidence + {confidence ?? '—'} + + + + Components + + {(s.components_run as string[] | null)?.map((c) => ( + + ))} + {(s.components_skipped as string[] | null)?.map((c) => ( + + ))} + + + + + + {/* Queue meta */} + + + + Queue Metadata + + + Reason + {entry.enqueue_reason} + + + Created + {ageMinutes(entry.created_at)}m ago + + + Assigned + {entry.assigned_to ?? '—'} + + + Resolved + {entry.resolved_by ?? '—'} + + + {entry.enqueue_meta && ( + + Triage meta +
+                    {JSON.stringify(entry.enqueue_meta, null, 2)}
+                  
+
+ )} +
+
+
+
+ + {/* Resolve Dialog */} + setResolveOpen(false)} maxWidth="md" fullWidth> + Resolve Queue Entry #{entry.queue_id} + + + v && setResolveAction(v)} + > + Approved (no change) + Corrected + Rejected + + + {resolveAction === 'corrected' && ( + <> + + Provide corrections as a JSON diff. Example:
+ + {`{ "verdict": { "risk_score": { "from": 67, "to": 45 } }, "techniques": { "removed": ["false_dilemma"] } }`} + +
+ setResolveCorrections(e.target.value)} + multiline + minRows={6} + maxRows={20} + sx={{ '& textarea': { fontFamily: 'monospace', fontSize: 13 } }} + fullWidth + /> + + )} + + setResolveNotes(e.target.value)} + multiline + minRows={2} + fullWidth + /> +
+
+ + + + +
+
+ ); +}; + +export default ModerationDetail; diff --git a/backend/admin-dashboard/src/components/Moderation/ModerationQueue.tsx b/backend/admin-dashboard/src/components/Moderation/ModerationQueue.tsx new file mode 100644 index 0000000..df4d871 --- /dev/null +++ b/backend/admin-dashboard/src/components/Moderation/ModerationQueue.tsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, Typography, Paper, Table, TableHead, TableBody, TableRow, TableCell, + Chip, IconButton, ToggleButtonGroup, ToggleButton, CircularProgress, Alert, Button, Pagination, +} from '@mui/material'; +import { Refresh as RefreshIcon, OpenInNew as OpenIcon, Shield as ShieldIcon } from '@mui/icons-material'; +import { listQueue, priorityLabel, priorityColor, statusColor, ageMinutes, type QueueEntry, type QueueStatus } from './api'; + +const PAGE_SIZE = 20; + +export const ModerationQueue: React.FC = () => { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('pending'); + const [priorityFilter, setPriorityFilter] = useState(''); + const [page, setPage] = useState(1); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params: { status?: string; priority?: string; limit: number; offset: number } = { + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }; + if (statusFilter !== 'all') params.status = statusFilter; + if (priorityFilter) params.priority = priorityFilter; + const res = await listQueue(params); + if (res.success && res.data) { + setItems(res.data); + setTotal(res.total ?? res.data.length); + } else { + setError(res.error ?? 'Failed to load queue'); + } + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [statusFilter, priorityFilter, page]); + + useEffect(() => { load(); }, [load]); + + // Auto-refresh every 30s when on pending tab + useEffect(() => { + if (statusFilter !== 'pending') return; + const t = setInterval(load, 30_000); + return () => clearInterval(t); + }, [statusFilter, load]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( + + + + + + Moderation Queue + + + Sessions flagged by triage or user reports for human review. + + + + + + + + + + + + Status + v && (setPage(1), setStatusFilter(v))} + > + Pending + In Review + Resolved + All + + + + Priority + (setPage(1), setPriorityFilter(v ?? ''))} + > + Any + 1 + 2 + 3 + 4 + 5 + + + + {total} total + + + + + {error && setError(null)}>{error}} + + + {loading && items.length === 0 ? ( + + ) : items.length === 0 ? ( + + No items match these filters. + + ) : ( + + + + Queue ID + Priority + Reason + Status + Assigned + Age + Session + Action + + + + {items.map((it) => ( + navigate(`/moderation/${it.queue_id}`)}> + #{it.queue_id} + + + + + + + + + + {it.assigned_to ?? '—'} + {ageMinutes(it.created_at)}m + + + {it.session_id.slice(0, 8)}… + + + + { e.stopPropagation(); navigate(`/moderation/${it.queue_id}`); }}> + + + + + ))} + +
+ )} + + {totalPages > 1 && ( + + setPage(p)} size="small" /> + + )} +
+
+ ); +}; + +export default ModerationQueue; diff --git a/backend/admin-dashboard/src/components/Moderation/ModerationStats.tsx b/backend/admin-dashboard/src/components/Moderation/ModerationStats.tsx new file mode 100644 index 0000000..ff8d72a --- /dev/null +++ b/backend/admin-dashboard/src/components/Moderation/ModerationStats.tsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Box, Typography, Paper, Card, CardContent, Grid, CircularProgress, Alert, Button, Chip } from '@mui/material'; +import { Refresh as RefreshIcon, ArrowBack as BackIcon } from '@mui/icons-material'; +import { getStats, type QueueStats } from './api'; + +const STAT_COLORS: Record = { + pending: '#ff9800', + in_review: '#0288d1', + resolved_24h: '#2e7d32', +}; + +export const ModerationStats: React.FC = () => { + const navigate = useNavigate(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await getStats(); + if (res.success && res.data) setStats(res.data); + else setError(res.error ?? 'Failed to load stats'); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + const t = setInterval(load, 30_000); + return () => clearInterval(t); + }, [load]); + + if (loading && !stats) { + return ; + } + + return ( + + + + + Moderation Stats + + + + + {error && setError(null)} sx={{ mb: 2 }}>{error}} + + {stats && ( + + + + + Pending + {stats.pending} + Awaiting moderator claim + + + + + + + In Review + {stats.in_review} + Currently being moderated + + + + + + + Resolved (24h) + {stats.resolved_24h} + Completed in last 24h + + + + + + + Open by Priority + + {Object.keys(stats.by_priority).length === 0 ? ( + No open entries. + ) : ( + Object.entries(stats.by_priority).map(([k, v]) => ( + + )) + )} + + + + + + Avg time in queue + + {stats.avg_time_in_queue_ms != null + ? `${(stats.avg_time_in_queue_ms / 1000 / 60).toFixed(1)}m` + : '—'} + + + From enqueue to claim. Lower is better; spike means moderator capacity issue. + + + + + )} + + ); +}; + +export default ModerationStats; diff --git a/backend/admin-dashboard/src/components/Moderation/api.ts b/backend/admin-dashboard/src/components/Moderation/api.ts new file mode 100644 index 0000000..9631969 --- /dev/null +++ b/backend/admin-dashboard/src/components/Moderation/api.ts @@ -0,0 +1,135 @@ +/** + * API client for Moderation pages — talks to agent-v3 via /agent-v3/api/v3/moderation/*. + * (Nginx proxies the path; in dev/staging it goes direct.) + */ + +const AGENT_API = '/agent-v3/api/v3/moderation'; + +export type EnqueueReason = 'flagged' | 'low_confidence' | 'sensitive_topic' | 'mixed' | 'none'; +export type QueueStatus = 'pending' | 'in_review' | 'resolved' | 'declined' | 'auto_closed'; +export type ResolutionAction = 'approved' | 'corrected' | 'rejected'; + +export interface QueueEntry { + queue_id: number | string; + session_id: string; + priority: number; + enqueue_reason: EnqueueReason; + enqueue_meta: Record | null; + status: QueueStatus; + assigned_to: string | null; + assigned_at: string | null; + resolved_at: string | null; + resolved_by: string | null; + resolution_action: ResolutionAction | null; + time_in_queue_ms: number | null; + time_in_review_ms: number | null; + created_at: string; +} + +export interface QueueDetailResponse { + queue: QueueEntry; + session: Record | null; +} + +export interface QueueStats { + pending: number; + in_review: number; + resolved_24h: number; + by_priority: Record; + avg_time_in_queue_ms: number | null; +} + +export interface ResolveBody { + action: ResolutionAction; + corrections?: Record | null; + notes?: string | null; + user_id?: string; +} + +interface ApiResponse { + success: boolean; + data?: T; + total?: number; + limit?: number; + offset?: number; + error?: string; + message?: string; +} + +async function asJson(res: Response): Promise> { + try { return (await res.json()) as ApiResponse; } + catch { return { success: false, error: `${res.status} ${res.statusText}` }; } +} + +async function authedFetch(input: string, init: RequestInit = {}): Promise { + const token = localStorage.getItem('keycloak_token'); + const headers: HeadersInit = { + ...(init.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + return fetch(input, { ...init, headers }); +} + +export interface ListQueueParams { + status?: string; + priority?: string; + assigned_to?: string; + limit?: number; + offset?: number; +} + +export async function listQueue(params: ListQueueParams = {}): Promise> { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.priority) q.set('priority', params.priority); + if (params.assigned_to) q.set('assigned_to', params.assigned_to); + if (params.limit) q.set('limit', String(params.limit)); + if (params.offset) q.set('offset', String(params.offset)); + const res = await authedFetch(`${AGENT_API}/queue?${q}`); + return asJson(res); +} + +export async function getQueueEntry(queueId: number | string): Promise> { + const res = await authedFetch(`${AGENT_API}/queue/${queueId}`); + return asJson(res); +} + +export async function claimQueueEntry(queueId: number | string, userId?: string): Promise> { + const res = await authedFetch(`${AGENT_API}/queue/${queueId}/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userId ? { user_id: userId } : {}), + }); + return asJson(res); +} + +export async function resolveQueueEntry(queueId: number | string, body: ResolveBody): Promise> { + const res = await authedFetch(`${AGENT_API}/queue/${queueId}/resolve`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return asJson(res); +} + +export async function getStats(): Promise> { + const res = await authedFetch(`${AGENT_API}/stats`); + return asJson(res); +} + +// Helpers for UI +export function priorityLabel(p: number): string { + return ({ 1: 'Critical (flagged)', 2: 'High', 3: 'Low confidence', 4: 'Sensitive topic', 5: 'Random' } as Record)[p] ?? `P${p}`; +} + +export function priorityColor(p: number): 'error' | 'warning' | 'info' | 'success' | 'default' { + return ({ 1: 'error', 2: 'warning', 3: 'info', 4: 'info', 5: 'default' } as const)[p as 1 | 2 | 3 | 4 | 5] ?? 'default'; +} + +export function statusColor(s: QueueStatus): 'warning' | 'info' | 'success' | 'error' | 'default' { + return ({ pending: 'warning', in_review: 'info', resolved: 'success', declined: 'default', auto_closed: 'default' } as const)[s] ?? 'default'; +} + +export function ageMinutes(createdAt: string): number { + return Math.round((Date.now() - new Date(createdAt).getTime()) / 60000); +} diff --git a/backend/admin-dashboard/src/components/ModerationSettings/BrainClientCard.tsx b/backend/admin-dashboard/src/components/ModerationSettings/BrainClientCard.tsx new file mode 100644 index 0000000..b94e692 --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/BrainClientCard.tsx @@ -0,0 +1,163 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, FormGroup, Checkbox, Alert } from '@mui/material'; +import { Save as SaveIcon, Info as InfoIcon } from '@mui/icons-material'; +import type { ModerationConfig } from './api'; +import { updateModerationConfig } from './api'; + +interface Props { + config: ModerationConfig; + onSaved: (next: ModerationConfig) => void; + onError: (msg: string) => void; +} + +export const BrainClientCard: React.FC = ({ config, onSaved, onError }) => { + const [draft, setDraft] = useState(config); + const [saving, setSaving] = useState(false); + + useEffect(() => { setDraft(config); }, [config]); + + const dirty = JSON.stringify(draft) !== JSON.stringify(config); + + const handleSave = async () => { + setSaving(true); + try { + const res = await updateModerationConfig({ + brain_enabled: draft.brain_enabled, + brain_url: draft.brain_url, + brain_lookup_timeout_ms: draft.brain_lookup_timeout_ms, + brain_write_timeout_ms: draft.brain_write_timeout_ms, + brain_confidence_min_silver: Number(draft.brain_confidence_min_silver), + brain_semantic_threshold: Number(draft.brain_semantic_threshold), + brain_per_component: draft.brain_per_component, + }); + if (res.success && res.data) { + onSaved(res.data); + } else { + onError(res.error ?? 'Update failed'); + } + } catch (e) { + onError((e as Error).message); + } finally { + setSaving(false); + } + }; + + return ( + + + + + Brain Client + + How didi-backend talks to didi-brain. CLIENT settings only. + + + setDraft({ ...draft, brain_enabled: e.target.checked })} + /> + } + label={draft.brain_enabled ? 'Enabled' : 'Disabled'} + labelPlacement="start" + /> + + + } sx={{ mb: 2 }}> + These are client settings (how didi calls brain). Brain server config + (TTL, embeddings, eviction) lives in the AI platform dashboard. + + + {!draft.brain_enabled && ( + + Brain is OFF. Executors run LLM normally without checking cache. + + )} + + + setDraft({ ...draft, brain_url: e.target.value })} + helperText="HTTP(S) endpoint of didi-brain (e.g. http://10.11.10.13:8090)" + /> + setDraft({ ...draft, brain_lookup_timeout_ms: parseInt(e.target.value, 10) || 0 })} + /> + setDraft({ ...draft, brain_write_timeout_ms: parseInt(e.target.value, 10) || 0 })} + /> + + + + + Confidence threshold for silver write + setDraft({ ...draft, brain_confidence_min_silver: Number(v) })} + /> + + LLM results with confidence < {draft.brain_confidence_min_silver} get bronze (not served), ≥ get silver + + + + + Semantic match threshold (cosine distance) + setDraft({ ...draft, brain_semantic_threshold: Number(v) })} + /> + + Lower = more strict match. Default 0.08 ≈ similarity 0.92 + + + + + + Per-component cache opt-in + + {(['techniques', 'ai_tampered', 'claims'] as const).map((comp) => ( + setDraft({ + ...draft, + brain_per_component: { ...draft.brain_per_component, [comp]: e.target.checked }, + })} + /> + } + label={comp.replace('_', ' ')} + /> + ))} + + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ModerationSettings/RolesCard.tsx b/backend/admin-dashboard/src/components/ModerationSettings/RolesCard.tsx new file mode 100644 index 0000000..e70a0ac --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/RolesCard.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Card, CardContent, Typography, Box, Switch, Table, TableBody, TableCell, TableHead, TableRow, Chip } from '@mui/material'; +import type { ModerationRole } from './api'; +import { updateModerationRole } from './api'; + +interface Props { + roles: ModerationRole[]; + onChanged: () => void; + onError: (msg: string) => void; +} + +const TOGGLE_FIELDS: Array = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active']; + +export const RolesCard: React.FC = ({ roles, onChanged, onError }) => { + const toggle = async (code: string, field: keyof ModerationRole, value: boolean) => { + try { + const res = await updateModerationRole(code, { [field]: value } as Partial); + if (res.success) onChanged(); + else onError(res.error ?? 'Update failed'); + } catch (e) { + onError((e as Error).message); + } + }; + + return ( + + + Roles & Permissions + + Maps Keycloak realm roles to HIL actions. Toggle changes auto-sync to Redis. + + + + + + Role + can_resolve + can_escalate + can_force_gold_brain + active + + + + {roles.map((role) => ( + + + + + + {role.role_label} + + + + {TOGGLE_FIELDS.map((field) => ( + + toggle(role.role_code, field, e.target.checked)} + size="small" + /> + + ))} + + ))} + +
+
+
+ ); +}; diff --git a/backend/admin-dashboard/src/components/ModerationSettings/SensitiveTopicsCard.tsx b/backend/admin-dashboard/src/components/ModerationSettings/SensitiveTopicsCard.tsx new file mode 100644 index 0000000..21aa221 --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/SensitiveTopicsCard.tsx @@ -0,0 +1,409 @@ +import React, { useState } from 'react'; +import { + Card, CardContent, Typography, Box, Chip, TextField, Button, IconButton, Switch, FormControlLabel, + Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, Stack, Alert, Tooltip, +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Restore as RestoreIcon, + Tune as TuneIcon, +} from '@mui/icons-material'; +import type { SensitiveTopic, Volatility } from './api'; +import { createSensitiveTopic, deleteSensitiveTopic, updateSensitiveTopic } from './api'; + +interface Props { + topics: SensitiveTopic[]; + onChanged: () => void; + onError: (msg: string) => void; +} + +const VOLATILITY_LABEL: Record = { + volatile: 'Volatile', + evolving: 'Evolving', + stable: 'Stable', +}; + +const VOLATILITY_COLOR: Record = { + volatile: 'error', + evolving: 'warning', + stable: 'success', +}; + +// Sensible per-volatility defaults to seed the form when creating new topics +// or switching the volatility in the edit dialog. +const VOLATILITY_DEFAULTS: Record< + Volatility, + { cache_ttl_hours: number; recency_window_days: number; half_life_days: number } +> = { + volatile: { cache_ttl_hours: 24, recency_window_days: 7, half_life_days: 3 }, + evolving: { cache_ttl_hours: 168, recency_window_days: 14, half_life_days: 14 }, + stable: { cache_ttl_hours: 720, recency_window_days: 180, half_life_days: 180 }, +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Edit volatility dialog +// ────────────────────────────────────────────────────────────────────────────── + +interface EditDialogProps { + topic: SensitiveTopic | null; + onClose: () => void; + onSaved: () => void; + onError: (msg: string) => void; +} + +const EditVolatilityDialog: React.FC = ({ topic, onClose, onSaved, onError }) => { + const open = topic !== null; + const initialVol: Volatility = (topic?.volatility ?? 'evolving') as Volatility; + const [volatility, setVolatility] = useState(initialVol); + const [ttl, setTtl] = useState(topic?.cache_ttl_hours ?? 720); + const [window_, setWindow] = useState(topic?.recency_window_days ?? 30); + const [halfLife, setHalfLife] = useState( + Number(topic?.half_life_days ?? 30) + ); + const [atomicPath, setAtomicPath] = useState(topic?.atomic_path_prefix ?? ''); + const [saving, setSaving] = useState(false); + + // When the dialog re-opens for a different topic, reset state. + React.useEffect(() => { + if (topic) { + setVolatility((topic.volatility ?? 'evolving') as Volatility); + setTtl(topic.cache_ttl_hours ?? 720); + setWindow(topic.recency_window_days ?? 30); + setHalfLife(Number(topic.half_life_days ?? 30)); + setAtomicPath(topic.atomic_path_prefix ?? ''); + } + }, [topic]); + + const applyVolatilityDefaults = (v: Volatility) => { + setVolatility(v); + const d = VOLATILITY_DEFAULTS[v]; + setTtl(d.cache_ttl_hours); + setWindow(d.recency_window_days); + setHalfLife(d.half_life_days); + }; + + const handleSave = async () => { + if (!topic) return; + setSaving(true); + try { + const trimmedPath = atomicPath.trim(); + const res = await updateSensitiveTopic(topic.topic_id, { + volatility, + cache_ttl_hours: ttl, + recency_window_days: window_, + half_life_days: halfLife, + // Empty string explicitly tells the backend to clear the value (NULL). + atomic_path_prefix: trimmedPath === '' ? '' : trimmedPath, + }); + if (res.success) { + onSaved(); + onClose(); + } else { + onError(res.error ?? 'Update failed'); + } + } catch (e) { + onError((e as Error).message); + } finally { + setSaving(false); + } + }; + + return ( + + + Volatility config — {topic?.topic_code ?? ''} + + + + These values are read by didi-brain to set cache TTL, recency window, and + age-decay half-life for content tagged with this topic. Pick a tier to seed + sensible defaults, then fine-tune if needed. + + + applyVolatilityDefaults(e.target.value as Volatility)} + > + Volatile (war, breaking news, daily politics) + Evolving (economy, health, ongoing trials) + Stable (settled science, history) + + setTtl(Number(e.target.value))} + inputProps={{ min: 1, max: 26280 }} + helperText="Hard cap: brain truncates verdicts touching this topic to this many hours" + /> + setWindow(Number(e.target.value))} + inputProps={{ min: 1, max: 365 }} + helperText="For volatile topics, evidence older than this is dropped from /v1/gather" + /> + setHalfLife(Number(e.target.value))} + inputProps={{ min: 0.1, step: 0.5 }} + helperText="Recency boost decays with this half-life when ranking evidence" + /> + setAtomicPath(e.target.value)} + placeholder="e.g. Topics/Health/ or Topics/Politics/Elections" + helperText={ + 'Maps this policy topic to a path in the brain knowledge graph. ' + + 'Leave empty if no mapping. Used by classifier to also tag atoms ' + + 'for retrieval. No leading slash. Trailing slash is allowed.' + } + /> + + + + + + + + ); +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Main card +// ────────────────────────────────────────────────────────────────────────────── + +export const SensitiveTopicsCard: React.FC = ({ topics, onChanged, onError }) => { + const [showInactive, setShowInactive] = useState(false); + const [newCode, setNewCode] = useState(''); + const [newLabel, setNewLabel] = useState(''); + const [newVolatility, setNewVolatility] = useState('evolving'); + const [newAtomicPath, setNewAtomicPath] = useState(''); + const [creating, setCreating] = useState(false); + const [editing, setEditing] = useState(null); + + const filtered = topics.filter((t) => showInactive || t.is_active); + + const handleAdd = async () => { + if (!newCode.trim() || !newLabel.trim()) { + onError('Both topic_code and label are required'); + return; + } + setCreating(true); + try { + const defaults = VOLATILITY_DEFAULTS[newVolatility]; + const trimmedPath = newAtomicPath.trim(); + const res = await createSensitiveTopic({ + topic_code: newCode.trim(), + topic_label: newLabel.trim(), + volatility: newVolatility, + ...defaults, + ...(trimmedPath ? { atomic_path_prefix: trimmedPath } : {}), + }); + if (res.success) { + setNewCode(''); + setNewLabel(''); + setNewVolatility('evolving'); + setNewAtomicPath(''); + onChanged(); + } else { + onError(res.error ?? 'Create failed'); + } + } catch (e) { + onError((e as Error).message); + } finally { + setCreating(false); + } + }; + + const handleDeactivate = async (id: number) => { + try { + const res = await deleteSensitiveTopic(id); + if (res.success) onChanged(); + else onError(res.error ?? 'Delete failed'); + } catch (e) { + onError((e as Error).message); + } + }; + + const handleReactivate = async (id: number) => { + try { + const res = await updateSensitiveTopic(id, { is_active: true }); + if (res.success) onChanged(); + else onError(res.error ?? 'Reactivate failed'); + } catch (e) { + onError((e as Error).message); + } + }; + + return ( + + + + + Sensitive Topics + + Topics that trigger HIL review when detected, with per-topic brain cache volatility config. + Auto-syncs to Redis on change. + + + setShowInactive(e.target.checked)} />} + label="Show inactive" + /> + + + + {filtered.length === 0 && ( + No topics. + )} + {filtered.map((t) => { + const volatility = (t.volatility ?? 'evolving') as Volatility; + return ( + + + + + {t.topic_code} + + + {t.topic_label} + + {t.atomic_path_prefix && ( + + + ↳ {t.atomic_path_prefix} + + + )} + + + + + + + + + setEditing(t)}> + + + + {t.is_active ? ( + + handleDeactivate(t.topic_id)}> + + + + ) : ( + + handleReactivate(t.topic_id)}> + + + + )} + + ); + })} + + + + setNewCode(e.target.value.toLowerCase())} + helperText="lowercase, [a-z0-9_]" + sx={{ width: 180 }} + /> + setNewLabel(e.target.value)} + sx={{ minWidth: 180, flex: 1 }} + /> + setNewVolatility(e.target.value as Volatility)} + sx={{ width: 140 }} + > + Volatile + Evolving + Stable + + setNewAtomicPath(e.target.value)} + helperText="Brain taxonomy bridge" + sx={{ minWidth: 200, flex: 1 }} + /> + + + + setEditing(null)} + onSaved={onChanged} + onError={onError} + /> + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ModerationSettings/TriageCard.tsx b/backend/admin-dashboard/src/components/ModerationSettings/TriageCard.tsx new file mode 100644 index 0000000..49f76e2 --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/TriageCard.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { Card, CardContent, Typography, Box, Switch, TextField, Slider, Button, FormControlLabel, Alert } from '@mui/material'; +import { Save as SaveIcon } from '@mui/icons-material'; +import type { ModerationConfig } from './api'; +import { updateModerationConfig } from './api'; + +interface Props { + config: ModerationConfig; + onSaved: (next: ModerationConfig) => void; + onError: (msg: string) => void; +} + +export const TriageCard: React.FC = ({ config, onSaved, onError }) => { + const [draft, setDraft] = useState(config); + const [saving, setSaving] = useState(false); + + React.useEffect(() => { setDraft(config); }, [config]); + + const dirty = JSON.stringify(draft) !== JSON.stringify(config); + + const handleSave = async () => { + setSaving(true); + try { + const res = await updateModerationConfig({ + triage_enabled: draft.triage_enabled, + confidence_low: Number(draft.confidence_low), + risk_grey_min: Number(draft.risk_grey_min), + risk_grey_max: Number(draft.risk_grey_max), + queue_relax_at: draft.queue_relax_at, + queue_strict_at: draft.queue_strict_at, + auto_tune_enabled: draft.auto_tune_enabled, + }); + if (res.success && res.data) { + onSaved(res.data); + } else { + onError(res.error ?? 'Update failed'); + } + } catch (e) { + onError((e as Error).message); + } finally { + setSaving(false); + } + }; + + return ( + + + + + Triage + + Decide which sessions enter the moderation queue. Edits sync to Redis on save. + + + setDraft({ ...draft, triage_enabled: e.target.checked })} + /> + } + label={draft.triage_enabled ? 'Enabled' : 'Disabled'} + labelPlacement="start" + /> + + + {!draft.triage_enabled && ( + + Triage is OFF. No sessions will enter the moderation queue. + + )} + + + + Confidence threshold (low) + setDraft({ ...draft, confidence_low: Number(v) })} + /> + + Sessions with confidence < {draft.confidence_low} go to queue + + + + + Risk grey zone + + setDraft({ ...draft, risk_grey_min: e.target.value })} + /> + + setDraft({ ...draft, risk_grey_max: e.target.value })} + /> + + + Sensitive topics with risk in this band go to queue + + + + + setDraft({ ...draft, queue_relax_at: parseInt(e.target.value, 10) || 0 })} + /> + + + setDraft({ ...draft, queue_strict_at: parseInt(e.target.value, 10) || 0 })} + /> + + + + setDraft({ ...draft, auto_tune_enabled: e.target.checked })} + /> + } + label="Auto-tune thresholds (cron)" + /> + + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ModerationSettings/api.ts b/backend/admin-dashboard/src/components/ModerationSettings/api.ts new file mode 100644 index 0000000..6bbd28d --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/api.ts @@ -0,0 +1,171 @@ +/** + * API client for Moderation Settings — talks to didiFramework on /framework/api/*. + * All endpoints from migration 011 + Phase 1.2 routes. + */ + +const FRAMEWORK_API = '/framework/api'; + +export interface ModerationConfig { + config_id: number; + triage_enabled: boolean; + confidence_low: string | number; + risk_grey_min: string | number; + risk_grey_max: string | number; + queue_relax_at: number; + queue_strict_at: number; + auto_tune_enabled: boolean; + brain_enabled: boolean; + brain_url: string; + brain_lookup_timeout_ms: number; + brain_write_timeout_ms: number; + brain_confidence_min_silver: string | number; + brain_semantic_threshold: string | number; + brain_per_component: { techniques: boolean; ai_tampered: boolean; claims: boolean }; + updated_by: string | null; + updated_at: string; +} + +export type Volatility = 'volatile' | 'evolving' | 'stable'; + +export interface SensitiveTopic { + topic_id: number; + topic_code: string; + topic_label: string; + is_active: boolean; + // Phase D1 — drives brain cache TTL + recency boost. + // Optional in TS so legacy callers / older API responses still type-check; + // backend always returns them after migration 012. + volatility?: Volatility; + cache_ttl_hours?: number; + recency_window_days?: number; + half_life_days?: number | string; // numeric column comes back as string + // Migration 014 — bridge to atomic-server taxonomy. Optional, NULL when no + // mapping is defined. Format: "Topics/Health/" or "Topics/Politics/Elections". + atomic_path_prefix?: string | null; + created_at: string; + updated_at: string; +} + +export interface SensitiveTopicVolatilityPatch { + volatility?: Volatility; + cache_ttl_hours?: number; + recency_window_days?: number; + half_life_days?: number; + // Empty string clears the value (server maps '' → NULL). + atomic_path_prefix?: string | null; +} + +export interface ModerationRole { + role_code: string; + role_label: string; + can_resolve: boolean; + can_escalate: boolean; + can_force_gold_brain: boolean; + is_active: boolean; + created_at: string; + updated_at: string; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +async function asJson(res: Response): Promise> { + try { + return (await res.json()) as ApiResponse; + } catch { + return { success: false, error: `${res.status} ${res.statusText}` }; + } +} + +async function authedFetch(input: string, init: RequestInit = {}): Promise { + const token = localStorage.getItem('keycloak_token'); + const headers: HeadersInit = { + ...(init.headers || {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + return fetch(input, { ...init, headers }); +} + +// ─── moderation-config ─── +export async function getModerationConfig(): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`); + return asJson(res); +} + +export async function updateModerationConfig(patch: Partial): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/moderation-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return asJson(res); +} + +// ─── sensitive-topics ─── +export async function listSensitiveTopics(active: 'true' | 'false' | 'all' = 'all'): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics?active=${active}`); + return asJson(res); +} + +export async function createSensitiveTopic( + body: { + topic_code: string; + topic_label: string; + is_active?: boolean; + } & SensitiveTopicVolatilityPatch +): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return asJson(res); +} + +export async function updateSensitiveTopic( + id: number, + patch: { + topic_label?: string; + is_active?: boolean; + } & SensitiveTopicVolatilityPatch +): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return asJson(res); +} + +export async function deleteSensitiveTopic(id: number): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/sensitive-topics/${id}`, { method: 'DELETE' }); + return asJson(res); +} + +// ─── moderation-roles ─── +export async function listModerationRoles(): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles`); + return asJson(res); +} + +export async function updateModerationRole(code: string, patch: Partial): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/moderation-roles/${encodeURIComponent(code)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + return asJson(res); +} + +// ─── sync-redis ─── +export async function syncRedis(): Promise> { + const res = await authedFetch(`${FRAMEWORK_API}/sync-redis`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + return asJson<{ keys_written: number }>(res); +} diff --git a/backend/admin-dashboard/src/components/ModerationSettings/index.tsx b/backend/admin-dashboard/src/components/ModerationSettings/index.tsx new file mode 100644 index 0000000..613f6ac --- /dev/null +++ b/backend/admin-dashboard/src/components/ModerationSettings/index.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Typography, Alert, CircularProgress, Snackbar, Button } from '@mui/material'; +import { Refresh as RefreshIcon, CloudSync as SyncIcon } from '@mui/icons-material'; +import { + getModerationConfig, + listSensitiveTopics, + listModerationRoles, + syncRedis, + type ModerationConfig, + type SensitiveTopic, + type ModerationRole, +} from './api'; +import { TriageCard } from './TriageCard'; +import { BrainClientCard } from './BrainClientCard'; +import { SensitiveTopicsCard } from './SensitiveTopicsCard'; +import { RolesCard } from './RolesCard'; + +export const ModerationSettings: React.FC = () => { + const [config, setConfig] = useState(null); + const [topics, setTopics] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const loadAll = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [cfgRes, topRes, rolRes] = await Promise.all([ + getModerationConfig(), + listSensitiveTopics('all'), + listModerationRoles(), + ]); + if (cfgRes.success && cfgRes.data) setConfig(cfgRes.data); + else setError(cfgRes.error ?? 'Failed to load config'); + if (topRes.success && topRes.data) setTopics(topRes.data); + if (rolRes.success && rolRes.data) setRoles(rolRes.data); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadAll(); }, [loadAll]); + + const handleSync = async () => { + setSyncing(true); + setError(null); + try { + const res = await syncRedis(); + if (res.success) { + setSuccess(`Synced to Redis (${res.data?.keys_written ?? '?'} keys)`); + } else { + setError(res.error ?? 'Sync failed'); + } + } catch (e) { + setError((e as Error).message); + } finally { + setSyncing(false); + } + }; + + const handleConfigSaved = async (next: ModerationConfig) => { + setConfig(next); + setSuccess('Config saved. Syncing to Redis…'); + await handleSync(); + }; + + const handleTopicsChanged = async () => { + const res = await listSensitiveTopics('all'); + if (res.success && res.data) setTopics(res.data); + setSuccess('Topics updated. Syncing to Redis…'); + await handleSync(); + }; + + const handleRolesChanged = async () => { + const res = await listModerationRoles(); + if (res.success && res.data) setRoles(res.data); + setSuccess('Roles updated. Syncing to Redis…'); + await handleSync(); + }; + + if (loading && !config) { + return ( + + + + ); + } + + if (error && !config) { + return ( + + Retry}> + {error} + + + ); + } + + if (!config) return null; + + return ( + + + + + Moderation Settings + + + Triage rules + brain client + sensitive topics + roles. All settings persist in PG and sync to Redis. + + + + + + + + + {error && setError(null)} sx={{ mb: 2 }}>{error}} + + + + + + + + + setSuccess(null)} + message={success} + /> + + ); +}; + +export default ModerationSettings; diff --git a/backend/admin-dashboard/src/components/Pipelines/DryRunDialog.tsx b/backend/admin-dashboard/src/components/Pipelines/DryRunDialog.tsx new file mode 100644 index 0000000..226af21 --- /dev/null +++ b/backend/admin-dashboard/src/components/Pipelines/DryRunDialog.tsx @@ -0,0 +1,176 @@ +/** + * Dry-run dialog — resolves & shows the execution plan for a pipeline without + * dispatching (Run Console-lite: "ce ar rula", no credits consumed). Also + * offers a real async run + cancel so the operator can exercise INT-3 live. + */ +import React, { useState } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, + Select, MenuItem, TextField, Chip, CircularProgress, Alert, Paper, Divider, + Stack, Table, TableBody, TableCell, TableRow, +} from '@mui/material'; +import { pipelinesApi, DryRunResult, PipelineProfile } from './pipelinesApi'; + +const MEDIA_TYPES = ['text', 'url', 'image', 'audio', 'video']; + +interface Props { + open: boolean; + profile: PipelineProfile | null; + onClose: () => void; +} + +// Map a pipeline profile_code to the media_type its dry-run should use. +function defaultMediaType(code: string): string { + if (code.startsWith('text')) return 'text'; + if (['image', 'audio', 'video', 'url'].includes(code)) return code; + return 'text'; +} + +export const DryRunDialog: React.FC = ({ open, profile, onClose }) => { + const [mediaType, setMediaType] = useState('text'); + const [text, setText] = useState('Guvernul ascunde adevărul despre acest subiect, conform unor surse anonime neverificate.'); + const [planType, setPlanType] = useState(1); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + React.useEffect(() => { + if (profile) { setMediaType(defaultMediaType(profile.profile_code)); setResult(null); setError(null); } + }, [profile]); + + const runDryRun = async () => { + setLoading(true); setError(null); setResult(null); + try { + const r = await pipelinesApi.dryRun({ + media_type: mediaType, + text: mediaType === 'text' || mediaType === 'url' ? text : undefined, + url: mediaType === 'url' ? 'https://example.com/article' : undefined, + media_url: ['image', 'audio', 'video'].includes(mediaType) ? 'https://example.com/media' : undefined, + user_id: 'dashboard-dryrun', + plan_type: planType, + }); + setResult(r); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + return ( + + + Dry-run — {profile?.profile_name} + + Rezolvă planul de execuție fără dispatch (zero credite consumate) + + + + + + + + + + {(mediaType === 'text' || mediaType === 'url') && ( + setText(e.target.value)} + /> + )} + + {error && {error}} + + {result && ( + + + Flow + {result.flow} + + + {result.verdict_profile && ( + + + Verdict profile: {result.verdict_profile.profile_code} + + + {Object.entries(result.verdict_profile.weights).map(([k, v]) => ( + + ))} + + + )} + + Nodes ({result.nodes.length}) + {result.nodes.map(n => ( + + + + {n.queue && {n.queue}} + {n.timeout_ms && } + + {n.depends_on && ( + + depends_on: {n.depends_on.join(', ')} + + )} + {n.produces && ( + + {n.produces.map(p => )} + + )} + {n.stages && Object.entries(n.stages).map(([stage, tiers]) => ( + + {stage} + {Object.entries(tiers).map(([tier, models]) => ( + + + {models.map(m => ( + + + {tier} + + + {m.role} + + + {m.model_key} + + + ))} + +
+ ))} +
+ ))} +
+ ))} + + {result.skipped.length > 0 && ( + <> + + Skipped + {result.skipped.map(s => ( + + {s.component} — {s.reason} + + ))} + + )} + + {result.note} +
+ )} +
+ + + +
+ ); +}; diff --git a/backend/admin-dashboard/src/components/Pipelines/PipelinesPage.tsx b/backend/admin-dashboard/src/components/Pipelines/PipelinesPage.tsx new file mode 100644 index 0000000..1cbfc78 --- /dev/null +++ b/backend/admin-dashboard/src/components/Pipelines/PipelinesPage.tsx @@ -0,0 +1,257 @@ +/** + * Pipelines page — CRUD + lifecycle over the REAL pipeline definitions + * (input_type_profile) plus dry-run (Run Console-lite). Modul 1 caiet: + * „Workflow Builder: CRUD/versionare pipeline; Run Console". + * + * Backed by /framework/api/pipelines + /agent-v3/api/v3/pipeline/dry-run. + * NOT the dead pipelineApi (services/api/pipeline.ts). + */ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Box, Typography, Paper, Table, TableHead, TableRow, TableCell, TableBody, + Chip, IconButton, Button, Stack, CircularProgress, Alert, Tooltip, + Dialog, DialogTitle, DialogContent, DialogActions, TextField, Collapse, +} from '@mui/material'; +import { + PlayArrow as DryRunIcon, + ContentCopy as CloneIcon, + ToggleOn as ActivateIcon, + ToggleOff as DeactivateIcon, + History as VersionsIcon, + Restore as RestoreIcon, + Refresh as RefreshIcon, + Download as ExportIcon, + Upload as ImportIcon, +} from '@mui/icons-material'; +import { pipelinesApi, PipelineProfile, PipelineVersion } from './pipelinesApi'; +import { DryRunDialog } from './DryRunDialog'; + +export const PipelinesPage: React.FC = () => { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + + const [dryRunProfile, setDryRunProfile] = useState(null); + const [cloneSource, setCloneSource] = useState(null); + const [cloneCode, setCloneCode] = useState(''); + const [cloneName, setCloneName] = useState(''); + const [versionsFor, setVersionsFor] = useState(null); + const [versions, setVersions] = useState([]); + + const load = useCallback(async () => { + setLoading(true); setError(null); + try { + setProfiles(await pipelinesApi.list()); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const toggleActive = async (p: PipelineProfile) => { + setBusy(p.profile_code); + try { + if (p.is_active) await pipelinesApi.deactivate(p.profile_code); + else await pipelinesApi.activate(p.profile_code); + await load(); + } catch (e) { setError((e as Error).message); } finally { setBusy(null); } + }; + + const doClone = async () => { + if (!cloneSource || !cloneCode) return; + setBusy(cloneSource.profile_code); + try { + await pipelinesApi.clone(cloneSource.profile_code, cloneCode, cloneName || undefined); + setCloneSource(null); setCloneCode(''); setCloneName(''); + await load(); + } catch (e) { setError((e as Error).message); } finally { setBusy(null); } + }; + + const showVersions = async (code: string) => { + if (versionsFor === code) { setVersionsFor(null); return; } + try { + setVersions(await pipelinesApi.versions(code)); + setVersionsFor(code); + } catch (e) { setError((e as Error).message); } + }; + + const restore = async (code: string, versionId: number) => { + setBusy(code); + try { + await pipelinesApi.restore(code, versionId); + await load(); + setVersions(await pipelinesApi.versions(code)); + } catch (e) { setError((e as Error).message); } finally { setBusy(null); } + }; + + const exportProfile = async (code: string) => { + try { + const full = await pipelinesApi.get(code); // includes overrides + const blob = new Blob([JSON.stringify(full, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `pipeline-${code}.json`; a.click(); + URL.revokeObjectURL(url); + } catch (e) { setError((e as Error).message); } + }; + + const importProfile = (file: File) => { + const reader = new FileReader(); + reader.onload = async () => { + try { + const parsed = JSON.parse(String(reader.result)); + // Prefix new_code to avoid clashing with the source it was exported from. + const base = parsed.profile_code || 'imported'; + await pipelinesApi.import({ ...parsed, new_code: `${base}_import` }); + await load(); + } catch (e) { setError(`Import failed: ${(e as Error).message}`); } + }; + reader.readAsText(file); + }; + + return ( + + + + Pipelines + + Definiții de pipeline (componente, ponderi, praguri per tip de input) — clonare, versionare, activare, dry-run + + + + + + + + + {error && setError(null)}>{error}} + + {loading ? ( + + ) : ( + + + + + Pipeline + Status + Weights (tech / claims / ai / source) + Actions + + + + {profiles.map(p => ( + + + + {p.profile_name} + {p.profile_code} + + + + + + + {p.weight_techniques} / {p.weight_claims} / {p.weight_ai_tampered} / {p.weight_source} + + + + + setDryRunProfile(p)}> + + + { setCloneSource(p); setCloneCode(`${p.profile_code}_copy`); }}> + + + + toggleActive(p)}> + {p.is_active ? : } + + + + + exportProfile(p.profile_code)}> + + + showVersions(p.profile_code)}> + + + + + + + + Version history + {versions.length === 0 && No versions yet} + {versions.map(v => ( + + + {v.change_note} + + {new Date(v.changed_at).toLocaleString()} {v.changed_by ? `· ${v.changed_by}` : ''} + + + + restore(p.profile_code, v.version_id)}> + + + + + + ))} + + + + + + ))} + +
+
+ )} + + setDryRunProfile(null)} /> + + setCloneSource(null)} maxWidth="xs" fullWidth> + Clone pipeline + + + From {cloneSource?.profile_code}. The clone starts inactive. + + setCloneCode(e.target.value)} + helperText="lowercase, [a-z0-9_-]" + /> + setCloneName(e.target.value)} + /> + + + + + + +
+ ); +}; + +export default PipelinesPage; diff --git a/backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts b/backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts new file mode 100644 index 0000000..2ac6d9a --- /dev/null +++ b/backend/admin-dashboard/src/components/Pipelines/pipelinesApi.ts @@ -0,0 +1,115 @@ +/** + * Pipelines API client — talks to the REAL endpoints: + * - didiFramework /framework/api/pipelines/* (input_type_profile = pipeline definition) + * - agent-v3 /agent-v3/api/v3/pipeline/dry-run and /:id/cancel + * + * NOT the dead pipelineApi (services/api/pipeline.ts → /api/v1/pipelines, + * which has no backend). Everything here is backed by live routes. + */ +const FW = '/framework/api/pipelines'; +const AGENT = '/agent-v3/api/v3/pipeline'; + +function authHeader(): Record { + const token = localStorage.getItem('keycloak_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function req(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { + ...init, + headers: { 'Content-Type': 'application/json', ...authHeader(), ...(init?.headers || {}) }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body?.success === false) { + throw new Error(body?.error || `Request failed (${res.status})`); + } + return body; +} + +export interface PipelineProfile { + profile_code: string; + profile_name: string; + description?: string; + is_active: boolean; + weight_techniques: number; + weight_claims: number; + weight_ai_tampered: number; + weight_source: number; + min_components: number; + override_cap: number; + overrides?: unknown[]; +} + +export interface PipelineVersion { + version_id: number; + version_no: number; + changed_at: string; + changed_by: string | null; + change_note: string; +} + +export interface DryRunResult { + media_type: string; + plan_type: number; + flow: string; + results_queue: string; + nodes: Array<{ + component: string; + queue?: string; + priority?: number; + timeout_ms?: number; + depends_on?: string[]; + produces?: string[]; + stages?: Record>>; + prompt_config_keys?: string[]; + }>; + skipped: Array<{ component: string; reason: string }>; + verdict_profile: { + profile_code: string; + profile_name: string; + weights: Record; + min_components: number; + override_cap: number; + } | null; + note: string; +} + +export const pipelinesApi = { + list: () => req<{ data: PipelineProfile[]; count: number }>(FW).then(r => r.data), + + get: (code: string) => req<{ data: PipelineProfile }>(`${FW}/${code}`).then(r => r.data), + + clone: (code: string, new_code: string, new_name?: string) => + req<{ data: PipelineProfile }>(`${FW}/${code}/clone`, { + method: 'POST', body: JSON.stringify({ new_code, new_name }), + }).then(r => r.data), + + activate: (code: string) => req(`${FW}/${code}/activate`, { method: 'POST' }), + deactivate: (code: string) => req(`${FW}/${code}/deactivate`, { method: 'POST' }), + + update: (code: string, patch: Partial & { change_note?: string }) => + req<{ data: PipelineProfile; version_saved: number }>(`${FW}/${code}`, { + method: 'PUT', body: JSON.stringify(patch), + }), + + versions: (code: string) => + req<{ data: PipelineVersion[] }>(`${FW}/${code}/versions`).then(r => r.data), + + restore: (code: string, versionId: number) => + req(`${FW}/${code}/versions/${versionId}/restore`, { method: 'POST' }), + + dryRun: (body: { media_type: string; text?: string; url?: string; media_url?: string; user_id?: string; plan_type?: number; options?: unknown }) => + req<{ data: DryRunResult }>(`${AGENT}/dry-run`, { + method: 'POST', body: JSON.stringify(body), + }).then(r => r.data), + + cancel: (sessionId: string, user_id?: string) => + req(`${AGENT}/${sessionId}/cancel`, { + method: 'POST', body: JSON.stringify({ user_id }), + }), + + import: (payload: unknown) => + req<{ data: PipelineProfile }>(`${FW}/import`, { + method: 'POST', body: JSON.stringify(payload), + }).then(r => r.data), +}; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.tsx new file mode 100644 index 0000000..7f110aa --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.tsx @@ -0,0 +1,464 @@ +/** + * LLM Providers Management - Full CRUD + * + * Manages LLM providers, models, component assignments, and API keys. + * Integrated with PostgreSQL backend via /framework/api/providers/* endpoints. + * + * Original 1159-line file split into: + * ProvidersManagement.types.ts — domain types + TabPanel + API_BASE + * api.ts — 13 fetch wrappers + * tabs/{Providers,Models,Assignments,ApiKeys}Tab.tsx — 4 entity tables + * dialogs/{Provider,Model,Assignment,ApiKey,DeleteConfirm}Dialog.tsx — 5 dialogs + * ProvidersManagement.tsx (this) — state + handlers + render orchestrator + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Container, Paper, Typography, Tabs, Tab, Button, CircularProgress, Alert, +} from '@mui/material'; +import { + Refresh as RefreshIcon, + Key as KeyIcon, Memory as ModelIcon, + Assignment as AssignmentIcon, CloudQueue as ProviderIcon, +} from '@mui/icons-material'; +import { + type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey, + TabPanel, +} from './ProvidersManagement.types'; +import { + fetchAll, + createProvider, updateProvider, deleteProvider, + createModel, updateModel, deleteModel, + createAssignment, updateAssignment, deleteAssignment, + createApiKey, updateApiKey, deleteApiKey, + testProvider, +} from './api'; +import { ProvidersTab } from './tabs/ProvidersTab'; +import { ModelsTab } from './tabs/ModelsTab'; +import { AssignmentsTab } from './tabs/AssignmentsTab'; +import { ApiKeysTab } from './tabs/ApiKeysTab'; +import { ProviderDialog } from './dialogs/ProviderDialog'; +import { ModelDialog } from './dialogs/ModelDialog'; +import { AssignmentDialog } from './dialogs/AssignmentDialog'; +import { ApiKeyDialog } from './dialogs/ApiKeyDialog'; +import { DeleteConfirmDialog } from './dialogs/DeleteConfirmDialog'; + +export const ProvidersManagement: React.FC = () => { + const [tabValue, setTabValue] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [providers, setProviders] = useState([]); + const [models, setModels] = useState([]); + const [assignments, setAssignments] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + + // Dialog states + const [providerDialog, setProviderDialog] = useState<{ open: boolean; data: Partial | null }>({ open: false, data: null }); + const [modelDialog, setModelDialog] = useState<{ open: boolean; data: Partial | null }>({ open: false, data: null }); + const [assignmentDialog, setAssignmentDialog] = useState<{ open: boolean; data: Partial | null }>({ open: false, data: null }); + const [apiKeyDialog, setApiKeyDialog] = useState<{ open: boolean; data: Partial | null }>({ open: false, data: null }); + const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; type: string; id: number; name: string } | null>(null); + + // Test connection state + const [testingProvider, setTestingProvider] = useState(null); + const [testResult, setTestResult] = useState<{ providerId: number; success: boolean; latencyMs?: number; error?: string } | null>(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchAll(); + if (data.success) { + setProviders(data.data.providers || []); + setModels(data.data.models || []); + setAssignments(data.data.assignments || []); + setApiKeys(data.data.keys || []); + } else { + setError(data.error || 'Failed to fetch data'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch data'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + const showSuccess = (message: string) => { + setSuccess(message); + setTimeout(() => setSuccess(null), 3000); + }; + + const showError = (message: string) => { + setError(message); + setTimeout(() => setError(null), 5000); + }; + + // ─── Provider CRUD ──────────────────────────────────────────────────────── + + const handleSaveProvider = async () => { + const data = providerDialog.data; + if (!data) return; + try { + const isEdit = 'provider_id' in data && data.provider_id; + const result = isEdit + ? await updateProvider(data.provider_id as number, data) + : await createProvider(data); + + if (result.success) { + showSuccess(isEdit ? 'Provider updated successfully' : 'Provider created successfully'); + setProviderDialog({ open: false, data: null }); + fetchData(); + } else { + showError(result.error || 'Operation failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Operation failed'); + } + }; + + const handleDeleteProvider = async () => { + if (!deleteDialog || deleteDialog.type !== 'provider') return; + try { + const result = await deleteProvider(deleteDialog.id); + if (result.success) { + showSuccess('Provider deleted successfully'); + setDeleteDialog(null); + fetchData(); + } else { + showError(result.error || 'Delete failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleToggleProviderActive = async (provider: LlmProvider) => { + try { + const result = await updateProvider(provider.provider_id, { is_active: !provider.is_active }); + if (result.success) fetchData(); + } catch (err) { + showError(err instanceof Error ? err.message : 'Toggle failed'); + } + }; + + const handleTestProvider = async (providerId: number) => { + setTestingProvider(providerId); + setTestResult(null); + try { + const result = await testProvider(providerId); + setTestResult({ + providerId, + success: result.data?.success || false, + latencyMs: result.data?.latencyMs, + error: result.data?.error, + }); + } catch (err) { + setTestResult({ + providerId, + success: false, + error: err instanceof Error ? err.message : 'Test failed', + }); + } finally { + setTestingProvider(null); + } + }; + + // ─── Model CRUD ─────────────────────────────────────────────────────────── + + const handleSaveModel = async () => { + const data = modelDialog.data; + if (!data) return; + try { + const isEdit = 'model_id' in data && data.model_id; + const result = isEdit + ? await updateModel(data.model_id as number, data) + : await createModel(data); + + if (result.success) { + showSuccess(isEdit ? 'Model updated successfully' : 'Model created successfully'); + setModelDialog({ open: false, data: null }); + fetchData(); + } else { + showError(result.error || 'Operation failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Operation failed'); + } + }; + + const handleDeleteModel = async () => { + if (!deleteDialog || deleteDialog.type !== 'model') return; + try { + const result = await deleteModel(deleteDialog.id); + if (result.success) { + showSuccess('Model deleted successfully'); + setDeleteDialog(null); + fetchData(); + } else { + showError(result.error || 'Delete failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleToggleModelActive = async (model: LlmModel) => { + try { + const result = await updateModel(model.model_id, { is_active: !model.is_active }); + if (result.success) fetchData(); + } catch (err) { + showError(err instanceof Error ? err.message : 'Toggle failed'); + } + }; + + // ─── Assignment CRUD ────────────────────────────────────────────────────── + + const handleSaveAssignment = async () => { + const data = assignmentDialog.data; + if (!data) return; + try { + const isEdit = 'assignment_id' in data && data.assignment_id; + const result = isEdit + ? await updateAssignment(data.assignment_id as number, data) + : await createAssignment(data); + + if (result.success) { + showSuccess(isEdit ? 'Assignment updated successfully' : 'Assignment created successfully'); + setAssignmentDialog({ open: false, data: null }); + fetchData(); + } else { + showError(result.error || 'Operation failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Operation failed'); + } + }; + + const handleDeleteAssignment = async () => { + if (!deleteDialog || deleteDialog.type !== 'assignment') return; + try { + const result = await deleteAssignment(deleteDialog.id); + if (result.success) { + showSuccess('Assignment deleted successfully'); + setDeleteDialog(null); + fetchData(); + } else { + showError(result.error || 'Delete failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleToggleAssignmentEnabled = async (assignment: ComponentAssignment) => { + try { + const result = await updateAssignment(assignment.assignment_id, { is_enabled: !assignment.is_enabled }); + if (result.success) fetchData(); + } catch (err) { + showError(err instanceof Error ? err.message : 'Toggle failed'); + } + }; + + // ─── API Key CRUD ───────────────────────────────────────────────────────── + + const handleSaveApiKey = async () => { + const data = apiKeyDialog.data; + if (!data) return; + try { + const isEdit = 'api_key_id' in data && data.api_key_id; + const result = isEdit + ? await updateApiKey(data.api_key_id as number, data) + : await createApiKey(data as { provider_id: number; key_name: string; api_key_value: string }); + + if (result.success) { + showSuccess(isEdit ? 'API Key updated successfully' : 'API Key created successfully'); + setApiKeyDialog({ open: false, data: null }); + fetchData(); + } else { + showError(result.error || 'Operation failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Operation failed'); + } + }; + + const handleDeleteApiKey = async () => { + if (!deleteDialog || deleteDialog.type !== 'apikey') return; + try { + const result = await deleteApiKey(deleteDialog.id); + if (result.success) { + showSuccess('API Key deleted successfully'); + setDeleteDialog(null); + fetchData(); + } else { + showError(result.error || 'Delete failed'); + } + } catch (err) { + showError(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const handleToggleApiKeyActive = async (key: ApiKey) => { + try { + const result = await updateApiKey(key.api_key_id, { is_active: !key.is_active }); + if (result.success) fetchData(); + } catch (err) { + showError(err instanceof Error ? err.message : 'Toggle failed'); + } + }; + + // ─── Dispatcher: which delete handler runs based on deleteDialog.type ──── + + const handleDeleteConfirm = () => { + if (!deleteDialog) return; + switch (deleteDialog.type) { + case 'provider': return handleDeleteProvider(); + case 'model': return handleDeleteModel(); + case 'assignment': return handleDeleteAssignment(); + case 'apikey': return handleDeleteApiKey(); + } + }; + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( + + + + LLM Providers + + Manage LLM providers, models, assignments, and API keys + + + + + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + + {loading && providers.length === 0 ? ( + + + + ) : ( + + setTabValue(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}> + } label={`Providers (${providers.length})`} iconPosition="start" /> + } label={`Models (${models.length})`} iconPosition="start" /> + } label={`Assignments (${assignments.length})`} iconPosition="start" /> + } label={`API Keys (${apiKeys.length})`} iconPosition="start" /> + + + + setProviderDialog({ + open: true, + data: { provider_code: '', provider_name: '', base_url: '', auth_type: 'bearer', is_active: true, priority: 100, rate_limit_rpm: 60, rate_limit_tpm: 100000, description: '' }, + })} + onEdit={(p) => setProviderDialog({ open: true, data: p })} + onDelete={(p) => setDeleteDialog({ open: true, type: 'provider', id: p.provider_id, name: p.provider_name })} + onToggleActive={handleToggleProviderActive} + onTest={handleTestProvider} + /> + + + + 0} + onAdd={() => setModelDialog({ + open: true, + data: { provider_id: providers[0]?.provider_id, model_code: '', model_name: '', context_window: 32000, max_output_tokens: 4096, input_cost_per_1m: 0, output_cost_per_1m: 0, supports_streaming: true, supports_tools: true, supports_vision: false, is_active: true, description: '' }, + })} + onEdit={(m) => setModelDialog({ open: true, data: m })} + onDelete={(m) => setDeleteDialog({ open: true, type: 'model', id: m.model_id, name: m.model_name })} + onToggleActive={handleToggleModelActive} + /> + + + + 0 && models.length > 0} + onAdd={() => setAssignmentDialog({ + open: true, + data: { component_code: '', component_name: '', provider_id: providers[0]?.provider_id, model_id: models[0]?.model_id, temperature: 0.3, max_tokens: 4096, timeout_ms: 120000, is_enabled: true, description: '' }, + })} + onEdit={(a) => setAssignmentDialog({ open: true, data: a })} + onDelete={(a) => setDeleteDialog({ open: true, type: 'assignment', id: a.assignment_id, name: a.component_name })} + onToggleEnabled={handleToggleAssignmentEnabled} + /> + + + + 0} + onAdd={() => setApiKeyDialog({ + open: true, + data: { provider_id: providers[0]?.provider_id, key_name: '', api_key_value: '', is_active: true }, + })} + onEdit={(k) => setApiKeyDialog({ open: true, data: k })} + onDelete={(k) => setDeleteDialog({ open: true, type: 'apikey', id: k.api_key_id, name: k.key_name })} + onToggleActive={handleToggleApiKeyActive} + /> + + + )} + + {/* Dialogs */} + setProviderDialog({ open: false, data: null })} + onChange={(data) => setProviderDialog({ open: true, data })} + onSave={handleSaveProvider} + /> + + setModelDialog({ open: false, data: null })} + onChange={(data) => setModelDialog({ open: true, data })} + onSave={handleSaveModel} + /> + + setAssignmentDialog({ open: false, data: null })} + onChange={(data) => setAssignmentDialog({ open: true, data })} + onSave={handleSaveAssignment} + /> + + setApiKeyDialog({ open: false, data: null })} + onChange={(data) => setApiKeyDialog({ open: true, data })} + onSave={handleSaveApiKey} + /> + + setDeleteDialog(null)} + onConfirm={handleDeleteConfirm} + /> + + ); +}; + +export default ProvidersManagement; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.types.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.types.tsx new file mode 100644 index 0000000..6189755 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/ProvidersManagement.types.tsx @@ -0,0 +1,85 @@ +/** + * Domain types for the Providers Management page + a small TabPanel utility. + * + * All four entities (Provider, Model, ComponentAssignment, ApiKey) map 1:1 + * to /framework/api/providers/* endpoints and are joined into a single + * `/all` payload by the backend so we can render the dashboard with one fetch. + */ +import React from 'react'; +import { Box } from '@mui/material'; + +export const API_BASE = '/framework/api/providers'; + +export interface LlmProvider { + provider_id: number; + provider_code: string; + provider_name: string; + base_url: string; + auth_type: string; + is_active: boolean; + priority: number; + rate_limit_rpm: number; + rate_limit_tpm: number; + description: string; +} + +export interface LlmModel { + model_id: number; + provider_id: number; + provider_code?: string; + provider_name?: string; + model_code: string; + model_name: string; + context_window: number; + max_output_tokens: number; + input_cost_per_1m: number; + output_cost_per_1m: number; + supports_streaming: boolean; + supports_tools: boolean; + supports_vision: boolean; + is_active: boolean; + description: string; +} + +export interface ComponentAssignment { + assignment_id: number; + component_code: string; + component_name: string; + provider_id: number; + provider_code?: string; + model_id: number; + model_code?: string; + fallback_provider_id: number | null; + fallback_provider_code?: string; + fallback_model_id: number | null; + fallback_model_code?: string; + temperature: number; + max_tokens: number; + timeout_ms: number; + is_enabled: boolean; + description: string; +} + +export interface ApiKey { + api_key_id: number; + provider_id: number; + provider_code?: string; + key_name: string; + key_prefix: string; + is_active: boolean; + usage_count: number; + last_used_at: string | null; + expires_at: string | null; +} + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +export const TabPanel: React.FC = ({ children, value, index, ...other }) => ( + +); diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/api.ts b/backend/admin-dashboard/src/components/ProvidersManagement/api.ts new file mode 100644 index 0000000..e77e5d5 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/api.ts @@ -0,0 +1,114 @@ +/** + * Thin fetch wrappers for the /framework/api/providers/* CRUD surface. + * + * Each function returns whatever the backend wraps in its response (typically + * `{ success, data?, error? }`). Callers check `.success` and use `.data` / + * surface `.error` themselves — no error handling here so the page can show + * its own UX for failures (toasts, inline errors). + */ +import { + API_BASE, + type LlmProvider, type LlmModel, type ComponentAssignment, type ApiKey, +} from './ProvidersManagement.types'; + +export async function fetchAll() { + const res = await fetch(`${API_BASE}/all`); + return res.json(); +} + +export async function createProvider(data: Partial) { + const res = await fetch(`${API_BASE}/configs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateProvider(id: number, data: Partial) { + const res = await fetch(`${API_BASE}/configs/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function deleteProvider(id: number) { + const res = await fetch(`${API_BASE}/configs/${id}`, { method: 'DELETE' }); + return res.json(); +} + +export async function createModel(data: Partial) { + const res = await fetch(`${API_BASE}/models`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateModel(id: number, data: Partial) { + const res = await fetch(`${API_BASE}/models/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function deleteModel(id: number) { + const res = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' }); + return res.json(); +} + +export async function createAssignment(data: Partial) { + const res = await fetch(`${API_BASE}/assignments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateAssignment(id: number, data: Partial) { + const res = await fetch(`${API_BASE}/assignments/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function deleteAssignment(id: number) { + const res = await fetch(`${API_BASE}/assignments/${id}`, { method: 'DELETE' }); + return res.json(); +} + +export async function createApiKey(data: { provider_id: number; key_name: string; api_key_value: string; is_active?: boolean }) { + const res = await fetch(`${API_BASE}/keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function updateApiKey(id: number, data: Partial) { + const res = await fetch(`${API_BASE}/keys/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function deleteApiKey(id: number) { + const res = await fetch(`${API_BASE}/keys/${id}`, { method: 'DELETE' }); + return res.json(); +} + +export async function testProvider(providerId: number) { + const res = await fetch(`${API_BASE}/test/${providerId}`, { method: 'POST' }); + return res.json(); +} diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ApiKeyDialog.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ApiKeyDialog.tsx new file mode 100644 index 0000000..814c8d0 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ApiKeyDialog.tsx @@ -0,0 +1,77 @@ +/** + * Add/Edit dialog for an ApiKey row. + * + * `api_key_value` is a transient field — only sent to the backend on create + * or when explicitly changed during edit. Backend stores it encrypted and + * only returns the prefix on subsequent reads. + * + * Toggle button hides/shows the key value with a password-style mask. + */ +import React, { useState } from 'react'; +import { + Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl, + InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, IconButton, + InputAdornment, +} from '@mui/material'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import type { ApiKey, LlmProvider } from '../ProvidersManagement.types'; + +interface Props { + open: boolean; + data: Partial | null; + providers: LlmProvider[]; + onClose: () => void; + onChange: (data: Partial) => void; + onSave: () => void; +} + +export const ApiKeyDialog: React.FC = ({ open, data, providers, onClose, onChange, onSave }) => { + const [showKey, setShowKey] = useState(false); + + if (!data) return null; + const isEdit = 'api_key_id' in data && !!data.api_key_id; + + return ( + + {isEdit ? 'Edit API Key' : 'Add API Key'} + + + + Provider + + + onChange({ ...data, key_name: e.target.value })} required fullWidth placeholder="Primary Key, Backup Key, etc." /> + onChange({ ...data, api_key_value: e.target.value })} + required={!isEdit} + fullWidth + type={showKey ? 'text' : 'password'} + placeholder="sk-..." + InputProps={{ + endAdornment: ( + + setShowKey(!showKey)} edge="end"> + {showKey ? : } + + + ), + }} + /> + onChange({ ...data, is_active: e.target.checked })} />} label="Active" /> + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/AssignmentDialog.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/AssignmentDialog.tsx new file mode 100644 index 0000000..6c2f9c2 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/AssignmentDialog.tsx @@ -0,0 +1,104 @@ +/** + * Add/Edit dialog for a ComponentAssignment row. + * + * Maps a component (techniques/claims_extract/etc) to a primary provider+model + * with optional fallback provider+model. Models dropdown filters to only show + * models belonging to the currently-selected provider. + * + * Fallback section is fully optional — empty fallback_provider_id disables + * the fallback model dropdown. + */ +import React from 'react'; +import { + Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl, + InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, Typography, +} from '@mui/material'; +import type { ComponentAssignment, LlmProvider, LlmModel } from '../ProvidersManagement.types'; + +interface Props { + open: boolean; + data: Partial | null; + providers: LlmProvider[]; + models: LlmModel[]; + onClose: () => void; + onChange: (data: Partial) => void; + onSave: () => void; +} + +export const AssignmentDialog: React.FC = ({ open, data, providers, models, onClose, onChange, onSave }) => { + if (!data) return null; + const isEdit = 'assignment_id' in data && !!data.assignment_id; + + const filteredModels = models.filter((m) => m.provider_id === data.provider_id); + const filteredFallbackModels = models.filter((m) => m.provider_id === data.fallback_provider_id); + + return ( + + {isEdit ? 'Edit Assignment' : 'Add Assignment'} + + + onChange({ ...data, component_code: e.target.value })} required fullWidth placeholder="techniques, claims_extract, etc." /> + onChange({ ...data, component_name: e.target.value })} required fullWidth /> + + Primary Provider/Model + + + Provider + + + + Model + + + + + Fallback Provider/Model (Optional) + + + Fallback Provider + + + + Fallback Model + + + + + Settings + + onChange({ ...data, temperature: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.1, min: 0, max: 2 }} /> + onChange({ ...data, max_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + onChange({ ...data, timeout_ms: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + + + onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth /> + onChange({ ...data, is_enabled: e.target.checked })} />} label="Enabled" /> + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/DeleteConfirmDialog.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..ccbb023 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/DeleteConfirmDialog.tsx @@ -0,0 +1,33 @@ +/** + * Generic delete-confirmation dialog used by all four entity types. + * Caller dispatches the actual delete via onConfirm based on the row's `type`. + */ +import React from 'react'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material'; + +interface Props { + open: boolean; + name: string; + onCancel: () => void; + onConfirm: () => void; +} + +export const DeleteConfirmDialog: React.FC = ({ open, name, onCancel, onConfirm }) => ( + + Confirm Delete + + + Are you sure you want to delete {name}? + + + This action cannot be undone. + + + + + + + +); diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ModelDialog.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ModelDialog.tsx new file mode 100644 index 0000000..d2c2ebb --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ModelDialog.tsx @@ -0,0 +1,65 @@ +/** + * Add/Edit dialog for an LlmModel row. + * Provider dropdown is required; cost fields default to $0 (FREE). + */ +import React from 'react'; +import { + Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl, + InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, +} from '@mui/material'; +import type { LlmModel, LlmProvider } from '../ProvidersManagement.types'; + +interface Props { + open: boolean; + data: Partial | null; + providers: LlmProvider[]; + onClose: () => void; + onChange: (data: Partial) => void; + onSave: () => void; +} + +export const ModelDialog: React.FC = ({ open, data, providers, onClose, onChange, onSave }) => { + if (!data) return null; + const isEdit = 'model_id' in data && !!data.model_id; + + return ( + + {isEdit ? 'Edit Model' : 'Add Model'} + + + + Provider + + + onChange({ ...data, model_code: e.target.value })} required fullWidth placeholder="gpt-4o, claude-3-opus, etc." /> + onChange({ ...data, model_name: e.target.value })} required fullWidth /> + + onChange({ ...data, context_window: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + onChange({ ...data, max_output_tokens: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + + + onChange({ ...data, input_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} /> + onChange({ ...data, output_cost_per_1m: parseFloat(e.target.value) })} sx={{ flex: 1 }} inputProps={{ step: 0.01 }} /> + + + onChange({ ...data, supports_streaming: e.target.checked })} />} label="Streaming" /> + onChange({ ...data, supports_tools: e.target.checked })} />} label="Tools" /> + onChange({ ...data, supports_vision: e.target.checked })} />} label="Vision" /> + + onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth /> + onChange({ ...data, is_active: e.target.checked })} />} label="Active" /> + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ProviderDialog.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ProviderDialog.tsx new file mode 100644 index 0000000..ee5fd61 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/dialogs/ProviderDialog.tsx @@ -0,0 +1,58 @@ +/** + * Add/Edit dialog for an LlmProvider row. + * Save is disabled until provider_code + provider_name are non-empty. + */ +import React from 'react'; +import { + Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl, + InputLabel, Select, MenuItem, FormControlLabel, Checkbox, Button, +} from '@mui/material'; +import type { LlmProvider } from '../ProvidersManagement.types'; + +interface Props { + open: boolean; + data: Partial | null; + onClose: () => void; + onChange: (data: Partial) => void; + onSave: () => void; +} + +export const ProviderDialog: React.FC = ({ open, data, onClose, onChange, onSave }) => { + if (!data) return null; + const isEdit = 'provider_id' in data && !!data.provider_id; + + return ( + + {isEdit ? 'Edit Provider' : 'Add Provider'} + + + onChange({ ...data, provider_code: e.target.value })} required fullWidth /> + onChange({ ...data, provider_name: e.target.value })} required fullWidth /> + onChange({ ...data, base_url: e.target.value })} fullWidth placeholder="https://api.example.com/v1" /> + + Auth Type + + + + onChange({ ...data, priority: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + onChange({ ...data, rate_limit_rpm: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + onChange({ ...data, rate_limit_tpm: parseInt(e.target.value) })} sx={{ flex: 1 }} /> + + onChange({ ...data, description: e.target.value })} multiline rows={2} fullWidth /> + onChange({ ...data, is_active: e.target.checked })} />} label="Active" /> + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/index.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/index.tsx new file mode 100644 index 0000000..5213ac8 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/index.tsx @@ -0,0 +1 @@ +export { ProvidersManagement } from './ProvidersManagement'; diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ApiKeysTab.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ApiKeysTab.tsx new file mode 100644 index 0000000..f781799 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ApiKeysTab.tsx @@ -0,0 +1,65 @@ +/** + * API Keys tab — list of stored keys with usage telemetry. + * + * Backend redacts the key value to a prefix; frontend never sees the full key + * after creation. Edit dialog requires re-entering it if rotation is needed. + */ +import React from 'react'; +import { + Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Button, Chip, IconButton, Switch, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import type { ApiKey } from '../ProvidersManagement.types'; + +interface Props { + apiKeys: ApiKey[]; + hasProviders: boolean; + onAdd: () => void; + onEdit: (k: ApiKey) => void; + onDelete: (k: ApiKey) => void; + onToggleActive: (k: ApiKey) => void; +} + +export const ApiKeysTab: React.FC = ({ apiKeys, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => ( + <> + + + + + + + + Key Name + Provider + Prefix + Usage + Last Used + Active + Actions + + + + {apiKeys.map((k) => ( + + {k.key_name} + + {k.key_prefix}... + {k.usage_count} calls + {k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : 'Never'} + + onToggleActive(k)} size="small" /> + + + onEdit(k)}> + onDelete(k)} color="error"> + + + ))} + +
+
+ +); diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/tabs/AssignmentsTab.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/AssignmentsTab.tsx new file mode 100644 index 0000000..33d81ab --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/AssignmentsTab.tsx @@ -0,0 +1,79 @@ +/** + * Assignments tab — maps each component to a primary provider+model with + * an optional fallback. Settings column packs temp/max/timeout into one + * caption row to keep the table scannable. + */ +import React from 'react'; +import { + Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Button, Chip, IconButton, Switch, Typography, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import type { ComponentAssignment } from '../ProvidersManagement.types'; + +interface Props { + assignments: ComponentAssignment[]; + hasProvidersAndModels: boolean; + onAdd: () => void; + onEdit: (a: ComponentAssignment) => void; + onDelete: (a: ComponentAssignment) => void; + onToggleEnabled: (a: ComponentAssignment) => void; +} + +export const AssignmentsTab: React.FC = ({ assignments, hasProvidersAndModels, onAdd, onEdit, onDelete, onToggleEnabled }) => ( + <> + + + + + + + + Component + Provider / Model + Fallback + Settings + Enabled + Actions + + + + {assignments.map((a) => ( + + + {a.component_name} + {a.component_code} + + + + + + + {a.fallback_provider_code ? ( + <> + + + + ) : '-'} + + + + temp: {a.temperature} | max: {a.max_tokens} | timeout: {a.timeout_ms}ms + + + + onToggleEnabled(a)} size="small" /> + + + onEdit(a)}> + onDelete(a)} color="error"> + + + ))} + +
+
+ +); diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ModelsTab.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ModelsTab.tsx new file mode 100644 index 0000000..770820e --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ModelsTab.tsx @@ -0,0 +1,77 @@ +/** + * Models tab — list of LLM models with provider, context, cost, features. + * + * Cost column shows "FREE" chip when input cost is $0; otherwise shows + * "$ / $" per 1M tokens. Features are 3 small chips + * (Streaming/Tools/Vision) shown only when supported. + */ +import React from 'react'; +import { + Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Button, Chip, IconButton, Switch, Typography, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import type { LlmModel } from '../ProvidersManagement.types'; + +interface Props { + models: LlmModel[]; + hasProviders: boolean; + onAdd: () => void; + onEdit: (m: LlmModel) => void; + onDelete: (m: LlmModel) => void; + onToggleActive: (m: LlmModel) => void; +} + +export const ModelsTab: React.FC = ({ models, hasProviders, onAdd, onEdit, onDelete, onToggleActive }) => ( + <> + + + + + + + + Model + Provider + Context + Cost (per 1M) + Features + Active + Actions + + + + {models.map((m) => ( + + + {m.model_name} + {m.model_code} + + + {(m.context_window / 1000).toFixed(0)}K + + {m.input_cost_per_1m === 0 + ? + : `$${m.input_cost_per_1m} / $${m.output_cost_per_1m}`} + + + {m.supports_streaming && } + {m.supports_tools && } + {m.supports_vision && } + + + onToggleActive(m)} size="small" /> + + + onEdit(m)}> + onDelete(m)} color="error"> + + + ))} + +
+
+ +); diff --git a/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ProvidersTab.tsx b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ProvidersTab.tsx new file mode 100644 index 0000000..f859048 --- /dev/null +++ b/backend/admin-dashboard/src/components/ProvidersManagement/tabs/ProvidersTab.tsx @@ -0,0 +1,90 @@ +/** + * Providers tab — list of LLM providers with test-connection action. + * + * Test-connection runs against the backend's /test/:providerId endpoint + * and shows ✓ / ✗ + latency_ms inline next to the play button. Result + * stays visible until the next click (no auto-clear). + */ +import React from 'react'; +import { + Box, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Button, Chip, IconButton, Switch, Tooltip, CircularProgress, +} from '@mui/material'; +import { + Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, + PlayArrow as TestIcon, CheckCircle, Cancel, +} from '@mui/icons-material'; +import type { LlmProvider } from '../ProvidersManagement.types'; + +interface Props { + providers: LlmProvider[]; + testingProvider: number | null; + testResult: { providerId: number; success: boolean; latencyMs?: number; error?: string } | null; + onAdd: () => void; + onEdit: (p: LlmProvider) => void; + onDelete: (p: LlmProvider) => void; + onToggleActive: (p: LlmProvider) => void; + onTest: (id: number) => void; +} + +export const ProvidersTab: React.FC = ({ + providers, testingProvider, testResult, + onAdd, onEdit, onDelete, onToggleActive, onTest, +}) => ( + <> + + + + + + + + Provider + Code + Base URL + Auth + Priority + Rate Limits + Active + Actions + + + + {providers.map((p) => ( + + {p.provider_name} + + + {p.base_url} + + + {p.priority} + {p.rate_limit_rpm} RPM / {p.rate_limit_tpm} TPM + + onToggleActive(p)} size="small" /> + + + + onTest(p.provider_id)} disabled={testingProvider === p.provider_id}> + {testingProvider === p.provider_id ? : } + + + {testResult?.providerId === p.provider_id && ( + + {testResult.success + ? + : } + + )} + onEdit(p)}> + onDelete(p)} color="error"> + + + ))} + +
+
+ +); diff --git a/backend/admin-dashboard/src/components/UserManagement/RolesDialog.tsx b/backend/admin-dashboard/src/components/UserManagement/RolesDialog.tsx new file mode 100644 index 0000000..4203a89 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/RolesDialog.tsx @@ -0,0 +1,245 @@ +/** + * RolesDialog — multi-select Keycloak realm roles for a user. + * + * UX: + * 1. On open, load realm roles + user's current roles. + * 2. Show checkbox list with description tooltip. + * 3. Save → PUT /api/admin/users/:id/roles with the desired list. + * Server diffs add/remove and reports counts. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + List, + ListItem, + ListItemText, + Stack, + TextField, + Tooltip, + Typography, + Button, +} from '@mui/material'; + +interface RealmRole { + name: string; + description: string; +} + +interface PutRolesResponse { + success: boolean; + added?: string[]; + removed?: string[]; + errors?: string[]; + error?: string; +} + +interface Props { + open: boolean; + userId: number; + userEmail: string; + onClose: () => void; + onSaved: () => void; +} + +const API_BASE = '/framework'; + +function authHeaders(): HeadersInit { + const token = localStorage.getItem('keycloak_token'); + return token + ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + : { 'Content-Type': 'application/json' }; +} + +export const RolesDialog: React.FC = ({ open, userId, userEmail, onClose, onSaved }) => { + const [allRoles, setAllRoles] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [initial, setInitial] = useState>(new Set()); + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const [rolesRes, userRolesRes] = await Promise.all([ + fetch(`${API_BASE}/api/admin/realm-roles`, { headers: authHeaders() }), + fetch(`${API_BASE}/api/admin/users/${userId}/roles`, { headers: authHeaders() }), + ]); + const roles = await rolesRes.json(); + const userRoles = await userRolesRes.json(); + if (cancelled) return; + if (!roles.success || !userRoles.success) { + setError((roles.error || userRoles.error) ?? 'Failed to load roles'); + setLoading(false); + return; + } + const all = (roles.data ?? []) as RealmRole[]; + const have = new Set((userRoles.data?.roles ?? []) as string[]); + setAllRoles(all); + setSelected(new Set(have)); + setInitial(new Set(have)); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, userId]); + + const filtered = useMemo(() => { + const f = filter.trim().toLowerCase(); + if (!f) return allRoles; + return allRoles.filter( + (r) => + r.name.toLowerCase().includes(f) || + r.description.toLowerCase().includes(f), + ); + }, [allRoles, filter]); + + const diffSummary = useMemo(() => { + const added: string[] = []; + const removed: string[] = []; + selected.forEach((n) => { + if (!initial.has(n)) added.push(n); + }); + initial.forEach((n) => { + if (!selected.has(n)) removed.push(n); + }); + return { added, removed }; + }, [selected, initial]); + + const toggle = (name: string) => { + const next = new Set(selected); + if (next.has(name)) next.delete(name); + else next.add(name); + setSelected(next); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/api/admin/users/${userId}/roles`, { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ roles: Array.from(selected) }), + }); + const data = (await res.json()) as PutRolesResponse; + if (!data.success) { + setError(data.error ?? `Saved with errors: ${(data.errors ?? []).join('; ')}`); + if (res.status === 207) onSaved(); + return; + } + onSaved(); + onClose(); + } catch (e) { + setError((e as Error).message); + } finally { + setSaving(false); + } + }; + + const dirty = diffSummary.added.length > 0 || diffSummary.removed.length > 0; + + return ( + + + Realm roles — {userEmail} + + Realm: didi-clients (Keycloak SSO cluster) + + + + {loading && ( + + + + )} + {!loading && ( + + setFilter(e.target.value)} + fullWidth + /> + {filtered.length === 0 && ( + No roles match the filter. + )} + + {filtered.map((r) => ( + + toggle(r.name)} + /> + } + label={ + + + + } + /> + + ))} + + {dirty && ( + + {diffSummary.added.length > 0 && ( +
+ Add: {diffSummary.added.join(', ')} +
+ )} + {diffSummary.removed.length > 0 && ( +
+ Remove: {diffSummary.removed.join(', ')} +
+ )} +
+ )} + {error && {error}} +
+ )} +
+ + + + +
+ ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/UsageHistoryModal.tsx b/backend/admin-dashboard/src/components/UserManagement/UsageHistoryModal.tsx new file mode 100644 index 0000000..45bab5a --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/UsageHistoryModal.tsx @@ -0,0 +1,234 @@ +/** + * UsageHistoryModal — last N analyses + total cheltuit pentru un user. + * + * Reads bos_sysadmin.ai_credit_usage via /api/admin/users/:id/usage-history. + * The endpoint returns rows with whatever columns the table has — we render + * defensively (display known fields if present, fall back to a small pretty + * JSON for unknown columns). + */ + +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Button, +} from '@mui/material'; +import { Close as CloseIcon, Refresh as RefreshIcon } from '@mui/icons-material'; + +interface UsageRow { + // Best-effort columns. Names match what we observed in the schema; any + // extra columns the row has are still rendered via JSON fallback. + internet_user_id?: number; + session_id?: string; + input_type?: string; + credits_used?: number; + used_at?: string; + created_at?: string; + [k: string]: unknown; +} + +interface UsageResponse { + success: boolean; + data: UsageRow[]; + stats?: { rows: number; total_credits: number }; + error?: string; +} + +interface Props { + open: boolean; + userId: number; + userEmail: string; + onClose: () => void; +} + +const API_BASE = '/framework'; + +function authHeaders(): HeadersInit { + const token = localStorage.getItem('keycloak_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +const TYPE_COLOR: Record = { + text: 'primary', + url: 'info', + image: 'secondary', + audio: 'warning', + video: 'success', +}; + +function fmt(s: unknown): string { + if (!s) return '—'; + try { + return new Date(s as string).toLocaleString(); + } catch { + return String(s); + } +} + +function pickWhen(r: UsageRow): unknown { + return r.used_at ?? r.created_at ?? (r as Record).timestamp ?? null; +} + +export const UsageHistoryModal: React.FC = ({ open, userId, userEmail, onClose }) => { + const [rows, setRows] = useState([]); + const [stats, setStats] = useState<{ rows: number; total_credits: number } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `${API_BASE}/api/admin/users/${userId}/usage-history?limit=100`, + { headers: authHeaders() }, + ); + const data = (await res.json()) as UsageResponse; + if (!data.success) { + setError(data.error ?? 'Failed to load usage history'); + return; + } + setRows(data.data ?? []); + setStats(data.stats ?? null); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open) load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, userId]); + + return ( + + + + + Usage history — {userEmail} + {stats && ( + + {stats.rows} rows · {stats.total_credits} credits used (sum) + + )} + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && error && {error}} + {!loading && !error && rows.length === 0 && ( + No usage rows recorded for this user. + )} + {!loading && !error && rows.length > 0 && ( + + + + + When + Input type + Credits + Session + Other + + + + {rows.map((r, idx) => { + const inputType = (r.input_type ?? '') as string; + const credits = r.credits_used ?? + (r as Record).credits ?? + (r as Record).cost ?? + null; + const session = (r.session_id ?? '') as string; + + // Other columns — exclude the ones we already show. + const known = new Set(['internet_user_id','session_id','input_type','credits_used','used_at','created_at','credits','cost']); + const extras: Record = {}; + for (const [k, v] of Object.entries(r)) { + if (!known.has(k)) extras[k] = v; + } + const extrasStr = Object.keys(extras).length === 0 + ? '—' + : JSON.stringify(extras).slice(0, 80); + + return ( + + + {fmt(pickWhen(r))} + + + {inputType ? ( + + ) : ( + '—' + )} + + + + {credits === null ? '—' : String(credits)} + + + + + {session ? session.slice(0, 12) + '…' : '—'} + + + + + {extrasStr} + + + + ); + })} + +
+
+ )} +
+ + + +
+ ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/UserEditModal.tsx b/backend/admin-dashboard/src/components/UserManagement/UserEditModal.tsx new file mode 100644 index 0000000..db7fdd7 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/UserEditModal.tsx @@ -0,0 +1,415 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Switch, + FormControlLabel, + Box, + Typography, + Alert, + CircularProgress, + Divider, + Stack, + Snackbar, +} from '@mui/material'; +import { LockReset as LockResetIcon } from '@mui/icons-material'; + +interface User { + id: number; + email: string; + firstName: string; + lastName: string; + phone: string; + creditsRemained: number; + creditsSpent: number; + subscriptionPlanId: number; + subscriptionPlanName: string; + subscriptionStatus: number; + isActive: boolean; + keycloakId: string; + createdAt: string; +} + +interface SubscriptionPlan { + id: number; + name: string; + description: string; + creditsIncluded: number; + price: number; +} + +interface UserEditModalProps { + open: boolean; + user: User; + plans: SubscriptionPlan[]; + onClose: () => void; + onSave: () => void; +} + +const API_BASE = '/framework'; + +export const UserEditModal: React.FC = ({ + open, + user, + plans, + onClose, + onSave, +}) => { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + phone: '', + isActive: true, + creditsRemained: 0, + subscriptionPlanId: 1, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Phase U — group select + reset password (live values from Keycloak) + const [allGroups, setAllGroups] = useState<{ id: string; name: string }[]>([]); + const [currentGroup, setCurrentGroup] = useState(''); + const [initialGroup, setInitialGroup] = useState(''); + const [resetSent, setResetSent] = useState(null); + const [resetting, setResetting] = useState(false); + + useEffect(() => { + if (user) { + setFormData({ + firstName: user.firstName || '', + lastName: user.lastName || '', + phone: user.phone || '', + isActive: user.isActive, + creditsRemained: user.creditsRemained || 0, + subscriptionPlanId: user.subscriptionPlanId || 1, + }); + } + }, [user]); + + // Load Keycloak groups + the user's current group when the dialog opens. + useEffect(() => { + if (!open || !user?.id) return; + let cancelled = false; + (async () => { + try { + const [groupsRes, userGroupRes] = await Promise.all([ + fetch(`${API_BASE}/api/admin/groups`, { headers: getAuthHeader() as any }), + fetch(`${API_BASE}/api/admin/users/${user.id}/group`, { headers: getAuthHeader() as any }), + ]); + const groupsData = await groupsRes.json(); + const userGroupData = await userGroupRes.json(); + if (cancelled) return; + if (groupsData.success) setAllGroups(groupsData.data ?? []); + const g = (userGroupData.data?.groups ?? [])[0] ?? ''; + setCurrentGroup(g); + setInitialGroup(g); + } catch { + // best-effort — leave dropdown empty if Keycloak unreachable + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, user?.id]); + + const getAuthHeader = () => { + const token = localStorage.getItem('keycloak_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + }; + + const handleChange = (field: string) => (event: any) => { + const value = event.target.type === 'checkbox' + ? event.target.checked + : event.target.value; + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const handleSave = async () => { + setLoading(true); + setError(null); + + try { + // Update user profile + const profileResponse = await fetch(`${API_BASE}/api/admin/users/${user.id}`, { + method: 'PUT', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + firstName: formData.firstName, + lastName: formData.lastName, + phone: formData.phone, + isActive: formData.isActive, + creditsRemained: formData.creditsRemained, + }), + }); + + if (!profileResponse.ok) { + const data = await profileResponse.json(); + throw new Error(data.error || 'Failed to update user'); + } + + // Update subscription if changed + if (formData.subscriptionPlanId !== user.subscriptionPlanId) { + const subscriptionResponse = await fetch( + `${API_BASE}/api/admin/users/${user.id}/subscription`, + { + method: 'PUT', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + planId: formData.subscriptionPlanId, + }), + } + ); + + if (!subscriptionResponse.ok) { + const data = await subscriptionResponse.json(); + throw new Error(data.error || 'Failed to update subscription'); + } + } + + // Update Keycloak group if changed (Phase U) + if (currentGroup !== initialGroup) { + const grpRes = await fetch( + `${API_BASE}/api/admin/users/${user.id}/group`, + { + method: 'PUT', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ group: currentGroup || null }), + } + ); + if (!grpRes.ok) { + const data = await grpRes.json(); + throw new Error(data.error || 'Failed to update group'); + } + } + + onSave(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async () => { + setResetting(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/api/admin/users/${user.id}/reset-password`, { + method: 'POST', + headers: { + ...getAuthHeader() as Record, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lifespanSeconds: 86400 }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + throw new Error( + data.error || + data.detail || + `Reset request failed (HTTP ${res.status})`, + ); + } + setResetSent(`Reset email queued for ${user.email}`); + } catch (err: any) { + setError(err.message); + } finally { + setResetting(false); + } + }; + + const selectedPlan = plans.find(p => p.id === formData.subscriptionPlanId); + + return ( + + Edit User + + {error && ( + setError(null)}> + {error} + + )} + + + {/* Email (readonly) */} + + + {/* First Name */} + + + {/* Last Name */} + + + {/* Phone */} + + + + + Subscription & Credits + + + {/* Subscription Plan */} + + Subscription Plan + + + + {selectedPlan && ( + + Plan includes {selectedPlan.creditsIncluded} credits. + {selectedPlan.price > 0 && ` Price: $${selectedPlan.price}`} + + )} + + {/* Credits */} + + + + Credits spent: {user.creditsSpent} + + + + + Keycloak group + + + {/* Phase U — Keycloak group (single-select). + Empty value = remove from all groups. */} + + Group + + + {!user.keycloakId && ( + + No keycloak_id — sync this user first to manage their group. + + )} + + + + {/* Status */} + + } + label={formData.isActive ? 'Active' : 'Inactive'} + /> + + + + + + Triggers Keycloak's UPDATE_PASSWORD flow. 24h lifespan. + + + + + + + + + + setResetSent(null)} + message={resetSent ?? ''} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + + ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/UserManagement.helpers.ts b/backend/admin-dashboard/src/components/UserManagement/UserManagement.helpers.ts new file mode 100644 index 0000000..9518eb3 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/UserManagement.helpers.ts @@ -0,0 +1,43 @@ +/** + * Pure helpers + constants for UserManagement. + * + * getAuthHeader() reads the Keycloak token from localStorage and returns + * either a Bearer header or an empty object. Empty header is fine in staging + * when Keycloak auth bypass is on; backend ignores missing auth then. + */ +export function fmtBytes(n: number | undefined): string { + if (!n || n <= 0) return '0'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; +} + +export function fmtDate(s: string): string { + if (!s) return '—'; + try { + return new Date(s).toLocaleString(); + } catch { + return s; + } +} + +export const ACTION_COLORS: Record = { + 'user.update': 'primary', + 'user.delete': 'error', + 'user.sync': 'success', + 'user.email_verified': 'secondary', + 'user.subscription': 'primary', + 'user.roles': 'warning', + 'user.group': 'warning', + 'user.reset_password': 'warning', +}; + +export function getAuthHeader(): Record { + const token = localStorage.getItem('keycloak_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} diff --git a/backend/admin-dashboard/src/components/UserManagement/UserManagement.tsx b/backend/admin-dashboard/src/components/UserManagement/UserManagement.tsx new file mode 100644 index 0000000..9313266 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/UserManagement.tsx @@ -0,0 +1,548 @@ +/** + * USER MANAGEMENT — admin page for managing users / plans / audit log. + * + * Original 1153-line file split into: + * UserManagement.types.ts — User, SubscriptionPlan, AuditEntry, etc. + * UserManagement.helpers.ts — fmtBytes, fmtDate, ACTION_COLORS, getAuthHeader + * tabs/UsersTab.tsx — filters + bulk toolbar + users table + * tabs/PlansTab.tsx — plans inline editor + * tabs/AuditLogTab.tsx — audit log table + filters + * dialogs/DeleteUserDialog.tsx — hard-delete confirmation + * dialogs/BulkCreditsDialog.tsx — bulk-set-credits dialog + * UserManagement.tsx (this) — state + handlers + tab dispatcher + * + * Bulk operations are sequential (never parallel) so audit-log ordering is + * stable and we don't hammer Keycloak with concurrent writes. + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Container, Paper, Typography, Alert, Tab, Tabs, Snackbar, +} from '@mui/material'; +import { + People as PeopleIcon, + CreditCard as PlansIcon, + ReceiptLong as AuditIcon, +} from '@mui/icons-material'; +import { useAuth } from '../../contexts/AuthContext'; +import { UserEditModal } from './UserEditModal'; +import { RolesDialog } from './RolesDialog'; +import { UsageHistoryModal } from './UsageHistoryModal'; +import { UsersTab } from './tabs/UsersTab'; +import { PlansTab } from './tabs/PlansTab'; +import { AuditLogTab } from './tabs/AuditLogTab'; +import { DeleteUserDialog } from './dialogs/DeleteUserDialog'; +import { BulkCreditsDialog } from './dialogs/BulkCreditsDialog'; +import { AddUserDialog } from './dialogs/AddUserDialog'; +import { + type User, type SubscriptionPlan, type AuditEntry, type PaginationInfo, + type AuditFiltersState, type AuditPaginationState, type PlanFormState, + API_BASE, +} from './UserManagement.types'; +import { getAuthHeader } from './UserManagement.helpers'; + +export const UserManagement: React.FC = () => { + const { hasRole } = useAuth(); + void hasRole; + + const [users, setUsers] = useState([]); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [snackMsg, setSnackMsg] = useState(null); + const [pagination, setPagination] = useState({ + page: 1, limit: 20, total: 0, totalPages: 0, + }); + + // Filters + const [searchQuery, setSearchQuery] = useState(''); + const [planFilter, setPlanFilter] = useState(''); + const [syncFilter, setSyncFilter] = useState<'all' | 'synced' | 'keycloak_only'>('all'); + + // UI state + const [activeTab, setActiveTab] = useState(0); + const [editModalOpen, setEditModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + const [rolesUser, setRolesUser] = useState(null); + const [usageUser, setUsageUser] = useState(null); + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + + // Bulk selection + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkMenuAnchor, setBulkMenuAnchor] = useState(null); + const [bulkCreditsDialog, setBulkCreditsDialog] = useState(false); + const [bulkCreditsValue, setBulkCreditsValue] = useState(100); + + // Plans tab state + const [editingPlan, setEditingPlan] = useState(null); + const [planForm, setPlanForm] = useState({ name: '', description: '', creditsIncluded: 0, price: 0 }); + const [savingPlan, setSavingPlan] = useState(false); + + // Audit Log tab state + const [auditEntries, setAuditEntries] = useState([]); + const [auditLoading, setAuditLoading] = useState(false); + const [auditFilters, setAuditFilters] = useState({ action: '', actor: '', since: '' }); + const [auditPagination, setAuditPagination] = useState({ limit: 50, offset: 0, total: 0 }); + + // ─── data fetching ──────────────────────────────────────────────────────── + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + setSelectedIds(new Set()); + + try { + const params = new URLSearchParams(); + if (searchQuery) params.append('search', searchQuery); + if (syncFilter !== 'all') params.append('sync_status', syncFilter); + + const response = await fetch(`${API_BASE}/api/admin/users?${params.toString()}`, { + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + }); + if (!response.ok) { + if (response.status === 403) throw new Error('Admin access required'); + throw new Error('Failed to fetch users'); + } + const data = await response.json(); + let allUsers: User[] = data.data || []; + + // Client-side plan filter (server doesn't support it). + if (planFilter) { + const id = parseInt(planFilter, 10); + allUsers = allUsers.filter((u) => u.subscriptionPlanId === id); + } + + const total = allUsers.length; + setUsers(allUsers); + setPagination((prev) => ({ + ...prev, + total, + totalPages: Math.ceil(total / prev.limit) || 1, + })); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [searchQuery, planFilter, syncFilter]); + + const fetchPlans = useCallback(async () => { + try { + const response = await fetch(`${API_BASE}/api/admin/plans`, { + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + }); + if (response.ok) { + const data = await response.json(); + setPlans(data.data || []); + } + } catch (err) { + console.error('Failed to fetch plans:', err); + } + }, []); + + const fetchAudit = useCallback(async () => { + setAuditLoading(true); + try { + const params = new URLSearchParams({ + limit: String(auditPagination.limit), + offset: String(auditPagination.offset), + }); + if (auditFilters.action) params.append('action', auditFilters.action); + if (auditFilters.actor) params.append('actor', auditFilters.actor); + if (auditFilters.since) params.append('since', auditFilters.since); + const res = await fetch(`${API_BASE}/api/admin/audit-log?${params.toString()}`, { + headers: getAuthHeader(), + }); + const data = await res.json(); + if (data.success) { + setAuditEntries(data.data || []); + setAuditPagination((p) => ({ ...p, total: data.total ?? 0 })); + } + } catch (err) { + console.error('Failed to fetch audit log:', err); + } finally { + setAuditLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auditFilters, auditPagination.limit, auditPagination.offset]); + + useEffect(() => { fetchUsers(); }, [fetchUsers]); + useEffect(() => { fetchPlans(); }, [fetchPlans]); + useEffect(() => { if (activeTab === 2) fetchAudit(); }, [activeTab, fetchAudit]); + + // ─── derived ─────────────────────────────────────────────────────────────── + + const pagedUsers = useMemo(() => { + const start = (pagination.page - 1) * pagination.limit; + return users.slice(start, start + pagination.limit); + }, [users, pagination.page, pagination.limit]); + + const allOnPageSelected = pagedUsers.length > 0 && + pagedUsers.every((u) => u.id !== 0 && selectedIds.has(u.id)); + const someOnPageSelected = pagedUsers.some((u) => u.id !== 0 && selectedIds.has(u.id)); + + // ─── handlers ────────────────────────────────────────────────────────────── + + const handlePageChange = (_e: unknown, newPage: number) => { + setPagination((prev) => ({ ...prev, page: newPage + 1 })); + }; + const handleRowsPerPageChange = (e: React.ChangeEvent) => { + setPagination((prev) => ({ ...prev, limit: parseInt(e.target.value, 10), page: 1 })); + }; + + const handleEditUser = (user: User) => { + setSelectedUser(user); + setEditModalOpen(true); + }; + + const handleDeleteClick = (user: User) => { + setUserToDelete(user); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!userToDelete) return; + try { + // Hard delete (Keycloak + PG + bucket cascade) instead of just deactivating — + // matches the destructive intent of the trash icon. + const response = await fetch(`${API_BASE}/api/admin/users/${userToDelete.id}`, { + method: 'DELETE', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete user'); + } + fetchUsers(); + setSnackMsg(`User ${userToDelete.email} deleted`); + } catch (err: any) { + setError(err.message); + } finally { + setDeleteDialogOpen(false); + setUserToDelete(null); + } + }; + + const handleUserUpdated = () => { + fetchUsers(); + setEditModalOpen(false); + setSelectedUser(null); + }; + + const handleEmailVerifiedToggle = async (user: User) => { + try { + const response = await fetch(`${API_BASE}/api/admin/users/${user.id}/email-verified`, { + method: 'PUT', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ emailVerified: !user.emailVerified }), + }); + if (!response.ok) throw new Error('Failed to update email verification'); + setUsers((prev) => + prev.map((u) => (u.id === user.id ? { ...u, emailVerified: !u.emailVerified } : u)) + ); + } catch (err: any) { + setError(err.message); + } + }; + + const handleSyncUser = async (user: User) => { + try { + const response = await fetch(`${API_BASE}/api/admin/users/sync`, { + method: 'POST', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ keycloakId: user.keycloakId }), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to sync user'); + } + fetchUsers(); + setSnackMsg(`User ${user.email} synced`); + } catch (err: any) { + setError(err.message); + } + }; + + // ─── plans tab ───────────────────────────────────────────────────────────── + + const handleEditPlan = (plan: SubscriptionPlan) => { + setEditingPlan(plan); + setPlanForm({ + name: plan.name, + description: plan.description || '', + creditsIncluded: plan.creditsIncluded, + price: plan.price, + }); + }; + + const handleSavePlan = async () => { + if (!editingPlan) return; + setSavingPlan(true); + setError(null); + try { + const response = await fetch(`${API_BASE}/api/admin/plans/${editingPlan.id}`, { + method: 'PUT', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify(planForm), + }); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to update plan'); + } + setEditingPlan(null); + fetchPlans(); + } catch (err: any) { + setError(err.message); + } finally { + setSavingPlan(false); + } + }; + + // ─── bulk operations ────────────────────────────────────────────────────── + + const toggleSelectOne = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAllOnPage = () => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (allOnPageSelected) { + pagedUsers.forEach((u) => next.delete(u.id)); + } else { + pagedUsers.forEach((u) => { + if (u.id !== 0) next.add(u.id); + }); + } + return next; + }); + }; + + // Generic sequential bulk runner — never parallel, to avoid hammering Keycloak + // and keep audit log entries cleanly ordered. + const runBulk = async ( + label: string, + fn: (u: User) => Promise, + ) => { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + setBulkMenuAnchor(null); + setError(null); + let ok = 0; + let fail = 0; + for (const id of ids) { + const user = users.find((u) => u.id === id); + if (!user) continue; + try { + await fn(user); + ok++; + } catch (e: any) { + console.error(`bulk ${label} failed for ${user.email}:`, e); + fail++; + } + } + setSnackMsg(`Bulk ${label}: ${ok} ok, ${fail} failed`); + setSelectedIds(new Set()); + fetchUsers(); + }; + + const bulkActivate = (active: boolean) => + runBulk(active ? 'activate' : 'deactivate', async (u) => { + const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, { + method: 'PUT', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: active }), + }); + if (!r.ok) throw new Error(await r.text()); + }); + + const bulkEmailVerify = () => + runBulk('email-verify', async (u) => { + const r = await fetch(`${API_BASE}/api/admin/users/${u.id}/email-verified`, { + method: 'PUT', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ emailVerified: true }), + }); + if (!r.ok) throw new Error(await r.text()); + }); + + const bulkSetCredits = () => + runBulk(`set-credits=${bulkCreditsValue}`, async (u) => { + const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, { + method: 'PUT', + headers: { ...getAuthHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ creditsRemained: bulkCreditsValue }), + }); + if (!r.ok) throw new Error(await r.text()); + }); + + const bulkDelete = () => + runBulk('delete', async (u) => { + const r = await fetch(`${API_BASE}/api/admin/users/${u.id}`, { + method: 'DELETE', + headers: getAuthHeader(), + }); + if (!r.ok) throw new Error(await r.text()); + }); + + // ─── render ──────────────────────────────────────────────────────────────── + + return ( + + User Management + + Manage users, subscriptions, credits, roles, groups, and audit history + + + {error && ( + setError(null)}> + {error} + + )} + + + setActiveTab(v)}> + } iconPosition="start" label="Users" /> + } iconPosition="start" label="Subscription Plans" /> + } iconPosition="start" label="Audit Log" /> + + + + {activeTab === 0 && ( + setAddUserDialogOpen(true)} + /> + )} + + {activeTab === 1 && ( + + )} + + {activeTab === 2 && ( + + )} + + {/* ─── dialogs / modals ───────────────────────────────────────────── */} + + {selectedUser && ( + { setEditModalOpen(false); setSelectedUser(null); }} + onSave={handleUserUpdated} + /> + )} + + {rolesUser && ( + setRolesUser(null)} + onSaved={() => { fetchUsers(); setSnackMsg('Roles updated'); }} + /> + )} + + {usageUser && ( + setUsageUser(null)} + /> + )} + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteConfirm} + /> + + setBulkCreditsDialog(false)} + onApply={() => { setBulkCreditsDialog(false); bulkSetCredits(); }} + /> + + setAddUserDialogOpen(false)} + onCreated={({ email, realm }) => { + setSnackMsg(`User ${email} created in ${realm}`); + fetchUsers(); + }} + /> + + setSnackMsg(null)} + message={snackMsg ?? ''} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> + + ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/UserManagement.types.ts b/backend/admin-dashboard/src/components/UserManagement/UserManagement.types.ts new file mode 100644 index 0000000..73d78db --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/UserManagement.types.ts @@ -0,0 +1,80 @@ +/** + * Shared types for the UserManagement page + its tabs/dialogs. + * + * User has both PG fields (id, plan, credits) and Keycloak-only echo fields + * (keycloakId, syncStatus). syncStatus='keycloak_only' means the user exists + * in Keycloak but hasn't been imported into PG yet — surface as a warning row. + */ + +export interface User { + id: number; + email: string; + firstName: string; + lastName: string; + phone: string; + creditsRemained: number; + creditsSpent: number; + storageUsedBytes?: number; + storageLimitBytes?: number; + storagePct?: number; + subscriptionPlanId: number; + subscriptionPlanName: string; + subscriptionStatus: number; + isActive: boolean; + keycloakId: string; + createdAt: string; + emailVerified: boolean; + syncStatus: 'synced' | 'keycloak_only'; + roles?: string[]; + groups?: string[]; +} + +export interface SubscriptionPlan { + id: number; + name: string; + description: string; + creditsIncluded: number; + price: number; +} + +export interface AuditEntry { + auditId: number; + internetUserId: number | null; + targetEmail: string | null; + targetKeycloakId: string | null; + actorKeycloakId: string | null; + actorEmail: string | null; + action: string; + payload: Record; + requestIp: string | null; + userAgent: string | null; + createdAt: string; +} + +export interface PaginationInfo { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface AuditFiltersState { + action: string; + actor: string; + since: string; +} + +export interface AuditPaginationState { + limit: number; + offset: number; + total: number; +} + +export interface PlanFormState { + name: string; + description: string; + creditsIncluded: number; + price: number; +} + +export const API_BASE = '/framework'; diff --git a/backend/admin-dashboard/src/components/UserManagement/dialogs/AddUserDialog.tsx b/backend/admin-dashboard/src/components/UserManagement/dialogs/AddUserDialog.tsx new file mode 100644 index 0000000..228cb61 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/dialogs/AddUserDialog.tsx @@ -0,0 +1,317 @@ +/** + * Add User dialog — create user in Keycloak + PG from admin. + * + * Supports both realms (didi-clients = end-users, didi-admins = operators). + * - didi-clients: creates Keycloak user + person/internet_user/subscription + * rows in PG with the selected plan + MinIO prefix. + * - didi-admins: only Keycloak side (operator account, no PG profile). + * + * The user is created with a temporary password and the UPDATE_PASSWORD + * required action so they MUST change it at first login. + */ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { SubscriptionPlan } from '../UserManagement.types'; + +interface Props { + open: boolean; + plans: SubscriptionPlan[]; + apiBase: string; + authHeader: Record; + onClose: () => void; + onCreated: (info: { email: string; realm: string }) => void; +} + +const CLIENT_ROLES = ['viewer', 'analyst', 'api_user']; +const ADMIN_ROLES = ['admin', 'moderator', 'senior_moderator']; + +const CLIENT_GROUPS = [ + { name: '(none)', value: '' }, + { name: 'free-users', value: 'free-users' }, + { name: 'paid-users', value: 'paid-users' }, + { name: 'enterprise-users', value: 'enterprise-users' }, +]; + +export const AddUserDialog: React.FC = (p) => { + const [realm, setRealm] = useState<'didi-clients' | 'didi-admins'>('didi-clients'); + const [email, setEmail] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [planId, setPlanId] = useState(1); + const [roles, setRoles] = useState([]); + const [groupName, setGroupName] = useState(''); + const [forceUpdatePwd, setForceUpdatePwd] = useState(true); + const [emailVerified, setEmailVerified] = useState(true); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Reset on open + useEffect(() => { + if (p.open) { + setRealm('didi-clients'); + setEmail(''); + setFirstName(''); + setLastName(''); + setPassword(genPassword()); + setShowPassword(false); + setPlanId(1); + setRoles([]); + setGroupName(''); + setForceUpdatePwd(true); + setEmailVerified(true); + setError(null); + } + }, [p.open]); + + // When switching realm, reset role + group to safe defaults. + useEffect(() => { + setRoles([]); + setGroupName(''); + }, [realm]); + + const availableRoles = realm === 'didi-admins' ? ADMIN_ROLES : CLIENT_ROLES; + const isAdmin = realm === 'didi-admins'; + + function toggleRole(name: string) { + setRoles((cur) => (cur.includes(name) ? cur.filter((r) => r !== name) : [...cur, name])); + } + + async function handleSubmit() { + setError(null); + + if (!email || !email.includes('@')) { + setError('Invalid email'); + return; + } + if (!firstName.trim() || !lastName.trim()) { + setError('First and last name are required'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setSaving(true); + try { + const body: Record = { + realm, + email: email.trim().toLowerCase(), + firstName: firstName.trim(), + lastName: lastName.trim(), + password, + emailVerified, + requiredActions: forceUpdatePwd ? ['UPDATE_PASSWORD'] : [], + roles, + }; + if (!isAdmin) { + body.planId = planId; + if (groupName) body.groupName = groupName; + } + + const res = await fetch(`${p.apiBase}/api/admin/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...p.authHeader }, + body: JSON.stringify(body), + }); + const data = await res.json(); + + if (!res.ok || data.success === false) { + setError(data.error || `HTTP ${res.status}`); + setSaving(false); + return; + } + + p.onCreated({ email: body.email as string, realm }); + p.onClose(); + } catch (e: any) { + setError(e.message || 'Network error'); + } finally { + setSaving(false); + } + } + + return ( + + Create new user + + + {error && {error}} + + + Realm + + + + + setFirstName(e.target.value)} + /> + setLastName(e.target.value)} + /> + + + setEmail(e.target.value)} + helperText="Will be used as both email and username in Keycloak" + /> + +
+ + setPassword(e.target.value)} + /> + + + + + Min 8 characters. User must change it at first login. + +
+ + setForceUpdatePwd(e.target.checked)} />} + label="Force password change at first login (UPDATE_PASSWORD)" + /> + setEmailVerified(e.target.checked)} />} + label="Mark email as verified" + /> + + {!isAdmin && ( + <> + + Subscription plan + + + + Group (optional) + + + + )} + +
+ + Realm roles ({realm}) + + + {availableRoles.map((r) => ( + toggleRole(r)} />} + label={r} + /> + ))} + +
+ + {isAdmin && ( + + Operator accounts ({realm}) get only a Keycloak entry — no PG profile, no credits, no plan. + + )} +
+
+ + + + +
+ ); +}; + +// Generate a strong random password: 16 chars from mixed alphabet. +function genPassword(): string { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + const lower = 'abcdefghijkmnopqrstuvwxyz'; + const digits = '23456789'; + const symbols = '!@#$%&*'; + const all = upper + lower + digits + symbols; + const out: string[] = []; + // Guarantee at least one of each class. + out.push(upper[Math.floor(Math.random() * upper.length)]); + out.push(lower[Math.floor(Math.random() * lower.length)]); + out.push(digits[Math.floor(Math.random() * digits.length)]); + out.push(symbols[Math.floor(Math.random() * symbols.length)]); + for (let i = 0; i < 12; i++) { + out.push(all[Math.floor(Math.random() * all.length)]); + } + // Shuffle + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out.join(''); +} diff --git a/backend/admin-dashboard/src/components/UserManagement/dialogs/BulkCreditsDialog.tsx b/backend/admin-dashboard/src/components/UserManagement/dialogs/BulkCreditsDialog.tsx new file mode 100644 index 0000000..fd3084e --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/dialogs/BulkCreditsDialog.tsx @@ -0,0 +1,38 @@ +/** + * Bulk credits dialog — sets the same credits value on N selected users. + * Apply triggers the parent's bulkSetCredits which runs sequentially per user. + */ +import React from 'react'; +import { Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material'; + +interface Props { + open: boolean; + selectedCount: number; + bulkCreditsValue: number; + setBulkCreditsValue: (n: number) => void; + onCancel: () => void; + onApply: () => void; +} + +export const BulkCreditsDialog: React.FC = (p) => ( + + Set credits for {p.selectedCount} users + + p.setBulkCreditsValue(Number(e.target.value))} + inputProps={{ min: 0 }} + /> + + + + + + +); diff --git a/backend/admin-dashboard/src/components/UserManagement/dialogs/DeleteUserDialog.tsx b/backend/admin-dashboard/src/components/UserManagement/dialogs/DeleteUserDialog.tsx new file mode 100644 index 0000000..c41d6d1 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/dialogs/DeleteUserDialog.tsx @@ -0,0 +1,32 @@ +/** + * Confirmation dialog for hard-delete (Keycloak + PG + MinIO cascade). + * Shown with the trash-icon flow; bulk delete uses window.confirm directly. + */ +import React from 'react'; +import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; +import type { User } from '../UserManagement.types'; + +interface Props { + open: boolean; + user: User | null; + onCancel: () => void; + onConfirm: () => void; +} + +export const DeleteUserDialog: React.FC = ({ open, user, onCancel, onConfirm }) => ( + + Hard-delete user + + + This permanently deletes {user?.email} from + Keycloak, PostgreSQL, and MinIO. This cannot be undone. + + + + + + + +); diff --git a/backend/admin-dashboard/src/components/UserManagement/index.ts b/backend/admin-dashboard/src/components/UserManagement/index.ts new file mode 100644 index 0000000..65899e2 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/index.ts @@ -0,0 +1,2 @@ +export { UserManagement } from './UserManagement'; +export { UserEditModal } from './UserEditModal'; diff --git a/backend/admin-dashboard/src/components/UserManagement/tabs/AuditLogTab.tsx b/backend/admin-dashboard/src/components/UserManagement/tabs/AuditLogTab.tsx new file mode 100644 index 0000000..947e462 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/tabs/AuditLogTab.tsx @@ -0,0 +1,161 @@ +/** + * AUDIT LOG TAB — paginated audit-trail table for admin actions. + * + * Filters: action (preset list), actor email substring, since (datetime-local). + * Payload column shows truncated JSON with hover tooltip showing pretty-printed + * full payload. + * + * Pagination is offset-based (no "load more" — admins want page jumps). + */ +import React from 'react'; +import { + Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + TablePagination, TextField, Button, CircularProgress, FormControl, InputLabel, + Select, MenuItem, Chip, Typography, Stack, Tooltip, +} from '@mui/material'; +import { Refresh as RefreshIcon } from '@mui/icons-material'; +import type { AuditEntry, AuditFiltersState, AuditPaginationState } from '../UserManagement.types'; +import { fmtDate, ACTION_COLORS } from '../UserManagement.helpers'; + +interface Props { + auditEntries: AuditEntry[]; + auditLoading: boolean; + auditFilters: AuditFiltersState; + setAuditFilters: React.Dispatch>; + auditPagination: AuditPaginationState; + setAuditPagination: React.Dispatch>; + fetchAudit: () => void; +} + +export const AuditLogTab: React.FC = (p) => { + return ( + <> + + + + Action + + + p.setAuditFilters({ ...p.auditFilters, actor: e.target.value })} + sx={{ minWidth: 220 }} + /> + p.setAuditFilters({ ...p.auditFilters, since: e.target.value })} + /> + + + + + + + + + + When + Action + Target + Actor + Payload + IP + + + + {p.auditLoading ? ( + + + + + + ) : p.auditEntries.length === 0 ? ( + + + No audit entries. + + + ) : ( + p.auditEntries.map((e) => ( + + + {fmtDate(e.createdAt)} + + + + + + {e.targetEmail ?? (e.internetUserId ? `id=${e.internetUserId}` : '—')} + {e.targetKeycloakId && ( + + {e.targetKeycloakId.slice(0, 8)}… + + )} + + {e.actorEmail ?? staging} + + {JSON.stringify(e.payload, null, 2)}}> + + {JSON.stringify(e.payload).slice(0, 80)} + {JSON.stringify(e.payload).length > 80 ? '…' : ''} + + + + + + {e.requestIp ?? '—'} + + + + )) + )} + +
+
+ p.setAuditPagination((prev) => ({ ...prev, offset: np * prev.limit }))} + rowsPerPage={p.auditPagination.limit} + onRowsPerPageChange={(e) => p.setAuditPagination((prev) => ({ + ...prev, + limit: parseInt(e.target.value, 10), + offset: 0, + }))} + rowsPerPageOptions={[25, 50, 100, 200]} + /> +
+ + ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/tabs/PlansTab.tsx b/backend/admin-dashboard/src/components/UserManagement/tabs/PlansTab.tsx new file mode 100644 index 0000000..a3b38b5 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/tabs/PlansTab.tsx @@ -0,0 +1,117 @@ +/** + * PLANS TAB — subscription plans table with inline edit. + * + * editingPlan === plan.id puts that row into edit mode (each cell becomes a + * TextField). Save commits via PUT /api/admin/plans/:id; cancel just clears + * editingPlan. Other rows stay read-only during edit. + */ +import React from 'react'; +import { + Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + TextField, IconButton, Button, Typography, Stack, +} from '@mui/material'; +import { Edit as EditIcon, Save as SaveIcon } from '@mui/icons-material'; +import type { SubscriptionPlan, PlanFormState } from '../UserManagement.types'; + +interface Props { + plans: SubscriptionPlan[]; + editingPlan: SubscriptionPlan | null; + setEditingPlan: (p: SubscriptionPlan | null) => void; + planForm: PlanFormState; + setPlanForm: React.Dispatch>; + savingPlan: boolean; + handleEditPlan: (p: SubscriptionPlan) => void; + handleSavePlan: () => void; +} + +export const PlansTab: React.FC = (p) => { + return ( + + Subscription Plans + + + + + ID + Name + Description + Credits + Price + Actions + + + + {p.plans.map((plan) => ( + + {plan.id} + + {p.editingPlan?.id === plan.id ? ( + p.setPlanForm({ ...p.planForm, name: e.target.value })} + /> + ) : ( + plan.name + )} + + + {p.editingPlan?.id === plan.id ? ( + p.setPlanForm({ ...p.planForm, description: e.target.value })} + /> + ) : ( + plan.description || '—' + )} + + + {p.editingPlan?.id === plan.id ? ( + p.setPlanForm({ ...p.planForm, creditsIncluded: Number(e.target.value) })} + /> + ) : ( + plan.creditsIncluded + )} + + + {p.editingPlan?.id === plan.id ? ( + p.setPlanForm({ ...p.planForm, price: Number(e.target.value) })} + /> + ) : ( + plan.price + )} + + + {p.editingPlan?.id === plan.id ? ( + + + + + + + ) : ( + p.handleEditPlan(plan)} title="Edit plan"> + + + )} + + + ))} + +
+
+
+ ); +}; diff --git a/backend/admin-dashboard/src/components/UserManagement/tabs/UsersTab.tsx b/backend/admin-dashboard/src/components/UserManagement/tabs/UsersTab.tsx new file mode 100644 index 0000000..3b60ad8 --- /dev/null +++ b/backend/admin-dashboard/src/components/UserManagement/tabs/UsersTab.tsx @@ -0,0 +1,378 @@ +/** + * USERS TAB — filters + bulk action toolbar + users table. + * + * Receives all state + handlers from parent (UserManagement). Two visual modes: + * - normal row: hover, edit/roles/usage/delete actions + * - keycloak_only row: highlighted yellow + Sync button (no edit/delete + * until the user is imported into PG) + * + * Bulk toolbar appears only when selectedIds.size > 0; uses sequential-bulk + * runner from parent so audit log entries stay cleanly ordered. + */ +import React from 'react'; +import { + Box, Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + TablePagination, TextField, InputAdornment, IconButton, Chip, Button, + CircularProgress, FormControl, InputLabel, Select, MenuItem, Divider, Menu, + Switch, Tooltip, Typography, Checkbox, LinearProgress, Stack, Toolbar, +} from '@mui/material'; +import { + Search as SearchIcon, Edit as EditIcon, Delete as DeleteIcon, + Refresh as RefreshIcon, Sync as SyncIcon, Warning as WarningIcon, + AdminPanelSettings as RolesIcon, History as HistoryIcon, + MoreVert as MoreVertIcon, +} from '@mui/icons-material'; +import type { User, SubscriptionPlan, PaginationInfo } from '../UserManagement.types'; +import { fmtBytes } from '../UserManagement.helpers'; + +interface Props { + users: User[]; + pagedUsers: User[]; + plans: SubscriptionPlan[]; + loading: boolean; + pagination: PaginationInfo; + searchQuery: string; + setSearchQuery: (s: string) => void; + planFilter: string; + setPlanFilter: (s: string) => void; + syncFilter: 'all' | 'synced' | 'keycloak_only'; + setSyncFilter: (s: 'all' | 'synced' | 'keycloak_only') => void; + fetchUsers: () => void; + selectedIds: Set; + setSelectedIds: React.Dispatch>>; + allOnPageSelected: boolean; + someOnPageSelected: boolean; + toggleSelectOne: (id: number) => void; + toggleSelectAllOnPage: () => void; + bulkMenuAnchor: HTMLElement | null; + setBulkMenuAnchor: (el: HTMLElement | null) => void; + bulkActivate: (active: boolean) => void; + bulkEmailVerify: () => void; + bulkDelete: () => void; + setBulkCreditsDialog: (open: boolean) => void; + handlePageChange: (e: unknown, p: number) => void; + handleRowsPerPageChange: (e: React.ChangeEvent) => void; + handleEditUser: (u: User) => void; + handleDeleteClick: (u: User) => void; + handleEmailVerifiedToggle: (u: User) => void; + handleSyncUser: (u: User) => void; + setRolesUser: (u: User | null) => void; + setUsageUser: (u: User | null) => void; + onOpenAddUser: () => void; +} + +export const UsersTab: React.FC = (p) => { + void p.users; + return ( + <> + + + p.setSearchQuery(e.target.value)} + size="small" + sx={{ minWidth: 250 }} + InputProps={{ + startAdornment: , + }} + /> + + Plan + + + + Sync + + + + + + + + + {/* Bulk action toolbar — only visible when items are selected */} + {p.selectedIds.size > 0 && ( + + + + {p.selectedIds.size} selected + + + p.setBulkMenuAnchor(null)} + > + p.bulkActivate(true)}>Activate + p.bulkActivate(false)}>Deactivate + p.bulkEmailVerify()}>Mark email verified + { p.setBulkMenuAnchor(null); p.setBulkCreditsDialog(true); }}> + Set credits to… + + + { + if (window.confirm(`Hard-delete ${p.selectedIds.size} users (Keycloak + PG + MinIO)?`)) { + p.bulkDelete(); + } else { + p.setBulkMenuAnchor(null); + } + }} + sx={{ color: 'error.main' }} + > + Delete (hard) ⚠ + + + + + + )} + + + + + + + + + + Email + Name + Sync + Plan + Group + Roles + Verified + Active + Credits + Storage + Actions + + + + {p.loading ? ( + + + + + + ) : p.pagedUsers.length === 0 ? ( + + + No users found + + + ) : ( + p.pagedUsers.map((user) => { + const isKcOnly = user.syncStatus === 'keycloak_only'; + const isSelected = user.id !== 0 && p.selectedIds.has(user.id); + const roles = user.roles ?? []; + const groupName = (user.groups ?? [])[0]; + const storagePct = (user.storagePct ?? 0) * 100; + return ( + + + p.toggleSelectOne(user.id)} + disabled={user.id === 0} + /> + + + + {user.email} + + {user.keycloakId && ( + + {user.keycloakId.slice(0, 8)}… + + )} + + {user.firstName} {user.lastName} + + {isKcOnly ? ( + } label="KC only" size="small" color="warning" /> + ) : ( + + )} + + + + + + {groupName ? ( + + ) : ( + + )} + + + {roles.length === 0 ? ( + + ) : ( + + {roles.slice(0, 3).map((r) => ( + + ))} + {roles.length > 3 && ( + + + + )} + + )} + + + + p.handleEmailVerifiedToggle(user)} + color="success" + size="small" + /> + + + + + + + + {user.creditsRemained} + + + + {isKcOnly || !user.storageLimitBytes ? ( + + ) : ( + + + 90 ? 'error' : storagePct > 70 ? 'warning' : 'primary'} + sx={{ height: 6, borderRadius: 3, mb: 0.3 }} + /> + + {storagePct.toFixed(0)}% · {fmtBytes(user.storageUsedBytes)} + + + + )} + + + {isKcOnly ? ( + + p.handleSyncUser(user)} color="warning"> + + + + ) : ( + + + p.handleEditUser(user)}> + + + + + p.setRolesUser(user)} + disabled={!user.keycloakId} + > + + + + + p.setUsageUser(user)}> + + + + + p.handleDeleteClick(user)} + color="error" + > + + + + + )} + + + ); + }) + )} + +
+
+ +
+ + ); +}; diff --git a/backend/admin-dashboard/src/components/auth/LoginRedirect.tsx b/backend/admin-dashboard/src/components/auth/LoginRedirect.tsx new file mode 100644 index 0000000..e4b99fc --- /dev/null +++ b/backend/admin-dashboard/src/components/auth/LoginRedirect.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import { useAuth } from '../../contexts/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +export const LoginRedirect: React.FC = () => { + const { isAuthenticated, login, initialized } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (initialized) { + if (!isAuthenticated) { + login(); + } else { + navigate('/'); + } + } + }, [isAuthenticated, login, navigate, initialized]); + + return ( + + + + Redirecting to login... + + + ); +}; \ No newline at end of file diff --git a/backend/admin-dashboard/src/components/auth/ProtectedRoute.tsx b/backend/admin-dashboard/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..67abe72 --- /dev/null +++ b/backend/admin-dashboard/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { CircularProgress, Box } from '@mui/material'; + +interface ProtectedRouteProps { + children: React.ReactNode; + /** Single role gate (kept for back-compat with existing usage). */ + requiredRole?: string; + /** Any-of role gate — user passes if they have at least one of these roles. */ + requiredAnyRole?: string[]; +} + +export const ProtectedRoute: React.FC = ({ + children, + requiredRole, + requiredAnyRole, +}) => { + const { isAuthenticated, hasRole, initialized } = useAuth(); + + if (!initialized) { + return ( + + + + ); + } + + if (!isAuthenticated) { + return ; + } + + if (requiredRole && !hasRole(requiredRole)) { + return ; + } + + if (requiredAnyRole && requiredAnyRole.length > 0) { + const allowed = requiredAnyRole.some((r) => hasRole(r)); + if (!allowed) { + return ; + } + } + + return <>{children}; +}; \ No newline at end of file diff --git a/backend/admin-dashboard/src/components/auth/Unauthorized.tsx b/backend/admin-dashboard/src/components/auth/Unauthorized.tsx new file mode 100644 index 0000000..a3809b6 --- /dev/null +++ b/backend/admin-dashboard/src/components/auth/Unauthorized.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Box, Typography, Button, Paper } from '@mui/material'; +import { Block as BlockIcon } from '@mui/icons-material'; +import { useAuth } from '../../contexts/AuthContext'; + +export const Unauthorized: React.FC = () => { + const { user, logout } = useAuth(); + const userEmail = (user as { email?: string } | undefined)?.email ?? 'unknown'; + + return ( + + + + 403 — Access Denied + + Your account does not have permission to access the DIDI admin dashboard. + + + Logged in as {userEmail}. Required roles: + admin + / + moderator + / + senior_moderator. + + + End users (clients) should use the public web app, not the admin dashboard. + + + + + + + + ); +}; + +export default Unauthorized; diff --git a/backend/admin-dashboard/src/components/dashboard/ServicesDashboard.tsx b/backend/admin-dashboard/src/components/dashboard/ServicesDashboard.tsx new file mode 100644 index 0000000..04d70b3 --- /dev/null +++ b/backend/admin-dashboard/src/components/dashboard/ServicesDashboard.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + Container, + Typography, + Box, + CircularProgress, + Alert, + Paper, + Divider, + Chip, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { + Storage as StorageIcon, + Security as SecurityIcon, + Analytics as AnalyticsIcon, + DataObject as DatabaseIcon, + Speed as MonitoringIcon, +} from '@mui/icons-material'; +import { ServiceCard } from '../services/ServiceCard'; +import { services, checkServiceHealth, ServiceStatus } from '../../services/api'; +import { serviceGroups } from '../../config/serviceGroups'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +export const ServicesDashboard: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [serviceStatuses, setServiceStatuses] = useState(services); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const isInitialMount = useRef(true); + + const theme = useTheme(); + const isSmallMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const groupFilter = searchParams.get('group'); + + const checkAllServices = useCallback(async () => { + setLoading(true); + try { + const results = await Promise.all( + services.map(service => checkServiceHealth(service)) + ); + setServiceStatuses(results); + setError(null); + } catch { + setError('Failed to check services'); + } finally { + setLoading(false); + isInitialMount.current = false; + } + }, []); + + useEffect(() => { + if (isInitialMount.current) { + checkAllServices(); + } + const interval = setInterval(checkAllServices, 30000); + return () => clearInterval(interval); + }, [checkAllServices]); + + const handleRefresh = async (serviceName: string) => { + const service = serviceStatuses.find(s => s.name === serviceName); + if (service) { + const updated = await checkServiceHealth(service); + setServiceStatuses(prev => + prev.map(s => s.name === serviceName ? updated : s) + ); + } + }; + + const handleAccess = (service: ServiceStatus) => { + if (service.name === 'didi-framework') { + navigate('/framework'); + } else if (service.name === 'didi-agent-v3') { + navigate('/llm-components'); + } else if (service.uiUrl) { + window.open(service.uiUrl, '_blank'); + } + }; + + if (loading && serviceStatuses.every(s => s.status === 'unknown')) { + return ( + + + + ); + } + + const getGroupIcon = (icon: string) => { + switch (icon) { + case 'database': return ; + case 'storage': return ; + case 'security': return ; + case 'monitoring': return ; + case 'analytics': return ; + default: return null; + } + }; + + return ( + + + Service Monitor + + + {groupFilter + ? `Viewing: ${serviceGroups.find(g => g.id === groupFilter)?.title || 'Unknown'}` + : 'Monitor and access all backend services' + } + + + {error && {error}} + + {serviceGroups + .filter(group => !groupFilter || group.id === groupFilter) + .map((group) => { + const groupServices = serviceStatuses.filter(service => + group.services.includes(service.name) + ); + if (groupServices.length === 0) return null; + + return ( + + + + {getGroupIcon(group.icon)} + + + {group.title} + + + + + {group.description} + + + + {groupServices.map((service) => ( + handleRefresh(service.name)} + onAccess={() => handleAccess(service)} + /> + ))} + + + ); + })} + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/AnalysisFlowVisualization.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/AnalysisFlowVisualization.tsx new file mode 100644 index 0000000..a51f79e --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/AnalysisFlowVisualization.tsx @@ -0,0 +1,299 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import ReactFlow, { + Node, + Edge, + Controls, + Background, + MiniMap, + useNodesState, + useEdgesState, + Position, + MarkerType, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { + Box, + Drawer, + Typography, + IconButton, + CircularProgress, + Alert, + Paper, +} from '@mui/material'; +import { Close as CloseIcon } from '@mui/icons-material'; +import { useFrameworkData } from './context/FrameworkDataContext'; +import StepNode from './nodes/StepNode'; +import IntakeDetailPanel from './panels/IntakeDetailPanel'; +import TechniqueDetailPanel from './panels/TechniqueDetailPanel'; +import SourceDetailPanel from './panels/SourceDetailPanel'; +import ClaimsDetailPanel from './panels/ClaimsDetailPanel'; +import VerdictDetailPanel from './panels/VerdictDetailPanel'; +import { AnalysisStep, StepId, StepNodeData } from './types'; + +// Definim tipurile de noduri custom +const nodeTypes = { + stepNode: StepNode, +}; + +// Pașii de analiză +const analysisSteps: AnalysisStep[] = [ + { + id: 'intake', + title: 'Content Intake', + description: 'Receive input, preprocess, detect type & language', + status: 'completed', + }, + { + id: 'techniques', + title: 'Technique Detection', + description: 'Analyze D1, D2, D3, D5, D7, D8 dimensions', + status: 'completed', + }, + { + id: 'sources', + title: 'Source Assessment', + description: 'Platform detection, source credibility scoring', + status: 'active', + }, + { + id: 'claims', + title: 'Claims Extraction', + description: 'Extract verifiable claims, prioritize', + status: 'pending', + }, + { + id: 'verdict', + title: 'Verdict & Risk', + description: 'Aggregate scores, apply multipliers, final verdict', + status: 'pending', + }, +]; + +// Poziții pentru noduri (layout scară - diagonal de la stânga-sus la dreapta-jos) +const nodePositions: Record = { + intake: { x: 50, y: 0 }, + techniques: { x: 200, y: 140 }, + sources: { x: 350, y: 280 }, + claims: { x: 500, y: 420 }, + verdict: { x: 650, y: 560 }, +}; + +interface AnalysisFlowVisualizationProps { + // Opțional: date de la o analiză în curs + analysisId?: string; + onStepClick?: (stepId: StepId) => void; +} + +const AnalysisFlowVisualization: React.FC = ({ + analysisId, + onStepClick, +}) => { + const { loading, error, dimensions, techniques, verdictCategories } = useFrameworkData(); + const [selectedStep, setSelectedStep] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + // Crează nodurile pentru ReactFlow - fără date hardcodate + const initialNodes: Node[] = useMemo(() => { + return analysisSteps.map(step => ({ + id: step.id, + type: 'stepNode', + position: nodePositions[step.id], + data: { + step, + // Scorurile vin de la o analiză reală, nu hardcodate + score: undefined, + details: step.id === 'techniques' + ? { + dimensions: dimensions.length, + techniques: techniques.length, + } + : undefined, + onExpand: () => handleNodeExpand(step.id), + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + })); + }, [dimensions.length, techniques.length]); + + // Crează edge-urile (conexiunile între noduri) - stil smoothstep pentru layout diagonal + const initialEdges: Edge[] = useMemo(() => [ + { + id: 'e-intake-techniques', + source: 'intake', + target: 'techniques', + type: 'smoothstep', + animated: true, + style: { stroke: '#4caf50', strokeWidth: 3 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 }, + }, + { + id: 'e-techniques-sources', + source: 'techniques', + target: 'sources', + type: 'smoothstep', + animated: true, + style: { stroke: '#4caf50', strokeWidth: 3 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#4caf50', width: 20, height: 20 }, + }, + { + id: 'e-sources-claims', + source: 'sources', + target: 'claims', + type: 'smoothstep', + animated: true, + style: { stroke: '#1976d2', strokeWidth: 3 }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#1976d2', width: 20, height: 20 }, + }, + { + id: 'e-claims-verdict', + source: 'claims', + target: 'verdict', + type: 'smoothstep', + style: { stroke: '#9e9e9e', strokeWidth: 3, strokeDasharray: '8,4' }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#9e9e9e', width: 20, height: 20 }, + }, + ], []); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const handleNodeExpand = useCallback((stepId: StepId) => { + setSelectedStep(stepId); + setDrawerOpen(true); + + if (onStepClick) { + onStepClick(stepId); + } + }, [onStepClick]); + + const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => { + handleNodeExpand(node.id as StepId); + }, [handleNodeExpand]); + + if (loading) { + return ( + + + Loading framework data... + + ); + } + + if (error) { + return ( + + Error loading framework data: {error} + + ); + } + + return ( + + {/* Header cu statistici */} + + + Analysis Flow Visualization + + + + {dimensions.length} Dimensions loaded + + + {techniques.length} Techniques available + + + {verdictCategories.length} Verdict categories + + + Data source: bos_parammgmt (DB) + + + + + {/* ReactFlow Canvas */} + + + + { + const status = (node.data as StepNodeData)?.step?.status; + if (status === 'completed') return '#4caf50'; + if (status === 'active') return '#1976d2'; + return '#9e9e9e'; + }} + /> + + + + + {/* Legendă */} + + + Legend: + + + Completed + + + + Active + + + + Pending + + + Click on a node to see details + + + + + {/* Drawer pentru detalii */} + setDrawerOpen(false)} + PaperProps={{ + sx: { width: { xs: '100%', sm: 600, md: 700 } }, + }} + > + + + + {selectedStep ? analysisSteps.find(s => s.id === selectedStep)?.title : 'Details'} + + setDrawerOpen(false)}> + + + + + {/* Afișează panoul corect în funcție de pasul selectat */} + {selectedStep === 'intake' && } + + {selectedStep === 'techniques' && ( + + )} + + {selectedStep === 'sources' && } + + {selectedStep === 'claims' && } + + {selectedStep === 'verdict' && ( + + )} + + + + ); +}; + +export default AnalysisFlowVisualization; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/context/FrameworkDataContext.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/context/FrameworkDataContext.tsx new file mode 100644 index 0000000..32eb45f --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/context/FrameworkDataContext.tsx @@ -0,0 +1,296 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { + FrameworkData, + Dimension, + Subdimension, + Technique, + TechniqueIndicator, + TechniqueValidationRule, + VerdictCategory, + RiskMapping, + Platform, + PlatformModifier, + SourceType, + SourceCredibility, + DomainAgeScore, + DomainRiskLevel, + DomainRedFlag, + AuthorClassification, + AuthorCredibility, + ClaimStatus, + ClaimType, + ConfidenceLevel, + Interpretation, + ComponentWeight, + WeightScenario, + Multiplier, + SeverityAssessment, +} from '../types'; + +const FRAMEWORK_API_URL = '/framework'; + +interface FrameworkDataContextType extends FrameworkData { + refresh: () => Promise; + getTechniquesByDimension: (dimensionId: number) => Technique[]; + getIndicatorsByTechnique: (techniqueId: number) => TechniqueIndicator[]; + getRulesByTechnique: (techniqueId: number) => TechniqueValidationRule[]; + getVerdictByScore: (score: number) => VerdictCategory | undefined; + getRiskByScore: (score: number) => RiskMapping | undefined; +} + +const initialState: FrameworkData = { + dimensions: [], + subdimensions: [], + techniques: [], + indicators: [], + validationRules: [], + verdictCategories: [], + riskMappings: [], + // Source Assessment - complete hierarchy + platforms: [], + platformModifiers: [], + sourceTypes: [], + sourceCredibility: [], + domainAgeScores: [], + domainRiskLevels: [], + domainRedFlags: [], + authorClassifications: [], + authorCredibility: [], + // Claims Analysis - complete hierarchy + claimStatus: [], + claimTypes: [], + confidenceLevels: [], + interpretations: [], + // Weights & multipliers + componentWeights: [], + weightScenarios: [], + multipliers: [], + // Severity Assessment + severityAssessments: [], + loading: true, + error: null, +}; + +const FrameworkDataContext = createContext(null); + +export const useFrameworkData = (): FrameworkDataContextType => { + const context = useContext(FrameworkDataContext); + if (!context) { + throw new Error('useFrameworkData must be used within a FrameworkDataProvider'); + } + return context; +}; + +interface ProviderProps { + children: ReactNode; +} + +export const FrameworkDataProvider: React.FC = ({ children }) => { + const [data, setData] = useState(initialState); + + const fetchData = useCallback(async () => { + setData(prev => ({ ...prev, loading: true, error: null })); + + try { + // Fetch all data in parallel - including Source Assessment and Claims Analysis + const [ + dimensionsRes, + subdimensionsRes, + techniquesRes, + indicatorsRes, + validationRulesRes, + verdictsRes, + riskRes, + platformsRes, + platformModifiersRes, + sourcesRes, + sourceCredibilityRes, + domainAgeRes, + domainRiskRes, + domainRedFlagsRes, + authorClassRes, + authorCredRes, + claimStatusRes, + claimTypesRes, + confidenceRes, + interpretationRes, + weightsComponentsRes, + weightsScenariosRes, + multipliersRes, + severityRes, + ] = await Promise.all([ + fetch(`${FRAMEWORK_API_URL}/api/dimensions`), + fetch(`${FRAMEWORK_API_URL}/api/subdimensions`), + fetch(`${FRAMEWORK_API_URL}/api/techniques`), + fetch(`${FRAMEWORK_API_URL}/api/indicators`), + fetch(`${FRAMEWORK_API_URL}/api/validation-rules`), + fetch(`${FRAMEWORK_API_URL}/api/verdicts/categories`), + fetch(`${FRAMEWORK_API_URL}/api/verdicts/risk`), + // Source Assessment - Platform section + fetch(`${FRAMEWORK_API_URL}/api/platforms`), + fetch(`${FRAMEWORK_API_URL}/api/platform-modifiers`), + // Source Assessment - Publication section + fetch(`${FRAMEWORK_API_URL}/api/sources`), + fetch(`${FRAMEWORK_API_URL}/api/source-credibility`), + // Source Assessment - Domain section + fetch(`${FRAMEWORK_API_URL}/api/domain-age-scores`), + fetch(`${FRAMEWORK_API_URL}/api/domain-risk-levels`), + fetch(`${FRAMEWORK_API_URL}/api/domain-red-flags`), + // Source Assessment - Author section + fetch(`${FRAMEWORK_API_URL}/api/author-classifications`), + fetch(`${FRAMEWORK_API_URL}/api/author-credibility`), + // Claims Analysis + fetch(`${FRAMEWORK_API_URL}/api/claims/status`), + fetch(`${FRAMEWORK_API_URL}/api/claims/types`), + fetch(`${FRAMEWORK_API_URL}/api/claims/confidence`), + fetch(`${FRAMEWORK_API_URL}/api/claims/interpretation`), + // Weights & multipliers + fetch(`${FRAMEWORK_API_URL}/api/weights/components`), + fetch(`${FRAMEWORK_API_URL}/api/weights/scenarios`), + fetch(`${FRAMEWORK_API_URL}/api/weights/multipliers`), + // Severity Assessment + fetch(`${FRAMEWORK_API_URL}/api/verdicts/severity`), + ]); + + const [ + dimensionsData, + subdimensionsData, + techniquesData, + indicatorsData, + validationRulesData, + verdictsData, + riskData, + platformsData, + platformModifiersData, + sourcesData, + sourceCredibilityData, + domainAgeData, + domainRiskData, + domainRedFlagsData, + authorClassData, + authorCredData, + claimStatusData, + claimTypesData, + confidenceData, + interpretationData, + weightsComponentsData, + weightsScenariosData, + multipliersData, + severityData, + ] = await Promise.all([ + dimensionsRes.json(), + subdimensionsRes.json(), + techniquesRes.json(), + indicatorsRes.json(), + validationRulesRes.json(), + verdictsRes.json(), + riskRes.json(), + platformsRes.json(), + platformModifiersRes.json(), + sourcesRes.json(), + sourceCredibilityRes.json(), + domainAgeRes.json(), + domainRiskRes.json(), + domainRedFlagsRes.json(), + authorClassRes.json(), + authorCredRes.json(), + claimStatusRes.json(), + claimTypesRes.json(), + confidenceRes.json(), + interpretationRes.json(), + weightsComponentsRes.json(), + weightsScenariosRes.json(), + multipliersRes.json(), + severityRes.json(), + ]); + + setData({ + dimensions: dimensionsData.success ? dimensionsData.data : [], + subdimensions: subdimensionsData.success ? subdimensionsData.data : [], + techniques: techniquesData.success ? techniquesData.data : [], + indicators: indicatorsData.success ? indicatorsData.data : [], + validationRules: validationRulesData.success ? validationRulesData.data : [], + verdictCategories: verdictsData.success ? verdictsData.data : [], + riskMappings: riskData.success ? riskData.data : [], + // Source Assessment - complete hierarchy + platforms: platformsData.success ? platformsData.data : [], + platformModifiers: platformModifiersData.success ? platformModifiersData.data : [], + sourceTypes: sourcesData.success ? sourcesData.data : [], + sourceCredibility: sourceCredibilityData.success ? sourceCredibilityData.data : [], + domainAgeScores: domainAgeData.success ? domainAgeData.data : [], + domainRiskLevels: domainRiskData.success ? domainRiskData.data : [], + domainRedFlags: domainRedFlagsData.success ? domainRedFlagsData.data : [], + authorClassifications: authorClassData.success ? authorClassData.data : [], + authorCredibility: authorCredData.success ? authorCredData.data : [], + // Claims Analysis - complete hierarchy + claimStatus: claimStatusData.success ? claimStatusData.data : [], + claimTypes: claimTypesData.success ? claimTypesData.data : [], + confidenceLevels: confidenceData.success ? confidenceData.data : [], + interpretations: interpretationData.success ? interpretationData.data : [], + // Weights & multipliers + componentWeights: weightsComponentsData.success ? weightsComponentsData.data : [], + weightScenarios: weightsScenariosData.success ? weightsScenariosData.data : [], + multipliers: multipliersData.success ? multipliersData.data : [], + // Severity Assessment + severityAssessments: severityData.success ? severityData.data : [], + loading: false, + error: null, + }); + } catch (error) { + setData(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch framework data', + })); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Helper: Get techniques by dimension (via subdimension mapping) + const getTechniquesByDimension = useCallback((dimensionId: number): Technique[] => { + const dimSubdimensions = data.subdimensions.filter(s => s.dimension_id === dimensionId); + const subdimIds = dimSubdimensions.map(s => s.subdimension_id); + return data.techniques.filter(t => subdimIds.includes(t.subdimension_id)); + }, [data.subdimensions, data.techniques]); + + // Helper: Get indicators by technique + const getIndicatorsByTechnique = useCallback((techniqueId: number): TechniqueIndicator[] => { + return data.indicators.filter(i => i.technique_id === techniqueId); + }, [data.indicators]); + + // Helper: Get validation rules by technique + const getRulesByTechnique = useCallback((techniqueId: number): TechniqueValidationRule[] => { + return data.validationRules.filter(r => r.technique_id === techniqueId); + }, [data.validationRules]); + + // Helper: Get verdict category by score + const getVerdictByScore = useCallback((score: number): VerdictCategory | undefined => { + return data.verdictCategories.find(v => score >= v.start_range && score <= v.end_range); + }, [data.verdictCategories]); + + // Helper: Get risk mapping by score + const getRiskByScore = useCallback((score: number): RiskMapping | undefined => { + return data.riskMappings.find(r => score >= r.start_range && score <= r.end_range); + }, [data.riskMappings]); + + const contextValue: FrameworkDataContextType = { + ...data, + refresh: fetchData, + getTechniquesByDimension, + getIndicatorsByTechnique, + getRulesByTechnique, + getVerdictByScore, + getRiskByScore, + }; + + return ( + + {children} + + ); +}; + +export default FrameworkDataContext; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/index.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/index.tsx new file mode 100644 index 0000000..93a046d --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/index.tsx @@ -0,0 +1,21 @@ +// AnalysisFlowVisualization - Main export +// Vizualizează pipeline-ul de analiză în 5 pași cu date din DB + +export { default } from './AnalysisFlowVisualization'; +export { default as AnalysisFlowVisualization } from './AnalysisFlowVisualization'; + +// Context & Hook +export { FrameworkDataProvider, useFrameworkData } from './context/FrameworkDataContext'; + +// Nodes +export { default as StepNode } from './nodes/StepNode'; + +// Panels +export { default as IntakeDetailPanel } from './panels/IntakeDetailPanel'; +export { default as TechniqueDetailPanel } from './panels/TechniqueDetailPanel'; +export { default as SourceDetailPanel } from './panels/SourceDetailPanel'; +export { default as ClaimsDetailPanel } from './panels/ClaimsDetailPanel'; +export { default as VerdictDetailPanel } from './panels/VerdictDetailPanel'; + +// Types +export * from './types'; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/nodes/StepNode.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/nodes/StepNode.tsx new file mode 100644 index 0000000..5de06cb --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/nodes/StepNode.tsx @@ -0,0 +1,177 @@ +import React, { memo } from 'react'; +import { Handle, Position, NodeProps } from 'reactflow'; +import { + Box, + Card, + CardContent, + Typography, + Chip, + LinearProgress, + IconButton, + Tooltip, +} from '@mui/material'; +import { + Input as IntakeIcon, + Psychology as TechniqueIcon, + Source as SourceIcon, + FactCheck as ClaimsIcon, + Gavel as VerdictIcon, + ExpandMore as ExpandIcon, + CheckCircle as CompletedIcon, + RadioButtonUnchecked as PendingIcon, + PlayCircle as ActiveIcon, +} from '@mui/icons-material'; +import { StepNodeData, StepId } from '../types'; + +const stepIcons: Record = { + intake: , + techniques: , + sources: , + claims: , + verdict: , +}; + +const statusIcons = { + pending: , + active: , + completed: , +}; + +const statusColors = { + pending: 'default', + active: 'primary', + completed: 'success', +} as const; + +interface StepNodeProps extends NodeProps {} + +const StepNode: React.FC = ({ data, selected }) => { + const { step, score, details, onExpand } = data; + const Icon = stepIcons[step.id]; + + return ( + <> + {/* Input handle - not for intake (pe stânga pentru layout scară) */} + {step.id !== 'intake' && ( + + )} + + + + {/* Header */} + + + + {Icon} + + + {step.title} + + + {statusIcons[step.status]} + + + {/* Description */} + + {step.description} + + + {/* Score (if available) */} + {score !== undefined && ( + + + + Score + + + {score.toFixed(1)}% + + + 70 ? 'error.main' : score > 40 ? 'warning.main' : 'success.main', + }, + }} + /> + + )} + + {/* Details preview */} + {details && Object.keys(details).length > 0 && ( + + {Object.entries(details).slice(0, 3).map(([key, value]) => ( + + ))} + {Object.keys(details).length > 3 && ( + + )} + + )} + + {/* Expand button */} + {onExpand && ( + + + + + + + + )} + + + + {/* Output handle - not for verdict (pe dreapta pentru layout scară) */} + {step.id !== 'verdict' && ( + + )} + + ); +}; + +export default memo(StepNode); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/ClaimsCategoryRow.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/ClaimsCategoryRow.tsx new file mode 100644 index 0000000..c0fb6ed --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/ClaimsCategoryRow.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; +import { Box, Typography, Paper, Chip, IconButton, Collapse, Divider } from '@mui/material'; +import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material'; +import { colors } from './colors'; + +interface Props { + title: string; + icon: React.ReactNode; + itemCount: number; + children: React.ReactNode; + defaultExpanded?: boolean; + subtitle?: string; +} + +export const ClaimsCategoryRow: React.FC = ({ + title, + icon, + itemCount, + children, + defaultExpanded = false, + subtitle, +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1.5, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: expanded ? colors.surfaceAlt : '#fff', + '&:hover': { bgcolor: colors.surfaceAlt }, + }} + > + + {expanded ? : } + + + {icon} + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + + + + + + {children} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/colors.ts b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/colors.ts new file mode 100644 index 0000000..f3a1b65 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/colors.ts @@ -0,0 +1,46 @@ +export const colors = { + primary: '#1a237e', + secondary: '#37474f', + accent: '#455a64', + surface: '#f8f9fa', + surfaceAlt: '#eceff1', + border: '#cfd8dc', + borderLight: '#e0e0e0', + text: '#212121', + textSecondary: '#616161', + success: '#2e7d32', + warning: '#e65100', + error: '#c62828', + info: '#0277bd', +}; + +export const getWeightColor = (weight: number): string => { + if (weight >= 0.8) return colors.success; + if (weight >= 0.6) return '#558b2f'; + if (weight >= 0.5) return colors.warning; + if (weight >= 0.4) return '#d84315'; + return colors.error; +}; + +export const getStatusColorHex = (colorName: string): string => { + const map: Record = { + green: '#2e7d32', + lightgreen: '#558b2f', + gray: '#757575', + orange: '#e65100', + red: '#c62828', + blue: '#1565c0', + darkgray: '#424242', + }; + return map[colorName] || '#757575'; +}; + +export const getConfidenceColorHex = (colorName: string): string => { + const map: Record = { + yellow: '#f9a825', + orange: '#e65100', + red: '#c62828', + darkred: '#b71c1c', + }; + return map[colorName] || '#757575'; +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/index.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/index.tsx new file mode 100644 index 0000000..5998db8 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/index.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, LinearProgress, Divider } from '@mui/material'; +import { + FactCheck as ClaimIcon, + Category as TypeIcon, + Verified as VerifiedIcon, + Speed as SpeedIcon, + Handshake as ConcordanceIcon, + ArrowForward as ArrowIcon, +} from '@mui/icons-material'; +import { useFrameworkData } from '../../context/FrameworkDataContext'; +import { colors } from './colors'; +import { ClaimsCategoryRow } from './ClaimsCategoryRow'; +import { WeightBar, StatusBadge, ClaimTypeCard, ConfidenceLevelRow, InterpretationRow } from './rows'; + +const ClaimsDetailPanel: React.FC = () => { + const { + claimStatus, + claimTypes, + confidenceLevels, + interpretations, + componentWeights, + loading, + } = useFrameworkData(); + + const claimsWeight = componentWeights.find(cw => cw.component_name === 'claims'); + const sortedClaimTypes = [...claimTypes].sort((a, b) => b.base_weight - a.base_weight); + + if (loading) { + return ( + + Loading claims data... + + + ); + } + + return ( + + {/* Header */} + + + + + + + Claims Analysis + + + Claim verification and truth status determination + + + + + {/* Formula Box */} + + + Confidence Formula + + + Confidence(claim) = W_type x W_sources x W_concordance x W_recency + + + + Claims contribute {claimsWeight?.component_weight || 25}% to final risk score + + + + {/* Stats */} + + + {claimTypes.length} + Claim Types + + + {claimStatus.length} + Status Values + + + {confidenceLevels.length} + Confidence + + + {interpretations.length} + Concordance + + + + {/* Processing Flow */} + + + Processing Flow + + + + + + + + + + + + + + + {/* Category 1: Claim Types */} + } + itemCount={claimTypes.length} + defaultExpanded + > + + + Weight Overview + + {sortedClaimTypes.map(ct => ( + + ))} + + {sortedClaimTypes.map(ct => ( + + ))} + + + {/* Category 2: Claim Status */} + } + itemCount={claimStatus.length} + defaultExpanded + > + + + + Verified / Likely + + {claimStatus + .filter(s => ['VT', 'LT', 'UV'].includes(s.claim_code)) + .map(status => )} + + + + False / Other + + {claimStatus + .filter(s => ['LF', 'VF', 'OP', 'NV'].includes(s.claim_code)) + .map(status => )} + + + + + {/* Category 3: Confidence Levels */} + } + itemCount={confidenceLevels.length} + > + {confidenceLevels.map(level => ( + + ))} + + + {/* Category 4: Interpretation (W_concordance) */} + } + itemCount={interpretations.length} + > + {interpretations.map((item, index) => ( + + ))} + + + {/* Decision Matrix */} + + + Decision Matrix + + + Final status is determined by combining: + + + + x + + x + + x + + + + + + + ); +}; + +export default ClaimsDetailPanel; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/rows.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/rows.tsx new file mode 100644 index 0000000..c59abdc --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/ClaimsDetailPanel/rows.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Tooltip, LinearProgress } from '@mui/material'; +import { + Search as SearchIcon, + Psychology as LLMIcon, + HelpOutline as HelpIcon, + TrendingUp as TrendingUpIcon, +} from '@mui/icons-material'; +import type { ClaimStatus, ClaimType, ConfidenceLevel, Interpretation } from '../../types'; +import { colors, getWeightColor, getStatusColorHex, getConfidenceColorHex } from './colors'; + +export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => { + const percent = weight * 100; + const color = getWeightColor(weight); + + return ( + + + {label} + {percent.toFixed(0)}% + + + + ); +}; + +export const StatusBadge: React.FC<{ status: ClaimStatus }> = ({ status }) => { + const bgColor = getStatusColorHex(status.claim_color); + const hasRange = status.start_range !== null && status.end_range !== null; + + return ( + + + + + {status.claim_code} - {status.claim_name} + + + {hasRange ? ( + + ) : ( + + )} + + ); +}; + +export const ClaimTypeCard: React.FC<{ claimType: ClaimType }> = ({ claimType }) => { + const weightColor = getWeightColor(claimType.base_weight); + + const getMethodIcon = (method: string) => { + if (method.includes('LLM')) return ; + if (method.includes('Web Search')) return ; + if (method.includes('Not verifiable')) return ; + return ; + }; + + return ( + + + + + {claimType.claim_type_name} + + + + + + + + {claimType.description} + + + + {getMethodIcon(claimType.verification_method)} + + {claimType.verification_method} + + + + ); +}; + +export const ConfidenceLevelRow: React.FC<{ level: ConfidenceLevel }> = ({ level }) => { + const color = getConfidenceColorHex(level.confidence_color); + + return ( + + + {level.confidence_level} + + + + {level.confidence_name} + + + Action: {level.action} + + + + + ); +}; + +export const InterpretationRow: React.FC<{ item: Interpretation; index: number; total: number }> = ({ item, index, total }) => { + const percent = index / (total - 1); + const r = Math.round(198 - (percent * 152)); + const g = Math.round(40 + (percent * 86)); + const b = Math.round(40 + (percent * 10)); + const color = `rgb(${r}, ${g}, ${b})`; + + return ( + + + + + {item.interpretation} + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/IntakeDetailPanel.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/IntakeDetailPanel.tsx new file mode 100644 index 0000000..65af63d --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/IntakeDetailPanel.tsx @@ -0,0 +1,428 @@ +import React from 'react'; +import { + Box, + Typography, + Paper, + Chip, + Divider, +} from '@mui/material'; +import { + Input as IntakeIcon, + TextFields as TextIcon, + Image as ImageIcon, + AudioFile as AudioIcon, + VideoFile as VideoIcon, + Link as UrlIcon, + Translate as LangIcon, + CheckCircle as CheckIcon, + Block as BlockIcon, + ArrowForward as ArrowIcon, + FilterAlt as FilterIcon, + Settings as ProcessIcon, +} from '@mui/icons-material'; + +// ============================================================================ +// DESIGN SYSTEM - Professional colors +// ============================================================================ +const colors = { + primary: '#1a237e', + secondary: '#37474f', + accent: '#455a64', + surface: '#f8f9fa', + border: '#e0e0e0', + text: '#212121', + textSecondary: '#616161', + success: '#2e7d32', + warning: '#e65100', + error: '#c62828', +}; + +// ============================================================================ +// STEP ITEM COMPONENT +// ============================================================================ +interface StepItemProps { + number: number; + title: string; + description: string; +} + +const StepItem: React.FC = ({ number, title, description }) => ( + + + {number} + + + + {title} + + + {description} + + + +); + +// ============================================================================ +// CONTENT TYPE CHIP +// ============================================================================ +interface ContentTypeChipProps { + icon: React.ReactNode; + label: string; + sublabel?: string; +} + +const ContentTypeChip: React.FC = ({ icon, label, sublabel }) => ( + + {icon} + + + {label} + + {sublabel && ( + + {sublabel} + + )} + + +); + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ +const IntakeDetailPanel: React.FC = () => { + return ( + + {/* Header */} + + + + + + + Content Intake + + + Pre-processing and eligibility verification + + + + + {/* Processing Pipeline */} + + + Processing Pipeline + + + + + + + + + {/* Supported Content Types */} + + + Supported Content Types + + + + } label="Text" sublabel="Direct input" /> + } label="Image" sublabel="OCR extraction" /> + } label="Audio" sublabel="Speech-to-text" /> + } label="Video" sublabel="Frame + audio" /> + } label="URL" sublabel="Content fetch" /> + + + + {/* Supported Languages */} + + + + + Supported Languages + + + + + + + + + + + + {/* Eligibility Criteria */} + + + + + Eligibility Criteria + + + + + + + + Content must be in supported language (RO/EN) + + + + + + Minimum 50 characters of extractable text + + + + + + Contains verifiable claims or statements + + + + + + Not pure opinion, satire, or fiction + + + + + + {/* Refusal Policy */} + + + + + Refusal Policy + + + + + Content will be refused if: + + + + + - Language not supported + + + - Insufficient extractable content + + + - Pure entertainment/fiction content + + + - Technical/code content without claims + + + - Duplicate of recently analyzed content + + + + + {/* Output Flow */} + + + + + Output + + + + + + + + Proceeds to Technique Detection + + + + + + + + Returns refusal with reason code + + + + + ); +}; + +export default IntakeDetailPanel; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/WeightBar.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/WeightBar.tsx new file mode 100644 index 0000000..d21b1c5 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/WeightBar.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Box, Typography, LinearProgress } from '@mui/material'; +import { colors } from './colors'; + +export const WeightBar: React.FC<{ weight: number; label: string }> = ({ weight, label }) => ( + + + {label} + {weight}% + + + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/colors.ts b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/colors.ts new file mode 100644 index 0000000..599f1cf --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/colors.ts @@ -0,0 +1,18 @@ +/** + * SourceDetailPanel — design system colors + */ +export const colors = { + primary: '#1a237e', + secondary: '#37474f', + accent: '#455a64', + surface: '#f8f9fa', + surfaceAlt: '#eceff1', + border: '#cfd8dc', + borderLight: '#e0e0e0', + text: '#212121', + textSecondary: '#616161', + success: '#2e7d32', + warning: '#e65100', + error: '#c62828', + info: '#0277bd', +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/containers.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/containers.tsx new file mode 100644 index 0000000..f04b956 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/containers.tsx @@ -0,0 +1,151 @@ +/** + * SourceDetailPanel — collapsible container components + */ + +import React, { useState } from 'react'; +import { Box, Typography, Paper, Chip, Tooltip, IconButton, Collapse, Divider } from '@mui/material'; +import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material'; +import { colors } from './colors'; + +interface SourceCategoryRowProps { + title: string; + weight: number; + icon: React.ReactNode; + itemCount: number; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +export const SourceCategoryRow: React.FC = ({ + title, + weight, + icon, + itemCount, + children, + defaultExpanded = false, +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1.5, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: expanded ? colors.surfaceAlt : '#fff', + '&:hover': { bgcolor: colors.surfaceAlt }, + }} + > + + {expanded ? : } + + + {icon} + + + {title} + + + + + + + + + + + + + {children} + + + + ); +}; + +interface SubsectionRowProps { + title: string; + itemCount: number; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +export const SubsectionRow: React.FC = ({ + title, + itemCount, + children, + defaultExpanded = false, +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: '#fff', + '&:hover': { bgcolor: colors.surface }, + }} + > + + {expanded ? : } + + + + {title} + + + + + + + + + {children} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/index.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/index.tsx new file mode 100644 index 0000000..3f0e8ae --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/index.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Divider } from '@mui/material'; +import { + Web as PlatformIcon, + Source as SourceIcon, + Domain as DomainIcon, + Person as AuthorIcon, + Warning as WarningIcon, + Security as SecurityIcon, + ArrowForward as ArrowIcon, +} from '@mui/icons-material'; +import { useFrameworkData } from '../../context/FrameworkDataContext'; +import { colors } from './colors'; +import { WeightBar } from './WeightBar'; +import { SourceCategoryRow, SubsectionRow } from './containers'; +import { SourceTypeRow, SourceCredibilityRow } from './rows/source'; +import { PlatformRow, PlatformModifierRow } from './rows/platform'; +import { DomainAgeRow, DomainRiskRow, DomainRedFlagRow } from './rows/domain'; +import { AuthorClassificationRow, AuthorCredibilityRow } from './rows/author'; + +const SourceDetailPanel: React.FC = () => { + const { + platforms, + platformModifiers, + sourceTypes, + sourceCredibility, + domainAgeScores, + domainRiskLevels, + domainRedFlags, + authorClassifications, + authorCredibility, + } = useFrameworkData(); + + const totalItems = platforms.length + platformModifiers.length + sourceTypes.length + + sourceCredibility.length + domainAgeScores.length + domainRiskLevels.length + + domainRedFlags.length + authorClassifications.length + authorCredibility.length; + + const positiveModifiers = platformModifiers.filter(m => m.score > 0).length + + authorCredibility.filter(c => c.impact > 0).length; + const negativeModifiers = platformModifiers.filter(m => m.score < 0).length + + authorCredibility.filter(c => c.impact < 0).length + + domainRedFlags.length; + + return ( + + {/* Header */} + + + + + + + Source Assessment + + + Shared module - Claims, Manipulation, Verdict + + + + + {/* Formula Box */} + + + Source Score Formula + + + SOURCE_SCORE = (Publication x 35%) + (Domain x 25%) + (Author x 25%) + (Platform x 15%) + + + + Applied modifiers: domain_multiplier x author_modifier + + + + {/* Weight Distribution */} + + + Weight Distribution + + + + + + + + {/* Stats */} + + + {totalItems} + Parameters + + + {positiveModifiers} + Positive + + + {negativeModifiers} + Negative + + + {domainRedFlags.length} + Red Flags + + + + {/* Processing Flow */} + + + Processing Flow + + + + + + + + + + + + + + + {/* Categories */} + } + itemCount={sourceTypes.length + sourceCredibility.length} + defaultExpanded + > + + + Table: source_type | Base scores 5-100% + + {sourceTypes.map(item => )} + + + + Table: source_credibility | Multipliers + + {sourceCredibility.map(item => )} + + + + } + itemCount={domainAgeScores.length + domainRiskLevels.length + domainRedFlags.length} + > + + {domainAgeScores.map(item => )} + + + {domainRiskLevels.map(item => )} + + + {domainRedFlags.map(item => )} + + + + } + itemCount={authorClassifications.length + authorCredibility.length} + > + + {authorClassifications.map(item => )} + + + {authorCredibility.map(item => )} + + + + } + itemCount={platforms.length + platformModifiers.length} + > + + {platforms.map(item => )} + + + {platformModifiers.map(item => )} + + + + {/* Unknown Source Penalties */} + + + + + Unknown Source Penalties + + + + + + + + + + {/* DB Source */} + + Source: bos_parammgmt @ didi-postgres:5000/DIDI + + + ); +}; + +export default SourceDetailPanel; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/author.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/author.tsx new file mode 100644 index 0000000..6ec82b2 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/author.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { CheckCircle as CheckIcon, Cancel as CancelIcon } from '@mui/icons-material'; +import type { AuthorClassification, AuthorCredibility } from '../../../types'; +import { colors } from '../colors'; + +export const AuthorClassificationRow: React.FC<{ item: AuthorClassification }> = ({ item }) => ( + + + {item.author_classification_code} + + + {item.author_classification_name} + + = 100 ? colors.success : item.score >= 70 ? colors.warning : colors.error, + color: '#fff', + fontWeight: 600, + }} + /> + +); + +export const AuthorCredibilityRow: React.FC<{ item: AuthorCredibility }> = ({ item }) => ( + = 0 ? colors.success : colors.error}`, + }} + > + {item.impact >= 0 ? ( + + ) : ( + + )} + + {item.author_credibility} + + = 0 ? '+' : ''}${item.impact} pts`} + size="small" + variant="outlined" + sx={{ + borderColor: item.impact >= 0 ? colors.success : colors.error, + color: item.impact >= 0 ? colors.success : colors.error, + fontWeight: 600, + }} + /> + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/domain.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/domain.tsx new file mode 100644 index 0000000..89528f7 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/domain.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { + TrendingUp as TrendingUpIcon, + TrendingDown as TrendingDownIcon, + Flag as FlagIcon, +} from '@mui/icons-material'; +import type { DomainAgeScore, DomainRiskLevel, DomainRedFlag } from '../../../types'; +import { colors } from '../colors'; + +export const DomainAgeRow: React.FC<{ item: DomainAgeScore }> = ({ item }) => ( + = 0 ? colors.success : colors.error}`, + }} + > + + A{item.domain_age_score} + + + + {item.start_range} - {item.end_range === 9999 ? 'inf' : item.end_range} months + + + {item.description} + + + = 0 ? : } + label={`${item.score_impact >= 0 ? '+' : ''}${item.score_impact} pts`} + size="small" + variant="outlined" + sx={{ + borderColor: item.score_impact >= 0 ? colors.success : colors.error, + color: item.score_impact >= 0 ? colors.success : colors.error, + fontWeight: 600, + }} + /> + +); + +export const DomainRiskRow: React.FC<{ item: DomainRiskLevel }> = ({ item }) => ( + = 0 ? colors.success : colors.error}`, + }} + > + = 4 ? colors.error : item.domain_risk_level_id >= 3 ? colors.warning : colors.success, + color: '#fff', + fontWeight: 600, + minWidth: 70, + }} + /> + + + Score range: {item.start_range} - {item.end_range} + + + {item.interpretation} + + + = 0 ? '+' : ''}${item.score_impact} pts`} + size="small" + variant="outlined" + sx={{ + borderColor: item.score_impact >= 0 ? colors.success : colors.error, + color: item.score_impact >= 0 ? colors.success : colors.error, + fontWeight: 600, + }} + /> + +); + +export const DomainRedFlagRow: React.FC<{ item: DomainRedFlag }> = ({ item }) => ( + + + + + {item.domain_red_flag} + + + Condition: {item.condition} + + + Action: {item.action} + + + + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/platform.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/platform.tsx new file mode 100644 index 0000000..1ac0278 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/platform.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material'; +import type { Platform, PlatformModifier } from '../../../types'; +import { colors } from '../colors'; + +export const PlatformRow: React.FC<{ item: Platform }> = ({ item }) => ( + + + P{item.platform_id} + + + + {item.platform_name} + + + {item.notes} | Code: {item.platform_code} + + + = 70 ? colors.success : item.platform_score >= 40 ? colors.warning : colors.error, + color: '#fff', + fontWeight: 600, + }} + /> + +); + +export const PlatformModifierRow: React.FC<{ item: PlatformModifier }> = ({ item }) => ( + = 0 ? colors.success : colors.error}`, + }} + > + = 0 ? '#e8f5e9' : '#ffebee', + px: 0.75, + py: 0.25, + borderRadius: 0.5, + minWidth: 30, + textAlign: 'center', + }} + > + M{item.platform_modifier_id} + + + + {item.platform_modifier} + + + {item.condition} + + + = 0 ? : } + label={`${item.score >= 0 ? '+' : ''}${item.score} pts`} + size="small" + variant="outlined" + sx={{ + borderColor: item.score >= 0 ? colors.success : colors.error, + color: item.score >= 0 ? colors.success : colors.error, + fontWeight: 600, + }} + /> + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/source.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/source.tsx new file mode 100644 index 0000000..4e3cdff --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/SourceDetailPanel/rows/source.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material'; +import type { SourceType, SourceCredibility } from '../../../types'; +import { colors } from '../colors'; + +export const SourceTypeRow: React.FC<{ item: SourceType }> = ({ item }) => ( + + + S{item.source_type_id} + + + {item.source_type} + + = 70 ? colors.success : item.base_score >= 40 ? colors.warning : colors.error, + color: '#fff', + fontWeight: 600, + }} + /> + +); + +export const SourceCredibilityRow: React.FC<{ item: SourceCredibility }> = ({ item }) => ( + + + {item.source_credibility_id} + + + + {item.source_credibility} + + + {item.condition} + + + = 100 ? : } + label={`x${(item.factor / 100).toFixed(2)}`} + size="small" + variant="outlined" + sx={{ + borderColor: item.factor >= 100 ? colors.success : colors.warning, + color: item.factor >= 100 ? colors.success : colors.warning, + fontWeight: 600, + }} + /> + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/DimensionRow.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/DimensionRow.tsx new file mode 100644 index 0000000..8803008 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/DimensionRow.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { Box, Typography, Chip, Paper, Tooltip, IconButton, Collapse, Divider } from '@mui/material'; +import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material'; +import type { Subdimension, Technique, TechniqueIndicator, TechniqueValidationRule } from '../../types'; +import { colors } from './colors'; +import { SubdimensionRow } from './SubdimensionRow'; + +interface Props { + dimension: { + dimension_id: number; + dimension_code: string; + dimension_name: string; + description: string; + weight: number; + }; + subdimensions: Subdimension[]; + techniques: Technique[]; + getIndicatorsByTechnique: (id: number) => TechniqueIndicator[]; + getRulesByTechnique: (id: number) => TechniqueValidationRule[]; +} + +export const DimensionRow: React.FC = ({ + dimension, + subdimensions, + techniques, + getIndicatorsByTechnique, + getRulesByTechnique, +}) => { + const [expanded, setExpanded] = useState(false); + + const avgSeverity = techniques.length > 0 + ? Math.round(techniques.reduce((sum, t) => sum + t.severity, 0) / techniques.length) + : 0; + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1.5, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: expanded ? colors.surfaceAlt : '#fff', + '&:hover': { bgcolor: colors.surfaceAlt }, + }} + > + + {expanded ? : } + + + + + + + {dimension.dimension_name} + + + {dimension.description} + + + + + + + + + + + + + + + + + + + + + dimension_id: {dimension.dimension_id} | weight: {dimension.weight}% + + {subdimensions.map(subdim => ( + t.subdimension_id === subdim.subdimension_id)} + getIndicatorsByTechnique={getIndicatorsByTechnique} + getRulesByTechnique={getRulesByTechnique} + /> + ))} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/SubdimensionRow.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/SubdimensionRow.tsx new file mode 100644 index 0000000..e4dafa8 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/SubdimensionRow.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { Box, Typography, Chip, Paper, IconButton, Collapse, Divider } from '@mui/material'; +import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material'; +import type { Subdimension, Technique, TechniqueIndicator, TechniqueValidationRule } from '../../types'; +import { colors } from './colors'; +import { TechniqueRow } from './TechniqueRow'; + +interface Props { + subdimension: Subdimension; + techniques: Technique[]; + getIndicatorsByTechnique: (id: number) => TechniqueIndicator[]; + getRulesByTechnique: (id: number) => TechniqueValidationRule[]; +} + +export const SubdimensionRow: React.FC = ({ + subdimension, + techniques, + getIndicatorsByTechnique, + getRulesByTechnique, +}) => { + const [expanded, setExpanded] = useState(false); + + if (techniques.length === 0) return null; + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: '#fff', + '&:hover': { bgcolor: colors.surface }, + }} + > + + {expanded ? : } + + + + + + + {subdimension.subdimension_name} + + {subdimension.description && ( + + {subdimension.description} + + )} + + + + + + + + + + subdimension_id: {subdimension.subdimension_id} + + {techniques.map(technique => ( + + ))} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/TechniqueRow.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/TechniqueRow.tsx new file mode 100644 index 0000000..15bf192 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/TechniqueRow.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { Box, Typography, Chip, Paper, Tooltip, IconButton, Collapse, Divider, Badge } from '@mui/material'; +import { + Visibility as VisibilityIcon, + VisibilityOff as VisibilityOffIcon, + Gavel as GavelIcon, +} from '@mui/icons-material'; +import type { Technique, TechniqueIndicator, TechniqueValidationRule } from '../../types'; +import { colors, getSeverityColor, getSeverityLabel } from './colors'; + +interface Props { + technique: Technique; + indicators: TechniqueIndicator[]; + validationRules: TechniqueValidationRule[]; +} + +export const TechniqueRow: React.FC = ({ technique, indicators, validationRules }) => { + const [showIndicators, setShowIndicators] = useState(false); + const [showRules, setShowRules] = useState(false); + + const severityColor = getSeverityColor(technique.severity); + + return ( + + + + T{technique.technique_id} + + + + + {technique.technique_name} + + {technique.description && ( + + {typeof technique.description === 'object' ? (technique.description.ro || technique.description.en || '') : String(technique.description)} + + )} + + + + + + + + + + + {indicators.length > 0 && ( + + { e.stopPropagation(); setShowIndicators(!showIndicators); }} + sx={{ color: showIndicators ? colors.info : colors.textSecondary }} + > + + {showIndicators ? : } + + + + )} + + {validationRules.length > 0 && ( + + { e.stopPropagation(); setShowRules(!showRules); }} + sx={{ color: showRules ? colors.warning : colors.textSecondary }} + > + + + + + + )} + + + {/* Indicators */} + + + + + DETECTION INDICATORS ({indicators.length}) + + {indicators.map((indicator, idx) => ( + + + {idx + 1} + + + + {indicator.indicator_name} + + {indicator.description && ( + + {indicator.description} + + )} + + + + ))} + + + + {/* Validation Rules */} + + + + + VALIDATION RULES ({validationRules.length}) + + {validationRules.map((rule, idx) => ( + + + {idx + 1} + + + + {rule.rule_name} + + + {rule.description} + + + + + ))} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/colors.ts b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/colors.ts new file mode 100644 index 0000000..8607ebb --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/colors.ts @@ -0,0 +1,34 @@ +export const colors = { + primary: '#1a237e', + secondary: '#37474f', + accent: '#455a64', + surface: '#f8f9fa', + surfaceAlt: '#eceff1', + border: '#cfd8dc', + borderLight: '#e0e0e0', + text: '#212121', + textSecondary: '#616161', + success: '#2e7d32', + warning: '#e65100', + error: '#c62828', + info: '#0277bd', + // Severity scale + severityCritical: '#b71c1c', + severityHigh: '#d84315', + severityMedium: '#f57c00', + severityLow: '#558b2f', +}; + +export const getSeverityColor = (severity: number): string => { + if (severity >= 80) return colors.severityCritical; + if (severity >= 60) return colors.severityHigh; + if (severity >= 40) return colors.severityMedium; + return colors.severityLow; +}; + +export const getSeverityLabel = (severity: number): string => { + if (severity >= 80) return 'CRITICAL'; + if (severity >= 60) return 'HIGH'; + if (severity >= 40) return 'MEDIUM'; + return 'LOW'; +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/index.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/index.tsx new file mode 100644 index 0000000..2273fa9 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/TechniqueDetailPanel/index.tsx @@ -0,0 +1,160 @@ +import React, { useMemo } from 'react'; +import { Box, Typography, Chip, Paper } from '@mui/material'; +import { + Psychology as PsychologyIcon, + AccountTree as TreeIcon, + Gavel as GavelIcon, + Info as InfoIcon, +} from '@mui/icons-material'; +import { useFrameworkData } from '../../context/FrameworkDataContext'; +import type { Subdimension, Technique } from '../../types'; +import { colors } from './colors'; +import { DimensionRow } from './DimensionRow'; + +const TechniqueDetailPanel: React.FC = () => { + const { + dimensions, + subdimensions, + techniques, + indicators, + validationRules, + getIndicatorsByTechnique, + getRulesByTechnique, + } = useFrameworkData(); + + const subdimensionsByDimension = useMemo(() => { + const grouped: Record = {}; + subdimensions.forEach(sub => { + if (!grouped[sub.dimension_id]) grouped[sub.dimension_id] = []; + grouped[sub.dimension_id].push(sub); + }); + return grouped; + }, [subdimensions]); + + const techniquesByDimension = useMemo(() => { + const grouped: Record = {}; + techniques.forEach(tech => { + const subdim = subdimensions.find(s => s.subdimension_id === tech.subdimension_id); + if (subdim) { + if (!grouped[subdim.dimension_id]) grouped[subdim.dimension_id] = []; + grouped[subdim.dimension_id].push(tech); + } + }); + return grouped; + }, [techniques, subdimensions]); + + const activeDimensions = dimensions.filter(dim => + [1, 2, 3, 5, 7, 8].includes(dim.dimension_id) + ); + + const totalWeight = activeDimensions.reduce((sum, d) => sum + d.weight, 0); + + return ( + + {/* Header */} + + + + + + + Technique Detection + + + Manipulation detection parameters hierarchy + + + + + {/* Stats Bar */} + + + } + label={`${activeDimensions.length} Dimensions (${totalWeight}%)`} + sx={{ bgcolor: colors.primary, color: '#fff', fontWeight: 600 }} + /> + + + + } + label={`${validationRules.length} Rules`} + variant="outlined" + size="small" + sx={{ borderColor: colors.warning, color: colors.warning }} + /> + + + + {/* Legend */} + + + + + Click to expand: Dimension - Subdimension - Technique - Indicators + Rules + + + + + + Low + + + + Medium + + + + High + + + + Critical + + + + + {/* Dimensions Tree */} + + {activeDimensions.map(dimension => ( + + ))} + + + {/* Footer Note */} + + + + + D4 (Amplification) and D6 (Operations) require external data (social media APIs, network analysis) - not processed by LLM + + + + + {/* DB Source */} + + Source: bos_parammgmt.dimension - subdimension - technique - technique_indicator + technique_validation_rule + + + ); +}; + +export default TechniqueDetailPanel; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/CategoryRow.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/CategoryRow.tsx new file mode 100644 index 0000000..c159df7 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/CategoryRow.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; +import { Box, Typography, Paper, Chip, IconButton, Collapse, Divider } from '@mui/material'; +import { ExpandMore as ExpandIcon, ChevronRight as ChevronRightIcon } from '@mui/icons-material'; +import { colors } from './colors'; + +interface CategoryRowProps { + title: string; + icon: React.ReactNode; + itemCount: number; + children: React.ReactNode; + defaultExpanded?: boolean; + subtitle?: string; +} + +export const CategoryRow: React.FC = ({ + title, + icon, + itemCount, + children, + defaultExpanded = false, + subtitle, +}) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + + setExpanded(!expanded)} + sx={{ + p: 1.5, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 1, + bgcolor: expanded ? colors.surfaceAlt : '#fff', + '&:hover': { bgcolor: colors.surfaceAlt }, + }} + > + + {expanded ? : } + + + {icon} + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + + + + + + {children} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/colors.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/colors.tsx new file mode 100644 index 0000000..62c5ac4 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/colors.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { + Category as CategoryIcon, + Psychology as ManipulationIcon, + FactCheck as ClaimsIcon, + Source as SourceIcon, + SmartToy as AIIcon, + Explore as ContextIcon, +} from '@mui/icons-material'; + +export const colors = { + primary: '#1a237e', + secondary: '#37474f', + accent: '#455a64', + surface: '#f8f9fa', + surfaceAlt: '#eceff1', + border: '#cfd8dc', + borderLight: '#e0e0e0', + text: '#212121', + textSecondary: '#616161', + success: '#2e7d32', + warning: '#e65100', + error: '#c62828', + info: '#0277bd', + manipulation: '#7b1fa2', + claims: '#1565c0', + source: '#00695c', + ai: '#0277bd', + context: '#455a64', + reliable: '#2e7d32', + mostlyReliable: '#558b2f', + mixedContent: '#f9a825', + suspicious: '#ef6c00', + misleading: '#d84315', + disinformation: '#b71c1c', +}; + +export const getComponentIcon = (name: string) => { + const icons: Record = { + manipulation: , + claims: , + source: , + ai: , + context: , + }; + return icons[name] || ; +}; + +export const getComponentColor = (name: string): string => { + return (colors as Record)[name] || colors.secondary; +}; + +export const getVerdictColor = (colorName: string): string => { + const map: Record = { + green: colors.reliable, + lightgreen: colors.mostlyReliable, + yellow: colors.mixedContent, + orange: colors.suspicious, + red: colors.misleading, + darkred: colors.disinformation, + }; + return map[colorName] || colors.secondary; +}; + +export const getSeverityColor = (category: string): string => { + const map: Record = { + LOW: colors.success, + MEDIUM: colors.warning, + HIGH: '#d84315', + CRITICAL: colors.error, + }; + return map[category.trim()] || colors.secondary; +}; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/index.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/index.tsx new file mode 100644 index 0000000..c8f06b0 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/index.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Tooltip, LinearProgress } from '@mui/material'; +import { + Calculate as CalcIcon, + Functions as FormulaIcon, + TrendingUp as MultiplierIcon, + Warning as RiskIcon, + ArrowForward as ArrowIcon, + Hub as HubIcon, + Shield as ShieldIcon, +} from '@mui/icons-material'; +import { useFrameworkData } from '../../context/FrameworkDataContext'; +import { colors, getComponentIcon, getComponentColor } from './colors'; +import { ComponentWeights } from './sections/ComponentWeights'; +import { TopicMultipliers } from './sections/TopicMultipliers'; +import { VerdictCategories } from './sections/VerdictCategories'; +import { RiskMappings } from './sections/RiskMappings'; +import { SeverityAssessments } from './sections/SeverityAssessments'; + +const VerdictDetailPanel: React.FC = () => { + const { + componentWeights, + multipliers, + verdictCategories, + riskMappings, + severityAssessments, + loading, + } = useFrameworkData(); + + if (loading) { + return ( + + Loading verdict data... + + + ); + } + + const topicMultipliers = multipliers.filter(m => m.multiplier_type === 1); + const inactiveMultipliers = multipliers.filter(m => m.multiplier_type !== 1); + + return ( + + {/* Header */} + + + + + + + Final Verdict Calculation + + + Aggregation of all components into final risk score + + + + + {/* Formula Box */} + + + + + Actual Calculation Pipeline + + + + + + base_risk = SUM(score x redistributed_weight) + + + + dampen if benign (techniques+ai near 0) + + + + overrides (false claims, severe tech, AI, domain) + + + + synergy bonus (2+ high-risk components) + + + x topic multiplier (if set) + + + = FINAL_RISK (0-100, rounded) + + + + + {componentWeights.map(cw => ( + + ))} + + + + {/* Stats */} + + + {componentWeights.length} + Components + + + {topicMultipliers.length} + Topic Mult. + + + {verdictCategories.length} + Categories + + + {riskMappings.length} + Risk Levels + + + {severityAssessments.length} + Severity + + + + {/* Processing Flow */} + + + + + Processing Flow + + + + + + {componentWeights.map(cw => ( + + + + ))} + + + } label="Weighted Sum" size="small" sx={{ bgcolor: colors.primary, color: '#fff' }} /> + + } label="Overrides + Synergy" size="small" sx={{ bgcolor: colors.error, color: '#fff' }} /> + + {topicMultipliers.length > 0 && ( + <> + } label={`x${topicMultipliers.length} Topic`} size="small" sx={{ bgcolor: colors.warning, color: '#fff' }} /> + + + )} + } label="Final Risk" size="small" sx={{ bgcolor: colors.error, color: '#fff' }} /> + + + + + + + + + + {/* Footer */} + + + Actual Calculation Logic (verdict-calculator.ts) + + + + 1. Extract component scores (manipulation, claims, ai, source) — already 0-100 + + + 2. Redistribute weights proportionally for missing components + + + 3. Calculate base_risk = SUM(score x redistributed_weight) / total_weight + + + 4. Dampen if benign: cap score when techniques + ai both near 0 + + + 5. Apply overrides: +bonus for false claims, severe techniques, undisclosed AI, untrusted domain + + + 6. Apply synergy bonus if 2+ components above threshold + + + 7. Floor: false claims guarantee minimum risk proportional to false ratio + + + 8. Apply topic multiplier (if options.topic set) + + + 9. Round to integer, map to verdict_category + risk_mapping + severity + + + 10. Calculate confidence score (base per component + signal bonuses) + + + + Full config (synergy, overrides, confidence) visible in LLM Components → Verdict tab. + + + + ); +}; + +export default VerdictDetailPanel; diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/ComponentWeights.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/ComponentWeights.tsx new file mode 100644 index 0000000..3ca0ce8 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/ComponentWeights.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Box, Typography, Paper, Tooltip } from '@mui/material'; +import { Balance as WeightIcon } from '@mui/icons-material'; +import type { ComponentWeight } from '../../../types'; +import { colors, getComponentColor, getComponentIcon } from '../colors'; +import { CategoryRow } from '../CategoryRow'; + +export const ComponentWeights: React.FC<{ componentWeights: ComponentWeight[] }> = ({ componentWeights }) => ( + } + itemCount={componentWeights.length} + defaultExpanded + > + {componentWeights.map(cw => ( + + + {getComponentIcon(cw.component_name)} + + + + {cw.component_name} + + + {cw.description} + + + + {cw.component_weight}% + + + ))} + + + + Visual weight distribution: + + + {componentWeights.map(cw => ( + + + {cw.component_weight}% + + + ))} + + + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/RiskMappings.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/RiskMappings.tsx new file mode 100644 index 0000000..6910bbd --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/RiskMappings.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Box, Typography, Chip, Tooltip } from '@mui/material'; +import { Warning as RiskIcon } from '@mui/icons-material'; +import type { RiskMapping } from '../../../types'; +import { colors, getVerdictColor } from '../colors'; +import { CategoryRow } from '../CategoryRow'; + +export const RiskMappings: React.FC<{ riskMappings: RiskMapping[] }> = ({ riskMappings }) => ( + } + itemCount={riskMappings.length} + > + + + Risk scale: + + + {riskMappings.map(rm => { + const width = rm.end_range - rm.start_range + 1; + const bgColor = getVerdictColor(rm.risk_color); + return ( + + + {rm.risk_level} + + + ); + })} + + + + {riskMappings.map(rm => { + const bgColor = getVerdictColor(rm.risk_color); + return ( + + + {rm.risk_level} + + + + {rm.risk_mapping} + + + Range: {rm.start_range}% - {rm.end_range}% + + + + + ); + })} + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/SeverityAssessments.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/SeverityAssessments.tsx new file mode 100644 index 0000000..9938253 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/SeverityAssessments.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Tooltip } from '@mui/material'; +import { ReportProblem as SeverityIcon } from '@mui/icons-material'; +import type { SeverityAssessment } from '../../../types'; +import { colors, getSeverityColor } from '../colors'; +import { CategoryRow } from '../CategoryRow'; + +export const SeverityAssessments: React.FC<{ severityAssessments: SeverityAssessment[] }> = ({ severityAssessments }) => ( + } + itemCount={severityAssessments.length} + > + + + Severity scale: + + + {severityAssessments.map(sa => { + const width = sa.end_range - sa.start_range + 1; + const bgColor = getSeverityColor(sa.severity_category); + return ( + + + {sa.severity_category.trim().substring(0, 3)} + + + ); + })} + + + + {severityAssessments.map(sa => { + const bgColor = getSeverityColor(sa.severity_category); + return ( + + + + + + + + {sa.severity_category.trim()} + + + + + + Recommended action: + + + {sa.recomended_action?.trim() || 'N/A'} + + + + + ); + })} + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/TopicMultipliers.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/TopicMultipliers.tsx new file mode 100644 index 0000000..7a118b6 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/TopicMultipliers.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Box, Typography, Paper, Chip, Tooltip } from '@mui/material'; +import { TrendingUp as MultiplierIcon } from '@mui/icons-material'; +import type { Multiplier } from '../../../types'; +import { colors } from '../colors'; +import { CategoryRow } from '../CategoryRow'; + +interface Props { + topicMultipliers: Multiplier[]; + inactiveMultipliers: Multiplier[]; +} + +export const TopicMultipliers: React.FC = ({ topicMultipliers, inactiveMultipliers }) => ( + } + itemCount={topicMultipliers.length} + > + + {topicMultipliers.map(m => ( + + 10 ? m.multiplier / 100 : m.multiplier).toFixed(2)}`} + size="small" + sx={{ + bgcolor: (m.multiplier > 10 ? m.multiplier > 100 : m.multiplier > 1) ? '#ffebee' : '#e8f5e9', + color: (m.multiplier > 10 ? m.multiplier > 100 : m.multiplier > 1) ? colors.error : colors.success, + fontWeight: 600, + }} + /> + + ))} + {topicMultipliers.length === 0 && ( + + No topic multipliers configured + + )} + + + {inactiveMultipliers.length > 0 && ( + + + Inactive (not applied in code): + + + {inactiveMultipliers.map(m => ( + + + + ))} + + + )} + + + + Only topic multipliers are applied. Temporal and reach multipliers exist in the database but are not yet implemented in the verdict calculator. + Values stored as percentages (e.g. 150 = 1.5x) are auto-converted. + + + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/VerdictCategories.tsx b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/VerdictCategories.tsx new file mode 100644 index 0000000..8eb4edf --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/panels/VerdictDetailPanel/sections/VerdictCategories.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Box, Typography, Paper, Tooltip } from '@mui/material'; +import { Category as CategoryIcon } from '@mui/icons-material'; +import type { VerdictCategory } from '../../../types'; +import { colors, getVerdictColor } from '../colors'; +import { CategoryRow } from '../CategoryRow'; + +export const VerdictCategories: React.FC<{ verdictCategories: VerdictCategory[] }> = ({ verdictCategories }) => ( + } + itemCount={verdictCategories.length} + > + + + Risk scale: + + + {verdictCategories.map(vc => { + const width = vc.end_range - vc.start_range + 1; + const bgColor = getVerdictColor(vc.verdict_category_color); + return ( + + + {vc.verdict_category_code.substring(0, 3)} + + + ); + })} + + + 0% + 100% + + + + + {verdictCategories.map(vc => { + const bgColor = getVerdictColor(vc.verdict_category_color); + return ( + + + {vc.verdict_category_code} + + + {vc.start_range}% - {vc.end_range}% + + + {vc.description} + + + ); + })} + + +); diff --git a/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/types.ts b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/types.ts new file mode 100644 index 0000000..cd9e1f0 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/AnalysisFlowVisualization/types.ts @@ -0,0 +1,259 @@ +// Types for Analysis Flow Visualization +// All data comes from didi-framework API (DB source of truth) + +export interface Dimension { + dimension_id: number; + dimension_code: string; + dimension_name: string; + description: string; + weight: number; +} + +export interface Subdimension { + subdimension_id: number; + dimension_id: number; + subdimension_name: string; + subdimension_code: string; + description: string; +} + +export interface Technique { + technique_id: number; + subdimension_id: number; + technique_key: number; + technique_name: string; + description: Record | string | null; + severity: number; + confidence: number; + detectability: number; +} + +export interface TechniqueIndicator { + technique_id: number; + indicator_id: number; + indicator_name: string; + description: string; + max_intensity: number; +} + +export interface TechniqueValidationRule { + technique_valid_rule_id: number; + technique_id: number; + rule_name: string; + rule_value: string; + description: string; +} + +export interface VerdictCategory { + verdict_category_id: number; + verdict_category_code: string; + description: string; + start_range: number; + end_range: number; + verdict_category_color: string; +} + +export interface RiskMapping { + risk_mapping_id: number; + risk_mapping: string; + risk_level: number; + start_range: number; + end_range: number; + risk_color: string; +} + +export interface Platform { + platform_id: number; + platform_code: string; + platform_name: string; + platform_score: number; + notes: string; +} + +export interface SourceType { + source_type_id: number; + source_type: string; + base_score: number; +} + +// ============================================================================ +// SOURCE ASSESSMENT TYPES - Complete hierarchy from bos_parammgmt +// ============================================================================ + +export interface PlatformModifier { + platform_modifier_id: number; + platform_modifier: string; + condition: string; + score: number; +} + +export interface SourceCredibility { + source_credibility_id: number; + source_credibility: string; + factor: number; + condition: string; +} + +export interface DomainAgeScore { + domain_age_score: number; + start_range: number; + end_range: number; + description: string; + score_impact: number; +} + +export interface DomainRiskLevel { + domain_risk_level_id: number; + domain_risk_level: string; + start_range: number; + end_range: number; + interpretation: string; + score_impact: number; +} + +export interface DomainRedFlag { + domain_red_flag_id: number; + domain_red_flag: string; + condition: string; + severity: number; + action: string; +} + +export interface AuthorClassification { + author_classification_id: number; + author_classification_code: string; + author_classification_name: string; + score: number; +} + +export interface AuthorCredibility { + author_credibility_id: number; + author_credibility: string; + impact: number; +} + +export interface ComponentWeight { + component_weight_id: number; + component_name: string; + component_weight: number; + description: string; +} + +export interface WeightScenario { + scenario_id: number; + scenario_name: string; + manipulation: number; + claims: number; + source: number; + ai: number; + context: number; + notes: string; +} + +export interface Multiplier { + multiplier_id: number; + multiplier_type: number; + multiplier_name: string; + description: string; + multiplier: number; +} + +// ============================================================================ +// CLAIMS ANALYSIS TYPES - Complete hierarchy from bos_parammgmt +// ============================================================================ + +export interface ClaimStatus { + claim_id: number; + claim_code: string; + claim_name: string; + claim_color: string; + start_range: number | null; + end_range: number | null; +} + +export interface ClaimType { + claim_type_id: number; + claim_type_code: string; + claim_type_name: string; + base_weight: number; + description: string; + verification_method: string; +} + +export interface ConfidenceLevel { + confidence_id: number; + confidence_name: string; + confidence_level: number; + confidence_color: string; + action: string; + start_range: number; + end_range: number; +} + +export interface Interpretation { + interpretation_id: number; + interpretation: string; + start_range: number; + end_range: number; +} + +export interface SeverityAssessment { + severity_id: string; + severity_category: string; + start_range: number; + end_range: number; + recomended_action: string; + parameter_id: number; +} + +// Framework Data Context +export interface FrameworkData { + dimensions: Dimension[]; + subdimensions: Subdimension[]; + techniques: Technique[]; + indicators: TechniqueIndicator[]; + validationRules: TechniqueValidationRule[]; + verdictCategories: VerdictCategory[]; + riskMappings: RiskMapping[]; + // Source Assessment - complete hierarchy + platforms: Platform[]; + platformModifiers: PlatformModifier[]; + sourceTypes: SourceType[]; + sourceCredibility: SourceCredibility[]; + domainAgeScores: DomainAgeScore[]; + domainRiskLevels: DomainRiskLevel[]; + domainRedFlags: DomainRedFlag[]; + authorClassifications: AuthorClassification[]; + authorCredibility: AuthorCredibility[]; + // Claims Analysis - complete hierarchy + claimStatus: ClaimStatus[]; + claimTypes: ClaimType[]; + confidenceLevels: ConfidenceLevel[]; + interpretations: Interpretation[]; + // Weights & multipliers + componentWeights: ComponentWeight[]; + weightScenarios: WeightScenario[]; + multipliers: Multiplier[]; + // Severity Assessment + severityAssessments: SeverityAssessment[]; + loading: boolean; + error: string | null; +} + +// Analysis Step Types +export type StepId = 'intake' | 'techniques' | 'sources' | 'claims' | 'verdict'; + +export interface AnalysisStep { + id: StepId; + title: string; + description: string; + status: 'pending' | 'active' | 'completed'; +} + +// Node Data for ReactFlow +export interface StepNodeData { + step: AnalysisStep; + score?: number; + details?: Record; + onExpand?: () => void; +} diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/DeleteDialog.tsx b/backend/admin-dashboard/src/components/framework/CrudDataTable/DeleteDialog.tsx new file mode 100644 index 0000000..cb37e75 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/DeleteDialog.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { + Box, Typography, Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress, +} from '@mui/material'; + +interface Props { + open: boolean; + operationLoading: boolean; + dependencies: any; + onClose: () => void; + onConfirm: () => void; +} + +export const DeleteDialog: React.FC = ({ + open, + operationLoading, + dependencies, + onClose, + onConfirm, +}) => ( + + + Confirmare stergere + + + {operationLoading ? ( + + + + ) : ( + <> + + Esti sigur ca vrei sa stergi acest element? + + + {dependencies && !dependencies.canDelete && ( + + + Nu se poate sterge + + + {dependencies.warning} + + + {dependencies.dependencies?.map((dep: any, idx: number) => ( + + {dep.count} {dep.displayName} + + ))} + + + )} + + {dependencies && dependencies.canDelete && ( + + + Elementul poate fi sters in siguranta. + + + )} + + )} + + + + + + +); diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/EditDialog.tsx b/backend/admin-dashboard/src/components/framework/CrudDataTable/EditDialog.tsx new file mode 100644 index 0000000..d4a86ca --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/EditDialog.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Box, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, CircularProgress, + Tabs, Tab, Alert, +} from '@mui/material'; +import type { ColumnDef } from './types'; + +interface Props { + open: boolean; + isCreating: boolean; + editableFields: ColumnDef[]; + formData: Partial; + operationLoading: boolean; + onClose: () => void; + onFieldChange: (key: string, value: any) => void; + onSave: () => void; + onReplaceFormData?: (data: Partial) => void; +} + +export function EditDialog>({ + open, + isCreating, + editableFields, + formData, + operationLoading, + onClose, + onFieldChange, + onSave, + onReplaceFormData, +}: Props) { + const [activeTab, setActiveTab] = useState<'form' | 'json'>('form'); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(null); + + const initialJson = useMemo(() => JSON.stringify(formData, null, 2), [formData]); + + useEffect(() => { + if (open) { + setJsonText(initialJson); + setJsonError(null); + setActiveTab('form'); + } + }, [open, initialJson]); + + const handleJsonChange = (val: string) => { + setJsonText(val); + try { + JSON.parse(val); + setJsonError(null); + } catch (e: any) { + setJsonError(e.message); + } + }; + + const handleApplyJson = () => { + try { + const parsed = JSON.parse(jsonText); + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + setJsonError('JSON-ul trebuie sa fie un obiect (nu array sau primitiv)'); + return; + } + onReplaceFormData?.(parsed); + setActiveTab('form'); + setJsonError(null); + } catch (e: any) { + setJsonError(e.message); + } + }; + + return ( + + + {isCreating ? 'Adauga element nou' : 'Editeaza element'} + + + setActiveTab(v)} + sx={{ + borderBottom: '1px solid #e2e8f0', + minHeight: 38, + backgroundColor: '#fff', + '& .MuiTab-root': { + textTransform: 'none', + fontWeight: 500, + minHeight: 38, + color: '#64748b', + '&.Mui-selected': { color: '#0052CC', fontWeight: 600 }, + }, + '& .MuiTabs-indicator': { backgroundColor: '#0052CC', height: 2 }, + }} + > + + + + + + {activeTab === 'form' && ( + + {editableFields.map((field) => ( + + onFieldChange( + String(field.key), + field.type === 'number' ? Number(e.target.value) : e.target.value, + ) + } + required={field.required} + fullWidth + size="small" + sx={{ + '& .MuiOutlinedInput-root': { + color: '#1e293b', + backgroundColor: '#fff', + '& fieldset': { borderColor: '#cbd5e1' }, + '&:hover fieldset': { borderColor: '#475569' }, + '&.Mui-focused fieldset': { borderColor: '#3b82f6', borderWidth: 1 }, + }, + '& .MuiInputLabel-root': { + color: '#64748b', + '&.Mui-focused': { color: '#3b82f6' }, + }, + }} + /> + ))} + + )} + + {activeTab === 'json' && ( + + handleJsonChange(e.target.value)} + fullWidth + error={!!jsonError} + InputProps={{ + sx: { + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace', + fontSize: '0.825rem', + backgroundColor: '#0f172a', + color: '#e2e8f0', + borderRadius: 1, + '& fieldset': { borderColor: '#334155' }, + }, + }} + /> + {jsonError ? ( + {jsonError} + ) : ( + + Editezi datele ca JSON. Apasă Aplică JSON ca să suprascrii form-ul cu valorile editate. + + )} + + + + + + )} + + + + + + + ); +} diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/Header.tsx b/backend/admin-dashboard/src/components/framework/CrudDataTable/Header.tsx new file mode 100644 index 0000000..46cd869 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/Header.tsx @@ -0,0 +1,174 @@ +import React, { useRef } from 'react'; +import { Box, Typography, Button, TextField, InputAdornment, Tooltip, IconButton } from '@mui/material'; +import { + Add as AddIcon, + Search as SearchIcon, + Download as DownloadIcon, + Upload as UploadIcon, +} from '@mui/icons-material'; + +interface Props { + title: string; + filteredCount: number; + totalCount: number; + searchTerm: string; + onSearchChange: (value: string) => void; + canAdd: boolean; + onAddClick: () => void; + onExportClick?: () => void; + onImportFile?: (file: File) => void; +} + +export const Header: React.FC = ({ + title, + filteredCount, + totalCount, + searchTerm, + onSearchChange, + canAdd, + onAddClick, + onExportClick, + onImportFile, +}) => { + const fileInputRef = useRef(null); + + return ( + + + + {title} + + + {filteredCount} / {totalCount} + + + + + onSearchChange(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + width: 200, + '& .MuiOutlinedInput-root': { + backgroundColor: '#fff', + borderRadius: 1, + color: '#1e293b', + height: 36, + '& fieldset': { borderColor: '#cbd5e1' }, + '&:hover fieldset': { borderColor: '#475569' }, + '&.Mui-focused fieldset': { borderColor: '#3b82f6', borderWidth: 1 }, + }, + '& .MuiInputBase-input::placeholder': { color: '#64748b', opacity: 1 }, + }} + /> + + {onExportClick && ( + + + + + + )} + + {onImportFile && ( + <> + { + const file = e.target.files?.[0]; + if (file) onImportFile(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }} + /> + + fileInputRef.current?.click()} + sx={{ + color: '#475569', + border: '1px solid #cbd5e1', + borderRadius: 1, + height: 36, + width: 36, + '&:hover': { color: '#3b82f6', borderColor: '#3b82f6', backgroundColor: '#eff6ff' }, + }} + > + + + + + )} + + {canAdd && ( + + )} + + + ); +}; diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/ImportPreviewDialog.tsx b/backend/admin-dashboard/src/components/framework/CrudDataTable/ImportPreviewDialog.tsx new file mode 100644 index 0000000..668272b --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/ImportPreviewDialog.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, Alert, Box, Typography, + List, ListItem, ListItemText, CircularProgress, Chip, +} from '@mui/material'; + +interface Props { + open: boolean; + rows: any[] | null; + error: string | null; + importing: boolean; + results: { success: number; failed: number; errors: string[] } | null; + onClose: () => void; + onConfirm: () => void; +} + +export const ImportPreviewDialog: React.FC = ({ + open, + rows, + error, + importing, + results, + onClose, + onConfirm, +}) => ( + + + Import JSON — preview (dry-run) + + + {error && {error}} + + {results && ( + + Import: {results.success} reușite, {results.failed} eșuate + {results.errors.length > 0 && ( + + {results.errors.slice(0, 5).map((err, i) => ( + + • {err} + + ))} + {results.errors.length > 5 && ( + …+{results.errors.length - 5} alte erori + )} + + )} + + )} + + {rows && ( + <> + + + Se vor crea {rows.length} rânduri noi prin POST în loop: + + + + + {rows.slice(0, 50).map((row, i) => ( + + + {JSON.stringify(row).slice(0, 140)} + {JSON.stringify(row).length > 140 ? '…' : ''} + + } + /> + + ))} + {rows.length > 50 && ( + + + …+{rows.length - 50} rânduri suplimentare ascunse + + } + /> + + )} + + + )} + + {!rows && !error && ( + Niciun fișier selectat + )} + + + + {rows && !results && ( + + )} + + +); diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/index.tsx b/backend/admin-dashboard/src/components/framework/CrudDataTable/index.tsx new file mode 100644 index 0000000..0f353ee --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/index.tsx @@ -0,0 +1,498 @@ +/** + * CrudDataTable - Reusable CRUD Table Component + * + * Features: + * - Add / Edit / Delete / Clone operations + * - Form editor + JSON editor (Tabs) with schema validation + * - Export JSON (download current table snapshot) + * - Import JSON (preview / dry-run / bulk POST) + * - Dependency check before delete + * - Search/filter + */ + +import React, { useState } from 'react'; +import { + Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Paper, IconButton, Tooltip, Alert, Snackbar, LinearProgress, +} from '@mui/material'; +import { + Edit as EditIcon, + Delete as DeleteIcon, + ContentCopy as CloneIcon, +} from '@mui/icons-material'; +import type { ColumnDef, CrudDataTableProps } from './types'; +import { Header } from './Header'; +import { EditDialog } from './EditDialog'; +import { DeleteDialog } from './DeleteDialog'; +import { ImportPreviewDialog } from './ImportPreviewDialog'; + +export type { ColumnDef, CrudDataTableProps } from './types'; + +const FRAMEWORK_API_URL = '/framework'; + +export function CrudDataTable>({ + title, + data, + columns, + idField, + apiEndpoint, + onDataChange, + canAdd = true, + canEdit = true, + canDelete = true, + canClone = true, + canExport = true, + canImport = true, + createFields, + loading = false, +}: CrudDataTableProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [formData, setFormData] = useState>({}); + const [dependencies, setDependencies] = useState(null); + const [operationLoading, setOperationLoading] = useState(false); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ + open: false, + message: '', + severity: 'success', + }); + + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importRows, setImportRows] = useState(null); + const [importError, setImportError] = useState(null); + const [importing, setImporting] = useState(false); + const [importResults, setImportResults] = useState<{ success: number; failed: number; errors: string[] } | null>(null); + + const filteredData = data.filter((item) => { + if (!searchTerm) return true; + const searchLower = searchTerm.toLowerCase(); + return Object.values(item).some((val) => + String(val).toLowerCase().includes(searchLower) + ); + }); + + const handleEditClick = (item: T) => { + setSelectedItem(item); + setFormData({ ...item }); + setIsCreating(false); + setEditDialogOpen(true); + }; + + const handleAddClick = () => { + setSelectedItem(null); + setFormData({}); + setIsCreating(true); + setEditDialogOpen(true); + }; + + const handleCloneClick = (item: T) => { + const clone: Partial = { ...item }; + delete (clone as any)[idField as string]; + for (const key of ['name', 'technique_name', 'platform_name', 'label', 'title']) { + if (key in clone && typeof (clone as any)[key] === 'string') { + (clone as any)[key] = `${(clone as any)[key]} (copy)`; + break; + } + } + setSelectedItem(null); + setFormData(clone); + setIsCreating(true); + setEditDialogOpen(true); + }; + + const handleDeleteClick = async (item: T) => { + setSelectedItem(item); + setOperationLoading(true); + setDependencies(null); + + try { + const res = await fetch(`${FRAMEWORK_API_URL}${apiEndpoint}/${item[idField]}/dependencies`); + if (res.ok) { + const data = await res.json(); + if (data.success) setDependencies(data.data); + } + } catch (err) { + // Endpoint might not exist + } + + setOperationLoading(false); + setDeleteDialogOpen(true); + }; + + const handleFieldChange = (key: string, value: any) => { + setFormData((prev) => ({ ...prev, [key]: value })); + }; + + const handleReplaceFormData = (data: Partial) => { + setFormData(data); + }; + + const handleSave = async () => { + setOperationLoading(true); + + try { + const url = isCreating + ? `${FRAMEWORK_API_URL}${apiEndpoint}` + : `${FRAMEWORK_API_URL}${apiEndpoint}/${selectedItem?.[idField]}`; + + const res = await fetch(url, { + method: isCreating ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + const result = await res.json(); + + if (result.success) { + setSnackbar({ + open: true, + message: isCreating ? 'Creat cu succes!' : 'Actualizat cu succes!', + severity: 'success', + }); + setEditDialogOpen(false); + onDataChange(); + } else { + setSnackbar({ + open: true, + message: result.error || 'Operație eșuată', + severity: 'error', + }); + } + } catch (err) { + setSnackbar({ open: true, message: 'Eroare de conexiune', severity: 'error' }); + } + + setOperationLoading(false); + }; + + const handleDeleteConfirm = async () => { + if (!selectedItem) return; + setOperationLoading(true); + + try { + const res = await fetch(`${FRAMEWORK_API_URL}${apiEndpoint}/${selectedItem[idField]}`, { + method: 'DELETE', + }); + + const result = await res.json(); + + if (result.success) { + setSnackbar({ open: true, message: 'Șters cu succes!', severity: 'success' }); + setDeleteDialogOpen(false); + onDataChange(); + } else { + setSnackbar({ + open: true, + message: result.error || 'Ștergere eșuată', + severity: 'error', + }); + } + } catch (err) { + setSnackbar({ open: true, message: 'Eroare de conexiune', severity: 'error' }); + } + + setOperationLoading(false); + }; + + const handleExportClick = () => { + const payload = { + table: title, + endpoint: apiEndpoint, + id_field: idField, + exported_at: new Date().toISOString(), + count: filteredData.length, + rows: filteredData, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `didi-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setSnackbar({ open: true, message: `Exportat ${filteredData.length} rânduri`, severity: 'success' }); + }; + + const handleImportFile = async (file: File) => { + setImportRows(null); + setImportError(null); + setImportResults(null); + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const rows = Array.isArray(parsed) ? parsed : parsed.rows; + if (!Array.isArray(rows)) { + throw new Error('JSON-ul trebuie să conțină un array de obiecte sau { "rows": [...] }'); + } + const cleaned = rows.map((r: any) => { + const copy = { ...r }; + delete copy[idField as string]; + return copy; + }); + setImportRows(cleaned); + setImportDialogOpen(true); + } catch (e: any) { + setImportError(e.message || 'JSON invalid'); + setImportDialogOpen(true); + } + }; + + const handleImportConfirm = async () => { + if (!importRows) return; + setImporting(true); + let success = 0; + let failed = 0; + const errors: string[] = []; + for (const row of importRows) { + try { + const res = await fetch(`${FRAMEWORK_API_URL}${apiEndpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(row), + }); + const result = await res.json(); + if (res.ok && result.success !== false) { + success++; + } else { + failed++; + errors.push(result.error || `HTTP ${res.status}`); + } + } catch (e: any) { + failed++; + errors.push(e.message || 'fetch failed'); + } + } + setImporting(false); + setImportResults({ success, failed, errors }); + if (success > 0) onDataChange(); + }; + + const handleImportClose = () => { + setImportDialogOpen(false); + setImportRows(null); + setImportError(null); + setImportResults(null); + }; + + const getValue = (obj: T, path: string): any => { + return path.split('.').reduce((acc, part) => acc?.[part], obj as any); + }; + + const editableFields: ColumnDef[] = createFields || columns.filter((c) => c.editable !== false); + + return ( + +
+ + {loading && ( + + )} + + + + + + {columns.map((col) => ( + + {col.header} + + ))} + {(canEdit || canDelete || canClone) && ( + + Actiuni + + )} + + + + {filteredData.map((row) => ( + + {columns.map((col) => ( + + {col.render + ? col.render(getValue(row, String(col.key)), row) + : getValue(row, String(col.key))} + + ))} + {(canEdit || canDelete || canClone) && ( + + {canEdit && ( + + handleEditClick(row)} + sx={{ + color: '#64748b', + '&:hover': { color: '#3b82f6', backgroundColor: 'transparent' }, + }} + > + + + + )} + {canClone && ( + + handleCloneClick(row)} + sx={{ + color: '#64748b', + '&:hover': { color: '#10b981', backgroundColor: 'transparent' }, + }} + > + + + + )} + {canDelete && ( + + handleDeleteClick(row)} + sx={{ + color: '#64748b', + '&:hover': { color: '#ef4444', backgroundColor: 'transparent' }, + }} + > + + + + )} + + )} + + ))} + {filteredData.length === 0 && ( + + + {searchTerm ? 'Nu s-au gasit rezultate' : 'Nu exista date'} + + + )} + +
+
+ + setEditDialogOpen(false)} + onFieldChange={handleFieldChange} + onSave={handleSave} + onReplaceFormData={handleReplaceFormData} + /> + + setDeleteDialogOpen(false)} + onConfirm={handleDeleteConfirm} + /> + + + + setSnackbar((s) => ({ ...s, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + > + setSnackbar((s) => ({ ...s, open: false }))} + sx={{ width: '100%' }} + > + {snackbar.message} + + + + ); +} + +export default CrudDataTable; diff --git a/backend/admin-dashboard/src/components/framework/CrudDataTable/types.ts b/backend/admin-dashboard/src/components/framework/CrudDataTable/types.ts new file mode 100644 index 0000000..aeeefdf --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/CrudDataTable/types.ts @@ -0,0 +1,29 @@ +import React from 'react'; + +export interface ColumnDef { + key: keyof T | string; + header: string; + width?: number | string; + render?: (value: any, row: T) => React.ReactNode; + editable?: boolean; + type?: 'text' | 'number' | 'select' | 'color'; + options?: { value: any; label: string }[]; + required?: boolean; +} + +export interface CrudDataTableProps { + title: string; + data: T[]; + columns: ColumnDef[]; + idField: keyof T; + apiEndpoint: string; + onDataChange: () => void; + canAdd?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canClone?: boolean; + canExport?: boolean; + canImport?: boolean; + createFields?: ColumnDef[]; + loading?: boolean; +} diff --git a/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/EditDialog.tsx b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/EditDialog.tsx new file mode 100644 index 0000000..23e82da --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/EditDialog.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, + FormControl, InputLabel, Select, MenuItem, +} from '@mui/material'; +import type { Parameter } from './types'; + +interface Props { + open: boolean; + param: Parameter | null; + onClose: () => void; + onSave: (value: any) => void; +} + +export const EditDialog: React.FC = ({ open, param, onClose, onSave }) => ( + + Editează: {param?.name} + + {param?.type === 'weight' && ( + onSave(Number(e.target.value))} + sx={{ mt: 2 }} + /> + )} + {param?.type === 'source' && ( + + Valoare + + + )} + {param?.type === 'dimension' && ( + onSave(e.target.value.split(',').map((s) => s.trim()).filter(Boolean))} + sx={{ mt: 2 }} + helperText="Ex: deepfake_video, ai_generated_text" + /> + )} + + + + + + +); diff --git a/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/derived.ts b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/derived.ts new file mode 100644 index 0000000..1960778 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/derived.ts @@ -0,0 +1,61 @@ +import type { StepState } from './types'; +import { INITIAL_STEPS } from './steps-data'; + +/** + * Resolve derived (calculated) parameter values based on current step state. + * Recurses across steps for cross-step derivations (e.g. final_score depends + * on manipulation_score which depends on dimension state). + */ +export function calculateDerivedValue( + stepId: string, + paramId: string, + stepStates: Record, +): any { + const state = stepStates[stepId]; + if (!state) return null; + + switch (paramId) { + case 'source_credibility_score': { + const sourceType = state.data.source_type || 'news'; + const domainRisk = state.data.domain_risk || 'low'; + const baseScores: Record = { news: 70, blog: 40, social: 30, official: 90 }; + const riskPenalties: Record = { low: 0, medium: -15, high: -30, critical: -50 }; + return Math.max(0, Math.min(100, (baseScores[sourceType] || 50) + (riskPenalties[domainRisk] || 0))); + } + + case 'manipulation_score': { + const dims = INITIAL_STEPS[2].parameters; + const totalWeight = dims.reduce((sum, d) => sum + (d.value?.length || 0), 0); + return totalWeight > 0 ? Math.min(100, totalWeight * 5) : 0; + } + + case 'final_score': { + const sourceScore = calculateDerivedValue('source', 'source_credibility_score', stepStates) || 50; + const manipScore = calculateDerivedValue('dimensions', 'manipulation_score', stepStates) || 0; + const claimsScore = stepStates['claims']?.data.claims_score || 50; + const raw = (manipScore * 0.35) + (claimsScore * 0.25) + (sourceScore * 0.20) + (50 * 0.20); + const mult = (state.data.topic_mult || 1) * (state.data.temporal_mult || 1) * (state.data.reach_mult || 1); + return Math.min(100, Math.round(raw * mult)); + } + + case 'verdict_category': { + const finalScore = calculateDerivedValue('scoring', 'final_score', stepStates) || 0; + if (finalScore <= 20) return 'TRUE'; + if (finalScore <= 40) return 'MOSTLY_TRUE'; + if (finalScore <= 60) return 'MIXED'; + if (finalScore <= 80) return 'MOSTLY_FALSE'; + return 'FALSE'; + } + + case 'risk_level': { + const score = calculateDerivedValue('scoring', 'final_score', stepStates) || 0; + if (score <= 30) return 'LOW'; + if (score <= 60) return 'MEDIUM'; + if (score <= 85) return 'HIGH'; + return 'CRITICAL'; + } + + default: + return state.data[paramId]; + } +} diff --git a/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/index.tsx b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/index.tsx new file mode 100644 index 0000000..b65e846 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/index.tsx @@ -0,0 +1,360 @@ +import React, { useState, useCallback } from 'react'; +import { + Box, Typography, Paper, Chip, Stepper, Step, StepLabel, + Button, IconButton, Tooltip, List, ListItem, ListItemText, + Grid, Divider, Alert, AlertTitle, +} from '@mui/material'; +import { + Edit as EditIcon, + ArrowForward as NextIcon, + ArrowBack as PrevIcon, + AccountTree as TreeIcon, + Link as LinkIcon, +} from '@mui/icons-material'; +import type { Parameter, StepState } from './types'; +import { INITIAL_STEPS } from './steps-data'; +import { calculateDerivedValue } from './derived'; +import { EditDialog } from './EditDialog'; + +export const ProgressiveAnalysisTree: React.FC = () => { + const [activeStep, setActiveStep] = useState(0); + const [stepStates, setStepStates] = useState>(() => { + const states: Record = {}; + INITIAL_STEPS.forEach((step, index) => { + states[step.id] = { + status: index === 0 ? 'in_progress' : 'locked', + data: {}, + expanded: index === 0, + }; + }); + return states; + }); + const [editingParam, setEditingParam] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + + const canAccessStep = useCallback((stepIndex: number): boolean => { + if (stepIndex === 0) return true; + const step = INITIAL_STEPS[stepIndex]; + return step.dependencies.every((depId) => stepStates[depId]?.status === 'completed'); + }, [stepStates]); + + const handleStepClick = useCallback((index: number) => { + if (!canAccessStep(index)) return; + + setActiveStep(index); + setStepStates((prev) => ({ + ...prev, + [INITIAL_STEPS[index].id]: { + ...prev[INITIAL_STEPS[index].id], + expanded: true, + status: prev[INITIAL_STEPS[index].id].status === 'available' ? 'in_progress' : prev[INITIAL_STEPS[index].id].status, + }, + })); + }, [canAccessStep]); + + const handleCompleteStep = useCallback(() => { + const currentStepId = INITIAL_STEPS[activeStep].id; + + setStepStates((prev) => { + const newStates = { ...prev }; + newStates[currentStepId] = { ...newStates[currentStepId], status: 'completed' }; + + if (activeStep < INITIAL_STEPS.length - 1) { + const nextStepId = INITIAL_STEPS[activeStep + 1].id; + newStates[nextStepId] = { ...newStates[nextStepId], status: 'available' }; + } + + return newStates; + }); + + if (activeStep < INITIAL_STEPS.length - 1) { + setActiveStep((prev) => prev + 1); + } + }, [activeStep]); + + const handleEditParam = useCallback((param: Parameter) => { + if (!param.editable) return; + setEditingParam(param); + setEditDialogOpen(true); + }, []); + + const handleSaveParam = useCallback((value: any) => { + if (!editingParam) return; + + setStepStates((prev) => { + const currentStepId = INITIAL_STEPS[activeStep].id; + return { + ...prev, + [currentStepId]: { + ...prev[currentStepId], + data: { + ...prev[currentStepId].data, + [editingParam.id]: value, + }, + }, + }; + }); + + setEditDialogOpen(false); + setEditingParam(null); + }, [editingParam, activeStep]); + + const getStepColor = (status: StepState['status']) => { + switch (status) { + case 'completed': return 'success'; + case 'in_progress': return 'primary'; + case 'available': return 'info'; + case 'locked': return 'disabled'; + } + }; + + const getImpactColor = (impact?: string) => { + switch (impact) { + case 'high': return 'error'; + case 'medium': return 'warning'; + case 'low': return 'info'; + default: return 'default'; + } + }; + + return ( + + + + + Arbore Logic de Progresie - Analiză Dezinformare + + + Parcurge pașii analizei pentru a configura parametrii și vedea derivările între ei. + Fiecare pas depinde de cel anterior și influențează rezultatul final. + + + + + Progres Analiză + + {INITIAL_STEPS.map((step, index) => { + const state = stepStates[step.id]; + const isAccessible = canAccessStep(index); + + return ( + + handleStepClick(index)} + sx={{ + cursor: isAccessible ? 'pointer' : 'not-allowed', + opacity: isAccessible ? 1 : 0.5, + }} + > + + + {step.title} + + + + + ); + })} + + + + + + + + + {INITIAL_STEPS[activeStep].icon} + {INITIAL_STEPS[activeStep].title} + + + + + + {INITIAL_STEPS[activeStep].description} + + + + + Parametri Configurabili + + + {INITIAL_STEPS[activeStep].parameters.map((param) => { + const derivedValue = calculateDerivedValue(INITIAL_STEPS[activeStep].id, param.id, stepStates); + const displayValue = derivedValue !== undefined ? derivedValue : param.value; + + return ( + handleEditParam(param)}> + + + ) + } + sx={{ + bgcolor: param.derivedFrom ? 'action.hover' : 'background.paper', + mb: 1, + borderRadius: 1, + }} + > + + {param.name} + + {param.derivedFrom && ( + + + + )} + + } + secondary={ + + + Valoare: {typeof displayValue === 'object' ? JSON.stringify(displayValue) : String(displayValue)} + + {param.relations && ( + + Relații: {param.relations.join(', ')} + + )} + + } + /> + + ); + })} + + + + + + + + + + + + Output-uri Generate + + {INITIAL_STEPS[activeStep].outputs.map((output) => { + const value = calculateDerivedValue(INITIAL_STEPS[activeStep].id, output, stepStates); + return ( + + + + ); + })} + + + + {INITIAL_STEPS[activeStep].dependencies.length > 0 && ( + + Dependențe + + Pași necesari + Acest pas depinde de: + + {INITIAL_STEPS[activeStep].dependencies.map((depId) => { + const depStep = INITIAL_STEPS.find((s) => s.id === depId); + const status = stepStates[depId]?.status; + return ( + + + {depStep?.title} + + ); + })} + + + + )} + + + Flux de Date + + {INITIAL_STEPS.map((step, index) => ( + + + {index + 1} + + {step.title} + + ))} + + + + + + setEditDialogOpen(false)} + onSave={handleSaveParam} + /> + + ); +}; + +export default ProgressiveAnalysisTree; diff --git a/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/steps-data.tsx b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/steps-data.tsx new file mode 100644 index 0000000..81089d6 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/steps-data.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { + Settings as ConfigIcon, + Source as SourceIcon, + Psychology as AnalysisIcon, + FactCheck as ClaimsIcon, + Calculate as ScoringIcon, + Gavel as VerdictIcon, +} from '@mui/icons-material'; +import type { AnalysisStep } from './types'; + +export const INITIAL_STEPS: AnalysisStep[] = [ + { + id: 'config', + title: 'Configurare Analiză', + description: 'Selectare scenariu, tip conținut și acces tier', + icon: , + dependencies: [], + parameters: [ + { id: 'scenario', name: 'Scenariu Analiză', type: 'weight', value: 'full', editable: true, impact: 'high' }, + { id: 'content_type', name: 'Tip Conținut', type: 'source', value: 'text', editable: true, impact: 'high' }, + { id: 'tier_access', name: 'Nivel Acces', type: 'source', value: 'premium', editable: true, impact: 'medium' }, + ], + outputs: ['selected_scenario', 'component_weights'], + }, + { + id: 'source', + title: 'Verificare Sursă', + description: 'Analiza sursei, platformei, domeniului și autorului', + icon: , + dependencies: ['config'], + parameters: [ + { id: 'source_type', name: 'Tip Sursă', type: 'source', value: 'news', editable: true, impact: 'high', relations: ['platform', 'domain'] }, + { id: 'platform', name: 'Platformă', type: 'platform', value: 'web', editable: true, impact: 'medium', derivedFrom: 'source_type' }, + { id: 'platform_modifier', name: 'Modificatori Platformă', type: 'platform', value: [], editable: true, impact: 'low', derivedFrom: 'platform' }, + { id: 'domain', name: 'Domeniu', type: 'source', value: '', editable: true, impact: 'high' }, + { id: 'domain_age', name: 'Vechime Domeniu', type: 'source', value: 'established', editable: true, impact: 'medium', derivedFrom: 'domain' }, + { id: 'domain_risk', name: 'Nivel Risc Domeniu', type: 'risk', value: 'low', editable: true, impact: 'high', derivedFrom: 'domain' }, + { id: 'domain_flags', name: 'Red Flags Domeniu', type: 'source', value: [], editable: true, impact: 'medium', derivedFrom: 'domain' }, + { id: 'author_class', name: 'Clasificare Autor', type: 'source', value: 'journalist', editable: true, impact: 'medium' }, + { id: 'author_cred', name: 'Credibilitate Autor', type: 'source', value: 'known', editable: true, impact: 'medium', derivedFrom: 'author_class' }, + ], + outputs: ['source_credibility_score', 'source_risk_level'], + }, + { + id: 'dimensions', + title: 'Analiza Dimensiuni (D1-D8)', + description: 'Detectarea tehnicilor de manipulare pe cele 8 dimensiuni', + icon: , + dependencies: ['source'], + parameters: [ + { id: 'd1_content', name: 'D1: Content (20%)', type: 'dimension', value: [], editable: true, impact: 'high' }, + { id: 'd2_narrative', name: 'D2: Narrative (15%)', type: 'dimension', value: [], editable: true, impact: 'high' }, + { id: 'd3_media', name: 'D3: Media (15%)', type: 'dimension', value: [], editable: true, impact: 'high' }, + { id: 'd4_amplification', name: 'D4: Amplification (10%)', type: 'dimension', value: [], editable: true, impact: 'medium' }, + { id: 'd5_evasion', name: 'D5: Evasion (10%)', type: 'dimension', value: [], editable: true, impact: 'medium' }, + { id: 'd6_operations', name: 'D6: Operations (10%)', type: 'dimension', value: [], editable: true, impact: 'medium' }, + { id: 'd7_temporal', name: 'D7: Temporal (10%)', type: 'dimension', value: [], editable: true, impact: 'medium' }, + { id: 'd8_targeting', name: 'D8: Targeting (10%)', type: 'dimension', value: [], editable: true, impact: 'medium' }, + ], + outputs: ['manipulation_score', 'detected_techniques', 'dimension_scores'], + }, + { + id: 'claims', + title: 'Verificare Claim-uri', + description: 'Extragere și verificare factuală a claim-urilor', + icon: , + dependencies: ['dimensions'], + parameters: [ + { id: 'extracted_claims', name: 'Claim-uri Extrase', type: 'source', value: [], editable: true, impact: 'high' }, + { id: 'claim_statuses', name: 'Status Claim-uri', type: 'verdict', value: {}, editable: true, impact: 'high', derivedFrom: 'extracted_claims' }, + { id: 'fact_sources', name: 'Surse Fact-checking', type: 'source', value: [], editable: true, impact: 'medium' }, + ], + outputs: ['claims_score', 'verified_claims', 'unverified_claims'], + }, + { + id: 'scoring', + title: 'Scoring Final', + description: 'Agregare scoruri și aplicare multiplicatori', + icon: , + dependencies: ['claims'], + parameters: [ + { id: 'manipulation_weight', name: 'Pondere Manipulare (35%)', type: 'weight', value: 35, editable: true, impact: 'high', derivedFrom: 'config' }, + { id: 'claims_weight', name: 'Pondere Claim-uri (25%)', type: 'weight', value: 25, editable: true, impact: 'high', derivedFrom: 'config' }, + { id: 'source_weight', name: 'Pondere Sursă (20%)', type: 'weight', value: 20, editable: true, impact: 'high', derivedFrom: 'config' }, + { id: 'ai_weight', name: 'Pondere AI (10%)', type: 'weight', value: 10, editable: true, impact: 'medium', derivedFrom: 'config' }, + { id: 'context_weight', name: 'Pondere Context (10%)', type: 'weight', value: 10, editable: true, impact: 'medium', derivedFrom: 'config' }, + { id: 'topic_mult', name: 'Multiplicator Topic', type: 'multiplier', value: 1.0, editable: true, impact: 'medium' }, + { id: 'temporal_mult', name: 'Multiplicator Temporal', type: 'multiplier', value: 1.0, editable: true, impact: 'medium' }, + { id: 'reach_mult', name: 'Multiplicator Reach', type: 'multiplier', value: 1.0, editable: true, impact: 'low' }, + ], + outputs: ['raw_score', 'final_score', 'component_breakdown'], + }, + { + id: 'verdict', + title: 'Verdict & Risc', + description: 'Determinare verdict final și nivel de risc', + icon: , + dependencies: ['scoring'], + parameters: [ + { id: 'verdict_category', name: 'Categorie Verdict', type: 'verdict', value: '', editable: false, impact: 'high', derivedFrom: 'final_score' }, + { id: 'risk_level', name: 'Nivel Risc', type: 'risk', value: '', editable: false, impact: 'high', derivedFrom: 'final_score' }, + { id: 'confidence', name: 'Nivel Încredere', type: 'source', value: '', editable: false, impact: 'medium', derivedFrom: 'all' }, + { id: 'action', name: 'Acțiune Recomandată', type: 'risk', value: '', editable: false, impact: 'high', derivedFrom: 'risk_level' }, + ], + outputs: ['final_verdict', 'risk_assessment', 'recommended_action', 'confidence_level'], + }, +]; diff --git a/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/types.ts b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/types.ts new file mode 100644 index 0000000..694708c --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/ProgressiveAnalysisTree/types.ts @@ -0,0 +1,28 @@ +import React from 'react'; + +export interface Parameter { + id: string; + name: string; + type: 'dimension' | 'subdimension' | 'technique' | 'indicator' | 'rule' | 'source' | 'platform' | 'weight' | 'verdict' | 'risk' | 'multiplier'; + value: any; + editable: boolean; + relations?: string[]; + derivedFrom?: string; + impact?: 'high' | 'medium' | 'low'; +} + +export interface AnalysisStep { + id: string; + title: string; + description: string; + icon: React.ReactNode; + parameters: Parameter[]; + outputs: string[]; + dependencies: string[]; +} + +export interface StepState { + status: 'locked' | 'available' | 'in_progress' | 'completed'; + data: Record; + expanded: boolean; +} diff --git a/backend/admin-dashboard/src/components/framework/RunTestPipelineDialog.tsx b/backend/admin-dashboard/src/components/framework/RunTestPipelineDialog.tsx new file mode 100644 index 0000000..d325f28 --- /dev/null +++ b/backend/admin-dashboard/src/components/framework/RunTestPipelineDialog.tsx @@ -0,0 +1,269 @@ +import React, { useState } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, Alert, Chip, + CircularProgress, Stack, Typography, Paper, MenuItem, IconButton, +} from '@mui/material'; +import { + PlayArrow as PlayIcon, OpenInNew as OpenInNewIcon, Refresh as RefreshIcon, + Close as CloseIcon, +} from '@mui/icons-material'; + +const AGENT_BASE = process.env.REACT_APP_AGENT_V3_URL || '/agent-v3'; + +interface Props { + open: boolean; + onClose: () => void; +} + +type Phase = 'idle' | 'submitting' | 'polling' | 'done' | 'error'; + +const DEFAULT_TEXT = + 'Vaccinurile COVID-19 conțin cipuri 5G care permit guvernului mondial să controleze populația prin frecvențe radio. Demonstrat științific de cercetători independenți cenzurați de mass-media corporativă.'; + +export const RunTestPipelineDialog: React.FC = ({ open, onClose }) => { + const [text, setText] = useState(DEFAULT_TEXT); + const [mediaType, setMediaType] = useState<'text' | 'url'>('text'); + const [planType, setPlanType] = useState<'free' | 'premium'>('free'); + const [phase, setPhase] = useState('idle'); + const [error, setError] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [result, setResult] = useState(null); + const [elapsed, setElapsed] = useState(0); + + const reset = () => { + setPhase('idle'); + setError(null); + setSessionId(null); + setResult(null); + setElapsed(0); + }; + + const handleClose = () => { + if (phase === 'submitting' || phase === 'polling') return; + reset(); + onClose(); + }; + + const handleRun = async () => { + if (!text.trim() || text.length < 20) { + setError('Textul trebuie să aibă cel puțin 20 caractere.'); + return; + } + setError(null); + setResult(null); + setSessionId(null); + setPhase('submitting'); + const started = Date.now(); + + try { + const token = localStorage.getItem('keycloak_token'); + const body = + mediaType === 'url' + ? { url: text, user_id: 'test-run', media_type: 'url', plan_type: planType } + : { text, user_id: 'test-run', media_type: 'text', plan_type: planType }; + + const res = await fetch(`${AGENT_BASE}/api/v3/pipeline/analyze`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (!json.success) { + throw new Error(json.error || `HTTP ${res.status}`); + } + const sid = json.data?.session_id; + setSessionId(sid); + // /analyze is sync — full result is already in json.data + setResult(json.data); + setElapsed(Date.now() - started); + setPhase('done'); + } catch (e: any) { + setError(e.message || 'Run failed'); + setPhase('error'); + } + }; + + const renderResult = () => { + if (!result) return null; + return ( + + + = 70 + ? 'error' + : result.risk_score >= 40 + ? 'warning' + : 'success' + } + /> + + + + + + {(result.components_run || []).map((c: string) => ( + + ))} + {(result.components_skipped || []).map((c: string) => ( + + ))} + + {result.techniques?.techniques_count > 0 && ( + + + Tehnici detectate ({result.techniques.techniques_count}) · scor {result.techniques.manipulation_score}/100 + + + {(result.techniques.techniques_detected || []) + .slice(0, 3) + .map((t: any) => t.name_ro || t.name_en || t.name) + .join(' · ')} + {result.techniques.techniques_detected?.length > 3 ? '…' : ''} + + + )} + {result.claims?.total_claims > 0 && ( + + + Claims: {result.claims.verified_true} confirmate · {result.claims.verified_false} false · credibilitate {result.claims.credibility_score}/100 + + + )} + {result.verdict?.explanation_ro && ( + + + Verdict (RO): + + + {result.verdict.explanation_ro} + + + )} + + + + + + ); + }; + + return ( + + + + Run Test Pipeline + + + + + + + + + Lansează o analiză pe pipeline-ul curent cu input ad-hoc. Folosit pentru debug / validare config după modificări. + + + + setMediaType(e.target.value as 'text' | 'url')} + sx={{ width: 160 }} + disabled={phase === 'submitting'} + > + Text + URL + + setPlanType(e.target.value as 'free' | 'premium')} + sx={{ width: 160 }} + disabled={phase === 'submitting'} + > + free + premium + + + + setText(e.target.value)} + fullWidth + disabled={phase === 'submitting'} + helperText={`${text.length} caractere · ${mediaType === 'text' ? 'minim 20' : 'URL valid'}`} + /> + + {error && ( + setError(null)}> + {error} + + )} + + {phase === 'submitting' && ( + }> + Analiza rulează (pipeline async — poate dura 30-300s în funcție de model)… + + )} + + {phase === 'done' && ( + + Analiză completă în {Math.round(elapsed / 1000)}s · session {sessionId?.slice(0, 8)}… + + )} + + {renderResult()} + + + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/layout/AdminLayout.tsx b/backend/admin-dashboard/src/components/layout/AdminLayout.tsx new file mode 100644 index 0000000..bf3e0f2 --- /dev/null +++ b/backend/admin-dashboard/src/components/layout/AdminLayout.tsx @@ -0,0 +1,284 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Divider, + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Avatar, + Menu, + MenuItem, + AppBar, + Toolbar, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { + Storage as StorageIcon, + Security as SecurityIcon, + Analytics as AnalyticsIcon, + DataObject as DatabaseIcon, + Speed as MonitoringIcon, + Dashboard as DashboardIcon, + Person, + Logout, + Menu as MenuIcon, + People as PeopleIcon, + SmartToy as ProvidersIcon, + History as HistoryIcon, + Settings as FrameworkIcon, + Psychology as LLMIcon, + Shield as ModerationIcon, + AccountTree as PipelinesIcon, +} from '@mui/icons-material'; +import { useNavigate, useLocation, Outlet } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { serviceGroups } from '../../config/serviceGroups'; + +const drawerWidth = 260; + +export const AdminLayout: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout, hasRole } = useAuth(); + const isAdmin = hasRole('admin'); + const canModerate = hasRole('moderator') || hasRole('senior_moderator') || isAdmin; + const [anchorEl, setAnchorEl] = useState(null); + const [mobileOpen, setMobileOpen] = useState(false); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const currentPath = location.pathname.replace('/admin', '') || '/'; + + const handleDrawerToggle = () => setMobileOpen(!mobileOpen); + + const navigateTo = (path: string) => { + navigate(path); + if (isMobile) setMobileOpen(false); + }; + + const groupIcon = (icon: string) => { + switch (icon) { + case 'database': return ; + case 'storage': return ; + case 'security': return ; + case 'monitoring': return ; + case 'analytics': return ; + default: return ; + } + }; + + const drawer = ( + <> + + theme.palette.mode === 'dark' ? '#ffffff' : '#4A148C', + letterSpacing: '0.05em', + cursor: 'pointer', + }} + onClick={() => navigateTo('/')} + > + didi + + + + {/* Main nav — Dashboard + service groups visible only to admin (operators see Moderation) */} + {isAdmin && ( + + navigateTo('/')}> + + + + + + + {/* Service groups as filters (navigate to dashboard with group param) */} + {serviceGroups.map((group) => ( + navigateTo(`/?group=${group.id}`)} + sx={{ pl: 3 }} + > + + {groupIcon(group.icon)} + + + + ))} + + )} + + {/* Configuration section — admin only */} + {isAdmin && ( + <> + + + Configuration + + + navigateTo('/framework')}> + + + + + navigateTo('/llm-components')}> + + + + + navigateTo('/pipelines')}> + + + + + navigateTo('/providers')}> + + + + + + )} + + {/* Management section — admin gets Users + History; moderator gets History + Moderation */} + {(isAdmin || canModerate) && ( + <> + {isAdmin && } + + Management + + + {isAdmin && ( + navigateTo('/users')}> + + + + )} + + navigateTo('/history')}> + + + + + {canModerate && ( + navigateTo('/moderation')} + > + + + + )} + + + )} + + ); + + return ( + + {/* Mobile App Bar */} + {isMobile && ( + + + + + + + didi admin + + setAnchorEl(e.currentTarget)} size="small"> + + {user?.preferred_username?.[0]?.toUpperCase() || 'U'} + + + + + )} + + {/* Mobile Sidebar */} + + {drawer} + + + {/* Desktop Sidebar */} + + {drawer} + + + {/* Main Content */} + + {/* Desktop Header */} + {!isMobile && ( + + setAnchorEl(e.currentTarget)}> + + {user?.preferred_username?.[0]?.toUpperCase() || 'U'} + + + + )} + + {/* User Menu */} + setAnchorEl(null)}> + + + {user?.preferred_username || 'User'} + + + { setAnchorEl(null); logout(); }}> + + Logout + + + + {/* Page Content (rendered by React Router Outlet) */} + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/services/LogsModal.tsx b/backend/admin-dashboard/src/components/services/LogsModal.tsx new file mode 100644 index 0000000..09729cb --- /dev/null +++ b/backend/admin-dashboard/src/components/services/LogsModal.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + IconButton, + CircularProgress, + Slider, + Chip, +} from '@mui/material'; +import { Close, Refresh, ArrowDownward } from '@mui/icons-material'; +import { fetchContainerLogs } from '../../services/api'; + +interface LogsModalProps { + open: boolean; + onClose: () => void; + containerName: string; + displayName: string; +} + +// Colorize a single log line +function colorizeLog(line: string): React.ReactNode { + // Extract timestamp prefix (ISO format) + const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s?(.*)/); + const timestamp = tsMatch ? tsMatch[1] : null; + const rest = tsMatch ? tsMatch[2] : line; + + // Determine log level color + let levelColor: string | null = null; + let bgColor: string | null = null; + const upper = rest.toUpperCase(); + + if (upper.includes('ERROR') || upper.includes('FATAL') || upper.includes('BILLING_GAP')) { + levelColor = '#ff6b6b'; + bgColor = 'rgba(255,107,107,0.08)'; + } else if (upper.includes('WARN')) { + levelColor = '#ffd43b'; + bgColor = 'rgba(255,212,59,0.06)'; + } else if (upper.includes('SUCCESS') || upper.includes(' OK') || upper.includes('✅') || upper.includes('COMPLETED')) { + levelColor = '#51cf66'; + } else if (upper.includes('[CREDITS]') || upper.includes('DEDUCTED')) { + levelColor = '#da77f2'; + } else if (upper.includes('[DISPATCHER]') || upper.includes('PUBLISHED')) { + levelColor = '#74c0fc'; + } else if (upper.includes('[VISION]') || upper.includes('[VIDEO]') || upper.includes('[URL]')) { + levelColor = '#ffa94d'; + } + + return ( + + {timestamp && ( + + {timestamp.replace('T', ' ').replace('Z', '').split('.')[0]} + + )} + + {rest} + + + ); +} + +export const LogsModal: React.FC = ({ open, onClose, containerName, displayName }) => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lineCount, setLineCount] = useState(200); + const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(null); + + const loadLogs = useCallback(async () => { + if (!containerName) return; + setLoading(true); + setError(null); + try { + const lines = await fetchContainerLogs(containerName, lineCount); + setLogs(lines); + } catch (err: any) { + setError(err.response?.data?.error || err.message || 'Failed to load logs'); + } finally { + setLoading(false); + } + }, [containerName, lineCount]); + + useEffect(() => { + if (open) loadLogs(); + }, [open, loadLogs]); + + useEffect(() => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs, autoScroll]); + + const scrollToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; + + return ( + + + + 📋 {displayName} + + + + Lines: + setLineCount(v as number)} + onChangeCommitted={() => loadLogs()} + min={50} + max={2000} + step={50} + sx={{ width: 120, color: '#7aa2f7' }} + size="small" + /> + {lineCount} + + + + + + + + + + + + + + {loading && logs.length === 0 ? ( + + + + ) : error ? ( + + {error} + + ) : ( + + {logs.map((line, i) => ( + + + {String(i + 1).padStart(4)} + + {colorizeLog(line)} + + ))} + {logs.length === 0 && !loading && ( + No logs available + )} + + )} + + + + + {logs.length} lines {loading && '(refreshing...)'} + + + + + + ); +}; diff --git a/backend/admin-dashboard/src/components/services/ServiceCard.tsx b/backend/admin-dashboard/src/components/services/ServiceCard.tsx new file mode 100644 index 0000000..cb113d5 --- /dev/null +++ b/backend/admin-dashboard/src/components/services/ServiceCard.tsx @@ -0,0 +1,199 @@ +import React, { useState } from 'react'; +import { + Card, + CardContent, + Typography, + Box, + Chip, + Button, + IconButton, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { + Storage, + Memory, + Queue, + Lock, + Api, + MonitorHeart, + Dashboard, + Hub, + DataObject, + Refresh, + OpenInNew, + TextFields, + Image, + Videocam, + Audiotrack, + Article, +} from '@mui/icons-material'; +import { ServiceStatus, getContainerName } from '../../services/api'; +import { gradients } from '../../theme'; +import { LogsModal } from './LogsModal'; + +interface ServiceCardProps { + service: ServiceStatus; + onRefresh: () => void; + onAccess: () => void; + groupColor?: string; +} + +const iconMap: { [key: string]: React.ElementType } = { + database: DataObject, + memory: Memory, + queue: Queue, + storage: Storage, + lock: Lock, + api: Api, + monitoring: MonitorHeart, + dashboard: Dashboard, + hub: Hub, + text_fields: TextFields, + image: Image, + videocam: Videocam, + audiotrack: Audiotrack, +}; + +export const ServiceCard: React.FC = ({ + service, + onRefresh, + onAccess, + groupColor, +}) => { + const [logsOpen, setLogsOpen] = useState(false); + const Icon = iconMap[service.icon || 'hub'] || Hub; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const containerName = getContainerName(service.name); + + const getStatusColor = (status: string) => { + switch (status) { + case 'healthy': + return 'success'; + case 'unhealthy': + return 'error'; + default: + return 'default'; + } + }; + + return ( + + {/* Gradient header strip */} + + + + + + + + + {service.displayName} + + {service.cluster && ( + + )} + + + {service.cluster && service.clusterHost + ? `${service.clusterHost}:${service.port}` + : `Port: ${service.port}`} + + + + + + + + {service.description && ( + + {service.description} + + )} + + {service.details && ( + + {service.details} + + )} + + + + Status: + + + + + {service.lastChecked && ( + + Last checked: {new Date(service.lastChecked).toLocaleTimeString()} + + )} + + + + {(service.uiUrl || + service.name === 'orchestrator' || + service.name === 'analysis' || + service.name === 'didi-framework' || + service.name === 'didi-agent-v3') && ( + + )} + {containerName && ( + + )} + + + {containerName && ( + setLogsOpen(false)} + containerName={containerName} + displayName={service.displayName} + /> + )} + + ); +}; \ No newline at end of file diff --git a/backend/admin-dashboard/src/config/serviceGroups.ts b/backend/admin-dashboard/src/config/serviceGroups.ts new file mode 100644 index 0000000..3d507de --- /dev/null +++ b/backend/admin-dashboard/src/config/serviceGroups.ts @@ -0,0 +1,54 @@ +export interface ServiceGroup { + id: string; + title: string; + description: string; + icon: string; + color: string; + services: string[]; // service names that belong to this group +} + +export const serviceGroups: ServiceGroup[] = [ + { + id: 'data-layer', + title: 'Data Layer', + description: 'Database, cache, queue, and storage services', + icon: 'database', + color: '#2E7D32', // green + services: ['didi-postgres', 'didi-cache', 'staging-dataLayer-rabbitmq', 'staging-dataLayer-minio'] + }, + { + id: 'gateway-auth-layer', + title: 'Gateway & Auth Layer', + description: 'API gateway and authentication services', + icon: 'security', + color: '#1565C0', // blue + services: ['didi-kong', 'didi-keycloak'] + }, + { + id: 'orchestration-layer', + title: 'Orchestration Layer', + description: 'Pipeline orchestration and analysis services', + icon: 'analytics', + color: '#6A1B9A', // purple + services: ['didi-framework', 'didi-agent-v3'] + }, + { + id: 'monitoring-layer', + title: 'Monitoring Layer', + description: 'Metrics, logs, tracing & alerting', + icon: 'monitoring', + color: '#E65100', // orange + services: ['didi-prometheus', 'didi-grafana', 'didi-loki', 'didi-jaeger', 'didi-alertmanager', 'didi-otel-collector', 'didi-promtail'] + } +]; + +// Helper function to get group for a service +export const getServiceGroup = (serviceName: string): ServiceGroup | undefined => { + return serviceGroups.find(group => group.services.includes(serviceName)); +}; + +// Helper function to get color for a service +export const getServiceGroupColor = (serviceName: string): string => { + const group = getServiceGroup(serviceName); + return group?.color || '#757575'; // default gray +}; \ No newline at end of file diff --git a/backend/admin-dashboard/src/contexts/AuthContext.tsx b/backend/admin-dashboard/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..551b218 --- /dev/null +++ b/backend/admin-dashboard/src/contexts/AuthContext.tsx @@ -0,0 +1,144 @@ +import React, { createContext, useContext, ReactNode, useState, useEffect, useCallback, useRef } from 'react'; +import keycloak from '../services/keycloak'; + +interface AuthContextType { + isAuthenticated: boolean; + initialized: boolean; + user: any; + token: string | undefined; + login: () => void; + logout: () => void; + hasRole: (role: string) => boolean; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [initialized, setInitialized] = useState(false); + const [authenticated, setAuthenticated] = useState(false); + const refreshIntervalRef = useRef | null>(null); + + // Memoized logout function for event listener stability + const logout = useCallback(() => { + localStorage.removeItem('keycloak_token'); + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + refreshIntervalRef.current = null; + } + if (keycloak?.authenticated) { + keycloak.logout(); + } else { + window.location.reload(); + } + }, []); + + useEffect(() => { + // Check for explicit staging mode flag (must be explicitly enabled) + const stagingMode = process.env.REACT_APP_STAGING_MODE === 'true'; + + if (stagingMode) { + console.warn('STAGING_MODE enabled: Authentication bypassed. DO NOT use in production!'); + setAuthenticated(true); + setInitialized(true); + return; + } + + const initKeycloak = async () => { + try { + console.log('[Auth] Initializing Keycloak...'); + const authenticated = await keycloak.init({ + onLoad: 'login-required', + checkLoginIframe: false, + }); + + console.log('[Auth] Keycloak initialized, authenticated:', authenticated); + setAuthenticated(authenticated); + + if (authenticated && keycloak.token) { + localStorage.setItem('keycloak_token', keycloak.token); + + // Token refresh: check every 30s, refresh if expiring within 70s + refreshIntervalRef.current = setInterval(() => { + keycloak.updateToken(70).then((refreshed) => { + if (refreshed && keycloak.token) { + console.log('[Auth] Token refreshed'); + localStorage.setItem('keycloak_token', keycloak.token); + } + }).catch(() => { + console.warn('[Auth] Token refresh failed, redirecting to login'); + keycloak.login(); + }); + }, 30000); + } + } catch (error) { + console.error('[Auth] Keycloak init failed:', error); + setAuthenticated(false); + } finally { + setInitialized(true); + } + }; + + initKeycloak(); + + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, []); + + // Listen for unauthorized events from API interceptors (skip in staging mode) + useEffect(() => { + if (process.env.REACT_APP_STAGING_MODE === 'true') return; + + const handleUnauthorized = () => { + console.log('[Auth] Received unauthorized event, logging out'); + logout(); + }; + + window.addEventListener('auth:unauthorized', handleUnauthorized); + return () => { + window.removeEventListener('auth:unauthorized', handleUnauthorized); + }; + }, [logout]); + + const value: AuthContextType = { + isAuthenticated: authenticated, + initialized, + user: keycloak?.tokenParsed, + token: keycloak?.token, + login: () => keycloak?.login(), + logout, + hasRole: (role: string) => { + if (process.env.REACT_APP_STAGING_MODE === 'true') return true; + return keycloak?.hasRealmRole(role) || keycloak?.hasResourceRole(role) || false; + }, + }; + + if (!initialized) { + return ( +
+ Loading authentication... +
+ ); + } + + return {children}; +}; diff --git a/backend/admin-dashboard/src/contexts/ThemeContext.tsx b/backend/admin-dashboard/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..8db8ab3 --- /dev/null +++ b/backend/admin-dashboard/src/contexts/ThemeContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import { createAppTheme, ThemeMode } from '../theme'; + +interface ThemeContextType { + mode: ThemeMode; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +const THEME_STORAGE_KEY = 'didi-admin-theme'; + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // Initialize theme from localStorage or default to 'light' + const [mode, setMode] = useState(() => { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (stored === 'light' || stored === 'dark') { + return stored; + } + if (stored !== null) { + console.warn('[Theme] Invalid stored value, using default:', stored); + } + return 'light'; + } catch (error) { + console.warn('[Theme] Failed to read preference from localStorage:', error); + return 'light'; + } + }); + + // Persist theme preference to localStorage + useEffect(() => { + try { + localStorage.setItem(THEME_STORAGE_KEY, mode); + } catch (error) { + console.error('Failed to save theme preference:', error); + } + }, [mode]); + + const toggleTheme = () => { + setMode(prev => prev === 'light' ? 'dark' : 'light'); + }; + + // Memoize theme to avoid unnecessary re-renders + const theme = useMemo(() => createAppTheme(mode), [mode]); + + return ( + + + + {children} + + + ); +}; + +export const useThemeMode = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useThemeMode must be used within ThemeProvider'); + } + return context; +}; diff --git a/backend/admin-dashboard/src/index.css b/backend/admin-dashboard/src/index.css new file mode 100644 index 0000000..e54902c --- /dev/null +++ b/backend/admin-dashboard/src/index.css @@ -0,0 +1,32 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +/* Fix React Flow node background */ +.react-flow__node { + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +.react-flow__node-custom { + background: transparent !important; + border: none !important; + padding: 0 !important; +} + +.react-flow__node-default { + background: transparent !important; + border: none !important; + padding: 0 !important; +} diff --git a/backend/admin-dashboard/src/index.tsx b/backend/admin-dashboard/src/index.tsx new file mode 100644 index 0000000..458c8a4 --- /dev/null +++ b/backend/admin-dashboard/src/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + +); diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/analysis-core.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/analysis-core.tsx new file mode 100644 index 0000000..3b34558 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/analysis-core.tsx @@ -0,0 +1,55 @@ +/** + * Analysis Core category — dimensions + subdimensions tables. + * Pondere column has a LinearProgress + percentage label render. + */ +import React from 'react'; +import { Box, LinearProgress, Typography } from '@mui/material'; +import { Category as CategoryIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const analysisCoreCategory: CategoryConfig = { + id: 'analysis-core', + name: 'Analysis Core', + icon: , + color: '#2196F3', + description: 'Dimensiuni și subdimensiuni de analiză', + tables: [ + { + id: 'dimensions', + name: 'Dimensiuni', + endpoint: '/api/dimensions', + idField: 'dimension_id', + columns: [ + { key: 'dimension_id', header: 'ID', width: 60, editable: false }, + { key: 'dimension_code', header: 'Cod', width: 80, required: true }, + { key: 'dimension_name', header: 'Nume', required: true }, + { key: 'description', header: 'Descriere' }, + { + key: 'weight', + header: 'Pondere', + width: 120, + type: 'number', + render: (val) => ( + + + {val}% + + ), + }, + ], + }, + { + id: 'subdimensions', + name: 'Subdimensiuni', + endpoint: '/api/subdimensions', + idField: 'subdimension_id', + columns: [ + { key: 'subdimension_id', header: 'ID', width: 60, editable: false }, + { key: 'dimension_id', header: 'Dim ID', width: 80, type: 'number', required: true }, + { key: 'subdimension_code', header: 'Cod', width: 150, required: true }, + { key: 'subdimension_name', header: 'Nume', required: true }, + { key: 'description', header: 'Descriere' }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/claims.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/claims.tsx new file mode 100644 index 0000000..efa050a --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/claims.tsx @@ -0,0 +1,101 @@ +/** + * Claims category — status / types / confidence / interpretation tables. + * + * Status + confidence + verdict columns store a color name on the row; + * this category renders a colored Chip with the row's stored color. + */ +import React from 'react'; +import { Box, Chip } from '@mui/material'; +import { Psychology as PsychologyIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const claimsCategory: CategoryConfig = { + id: 'claims', + name: 'Analiza Claims', + icon: , + color: '#00BCD4', + description: 'Status, tipuri, confidence, interpretare', + tables: [ + { + id: 'claim-status', + name: 'Status Claim', + endpoint: '/api/claims/status', + idField: 'claim_id', + columns: [ + { key: 'claim_id', header: 'ID', width: 60, editable: false }, + { + key: 'claim_code', + header: 'Cod', + width: 80, + required: true, + render: (val, row: any) => ( + + ), + }, + { key: 'claim_name', header: 'Nume', required: true }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + { + key: 'claim_color', + header: 'Culoare', + width: 100, + render: (val) => ( + + ), + }, + ], + }, + { + id: 'claim-types', + name: 'Tipuri Claim', + endpoint: '/api/claims/types', + idField: 'claim_type_id', + columns: [ + { key: 'claim_type_id', header: 'ID', width: 60, editable: false }, + { key: 'claim_type_code', header: 'Cod', width: 80, required: true }, + { key: 'claim_type_name', header: 'Nume', required: true }, + { key: 'base_weight', header: 'Pondere', width: 80, type: 'number' }, + { key: 'description', header: 'Descriere' }, + { key: 'verification_method', header: 'Metodă Verificare' }, + ], + }, + { + id: 'confidence', + name: 'Niveluri Confidence', + endpoint: '/api/claims/confidence', + idField: 'confidence_id', + columns: [ + { key: 'confidence_id', header: 'ID', width: 60, editable: false }, + { key: 'confidence_name', header: 'Nume', required: true }, + { key: 'confidence_level', header: 'Nivel', width: 80, type: 'number' }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + { + key: 'confidence_color', + header: 'Culoare', + width: 100, + render: (val) => ( + + ), + }, + { key: 'action', header: 'Acțiune' }, + ], + }, + { + id: 'interpretation', + name: 'Interpretări', + endpoint: '/api/claims/interpretation', + idField: 'interpretation_id', + columns: [ + { key: 'interpretation_id', header: 'ID', width: 60, editable: false }, + { key: 'interpretation', header: 'Interpretare', required: true }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/index.ts b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/index.ts new file mode 100644 index 0000000..fe0be45 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/index.ts @@ -0,0 +1,23 @@ +/** + * CATEGORIES barrel — assembles the 7 category configs into the array + * consumed by the dashboard. Order here determines the order of buttons + * in the category navigation row. + */ +import type { CategoryConfig } from '../types'; +import { analysisCoreCategory } from './analysis-core'; +import { techniquesCategory } from './techniques'; +import { sourceAssessmentCategory } from './source-assessment'; +import { claimsCategory } from './claims'; +import { verdictsCategory } from './verdicts'; +import { weightsCategory } from './weights'; +import { providersCategory } from './providers'; + +export const CATEGORIES: CategoryConfig[] = [ + analysisCoreCategory, + techniquesCategory, + sourceAssessmentCategory, + claimsCategory, + verdictsCategory, + weightsCategory, + providersCategory, +]; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/providers.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/providers.tsx new file mode 100644 index 0000000..29423f9 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/providers.tsx @@ -0,0 +1,181 @@ +/** + * LLM Providers category — provider configs / models / component assignments / API keys. + * + * Largest category in the framework dashboard. Each table maps to a separate + * `/api/providers/*` endpoint. Component assignment column has an emoji + * lookup table for visual scanning (techniques 🔬, claims_extract 📝, etc.). + * + * API keys column shows only the key_prefix; full keys are never exposed + * to the frontend (backend redacts them from the response). + */ +import React from 'react'; +import { Chip, Typography } from '@mui/material'; +import { Hub as HubIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const providersCategory: CategoryConfig = { + id: 'providers', + name: 'LLM Providers', + icon: , + color: '#E91E63', + description: 'Configurare provideri LLM per componentă de analiză', + tables: [ + { + id: 'provider-configs', + name: 'Configurații Provider', + endpoint: '/api/providers/configs', + idField: 'provider_id', + columns: [ + { key: 'provider_id', header: 'ID', width: 60, editable: false }, + { + key: 'provider_code', + header: 'Provider', + width: 120, + required: true, + render: (val: string) => ( + + ), + }, + { key: 'provider_name', header: 'Nume', required: true }, + { key: 'base_url', header: 'Base URL' }, + { + key: 'is_active', + header: 'Activ', + width: 80, + render: (val: boolean) => ( + + ), + }, + { key: 'priority', header: 'Prioritate', width: 80, type: 'number' }, + ], + }, + { + id: 'provider-models', + name: 'Modele LLM', + endpoint: '/api/providers/models', + idField: 'model_id', + columns: [ + { key: 'model_id', header: 'ID', width: 60, editable: false }, + { key: 'provider_code', header: 'Provider', width: 100 }, + { key: 'model_code', header: 'Cod Model', required: true }, + { key: 'model_name', header: 'Nume', required: true }, + { key: 'context_window', header: 'Context', width: 100, type: 'number' }, + { key: 'max_output_tokens', header: 'Max Tokens', width: 100, type: 'number' }, + { + key: 'input_cost_per_1m', + header: '$/1M In', + width: 90, + type: 'number', + render: (val: number | string) => ${Number(val ?? 0).toFixed(2)}, + }, + { + key: 'output_cost_per_1m', + header: '$/1M Out', + width: 90, + type: 'number', + render: (val: number | string) => ${Number(val ?? 0).toFixed(2)}, + }, + ], + }, + { + id: 'component-assignments', + name: 'Asignări Componente', + endpoint: '/api/providers/assignments', + idField: 'assignment_id', + columns: [ + { key: 'assignment_id', header: 'ID', width: 50, editable: false }, + { + key: 'component_code', + header: 'Componentă', + width: 130, + required: true, + render: (val: string) => { + const icons: Record = { + techniques: '🔬', + source: '🔍', + claims_extract: '📝', + claims_verify: '✅', + ai_detection: '🤖', + context: '📋', + }; + return ( + + ); + }, + }, + { key: 'provider_code', header: 'Provider', width: 100 }, + { key: 'model_code', header: 'Model', width: 180 }, + { + key: 'temperature', + header: 'Temp', + width: 70, + type: 'number', + render: (val: number) => {val}, + }, + { key: 'max_tokens', header: 'Max Tok', width: 80, type: 'number' }, + { + key: 'timeout_ms', + header: 'Timeout', + width: 80, + type: 'number', + render: (val: number) => {Math.round(val / 1000)}s, + }, + { + key: 'is_enabled', + header: 'Activ', + width: 60, + render: (val: boolean) => ( + + ), + }, + ], + }, + { + id: 'api-keys', + name: 'API Keys', + endpoint: '/api/providers/keys', + idField: 'api_key_id', + columns: [ + { key: 'api_key_id', header: 'ID', width: 60, editable: false }, + { key: 'provider_code', header: 'Provider', width: 100 }, + { key: 'key_name', header: 'Nume Cheie', required: true }, + { + key: 'key_prefix', + header: 'API Key', + render: (val: string) => ( + + {val ? `${val}...` : '••••••••'} + + ), + }, + { + key: 'is_active', + header: 'Activ', + width: 70, + render: (val: boolean) => ( + + ), + }, + { key: 'usage_count', header: 'Utilizări', width: 90, type: 'number' }, + { key: 'last_used_at', header: 'Ultima Utilizare', width: 150 }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/source-assessment.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/source-assessment.tsx new file mode 100644 index 0000000..f70bd44 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/source-assessment.tsx @@ -0,0 +1,156 @@ +/** + * Source Assessment category — 7 entity tables (platforms, modifiers, + * source-credibility, domain age/risk/red-flags, author classifications + credibility). + * + * Score columns use signed-chip rendering (+5 green, -3 red, 0 default) so + * the operator can see at a glance which factors push trust up vs down. + */ +import React from 'react'; +import { Chip } from '@mui/material'; +import { Source as SourceIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const sourceAssessmentCategory: CategoryConfig = { + id: 'source-assessment', + name: 'Evaluare Sursă', + icon: , + color: '#9C27B0', + description: 'Platforme, credibilitate, domenii, autori', + tables: [ + { + id: 'platforms', + name: 'Platforme', + endpoint: '/api/platforms', + idField: 'platform_id', + columns: [ + { key: 'platform_id', header: 'ID', width: 60, editable: false }, + { + key: 'platform_code', + header: 'Cod', + width: 100, + required: true, + render: (val) => , + }, + { key: 'platform_name', header: 'Nume', required: true }, + { key: 'platform_score', header: 'Scor', width: 80, type: 'number' }, + { key: 'notes', header: 'Note' }, + ], + }, + { + id: 'platform-modifiers', + name: 'Modificatori Platformă', + endpoint: '/api/platform-modifiers', + idField: 'platform_modifier_id', + columns: [ + { key: 'platform_modifier_id', header: 'ID', width: 60, editable: false }, + { key: 'platform_modifier', header: 'Modificator', required: true }, + { key: 'condition', header: 'Condiție' }, + { + key: 'score', + header: 'Scor', + width: 80, + type: 'number', + render: (val) => ( + 0 ? `+${val}` : val} + size="small" + color={val > 0 ? 'success' : val < 0 ? 'error' : 'default'} + /> + ), + }, + ], + }, + { + id: 'source-credibility', + name: 'Credibilitate Sursă', + endpoint: '/api/source-credibility', + idField: 'source_credibility_id', + columns: [ + { key: 'source_credibility_id', header: 'ID', width: 60, editable: false }, + { key: 'source_credibility', header: 'Credibilitate', required: true }, + { key: 'factor', header: 'Factor', width: 80, type: 'number' }, + { key: 'condition', header: 'Condiție' }, + ], + }, + { + id: 'domain-age-scores', + name: 'Scoruri Vârstă Domeniu', + endpoint: '/api/domain-age-scores', + idField: 'domain_age_score', + columns: [ + { key: 'domain_age_score', header: 'ID', width: 60, type: 'number', required: true }, + { key: 'start_range', header: 'De la (luni)', width: 100, type: 'number' }, + { key: 'end_range', header: 'Până la (luni)', width: 100, type: 'number' }, + { key: 'description', header: 'Descriere' }, + { key: 'score_impact', header: 'Impact', width: 80, type: 'number' }, + ], + }, + { + id: 'domain-risk-levels', + name: 'Niveluri Risc Domeniu', + endpoint: '/api/domain-risk-levels', + idField: 'domain_risk_level_id', + columns: [ + { key: 'domain_risk_level_id', header: 'ID', width: 60, editable: false }, + { key: 'domain_risk_level', header: 'Nivel', required: true }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + { key: 'interpretation', header: 'Interpretare' }, + { key: 'score_impact', header: 'Impact', width: 80, type: 'number' }, + ], + }, + { + id: 'domain-red-flags', + name: 'Red Flags Domeniu', + endpoint: '/api/domain-red-flags', + idField: 'domain_red_flag_id', + columns: [ + { key: 'domain_red_flag_id', header: 'ID', width: 60, editable: false }, + { + key: 'domain_red_flag', + header: 'Red Flag', + required: true, + render: (val) => , + }, + { key: 'condition', header: 'Condiție' }, + { key: 'severity', header: 'Severitate', width: 80, type: 'number' }, + { key: 'action', header: 'Acțiune' }, + ], + }, + { + id: 'author-classifications', + name: 'Clasificări Autor', + endpoint: '/api/author-classifications', + idField: 'author_classification_id', + columns: [ + { key: 'author_classification_id', header: 'ID', width: 60, editable: false }, + { key: 'author_classification_code', header: 'Cod', width: 120, required: true }, + { key: 'author_classification_name', header: 'Nume', required: true }, + { key: 'score', header: 'Scor', width: 80, type: 'number' }, + ], + }, + { + id: 'author-credibility', + name: 'Credibilitate Autor', + endpoint: '/api/author-credibility', + idField: 'author_credibility_id', + columns: [ + { key: 'author_credibility_id', header: 'ID', width: 60, editable: false }, + { key: 'author_credibility', header: 'Credibilitate', required: true }, + { + key: 'impact', + header: 'Impact', + width: 80, + type: 'number', + render: (val) => ( + 0 ? `+${val}` : val} + size="small" + color={val > 0 ? 'success' : val < 0 ? 'error' : 'default'} + /> + ), + }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/techniques.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/techniques.tsx new file mode 100644 index 0000000..7c3c71d --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/techniques.tsx @@ -0,0 +1,88 @@ +/** + * Techniques category — techniques + indicators + validation rules tables. + * + * Severity column renders a colored Chip (red ≥80, orange ≥50, green <50). + * Description column tolerates both string and {ro,en} object shapes + * because legacy rows may still hold the bilingual object form. + */ +import React from 'react'; +import { Chip, Tooltip, Typography } from '@mui/material'; +import { Warning as WarningIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const techniquesCategory: CategoryConfig = { + id: 'techniques', + name: 'Tehnici', + icon: , + color: '#FF9800', + description: 'Tehnici de manipulare cu indicatori', + tables: [ + { + id: 'techniques', + name: 'Tehnici', + endpoint: '/api/techniques', + idField: 'technique_id', + columns: [ + { key: 'technique_id', header: 'ID', width: 60, editable: false }, + { key: 'subdimension_id', header: 'Subdim', width: 80, type: 'number', required: true }, + { key: 'technique_name', header: 'Nume Tehnică', required: true }, + { + key: 'description', + header: 'Descriere', + render: (val: any) => { + if (!val) return ; + const text = typeof val === 'object' ? (val.ro || val.en || '') : String(val); + return ( + + + {text} + + + ); + }, + }, + { + key: 'severity', + header: 'Severitate', + width: 100, + type: 'number', + render: (val) => ( + = 80 ? 'error' : val >= 50 ? 'warning' : 'success'} + /> + ), + }, + { key: 'confidence', header: 'Confidence', width: 100, type: 'number' }, + { key: 'detectability', header: 'Detectab.', width: 100, type: 'number' }, + ], + }, + { + id: 'indicators', + name: 'Indicatori', + endpoint: '/api/indicators', + idField: 'technique_indicator_id', + columns: [ + { key: 'technique_indicator_id', header: 'ID', width: 60, editable: false }, + { key: 'technique_id', header: 'Tehnică ID', width: 100, type: 'number', required: true }, + { key: 'indicator_name', header: 'Nume Indicator', required: true }, + { key: 'description', header: 'Descriere' }, + { key: 'max_intensity', header: 'Max Intensitate', width: 120, type: 'number' }, + ], + }, + { + id: 'validation-rules', + name: 'Reguli Validare', + endpoint: '/api/validation-rules', + idField: 'technique_valid_rule_id', + columns: [ + { key: 'technique_valid_rule_id', header: 'ID', width: 60, editable: false }, + { key: 'technique_id', header: 'Tehnică ID', width: 100, type: 'number', required: true }, + { key: 'rule_name', header: 'Nume Regulă', required: true }, + { key: 'rule_value', header: 'Valoare', required: true }, + { key: 'description', header: 'Descriere' }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/verdicts.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/verdicts.tsx new file mode 100644 index 0000000..80575f7 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/verdicts.tsx @@ -0,0 +1,99 @@ +/** + * Verdicts & Scoring category — verdict categories / risk mappings / severity. + * + * verdict_category_color and risk_color are stored as color names + * (green/red/yellow/etc.) — uses getVerdictColor to resolve to hex. + */ +import React from 'react'; +import { Box, Chip } from '@mui/material'; +import { Gavel as GavelIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; +import { getVerdictColor } from '../helpers'; + +export const verdictsCategory: CategoryConfig = { + id: 'verdicts', + name: 'Verdicte & Scoruri', + icon: , + color: '#4CAF50', + description: 'Categorii verdict, risc, severitate', + tables: [ + { + id: 'verdict-categories', + name: 'Categorii Verdict', + endpoint: '/api/verdicts/categories', + idField: 'verdict_category_id', + columns: [ + { key: 'verdict_category_id', header: 'ID', width: 60, editable: false }, + { + key: 'verdict_category_code', + header: 'Cod', + width: 120, + required: true, + render: (val, row: any) => ( + + ), + }, + { key: 'description', header: 'Descriere' }, + { key: 'start_range', header: 'Min %', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max %', width: 80, type: 'number' }, + { + key: 'verdict_category_color', + header: 'Culoare', + width: 100, + render: (val) => ( + + ), + }, + ], + }, + { + id: 'risk-mappings', + name: 'Mapări Risc', + endpoint: '/api/verdicts/risk', + idField: 'risk_mapping_id', + columns: [ + { key: 'risk_mapping_id', header: 'ID', width: 60, editable: false }, + { + key: 'risk_mapping', + header: 'Risc', + required: true, + render: (val, row: any) => ( + + ), + }, + { key: 'risk_level', header: 'Nivel', width: 80, type: 'number' }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + { + key: 'risk_color', + header: 'Culoare', + width: 100, + render: (val) => ( + + ), + }, + ], + }, + { + id: 'severity', + name: 'Evaluare Severitate', + endpoint: '/api/verdicts/severity', + idField: 'severity_id', + columns: [ + { key: 'severity_id', header: 'ID', width: 80, required: true }, + { key: 'severity_category', header: 'Categorie', required: true }, + { key: 'start_range', header: 'Min', width: 80, type: 'number' }, + { key: 'end_range', header: 'Max', width: 80, type: 'number' }, + { key: 'recomended_action', header: 'Acțiune Recomandată' }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/weights.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/weights.tsx new file mode 100644 index 0000000..1deca9f --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/categories/weights.tsx @@ -0,0 +1,87 @@ +/** + * Weights & Config category — component weights / weight scenarios / multipliers. + * + * multiplier_type column has a small Chip-with-name lookup table + * (1=Topic, 2=Temporal, 3=Reach) since the API returns the integer code. + */ +import React from 'react'; +import { Box, Chip, LinearProgress, Typography } from '@mui/material'; +import { Balance as BalanceIcon } from '@mui/icons-material'; +import type { CategoryConfig } from '../types'; + +export const weightsCategory: CategoryConfig = { + id: 'weights', + name: 'Ponderi & Config', + icon: , + color: '#FF5722', + description: 'Ponderi componente, scenarii, multiplicatori', + tables: [ + { + id: 'component-weights', + name: 'Ponderi Componente', + endpoint: '/api/weights/components', + idField: 'component_weight_id', + columns: [ + { key: 'component_weight_id', header: 'ID', width: 60, editable: false }, + { key: 'component_name', header: 'Componentă', required: true }, + { + key: 'component_weight', + header: 'Pondere', + width: 120, + type: 'number', + render: (val) => ( + + + {val}% + + ), + }, + { key: 'description', header: 'Descriere' }, + ], + }, + { + id: 'weight-scenarios', + name: 'Scenarii Ponderare', + endpoint: '/api/weights/scenarios', + idField: 'scenario_id', + columns: [ + { key: 'scenario_id', header: 'ID', width: 60, editable: false }, + { key: 'scenario_name', header: 'Scenariu', required: true }, + { key: 'manipulation', header: 'Manip.', width: 70, type: 'number' }, + { key: 'claims', header: 'Claims', width: 70, type: 'number' }, + { key: 'source', header: 'Sursă', width: 70, type: 'number' }, + { key: 'ai', header: 'AI', width: 70, type: 'number' }, + { key: 'context', header: 'Context', width: 70, type: 'number' }, + { key: 'notes', header: 'Note' }, + ], + }, + { + id: 'multipliers', + name: 'Multiplicatori', + endpoint: '/api/weights/multipliers', + idField: 'multiplier_id', + columns: [ + { key: 'multiplier_id', header: 'ID', width: 60, editable: false }, + { + key: 'multiplier_type', + header: 'Tip', + width: 100, + type: 'number', + render: (val) => { + const types: Record = { 1: 'Topic', 2: 'Temporal', 3: 'Reach' }; + return ; + }, + }, + { key: 'multiplier_name', header: 'Nume', required: true }, + { key: 'description', header: 'Descriere' }, + { + key: 'multiplier', + header: 'Valoare', + width: 80, + type: 'number', + render: (val) => {val}x, + }, + ], + }, + ], +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/helpers.ts b/backend/admin-dashboard/src/pages/FrameworkDashboard/helpers.ts new file mode 100644 index 0000000..863ca24 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/helpers.ts @@ -0,0 +1,16 @@ +/** + * Map verdict color names to hex. Used by category column renderers when + * the row stores a name (e.g. "red", "lightgreen") instead of a hex value — + * keeps the framework data backwards-compatible with old stored colors. + */ +export const getVerdictColor = (color: string): string => { + const colorMap: Record = { + green: '#4caf50', + lightgreen: '#8bc34a', + yellow: '#ffeb3b', + orange: '#ff9800', + red: '#f44336', + darkred: '#c62828', + }; + return colorMap[color] || color || '#757575'; +}; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/index.tsx b/backend/admin-dashboard/src/pages/FrameworkDashboard/index.tsx new file mode 100644 index 0000000..44e5cc8 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/index.tsx @@ -0,0 +1,411 @@ +/** + * DIDI Framework Dashboard - Full CRUD Management + * + * Categories: + * 1. Analysis Core (Dimensions, Subdimensions) + * 2. Techniques (Techniques, Indicators, Validation Rules) + * 3. Source Assessment (Platforms, Modifiers, Credibility, etc.) + * 4. Claims Analysis (Status, Types, Confidence, Interpretation) + * 5. Verdicts & Scoring (Categories, Risk, Severity) + * 6. Weights & Config (Components, Scenarios, Multipliers) + * 7. LLM Providers (Configs, Models, Assignments, API Keys) + * + * Original 1141-line FrameworkDashboard.tsx split into: + * types.ts — FrameworkStats, CategoryConfig, TableConfig + * helpers.ts — getVerdictColor (color name → hex) + * categories/ — one file per category (7 files + index barrel) + * index.tsx — page component shell (state + effects + render) + */ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Container, + Typography, + Grid, + CircularProgress, + Alert, + Tabs, + Tab, + Paper, + IconButton, + Collapse, + Button, + Chip, +} from '@mui/material'; +import { + Settings as SettingsIcon, + Refresh as RefreshIcon, + AccountBalance as FlowIcon, + Storage as StorageIcon, + CheckCircle as CheckCircleIcon, + PlayArrow as PlayIcon, +} from '@mui/icons-material'; +import { AnalysisFlowVisualization, FrameworkDataProvider } from '../../components/framework/AnalysisFlowVisualization'; +import { CrudDataTable } from '../../components/framework/CrudDataTable'; +import { RunTestPipelineDialog } from '../../components/framework/RunTestPipelineDialog'; +import type { FrameworkStats } from './types'; +import { CATEGORIES } from './categories'; + +const FRAMEWORK_API_URL = '/framework'; + +export const FrameworkDashboard: React.FC = () => { + const [activeCategory, setActiveCategory] = useState(CATEGORIES[0].id); + const [activeTable, setActiveTable] = useState(CATEGORIES[0].tables[0].id); + const [stats, setStats] = useState(null); + const [tableData, setTableData] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAnalysisFlow, setShowAnalysisFlow] = useState(false); + const [runTestOpen, setRunTestOpen] = useState(false); + const [syncingRedis, setSyncingRedis] = useState(false); + const [redisStatus, setRedisStatus] = useState<{ synced: boolean; last_sync?: string } | null>(null); + const [syncSuccess, setSyncSuccess] = useState(null); + + const fetchStats = useCallback(async () => { + try { + setError(null); + const res = await fetch(`${FRAMEWORK_API_URL}/api/overview/stats`); + const data = await res.json(); + if (data.success) setStats(data.data); + } catch (err) { + console.error('Failed to fetch stats:', err); + setError('Nu s-au putut încărca statisticile'); + } + }, []); + + const fetchTableData = useCallback(async (endpoint: string, tableId: string) => { + try { + const res = await fetch(`${FRAMEWORK_API_URL}${endpoint}`); + const data = await res.json(); + if (data.success) { + setTableData((prev) => ({ ...prev, [tableId]: data.data })); + } + } catch (err) { + console.error(`Failed to fetch ${tableId}:`, err); + setError(`Eroare la încărcarea datelor pentru ${tableId}`); + } + }, []); + + const checkRedisStatus = useCallback(async () => { + try { + const res = await fetch(`${FRAMEWORK_API_URL}/api/sync-redis/status`); + const data = await res.json(); + if (data.success) { + setRedisStatus(data.data); + } + } catch (err) { + console.error('Failed to check Redis status:', err); + } + }, []); + + const handleSyncToRedis = useCallback(async () => { + try { + setSyncingRedis(true); + setError(null); + setSyncSuccess(null); + + const res = await fetch(`${FRAMEWORK_API_URL}/api/sync-redis`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + + if (data.success) { + setRedisStatus({ synced: true, last_sync: data.data.last_sync }); + const totalItems = + data.data.counts.techniques.techniques + + data.data.counts.sources.platforms + + data.data.counts.claims.status + + data.data.counts.verdicts.categories; + setSyncSuccess(`Sincronizat cu succes! ${totalItems} elemente în ${data.data.duration_ms}ms`); + setTimeout(() => setSyncSuccess(null), 5000); + } else { + setError(data.error || 'Sync to Redis failed'); + } + } catch (err) { + console.error('Sync to Redis error:', err); + setError('Eroare la sincronizarea cu Redis'); + } finally { + setSyncingRedis(false); + } + }, []); + + // Initial load + useEffect(() => { + const loadInitialData = async () => { + setLoading(true); + await Promise.all([fetchStats(), checkRedisStatus()]); + const firstTable = CATEGORIES[0].tables[0]; + await fetchTableData(firstTable.endpoint, firstTable.id); + setLoading(false); + }; + loadInitialData(); + }, [fetchStats, fetchTableData, checkRedisStatus]); + + // Load table data when switching tables + useEffect(() => { + const category = CATEGORIES.find((c) => c.id === activeCategory); + const table = category?.tables.find((t) => t.id === activeTable); + if (table && !tableData[table.id]) { + fetchTableData(table.endpoint, table.id); + } + }, [activeCategory, activeTable, tableData, fetchTableData]); + + const handleCategoryChange = (categoryId: string) => { + setActiveCategory(categoryId); + const category = CATEGORIES.find((c) => c.id === categoryId); + if (category?.tables.length) { + setActiveTable(category.tables[0].id); + } + }; + + const handleDataChange = (tableId: string, endpoint: string) => { + fetchTableData(endpoint, tableId); + fetchStats(); + }; + + const currentCategory = CATEGORIES.find((c) => c.id === activeCategory); + const currentTable = currentCategory?.tables.find((t) => t.id === activeTable); + + if (loading && !stats) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + + + + + DIDI Framework + + + Management complet al parametrilor și configurațiilor + + + + + + + + {redisStatus?.synced && ( + } + label={redisStatus.last_sync ? new Date(redisStatus.last_sync).toLocaleTimeString('ro-RO') : 'Synced'} + size="small" + sx={{ + backgroundColor: 'rgba(76, 175, 80, 0.15)', + color: '#4caf50', + border: '1px solid rgba(76, 175, 80, 0.3)', + '& .MuiChip-icon': { color: '#4caf50' }, + }} + /> + )} + { fetchStats(); checkRedisStatus(); if (currentTable) fetchTableData(currentTable.endpoint, currentTable.id); }}> + + + + + + {/* Analysis Flow (collapsible) */} + + + + + + + + + {/* Stats Cards */} + + {[ + { label: 'Dimensiuni', value: stats?.dimensions || 0 }, + { label: 'Tehnici', value: stats?.techniques || 0 }, + { label: 'Verdicte', value: stats?.verdicts || 0 }, + { label: 'Niveluri Risc', value: stats?.riskMappings || 0 }, + { label: 'Tipuri Sursa', value: stats?.sourceTypes || 0 }, + { label: 'Platforme', value: stats?.platforms || 0 }, + ].map((stat) => ( + + + + {stat.label} + + + {stat.value} + + + + ))} + + + {/* Categories Navigation */} + + {CATEGORIES.map((cat) => ( + + ))} + + + {/* Table Navigation */} + {currentCategory && ( + + setActiveTable(val)} + variant="scrollable" + scrollButtons="auto" + sx={{ + minHeight: 40, + '& .MuiTab-root': { + minHeight: 40, + textTransform: 'none', + color: '#64748b', + fontWeight: 400, + fontSize: '0.875rem', + px: 2, + '&.Mui-selected': { + color: '#0052CC', + fontWeight: 600, + backgroundColor: '#f1f5f9', + }, + }, + '& .MuiTabs-indicator': { + backgroundColor: '#0052CC', + height: 2, + }, + '& .MuiTabScrollButton-root': { + color: '#94a3b8', + '&.Mui-disabled': { + opacity: 0.3, + }, + }, + }} + > + {currentCategory.tables.map((table) => ( + + ))} + + + )} + + {/* Data Table */} + {currentTable && ( + handleDataChange(currentTable.id, currentTable.endpoint)} + loading={!tableData[currentTable.id]} + /> + )} + + {syncSuccess && ( + + {syncSuccess} + + )} + + {error && ( + + {error} + + )} + + setRunTestOpen(false)} /> + + ); +}; + +export default FrameworkDashboard; diff --git a/backend/admin-dashboard/src/pages/FrameworkDashboard/types.ts b/backend/admin-dashboard/src/pages/FrameworkDashboard/types.ts new file mode 100644 index 0000000..fdf5426 --- /dev/null +++ b/backend/admin-dashboard/src/pages/FrameworkDashboard/types.ts @@ -0,0 +1,35 @@ +/** + * Public types for the Framework Dashboard page. + * + * CategoryConfig + TableConfig describe the static configuration table that + * drives the dashboard's category navigation + per-tab CRUD tables. Each + * category file under ./categories/ exports one CategoryConfig instance. + */ +import type React from 'react'; +import type { ColumnDef } from '../../components/framework/CrudDataTable'; + +export interface FrameworkStats { + dimensions: number; + techniques: number; + verdicts: number; + riskMappings: number; + sourceTypes: number; + platforms: number; +} + +export interface CategoryConfig { + id: string; + name: string; + icon: React.ReactNode; + color: string; + description: string; + tables: TableConfig[]; +} + +export interface TableConfig { + id: string; + name: string; + endpoint: string; + idField: string; + columns: ColumnDef[]; +} diff --git a/backend/admin-dashboard/src/react-app-env.d.ts b/backend/admin-dashboard/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/backend/admin-dashboard/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/backend/admin-dashboard/src/services/api/client.ts b/backend/admin-dashboard/src/services/api/client.ts new file mode 100644 index 0000000..fa0a50b --- /dev/null +++ b/backend/admin-dashboard/src/services/api/client.ts @@ -0,0 +1,86 @@ +/** + * services/api — axios client + interceptors + env config + */ + +import axios from 'axios'; + +const getEnv = (name: string, fallback: string): string => { + return process.env[name] || fallback; +}; + +const getEnvInt = (name: string, fallback: number): number => { + const value = process.env[name]; + if (!value) return fallback; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? fallback : parsed; +}; + +// API calls use relative URLs - requests go through Kong on same origin (no CORS) +// This allows the dashboard to work from any IP/hostname +export const API_BASE_URL = ''; + +// HOST is only needed for external service UI links (pgadmin, keycloak console, etc.) +// These open in new tabs and don't need CORS - user clicks through to them +export const HOST = getEnv('REACT_APP_HOST', window.location.hostname); + +// Service ports - used for external UI links only +export const PORTS = { + postgres: getEnvInt('REACT_APP_POSTGRES_PORT', 35432), + redis: getEnvInt('REACT_APP_REDIS_PORT', 36379), + redisCommander: getEnvInt('REACT_APP_COMMANDER_PORT', 38081), + rabbitmq: getEnvInt('REACT_APP_RABBITMQ_MGMT_PORT', 35672), + minio: getEnvInt('REACT_APP_MINIO_CONSOLE_PORT', 39001), + pgadmin: getEnvInt('REACT_APP_PGADMIN_PORT', 35050), + kong: getEnvInt('REACT_APP_KONG_PROXY_PORT', 38100), + kongAdmin: getEnvInt('REACT_APP_KONG_ADMIN_PORT', 38101), + kongManager: getEnvInt('REACT_APP_KONG_MANAGER_PORT', 38102), + keycloak: getEnvInt('REACT_APP_KEYCLOAK_PORT', 38180), + orchestrator: getEnvInt('REACT_APP_ORCHESTRATOR_PORT', 38000), + analysis: getEnvInt('REACT_APP_ANALYSIS_PORT', 38200), +}; + +export const api = axios.create({ + baseURL: API_BASE_URL, + maxRedirects: 5, + timeout: 30000, +}); + +/** + * Build query params object, filtering out undefined values. + */ +export const buildParams = (obj: Record): Record => + Object.fromEntries( + Object.entries(obj).filter(([_, v]) => v !== undefined) + ); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('keycloak_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +let isHandlingUnauthorized = false; + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 && !isHandlingUnauthorized) { + isHandlingUnauthorized = true; + localStorage.removeItem('keycloak_token'); + window.dispatchEvent(new CustomEvent('auth:unauthorized')); + setTimeout(() => { isHandlingUnauthorized = false; }, 1000); + } + + if (error.response?.status >= 500) { + console.error('[API] Server error:', { + url: error.config?.url, + status: error.response?.status, + message: error.response?.data?.detail || error.message, + }); + } + + return Promise.reject(error); + } +); diff --git a/backend/admin-dashboard/src/services/api/containers.ts b/backend/admin-dashboard/src/services/api/containers.ts new file mode 100644 index 0000000..34cf55e --- /dev/null +++ b/backend/admin-dashboard/src/services/api/containers.ts @@ -0,0 +1,34 @@ +/** + * services/api — Docker container helpers + */ + +import { api } from './client'; + +// Map service names (from ServiceStatus) to Docker container names +const serviceContainerMap: Record = { + 'didi-agent-v3': 'didi-agent-v3', + 'didi-framework': 'didi-framework', + 'redis': 'didi-cache', + 'redis-commander': 'staging-dataLayer-redis-commander', + 'rabbitmq': '66e3748c7d9f_staging-dataLayer-rabbitmq', + 'minio': 'staging-dataLayer-minio', + 'pgadmin': 'staging-dataLayer-pgadmin', + 'kong': 'kong', + 'kong-admin': 'kong', + 'kong-manager': 'kong', + 'keycloak': 'keycloak', +}; + +export const getContainerName = (serviceName: string): string | null => { + return serviceContainerMap[serviceName] || null; +}; + +export const fetchContainerLogs = async (containerName: string, lines = 200): Promise => { + const res = await api.get(`/framework/api/admin/logs/${containerName}?lines=${lines}`); + return res.data?.data?.logs || []; +}; + +export const fetchRunningContainers = async () => { + const res = await api.get('/framework/api/admin/containers'); + return res.data?.data || []; +}; diff --git a/backend/admin-dashboard/src/services/api/external.ts b/backend/admin-dashboard/src/services/api/external.ts new file mode 100644 index 0000000..d0cdda4 --- /dev/null +++ b/backend/admin-dashboard/src/services/api/external.ts @@ -0,0 +1,186 @@ +/** + * services/api — external integrations + * - externalApiService: external API providers + credentials + tier access + * - openAPIDiscoveryApi: auto-discover services from OpenAPI specs + * - providerApi: quick-add LLM providers (OpenRouter, OpenAI, etc.) + * - liteLLMCatalogueApi: read-only LiteLLM model catalogue browser + */ + +import { api, buildParams } from './client'; +import type { + OpenAPIDiscoveryResult, + OpenAPIImportRequest, + OpenAPIImportResponse, + OpenAPIRefreshResponse, + GatewayHealthResponse, + QuickAddRequest, + QuickAddResponse, + ValidateRequest, + ValidateResponse, +} from './types'; + +export const externalApiService = { + listProviders: () => + api.get('/api/v1/external-apis/providers'), + + listAPIs: (contentType?: string, isActive?: boolean) => + api.get('/api/v1/external-apis/apis', { + params: buildParams({ content_type: contentType, is_active: isActive }), + }), + + getCredentialStatus: (apiId: string) => + api.get(`/api/v1/external-apis/apis/${apiId}/credentials`), + + setCredential: (apiId: string, credentialName: string, credentialValue: string) => + api.post(`/api/v1/external-apis/apis/${apiId}/credentials`, { + credential_name: credentialName, + credential_value: credentialValue, + }), + + deleteCredential: (apiId: string, credentialName: string) => + api.delete(`/api/v1/external-apis/apis/${apiId}/credentials/${credentialName}`), + + toggleApiActivation: (apiId: string, isActive: boolean) => + api.put(`/api/v1/external-apis/apis/${apiId}/activate`, { is_active: isActive }), + + listTierAccess: (tier?: string, contentType?: string) => + api.get('/api/v1/external-apis/tier-access', { + params: buildParams({ tier, content_type: contentType }), + }), + + updateTierAccess: (accessId: string, data: { + is_enabled?: boolean; + rate_limit_override?: number; + monthly_quota?: number; + }) => + api.put(`/api/v1/external-apis/tier-access/${accessId}`, data), + + createAPI: (data: { + provider_id: string; + content_type: string; + name: string; + endpoint_url: string; + api_version?: string; + description?: string; + pricing_model?: string; + rate_limit?: number; + features: string[]; + auth_type: string; + auth_header_name?: string; + auth_header_prefix?: string; + is_active: boolean; + }) => + api.post('/api/v1/external-apis/apis', data), + + deleteAPI: (apiId: string) => + api.delete(`/api/v1/external-apis/apis/${apiId}`), + + updateAPI: (apiId: string, data: { + provider_id?: string; + name?: string; + content_type?: string; + endpoint_url?: string; + api_version?: string; + description?: string; + pricing_model?: string; + rate_limit?: number; + features?: string[]; + auth_type?: string; + auth_header_name?: string; + auth_header_prefix?: string; + is_active?: boolean; + }) => + api.put(`/api/v1/external-apis/apis/${apiId}`, data), +}; + +// ============================================================================= +// OpenAPI Auto-Discovery +// ============================================================================= + +export const openAPIDiscoveryApi = { + /** + * Discover services from an OpenAPI specification URL. + * Returns a list of services grouped by tag/prefix with inferred types. + */ + discover: (url: string, authHeader?: string) => + api.post('/api/v1/sync/openapi/discover', { + openapi_url: url, + auth_header: authHeader, + }), + + /** + * Import discovered services as AI Components. + * Creates a gateway resource and child service resources. + */ + import: (request: OpenAPIImportRequest) => + api.post('/api/v1/sync/openapi/import', request), + + /** + * Refresh services from an existing gateway's OpenAPI spec. + * Adds new services, updates existing, marks removed as inactive. + */ + refreshGateway: (gatewayId: string) => + api.post('/api/v1/sync/openapi/refresh', { + gateway_id: gatewayId, + }), + + /** + * Check health of a gateway and all its child services. + */ + getGatewayHealth: (gatewayId: string) => + api.get(`/api/v1/sync/openapi/health/${gatewayId}`), +}; + +// ============================================================================= +// Provider Quick-Add +// ============================================================================= + +export const providerApi = { + /** + * Quick-add a known provider with automatic validation and model sync. + * Supports: openrouter, openai, anthropic, groq, together, azure, custom + */ + quickAdd: (data: QuickAddRequest) => + api.post('/api/v1/providers/quick-add', data), + + /** + * Validate a provider's API key without creating any resources. + */ + validate: (data: ValidateRequest) => + api.post('/api/v1/providers/validate', data), +}; + +// ============================================================================= +// LiteLLM Model Catalogue (read-only — 2,251+ models) +// ============================================================================= + +export const liteLLMCatalogueApi = { + listModels: (params?: { + skip?: number; + limit?: number; + search?: string; + provider?: string; + mode?: string; + configured_only?: boolean; + capabilities?: string; + }) => + api.get('/api/v1/catalog/litellm-catalogue', { params }), + + getModel: (modelId: string) => + api.get(`/api/v1/catalog/litellm-catalogue/${encodeURIComponent(modelId)}`), + + getProviders: () => + api.get('/api/v1/catalog/litellm-catalogue/providers'), + + getModes: () => + api.get('/api/v1/catalog/litellm-catalogue/modes'), + + getCapabilities: () => + api.get('/api/v1/catalog/litellm-catalogue/capabilities'), + + getStats: () => + api.get('/api/v1/catalog/litellm-catalogue/stats'), + + refreshCache: () => + api.post('/api/v1/catalog/litellm-catalogue/refresh'), +}; diff --git a/backend/admin-dashboard/src/services/api/feature.ts b/backend/admin-dashboard/src/services/api/feature.ts new file mode 100644 index 0000000..71cfe4d --- /dev/null +++ b/backend/admin-dashboard/src/services/api/feature.ts @@ -0,0 +1,97 @@ +/** + * services/api — feature management endpoints + */ + +import { api, buildParams } from './client'; +import type { FeatureExtractor } from './types'; + +// New Feature Management APIs (using new feature_extraction schema) +export const featureManagementApi = { + listExtractors: (category?: string, isActive?: boolean) => + api.get('/api/v1/feature-management/extractors', { + params: buildParams({ category, is_active: isActive }), + }), + + getExtractor: (extractorId: string) => + api.get(`/api/v1/feature-management/extractors/${extractorId}`), + + listToggles: (tier?: string, extractorId?: string) => + api.get('/api/v1/feature-management/toggles', { + params: buildParams({ tier, extractor_id: extractorId }), + }), + + updateToggle: (data: { + extractor_id: string; + tier: string; + enabled: boolean; + rate_limit?: number; + custom_config?: any; + }) => + api.post('/api/v1/feature-management/toggles', data), + + deleteToggle: (toggleId: string) => + api.delete(`/api/v1/feature-management/toggles/${toggleId}`), + + activateExtractor: (extractorId: string) => + api.post(`/api/v1/feature-management/extractors/${extractorId}/activate`), + + deactivateExtractor: (extractorId: string) => + api.post(`/api/v1/feature-management/extractors/${extractorId}/deactivate`), +}; + +// Feature extractor APIs (DEPRECATED — Some components still use these endpoints). +// TODO: Migrate feature extractors to dynamic templates +export const featureApi = { + listExtractors: (contentType?: string) => { + const params = contentType ? { content_type: contentType } : {}; + return api.get('/api/v1/features/extractors', { params }); + }, + + listFeatureToggles: (tier?: string, contentType?: string) => + api.get('/api/v1/features/toggles', { + params: buildParams({ tier, content_type: contentType }), + }), + + updateFeatureToggle: (data: { + tier: string; + content_type: string; + feature_name: string; + enabled: boolean; + }) => + api.put('/api/v1/features/toggles', data), + + bulkUpdateToggles: (updates: Array<{ + tier: string; + content_type: string; + feature_name: string; + enabled: boolean; + }>) => + api.post('/api/v1/features/toggles/bulk', { updates }), + + createExtractor: (data: { + name: string; + content_type: string; + extractor_class: string; + configuration: any; + is_active: boolean; + requires_gpu: boolean; + }) => + api.post('/api/v1/features/extractors', data), + + deleteExtractor: (extractorId: string) => + api.delete(`/api/v1/features/extractors/${extractorId}`), + + getExtractor: (extractorId: string) => + api.get(`/api/v1/features/extractors/detail/${extractorId}`), + + updateExtractor: (extractorId: string, data: { + name?: string; + content_type?: string; + extractor_class?: string; + configuration?: any; + is_active?: boolean; + requires_gpu?: boolean; + enabled_tiers?: string[]; + }) => + api.put(`/api/v1/features/extractors/${extractorId}`, data), +}; diff --git a/backend/admin-dashboard/src/services/api/index.ts b/backend/admin-dashboard/src/services/api/index.ts new file mode 100644 index 0000000..bdd52e5 --- /dev/null +++ b/backend/admin-dashboard/src/services/api/index.ts @@ -0,0 +1,33 @@ +/** + * services/api — barrel re-export + * + * Public API mirrors the original monolithic services/api.ts (884 LOC) split + * into 9 focused modules. Imports of `from '../../services/api'` resolve here. + */ + +export type { + Pipeline, + TaskTemplate, + LLMModel, + FeatureExtractor, + CatalogResource, + ServiceStatus, + QuickAddRequest, + QuickAddResponse, + ValidateRequest, + ValidateResponse, +} from './types'; + +export { API_BASE_URL, api } from './client'; + +export { services, checkServiceHealth } from './services'; + +export { getContainerName, fetchContainerLogs, fetchRunningContainers } from './containers'; + +export { featureManagementApi, featureApi } from './feature'; + +export { llmModelApi } from './llm-models'; + +export { dynamicTemplateApi, pipelineApi, analysisApi, taskTemplateApi } from './pipeline'; + +export { externalApiService, openAPIDiscoveryApi, providerApi, liteLLMCatalogueApi } from './external'; diff --git a/backend/admin-dashboard/src/services/api/llm-models.ts b/backend/admin-dashboard/src/services/api/llm-models.ts new file mode 100644 index 0000000..aacdd21 --- /dev/null +++ b/backend/admin-dashboard/src/services/api/llm-models.ts @@ -0,0 +1,36 @@ +/** + * services/api — LLM Model Registry endpoints + */ + +import { api, buildParams } from './client'; +import type { LLMModel } from './types'; + +export const llmModelApi = { + listModels: (provider?: string, tier?: string, isActive?: boolean) => + api.get('/api/v1/llm-models/models', { + params: buildParams({ provider, tier, is_active: isActive }), + }), + + getModel: (modelId: string) => + api.get(`/api/v1/llm-models/models/${modelId}`), + + createModel: (data: any) => + api.post('/api/v1/llm-models/models', data), + + updateModel: (modelId: string, data: any) => + api.put(`/api/v1/llm-models/models/${modelId}`, data), + + deleteModel: (modelId: string) => + api.delete(`/api/v1/llm-models/models/${modelId}`), + + updateTierAccess: (modelId: string, tier: string, enabled: boolean) => + api.put(`/api/v1/llm-models/models/${modelId}/tier-access`, { tier, enabled }), + + addBenchmark: (modelId: string, benchmark: any) => + api.post(`/api/v1/llm-models/models/${modelId}/benchmark`, benchmark), + + getModelsByCapability: (capability: string, tier?: string, minContextLength?: number) => + api.get(`/api/v1/llm-models/models/by-capability/${capability}`, { + params: buildParams({ tier, min_context_length: minContextLength }), + }), +}; diff --git a/backend/admin-dashboard/src/services/api/pipeline.ts b/backend/admin-dashboard/src/services/api/pipeline.ts new file mode 100644 index 0000000..5edff47 --- /dev/null +++ b/backend/admin-dashboard/src/services/api/pipeline.ts @@ -0,0 +1,122 @@ +/** + * services/api — pipeline orchestration endpoints + * - dynamicTemplateApi: dynamic templates CRUD + * - pipelineApi: pipeline CRUD + execute + * - taskTemplateApi: task templates CRUD + * - analysisApi: thin convenience wrappers over dynamicTemplateApi + */ + +import { api } from './client'; +import type { Pipeline, TaskTemplate } from './types'; + +export const dynamicTemplateApi = { + create: (data: any) => + api.post('/api/v1/dynamic-templates/templates', data), + + list: (params?: { + template_type?: string; + tier?: string; + content_type?: string; + stage?: string; + is_active?: boolean; + }) => + api.get('/api/v1/dynamic-templates/templates', { params }), + + get: (templateId: string) => + api.get(`/api/v1/dynamic-templates/templates/${templateId}`), + + update: (templateId: string, data: any) => + api.put(`/api/v1/dynamic-templates/templates/${templateId}`, data), + + delete: (templateId: string) => + api.delete(`/api/v1/dynamic-templates/templates/${templateId}`), + + listTasks: (tier?: string) => + api.get('/api/v1/dynamic-templates/templates/tasks/list', { params: { tier } }), + + listExternalAPIs: (contentType?: string) => + api.get('/api/v1/dynamic-templates/templates/external-apis/list', { params: { content_type: contentType } }), + + listFeatureExtractors: (contentType?: string) => + api.get('/api/v1/dynamic-templates/templates/feature-extractors/list', { params: { content_type: contentType } }), +}; + +export const pipelineApi = { + create: (data: any) => + api.post('/api/v1/pipelines', data), + + list: (params?: { + pipeline_type?: string; + content_type?: string; + tier?: string; + is_active?: boolean; + is_default?: boolean; + }) => + api.get('/api/v1/pipelines', { params }), + + get: (pipelineId: string) => + api.get(`/api/v1/pipelines/${pipelineId}`), + + update: (pipelineId: string, data: any) => + api.put(`/api/v1/pipelines/${pipelineId}`, data), + + delete: (pipelineId: string) => + api.delete(`/api/v1/pipelines/${pipelineId}`), + + execute: (pipelineId: string, inputData: any) => + api.post(`/api/v1/pipelines/${pipelineId}/execute`, inputData), + + preview: (pipelineId: string) => + api.get(`/api/v1/pipelines/${pipelineId}/preview`), +}; + +export const analysisApi = { + getTaskTemplates: () => + dynamicTemplateApi.listTasks(), + + getFeatureExtractors: () => + dynamicTemplateApi.listFeatureExtractors(), + + getExternalAPIs: () => + dynamicTemplateApi.listExternalAPIs(), +}; + +export const taskTemplateApi = { + listTemplates: (params?: { + task_type?: string; + category?: string; + tier?: string; + is_active?: boolean; + search?: string; + }) => + api.get('/api/v1/task-templates', { params }), + + getTemplate: (taskId: string) => + api.get(`/api/v1/task-templates/${taskId}`), + + createTemplate: (data: any) => + api.post('/api/v1/task-templates', data), + + updateTemplate: (taskId: string, data: any) => + api.put(`/api/v1/task-templates/${taskId}`, data), + + deleteTemplate: (taskId: string) => + api.delete(`/api/v1/task-templates/${taskId}`), + + validateConfig: (taskId: string, config: any) => + api.post(`/api/v1/task-templates/${taskId}/validate`, config), + + testTemplate: (taskId: string, testInput: any) => + api.post(`/api/v1/task-templates/${taskId}/test`, testInput), + + executeTask: (taskId: string, inputData: any) => + api.post(`/api/v1/task-templates/${taskId}/execute`, inputData), + + getCompatibleTasks: (taskId: string, targetType?: string) => { + const params = targetType ? { target_type: targetType } : {}; + return api.get(`/api/v1/task-templates/${taskId}/compatible`, { params }); + }, + + getStats: () => + api.get('/api/v1/task-templates/stats/summary'), +}; diff --git a/backend/admin-dashboard/src/services/api/services.ts b/backend/admin-dashboard/src/services/api/services.ts new file mode 100644 index 0000000..94c930c --- /dev/null +++ b/backend/admin-dashboard/src/services/api/services.ts @@ -0,0 +1,253 @@ +/** + * services/api — service registry + health checks + */ + +import type { ServiceStatus } from './types'; +import { fetchRunningContainers } from './containers'; + +// Service configurations - Organized by layers +// Cluster items: probed by HTTP at clusterHost. Local items: probed via Docker container state. +// Consolele native (RabbitMQ/MinIO/Keycloak/Grafana/etc.) rulează pe același host +// ca dashboard-ul — derivăm host-ul din pagină, fără IP hardcodat. +const HOST = typeof window !== 'undefined' ? window.location.hostname : 'localhost'; + +export const services: ServiceStatus[] = [ + // ==================== DATA LAYER (local single-node containers on didi-network) ==================== + { + name: 'didi-postgres', + displayName: 'PostgreSQL (local)', + port: 5432, + status: 'unknown', + icon: 'database', + description: 'PostgreSQL 17 local — seed DIDI (4 scheme bos_*)', + details: 'container didi-postgres:5432 — DB DIDI, user bos_interface', + }, + { + name: 'didi-cache', + displayName: 'Redis (local)', + port: 6379, + status: 'unknown', + icon: 'memory', + description: 'Redis 7 local — framework config cache + session state', + details: 'container didi-cache:6379 — prefix didi:*', + }, + { + name: 'staging-dataLayer-rabbitmq', + displayName: 'RabbitMQ (local)', + port: 5672, + status: 'unknown', + icon: 'queue', + uiUrl: `http://${HOST}:15672`, + description: 'RabbitMQ 3.12 local — 24 cozi analiză async', + details: 'container staging-dataLayer-rabbitmq:5672 — user admin', + }, + { + name: 'staging-dataLayer-minio', + displayName: 'MinIO (local)', + port: 9000, + status: 'unknown', + icon: 'storage', + uiUrl: `http://${HOST}:9001`, + description: 'MinIO local — S3-compatible object storage', + details: 'container staging-dataLayer-minio:9000 — bucket didi-prod, user minioadmin', + }, + + // ==================== GATEWAY & AUTH LAYER (local) ==================== + { + name: 'didi-kong', + displayName: 'Kong Gateway (local)', + port: 8000, + status: 'unknown', + icon: 'api', + description: 'Kong DBless local — reverse proxy + rate limiting', + details: 'container didi-kong — proxy :18000, admin :18001', + }, + { + name: 'didi-keycloak', + displayName: 'Keycloak SSO (local)', + port: 8080, + status: 'unknown', + icon: 'lock', + uiUrl: `http://${HOST}:28080/auth/admin/master/console`, + description: 'Keycloak 26 local — realmuri didi-admins + didi-clients', + details: 'container didi-keycloak — :28080/auth', + }, + + // ==================== ORCHESTRATION LAYER ==================== + { + name: 'didi-framework', + displayName: 'DIDI Framework', + port: 3005, + status: 'unknown', + healthEndpoint: '/api/health', + icon: 'settings', + }, + { + name: 'didi-agent-v3', + displayName: 'Agent V3 Pipeline', + port: 24803, + status: 'unknown', + healthEndpoint: '/api/v3/health', + icon: 'smart_toy', + }, + + // ==================== MONITORING / OBSERVABILITY LAYER (local) ==================== + { + name: 'didi-prometheus', + displayName: 'Prometheus', + port: 9090, + status: 'unknown', + icon: 'monitoring', + uiUrl: `http://${HOST}:9090`, + description: 'Metrici — scrape agent-v3 + workeri + AI platform', + details: 'container didi-prometheus:9090', + }, + { + name: 'didi-grafana', + displayName: 'Grafana', + port: 3000, + status: 'unknown', + icon: 'dashboard', + uiUrl: `http://${HOST}:3030`, + description: 'Dashboards — datasources Prometheus/Loki/Jaeger', + details: 'container didi-grafana — UI :3030', + }, + { + name: 'didi-loki', + displayName: 'Loki', + port: 3100, + status: 'unknown', + icon: 'article', + description: 'Agregare logs (via Promtail)', + details: 'container didi-loki:3100', + }, + { + name: 'didi-jaeger', + displayName: 'Jaeger', + port: 16686, + status: 'unknown', + icon: 'account_tree', + uiUrl: `http://${HOST}:16686`, + description: 'Tracing distribuit (OTLP)', + details: 'container didi-jaeger — UI :16686', + }, + { + name: 'didi-alertmanager', + displayName: 'Alertmanager', + port: 9093, + status: 'unknown', + icon: 'notifications', + uiUrl: `http://${HOST}:9093`, + description: 'Alerte Prometheus', + details: 'container didi-alertmanager:9093', + }, + { + name: 'didi-otel-collector', + displayName: 'OTEL Collector', + port: 4317, + status: 'unknown', + icon: 'hub', + description: 'Pipeline telemetrie (OTLP → Prometheus/Loki/Jaeger)', + details: 'container didi-otel-collector', + }, + { + name: 'didi-promtail', + displayName: 'Promtail', + port: 9080, + status: 'unknown', + icon: 'sync_alt', + description: 'Colector logs containere → Loki', + details: 'container didi-promtail', + }, +]; + +// Map service name → Docker container name (only for LOCAL services). +// Cluster services use ServiceStatus.healthCheckUrl + direct HTTP probe. +const serviceToContainer: Record = { + // Data layer (local single-node) + 'didi-postgres': 'didi-postgres', + 'didi-cache': 'didi-cache', + 'staging-dataLayer-rabbitmq': 'staging-dataLayer-rabbitmq', + 'staging-dataLayer-minio': 'staging-dataLayer-minio', + // Gateway & auth + 'didi-kong': 'didi-kong', + 'didi-keycloak': 'didi-keycloak', + // Orchestration + 'didi-framework': 'didi-framework', + 'didi-agent-v3': 'didi-agent-v3', + // Monitoring / observability + 'didi-prometheus': 'didi-prometheus', + 'didi-grafana': 'didi-grafana', + 'didi-loki': 'didi-loki', + 'didi-jaeger': 'didi-jaeger', + 'didi-alertmanager': 'didi-alertmanager', + 'didi-otel-collector': 'didi-otel-collector', + 'didi-promtail': 'didi-promtail', +}; + +// Cached container states (refreshed in batch) +let _containerCache: Record = {}; +let _containerCacheTime = 0; + +async function getContainerStates(): Promise> { + const now = Date.now(); + if (now - _containerCacheTime < 5000 && Object.keys(_containerCache).length > 0) { + return _containerCache; + } + try { + const containers = await fetchRunningContainers(); + const map: Record = {}; + for (const c of containers) { + map[c.name] = { state: c.state, status: c.status }; + } + _containerCache = map; + _containerCacheTime = now; + return map; + } catch { + return _containerCache; + } +} + +// Check service health: cluster services → HTTP probe, local services → Docker container state +export const checkServiceHealth = async (service: ServiceStatus): Promise => { + if (service.cluster && service.healthCheckUrl) { + try { + const res = await fetch(service.healthCheckUrl, { + method: 'GET', + mode: 'no-cors', // some endpoints don't allow CORS — opaque response is fine + signal: AbortSignal.timeout(5000), + }); + // no-cors returns opaque response (status=0) — if no error thrown, host is reachable + const status: 'healthy' | 'unhealthy' = (res.type === 'opaque' || res.ok) ? 'healthy' : 'unhealthy'; + return { ...service, status, lastChecked: new Date() }; + } catch { + return { ...service, status: 'unhealthy' as const, lastChecked: new Date() }; + } + } + + const containerName = serviceToContainer[service.name]; + if (!containerName) { + return { ...service, status: 'unknown' as const, lastChecked: new Date() }; + } + + const states = await getContainerStates(); + const container = states[containerName]; + + if (!container) { + return { ...service, status: 'unhealthy' as const, lastChecked: new Date() }; + } + + const dockerStatus = container.status.toLowerCase(); + let status: 'healthy' | 'unhealthy' | 'unknown' = 'unknown'; + if (dockerStatus.includes('(healthy)')) { + status = 'healthy'; + } else if (dockerStatus.includes('(unhealthy)')) { + status = 'unhealthy'; + } else if (container.state === 'running') { + status = 'healthy'; + } else { + status = 'unhealthy'; + } + + return { ...service, status, lastChecked: new Date() }; +}; diff --git a/backend/admin-dashboard/src/services/api/types.ts b/backend/admin-dashboard/src/services/api/types.ts new file mode 100644 index 0000000..7ad2af8 --- /dev/null +++ b/backend/admin-dashboard/src/services/api/types.ts @@ -0,0 +1,125 @@ +/** + * services/api — shared types + */ + +// Legacy catalog types removed — kept as any for backward compat of unused functions +export type OpenAPIDiscoveryResult = any; +export type OpenAPIImportRequest = any; +export type OpenAPIImportResponse = any; +export type OpenAPIRefreshResponse = any; +export type GatewayHealthResponse = any; + +export interface Pipeline { + id: string; + slug: string; + name: string; + description?: string; + pipeline_type: string; + content_type: string; + tier: string; + is_active: boolean; + is_default: boolean; + version: number; + graph: Record; + created_at: string; + updated_at: string; +} + +export interface TaskTemplate { + id: string; + task_id: string; + name: string; + description: string; + task_type: 'llm' | 'feature_extraction' | 'external_api' | 'routing' | 'transform'; + task_config: Record; + input_schema: Record; + output_schema: Record; + category: string; + tags: string[]; + tier: string; + is_active: boolean; + is_experimental: boolean; + usage_count: number; + version: string; + created_at: string; + updated_at: string; +} + +export interface LLMModel { + id: string; + slug: string; + name: string; + provider: string; + model_id: string; + context_length: number; + is_active: boolean; + tier_access: Record; + created_at: string; +} + +export interface FeatureExtractor { + id: string; + name: string; + category: string; + extractor_class: string; + configuration: Record; + is_active: boolean; +} + +export interface CatalogResource { + id: string; + slug: string; + name: string; + resource_type: string; + provider: string; + endpoint_url?: string; + is_active: boolean; +} + +export interface ServiceStatus { + name: string; + displayName: string; + port: number; + status: 'healthy' | 'unhealthy' | 'unknown'; + healthEndpoint?: string; + uiUrl?: string; + lastChecked?: Date; + icon?: string; + description?: string; + // External cluster service (probe by direct HTTP, not local Docker container) + cluster?: boolean; + clusterHost?: string; + healthCheckUrl?: string; + details?: string; +} + +export interface QuickAddRequest { + provider_type: string; + api_key: string; + custom_name?: string; + custom_base_url?: string; + auto_sync_models?: boolean; + tags?: string[]; +} + +export interface QuickAddResponse { + status: string; + message: string; + provider_id?: string; + provider_slug?: string; + provider_name?: string; + models_synced?: number; +} + +export interface ValidateRequest { + provider_type: string; + api_key: string; + custom_base_url?: string; +} + +export interface ValidateResponse { + status: string; + message: string; + provider_name?: string; + available_models?: number; +} diff --git a/backend/admin-dashboard/src/services/keycloak.ts b/backend/admin-dashboard/src/services/keycloak.ts new file mode 100644 index 0000000..db0a6c8 --- /dev/null +++ b/backend/admin-dashboard/src/services/keycloak.ts @@ -0,0 +1,33 @@ +import Keycloak from 'keycloak-js'; + +/** + * Get required environment variable or throw error. + * Fail-fast pattern ensures misconfiguration is caught at startup. + */ +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) { + throw new Error( + `Required environment variable '${name}' is not set. ` + + `Ensure all REACT_APP_* variables are configured before building.` + ); + } + return value; +}; + +/** + * Keycloak URL: use configured external SSO endpoint (REACT_APP_KEYCLOAK_URL). + * Form load + form action + POST all happen same-origin on the SSO host, + * so cookies set during GET are reliably sent on POST submit. + */ +const keycloakUrl = requireEnv('REACT_APP_KEYCLOAK_URL'); + +const keycloakConfig = { + url: keycloakUrl, + realm: requireEnv('REACT_APP_KEYCLOAK_REALM'), + clientId: requireEnv('REACT_APP_KEYCLOAK_CLIENT_ID'), +}; + +const keycloak = new Keycloak(keycloakConfig); + +export default keycloak; \ No newline at end of file diff --git a/backend/admin-dashboard/src/setupProxy.js b/backend/admin-dashboard/src/setupProxy.js new file mode 100644 index 0000000..87fc9fd --- /dev/null +++ b/backend/admin-dashboard/src/setupProxy.js @@ -0,0 +1,14 @@ +const { createProxyMiddleware } = require('http-proxy-middleware'); + +module.exports = function(app) { + app.use( + '/agent-v3', + createProxyMiddleware({ + target: 'http://172.17.0.1:24803', + changeOrigin: true, + pathRewrite: { + '^/agent-v3': '', + }, + }) + ); +}; diff --git a/backend/admin-dashboard/src/theme/index.ts b/backend/admin-dashboard/src/theme/index.ts new file mode 100644 index 0000000..84378af --- /dev/null +++ b/backend/admin-dashboard/src/theme/index.ts @@ -0,0 +1,222 @@ +import { createTheme } from '@mui/material/styles'; + +// Theme mode type +export type ThemeMode = 'light' | 'dark'; + +// Color system based on design document +export const colors = { + primary: { + main: '#0052CC', // Deep Trust Blue + light: 'rgba(0, 82, 204, 0.2)', + dark: '#003d99', + }, + secondary: { + main: '#00BFA6', // Honest Teal + light: 'rgba(0, 191, 166, 0.2)', + dark: '#008f7a', + }, + background: { + default: '#F5F7FA', // Warm Gray + paper: '#FFFFFF', + }, + text: { + primary: '#1F2933', // Slate Charcoal + secondary: '#6B7280', // Steel Gray + }, + alert: '#FF8C42', // Insight Orange + success: '#28A745', // Truth Green + error: '#E63946', // Caution Red +}; + +// Spacing based on 4px base unit +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, +}; + +// Create MUI theme factory - supports light and dark modes +export const createAppTheme = (mode: ThemeMode = 'light') => createTheme({ + palette: { + mode, + primary: { + main: colors.primary.main, + light: colors.primary.light, + dark: colors.primary.dark, + }, + secondary: { + main: colors.secondary.main, + light: colors.secondary.light, + dark: colors.secondary.dark, + }, + // Set background and text colors based on mode + background: mode === 'light' ? { + default: colors.background.default, + paper: colors.background.paper, + } : { + default: '#121212', + paper: '#1e1e1e', + }, + text: mode === 'light' ? { + primary: colors.text.primary, + secondary: colors.text.secondary, + } : { + primary: '#ffffff', + secondary: 'rgba(255, 255, 255, 0.7)', + }, + success: { + main: colors.success, + }, + error: { + main: colors.error, + }, + warning: { + main: colors.alert, + }, + }, + typography: { + fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif', + h1: { + fontSize: 32, + fontWeight: 700, + lineHeight: 1.25, + }, + h2: { + fontSize: 24, + fontWeight: 600, + lineHeight: 1.33, + }, + h3: { + fontSize: 20, + fontWeight: 500, + lineHeight: 1.4, + }, + body1: { + fontSize: 16, + fontWeight: 400, + lineHeight: 1.5, + }, + button: { + fontSize: 14, + fontWeight: 500, + lineHeight: 1.43, + letterSpacing: 0.5, + textTransform: 'uppercase', + }, + caption: { + fontSize: 12, + fontWeight: 400, + lineHeight: 1.33, + }, + }, + shape: { + borderRadius: 8, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + borderRadius: 8, + padding: '12px 24px', + transition: 'all 300ms ease-in-out', + }, + containedPrimary: { + backgroundColor: colors.primary.main, + '&:hover': { + backgroundColor: colors.primary.dark, + }, + }, + outlinedSecondary: { + borderColor: colors.secondary.main, + color: colors.secondary.main, + '&:hover': { + borderColor: colors.secondary.dark, + backgroundColor: colors.secondary.light, + }, + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', // Remove default elevation gradient in dark mode + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 12, + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + transition: 'all 300ms ease-in-out', + '&:hover': { + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + }, + }, + }, + }, + MuiTableContainer: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundImage: 'none', // Remove default gradient + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + borderRadius: 6, + '&:hover fieldset': { + borderColor: colors.primary.main, + }, + '&.Mui-focused fieldset': { + borderColor: colors.primary.main, + borderWidth: 2, + }, + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 16, + fontWeight: 500, + }, + colorSuccess: { + backgroundColor: colors.success, + color: '#FFFFFF', + }, + colorError: { + backgroundColor: colors.error, + color: '#FFFFFF', + }, + colorWarning: { + backgroundColor: colors.alert, + color: '#FFFFFF', + }, + }, + }, + }, +}); + +// Default theme (light mode) for backwards compatibility +export const theme = createAppTheme('light'); + +// Gradients +export const gradients = { + trustBloom: 'linear-gradient(90deg, #0052CC 0%, #00BFA6 100%)', + progressPop: 'linear-gradient(90deg, #00BFA6 0%, #FF8C42 100%)', + primary: 'linear-gradient(90deg, #0052CC 0%, #003d99 100%)', + primaryHover: 'linear-gradient(90deg, #003d99 0%, #0052CC 100%)', +}; \ No newline at end of file diff --git a/backend/admin-dashboard/src/types/keycloak.d.ts b/backend/admin-dashboard/src/types/keycloak.d.ts new file mode 100644 index 0000000..c2ddea0 --- /dev/null +++ b/backend/admin-dashboard/src/types/keycloak.d.ts @@ -0,0 +1,41 @@ +declare module 'keycloak-js' { + export default class Keycloak { + constructor(config?: Keycloak.KeycloakConfig); + init(initOptions?: Keycloak.KeycloakInitOptions): Promise; + login(options?: Keycloak.KeycloakLoginOptions): void; + logout(options?: Keycloak.KeycloakLogoutOptions): void; + updateToken(minValidity: number): Promise; + hasRealmRole(role: string): boolean; + hasResourceRole(role: string, resource?: string): boolean; + + token?: string; + tokenParsed?: any; + refreshToken?: string; + authenticated?: boolean; + + static KeycloakConfig: Keycloak.KeycloakConfig; + static KeycloakInitOptions: Keycloak.KeycloakInitOptions; + } + + namespace Keycloak { + interface KeycloakConfig { + url?: string; + realm: string; + clientId: string; + } + + interface KeycloakInitOptions { + onLoad?: 'login-required' | 'check-sso'; + checkLoginIframe?: boolean; + silentCheckSsoRedirectUri?: string; + } + + interface KeycloakLoginOptions { + redirectUri?: string; + } + + interface KeycloakLogoutOptions { + redirectUri?: string; + } + } +} \ No newline at end of file diff --git a/backend/admin-dashboard/src/utils/clipboard.ts b/backend/admin-dashboard/src/utils/clipboard.ts new file mode 100644 index 0000000..0a688b6 --- /dev/null +++ b/backend/admin-dashboard/src/utils/clipboard.ts @@ -0,0 +1,63 @@ +/** + * Utility for copying text to clipboard with fallback for non-HTTPS contexts + */ + +/** + * Copy text to clipboard with automatic fallback + * @param text Text to copy + * @returns Promise that resolves when text is copied + */ +export async function copyToClipboard(text: string): Promise { + // Try modern Clipboard API first (requires HTTPS or localhost) + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (err) { + console.warn('Clipboard API failed, trying fallback:', err); + } + } + + // Fallback for HTTP contexts or browsers without Clipboard API + return copyToClipboardFallback(text); +} + +/** + * Fallback method using deprecated document.execCommand + * Works in HTTP contexts where Clipboard API is unavailable + */ +function copyToClipboardFallback(text: string): Promise { + return new Promise((resolve, reject) => { + const textarea = document.createElement('textarea'); + + // Style textarea to be invisible but still accessible + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.setAttribute('readonly', ''); + + document.body.appendChild(textarea); + + try { + // Select the text + textarea.select(); + textarea.setSelectionRange(0, text.length); + + // Execute copy command + const successful = document.execCommand('copy'); + + if (!successful) { + throw new Error('execCommand copy failed'); + } + + resolve(); + } catch (err) { + console.error('Clipboard fallback failed:', err); + reject(new Error('Failed to copy to clipboard')); + } finally { + // Clean up + document.body.removeChild(textarea); + } + }); +} diff --git a/backend/admin-dashboard/tsconfig.json b/backend/admin-dashboard/tsconfig.json new file mode 100644 index 0000000..a273b0c --- /dev/null +++ b/backend/admin-dashboard/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} diff --git a/backend/docs/00_INDEX_Dosar_Livrare_Lot2.docx b/backend/docs/00_INDEX_Dosar_Livrare_Lot2.docx new file mode 100644 index 0000000..fe12b3a Binary files /dev/null and b/backend/docs/00_INDEX_Dosar_Livrare_Lot2.docx differ diff --git a/backend/docs/00_INDEX_Dosar_Livrare_Lot2.md b/backend/docs/00_INDEX_Dosar_Livrare_Lot2.md new file mode 100644 index 0000000..3f06065 --- /dev/null +++ b/backend/docs/00_INDEX_Dosar_Livrare_Lot2.md @@ -0,0 +1,51 @@ +% Dosar de livrare — DiDi Lot 2 (Backend) +% Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# Cuprinsul dosarului de livrare + +Prezentul dosar documentează livrarea **Lotului 2 — Aplicație software backend** a platformei +DiDi. Documentele sunt corelate cu **Propunerea Tehnică Lot 2** (9 module) și cu caietul de +sarcini. + +| # | Document | Conținut | +|---|---|---| +| 00 | **INDEX Dosar Livrare** (acest fișier) | Cuprins + stare livrare | +| 01 | **Arhitectură Lot 2** | Arhitectura pe 3 straturi, fluxuri, model de date, integrarea cu Lot 1 | +| 02 | **Ghid Instalare & Operare** | Instalare de la zero (`build-local.sh`), operare, comenzi | +| 03 | **Raport Testare API** | 374/374 endpoint-uri cablate, securitate, OpenAPI validat | +| 04 | **Raport Testare Integrare Lot1↔Lot2** | 9/9 servicii AI, 5/5 analize E2E, fail-open, 1 defect remediat | +| 05 | **Ghid de Utilizare** | Tur al platformei cu capturi reale, mapat pe module | +| 06 | **Matrice Trasabilitate Cerințe** | Cerințe caiet + ofertă → livrabil/dovadă | +| 07 | **Specificații API** | Structura celor 2 API-uri (374 op.), Swagger, `x-integrations` | +| — | **openapi.yaml** (×2) | Specificațiile OpenAPI 3.0.3 mașinabile (agent-v3 + framework) | +| — | **scripts/integration/** | Harness reproductibil de testare a integrării + dovezi | + +--- + +# Stare livrare (sinteză) + +| Indicator | Valoare | +|---|---| +| Module ofertă acoperite | **9 / 9** | +| Endpoint-uri API cablate | **374 / 374** | +| Integrări cu Lotul 1 verificate | **9 / 9** | +| Analize end-to-end (tipuri de conținut) | **5 / 5** (text, URL, imagine, audio, video) | +| Teste de integrare | **16 PASS · 1 DEGRADED (fail-open, corect) · 0 FAIL** | +| Defecte găsite în testare | 1 — remediat și întărit | +| Servicii live (moment recepție) | toate `healthy` | + +--- + +# Acces platformă (mediu de recepție) + +| Resursă | Adresă | Acces | +|---|---|---| +| Dashboard administrativ | `https://:3001/admin` | `admin` / `Admin12345` (realm `didi-admins`) | +| API Agent V3 | `:24803` (prin Kong `/agent-v3/*`) | JWT Bearer | +| API didiFramework | `:3005` (prin Kong `/framework/*`) | JWT Bearer | +| Health integrare | `GET /api/v3/health/all` | infra + 9 servicii Lot 1 | + +Toate documentele sunt livrate în format Markdown (sursă) și `.docx` (pentru dosar). diff --git a/backend/docs/01_Arhitectura_Lot2.docx b/backend/docs/01_Arhitectura_Lot2.docx new file mode 100644 index 0000000..776d351 Binary files /dev/null and b/backend/docs/01_Arhitectura_Lot2.docx differ diff --git a/backend/docs/01_Arhitectura_Lot2.md b/backend/docs/01_Arhitectura_Lot2.md new file mode 100644 index 0000000..34b3e06 --- /dev/null +++ b/backend/docs/01_Arhitectura_Lot2.md @@ -0,0 +1,245 @@ +% Documentație de arhitectură — DiDi Lot 2 (Backend) +% Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# 1. Scop și context + +Prezentul document descrie arhitectura tehnică a **Lotului 2 — Aplicație software backend** +din cadrul platformei DiDi, sistem de detecție a dezinformării dezvoltat în cadrul +proiectului PNRR DIGI150. Backendul primește conținut (text, URL, imagine, audio, video), +îl analizează pe patru dimensiuni independente și produce un verdict de risc cu scor +(0–100) și explicație bilingvă (RO/EN). + +Lotul 2 acoperă **coloana vertebrală software** a platformei: orchestrarea analizei, +brokerul de mesaje, gateway-ul de API, baza de date, autentificarea, dashboard-ul +administrativ, observabilitatea și livrarea cloud-native. Serviciile de inteligență +artificială propriu-zise (modele LLM, deepfake, transcriere, extractori) sunt furnizate +de **Lotul 1 (Platforma AI)** și sunt consumate de backend prin interfețe HTTP configurabile +(vezi §9). + +--- + +# 2. Vedere de ansamblu — arhitectură pe trei straturi + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ STRAT 3 — Gateway & Autentificare │ +│ Kong API Gateway (JWT RS256) · Keycloak (OIDC, realms clients/admins) │ +├──────────────────────────────────────────────────────────────────────┤ +│ STRAT 2 — Orchestrare │ +│ Agent V3 (:24803) — motor de analiză, dispatch async │ +│ didiFramework (:3005) — CRUD parametri + sync Redis │ +│ Workeri (techniques ×2, ai-tampered ×2, claims ×3, domain ×2, │ +│ media-preprocess ×2, verdict-aggregator ×2) │ +│ Admin Dashboard (React) — configurare, monitorizare, moderare │ +├──────────────────────────────────────────────────────────────────────┤ +│ STRAT 1 — Date │ +│ PostgreSQL 17 · Redis 7 · RabbitMQ 3.12 · MinIO (S3) │ +├──────────────────────────────────────────────────────────────────────┤ +│ Observabilitate transversală │ +│ Prometheus · Grafana · Loki · OpenTelemetry · Jaeger · Alertmanager │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +Principiul de proiectare: **separarea configurării de execuție**. Toți parametrii de +analiză (tehnici, ponderi, verdicte, modele LLM, prompturi, profiluri de pipeline) sunt +gestionați declarativ prin `didiFramework` și stocați în PostgreSQL, apoi sincronizați în +Redis. Motorul de analiză (`agent-v3`) citește configurarea din Redis la fiecare rulare — +astfel comportamentul se modifică fără redeploy de cod. + +--- + +# 3. Componentele principale + +## 3.1 Agent V3 — motorul de analiză (port 24803) + +Serviciul central. Node.js + TypeScript + Express 5. Expune API-ul de analiză (toate +endpoint-urile de analiză sunt **asincrone**: dispatch pe RabbitMQ, răspuns `202` + poll). + +Rulează patru componente de analiză independente + un calculator de verdict: + +| Componentă | Ce detectează | Scor | +|---|---|---| +| **Techniques** | tehnici de manipulare (166 tehnici, 8 dimensiuni) | manipulation_score 0–100 | +| **AI-Tampered** | conținut generat/modificat de AI | ai_probability 0–100 | +| **Claims** | afirmații verificate prin căutare web | credibility_score 0–100 | +| **Source Assessment** | credibilitatea sursei/domeniului | trust_score 0–100 | +| **Verdict** | agregare ponderată → risc final + explicație RO/EN | risk_score 0–100 | + +Fiecare componentă (mai puțin Domain) rulează în două etape: **screening** (analiză rapidă) +→ **deep analysis** (analiză detaliată). Fiecare etapă are un lanț de modele LLM cu până la +3 nivele de fallback. + +## 3.2 didiFramework — managementul parametrilor (port 3005) + +Node.js + TypeScript + Express 4. API CRUD pentru toți parametrii platformei (peste 280 de +endpoint-uri). Stochează configurarea în PostgreSQL (schema `bos_parammgmt`) și o +sincronizează în Redis prin `POST /api/sync-redis` (~51 chei de configurare). Gestionează de +asemenea utilizatorii, creditele, abonamentele și integrarea cu Keycloak. + +## 3.3 Admin Dashboard + +Aplicație React 19 + Material UI. Interfață pentru: configurarea framework-ului de analiză, +managementul modelelor LLM (chain-uri free/premium), utilizatori, istoric analize, coada de +moderare umană (HIL), editorul de pipeline-uri și consola de rulare. + +## 3.4 Kong API Gateway + +Punctul unic de intrare pentru traficul extern. Mod DBless (configurație declarativă). +Aplică validarea JWT (RS256, contra JWKS-ului Keycloak), rate limiting, CORS, transformări +de request/response și limitare de dimensiune. Rutează `/api/v3/*` → agent-v3 și `/api/*` → +didiFramework. + +## 3.5 Keycloak + +Serviciul de autentificare OIDC/OAuth2. Două realm-uri: `didi-clients` (utilizatori finali) +și `didi-admins` (operatori). Emite token-uri JWT RS256, aplică politici de parolă, protecție +brute-force și MFA (TOTP). + +## 3.6 Stratul de date + +- **PostgreSQL 17** — sursa de adevăr. 4 scheme: `bos_parammgmt` (parametri), `bos_analysis` + (rezultate), `bos_sysadmin` (utilizatori), `bos_subscriber` (date personale). +- **Redis 7** — cache derivat din PostgreSQL (configurare) + stare de sesiune (TTL 7 zile) + + lock-uri workeri. +- **RabbitMQ 3.12** — cozi async (4 componente × 6 planuri de prioritate + media-preprocess + + results + DLQ). +- **MinIO** — stocare fișiere media (S3-compatibil). + +--- + +# 4. Fluxul de date + +## 4.1 Analiză asincronă (fluxul principal) + +``` +Client → Kong (validare JWT) → agent-v3 :24803 + │ validare input + verificare credite (didiFramework) + ▼ +Dispatcher → publică task-uri în RabbitMQ (prioritate din planul de abonament) + ▼ răspuns 202: { session_id, poll_url, result_url } + +--- în paralel, workeri Docker --- +Worker Techniques ┐ +Worker AI-Tampered ├─ consumă din coadă → rulează executor → publică rezultat +Worker Claims │ +Worker Domain ┘ + ▼ +Verdict Aggregator → așteaptă toate componentele → VerdictCalculator (funcție pură) + → explicație LLM (RO/EN) → persistă în PostgreSQL + Redis + ▼ +Client face poll: GET /:sessionId/queue-status → progres + GET /:sessionId/result → AnalysisSession completă +``` + +## 4.2 Analiză media (video/audio/imagine) + +Un worker dedicat `media-preprocess` centralizează descărcarea, extragerea cadrelor +(ffmpeg), transcrierea (Whisper) și analiza vizuală (o singură dată), apoi dispecerizează +componentele de analiză care citesc rezultatele din cache-ul Redis — evitând reprocesarea. + +## 4.3 Pipeline = workflow cu dependențe explicite + +Fluxul de analiză este modelat ca **workflow cu dependențe explicite** (conform caietului, +„DAG *sau* workflow"): + +``` +intake → [media_preprocess] → {techniques ∥ ai_tampered ∥ claims ∥ domain} → verdict → persist +``` + +Variantele de pipeline per tip de conținut sunt definite prin `input_type_profile` +(6 profiluri: text_no_url, text_with_url, image, audio, video, url), fiecare cu ponderi, +reguli de override și reguli INCONCLUSIVE proprii. Endpoint-ul `POST /dry-run` rezolvă +întregul plan (noduri, dependențe, cozi, lanțuri de modele) fără a consuma resurse. + +--- + +# 5. Securitate + +- **Autentificare:** JWT RS256 emise de Keycloak. Verificate criptografic **atât la gateway + (Kong)** cât și **în backend** (agent-v3 + didiFramework) — apărare în adâncime; un token + forjat/expirat este respins cu 401 pe orice cale. +- **Autorizare (RBAC):** roluri Keycloak (`admin`, `moderator`, `senior_moderator`, `viewer`). + Endpoint-urile sensibile (istoric admin, moderare) impun rol. +- **Izolarea tier-urilor:** tier-ul (free/premium) este derivat exclusiv din planul de + abonament returnat de didiFramework, nu din body-ul cererii — previne escaladarea de + privilegii. +- **TLS** pe dashboard-ul administrativ; secretele sunt în fișiere `.env` (excluse din + versionare). + +--- + +# 6. Scalare și reziliență + +- **Scalare workeri configurabilă:** `scale-workers.sh` (status/set/auto pe metrica + `didi_queue_depth` din Prometheus). +- **Broker rezilient:** publisher confirms (așteaptă ACK-ul broker-ului), re-subscribe + automat la reconectare, DLQ cu monitor + alertă. +- **HA PostgreSQL:** livrat ca IaC reproductibil (Patroni + etcd + HAProxy) în + `didiDatabase/ha-cluster/`, cu drill de failover. +- **Fail-open pe servicii AI:** orice eroare a unui serviciu Lot 1 (timeout, indisponibil) + nu blochează analiza — se continuă cu fallback. + +--- + +# 7. Tehnologii + +| Componentă | Tehnologie | +|---|---| +| Agent V3 | Node.js, TypeScript, Express 5 | +| didiFramework | Node.js, TypeScript, Express 4 | +| Admin Dashboard | React 19, Material UI 7, TypeScript | +| Bază de date | PostgreSQL 17 (+ Patroni/HAProxy pentru HA) | +| Cache | Redis 7 | +| Coadă | RabbitMQ 3.12 | +| Stocare | MinIO (S3-compatibil) | +| Gateway | Kong 3.9 (DBless) | +| Autentificare | Keycloak 26 | +| Observabilitate | Prometheus, Grafana, Loki, OpenTelemetry, Jaeger, Alertmanager | +| CI/CD | GitLab CI (build/test/publish + rollback + health-gate) | + +--- + +# 8. Modelul de date (rezumat) + +**PostgreSQL — schema `bos_analysis`** (rezultatele analizelor): `analysis_session` (rădăcină) ++ câte un tabel per componentă (`analysis_techniques`, `analysis_ai_tampered`, +`analysis_claims`, `analysis_domain`, `analysis_verdict`) + `moderation_queue` (coada HIL). + +**PostgreSQL — schema `bos_parammgmt`** (parametri, ~40 tabele): ierarhia de tehnici +(dimensiuni → subdimensiuni → tehnici → indicatori), verdicte, ponderi, surse, claims, +provideri și modele LLM, `component_stage_assignment` (chain-uri model per etapă și tier), +`component_prompt`, `input_type_profile`. + +**Redis:** chei `didi:framework:*` și `didi:config:*` (configurare, permanente) + chei +`didi:pipeline:*` (sesiuni, TTL 7 zile) + chei `didi:queue:*` (stare workeri, TTL scurt). + +--- + +# 9. Integrarea cu Lotul 1 (Platforma AI) + +Agent V3 consumă serviciile Lotului 1 prin interfețe HTTP, cu **URL-uri configurabile din +mediu** (`.env`). Pe orice deployment se ajustează doar host-urile. + +| Variabilă env | Serviciu Lot 1 | Rol | +|---|---|---| +| `LLM_ROUTER_URL` | llm-inference | modele LLM text (Qwen) + OCR | +| `VISION_LLM_URL` | vision | analiză imagine / cadre video | +| `DIDI_BRAIN_URL` | brain | cache de verificare + RAG (fact-checking) | +| `VIDEO_ANALYSIS_URL` | video / BusterX | detecție deepfake video | +| `EXTRACTORS_URL` | extractors | EXIF, ELA, spectrogramă, NER, YOLO, OCR | +| `FORENSIC_API_URL` | forensic | trăsături forensice media | +| `M17_WHISPER_URL` | audio | transcriere audio | +| `M17_WEB_API_URL` | web | căutare web pentru claims/surse | +| `DOMAIN_CHECK_API_URL` | domain-check | WHOIS/DNS/SSL/blacklist domeniu | + +Fiecare integrare este **fail-open**: dacă serviciul Lot 1 nu răspunde, analiza continuă cu +degradare grațioasă, fără a eșua. Delimitarea responsabilităților: Lotul 2 orchestrează și +consumă; Lotul 1 furnizează modelele și izolarea execuției (inclusiv sandbox-ul de cod). + +--- + +*Document generat pentru dosarul de recepție Lot 2. Corespondentul tehnic detaliat per +serviciu se află în fișierele `INDEX.md` din fiecare director de serviciu.* diff --git a/backend/docs/02_Ghid_Instalare_Operare_Lot2.docx b/backend/docs/02_Ghid_Instalare_Operare_Lot2.docx new file mode 100644 index 0000000..5c3e0fb Binary files /dev/null and b/backend/docs/02_Ghid_Instalare_Operare_Lot2.docx differ diff --git a/backend/docs/02_Ghid_Instalare_Operare_Lot2.md b/backend/docs/02_Ghid_Instalare_Operare_Lot2.md new file mode 100644 index 0000000..52b2620 --- /dev/null +++ b/backend/docs/02_Ghid_Instalare_Operare_Lot2.md @@ -0,0 +1,188 @@ +% Ghid de instalare și operare — DiDi Lot 2 (Backend) +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# 1. Precondiții + +## Software +- **Docker** ≥ 24 și **Docker Compose v2** (`docker compose version`). +- `git`, `curl`, `bash`. Fără dependențe de rețea externă pentru pornirea backend-ului. + +## Hardware (recomandat, per mașină backend) +- 8 vCPU, 16 GB RAM, 50 GB disc liber (fără modelele AI, care rulează pe Lotul 1). + +## Rețea +- Backendul rulează self-contained. Pentru analiza AI reală, mașina trebuie să poată accesa + serviciile **Lotului 1** (platforma AI) prin URL-urile configurate în `.env` (vezi §5). + +--- + +# 2. Conținutul arhivei livrate + +``` +backend/ +├── production/ +│ ├── build-local.sh ← scriptul principal de instalare +│ └── .env.example ← șablon variabile (Redis/Keycloak/Kong) +├── services/ +│ ├── data-layer/ ← PostgreSQL + Redis + RabbitMQ + MinIO + Keycloak +│ │ └── didiDatabase/DIDI_full_export_2026-07-02.sql ← seed baza de date +│ ├── gateway-auth-layer/ ← Kong + Keycloak (config + realm) +│ └── orchestration-layer/ +│ ├── agent-v3/ ← motorul de analiză (+ .env.example) +│ └── didiFramework/ ← CRUD parametri (+ .env.example) +├── admin-dashboard/ ← interfața administrativă React +├── observability/ ← Prometheus/Grafana/Loki/OTel +└── docs/ ← acest ghid + arhitectură + raport testare +``` + +> **Seed-ul bazei de date** (`DIDI_full_export_2026-07-02.sql`, ~23 MB) trebuie să fie prezent +> în `services/data-layer/didiDatabase/` înainte de instalare. Conține schema completă + +> datele + toate migrațiile. + +--- + +# 3. Pași de instalare + +## Pasul 1 — Pregătește fișierele `.env` + +Fiecare serviciu are un `.env.example`. Copiază-l în `.env` și completează secretele +(valorile marcate `CHANGE_ME`): + +```bash +cd backend +cp services/orchestration-layer/agent-v3/.env.example services/orchestration-layer/agent-v3/.env +cp services/orchestration-layer/didiFramework/.env.example services/orchestration-layer/didiFramework/.env +cp admin-dashboard/.env.example admin-dashboard/.env +cp production/.env.example production/.env +``` + +Secrete de completat în `agent-v3/.env`: cheile LLM (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, +`GROQ_API_KEY`, opțional `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`), tokenul Whisper +(`M17_WHISPER_TOKEN`), cheile MinIO. **URL-urile Lotului 1** se setează tot aici (vezi §5). + +## Pasul 2 — Rulează scriptul de instalare + +```bash +cd backend/production +chmod +x build-local.sh +./build-local.sh [hostname] # implicit: hostname-ul mașinii de deployment +``` + +Scriptul ridică toată stiva în ordinea corectă: +1. rețea Docker `didi-network`; +2. stratul de date (PostgreSQL + **import automat al seed-ului** la primul boot, Redis, + RabbitMQ, MinIO, Keycloak); +3. Kong (gateway DBless); +4. didiFramework + **sincronizarea Redis** (încarcă parametrii din PostgreSQL); +5. agent-v3 + cei 13 workeri; +6. admin dashboard. + +Durata tipică: 5–10 minute (majoritatea = build-ul imaginilor Docker + `npm install`). + +## Pasul 3 — Verifică + +La final, scriptul afișează un rezumat de health checks. Verificare manuală: + +```bash +docker exec didi-postgres pg_isready -U bos_interface +docker exec didi-framework wget -qO- http://127.0.0.1:3005/health +docker exec didi-agent-v3 wget -qO- http://localhost:24803/api/v3/health +docker exec didi-cache redis-cli -a redis123 --no-auth-warning keys 'didi:framework:*' | wc -l # aștept 8 +``` + +--- + +# 4. Ce pornește (containere) + +| Container | Rol | Port | +|---|---|---| +| `didi-postgres` | PostgreSQL 17 (baza de date principală) | 5432 | +| `didi-cache` | Redis 7 | 6379 | +| `staging-dataLayer-rabbitmq` | RabbitMQ 3.12 | 5672 / 15672 | +| `staging-dataLayer-minio` | MinIO (S3) | 9000 / 9001 | +| `didi-keycloak` | Keycloak (OIDC) | 28080 (`/auth`) | +| `didi-kong` | API Gateway (DBless) | 18000 / 18001 / 18443 | +| `didi-framework` | CRUD parametri | 3005 (intern) | +| `didi-agent-v3` | motor de analiză | 24803 | +| `agent-v3-worker-*` (×11) | workeri componente | — | +| `verdict-aggregator` (×2) | agregare verdict | — | +| `didi-admin-local` | admin dashboard | 3081 / 3001 | + +--- + +# 5. Configurarea Lotului 1 (platforma AI) + +Agent V3 apelează serviciile AI prin URL-uri din `agent-v3/.env`, blocul „Lot 1 — Platforma +AI". **Pe mașina unde e instalat și Lotul 1, se ajustează doar host-urile:** + +``` +LLM_ROUTER_URL=http://:14011 # modele LLM text + OCR +VISION_LLM_URL=http://:14011 # analiză imagine +DIDI_BRAIN_URL=http://:8090 # brain (verification cache + RAG) +VIDEO_ANALYSIS_URL=http://:54600 # deepfake video +EXTRACTORS_URL=http://:54400 # EXIF/ELA/NER/YOLO/OCR +FORENSIC_API_URL=http://:8080 # forensic +M17_WHISPER_URL=http://:54300/v1/audio/transcriptions # transcriere +M17_WEB_API_URL=http://:51100 # web search +DOMAIN_CHECK_API_URL=http://:11000/api/v1/check/check # domain check +``` + +Dacă Lotul 1 rulează pe **aceeași rețea Docker**, se pot folosi numele de container +(ex. `http://didiAI-extractors:54400`). Dacă e pe **alt host**, se pune IP-ul. + +După modificarea `.env`, se reconstruiește agent-v3: +```bash +cd backend/services/orchestration-layer/agent-v3 && docker compose up -d --build +``` + +--- + +# 6. Operare curentă + +## Resincronizarea parametrilor în Redis (după orice modificare de config) +```bash +docker exec didi-framework wget -qO- --post-data='' http://127.0.0.1:3005/api/sync-redis +``` + +## Reconstruirea unui serviciu +```bash +# agent-v3 + workeri +cd backend/services/orchestration-layer/agent-v3 && docker compose up -d --build +# framework +cd backend/services/orchestration-layer/didiFramework && docker compose up -d --build +``` + +## Scalarea workerilor +```bash +cd backend/services/orchestration-layer/agent-v3 +./scale-workers.sh status # replici + backlog live +./scale-workers.sh set worker-claims 4 # scalare manuală +./scale-workers.sh auto --apply # scalare pe metrici +``` + +## Loguri +```bash +docker logs -f didi-agent-v3 +docker logs -f didi-framework +``` + +## Rebuild bază de date (alt host / reset) +Vezi rețeta completă în `services/data-layer/didiDatabase/REBUILD.md`. + +--- + +# 7. Depanare + +| Simptom | Cauză probabilă | Soluție | +|---|---|---| +| Baza DIDI e goală după build | seed absent înainte de primul boot | pune seed-ul + șterge volumul `didi-postgres-data` + re-rulează | +| Analize eșuează / fără rezultat LLM | chei API lipsă sau Lot 1 inaccesibil | verifică `agent-v3/.env` (chei + URL-uri Lot 1) | +| `401` pe orice cerere prin Kong | token JWT lipsă/invalid | obține token de la Keycloak; rutele publice (waitlist, verify-email) sunt exceptate | +| Redis fără chei framework | sync-redis neexecutat | rulează comanda de sync (§6) | +| Worker „mort" tăcut | reconectare broker | workerii se re-abonează automat; verifică `docker logs` | + +--- + +*Documentul-pereche (operațional, cu inventar de scripturi): `backend/BUILD_AND_SCRIPTS.md`.* diff --git a/backend/docs/03_Raport_Testare_API_Lot2.docx b/backend/docs/03_Raport_Testare_API_Lot2.docx new file mode 100644 index 0000000..9a1650a Binary files /dev/null and b/backend/docs/03_Raport_Testare_API_Lot2.docx differ diff --git a/backend/docs/03_Raport_Testare_API_Lot2.md b/backend/docs/03_Raport_Testare_API_Lot2.md new file mode 100644 index 0000000..308e315 --- /dev/null +++ b/backend/docs/03_Raport_Testare_API_Lot2.md @@ -0,0 +1,136 @@ +% Raport de testare API — DiDi Lot 2 (Backend) +% PNRR DIGI150 · contract 11.1.i3.c9 +% Data testării: 2026-07-08 + +--- + +# 1. Obiect și scop + +Prezentul raport documentează testarea completă a interfețelor de programare (API) ale +**Lotului 2 — Backend**, respectiv cele două servicii software livrate: + +- **Agent V3** (motor de analiză, port 24803); +- **didiFramework** (management parametri, port 3005). + +Testarea acoperă **strict Lotul 2**. Serviciile de inteligență artificială (Lotul 1) sunt +testate separat, prin testele de integrare rulate pe mediul unde este instalat Lotul 1. + +Raportul constituie dovada pentru criteriul 6 din caietul de sarcini („Implementare & +transfer": OpenAPI/Swagger + set minim de teste API) și însoțește specificațiile +`openapi.yaml` ale celor două servicii. + +--- + +# 2. Metodologie + +1. **Inventar din codul sursă** — toate definițiile de rute au fost extrase automat din cod + (nu din documentație), cu un script reproductibil. S-a verificat separat absența rutelor + definite dinamic, a mount-urilor cu prefix nescanate și a generatoarelor CRUD active — + toate verificările au ieșit goale, deci inventarul static este complet. +2. **Probă live** — fiecare endpoint a fost apelat pe mediul de testare, cu token JWT real + emis de Keycloak. Strategie **non-distructivă**: cererile GET au fost apelate real; cererile + POST/PUT/PATCH/DELETE au fost apelate cu identificatori inexistenți sau body gol (răspunsul + 400/404 dovedește că ruta este cablată, fără a modifica date). Endpoint-urile idempotente + (dry-run, sync-redis, check-credits) au fost rulate real; cele cu efect mutativ real au + fost verificate manual și consemnate separat. +3. **Verificare de integritate** — s-a capturat un instantaneu al bazei de date (7 contoare) + înainte și după rularea completă: **identic**, confirmând că testarea nu a alterat datele. + +--- + +# 3. Rezultate + +| Metric | Valoare | +|---|---| +| Endpoint-uri inventariate | **374** | +| — Agent V3 | 87 | +| — didiFramework | 287 | +| Endpoint-uri cablate (răspund cu handler propriu) | **374 / 374** | +| Rute moarte (`Cannot GET/POST …`) | **0** | + +**Distribuția codurilor de răspuns:** + +| Cod HTTP | Nr. | Semnificație | +|---|---|---| +| 200 | 135 | răspuns corect | +| 400 | 102 | validare de input (comportament corect pe body/ID de test) | +| 401 | 4 | necesită autentificare (extension API-key) — corect | +| 403 | 2 | necesită rol (moderare) — separare de roluri funcțională | +| 404 | 118 | resursă inexistentă (ID de test) — comportament corect | +| 409 | 9 | conflict (ex. duplicat) — comportament corect | +| 500 | 4 | vezi §5 (probleme cunoscute, neblocante) | + +Toate codurile 401/403 reflectă **comportament de securitate corect**, nu erori: rutele de +extensie cer `X-API-Key`; operațiile de moderare (claim/resolve) cer rol +`moderator`/`senior_moderator`, pe care contul de test (admin) nu îl deține. + +--- + +# 4. Corectări efectuate în urma testării + +Testarea a identificat 4 defecte reale (toate de tip ordonare de rute / validare de input), +reparate și re-testate: + +| Endpoint | Defect | Corecție | +|---|---|---| +| `GET /api/validation-rules/stats` | 500 — umbrit de ruta `/:id` declarată înainte | reordonare rute | +| `DELETE /api/indicators/by-technique/:techniqueId` | inaccesibil — umbrit de `/:techniqueId/:indicatorId` | reordonare rute | +| `POST /api/sync-analysis/batch` | umbrit de `/:sessionId` | reordonare rute | +| `GET /api/weights/multipliers/type/:type` | 500 pe input non-numeric | validare → 400 | + +Suplimentar, s-a rulat un scan sistematic de umbriri de rute pe ambele servicii — **zero +umbriri rămase**. + +--- + +# 5. Verificări de securitate + +| Verificare | Rezultat | +|---|---| +| Cerere fără token JWT pe rută protejată (prin gateway) | **401** | +| Cerere cu token JWT forjat | **401** | +| Cerere cu token JWT valid | **200** | +| Enforcement JWT la gateway — Agent V3 | **DA** | +| Enforcement JWT la gateway — didiFramework | **DA** | +| Verificare criptografică JWT în backend (apărare în adâncime) | **DA** (ambele servicii) | +| Separare de roluri (RBAC pe moderare) | **DA** (403 fără rol) | + +--- + +# 6. Probleme cunoscute (neblocante) + +1. **`/api/waitlist/*` → 500** — funcționalitatea de listă de așteptare depinde de o bază de + date de staging separată, neprezentă în deployment-ul standard. Funcție marginală + (înscriere pre-lansare), marcată `deprecated` în specificația OpenAPI. +2. **`GET /api/v3/pipeline/history/admin/:id`** — răspunde 500 (în loc de 400) când + identificatorul nu are format UUID; cu UUID valid răspunde corect. Diferență cosmetică de + validare. +3. Endpoint-uri legacy marcate `deprecated` în OpenAPI: `domain/*` (înlocuit de + source-assessment), `sync-analysis/*` (persistență directă în PostgreSQL), `prompts/*` pe + fișiere (sursa operațională este baza de date). + +--- + +# 7. Artefacte și reproducere + +| Artefact | Locație | +|---|---| +| Specificație OpenAPI 3.0.3 — Agent V3 (87 operații) | `services/orchestration-layer/agent-v3/openapi.yaml` | +| Specificație OpenAPI 3.0.3 — didiFramework (287 operații) | `services/orchestration-layer/didiFramework/openapi.yaml` | +| Script de probă (reproductibil) | `scripts/api/api_probe.py` | +| Generator de specificație | `scripts/api/generate_openapi.py` | +| Rezultate brute ale probei | `scripts/api/probe_results_2026-07-08.json` | +| Interfață Swagger UI | container `didi-api-docs` (ambele specificații) | + +Ambele specificații OpenAPI au fost **validate** cu `openapi-spec-validator` (rezultat: OK). +Fiecare operație poartă adnotarea `x-tested` cu codul HTTP observat la probă. + +--- + +# 8. Concluzie + +Toate cele **374 de endpoint-uri** ale Lotului 2 sunt cablate și funcționale (374/374), fără +rute moarte. Securitatea (autentificare JWT la gateway și în backend, autorizare pe roluri) +este verificată și funcțională. Cele 4 defecte identificate au fost corectate. Problemele +rămase sunt marginale și documentate. Pachetul de API al Lotului 2 este **conform și +verificabil**. diff --git a/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.docx b/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.docx new file mode 100644 index 0000000..d58cadd Binary files /dev/null and b/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.docx differ diff --git a/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.md b/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.md new file mode 100644 index 0000000..dcbc3a9 --- /dev/null +++ b/backend/docs/04_Raport_Testare_Integrare_Lot1-Lot2.md @@ -0,0 +1,198 @@ +% Raport de testare a integrării — DiDi Lot 1 (AI) ↔ Lot 2 (Backend) +% PNRR DIGI150 · contract 11.1.i3.c9 +% Data testării: 2026-07-09 + +--- + +# 1. Obiect și scop + +Prezentul raport documentează **testele de integrare** dintre **Lotul 2 — Backend** +(motorul de analiză `agent-v3` + `didiFramework` + workeri) și **Lotul 1 — Platforma AI** +(modelele LLM, deepfake, transcriere, extractoare, forensic, brain/RAG, domain-check). + +Raportul de testare a API-ului Lotului 2 (`03_Raport_Testare_API_Lot2`) acoperă **strict +Lotul 2** și menționează explicit că serviciile AI (Lotul 1) „sunt testate separat, prin +testele de integrare". Documentul de față **este acea probă**: demonstrează pe mediul live +că cele două loturi **colaborează** — de la conectivitate punct-la-punct până la producerea +unui verdict complet pe fiecare tip de conținut, plus proprietățile de reziliență și +securitate ale integrării. + +Testarea confirmă cerințele din caietul de sarcini privind consumul de **extractoare +specializate** (deepfake, NER, OCR, Whisper, YOLO), **modulul web-crawl/evidence**, **scorul +de credibilitate a sursei** și **orchestrarea fluxurilor ML end-to-end** prin backend. + +--- + +# 2. Metodologie + +Testarea a fost efectuată pe platforma live (``, 2× H200), **non-distructiv**, +printr-un harness reproductibil (`scripts/integration/run-integration-tests.sh`) structurat +pe **trei niveluri**: + +- **Nivel A — Conectivitate & contract.** Din interiorul containerului `agent-v3` + (consumatorul real), fiecare serviciu Lot 1 este sondat prin `didi-network`, pe nume de + container (nu IP). Dovedește că boundary-ul de rețea și contractul HTTP funcționează. +- **Nivel B — End-to-end pe tip de conținut.** Pentru fiecare din cele 5 tipuri (text, URL, + imagine, audio, video) se lansează o analiză reală prin `POST /api/v3/pipeline/analyze-async` + și se așteaptă verdictul persistat. Dovedește orchestrarea completă Lot2→Lot1→verdict. +- **Nivel C — Proprietăți transversale.** Fail-open (degradare grațioasă la indisponibilitatea + unui serviciu AI), rutarea către modelul LLM local (fără fallback plătit) și enforcement-ul + gateway-ului peste lanțul AI. + +Fiecare test capturează **trei artefacte independente**: (1) request-ul emis de `agent-v3`, +(2) **access-log-ul serviciului Lot 1** care dovedește primirea cererii, (3) **verdictul +persistat în PostgreSQL** (`bos_analysis.analysis_session`). Toate artefactele brute sunt +salvate în `scripts/integration/results/evidence_/`. + +Utilizatorul de test are credite alocate; strategia de dispatch este cea de producție (cozi +RabbitMQ + workeri Docker + agregator de verdict). + +--- + +# 3. Rezultate + +| Metric | Valoare | +|---|---| +| Teste de integrare rulate | **17** | +| — Nivel A (conectivitate) | 9 | +| — Nivel B (end-to-end) | 5 | +| — Nivel C (transversale) | 3 | +| **PASS** | **16** | +| **DEGRADED** (fail-open, comportament corect) | **1** | +| **FAIL** | **0** | +| Defecte identificate și remediate | 1 (Lot 1 — vezi §4) | + +## 3.1 Nivel A — Conectivitate agent-v3 → servicii Lot 1 + +Toate cele 9 integrări din documentația de arhitectură (§9) răspund din chiar consumatorul +`agent-v3`, pe `didi-network`: + +| # | Serviciu Lot 1 | Adresă (didi-network) | Cerință acoperită | Rezultat | +|---|---|---|---|---| +| A1 | LLM text (Qwen 3.5) | `llm-api:14011` | flux LLM / ML | **PASS** (HTTP 200, model `qwen3.5` încărcat) | +| A2 | LLM vision / OCR | `llm-api:14011` | extractor OCR | **PASS** (`qwen3.5` vision-capable) | +| A3 | Whisper transcriere | `audio-api:54300` | extractor Whisper | **PASS** (HTTP 200) | +| A4 | BusterX deepfake | `video-api:54600` | extractor deepfake | **PASS** (HTTP 200) | +| A5 | Extractoare | `extractors:54400` | NER/YOLO/OCR/EXIF | **PASS** (HTTP 200) | +| A6 | Forensic media | `forensic:8080` | analiză forensică | **PASS** (HTTP 200) | +| A7 | Web / evidence | `web-api:51100` | modul web-crawl | **PASS** (HTTP 200) | +| A8 | Brain / RAG | `brain-api:8090` | flux ML fact-check | **PASS** (HTTP 200) | +| A9 | Domain-check (T4) | `domain-check-api:11000` | scor credibilitate sursă | **PASS** (`POST /check/check` → `risk_score` real) | + +## 3.2 Nivel B — Analize end-to-end (pipeline complet → verdict) + +Fiecare analiză a rulat prin cozile RabbitMQ și workerii de producție, verdictul fiind +persistat în PostgreSQL. Coloana „Dovada Lot 1" citează access-log-ul serviciului apelat. + +| # | Input | Lanț Lot 1 | Verdict (scor/categorie) | Dovada Lot 1 | Rezultat | +|---|---|---|---|---|---| +| B1 | Text dezinformare (microcipuri 5G) | llm-api | **DISINFORMATION / 92** | `POST /v1/chat/completions 200` | **PASS** | +| B2 | URL (bbc.com/news) | web + domain-check | **RELIABLE / 7** | `POST /api/v1/check/check 200` | **PASS** | +| B3 | Imagine (titlu fals) | vision-OCR + extractoare | **DISINFORMATION / 95** | `POST /v1/chat/completions 200` (OCR a citit titlul) | **PASS** | +| B4 | Audio (discurs) | Whisper → llm | **RELIABLE / 0** | `POST /v1/audio/transcriptions 200` | **PASS** | +| B5 | Video | media-preprocess → BusterX | **RELIABLE / 5** | `POST /analyze/video 200` | **PASS** | + +> Nota B3: OCR-ul local (Qwen vision) a extras corect textul titlului fabricat din imagine, +> iar pipeline-ul l-a clasificat DISINFORMATION — dovadă directă a lanțului vision→verdict. +> Nota B5: verdict obținut după remedierea defectului D1 (vezi §4); BusterX a răspuns `200`. + +## 3.3 Nivel C — Proprietăți transversale + +| # | Test | Cerință | Rezultat | +|---|---|---|---| +| C1 | **Fail-open**: `didiAI-web-api` oprit temporar, analiză lansată | reziliență / fail-open servicii AI | **DEGRADED** — analiza s-a **terminat** cu degradare grațioasă (`status=completed`), fără eșec; serviciul a fost repornit | +| C2 | **Rutare model local**: analiză text | flux LLM local, fără fallback plătit | **PASS** — 5 apeluri către `llm-api:14011`, provider `qwen35` local; niciun apel OpenRouter plătit | +| C3 | **Gateway**: `POST /pipeline/analyze-async` prin Kong fără token | API Gateway + securitate | **PASS** — Kong răspunde **401**, păzind lanțul AI | + +Rezultatul **DEGRADED** la C1 este comportamentul **corect și dorit**: afirmația de +reziliență din documentația de arhitectură (§6, „fail-open pe servicii AI") este validată +empiric — indisponibilitatea unui serviciu Lot 1 nu blochează analiza. + +--- + +# 4. Defecte identificate și remediate + +Testarea de integrare a identificat **1 defect real** (în Lotul 1), remediat și re-testat: + +| # | Severitate | Serviciu | Defect | Cauză rădăcină | Remediere | Verificare | +|---|---|---|---|---|---|---| +| **D1** | major | `didiAI-video-api` (BusterX), Lot 1 | `POST /analyze/video` → **500**; detecția deepfake nu rula (mascată de fail-open, care producea totuși verdict) | Bind-mount **stale** pe `/app/runs`: directorul host `local_gpu_stack/runs` fusese recreat după pornirea containerului → inode container (`6195429`) ≠ inode host (`6195434`) → `os.makedirs('/app/runs/')` eșua cu `FileNotFoundError` | `docker restart didiAI-video-api` (re-rezolvă bind-mount-ul la inode-ul curent) | Inode host == container (`6195434`); test de scriere OK; `POST /analyze/video` → **200**; verdict video RELIABLE/5 | + +Observație importantă: defectul era **mascat** de mecanismul fail-open — analiza video se +finaliza cu verdict, dar BusterX nu se executa efectiv. Doar inspecția access-log-ului +serviciului Lot 1 (parte din metodologia acestui raport) a expus problema. + +## 4.1 Măsuri de robustețe adăugate (ca defectul să nu se repete și să nu mai fie tăcut) + +Pe lângă remedierea imediată, au fost adăugate două măsuri durabile: + +1. **Healthcheck de scriere pe `video-api` (Lot 1).** Containerul `didiAI-video-api` a primit + un healthcheck care **verifică efectiv scrierea în `/app/runs`** (nu doar `GET /health`). + Un bind-mount stale îl face imediat `unhealthy` (vizibil în `docker ps` + dashboard), în loc + să producă un `500` tăcut. `local_gpu_stack/docker-compose.yml`. + +2. **Degradare vizibilă în verdict (Lot 2).** Când BusterX era activat dar nu a returnat + rezultat, `media-preprocess-worker` scrie acum o santinelă `UNAVAILABLE`, iar + `video-2-track.ts` o **semnalează explicit** în verdictul persistat: indicator + `VID.2 „Verificare deepfake INDISPONIBILĂ — recomandată verificare manuală"` + + `coupling_context.for_verdict.deepfake_check = "unavailable"` + `needs_manual_review = true`. + Astfel un eșec al detecției deepfake **nu mai poate fi confundat** cu o verificare reușită. + + Verificat controlat (2 rulări video pe același clip): + + | Scenariu | `deepfake_check` | `needs_manual_review` | Indicator VID.2 | + |---|---|---|---| + | BusterX activ | *(absent)* | true | „BusterX deepfake detection: REAL" | + | BusterX oprit | **`unavailable`** | true | „Verificare deepfake INDISPONIBILĂ — verificare manuală" | + + Fișiere: `agent-v3/src/queue/workers/media-preprocess-worker.ts`, + `agent-v3/src/queue/workers/component-worker-helpers/video-2-track.ts`. + +--- + +# 5. Reflectarea integrării în specificația OpenAPI (Swagger) + +Integrarea cu Lotul 1 constă în dependențe **outbound** ale `agent-v3` (apeluri HTTP către +servicii AI), nu în rute expuse de backend — prin urmare **nu** apare ca `paths` în +specificația OpenAPI. A fost documentată, în schimb, prin două mecanisme: + +1. **Endpoint de sondă de integrare** — `GET /api/v3/health/all` a fost extins să verifice + **toate cele 9 servicii Lot 1** (pe lângă infrastructura internă), *fail-open*: o + dependență AI indisponibilă produce `degraded` (HTTP 200), nu `unhealthy`. Endpoint-ul + este documentat în `openapi.yaml` prin schema `DeepHealthResponse` și servește simultan + ca **test de integrare live**, rulabil oricând. +2. **Bloc `x-integrations`** la nivel de specificație — listează cele 9 servicii Lot 1 + consumate (cheie, variabilă de mediu, țintă pe `didi-network`, rol), contractul fail-open + și sonda de health. Extensie OpenAPI validă, informativă. + +Ambele au fost validate cu `openapi-spec-validator` (rezultat: **VALID**, OpenAPI 3.0.3). +Verificare live a sondei extinse: 14 dependențe (4 interne + 10 AI), toate `healthy`. + +--- + +# 6. Artefacte și reproducere + +| Artefact | Locație | +|---|---| +| Harness de testare (reproductibil) | `scripts/integration/run-integration-tests.sh` | +| Fixturi (imagine, audio, video) | `scripts/integration/fixtures/` | +| Rezultate structurate (JSON) | `scripts/integration/results/results_2026-07-09.json` | +| Dovezi brute (access-log Lot 1, verdicte PG, health/all) | `scripts/integration/results/evidence_20260709_224124/` | +| Sondă de integrare + schema OpenAPI | `agent-v3/openapi.yaml` (`x-integrations`, `DeepHealthResponse`) | +| Tabel integrări Lot 1 | `docs/01_Arhitectura_Lot2.md` §9 | + +Rulare: `bash scripts/integration/run-integration-tests.sh` (variabile opționale: +`AGENT_URL`, `TEST_USER`, `KONG_URL`). + +--- + +# 7. Concluzie + +Integrarea dintre **Lotul 2 (Backend)** și **Lotul 1 (Platforma AI)** este **funcțională și +verificată end-to-end** pe mediul live: **16/17 teste PASS**, 1 DEGRADED (comportament +fail-open corect), **0 FAIL**. Toate cele 9 servicii AI sunt raggiunse și consumate corect; +cele 5 tipuri de conținut produc verdicte complete; reziliența (fail-open), rutarea către +modelul local și enforcement-ul gateway-ului sunt confirmate. Singurul defect identificat +(D1 — bind-mount stale pe BusterX) a fost remediat și re-testat cu succes. Integrarea +Lot1↔Lot2 este **conformă și verificabilă**, iar starea ei live este observabilă permanent +prin `GET /api/v3/health/all`. diff --git a/backend/docs/05_Ghid_Utilizare_Lot2.docx b/backend/docs/05_Ghid_Utilizare_Lot2.docx new file mode 100644 index 0000000..4b775a4 Binary files /dev/null and b/backend/docs/05_Ghid_Utilizare_Lot2.docx differ diff --git a/backend/docs/05_Ghid_Utilizare_Lot2.md b/backend/docs/05_Ghid_Utilizare_Lot2.md new file mode 100644 index 0000000..c24b53e --- /dev/null +++ b/backend/docs/05_Ghid_Utilizare_Lot2.md @@ -0,0 +1,296 @@ +% Ghid de utilizare — DiDi Lot 2 (Backend) +% Platformă digitală inteligentă pentru prevenirea și combaterea dezinformării +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# 1. Scop și context + +Prezentul ghid prezintă utilizarea **Dashboard-ului administrativ** al Lotului 2 — interfața +prin care operatorul configurează, rulează, monitorizează și moderează platforma DiDi de +detecție a dezinformării. Capturile de ecran provin din **platforma reală, în funcțiune** +(`https://:3001/admin`). + +Fiecare secțiune este corelată cu **modulul corespondent din Propunerea Tehnică Lot 2** și cu +cerințele caietului de sarcini, astfel încât ghidul servește simultan ca **manual de operare** +și ca **dovadă de conformitate funcțională**. + +| Zonă interfață | Modul ofertă | Rol | +|---|---|---| +| Autentificare | Modulul 7 | Login OIDC/Keycloak, roluri RBAC | +| Service Monitor | Modulele 6.8, 8 | Sănătatea serviciilor + observabilitate | +| Framework | Modulele 1.3, 6.5 | Parametrizarea completă a detecției | +| LLM Components / Providers | Modulele 1.7, 2.5 | Modele LLM, chain-uri, chei API | +| Pipelines | Modulele 1.2, 6.3, 6.4 | Definire, versionare, dry-run, rulare | +| Cozi | Modulele 3, 6.4 | Broker de mesaje, procesare async | +| Istoric analize | Modulele 2.6, 6.7 | Rezultate, verdicte, trasabilitate | +| Moderare | Modulul 6 | Coadă HIL, triaj, revizuire umană | +| Utilizatori | Modulele 6.6, 5.3 | Conturi, roluri, abonamente, credite | + +--- + +# 2. Autentificare (Modulul 7) + +Accesul la dashboard se face prin **Keycloak** (OIDC/OAuth2 + PKCE), realm `didi-admins`. +Autentificarea emite un token JWT RS256 care este verificat atât la gateway (Kong), cât și în +backend. Rolul din token (`admin`, `moderator`, `senior_moderator`) determină ce operații sunt +permise. + +![Ecran de autentificare Keycloak (realm didi-admins)](<../teste livrare/login keyckloak backend.png>){width=6in} + +--- + +# 3. Monitorizarea serviciilor — Service Monitor (Modulele 6.8, 8) + +Ecranul principal grupează toate serviciile platformei pe straturi (Data Layer, Gateway & Auth, +Orchestration, Monitoring) și afișează starea fiecăruia în timp real. Starea serviciilor locale +este derivată din **starea containerelor Docker**, nu dintr-un simplu ping — deci reflectă +sănătatea reală (`healthy` / `unhealthy`). Butonul **Open UI** deschide consola nativă a +serviciului (MinIO, RabbitMQ etc.). + +![Service Monitor — stratul de date (PostgreSQL, Redis, RabbitMQ, MinIO), toate HEALTHY](<../teste livrare/dashboard_1.png>){width=6.5in} + +![Service Monitor — stratul de orchestrare și gateway/autentificare](<../teste livrare/dashboard_2.png>){width=6.5in} + +![Service Monitor — stratul de observabilitate (Prometheus, Grafana, Loki, Jaeger, Alertmanager)](<../teste livrare/dashboard_3.png>){width=6.5in} + +--- + +# 4. Framework — parametrizarea detecției (Modulele 1.3, 6.5) + +Principiul central al platformei este **separarea configurării de execuție**: toți parametrii de +analiză sunt gestionați declarativ din acest ecran, stocați în PostgreSQL și sincronizați în +Redis prin butonul **Sync to Redis**. Motorul de analiză citește configurarea din Redis la +fiecare rulare — comportamentul se modifică **fără redeploy de cod**. + +Pagina afișează sumarul (8 dimensiuni, 166 tehnici, 7 verdicte, 6 niveluri de risc, 12 tipuri de +sursă, 11 platforme) și grupează parametrii pe categorii, prin tab-uri. Butoanele **Analysis +Flow**, **Run Test Pipeline** și **Sync to Redis** permit vizualizarea fluxului, testarea și +publicarea configurării. + +![DIDI Framework — vedere de ansamblu: dimensiunile de analiză (D1–D8) cu ponderi editabile](<../teste livrare/framework_!.png>){width=6.5in} + +## 4.1 Tehnici de manipulare (dimensiuni → subdimensiuni → tehnici → indicatori) + +Ierarhia de 166 de tehnici organizate pe 8 dimensiuni. Fiecare tehnică are indicatori, reguli de +validare, ponderi și o alocare de model LLM per etapă. + +![Catalogul tehnicilor de manipulare](<../teste livrare/manipulation_techniques.png>){width=6.5in} + +![Parametrii unei tehnici de manipulare](<../teste livrare/manipulation parameters.png>){width=6.5in} + +![Indicatorii asociați tehnicilor de manipulare](<../teste livrare/manipulationtechniques indicators.png>){width=6.5in} + +![Regulile de validare pentru tehnici](<../teste livrare/manipulation_validation.png>){width=6.5in} + +![Alocarea modelelor LLM pe etapele componentei Techniques (screening → deep)](<../teste livrare/manipulation asignation.png>){width=6.5in} + +## 4.2 Conținut generat de AI (AI-Tampered) + +Parametrii componentei care detectează conținut generat/modificat de AI (text, imagine, video) +și alocarea modelelor pe etape. + +![Parametrii componentei AI-Tampered](<../teste livrare/ai tamper parameters.png>){width=6.5in} + +![Alocarea modelelor LLM pentru AI-Tampered](<../teste livrare/ai tamper asignation.png>){width=6.5in} + +## 4.3 Afirmații verificabile (Claims) + +Extragerea și verificarea afirmațiilor prin căutare web, tipurile de claim și nivelurile de +încredere. + +![Parametrii componentei Claims](<../teste livrare/claims parameters.png>){width=6.5in} + +![Tipuri de claim configurabile](<../teste livrare/claim types.png>){width=6.5in} + +![Taxonomia tipurilor de claim](<../teste livrare/tipuri claims.png>){width=6.5in} + +![Niveluri de încredere pentru claims](<../teste livrare/niveluri confidence claims.png>){width=6.5in} + +![Alocarea modelelor LLM pentru Claims](<../teste livrare/claims asignation.png>){width=6.5in} + +## 4.4 Evaluarea sursei (Source Assessment) + +Credibilitatea sursei/domeniului: clasificarea și credibilitatea autorului, vechimea domeniului, +modificatorii de platformă și alocarea modelelor. + +![Parametrii componentei Source Assessment](<../teste livrare/source assesment parameters.png>){width=6.5in} + +![Credibilitatea sursei](<../teste livrare/source credibility.png>){width=6.5in} + +![Disponibilitatea / evaluarea sursei](<../teste livrare/source aval_.png>){width=6.5in} + +![Influența vechimii domeniului asupra scorului](<../teste livrare/source domain age.png>){width=6.5in} + +![Clasificarea autorului](<../teste livrare/clasificari autor.png>){width=6.5in} + +![Credibilitatea autorului](<../teste livrare/credibilitate autor.png>){width=6.5in} + +![Modificatori de scor per platformă](<../teste livrare/surce modificatos platform.png>){width=6.5in} + +![Alocarea modelelor LLM pentru Source Assessment](<../teste livrare/source assesment asignation.png>){width=6.5in} + +## 4.5 Analiza domeniului + +Scorul de risc al domeniului și semnalele de alarmă (red flags) — corespondentul frontend al +integrării cu serviciul domain-check (T4): WHOIS, DNS, SSL, blacklist. + +![Scorul de risc al domeniului](<../teste livrare/domain risk.png>){width=6.5in} + +![Semnale de alarmă (red flags) pentru domeniu](<../teste livrare/red flags domeniu.png>){width=6.5in} + +## 4.6 Verdicte, scoruri și interpretări + +Categoriile de verdict, nivelurile de risc, pragurile de severitate, maparea scorului și regulile +de interpretare care transformă scorurile componentelor într-un verdict final. + +![Categorii de verdict](<../teste livrare/categorii verdict.png>){width=6.5in} + +![Categoriile de verdict (detaliu)](<../teste livrare/verdict categories.png>){width=6.5in} + +![Niveluri de risc ale verdictului](<../teste livrare/verdict risk levels.png>){width=6.5in} + +![Evaluarea severității](<../teste livrare/eval severitate.png>){width=6.5in} + +![Severitatea verdictului](<../teste livrare/verdict severity.png>){width=6.5in} + +![Nivelurile de încredere ale verdictului](<../teste livrare/verdict confidence.png>){width=6.5in} + +![Reguli de interpretare](<../teste livrare/niveluri interpretari.png>){width=6.5in} + +![Maparea scorului de risc](<../teste livrare/mapari risk.png>){width=6.5in} + +![Suprascrieri și sinergii între componente (overrides & synergy)](<../teste livrare/verdifcts overrides & sineryg.png>){width=6.5in} + +## 4.7 Ponderi și profiluri de pipeline + +Ponderile componentelor, multiplicatorii, scenariile de ponderare și profilurile de verdict per +tip de conținut (text, URL, imagine, audio, video). + +![Ponderile componentelor în verdictul final](<../teste livrare/ponderi componente.png>){width=6.5in} + +![Ponderile verdictului](<../teste livrare/verdict weighs.png>){width=6.5in} + +![Multiplicatori de scor](<../teste livrare/multiplicatori.png>){width=6.5in} + +![Multiplicatorii verdictului](<../teste livrare/verdict multipliers.png>){width=6.5in} + +![Scenarii de ponderare](<../teste livrare/scenarii pondere.png>){width=6.5in} + +![Profiluri de verdict per tip de input](<../teste livrare/final verdict input types profiles.png>){width=6.5in} + +--- + +# 5. Modele LLM și provideri (Modulele 1.7, 2.5) + +Platforma folosește **lanțuri de modele** (primar + fallback-uri) per componentă și etapă. +Providerii (local Qwen 3.5, OpenRouter etc.) și cheile API se gestionează din acest ecran, iar +modelul primar folosit este cel **local, servit de Lotul 1** (fără costuri per-token pe calea +principală). + +![Providerii LLM configurați](<../teste livrare/llm providers.png>){width=6.5in} + +![Managementul providerilor (1)](<../teste livrare/providers1.png>){width=6.5in} + +![Managementul providerilor (2) — modele și costuri](<../teste livrare/providers2.png>){width=6.5in} + +--- + +# 6. Pipelines — definire, versionare, rulare (Modulele 1.2, 6.3, 6.4) + +Fluxul de analiză este modelat ca **workflow cu dependențe explicite**. Fiecare tip de conținut +are un profil de pipeline propriu. Editorul permite definirea nodurilor și dependențelor, +versionarea, iar consola de rulare permite testarea și **dry-run** (rezolvarea întregului plan — +noduri, cozi, lanțuri de modele — fără a consuma resurse). + +![Editor de pipeline (1)](<../teste livrare/pipelines1.png>){width=6.5in} + +![Editor de pipeline (2)](<../teste livrare/pipelies2.png>){width=6.5in} + +![Editor de pipeline (3)](<../teste livrare/pipeline3.png>){width=6.5in} + +![Pipeline pentru analiza de text](<../teste livrare/textpipeline1.png>){width=6.5in} + +--- + +# 7. Cozi de procesare — Broker de mesaje (Modulele 3, 6.4) + +Analizele asincrone sunt dispecerizate pe cozi RabbitMQ (componente × planuri de prioritate + +media-preprocess + results + DLQ). Ecranul afișează starea cozilor, adâncimea și workerii activi. + +![Starea cozilor de procesare (1)](<../teste livrare/queue1.png>){width=6.5in} + +![Starea cozilor de procesare (2)](<../teste livrare/queue2.png>){width=6.5in} + +![Starea cozilor de procesare (3)](<../teste livrare/queue3.png>){width=6.5in} + +--- + +# 8. Istoric analize și citirea unui verdict (Modulele 2.6, 6.7) + +Toate analizele sunt persistate și consultabile. Ecranul de detaliu al unei analize arată: +verdictul final cu scor (0–100) și categorie, rezultatul fiecărei componente, afirmațiile +verificate cu sursele care le confirmă/contrazic, căutările web efectuate și modelele folosite — +**trasabilitate completă**. + +![Istoric analize — listă](<../teste livrare/analysis history.png>){width=6.5in} + +![Detaliu analiză — verdict DISINFORMATION (risc 100), afirmații verificate + surse care contrazic](<../teste livrare/analysis history5.png>){width=6.5in} + +![Detaliu analiză (2)](<../teste livrare/analysis history2.png>){width=6.5in} + +![Detaliu analiză (3)](<../teste livrare/analysis history3.png>){width=6.5in} + +![Detaliu analiză (4)](<../teste livrare/analysis history4.png>){width=6.5in} + +![Detaliu analiză (6)](<../teste livrare/analysis history6.png>){width=6.5in} + +![Detaliu analiză (7)](<../teste livrare/analysis history7.png>){width=6.5in} + +--- + +# 9. Moderare umană (HIL) (Modulul 6) + +Sesiunile care îndeplinesc criteriile de triaj (prag de încredere, zonă gri de risc, subiecte +sensibile) intră într-o **coadă de moderare umană**. Operatorii cu rol `moderator` / +`senior_moderator` revizuiesc și corectează verdictele. Setările de triaj și clientul de brain +(cache de fact-checking) se configurează din **Moderation Settings** și se sincronizează în Redis. + +![Setări de moderare — reguli de triaj + client brain](<../teste livrare/mode3ration1.png>){width=6.5in} + +![Coada de moderare — revizuirea sesiunilor](<../teste livrare/moderation2.png>){width=6.5in} + +--- + +# 10. Utilizatori și abonamente (Modulele 6.6, 5.3) + +Managementul conturilor (sincronizate cu Keycloak), al rolurilor, al abonamentelor și al +creditelor. Planurile de abonament determină prioritatea în cozi și lanțul de modele (free / +premium). + +![Management utilizatori — conturi și roluri](<../teste livrare/user management .png>){width=6.5in} + +![Planuri de abonament și credite](<../teste livrare/subscription plans.png>){width=6.5in} + +--- + +# 11. Rezumatul conformității funcționale + +Ghidul de față demonstrează, pe capturi din platforma reală, acoperirea funcțională a modulelor +din Propunerea Tehnică Lot 2: + +| Modul ofertă | Funcționalitate demonstrată | Secțiune | +|---|---|---| +| M1 — Orchestrator | Parametrizare, pipelines, dry-run, catalog AI | §4, §5, §6 | +| M2 — Analiză | Executori (techniques, ai-tampered, claims, source, domain), agregare verdict | §4, §8 | +| M3 — Broker mesaje | Cozi de procesare async, priorități | §7 | +| M4 — API Gateway | Autentificare la gateway, RBAC | §2 | +| M5 — Baze de date | Persistare, scheme (rezultate, config, useri, abonamente) | §8, §10 | +| M6 — Dashboard | Toată interfața administrativă (config, run, monitor, moderare, useri) | §3–§10 | +| M7 — Autentificare | Login OIDC/Keycloak, roluri | §2 | +| M8 — Observabilitate | Stratul de monitorizare în Service Monitor | §3 | +| M9 — Containerizare/CI-CD | Servicii containerizate vizibile în Service Monitor | §3 | + +Documentul se completează cu: `01_Arhitectura_Lot2` (arhitectură), `02_Ghid_Instalare_Operare` +(instalare/operare), `03_Raport_Testare_API` (test API), `04_Raport_Testare_Integrare_Lot1-Lot2` +(test integrare) și specificațiile OpenAPI ale celor două servicii. diff --git a/backend/docs/06_Matrice_Trasabilitate_Cerinte.docx b/backend/docs/06_Matrice_Trasabilitate_Cerinte.docx new file mode 100644 index 0000000..1981f53 Binary files /dev/null and b/backend/docs/06_Matrice_Trasabilitate_Cerinte.docx differ diff --git a/backend/docs/06_Matrice_Trasabilitate_Cerinte.md b/backend/docs/06_Matrice_Trasabilitate_Cerinte.md new file mode 100644 index 0000000..3be3269 --- /dev/null +++ b/backend/docs/06_Matrice_Trasabilitate_Cerinte.md @@ -0,0 +1,93 @@ +% Matrice de trasabilitate a cerințelor — DiDi Lot 2 (Backend) +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# 1. Scop + +Prezentul document asigură **trasabilitatea** dintre cerințele contractuale (caietul de sarcini ++ Propunerea Tehnică Lot 2) și livrabilele efective. Pentru fiecare modul/cerință se indică +**dovada** (document, endpoint, ecran, test) prin care se poate verifica îndeplinirea. + +Legendă dovezi: +`ARH`=01_Arhitectura · `INST`=02_Ghid_Instalare_Operare · `API`=03_Raport_Testare_API · +`INT`=04_Raport_Testare_Integrare · `GHID`=05_Ghid_Utilizare (cu capturi) · +`OAPI`=openapi.yaml (Swagger) · `COD`=cod sursă · `LIVE`=platformă în funcțiune. + +--- + +# 2. Trasabilitate pe modulele Propunerii Tehnice Lot 2 + +| # | Modul / cerință ofertă | Stare | Dovadă | +|---|---|---|---| +| **M1** | **Orchestrator** — motor de analiză, dispatch | ✅ | ARH §3.1, COD `agent-v3` | +| M1.2 | Definire și modelare pipeline-uri (workflow cu dependențe) | ✅ | GHID §6, ARH §4.3 | +| M1.3 | Configurare și parametrizare (declarativ, sync Redis) | ✅ | GHID §4, LIVE Framework | +| M1.4 | Execuție sincronă | ✅ | API `/pipeline/analyze`, INT B1/B3 | +| M1.5 | Execuție asincronă (202 + poll) | ✅ | API `/pipeline/analyze-async`, INT B1–B5 | +| M1.6 | Simulare / dry-run | ✅ | OAPI `/pipeline/dry-run`, GHID §6 | +| M1.7 | Catalog resurse AI (servicii Lot 1) | ✅ | INT §3.1 (9/9), GHID §5 | +| M1.8 | Reluare execuții (resume/checkpoint) | ✅ | COD `dispatcher.resumeDispatch`, ARH §6 | +| M1.9 | Monitorizare și trasabilitate | ✅ | GHID §8, `/health/all` | +| M1.10 | API-uri expuse | ✅ | API (87 op.), OAPI agent-v3 | +| M1.11 | Securizare comunicații (JWT) | ✅ | API §5, INT C3 | +| M1.12 | Integrare bază de date | ✅ | ARH §8, COD `pg-adapter` | +| **M2** | **Analiză** — workeri + executori | ✅ | ARH §3.1, COD `src/components` | +| M2.4 | Executori: techniques, ai-tampered, claims, domain, RAG | ✅ | GHID §4, INT B1–B5 | +| M2.5 | Integrarea cu platforma AI (Lot 1) | ✅ | **INT (raport dedicat)**, `/health/all` | +| M2.6 | Agregare și verdict | ✅ | GHID §8, COD `aggregator` | +| M2.7 | Procesare media (imagine/audio/video) | ✅ | INT B3/B4/B5 | +| M2.9 | Reziliență și gestionarea erorilor (fail-open) | ✅ | INT C1 + §4.1 (degradare vizibilă) | +| **M3** | **Broker de mesaje** (RabbitMQ) | ✅ | ARH §3.6, GHID §7 | +| M3.2 | Topologie cozi (componente × priorități + DLQ) | ✅ | GHID §7, ARH §8 | +| M3.4 | Mecanisme de reziliență (publisher confirms, DLQ) | ✅ | ARH §6, COD `queue/` | +| **M4** | **API Gateway** (Kong) | ✅ | ARH §3.4 | +| M4.3 | Autentificare și autorizare (JWT RS256, RBAC) | ✅ | API §5, INT C3, GHID §2 | +| M4.4 | Rate limiting | ✅ | COD `kong.yml`, ARH §3.4 | +| M4.8 | Health checks | ✅ | `/health/all`, GHID §3 | +| **M5** | **Baze de date SQL** (PostgreSQL) | ✅ | ARH §8 | +| M5.3 | Organizare pe scheme (analiză/config/useri/date personale) | ✅ | ARH §8, GHID §10 | +| M5.5 | Sincronizare cu cache (Redis) | ✅ | GHID §4, COD `sync-redis` | +| M5.8 | Securizare și disponibilitate (HA Patroni/HAProxy) | ✅ | ARH §6, COD `ha-cluster/` | +| **M6** | **Dashboard** (React) | ✅ | **GHID (întreg)** | +| M6.3 | CRUD pipeline-uri (workflow builder) | ✅ | GHID §6 | +| M6.4 | Rulare pipeline și debug (run console) | ✅ | GHID §6, §7 | +| M6.5 | CRUD catalog (modele, funcții, extractoare) | ✅ | GHID §4, §5 | +| M6.6 | Management utilizatori | ✅ | GHID §10 | +| M6.7 | Istoric analize | ✅ | GHID §8 | +| M6.8 | Monitorizare servicii (health dashboard) | ✅ | GHID §3 | +| **M7** | **Autentificare** (Keycloak OIDC) | ✅ | GHID §2, ARH §3.5 | +| **M8** | **Observabilitate & Logging** | ✅ | GHID §3, ARH §2, INST | +| **M9** | **Containerizare, CI/CD, Orchestrare** | ✅ | INST, `build-local.sh`, ARH §7 | + +--- + +# 3. Trasabilitate pe cerințele funcționale ale caietului de sarcini + +| Cerință caiet (backend) | Livrabil / dovadă | +|---|---| +| Consumul extractoarelor specializate (deepfake, NER, OCR, Whisper, YOLO) | INT §3.1–3.2 (A3–A6, B3–B5), GHID §4 | +| Modul web-crawl / evidence integrat cu backend | INT A7/B2, GHID §4.3 | +| Scor de credibilitate a sursei (WHOIS/SSL/blacklist/DNS — T4) | INT A9/B2, GHID §4.4–4.5 | +| Orchestrarea fluxurilor ML end-to-end | INT B1–B5, GHID §6 | +| Integrarea cu API Gateway + politici de securitate | API §5, INT C3, GHID §2 | +| Autentificare, autorizare, RBAC | API §5, GHID §2, §10 | +| Containerizare, CI/CD, orchestrare | INST, `build-local.sh` | +| OpenAPI/Swagger + set minim de teste API (criteriu 6) | API, OAPI (2 servicii, 374 op.) | +| Cod sursă livrat integral, documentat | Arhiva Lot 2 + docs 01–06 | + +--- + +# 4. Sinteză stare livrare + +| Categorie | Rezultat | +|---|---| +| Module ofertă acoperite | **9/9** | +| Endpoint-uri API cablate (Lot 2) | **374/374** (API) | +| Integrări Lot 1 verificate | **9/9** (INT) | +| Teste de integrare | **16 PASS / 1 DEGRADED (fail-open) / 0 FAIL** | +| Defecte identificate în testare | 1, remediat + întărit (INT §4) | +| Documente de livrare | 01–06 + OpenAPI + rapoarte de test | + +Toate modulele din Propunerea Tehnică Lot 2 sunt **implementate, livrate și verificabile** pe +platforma în funcțiune, cu dovezi trasabile în documentele indicate. diff --git a/backend/docs/07_Specificatii_API_Lot2.docx b/backend/docs/07_Specificatii_API_Lot2.docx new file mode 100644 index 0000000..446c3a0 Binary files /dev/null and b/backend/docs/07_Specificatii_API_Lot2.docx differ diff --git a/backend/docs/07_Specificatii_API_Lot2.md b/backend/docs/07_Specificatii_API_Lot2.md new file mode 100644 index 0000000..c5e5e28 --- /dev/null +++ b/backend/docs/07_Specificatii_API_Lot2.md @@ -0,0 +1,87 @@ +% Specificații API — DiDi Lot 2 (Backend) +% PNRR DIGI150 · contract 11.1.i3.c9 + +--- + +# 1. Scop + +Lotul 2 expune două servicii cu API documentat prin **OpenAPI 3.0.3**, însumând **374 de +operații**. Prezentul document rezumă structura API-urilor și modul de consultare (Swagger UI). +Specificațiile complete, mașinabile, sunt livrate ca fișiere `openapi.yaml`. + +| Serviciu | Port | Operații | Specificație | +|---|---|---|---| +| **Agent V3** (motor de analiză) | 24803 | **87** | `services/orchestration-layer/agent-v3/openapi.yaml` | +| **didiFramework** (parametri) | 3005 | **287** | `services/orchestration-layer/didiFramework/openapi.yaml` | +| **Total** | | **374** | validate cu `openapi-spec-validator` (OK) | + +Ambele API-uri sunt protejate prin **JWT RS256** (Keycloak), verificat la gateway (Kong) și în +backend. Autentificarea se face cu header `Authorization: Bearer `. + +--- + +# 2. Agent V3 — API de analiză (87 operații) + +Toate endpoint-urile de analiză sunt **asincrone** (dispatch pe RabbitMQ, răspuns `202` + poll). + +| Prefix | Rol | +|---|---| +| `/api/v3/pipeline/*` | Pipeline complet: analiză (sync/async), status, istoric, dry-run, resume, cancel | +| `/api/v3/techniques/*` | Detecția tehnicilor de manipulare | +| `/api/v3/ai-tampered/*` | Detecția conținutului generat/modificat de AI | +| `/api/v3/claims/*` | Extragerea + verificarea afirmațiilor | +| `/api/v3/source-assessment/*` | Credibilitatea sursei | +| `/api/v3/domain/*` | Analiza domeniului (WHOIS/DNS/SSL — T4) | +| `/api/v3/media/*` | Upload/download fișiere media (MinIO) | +| `/api/v3/health`, `/api/v3/health/all` | Liveness + health profund cu **toate dependențele Lot 1** | + +**Fluxul tipic de analiză:** + +1. `POST /api/v3/pipeline/analyze-async` cu `{ media_type, text|url|media_url, user_id }` + → `202 { session_id, poll_url, result_url }` +2. `GET /api/v3/pipeline/{session_id}/queue-status` → progres +3. `GET /api/v3/pipeline/{session_id}/result` → `AnalysisSession` completă (verdict + componente) + +**Integrarea cu Lotul 1** este documentată în specificație prin blocul `x-integrations` +(cele 9 servicii AI consumate, cu variabila de mediu, ținta pe `didi-network` și rolul) și prin +schema `DeepHealthResponse` a endpoint-ului `/api/v3/health/all` — care servește și ca sondă de +integrare live (vezi raportul 04). + +--- + +# 3. didiFramework — API de parametri (287 operații) + +CRUD complet pentru toți parametrii platformei (schema `bos_parammgmt`), plus utilizatori, +abonamente și integrarea Keycloak. + +| Grup | Rol | +|---|---| +| `/api/techniques`, `/api/dimensions`, `/api/subdimensions`, `/api/indicators`, `/api/validation-rules` | Ierarhia de tehnici de manipulare | +| `/api/verdicts`, `/api/weights`, `/api/risk-levels` | Verdicte, ponderi, niveluri de risc | +| `/api/claims`, `/api/sources`, `/api/platforms` | Claims, surse, platforme | +| `/api/providers/*`, `/api/llm-models` | Provideri LLM, modele, chei API | +| `/api/prompts` | Prompturi per componentă/etapă | +| `/api/sync-redis` | Sincronizarea configurării PostgreSQL → Redis | +| `/api/admin/*` | Utilizatori, roluri, containere, moderare | +| `/api/subscriptions`, `/api/auth/*` | Abonamente, autentificare, credite | + +--- + +# 4. Consultarea interactivă (Swagger UI) + +Ambele specificații sunt servite printr-o interfață **Swagger UI** (container `didi-api-docs`), +unde fiecare operație poate fi inspectată și testată. Fiecare operație poartă adnotarea +`x-tested` cu codul HTTP observat la proba live (vezi raportul 03). + +Alternativ, fișierele `openapi.yaml` pot fi deschise în orice unealtă compatibilă OpenAPI 3.0 +(Swagger Editor, Postman, Insomnia, generatoare de client). + +--- + +# 5. Testare și validare + +- **374/374** endpoint-uri cablate și funcționale (raport `03_Raport_Testare_API`). +- Ambele specificații **validate** cu `openapi-spec-validator` (OpenAPI 3.0.3, rezultat OK). +- Securitate verificată: 401 fără token / cu token forjat, 200 cu token valid, RBAC pe operațiile + sensibile (raport 03 §5). +- Integrarea cu Lotul 1 testată separat (raport `04_Raport_Testare_Integrare_Lot1-Lot2`). diff --git a/backend/observability/.env.example b/backend/observability/.env.example new file mode 100644 index 0000000..40ff351 --- /dev/null +++ b/backend/observability/.env.example @@ -0,0 +1,17 @@ +# Observability Stack — Environment Template +# ============================================ +# Copy to .env and fill in: +# cp .env.example .env + +# Grafana +GRAFANA_ADMIN_PASSWORD= + +# Alertmanager — for SMTP email alerts (uses SendGrid) +SENDGRID_API_KEY= + +# Optional: Slack/Teams webhook for alerts (paste in alertmanager.yml when ready) +# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +# TEAMS_WEBHOOK_URL=https://... + +# Optional: PagerDuty integration key +# PAGERDUTY_KEY=... diff --git a/backend/observability/README.md b/backend/observability/README.md new file mode 100644 index 0000000..4fee9da --- /dev/null +++ b/backend/observability/README.md @@ -0,0 +1,234 @@ +# DiDi Observability Stack + +Stack complet de monitorizare, logging și tracing distribuit pentru platforma DiDi. Implementează cerința **LOT 2 modul 8 (Observabilitate & Logging)** din caietul de sarcini + diagrama A.9 din oferta EVOTECH. + +## Componente + +| Component | Rol | URL UI (LAN) | +|---|---|---| +| **Prometheus** | Scraping metrici + alert rules | http://10.11.10.12:9090 | +| **Grafana** | Dashboard-uri vizualizare metrici + loguri + traces | http://10.11.10.12:3030 | +| **Loki** | Agregare loguri | http://10.11.10.12:3100 | +| **Promtail** | Shipper Docker logs → Loki | (no UI) | +| **Jaeger** | UI tracing distribuit | http://10.11.10.12:16686 | +| **OTel Collector** | Receiver OTLP (trace + metric) + processor + exporter | http://10.11.10.12:4319 (gRPC), :4320 (HTTP) | +| **Alertmanager** | Routing alerte (email, Slack/Teams, PagerDuty) | http://10.11.10.12:9093 | + +## Quick start + +```bash +cd /home/admin365/didi_mono/didi_mono/backend/observability + +# Setup .env +cp .env.example .env +$EDITOR .env # set GRAFANA_ADMIN_PASSWORD + SENDGRID_API_KEY + +# Start stack +docker compose up -d + +# Verify all healthy +docker compose ps +``` + +Apoi accesează **Grafana** la http://10.11.10.12:3030 (user `admin`, parola din `GRAFANA_ADMIN_PASSWORD`). + +Datasources sunt deja provisioned (Prometheus, Loki, Jaeger). Adaugă dashboard-uri custom în `grafana/dashboards/` (auto-provisioned la 30s). + +## Instrumentare servicii + +### Node.js (agent-v3, didi-framework, admin-dashboard) + +Instalează `prom-client` + `@opentelemetry/sdk-node`: +```bash +npm install --save prom-client @opentelemetry/api @opentelemetry/sdk-node \ + @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-grpc \ + @opentelemetry/resources @opentelemetry/semantic-conventions +``` + +Adaugă în `src/index.ts` (înainte de orice alt import): +```ts +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { Resource } from '@opentelemetry/resources'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; + +const sdk = new NodeSDK({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'agent-v3', + [SemanticResourceAttributes.SERVICE_VERSION]: '3.0.0', + }), + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://didi-otel-collector:4317', + }), + instrumentations: [getNodeAutoInstrumentations()], +}); +sdk.start(); +``` + +Adaugă endpoint `/metrics` în Express: +```ts +import { register, collectDefaultMetrics } from 'prom-client'; +collectDefaultMetrics({ prefix: 'didi_agent_v3_' }); + +app.get('/metrics', async (req, res) => { + res.set('Content-Type', register.contentType); + res.end(await register.metrics()); +}); +``` + +Custom metrics relevante DiDi: +```ts +import { Counter, Histogram } from 'prom-client'; + +export const analysisCompleted = new Counter({ + name: 'didi_analyses_completed_total', + help: 'Total number of analyses completed', + labelNames: ['component', 'tier', 'media_type', 'verdict'], +}); + +export const pipelineDuration = new Histogram({ + name: 'didi_pipeline_duration_seconds', + help: 'Pipeline duration in seconds', + labelNames: ['component', 'tier'], + buckets: [1, 5, 10, 30, 60, 120, 300], +}); + +// În executor: +const end = pipelineDuration.startTimer({ component: 'techniques', tier }); +try { + await runAnalysis(); + analysisCompleted.inc({ component: 'techniques', tier, media_type, verdict }); +} finally { + end(); +} +``` + +### Python (ai_platform modules) + +Instalează `prometheus_client` + `opentelemetry-instrumentation-fastapi`: +```bash +pip install prometheus-client opentelemetry-api opentelemetry-sdk \ + opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi +``` + +Adaugă în `app.py`: +```python +from prometheus_client import make_asgi_app +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.sdk.resources import Resource + +resource = Resource(attributes={"service.name": "llm-inference"}) +trace.set_tracer_provider(TracerProvider(resource=resource)) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint="http://didi-otel-collector:4317", insecure=True)) +) + +app = FastAPI() +FastAPIInstrumentor.instrument_app(app) + +# Mount /metrics +app.mount("/metrics", make_asgi_app()) +``` + +### Environment variables pe servicii + +Adaugă în compose-urile fiecărui serviciu: +```yaml +environment: + OTEL_EXPORTER_OTLP_ENDPOINT: http://didi-otel-collector:4317 + OTEL_SERVICE_NAME: agent-v3 + OTEL_RESOURCE_ATTRIBUTES: cluster=didi-prod,environment=production +``` + +## Dashboards predefinite + +Adaugă fișiere JSON în `grafana/dashboards/` (auto-importate). Recomandate: + +1. **DiDi Platform Overview** + - KPI cards: analize/min, error rate, P50/P95/P99 pipeline latency, GPU util + - Time-series: requests per service, queue backlog (RabbitMQ), Redis hit rate + +2. **AI Platform** + - LLM inference latency per model, GPU memory (Qwen 3.5, BusterX) + - Brain analysis_atom cache hit rate, scheduler health + +3. **Cozi & Workers** + - RabbitMQ queue depth per component × tier + - Worker throughput, retry count, DLQ messages + +4. **Cost & business** + - Stripe webhook success rate, Sales Invoice rate, MRR proxy + +5. **Infrastructure** + - CPU/RAM/Disk/Network per node, container restarts, healthcheck failures + +Import dashboards exemple din comunitate: +- ID 11074 (Node Exporter Full) +- ID 13639 (Logs via Loki) +- ID 17761 (Cadvisor) +- ID 14570 (RabbitMQ Cluster) + +În Grafana: **+ → Import → paste ID-ul → load**. + +## Verificare end-to-end + +```bash +# 1. Verifică Prometheus scrapes +curl -s http://10.11.10.12:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}' + +# 2. Verifică Loki primește loguri +curl -s 'http://10.11.10.12:3100/loki/api/v1/labels' | jq + +# 3. Trimite un trace de test din shell (după ce ai un service instrumentat) +# Vizibil în Jaeger UI: http://10.11.10.12:16686 + +# 4. Trimite o alertă de test +curl -X POST http://10.11.10.12:9090/-/reload +# Așteaptă să se trigger ServiceDown alert (timer ~5min) +``` + +## Retention + +- **Prometheus**: 30 zile (configurabil în compose `--storage.tsdb.retention.time`) +- **Loki**: 7 zile (configurabil în `loki-config.yaml` `retention_period`) +- **Jaeger** (Badger storage): persistent, ~10GB cap +- **Alertmanager**: persistent state + +## SLA + escalation + +Vezi `alertmanager.yml` pentru routing: +- `severity=critical` → notify imediat la `office@clossers.com` +- `severity=warning` → batch, repeat 12h +- TODO: adaugă Slack/Teams webhook + PagerDuty key pentru on-call + +## Troubleshooting + +**Prometheus arată target-uri DOWN**: verifică că serviciul are endpoint `/metrics` accesibil + că e pe `didi-network`. + +**Loki nu primește loguri**: verifică `promtail` logs (`docker logs didi-promtail`) — probabil container labels nu match-uiesc. + +**Jaeger fără traces**: verifică că serviciul are env `OTEL_EXPORTER_OTLP_ENDPOINT` setat corect + că face HTTP/gRPC către `didi-otel-collector:4317`. + +**Alertmanager nu trimite email**: verifică `SENDGRID_API_KEY` în env + că from address `alerts@didi365.eu` e validat în SendGrid. + +## Roadmap + +- [ ] Adaugă **postgres_exporter** pentru metrici PostgreSQL (slow queries, replication lag) +- [ ] Adaugă **redis_exporter** pentru metrici Redis +- [ ] Adaugă **nvidia_gpu_exporter** pe GPU host pentru metrici VRAM/utilization +- [ ] Instrumentare agent-v3 (PR follow-up) +- [ ] Instrumentare ai_platform modules (PR follow-up) +- [ ] Slack/Teams webhook integration +- [ ] PagerDuty on-call rotation +- [ ] Synthetic monitoring (uptime checks pe didi365.eu) + +## Referințe + +- Caiet sarcini LOT 2 modul 8 (Observabilitate & Logging) +- Oferta EVOTECH §A.9 (diagrama observabilitate completă) +- Cercetare industrială §C (validare experimentală + KPI-uri) diff --git a/backend/observability/alertmanager/alertmanager.yml b/backend/observability/alertmanager/alertmanager.yml new file mode 100644 index 0000000..cdde1ec --- /dev/null +++ b/backend/observability/alertmanager/alertmanager.yml @@ -0,0 +1,62 @@ +global: + resolve_timeout: 5m + # Configure SMTP for email alerts + smtp_from: 'alerts@didi365.eu' + smtp_smarthost: 'smtp.sendgrid.net:587' + smtp_auth_username: 'apikey' + # smtp_auth_password set via env $SENDGRID_API_KEY in compose + +route: + group_by: ['alertname', 'cluster', 'service'] + group_wait: 30s + group_interval: 5m + repeat_interval: 12h + receiver: 'default' + routes: + - matchers: + - severity = critical + receiver: 'critical' + group_wait: 10s + repeat_interval: 1h + + - matchers: + - team = revenue + receiver: 'revenue-team' + + - matchers: + - team = ai-platform + receiver: 'ai-team' + +receivers: + - name: 'default' + email_configs: + - to: 'office@clossers.com' + send_resolved: true + headers: + Subject: '[DiDi] {{ .Status | toUpper }}: {{ .GroupLabels.alertname }}' + + - name: 'critical' + email_configs: + - to: 'office@clossers.com' + send_resolved: true + headers: + Subject: '[DiDi CRITICAL] {{ .GroupLabels.alertname }}' + # Add webhook for Slack/Teams when ready: + # webhook_configs: + # - url: 'https://hooks.slack.com/services/T.../B.../...' + + - name: 'revenue-team' + email_configs: + - to: 'office@clossers.com' + + - name: 'ai-team' + email_configs: + - to: 'office@clossers.com' + +inhibit_rules: + # Suppress non-critical alerts if a critical alert is already firing for same service + - source_matchers: + - severity = critical + target_matchers: + - severity = warning + equal: ['alertname', 'cluster', 'service'] diff --git a/backend/observability/docker-compose.yml b/backend/observability/docker-compose.yml new file mode 100644 index 0000000..c16213d --- /dev/null +++ b/backend/observability/docker-compose.yml @@ -0,0 +1,183 @@ +# DiDi Observability Stack +# ======================== +# Implements LOT 2 modul 8 (Observabilitate & Logging) conform oferta EVOTECH §A.9: +# - Prometheus — metrics scraping +# - Grafana — dashboards +# - Loki — log aggregation +# - Promtail — log shipper (Docker logs → Loki) +# - Jaeger — distributed tracing UI +# - OTel Collector — receives traces from services, forwards to Jaeger +# - Alertmanager — alert routing (email, Slack/Teams, PagerDuty) +# +# Network: didi-network (shared cu DIDI + AI platform stacks) +# Storage: volumes locale persistente +# +# Quick start: +# cd /home/admin365/didi_mono/didi_mono/backend/observability +# docker compose up -d +# +# UI access: +# Grafana: http://10.11.10.12:3030 (admin / GRAFANA_ADMIN_PASSWORD) +# Prometheus: http://10.11.10.12:9090 +# Jaeger UI: http://10.11.10.12:16686 +# Alertmanager: http://10.11.10.12:9093 + +services: + prometheus: + image: prom/prometheus:v2.55.0 + container_name: didi-prometheus + restart: unless-stopped + user: "65534:65534" # nobody:nobody (avoids root warnings) + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/rules:/etc/prometheus/rules:ro + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--storage.tsdb.retention.size=20GB' + - '--web.enable-lifecycle' + - '--web.enable-admin-api' + ports: + - "0.0.0.0:9090:9090" + networks: + - didi-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 5s + retries: 3 + + grafana: + image: grafana/grafana:11.3.0 + container_name: didi-grafana + restart: unless-stopped + depends_on: + prometheus: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_INSTALL_PLUGINS: "grafana-piechart-panel,grafana-clock-panel" + GF_FEATURE_TOGGLES_ENABLE: "traceqlEditor" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "0.0.0.0:3030:3000" + networks: + - didi-network + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + + loki: + image: grafana/loki:3.2.0 + container_name: didi-loki + restart: unless-stopped + volumes: + - ./loki/loki-config.yaml:/etc/loki/local-config.yaml:ro + - loki-data:/loki + command: -config.file=/etc/loki/local-config.yaml + ports: + - "0.0.0.0:3100:3100" + networks: + - didi-network + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + promtail: + image: grafana/promtail:3.2.0 + container_name: didi-promtail + restart: unless-stopped + depends_on: + loki: + condition: service_healthy + volumes: + - ./promtail/promtail-config.yaml:/etc/promtail/config.yaml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - promtail-data:/tmp + command: -config.file=/etc/promtail/config.yaml + networks: + - didi-network + + jaeger: + image: jaegertracing/all-in-one:1.62.0 + container_name: didi-jaeger + restart: unless-stopped + environment: + COLLECTOR_OTLP_ENABLED: "true" + SPAN_STORAGE_TYPE: memory + BADGER_EPHEMERAL: "false" + BADGER_DIRECTORY_VALUE: /badger/data + BADGER_DIRECTORY_KEY: /badger/key + volumes: + - jaeger-data:/badger + ports: + - "0.0.0.0:16686:16686" # Jaeger UI + - "0.0.0.0:14250:14250" # gRPC collector (legacy) + - "0.0.0.0:14268:14268" # HTTP collector (legacy) + - "0.0.0.0:4317:4317" # OTLP gRPC + - "0.0.0.0:4318:4318" # OTLP HTTP + networks: + - didi-network + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.110.0 + container_name: didi-otel-collector + restart: unless-stopped + depends_on: + - jaeger + - prometheus + volumes: + - ./otel-collector/otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro + command: ["--config=/etc/otelcol-contrib/config.yaml"] + ports: + - "0.0.0.0:4319:4317" # OTLP gRPC (services push traces here) + - "0.0.0.0:4320:4318" # OTLP HTTP + - "0.0.0.0:8888:8888" # collector self-metrics + - "0.0.0.0:8889:8889" # prometheus exporter + networks: + - didi-network + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: didi-alertmanager + restart: unless-stopped + volumes: + - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager-data:/alertmanager + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + ports: + - "0.0.0.0:9093:9093" + networks: + - didi-network + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:9093/-/healthy || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + prometheus-data: {} + grafana-data: {} + loki-data: {} + promtail-data: {} + jaeger-data: {} + alertmanager-data: {} + +networks: + didi-network: + external: true diff --git a/backend/observability/grafana/dashboards/didi-overview.json b/backend/observability/grafana/dashboards/didi-overview.json new file mode 100644 index 0000000..dc88b1a --- /dev/null +++ b/backend/observability/grafana/dashboards/didi-overview.json @@ -0,0 +1,105 @@ +{ + "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, + "description": "DiDi Platform — overall health, request rates, latency, errors, infrastructure", + "editable": true, + "graphTooltip": 1, + "id": null, + "panels": [ + { "type": "row", "title": "Service Health", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 100, "collapsed": false }, + { + "type": "stat", "title": "Services UP", "id": 1, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "targets": [ { "expr": "count(up == 1)", "refId": "A" } ], + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 5 } ] } } }, + "options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" } + }, + { + "type": "stat", "title": "Services DOWN", "id": 2, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "targets": [ { "expr": "count(up == 0)", "refId": "A" } ], + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] } } }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" } + }, + { + "type": "stat", "title": "HTTP req/sec (total)", "id": 3, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 1 }, + "targets": [ { "expr": "sum(rate(http_requests_total[5m]))", "refId": "A" } ], + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "thresholds" } } }, + "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { + "type": "stat", "title": "Active Workers (agent-v3)", "id": 4, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 1 }, + "targets": [ { "expr": "count(up{job=\"agent-v3-workers\"} == 1)", "refId": "A" } ], + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 10 } ] } } }, + "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } } + }, + { "type": "row", "title": "HTTP traffic", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 101, "collapsed": false }, + { + "type": "timeseries", "title": "Request rate per service", "id": 10, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "targets": [ { "expr": "sum by (service) (rate(http_requests_total[1m]))", "refId": "A", "legendFormat": "{{service}}" } ], + "fieldConfig": { "defaults": { "unit": "reqps" } }, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } } + }, + { + "type": "timeseries", "title": "p95 latency per service (s)", "id": 11, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "targets": [ { "expr": "histogram_quantile(0.95, sum by (service, le) (rate(http_request_duration_seconds_bucket[5m])))", "refId": "A", "legendFormat": "{{service}}" } ], + "fieldConfig": { "defaults": { "unit": "s" } }, + "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] } } + }, + { + "type": "timeseries", "title": "5xx error rate", "id": 12, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "targets": [ { "expr": "sum by (service) (rate(http_requests_total{status_code=~\"5..\"}[5m]))", "refId": "A", "legendFormat": "{{service}}" } ], + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } } } + }, + { + "type": "timeseries", "title": "Process RSS memory (MB)", "id": 13, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "targets": [ { "expr": "process_resident_memory_bytes / 1024 / 1024", "refId": "A", "legendFormat": "{{service}}" } ], + "fieldConfig": { "defaults": { "unit": "MB" } } + }, + { "type": "row", "title": "Infrastructure", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }, "id": 103, "collapsed": false }, + { + "type": "timeseries", "title": "CPU per container", "id": 30, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 }, + "targets": [ { "expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\"didi.*|agent-v3.*\"}[1m]))", "refId": "A", "legendFormat": "{{name}}" } ], + "fieldConfig": { "defaults": { "unit": "percentunit" } }, + "options": { "legend": { "displayMode": "table", "placement": "bottom" } } + }, + { + "type": "timeseries", "title": "Memory per container (MB)", "id": 31, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 }, + "targets": [ { "expr": "container_memory_usage_bytes{name=~\"didi.*|agent-v3.*\"} / 1024 / 1024", "refId": "A", "legendFormat": "{{name}}" } ], + "fieldConfig": { "defaults": { "unit": "MB" } } + }, + { + "type": "timeseries", "title": "RabbitMQ queue depth", "id": 40, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 31 }, + "targets": [ { "expr": "rabbitmq_queue_messages", "refId": "A", "legendFormat": "{{queue}}" } ], + "options": { "legend": { "displayMode": "table", "placement": "bottom" } } + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["didi", "overview"], + "templating": { "list": [] }, + "time": { "from": "now-30m", "to": "now" }, + "timezone": "Europe/Bucharest", + "title": "DiDi Platform Overview", + "uid": "didi-overview", + "version": 1 +} diff --git a/backend/observability/grafana/provisioning/dashboards/dashboards.yml b/backend/observability/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..287462a --- /dev/null +++ b/backend/observability/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'DiDi Platform Dashboards' + orgId: 1 + folder: 'DiDi' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/backend/observability/grafana/provisioning/datasources/datasources.yml b/backend/observability/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..352c092 --- /dev/null +++ b/backend/observability/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,40 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://didi-prometheus:9090 + isDefault: true + editable: false + jsonData: + httpMethod: POST + timeInterval: 30s + + - name: Loki + type: loki + access: proxy + url: http://didi-loki:3100 + editable: false + jsonData: + derivedFields: + - name: TraceID + matcherRegex: 'trace_id=(\w+)' + url: '$${__value.raw}' + datasourceUid: jaeger + + - name: Jaeger + type: jaeger + uid: jaeger + access: proxy + url: http://didi-jaeger:16686 + editable: false + jsonData: + tracesToLogsV2: + datasourceUid: loki + tags: ['service.name'] + spanStartTimeShift: '-1h' + spanEndTimeShift: '1h' + tracesToMetrics: + datasourceUid: prometheus + tags: [{ key: 'service.name', value: 'service' }] diff --git a/backend/observability/loki/loki-config.yaml b/backend/observability/loki/loki-config.yaml new file mode 100644 index 0000000..c6da540 --- /dev/null +++ b/backend/observability/loki/loki-config.yaml @@ -0,0 +1,46 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://didi-alertmanager:9093 + +limits_config: + retention_period: 168h # 7 days (logs) + reject_old_samples: true + reject_old_samples_max_age: 168h + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_query_series: 10000 diff --git a/backend/observability/otel-collector/otel-collector.yaml b/backend/observability/otel-collector/otel-collector.yaml new file mode 100644 index 0000000..2edee4b --- /dev/null +++ b/backend/observability/otel-collector/otel-collector.yaml @@ -0,0 +1,77 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 1s + send_batch_size: 1024 + + memory_limiter: + check_interval: 5s + limit_mib: 512 + + resource: + attributes: + - key: cluster + value: didi-prod + action: insert + - key: environment + value: production + action: insert + + tail_sampling: + decision_wait: 10s + num_traces: 100 + expected_new_traces_per_sec: 10 + policies: + - name: errors-policy + type: status_code + status_code: { status_codes: [ERROR] } + - name: slow-traces-policy + type: latency + latency: { threshold_ms: 1000 } + - name: random-sampling + type: probabilistic + probabilistic: { sampling_percentage: 10 } + +exporters: + # Jaeger receives via OTLP natively + otlp/jaeger: + endpoint: didi-jaeger:4317 + tls: + insecure: true + + # Prometheus exporter for service metrics derived from spans + prometheus: + endpoint: 0.0.0.0:8889 + namespace: otel + const_labels: + cluster: didi-prod + + # Log to stdout for debugging (disable in prod) + debug: + verbosity: basic + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, resource, tail_sampling, batch] + exporters: [otlp/jaeger] + metrics: + receivers: [otlp] + processors: [memory_limiter, resource, batch] + exporters: [prometheus] + telemetry: + metrics: + address: 0.0.0.0:8888 diff --git a/backend/observability/prometheus/prometheus.yml b/backend/observability/prometheus/prometheus.yml new file mode 100644 index 0000000..5762b76 --- /dev/null +++ b/backend/observability/prometheus/prometheus.yml @@ -0,0 +1,104 @@ +global: + scrape_interval: 30s + evaluation_interval: 30s + external_labels: + cluster: didi-prod + environment: production + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: + - didi-alertmanager:9093 + +scrape_configs: + # Prometheus self-monitoring + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] + + # Backend Node.js services (expun /metrics via prom-client) + - job_name: agent-v3 + metrics_path: /metrics + static_configs: + - targets: ['didi-agent-v3:24803'] + labels: { service: agent-v3, layer: orchestration } + + - job_name: didi-framework + metrics_path: /metrics + static_configs: + - targets: ['didi-framework:3005'] + labels: { service: framework, layer: orchestration } + + # AI Platform Python services (expun /metrics via prometheus_client) + - job_name: ai-platform + metrics_path: /metrics + static_configs: + - targets: + - 'didiAI-llm-api:14011' + - 'didiAI-embeddings-api:14100' + - 'didiAI-rerank-api:14200' + - 'didiAI-audio-api:54300' + - 'didiAI-video-api:54600' + - 'didiAI-web-api:51100' + - 'didiAI-catalog-api:11000' + - 'didiAI-dashboard:51300' + - 'didibrain-api:8090' + labels: { layer: ai-platform } + + # Workers (expun /metrics pe portul intern) + - job_name: agent-v3-workers + metrics_path: /metrics + static_configs: + - targets: + - 'agent-v3-worker-techniques-1:24803' + - 'agent-v3-worker-techniques-2:24803' + - 'agent-v3-worker-ai-tampered-1:24803' + - 'agent-v3-worker-ai-tampered-2:24803' + - 'agent-v3-worker-claims-1:24803' + - 'agent-v3-worker-claims-2:24803' + - 'agent-v3-worker-claims-3:24803' + - 'agent-v3-worker-domain-1:24803' + - 'agent-v3-worker-domain-2:24803' + - 'agent-v3-worker-media-preprocess-1:24803' + - 'agent-v3-worker-media-preprocess-2:24803' + - 'agent-v3-verdict-aggregator-1:24803' + - 'agent-v3-verdict-aggregator-2:24803' + labels: { service: agent-v3, layer: workers } + + # Infrastructure metrics (cadvisor + node-exporter already running on host) + - job_name: cadvisor + static_configs: + - targets: ['heimdall_cadvisor:8080'] + labels: { layer: infrastructure } + + - job_name: node-exporter + static_configs: + - targets: ['heimdall_node_exporter:9100'] + labels: { layer: infrastructure } + + # OTel Collector self-metrics + traces (metric forwarder) + - job_name: otel-collector + static_configs: + - targets: ['didi-otel-collector:8889'] + labels: { layer: observability } + + # PostgreSQL via postgres_exporter (opt-in — add postgres-exporter container if needed) + # - job_name: postgres-exporter + # static_configs: + # - targets: ['postgres-exporter:9187'] + + # RabbitMQ has built-in prometheus support since 3.10 — enable rabbitmq_prometheus plugin + - job_name: rabbitmq + metrics_path: /metrics + static_configs: + - targets: ['staging-dataLayer-rabbitmq:15692'] + labels: { service: rabbitmq, layer: data } + + # Redis via redis_exporter (opt-in) + # - job_name: redis-exporter + # static_configs: + # - targets: ['redis-exporter:9121'] diff --git a/backend/observability/prometheus/rules/alerts.yml b/backend/observability/prometheus/rules/alerts.yml new file mode 100644 index 0000000..2660ea5 --- /dev/null +++ b/backend/observability/prometheus/rules/alerts.yml @@ -0,0 +1,119 @@ +groups: + - name: didi-platform-availability + interval: 1m + rules: + - alert: ServiceDown + expr: up == 0 + for: 5m + labels: + severity: critical + team: platform + annotations: + summary: "Service {{ $labels.job }} ({{ $labels.instance }}) is DOWN" + description: "{{ $labels.job }} has been unreachable for >5m. Last scrape: {{ $value }}" + + - alert: HighErrorRate + expr: | + sum(rate(didi_http_requests_total{status=~"5.."}[5m])) by (service) + / + sum(rate(didi_http_requests_total[5m])) by (service) + > 0.05 + for: 10m + labels: + severity: warning + team: platform + annotations: + summary: "High 5xx error rate on {{ $labels.service }}" + description: "{{ $labels.service }} has >5% 5xx errors for >10m" + + - name: didi-pipeline-performance + interval: 1m + rules: + - alert: PipelineLatencyHigh + expr: | + histogram_quantile(0.95, + sum(rate(didi_pipeline_duration_seconds_bucket[5m])) by (le, component) + ) > 60 + for: 15m + labels: + severity: warning + team: platform + annotations: + summary: "Pipeline component {{ $labels.component }} P95 latency >60s" + description: "P95 latency on {{ $labels.component }} is {{ $value }}s" + + - alert: RabbitMQQueueBacklog + expr: rabbitmq_queue_messages_ready > 100 + for: 10m + labels: + severity: warning + team: platform + annotations: + summary: "Queue {{ $labels.queue }} backlog >100 messages" + description: "{{ $labels.queue }} has {{ $value }} unprocessed messages" + + - alert: DeadLetterMessages + expr: increase(didi_dlq_messages_total[15m]) > 0 + labels: + severity: warning + team: platform + annotations: + summary: "Messages landing in analysis_dlq ({{ $labels.component }})" + description: "{{ $value }} dead-lettered messages in 15m — check didi:queue:dlq:recent in Redis for session ids" + + - name: didi-infrastructure + interval: 1m + rules: + - alert: HighCPU + expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 + for: 10m + labels: + severity: warning + team: ops + annotations: + summary: "CPU >85% on {{ $labels.instance }}" + + - alert: LowDiskSpace + expr: | + (node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"}) + < 0.15 + for: 5m + labels: + severity: critical + team: ops + annotations: + summary: "Disk space <15% on {{ $labels.instance }}" + + - alert: GPUMemoryHigh + expr: nvidia_gpu_memory_used_bytes / nvidia_gpu_memory_total_bytes > 0.95 + for: 10m + labels: + severity: warning + team: ai-platform + annotations: + summary: "GPU {{ $labels.gpu }} memory >95% on {{ $labels.instance }}" + + - name: didi-business + interval: 5m + rules: + - alert: NoAnalysesIn30Min + expr: | + increase(didi_analyses_completed_total[30m]) == 0 + for: 30m + labels: + severity: warning + team: product + annotations: + summary: "No analyses completed in last 30 minutes" + description: "Platform may be down or no traffic — investigate" + + - alert: StripeWebhookFailures + expr: | + rate(didi_stripe_webhook_failures_total[5m]) > 0.1 + for: 5m + labels: + severity: critical + team: revenue + annotations: + summary: "Stripe webhook failures detected" diff --git a/backend/observability/promtail/promtail-config.yaml b/backend/observability/promtail/promtail-config.yaml new file mode 100644 index 0000000..908e2f8 --- /dev/null +++ b/backend/observability/promtail/promtail-config.yaml @@ -0,0 +1,44 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://didi-loki:3100/loki/api/v1/push + +scrape_configs: + # Scrape Docker container logs + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 30s + filters: + - name: label + values: ["com.docker.compose.project"] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: service + - source_labels: ['__meta_docker_container_label_com_docker_compose_project'] + target_label: project + pipeline_stages: + # Try to parse JSON logs (Node.js services emit JSON via pino/winston) + - json: + expressions: + level: level + service: service + request_id: request_id + session_id: session_id + msg: msg + - labels: + level: + request_id: + session_id: + # If "level" is missing, leave as info + - template: + source: level + template: '{{ if .Value }}{{ .Value }}{{ else }}info{{ end }}' diff --git a/backend/production/.env.example b/backend/production/.env.example new file mode 100644 index 0000000..510e664 --- /dev/null +++ b/backend/production/.env.example @@ -0,0 +1,42 @@ +# ============================================================================= +# DIDI Platform - Production Environment Configuration +# ============================================================================= +# Kong & Keycloak - using PostgreSQL Cluster +# ============================================================================= +COMPOSE_PROJECT_NAME=didi-production +# ============================================================================= +# PostgreSQL Cluster (Primary Database) +# ============================================================================= +PG_CLUSTER_HOST=10.11.50.167 +PG_CLUSTER_PORT=5000 +PG_CLUSTER_USER=bos_interface +PG_CLUSTER_PASSWORD=CHANGE_ME +# ============================================================================= +# Kong Configuration +# ============================================================================= +KONG_PG_HOST=10.11.50.167 +KONG_PG_PORT=5000 +KONG_PG_USER=kong +KONG_PG_PASSWORD=CHANGE_ME +KONG_PG_DATABASE=kong_db +# ============================================================================= +# Keycloak Configuration +# ============================================================================= +KC_DB_HOST=10.11.50.167 +KC_DB_PORT=5000 +KC_DB_NAME=keycloak_db +KC_DB_USER=keycloak +KC_DB_PASSWORD=CHANGE_ME +KEYCLOAK_ADMIN=CHANGE_ME +KEYCLOAK_ADMIN_PASSWORD=CHANGE_ME +# ============================================================================= +# Hostname Configuration +# ============================================================================= +KC_HOSTNAME_URL=https://didi365.eu/auth +# ============================================================================= +# Redis Cache Configuration +# ============================================================================= +REDIS_HOST=didi-cache +REDIS_PORT=6379 +REDIS_PASSWORD=CHANGE_ME +REDIS_DB=0 diff --git a/backend/production/API_TESTING_GUIDE.md b/backend/production/API_TESTING_GUIDE.md new file mode 100644 index 0000000..0de50b2 --- /dev/null +++ b/backend/production/API_TESTING_GUIDE.md @@ -0,0 +1,584 @@ +# DIDI Platform - Ghid Testare API + +Acest document descrie toate API-urile platformei DIDI, cum se testeaza, ce constrangeri au, si cum se pot simula mai multi utilizatori. + +--- + +## Arhitectura pe scurt + +``` +Client (browser/curl/script) + | + v +Kong API Gateway (port 443, HTTPS) + | + +-- agent-v3 (port 24803, analiza continut) + +-- didiFramework (port 3005, CRUD parametri + utilizatori) + +-- admin-dashboard (port 3000, SPA React) +``` + +In staging, agent-v3 este accesibil si direct pe localhost:24803 (bind 127.0.0.1). +Framework-ul nu expune port extern -- accesibil doar prin Docker network sau admin dashboard. + +--- + +## Autentificare + +### JWT (Keycloak) + +Platforma foloseste Keycloak pentru autentificare OAuth2/OIDC. + +Obtinere token: + +``` +POST http://localhost:28000/realms/didi-clients/protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=password&client_id=didi-web-app&username=EMAIL&password=PAROLA +``` + +Utilizatori pre-existenti: + +| Email | Parola | Tier | Credite | +|---------------------|-------------|------------|---------| +| admin@didi.local | admin123 | admin | nelimitat | +| demo@didi.local | Demo123! | free | 100 | +| free@didi.local | password123 | free | 100 | +| paid@didi.local | password123 | paid | 100 | +| enterprise@didi.local | password123 | enterprise | nelimitat | + +Exemplu complet cu curl: + +```bash +# Pas 1: Obtine token +TOKEN=$(curl -s -X POST \ + "http://localhost:28000/realms/didi-clients/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password&client_id=didi-web-app&username=admin@didi.local&password=admin123" \ + | jq -r '.access_token') + +echo $TOKEN + +# Pas 2: Foloseste token-ul +curl -H "Authorization: Bearer $TOKEN" https://localhost:443/api/... +``` + +Token-ul expira in 10 minute. Refresh la fiecare 30s cu refresh_token. + +### API Key (extensie browser) + +Extensia Chrome foloseste un API key in loc de JWT: + +``` +POST /api/v3/pipeline/extension/analyze +Header: X-API-Key: didi_ext_... +``` + +Cheile se creeaza prin: POST /api/v3/pipeline/extension/keys (necesita JWT admin). + +### Endpoint-uri fara autentificare + +In staging, agent-v3 NU valideaza JWT-ul pe requesturi directe (localhost:24803). +Kong valideaza JWT-ul in productie, dar in staging plugin-ul JWT nu este activat. + +Asta inseamna: + +- Accesand direct localhost:24803 -- NU ai nevoie de JWT (dar user_id/email sunt extrase din header daca exista) +- Accesand prin Kong (port 443) in staging -- NU ai nevoie de JWT (JWT plugin dezactivat in declarative mode) +- Accesand prin Kong in productie -- AI NEVOIE de JWT + +Pentru testare multi-user, trimite manual headerele: + +```bash +curl -X POST http://localhost:24803/api/v3/pipeline/analyze \ + -H "Content-Type: application/json" \ + -H "X-User-Id: test-user-1" \ + -H "X-User-Email: test1@test.com" \ + -d '{"text": "textul de analizat"}' +``` + +--- + +## Endpoint-uri Agent V3 (port 24803) + +Prefix: /api/v3 + +### Health (fara auth, fara body) + +``` +GET /api/v3/health +``` + +Raspuns: {"service": "agent-v3", "version": "3.0.0", "status": "ok"} + +### Analiza text sincron (endpoint principal) + +``` +POST /api/v3/pipeline/analyze +Content-Type: application/json + +{ + "text": "Textul de analizat. Minim 20 caractere, maxim 50000.", + "user_id": "optional", + "user_email": "optional" +} +``` + +Raspuns: AnalysisSession complet (techniques + ai_tampered + claims + domain + verdict). +Durata: 15-120 secunde in functie de lungimea textului. + +### Analiza text asincrona (recomandata pentru stress test) + +``` +POST /api/v3/pipeline/analyze-async +Content-Type: application/json + +{ + "text": "Textul de analizat", + "plan_type": 1 +} +``` + +Raspuns 202: + +```json +{ + "success": true, + "async": true, + "data": { + "session_id": "uuid", + "poll_url": "/api/v3/pipeline/uuid/queue-status", + "result_url": "/api/v3/pipeline/uuid/result" + } +} +``` + +Polling progres: + +``` +GET /api/v3/pipeline/{session_id}/queue-status +``` + +Raspuns rezultat final (cand status=completed): + +``` +GET /api/v3/pipeline/{session_id}/result +``` + +### Analiza URL + +``` +POST /api/v3/pipeline/analyze-url +Content-Type: application/json + +{ + "url": "https://example.com/articol", + "user_id": "optional" +} +``` + +Detecteaza automat tipul: YouTube (video), imagine, articol. + +### Analiza media (imagine/audio/video) + +``` +POST /api/v3/pipeline/analyze-media +Content-Type: application/json + +{ + "media_url": "https://didi365.eu/api/v3/media/file/uploads/...", + "media_type": "image|audio|video", + "user_id": "optional" +} +``` + +Inainte de analiza media, uploadeaza fisierul: + +``` +POST /api/v3/media/upload +Content-Type: multipart/form-data +Field: file (max 50MB) +``` + +### Componente individuale + +Analiza doar o singura componenta (util pentru testare granulara): + +``` +POST /api/v3/techniques/analyze {"text": "..."} +POST /api/v3/ai-tampered/analyze {"text": "..."} +POST /api/v3/claims/analyze {"text": "..."} +POST /api/v3/domain/analyze {"url": "https://..."} +POST /api/v3/source-assessment/analyze {"text": "...", "url": "optional"} +``` + +### Istoric + +``` +GET /api/v3/pipeline/history?user_id=USER&page=1&limit=20 +GET /api/v3/pipeline/history/{session_id} +DELETE /api/v3/pipeline/history/{session_id}?user_id=USER + +GET /api/v3/pipeline/history/admin?page=1&limit=20&search=&risk_level=&status=&from_date=&to_date= +``` + +### Configurare (read-only, util pentru debug) + +``` +GET /api/v3/techniques/definitions -- ierarhie tehnici +GET /api/v3/techniques/config -- config completa +GET /api/v3/techniques/models -- modele LLM disponibile +GET /api/v3/ai-tampered/config +GET /api/v3/ai-tampered/categories +GET /api/v3/claims/config +GET /api/v3/claims/types +GET /api/v3/claims/statuses +GET /api/v3/pipeline/verdict-config -- config verdict +GET /api/v3/pipeline/queue-health -- health RabbitMQ +``` + +--- + +## Endpoint-uri didiFramework (port 3005, doar Docker network) + +Pentru acces extern, foloseste admin dashboard (nginx proxiaza la /framework/). + +### Health + +``` +GET /health +GET /health/all -- verifica si PostgreSQL si MinIO +``` + +### Sync Redis (IMPORTANT) + +``` +POST /api/sync-redis -- sincronizeaza toti parametrii in Redis +GET /api/sync-redis/status -- cand s-a facut ultima sincronizare +``` + +### CRUD parametri (toate au GET, POST, PUT, DELETE) + +/api/dimensions, /api/subdimensions, /api/techniques, /api/indicators, +/api/validation-rules, /api/verdicts/categories, /api/verdicts/risk, +/api/verdicts/severity, /api/weights/components, /api/weights/scenarios, +/api/weights/multipliers, /api/platforms, /api/sources, +/api/claims/status, /api/claims/types, /api/claims/confidence, +/api/claims/interpretation, /api/providers/configs, /api/providers/models, +/api/providers/assignments, /api/providers/keys + +### Utilizatori + +``` +GET /api/admin/users?page=1&limit=20&search=&planId= +PUT /api/admin/users/:id +DELETE /api/admin/users/:id +GET /api/admin/plans +PUT /api/admin/plans/:id +``` + +### Credite (apelat intern de agent-v3) + +``` +POST /api/auth/internal/check-credits {"keycloak_id": "..."} +POST /api/auth/internal/deduct-credits {"keycloak_id": "...", "media_type": "text"} +``` + +--- + +## Constrangeri si limite + +### Dimensiune text + +| Parametru | Valoare | +|----------------|---------| +| Minim text | 20 caractere | +| Maxim text | 50,000 caractere | +| Encoding | UTF-8 valid | + +### Dimensiune fisiere (upload) + +| Tip | Limita | +|------------|----------| +| Imagine | 20 MB | +| Audio | 100 MB | +| Video | 500 MB | +| Text | 10 MB | +| Document | 50 MB | +| Upload API | 50 MB (multer) | + +### Durata media + +| Tip | Limita | +|-------|------------| +| Video | 180s (3 min) | +| Audio | 420s (7 min) | + +### Request payload (Kong) + +Maxim 100 MB per request (request-size-limiting plugin). + +### Timeout-uri + +| Ruta | Timeout | +|------------------------|-----------| +| /api/v3/pipeline/* | 660s (11 min) | +| /*/analyze-media | 300s (5 min) | +| Toate celelalte | 180s (3 min) | +| Kong -> agent-v3 | 660s connect, 660s read | + +### Rate Limiting (Kong) + +| Nivel | Per minut | Per ora | Per zi | +|----------|-----------|---------|---------| +| Global | 100 | 2000 | 10,000 | + +Rate limiting-ul Kong este per consumer (global in staging, nu per user). +In staging, toti clientii sunt un singur consumer anonim. + +Keycloak defineste rate limits per grup dar NU sunt aplicate inca in Kong: +- free-users: 10/min +- paid-users: 60/min +- enterprise-users: 600/min + +### Credite (agent-v3 -> framework) + +Fiecare analiza costa credite. Agent-v3 verifica la framework inainte de analiza. +Costul depinde de media_type (text < image < audio < video). +Daca user-ul nu are credite, raspunsul este 403. + +Utilizatorii pre-configurati au credite initiale limitate (100 pentru free/paid). +admin@didi.local si enterprise@didi.local au credite nelimitate. + +### CORS + +Origins permise: localhost:3000, localhost:3001, localhost:8100, * (wildcard). +Metode: GET, POST, PUT, DELETE, OPTIONS, PATCH. +Credentials: activat. + +--- + +## Cum sa testezi + +### Test simplu (un request) + +```bash +# Health check +curl http://localhost:24803/api/v3/health + +# Analiza text (sincron, poate dura 30-60s) +curl -X POST http://localhost:24803/api/v3/pipeline/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "Vaccinurile COVID au fost create de Bill Gates pentru a implanta cipuri 5G in populatie. Studiile arata ca milioane de oameni au fost afectati."}' + +# Analiza asincrona (raspuns instant, polling pentru rezultat) +curl -X POST http://localhost:24803/api/v3/pipeline/analyze-async \ + -H "Content-Type: application/json" \ + -d '{"text": "Vaccinurile COVID au fost create de Bill Gates.", "plan_type": 1}' +``` + +### Test cu JWT prin Kong + +```bash +# Obtine token +TOKEN=$(curl -s -X POST \ + "http://localhost:28000/realms/didi-clients/protocol/openid-connect/token" \ + -d "grant_type=password&client_id=didi-web-app&username=admin@didi.local&password=admin123" \ + | jq -r '.access_token') + +# Analiza prin Kong (productie path) +curl -k -X POST https://localhost:443/api/analyze \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text": "Text de test pentru analiza."}' +``` + +### Test componenta individuala + +```bash +# Doar techniques +curl -X POST http://localhost:24803/api/v3/techniques/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "Textul de analizat aici"}' + +# Doar claims +curl -X POST http://localhost:24803/api/v3/claims/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "Romania are 20 milioane de locuitori si PIB-ul a crescut cu 15% anul trecut."}' + +# Doar AI detection +curl -X POST http://localhost:24803/api/v3/ai-tampered/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "This text was definitely written by a human and not an AI."}' +``` + +### Test upload media + analiza + +```bash +# Upload imagine +UPLOAD=$(curl -s -X POST http://localhost:24803/api/v3/media/upload \ + -F "file=@/path/to/image.jpg" \ + -F "user_id=test-user") +echo $UPLOAD + +# Extrage URL-ul +MEDIA_URL=$(echo $UPLOAD | jq -r '.data.public_url') + +# Analiza imagine +curl -X POST http://localhost:24803/api/v3/pipeline/analyze-media \ + -H "Content-Type: application/json" \ + -d "{\"media_url\": \"$MEDIA_URL\", \"media_type\": \"image\"}" +``` + +--- + +## Testare multi-user + +### Strategia + +1. Fiecare "user" simulat trimite request-uri cu un user_id diferit +2. Agent-v3 accepta user_id si user_email in body-ul requestului +3. In staging, JWT nu este validat -- deci poti simula useri fara token-uri reale +4. Pentru teste realiste (cu credite, cu token), foloseste utilizatorii Keycloak + +### Ce trebuie stiut + +- Analiza sincrona blocheaza conexiunea 15-120 secunde +- Analiza asincrona returneaza instant si workerii proceseaza in background +- RabbitMQ are 24 cozi: 4 componente x 6 plan types +- Workeri: 2 techniques, 2 ai-tampered, 3 claims, 2 domain, 2 media-preprocess, 2 aggregators +- Fiecare worker proceseaza un singur mesaj la un moment dat (prefetch 3-10 in functie de componenta) +- Claims este cel mai lent (cautare web per claim, 3 replici) +- Domain este cel mai rapid (analiza locala, fara LLM) +- Modelele LLM externe (OpenRouter, OpenAI, Groq) au propriile rate limits + +### Throughput estimat + +| Plan type | Ce se intampla | +|-----------|---------------------------------------| +| 1 (free) | Prioritate minima in coada | +| 6 (enterprise) | Prioritate maxima in coada | + +Cu 2 workers techniques si prefetch 5, poti procesa ~10 analize text simultane. +Claims este bottleneck: 3 workers x prefetch 3 = ~9 analize simultane. +Video/audio sunt mult mai lente (transcriere + viziune): 1-5 minute per analiza. + +### Endpoint recomandat pentru stress test + +Foloseste analiza asincrona: + +``` +POST /api/v3/pipeline/analyze-async +{"text": "...", "plan_type": 1} +``` + +Avantaje: +- Raspuns instant (202 Accepted) +- Workerii proceseaza in paralel +- Poti monitoriza progresul individual per sesiune +- Nu blocheaza conexiunea HTTP + +Polling status: + +``` +GET /api/v3/pipeline/{session_id}/queue-status +``` + +### Bottleneck-uri de monitorizat + +| Resursa | Cum verifici | +|-------------------|------------------------------------------------------| +| RabbitMQ | http://localhost:15672 (admin/rabbitmq123) | +| Redis memorie | docker exec didi-cache redis-cli -a redis123 info memory | +| Workers activi | docker ps --filter name=agent-v3-worker | +| PG conexiuni | Prin PgAdmin http://localhost:5050 | +| Cozi pline | RabbitMQ UI -> Queues -> Ready messages | + +### Limitari stress test + +1. API keys LLM (OpenRouter, OpenAI, Groq) au rate limits proprii -- daca trimiti 50 analize simultan, vei primi erori 429 de la providerii LLM +2. Modelul local Qwen Vision (10.11.10.17:14011) proceseaza secvential -- nu scala orizontal +3. M17 Whisper (10.11.10.17:54300) -- un singur endpoint, probabil limitat +4. Redis 512MB -- la volum mare de sesiuni simultane, verifica memoria +5. PostgreSQL cluster -- in general nu este bottleneck, dar verifica conexiunile active + +### Chei Redis pentru monitoring + +```bash +# Sesiuni active +docker exec didi-cache redis-cli -a redis123 keys "didi:pipeline:*:status" | wc -l + +# Lock-uri active (workeri in procesare) +docker exec didi-cache redis-cli -a redis123 keys "didi:queue:lock:*" | wc -l + +# Framework config (trebuie sa existe mereu) +docker exec didi-cache redis-cli -a redis123 keys "didi:framework:*" +``` + +--- + +## Structura raspuns AnalysisSession + +Orice analiza completa returneaza acest format: + +``` +session_id -- UUID unic +status -- running | completed | failed +input_type -- text | url | image | audio | video +risk_score -- 0-100 (scor final) +risk_category -- RELIABLE | MOSTLY_RELIABLE | MIXED | UNRELIABLE | DISINFORMATION | INCONCLUSIVE +risk_level -- VERY_LOW | LOW | MODERATE | HIGH | VERY_HIGH | CRITICAL +confidence -- 0-100 +total_duration_ms -- milisecunde + +techniques.manipulation_score -- 0-100 +ai_tampered.ai_probability -- 0-100 +claims.credibility_score -- 0-100 (null daca nu sunt claims) +domain.trust_score -- 0-100 (null daca nu exista URL) +verdict.risk_score -- 0-100 (identic cu root risk_score) +verdict.explanation_ro -- explicatie in romana +verdict.explanation_en -- explicatie in engleza +``` + +--- + +## Coduri eroare frecvente + +| Cod | Cauza | Solutie | +|-----|------------------------------------------|----------------------------------| +| 400 | Text prea scurt (<20 chars) sau invalid | Mareste textul | +| 400 | media_type invalid sau lipsa | Verifica parametrii | +| 403 | Credite insuficiente | Foloseste admin@didi.local | +| 408 | Timeout (analiza prea lenta) | Foloseste analyze-async | +| 413 | Payload prea mare (>100MB) | Micoreaza fisierul | +| 429 | Rate limit Kong | Asteapta 1 minut | +| 500 | Eroare interna (LLM, Redis, PG) | Verifica logs: docker logs didi-agent-v3 | +| 502 | Serviciu backend indisponibil | Verifica ca agent-v3 ruleaza | +| 504 | Gateway timeout | Analiza dureaza prea mult | + +--- + +## Verificare rapida ca totul functioneaza + +Aceste comenzi, in ordine, confirma ca platforma este operationala: + +```bash +# 1. Health agent-v3 +curl -s http://localhost:24803/api/v3/health | jq . + +# 2. Config exista in Redis (trebuie sa fie non-null) +curl -s http://localhost:24803/api/v3/techniques/definitions | jq '.dimensions | length' + +# 3. Analiza text rapida (30-60s) +curl -s -X POST http://localhost:24803/api/v3/techniques/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "Studiile demonstreaza ca pamantul este plat si NASA ne minte de decenii. Milioane de oameni au descoperit adevarul."}' | jq '{manipulation_score, techniques_count}' + +# 4. Analiza completa (60-120s) +curl -s -X POST http://localhost:24803/api/v3/pipeline/analyze \ + -H "Content-Type: application/json" \ + -d '{"text": "Studiile demonstreaza ca pamantul este plat si NASA ne minte de decenii. Milioane de oameni au descoperit adevarul."}' | jq '{risk_score, risk_category, confidence}' + +# 5. RabbitMQ functional (trebuie sa fie cozi) +curl -s -u admin:rabbitmq123 http://localhost:15672/api/queues | jq '.[].name' | head -10 +``` diff --git a/backend/production/API_TEST_REPORT_2026-07-08.md b/backend/production/API_TEST_REPORT_2026-07-08.md new file mode 100644 index 0000000..6068cd4 --- /dev/null +++ b/backend/production/API_TEST_REPORT_2026-07-08.md @@ -0,0 +1,89 @@ +# Raport testare API — 2026-07-08 + +> Dovadă pentru criteriul 6 din caietul de sarcini („Implementare & transfer": OpenAPI/Swagger + +> set minim teste API). Documentul-pereche: `API_TESTING_GUIDE.md` (ghid de testare manuală) și +> specificațiile `agent-v3/openapi.yaml` + `didiFramework/openapi.yaml`. + +## Metodă + +1. **Inventar din cod, nu din documentație**: toate definițiile de rute Express au fost extrase + automat din sursă (`scripts/api/api_probe.py`). S-a verificat separat că nu există rute definite + dinamic (variabile/template literals), mount-uri cu prefix nescanate sau generatoare CRUD active — + toate cele 4 verificări au ieșit goale, deci inventarul static este complet. +2. **Probă live pe didi11**: fiecare endpoint apelat cu token Keycloak real (user `api-test`, + realm `didi-admins`, rol `admin`). Strategie non-distructivă: GET real; POST/PUT/PATCH/DELETE cu + body gol sau ID inexistent (răspunsul 400/404 dovedește cablarea fără a muta date). Excepții + idempotente rulate real: `dry-run`, `sync-redis`, `check-credits`. Endpoint-urile cu efect real + pe body gol (email de test, credit-reset, use-credit) sunt consemnate ca verificate manual și + excluse din rulările automate. +3. **Verificare integritate**: snapshot pe 7 contoare DB înainte/după rularea finală — identic + (800 indicatori / 621 reguli / 166 tehnici / 23 modele / 83 assignments / sesiuni / utilizatori). + +## Rezultat + +| Metric | Valoare | +|---|---| +| Endpoint-uri inventariate | **374** (87 agent-v3 + 287 didiFramework) | +| Cablate (răspund cu handler propriu) | **374 / 374** | +| Rute moarte (`Cannot GET/POST …`) | **0** | +| Distribuție status finală | 200×135 · 400×102 · 401×4 · 403×2 · 404×118 · 409×9 · 500×4 | + +Cele 401/403 sunt comportament CORECT (extension cere `X-API-Key`; claim/resolve moderare cer rol +`moderator`/`senior_moderator`, pe care admin nu îl are — separare de roluri funcțională). + +## Buguri găsite și REPARATE în această sesiune + +| Endpoint | Problemă | Fix | +|---|---|---| +| `GET /api/validation-rules/stats` | 500 — umbrit de ruta `/:id` (declarată înainte) | reordonare rute (`validation-rules.ts`) | +| `DELETE /api/indicators/by-technique/:techniqueId` | inaccesibil — umbrit de `/:techniqueId/:indicatorId` | reordonare rute (`indicators.ts`) | +| `POST /api/sync-analysis/batch` | umbrit de `/:sessionId` (legacy, reparat oricum) | reordonare rute (`sync-analysis.ts`) | +| `GET /api/weights/multipliers/type/:type` | 500 pe input non-numeric (coloana e integer) | validare → 400 (`weights.ts`) | + +S-a rulat și un scan sistematic de umbriri de rute pe ambele servicii — zero umbriri rămase. + +## Probleme cunoscute, deschise (consemnate, ne-blocante) + +1. **`/api/waitlist/*` → 500**: cere `STAGING_DB_HOST` + containerul `staging-dataLayer-postgres` + (absent pe didi11). Feature pre-lansare, marcat `deprecated` în spec. Remediere: setare env + + pornire container, sau eliminarea rutelor. +2. **`GET /api/v3/pipeline/history/admin/:id`** răspunde 500 (în loc de 400) dacă `:id` nu e UUID — + gap cosmetic de validare în agent-v3; cu UUID valid răspunde corect (`Session not found` / 200). +3. Endpoint-uri legacy marcate `deprecated` în spec: `domain/*` (înlocuit de source-assessment), + `sync-analysis/*` (agent-v3 persistă direct în PG), `prompts/*` pe fișiere (sursa operațională + e DB via `/api/providers/prompts`). + +## Artefacte + +| Artefact | Locație | +|---|---| +| Spec OpenAPI 3.0.3 agent-v3 (87 operații) | `backend/services/orchestration-layer/agent-v3/openapi.yaml` | +| Spec OpenAPI 3.0.3 didiFramework (287 operații) | `backend/services/orchestration-layer/didiFramework/openapi.yaml` | +| Script probă (reproductibil la recepție) | `scripts/api/api_probe.py` | +| Generator spec din inventar + probe | `scripts/api/generate_openapi.py` | +| Rezultate brute probă | `scripts/api/probe_results_2026-07-08.json` | +| Swagger UI live | `http://10.11.10.11:8089` (container `didi-api-docs`, servește ambele spec-uri) | + +Ambele spec-uri sunt **validate** cu `openapi-spec-validator` (OK). Fiecare operație poartă +adnotarea `x-tested` cu statusul HTTP observat la probă și data testării. + +## Reproducere + +```bash +# 1. Probă completă (necesită serviciile pornite + Keycloak local) +python3 scripts/api/api_probe.py x /tmp/probe_results.json + +# 2. Regenerare spec-uri +python3 scripts/api/generate_openapi.py /tmp/probe_results.json \ + backend/services/orchestration-layer/agent-v3/openapi.yaml \ + backend/services/orchestration-layer/didiFramework/openapi.yaml + +# 3. Validare +docker run --rm -v $PWD/backend/services/orchestration-layer:/s python:3.12-alpine \ + sh -c 'pip install -q openapi-spec-validator && \ + python -m openapi_spec_validator /s/agent-v3/openapi.yaml && \ + python -m openapi_spec_validator /s/didiFramework/openapi.yaml' +``` + +User de test recepție: `api-test` / `ApiTest2026x` (realm `didi-admins`, rol `admin`; +cont PG auto-creat `api-test@didi.local`, internet_user_id 90001). diff --git a/backend/production/build-local.sh b/backend/production/build-local.sh new file mode 100644 index 0000000..1721c27 --- /dev/null +++ b/backend/production/build-local.sh @@ -0,0 +1,333 @@ +#!/bin/bash +# ============================================================================= +# DIDI Platform — Build From Scratch (topologie LOCALĂ) +# ============================================================================= +# Ridică întreaga platformă backend de la zero, cu TOATE serviciile în containere +# locale pe mașina de deployment (fără cluster extern). Topologia serviciilor: +# +# Data layer : didi-postgres (PG17, DB principală LOCALĂ) + didi-cache (Redis) +# + staging-dataLayer-rabbitmq + staging-dataLayer-minio + didi-keycloak +# → docker-compose.local.yml +# Gateway : didi-kong (DBless, imagine didi-kong:latest) +# Orchestration: didi-framework + didi-agent-v3 + 13 workeri +# UI : didi-admin +# +# Seed: DIDI_full_export_2026-07-02.sql (23MB, schema + date + migrațiile 016/017). +# Se importă automat la PRIMUL boot al didi-postgres (volum gol), prin +# montarea în /docker-entrypoint-initdb.d. Redis se reface din PG cu sync-redis. +# +# Cerințe: docker, docker compose v2+. Nicio dependență de rețea externă. +# Rulare: chmod +x build-local.sh && ./build-local.sh [HOSTNAME] +# (implicit = hostname-ul mașinii curente) +# +# NB: documentul-pereche este DEPLOY_FROM_SCRATCH.md. Scriptul vechi full-build.sh +# vizează topologia cluster (PG/Kong externe) și este păstrat ca referință. +# ============================================================================= + +set -euo pipefail + +# Hostname-ul mașinii de deployment. Implicit = hostname-ul mașinii curente +# (agnostic — funcționează pe orice mașină). Se poate suprascrie ca prim argument. +PLATFORM_HOSTNAME="${1:-$(hostname -f 2>/dev/null || hostname)}" + +# IP-ul principal LAN al mașinii (pentru dashboard + redirect URIs Keycloak). +# Portabil: se poate suprascrie ca al 2-lea argument. +HOST_IP="${2:-$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^(127\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.|10\.0\.)' | head -1)}" +[ -z "$HOST_IP" ] && HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' + +# -- Paths (auto-detectate din locația scriptului — portabil pe orice mașină) --- +# Scriptul stă în backend/production/ → BACKEND = părintele lui production/ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND="$(cd "$SCRIPT_DIR/.." && pwd)" +DATA_LAYER="$BACKEND/services/data-layer" +DIDI_DB="$DATA_LAYER/didiDatabase" +FRAMEWORK="$BACKEND/services/orchestration-layer/didiFramework" +AGENT_V3="$BACKEND/services/orchestration-layer/agent-v3" +KONG_DIR="$BACKEND/services/gateway-auth-layer/didiKong" +ADMIN_DIR="$BACKEND/admin-dashboard" + +# -- PostgreSQL LOCAL (container didi-postgres) ---------------------------- +PG_CONTAINER="didi-postgres" +PG_USER="bos_interface" +PG_DB="DIDI" +SEED_FILE="$DIDI_DB/DIDI_full_export_2026-07-02.sql" # sursă de adevăr; NU cel din martie + +# -- Keycloak LOCAL -------------------------------------------------------- +KC_PORT="28080" # host → 8080 container +KC_BASE="http://localhost:${KC_PORT}/auth" # servit sub /auth (KC_HTTP_RELATIVE_PATH) +KC_ADMIN_USER="admin" +KC_ADMIN_PASS="admin123" + +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*"; } +ok() { echo -e "${GREEN} OK${NC} $*"; } +warn() { echo -e "${YELLOW} WARN${NC} $*"; } +fail() { echo -e "${RED} FAIL${NC} $*"; exit 1; } +section() { echo; echo -e "${YELLOW}====================================================================${NC}"; echo -e "${YELLOW} $*${NC}"; echo -e "${YELLOW}====================================================================${NC}"; } + +wait_healthy() { + local container="$1" max="${2:-120}" elapsed=0 status + log "Aștept $container să fie healthy (max ${max}s)..." + while [ $elapsed -lt $max ]; do + status=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}' "$container" 2>/dev/null || echo "missing") + [ "$status" = "healthy" ] || [ "$status" = "running" ] && { ok "$container ($status)"; return 0; } + sleep 3; elapsed=$((elapsed + 3)) + done + warn "$container nu a devenit healthy în ${max}s (status: $status)"; return 1 +} + +pg() { docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -tAc "$1" 2>/dev/null; } + +# ============================================================================= +section "FAZA 0: Verificări preliminare" +# ============================================================================= +docker info >/dev/null 2>&1 || fail "Docker nu rulează"; ok "Docker activ" +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 lipsește"; ok "Docker Compose disponibil" +[ -f "$SEED_FILE" ] || fail "Lipsește seed-ul: $SEED_FILE" +ok "Seed găsit: $(basename "$SEED_FILE") ($(du -h "$SEED_FILE" | cut -f1))" + +# ============================================================================= +section "FAZA 1: Docker network" +# ============================================================================= +docker network create didi-network 2>/dev/null && ok "Rețea creată" || ok "Rețeaua didi-network există deja" + +# ============================================================================= +section "FAZA 2: Data layer local (PG + seed, Redis, RabbitMQ, MinIO, Keycloak)" +# ============================================================================= +cd "$DATA_LAYER" + +# didi-postgres importă seed-ul automat la primul boot pe volum gol +# (montaj /tmp/didi_full_dump.sql → /docker-entrypoint-initdb.d/01-dump.sql, vezi compose). +# Prima instalare = containerul didi-postgres nu există încă (deci nici volumul lui). +if [ -n "$(docker ps -aq -f name=^${PG_CONTAINER}$)" ]; then + warn "didi-postgres există deja — NU re-importez seed-ul (baza e populată)." + warn "Pentru un import curat: oprește stack-ul + șterge volumul didi-postgres-data, apoi re-rulează." + SEED_ON_INIT=0 +else + log "Prima instalare — pregătesc seed-ul pentru auto-import la boot..." + cp "$SEED_FILE" /tmp/didi_full_dump.sql + ok "Seed copiat în /tmp/didi_full_dump.sql (montat ca init script în didi-postgres)" + SEED_ON_INIT=1 +fi + +log "Pornesc data layer (docker-compose.local.yml)..." +docker compose -f docker-compose.local.yml up -d 2>&1 | tail -6 +ok "Data layer pornit" + +wait_healthy "$PG_CONTAINER" 90 +# Aștept ca importul init (dacă e prima instalare) să termine — poate dura pe seed 23MB +if [ "${SEED_ON_INIT:-0}" = "1" ]; then + log "Aștept finalizarea importului seed (init script rulează la primul boot)..." + for i in $(seq 1 60); do + C=$(pg "SELECT count(*) FROM information_schema.schemata WHERE schema_name IN ('bos_analysis','bos_parammgmt','bos_sysadmin','bos_subscriber')" || echo 0) + [ "$C" = "4" ] && break + sleep 3 + done +fi + +SCHEMAS=$(pg "SELECT count(*) FROM information_schema.schemata WHERE schema_name IN ('bos_analysis','bos_parammgmt','bos_sysadmin','bos_subscriber')" || echo 0) +[ "$SCHEMAS" = "4" ] || fail "Baza DIDI nu are cele 4 scheme bos_* (găsit: $SCHEMAS). Import eșuat." +ok "Baza DIDI populată: 4 scheme bos_*" + +# Sanity pe tabelele-cheie (nu doar tehnici) +log "Verific tabele critice..." +for entry in "bos_parammgmt|technique|166" "bos_parammgmt|dimension|8" "bos_parammgmt|llm_model|" \ + "bos_parammgmt|component_stage_assignment|" "bos_parammgmt|input_type_profile|6" \ + "bos_parammgmt|source_type|" "bos_parammgmt|verdict_category|" \ + "bos_sysadmin|subscription_plan|" "bos_analysis|analysis_session|"; do + IFS='|' read -r sch tbl exp <<< "$entry" + n=$(pg "SELECT count(*) FROM ${sch}.${tbl}" || echo "ERR") + if [ "$n" = "ERR" ]; then warn " LIPSĂ: ${sch}.${tbl}"; else ok " ${sch}.${tbl} = ${n}${exp:+ (aștept ~$exp)}"; fi +done + +# Localizare config LLM: seed-ul livrează modelul primar ca `Qwen3.5-397B-A17B` pe +# provider remote `10.11.10.17` — dar vLLM-ul local servește `qwen3.5` prin routerul +# `llm-api:14011`. Aliniem model_code + provider base_url (idempotent, rulează la fiecare build). +log "Localizez config LLM (model → qwen3.5, provideri → llm-api:14011)..." +docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c " + UPDATE bos_parammgmt.llm_model SET model_code='qwen3.5', model_name='Qwen 3.5 (local)' WHERE model_code='Qwen3.5-397B-A17B'; + UPDATE bos_parammgmt.llm_provider SET base_url='http://llm-api:14011/v1' WHERE base_url LIKE 'http://10.11.10.17:1401%'; +" >/dev/null 2>&1 && ok "Config LLM localizat (qwen3.5 @ llm-api:14011)" || warn "Localizarea config LLM a eșuat — verifică manual" + +wait_healthy didi-cache 30 +wait_healthy staging-dataLayer-rabbitmq 90 +wait_healthy staging-dataLayer-minio 60 +wait_healthy didi-keycloak 180 + +# --- Localizare Keycloak: frontendUrl + user admin + redirect URIs pentru HOST_IP --- +# Realm-import livrează didi-admins fără user de admin și cu frontendUrl staging. +log "Localizez Keycloak (frontendUrl + user admin + redirect URIs) pentru $HOST_IP..." +KC_API="http://localhost:${KC_PORT}/auth" +for i in $(seq 1 30); do + KTOK=$(curl -s -X POST "$KC_API/realms/master/protocol/openid-connect/token" -d "client_id=admin-cli" -d "username=${KC_ADMIN_USER}" -d "password=${KC_ADMIN_PASS}" -d "grant_type=password" 2>/dev/null | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + [ -n "$KTOK" ] && break; sleep 3 +done +if [ -n "$KTOK" ]; then + # frontendUrl → host curent (issuer-ul token-ului trebuie sa coincida cu dashboard-ul) + curl -s -X PUT "$KC_API/admin/realms/didi-admins" -H "Authorization: Bearer $KTOK" -H 'Content-Type: application/json' \ + -d "{\"realm\":\"didi-admins\",\"attributes\":{\"frontendUrl\":\"https://${HOST_IP}:3001/auth\"}}" >/dev/null 2>&1 + # user admin (idempotent) + parola conforma cu passwordPolicy (length10+upper+digit+lower) + curl -s -X POST "$KC_API/admin/realms/didi-admins/users" -H "Authorization: Bearer $KTOK" -H 'Content-Type: application/json' \ + -d '{"username":"admin","email":"admin@didi.local","enabled":true,"emailVerified":true,"firstName":"DIDI","lastName":"Admin","credentials":[{"type":"password","value":"Admin12345","temporary":false}]}' >/dev/null 2>&1 + AUID=$(curl -s "$KC_API/admin/realms/didi-admins/users?username=admin" -H "Authorization: Bearer $KTOK" 2>/dev/null | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) + # admin + moderator + senior_moderator (write-ul de moderare cere moderator/senior_moderator, NU admin) + AROLES=$(curl -s "$KC_API/admin/realms/didi-admins/roles" -H "Authorization: Bearer $KTOK" 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps([{'id':r['id'],'name':r['name']} for r in d if r['name'] in ('admin','moderator','senior_moderator')]))" 2>/dev/null) + [ -n "$AUID" ] && [ -n "$AROLES" ] && curl -s -X POST "$KC_API/admin/realms/didi-admins/users/$AUID/role-mappings/realm" -H "Authorization: Bearer $KTOK" -H 'Content-Type: application/json' -d "$AROLES" >/dev/null 2>&1 + # redirect URIs + webOrigin pentru host curent pe clientul admin-dashboard + ACID=$(curl -s "$KC_API/admin/realms/didi-admins/clients?clientId=admin-dashboard" -H "Authorization: Bearer $KTOK" 2>/dev/null | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) + if [ -n "$ACID" ]; then + curl -s "$KC_API/admin/realms/didi-admins/clients/$ACID" -H "Authorization: Bearer $KTOK" 2>/dev/null > /tmp/ac.json + python3 - "$HOST_IP" <<'PYKC' 2>/dev/null +import json,sys +ip=sys.argv[1]; c=json.load(open('/tmp/ac.json')) +u=set(c.get('redirectUris',[])); u.update([f'https://{ip}:3001/admin/*',f'https://{ip}:3001/*']); c['redirectUris']=sorted(u) +w=set(c.get('webOrigins',[])); w.add(f'https://{ip}:3001'); c['webOrigins']=sorted(w) +json.dump(c,open('/tmp/ac.json','w')) +PYKC + curl -s -X PUT "$KC_API/admin/realms/didi-admins/clients/$ACID" -H "Authorization: Bearer $KTOK" -H 'Content-Type: application/json' -d @/tmp/ac.json >/dev/null 2>&1 + fi + ok "Keycloak localizat — login: admin / Admin12345 (realm didi-admins)" +else + warn "Nu am putut obține token Keycloak — creează userul admin manual" +fi + +# ============================================================================= +section "FAZA 3: MinIO — bucket-uri" +# ============================================================================= +if [ -f "$DATA_LAYER/didiStorage/init-buckets.sh" ]; then + log "Rulez init-buckets.sh în containerul MinIO..." + docker exec -e MINIO_HOST=localhost -e MINIO_PORT=9000 \ + -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minio123 \ + staging-dataLayer-minio sh -c "$(cat "$DATA_LAYER/didiStorage/init-buckets.sh")" 2>&1 | tail -5 || warn "init-buckets a raportat erori (verifică manual)" + ok "Bucket-uri inițializate" +else + warn "init-buckets.sh lipsește — creează bucket-urile manual dacă e nevoie" +fi + +# ============================================================================= +section "FAZA 4: Kong (gateway DBless, imagine didi-kong:latest)" +# ============================================================================= +cd "$KONG_DIR" +if ! docker images didi-kong:latest --format '{{.ID}}' | grep -q .; then + log "Build imagine didi-kong:latest..." + docker build -t didi-kong:latest . 2>&1 | tail -3 +fi +docker rm -f didi-kong >/dev/null 2>&1 || true +log "Pornesc didi-kong (DBless, config local)..." +docker run -d --name didi-kong --network didi-network --restart unless-stopped \ + -p 127.0.0.1:18000:8000 -p 127.0.0.1:18001:8001 -p 127.0.0.1:18443:8443 \ + -e KONG_DATABASE=off \ + -e KONG_DECLARATIVE_CONFIG=/kong/declarative/kong.yml \ + -e "KONG_PROXY_LISTEN=0.0.0.0:8000, 0.0.0.0:8443 ssl" \ + -e KONG_ADMIN_LISTEN=0.0.0.0:8001 \ + -v "$KONG_DIR/declarative/kong.yml.didi11-local:/kong/declarative/kong.yml:ro" \ + didi-kong:latest >/dev/null +wait_healthy didi-kong 60 || warn "Kong nu a raportat healthy — verifică: docker logs didi-kong" + +# ============================================================================= +section "FAZA 5: didiFramework (CRUD parametri) + sync Redis" +# ============================================================================= +cd "$FRAMEWORK" +log "Build + start didi-framework..." +docker compose up -d --build 2>&1 | tail -5 +wait_healthy didi-framework 90 + +log "Sync framework → Redis (regenerează cache-ul din PostgreSQL)..." +SYNC=$(docker exec didi-framework wget -qO- --post-data='' "http://127.0.0.1:3005/api/sync-redis" 2>/dev/null || echo FAIL) +echo "$SYNC" | grep -q '"success"' && ok "Sync Redis reușit" || warn "Sync Redis posibil eșuat: $SYNC" +KF=$(docker exec didi-cache redis-cli -a redis123 --no-auth-warning keys "didi:framework:*" 2>/dev/null | wc -l) +KC=$(docker exec didi-cache redis-cli -a redis123 --no-auth-warning keys "didi:config:*" 2>/dev/null | wc -l) +ok "Redis: $KF chei framework, $KC chei config" +[ "$KF" -ge 5 ] || warn "Prea puține chei framework ($KF) — verifică sync-ul" + +# ============================================================================= +section "FAZA 6: Agent V3 + workeri" +# ============================================================================= +cd "$AGENT_V3" +[ -f .env ] || warn ".env lipsește în agent-v3 — analizele LLM nu vor merge fără OPENROUTER/OPENAI/GROQ keys" +# PUBLIC_API_BASE_URL (media URLs) = host curent, reachable din browser ȘI din workeri +if [ -f .env ]; then + if grep -q '^PUBLIC_API_BASE_URL=' .env; then + sed -i -E "s|^(PUBLIC_API_BASE_URL=).*|\1http://${HOST_IP}:24803|" .env + else + echo "PUBLIC_API_BASE_URL=http://${HOST_IP}:24803" >> .env + fi + ok "agent-v3 PUBLIC_API_BASE_URL → http://${HOST_IP}:24803" +fi +log "Build + start agent-v3 + workeri..." +docker compose up -d --build 2>&1 | tail -10 +wait_healthy didi-agent-v3 90 +W=$(docker ps --format '{{.Names}}' | grep -c "agent-v3-worker" || true) +A=$(docker ps --format '{{.Names}}' | grep -c "verdict-aggregator" || true) +ok "Workeri: $W | Aggregators: $A" + +# ============================================================================= +section "FAZA 7: Admin Dashboard" +# ============================================================================= +if docker ps --format '{{.Names}}' | grep -q "^didi-admin"; then + ok "Admin dashboard rulează deja" +else + log "Build + start didi-admin..." + cd "$ADMIN_DIR" + # Config React baked în bundle → aliniat la HOST_IP curent (Keycloak + API pe host:3001) + if [ -f .env ]; then + sed -i -E "s|^(REACT_APP_SERVER_HOST=).*|\1${HOST_IP}|; s|^(REACT_APP_HOST=).*|\1${HOST_IP}|; s|^(REACT_APP_KEYCLOAK_URL=).*|\1https://${HOST_IP}:3001/auth|; s|^(REACT_APP_API_BASE_URL=).*|\1https://${HOST_IP}:3001|" .env + ok "admin-dashboard/.env aliniat la ${HOST_IP}" + fi + docker build -t didi-admin:latest . 2>&1 | tail -5 + docker rm -f didi-admin-local >/dev/null 2>&1 || true + docker run -d --name didi-admin-local --network didi-network \ + -p 3081:80 -p 3001:443 --restart unless-stopped didi-admin:latest >/dev/null + ok "Admin dashboard pornit (3081 HTTP / 3001 HTTPS)" +fi + +# ============================================================================= +section "FAZA 8: Observabilitate (Prometheus/Grafana/Loki/Jaeger — opțional)" +# ============================================================================= +OBS_DIR="$BACKEND/observability" +if [ -f "$OBS_DIR/docker-compose.yml" ]; then + cd "$OBS_DIR" + log "Pornesc stack-ul de observabilitate..." + docker compose up -d 2>&1 | tail -4 + ok "Observabilitate pornită — Grafana :3030, Prometheus :9090, Jaeger :16686" +else + warn "observability/ lipsește — sar peste (opțional)" +fi + +# ============================================================================= +section "VERIFICARE FINALĂ" +# ============================================================================= +echo; log "Health checks:" +declare -A CHECKS=( + ["PostgreSQL"]="docker exec $PG_CONTAINER pg_isready -U $PG_USER 2>/dev/null" + ["Redis"]="docker exec didi-cache redis-cli -a redis123 --no-auth-warning ping 2>/dev/null" + ["RabbitMQ"]="docker exec staging-dataLayer-rabbitmq rabbitmq-diagnostics -q ping 2>/dev/null" + ["MinIO"]="docker exec staging-dataLayer-minio mc ready local 2>/dev/null" + ["Keycloak"]="curl -s -o /dev/null -w %{http_code} ${KC_BASE}/realms/master 2>/dev/null" + ["Kong"]="curl -s -o /dev/null -w %{http_code} http://127.0.0.1:18001/status 2>/dev/null" + ["Framework"]="docker exec didi-framework wget -qO- http://127.0.0.1:3005/health 2>/dev/null" + ["Agent-V3"]="docker exec didi-agent-v3 wget -qO- http://localhost:24803/api/v3/health 2>/dev/null" +) +for name in PostgreSQL Redis RabbitMQ MinIO Keycloak Kong Framework Agent-V3; do + r=$(eval "${CHECKS[$name]}" || echo FAIL) + echo "$r" | grep -qiE "PONG|ok|healthy|ready|service|accepting|200" && ok "$name" || warn "$name: $r" +done + +echo +echo -e "${GREEN}====================================================================${NC}" +echo -e "${GREEN} BUILD LOCAL COMPLET — $PLATFORM_HOSTNAME${NC}" +echo -e "${GREEN}====================================================================${NC}" +echo +echo "Endpoint-uri:" +echo " Admin Dashboard: https://localhost:3001/admin (sau prin frontend)" +echo " Agent V3 API: http://localhost:24803/api/v3/health" +echo " Framework API: intern pe didi-network (port 3005)" +echo " Kong Gateway: http://127.0.0.1:18000 (proxy) / :18001 (admin)" +echo " Keycloak: ${KC_BASE} (admin/${KC_ADMIN_PASS}, realms didi-clients + didi-admins)" +echo " RabbitMQ UI: http://localhost:15672 (admin/rabbitmq123)" +echo " MinIO Console: http://localhost:9001 (minioadmin/minio123)" +echo " API docs (Swagger): http://localhost:8089 (dacă didi-api-docs rulează)" +echo +echo "Seed importat automat la primul boot al didi-postgres. Re-sync Redis oricând:" +echo " docker exec didi-framework wget -qO- --post-data='' http://127.0.0.1:3005/api/sync-redis" diff --git a/backend/production/docker-compose.yml b/backend/production/docker-compose.yml new file mode 100644 index 0000000..7b6df74 --- /dev/null +++ b/backend/production/docker-compose.yml @@ -0,0 +1,49 @@ +# ============================================================================= +# DIDI Platform - Production Services +# ============================================================================= +# Redis fallback container only. +# Database: PostgreSQL Cluster (10.11.50.167:5000) +# +# REMOVED 2026-04-28: kong (cluster-based now — HAProxy 10.11.10.175 -> DP1/DP2) +# See services/gateway-auth-layer/didiKong/MIGRATION.md +# +# REMOVED 2026-04-30: keycloak (mutat temporar pe SSO extern, revenit ulterior la Keycloak local) +# See services/gateway-auth-layer/didiKeycloak/MIGRATION.md +# ============================================================================= + +name: didi-production + +services: + # =========================================================================== + # Redis Cache + # =========================================================================== + didi-cache: + image: redis:7-alpine + container_name: didi-cache + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} --save 60 1 --save 300 10 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + # Port removed - accessible only within Docker network + # ports: + # - "6379:6379" + volumes: + - didi-cache-data:/data + networks: + - didi-network + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + didi-network: + external: true + +volumes: + didi-cache-data: + name: didi-production-cache-data + # didi-keycloak-data volume kept on disk (didi-production-keycloak-data) for + # 1-week archival. Remove after 2026-05-07 if no rollback needed: + # docker volume rm didi-production-keycloak-data diff --git a/backend/production/full-build.sh b/backend/production/full-build.sh new file mode 100644 index 0000000..93c91de --- /dev/null +++ b/backend/production/full-build.sh @@ -0,0 +1,574 @@ +#!/bin/bash +# ============================================================================= +# DIDI Platform - Full Build From Scratch +# ============================================================================= +# Ridica intreaga platforma de la zero. +# Ordinea: network -> data-layer -> production (redis, keycloak, kong) -> +# framework (sync redis) -> agent-v3 + workers -> admin dashboard +# +# Cerinte: docker, docker compose v2+, conexiune la PG cluster 10.11.50.167:5000 +# Rulare: chmod +x full-build.sh && ./full-build.sh [HOSTNAME] +# Exemplu: ./full-build.sh +# ./full-build.sh didi365.eu +# Daca nu specifici hostname, il detecteaza automat din hostname -f. +# ============================================================================= + +set -euo pipefail + +# -- Hostname --------------------------------------------------------------- +if [ -n "${1:-}" ]; then + PLATFORM_HOSTNAME="$1" +else + PLATFORM_HOSTNAME=$(hostname -f 2>/dev/null || hostname) +fi + +# -- Culori ---------------------------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' + +# -- Paths ----------------------------------------------------------------- +BACKEND="/home/admin365/didi_mono/backend" +DATA_LAYER="$BACKEND/services/data-layer" +PRODUCTION="$BACKEND/production" +FRAMEWORK="$BACKEND/services/orchestration-layer/didiFramework" +AGENT_V3="$BACKEND/services/orchestration-layer/agent-v3" +KONG_DIR="$BACKEND/services/gateway-auth-layer/didiKong" + +# -- Conexiune PG cluster -------------------------------------------------- +PG_HOST="10.11.50.167" +PG_PORT="5000" +PG_USER="bos_interface" +PG_PASS="interface" +PG_DB="DIDI" + +# -- Functii helper --------------------------------------------------------- +log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*"; } +ok() { echo -e "${GREEN} OK${NC} $*"; } +warn() { echo -e "${YELLOW} WARN${NC} $*"; } +fail() { echo -e "${RED} FAIL${NC} $*"; exit 1; } + +wait_healthy() { + local container="$1" + local max_wait="${2:-120}" + local elapsed=0 + log "Astept container $container sa fie healthy (max ${max_wait}s)..." + while [ $elapsed -lt $max_wait ]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "missing") + if [ "$status" = "healthy" ]; then + ok "$container este healthy" + return 0 + fi + sleep 3 + elapsed=$((elapsed + 3)) + done + warn "$container nu a devenit healthy in ${max_wait}s (status: $status)" + return 1 +} + +pg_query() { + # Executa query PG prin orice container care are psql/node disponibil + local query="$1" + if docker ps --format '{{.Names}}' | grep -q staging-dataLayer-postgres; then + docker exec -e PGPASSWORD="$PG_PASS" staging-dataLayer-postgres \ + psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -tAc "$query" 2>/dev/null + elif docker ps --format '{{.Names}}' | grep -q didi-framework; then + docker exec didi-framework node -e " + const {Pool}=require('pg'); + const p=new Pool({host:'$PG_HOST',port:$PG_PORT,database:'$PG_DB',user:'$PG_USER',password:'$PG_PASS'}); + p.query(\`$query\`).then(r=>{r.rows.forEach(row=>console.log(Object.values(row).join('|')));p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)}); + " 2>/dev/null + else + return 1 + fi +} + +section() { + echo "" + echo -e "${YELLOW}====================================================================${NC}" + echo -e "${YELLOW} $*${NC}" + echo -e "${YELLOW}====================================================================${NC}" +} + +# ============================================================================= +section "FAZA 0: Verificari preliminare" +# ============================================================================= + +log "Verific docker..." +docker info >/dev/null 2>&1 || fail "Docker nu ruleaza" +ok "Docker activ" + +log "Verific docker compose..." +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 nu este instalat" +ok "Docker Compose disponibil" + +# ============================================================================= +section "FAZA 1: Docker Network" +# ============================================================================= + +log "Creez reteaua didi-network (daca nu exista)..." +docker network create didi-network 2>/dev/null && ok "Retea creata" || ok "Reteaua exista deja" + +# ============================================================================= +section "FAZA 2: Data Layer (PostgreSQL local, RabbitMQ, MinIO, PgAdmin)" +# ============================================================================= + +log "Verific daca volumele externe exista..." +for vol in didi-staging-postgres-data didi-staging-minio-data didi-staging-pgadmin-data; do + docker volume inspect "$vol" >/dev/null 2>&1 && ok "Volum $vol exista" || { + log "Creez volum $vol..." + docker volume create "$vol" + ok "Volum $vol creat" + } +done + +log "Build + start data-layer..." +cd "$DATA_LAYER" + +# Nota: containerul PG local este doar pentru waitlist. +# Baza de date principala (DIDI) este pe clusterul extern 10.11.50.167:5000. +# Dockerfile-ul custom necesita init.sql + health-check.sh care nu sunt in git (*.sql in .gitignore). +# Daca Dockerfile exista SI init.sql e fisier (nu director gol), build custom; altfel, skip. +if [ -f didiDatabase/Dockerfile ] && [ -f didiDatabase/init.sql ]; then + log "Build imagine didi-staging-postgres..." + docker build -t didi-staging-postgres:latest didiDatabase/ 2>&1 | tail -3 + ok "Imagine postgres construita" +else + log "Skip build custom PG (init.sql lipseste). Se foloseste imaginea standard postgres:15-alpine." +fi + +docker compose up -d --build 2>&1 | tail -5 +ok "Data layer pornit" + +# Astept serviciile critice +wait_healthy staging-dataLayer-rabbitmq 90 +wait_healthy staging-dataLayer-minio 60 + +# ============================================================================= +section "FAZA 3: Verificare PostgreSQL Cluster extern" +# ============================================================================= + +log "Testez conexiunea la PG cluster $PG_HOST:$PG_PORT..." + +# Astept sa avem un container cu psql sau node +sleep 5 + +SCHEMA_COUNT=$(pg_query "SELECT count(*) FROM information_schema.schemata WHERE schema_name IN ('bos_analysis','bos_parammgmt','bos_sysadmin','bos_subscriber')" 2>/dev/null || echo "0") + +if [ "$SCHEMA_COUNT" = "4" ]; then + ok "Toate 4 schemele exista in PG cluster — nu ating nimic" +elif [ "$SCHEMA_COUNT" = "0" ]; then + warn "ZERO scheme bos_* gasite — baza DIDI este goala" + + # Caut exportul complet + DIDI_EXPORT="" + for candidate in \ + "$BACKEND/services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql" \ + "$BACKEND/services/data-layer/didiDatabase"/DIDI_full_export_*.sql; do + if [ -f "$candidate" ]; then + DIDI_EXPORT="$candidate" + break + fi + done + + if [ -n "$DIDI_EXPORT" ]; then + EXPORT_SIZE=$(du -h "$DIDI_EXPORT" | cut -f1) + log "Gasit export: $DIDI_EXPORT ($EXPORT_SIZE)" + log "Baza este GOALA (0 scheme). Import exportul complet..." + + # Astept container-ul postgres local sa fie up (are psql) + wait_healthy staging-dataLayer-postgres 60 || true + + # Copiez fisierul in container si import prin psql + docker cp "$DIDI_EXPORT" staging-dataLayer-postgres:/tmp/didi_import.sql + docker exec -e PGPASSWORD="$PG_PASS" staging-dataLayer-postgres \ + psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -f /tmp/didi_import.sql 2>&1 | tail -20 + + # Verificare post-import + SCHEMA_COUNT_POST=$(pg_query "SELECT count(*) FROM information_schema.schemata WHERE schema_name IN ('bos_analysis','bos_parammgmt','bos_sysadmin','bos_subscriber')" 2>/dev/null || echo "0") + if [ "$SCHEMA_COUNT_POST" = "4" ]; then + ok "Import reusit — toate 4 schemele exista acum" + else + fail "Import esuat — doar $SCHEMA_COUNT_POST/4 scheme dupa import" + fi + + # Cleanup + docker exec staging-dataLayer-postgres rm -f /tmp/didi_import.sql + else + warn "Nu gasesc fisier DIDI_full_export_*.sql in $BACKEND/services/data-layer/didiDatabase/" + warn "Baza DIDI este goala si nu pot importa automat." + read -p "Continui fara baza de date? (y/N): " answer + [ "$answer" = "y" ] || [ "$answer" = "Y" ] || exit 1 + fi +else + warn "Gasit $SCHEMA_COUNT/4 scheme (partial). Nu ating — nu e gol, dar nici complet." + pg_query "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'bos_%' ORDER BY 1" || true + echo "" + warn "Verifica manual ce lipseste. Importul automat ruleaza DOAR pe baza complet goala." + read -p "Continui oricum? (y/N): " answer + [ "$answer" = "y" ] || [ "$answer" = "Y" ] || exit 1 +fi + +# Verific tabelele critice per schema +log "Verific tabele critice..." + +CRITICAL_TABLES=( + "bos_analysis|analysis_session" + "bos_analysis|analysis_verdict" + "bos_parammgmt|dimension" + "bos_parammgmt|technique" + "bos_parammgmt|verdict_category" + "bos_parammgmt|component_weight" + "bos_parammgmt|component_config" + "bos_parammgmt|input_type_profile" + "bos_sysadmin|internet_user" + "bos_sysadmin|user_credential" + "bos_sysadmin|subscription_plan" +) + +MISSING=0 +for entry in "${CRITICAL_TABLES[@]}"; do + schema="${entry%%|*}" + table="${entry##*|}" + EXISTS=$(pg_query "SELECT count(*) FROM information_schema.tables WHERE table_schema='$schema' AND table_name='$table'" 2>/dev/null || echo "0") + if [ "$EXISTS" = "1" ]; then + ok " $schema.$table" + else + warn " LIPSA: $schema.$table" + MISSING=$((MISSING + 1)) + fi +done + +if [ "$MISSING" -gt 0 ]; then + warn "$MISSING tabele critice lipsa. Migrari necesare." +fi + +# ============================================================================= +section "FAZA 4: Production Services (Redis, Keycloak, Kong)" +# ============================================================================= + +cd "$PRODUCTION" + +# -- Seteaza KC_HOSTNAME_URL in .env pe baza hostname-ului platformei -------- +log "Configurez Keycloak hostname: $PLATFORM_HOSTNAME" +if grep -q "^KC_HOSTNAME_URL=" .env 2>/dev/null; then + sed -i "s|^KC_HOSTNAME_URL=.*|KC_HOSTNAME_URL=https://${PLATFORM_HOSTNAME}/auth|" .env + ok "KC_HOSTNAME_URL actualizat in .env" +else + echo "KC_HOSTNAME_URL=https://${PLATFORM_HOSTNAME}/auth" >> .env + ok "KC_HOSTNAME_URL adaugat in .env" +fi + +log "Start Redis + Keycloak + Kong..." +docker compose up -d --build 2>&1 | tail -5 +ok "Production services pornite" + +wait_healthy didi-cache 30 + +log "Verific conexiunea Redis..." +REDIS_PONG=$(docker exec didi-cache redis-cli -a redis123 ping 2>/dev/null || echo "FAIL") +if [ "$REDIS_PONG" = "PONG" ]; then + ok "Redis raspunde" +else + warn "Redis nu raspunde: $REDIS_PONG" +fi + +wait_healthy keycloak 180 +wait_healthy kong 90 + +# -- Configurare Keycloak redirect URIs via Admin API ----------------------- +section "FAZA 4b: Keycloak - Configurare redirect URIs pentru $PLATFORM_HOSTNAME" + +log "Obtin token admin Keycloak..." +KC_ADMIN_USER=$(grep "^KEYCLOAK_ADMIN=" .env | cut -d= -f2-) +KC_ADMIN_PASS=$(grep "^KEYCLOAK_ADMIN_PASSWORD=" .env | cut -d= -f2-) + +KC_TOKEN=$(curl -s -X POST "http://localhost:28000/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=${KC_ADMIN_USER}" -d "password=${KC_ADMIN_PASS}" \ + -d "grant_type=password" -d "client_id=admin-cli" 2>/dev/null \ + | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "") + +if [ -z "$KC_TOKEN" ]; then + warn "Nu am obtinut token admin Keycloak. Redirect URIs trebuie configurate manual." +else + ok "Token admin obtinut" + + # Configureaza didi-web-app + log "Configurez client didi-web-app..." + WEB_CLIENT_UUID=$(curl -s "http://localhost:28000/admin/realms/didi-clients/clients?clientId=didi-web-app" \ + -H "Authorization: Bearer $KC_TOKEN" 2>/dev/null \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['id'] if d else '')" 2>/dev/null || echo "") + + if [ -n "$WEB_CLIENT_UUID" ]; then + curl -s -X PUT "http://localhost:28000/admin/realms/didi-clients/clients/$WEB_CLIENT_UUID" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"redirectUris\": [ + \"https://${PLATFORM_HOSTNAME}/*\", + \"http://localhost:3001/*\", + \"http://localhost:5173/*\" + ], + \"webOrigins\": [ + \"https://${PLATFORM_HOSTNAME}\", + \"http://localhost:3001\", + \"http://localhost:5173\" + ] + }" -w "" -o /dev/null 2>/dev/null + ok "didi-web-app: redirect URI -> https://${PLATFORM_HOSTNAME}/*" + else + warn "Client didi-web-app nu gasit in Keycloak" + fi + + # Configureaza admin-dashboard + log "Configurez client admin-dashboard..." + ADMIN_CLIENT_UUID=$(curl -s "http://localhost:28000/admin/realms/didi-clients/clients?clientId=admin-dashboard" \ + -H "Authorization: Bearer $KC_TOKEN" 2>/dev/null \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['id'] if d else '')" 2>/dev/null || echo "") + + if [ -n "$ADMIN_CLIENT_UUID" ]; then + curl -s -X PUT "http://localhost:28000/admin/realms/didi-clients/clients/$ADMIN_CLIENT_UUID" \ + -H "Authorization: Bearer $KC_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"redirectUris\": [ + \"https://${PLATFORM_HOSTNAME}/*\", + \"https://${PLATFORM_HOSTNAME}/admin/*\", + \"http://localhost:3003/*\" + ], + \"webOrigins\": [ + \"https://${PLATFORM_HOSTNAME}\", + \"http://localhost:3003\" + ] + }" -w "" -o /dev/null 2>/dev/null + ok "admin-dashboard: redirect URI -> https://${PLATFORM_HOSTNAME}/*" + else + warn "Client admin-dashboard nu gasit in Keycloak" + fi +fi + +# ============================================================================= +section "FAZA 5: didiFramework (CRUD backend + sync Redis)" +# ============================================================================= + +cd "$FRAMEWORK" + +log "Build + start didiFramework..." +docker compose up -d --build 2>&1 | tail -5 +ok "didiFramework pornit" + +wait_healthy didi-framework 60 + +# Verific health-ul complet (PG + MinIO) +log "Verific health didiFramework..." +HEALTH=$(docker exec didi-framework wget -qO- "http://127.0.0.1:3005/health/all" 2>/dev/null || echo "{}") +echo " $HEALTH" + +# ============================================================================= +section "FAZA 6: Sincronizare Framework -> Redis" +# ============================================================================= + +log "Trigger sync-redis (incarca parametri framework in Redis)..." +SYNC_RESULT=$(docker exec didi-framework wget -qO- --post-data='' "http://127.0.0.1:3005/api/sync-redis" 2>/dev/null || echo "FAIL") +if echo "$SYNC_RESULT" | grep -q '"success"'; then + ok "Sync Redis reusit" + echo " $SYNC_RESULT" | head -c 200 + echo "" +else + warn "Sync Redis posibil esuat: $SYNC_RESULT" + warn "Poti face sync manual mai tarziu: POST http://localhost:3005/api/sync-redis" +fi + +# Verific ca cheile au fost scrise +log "Verific chei framework in Redis..." +KEY_COUNT=$(docker exec didi-cache redis-cli -a redis123 keys "didi:framework:*" 2>/dev/null | wc -l) +CONFIG_COUNT=$(docker exec didi-cache redis-cli -a redis123 keys "didi:config:*" 2>/dev/null | wc -l) +ok "Chei framework: $KEY_COUNT | Chei config: $CONFIG_COUNT" + +if [ "$KEY_COUNT" -lt 5 ]; then + warn "Prea putine chei framework ($KEY_COUNT). Sync-ul poate sa nu fi functionat." + warn "Verifica manual: docker exec didi-cache redis-cli -a redis123 keys 'didi:framework:*'" +fi + +# ============================================================================= +section "FAZA 7: MinIO - Initializare bucket-uri" +# ============================================================================= + +log "Verific bucket-urile MinIO..." +BUCKET_LIST=$(docker exec staging-dataLayer-minio mc ls local/ 2>/dev/null || echo "") + +REQUIRED_BUCKETS=("uploads" "text-files" "image-files" "audio-files" "video-files" "document-files" "pipeline-artifacts" "backups") +BUCKETS_MISSING=0 + +for bucket in "${REQUIRED_BUCKETS[@]}"; do + if echo "$BUCKET_LIST" | grep -q "$bucket"; then + ok " Bucket: $bucket" + else + warn " LIPSA bucket: $bucket" + BUCKETS_MISSING=$((BUCKETS_MISSING + 1)) + fi +done + +if [ "$BUCKETS_MISSING" -gt 0 ]; then + log "Rulez init-buckets.sh..." + if [ -f "$DATA_LAYER/didiStorage/init-buckets.sh" ]; then + docker exec -e MINIO_HOST=localhost -e MINIO_PORT=9000 \ + -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minio123 \ + staging-dataLayer-minio sh -c "$(cat $DATA_LAYER/didiStorage/init-buckets.sh)" 2>&1 | tail -5 + ok "Bucket-uri initializate" + else + warn "init-buckets.sh nu exista. Creeaza bucket-urile manual." + fi +else + ok "Toate bucket-urile exista" +fi + +# ============================================================================= +section "FAZA 8: Agent V3 + Workers" +# ============================================================================= + +cd "$AGENT_V3" + +# Verific ca .env exista (contine API keys) +if [ ! -f .env ]; then + warn ".env lipseste in $AGENT_V3" + warn "Fisierul trebuie sa contina: OPENROUTER_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, M17_WHISPER_TOKEN" + warn "Fara aceste chei, analizele LLM nu vor functiona." + read -p "Continui fara .env? (y/N): " answer + [ "$answer" = "y" ] || [ "$answer" = "Y" ] || exit 1 +else + ok ".env exista (API keys configurate)" + # Verific cheile critice + for key in OPENROUTER_API_KEY OPENAI_API_KEY GROQ_API_KEY; do + val=$(grep "^$key=" .env 2>/dev/null | cut -d= -f2-) + if [ -z "$val" ]; then + warn " $key este gol in .env" + else + ok " $key configurat (${#val} caractere)" + fi + done +fi + +log "Build + start agent-v3 + toti workerii..." +docker compose up -d --build 2>&1 | tail -10 +ok "Agent V3 + workers porniti" + +wait_healthy didi-agent-v3 60 + +# Verific health +log "Verific health agent-v3..." +AGENT_HEALTH=$(docker exec didi-agent-v3 wget -qO- "http://localhost:24803/api/v3/health" 2>/dev/null || echo "FAIL") +echo " $AGENT_HEALTH" + +# Verific workerii +log "Verific workerii..." +WORKERS=$(docker ps --format '{{.Names}}' | grep -c "agent-v3-worker" || true) +AGGREGATORS=$(docker ps --format '{{.Names}}' | grep -c "verdict-aggregator" || true) +ok "Workers activi: $WORKERS | Aggregators: $AGGREGATORS" + +# ============================================================================= +section "FAZA 9: Admin Dashboard" +# ============================================================================= + +# Admin dashboard este in data-layer docker-compose (didi-admin container) +log "Verific admin dashboard..." +if docker ps --format '{{.Names}}' | grep -q didi-admin; then + ok "didi-admin deja ruleaza" +else + log "Admin dashboard nu ruleaza. Rebuild..." + cd "$BACKEND/admin-dashboard" + if [ -f Dockerfile ]; then + docker build -t didi-admin:latest . 2>&1 | tail -5 + ok "Imagine admin-dashboard construita" + fi + cd "$DATA_LAYER" + docker compose up -d didi-admin 2>&1 | tail -3 +fi + +wait_healthy didi-admin 60 || true + +# ============================================================================= +section "FAZA 10: Kong Build (imagine custom)" +# ============================================================================= + +log "Verific imaginea Kong..." +if docker images didi-kong:latest --format '{{.ID}}' | head -1 | grep -q .; then + ok "Imaginea didi-kong:latest exista" +else + log "Build imagine didi-kong..." + if [ -d "$KONG_DIR" ] && [ -f "$KONG_DIR/Dockerfile" ]; then + docker build -t didi-kong:latest "$KONG_DIR" 2>&1 | tail -3 + ok "Imaginea Kong construita. Restart Kong..." + cd "$PRODUCTION" + docker compose up -d kong 2>&1 | tail -3 + wait_healthy kong 90 || true + else + warn "Nu gasesc Dockerfile Kong la $KONG_DIR" + fi +fi + +# ============================================================================= +section "VERIFICARE FINALA" +# ============================================================================= + +echo "" +log "Status toate containerele DIDI:" +echo "" +printf "%-45s %-20s %s\n" "CONTAINER" "STATUS" "PORTS" +printf "%-45s %-20s %s\n" "---------" "------" "-----" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" --filter "network=didi-network" 2>/dev/null | tail -n +2 | sort | while read line; do + echo " $line" +done + +echo "" +log "Health checks rapide:" + +# Lista de verificari +declare -A CHECKS=( + ["Redis"]="docker exec didi-cache redis-cli -a redis123 ping 2>/dev/null" + ["Framework"]="docker exec didi-framework wget -qO- http://127.0.0.1:3005/health 2>/dev/null" + ["Agent-V3"]="docker exec didi-agent-v3 wget -qO- http://localhost:24803/api/v3/health 2>/dev/null" + ["RabbitMQ"]="docker exec staging-dataLayer-rabbitmq rabbitmq-diagnostics -q ping 2>/dev/null" + ["MinIO"]="docker exec staging-dataLayer-minio mc ready local 2>/dev/null" +) + +for name in Redis Framework Agent-V3 RabbitMQ MinIO; do + result=$(eval "${CHECKS[$name]}" || echo "FAIL") + if echo "$result" | grep -qiE "PONG|ok|healthy|ready|service|READY"; then + ok "$name" + else + warn "$name: $result" + fi +done + +echo "" + +# Verific chei Redis finale +FRAMEWORK_KEYS=$(docker exec didi-cache redis-cli -a redis123 keys "didi:framework:*" 2>/dev/null | wc -l) +CONFIG_KEYS=$(docker exec didi-cache redis-cli -a redis123 keys "didi:config:*" 2>/dev/null | wc -l) +log "Redis: $FRAMEWORK_KEYS chei framework, $CONFIG_KEYS chei config" + +echo "" +echo -e "${GREEN}====================================================================${NC}" +echo -e "${GREEN} BUILD COMPLET${NC}" +echo -e "${GREEN}====================================================================${NC}" +echo "" +echo "Platforma configurata pe: $PLATFORM_HOSTNAME" +echo "" +echo "Endpoint-uri disponibile:" +echo " Frontend: https://${PLATFORM_HOSTNAME}" +echo " Admin Dashboard: https://${PLATFORM_HOSTNAME}/admin" +echo " Agent V3 API: http://localhost:24803/api/v3/health (doar local)" +echo " Framework API: intern pe Docker network (port 3005)" +echo " Kong Gateway: https://localhost:443" +echo " Keycloak: http://localhost:28000" +echo " Keycloak Auth: https://${PLATFORM_HOSTNAME}/auth" +echo " RabbitMQ UI: http://localhost:15672 (admin/rabbitmq123)" +echo " MinIO Console: http://localhost:9001 (minioadmin/minio123)" +echo " PgAdmin: http://localhost:5050 (admin@example.com/admin123)" +echo "" +echo "Daca sync Redis nu a mers, ruleaza manual:" +echo " curl -X POST http://localhost:3005/api/sync-redis" +echo " (sau din interiorul Docker: docker exec didi-framework wget -qO- --post-data='' http://127.0.0.1:3005/api/sync-redis)" +echo "" diff --git a/backend/production/migrate-to-cluster.sh b/backend/production/migrate-to-cluster.sh new file mode 100644 index 0000000..52cb510 --- /dev/null +++ b/backend/production/migrate-to-cluster.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# ============================================================================= +# Migration Script: Move Kong & Keycloak DBs to PostgreSQL Cluster +# ============================================================================= + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Cluster connection +CLUSTER_HOST="10.11.50.167" +CLUSTER_PORT="5000" +CLUSTER_USER="bos_interface" +CLUSTER_PASS="interface" + +# Local postgres container +LOCAL_PG="staging-dataLayer-postgres" + +echo -e "${YELLOW}=== DIDI Database Migration to Cluster ===${NC}" +echo "" + +# Step 1: Create users in cluster +echo -e "${YELLOW}[1/6] Creating users in cluster...${NC}" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d DIDI << 'EOF' +-- Create kong user if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'kong') THEN + CREATE ROLE kong WITH LOGIN PASSWORD 'kong123'; + END IF; +END $$; + +-- Create keycloak user if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'keycloak') THEN + CREATE ROLE keycloak WITH LOGIN PASSWORD 'keycloak123'; + END IF; +END $$; + +SELECT rolname FROM pg_roles WHERE rolname IN ('kong', 'keycloak'); +EOF +echo -e "${GREEN}✓ Users created${NC}" + +# Step 2: Dump local databases +echo -e "${YELLOW}[2/6] Dumping local databases...${NC}" +mkdir -p /tmp/db-migration + +docker exec $LOCAL_PG pg_dump -U postgres -Fc kong_db > /tmp/db-migration/kong_db.dump +echo " - kong_db dumped ($(du -h /tmp/db-migration/kong_db.dump | cut -f1))" + +docker exec $LOCAL_PG pg_dump -U postgres -Fc keycloak_db > /tmp/db-migration/keycloak_db.dump +echo " - keycloak_db dumped ($(du -h /tmp/db-migration/keycloak_db.dump | cut -f1))" +echo -e "${GREEN}✓ Dumps complete${NC}" + +# Step 3: Create databases in cluster +echo -e "${YELLOW}[3/6] Creating databases in cluster...${NC}" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d DIDI << 'EOF' +-- Create kong_db if not exists +SELECT 'CREATE DATABASE kong_db OWNER kong' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'kong_db')\gexec + +-- Create keycloak_db if not exists +SELECT 'CREATE DATABASE keycloak_db OWNER keycloak' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'keycloak_db')\gexec + +\l kong_db keycloak_db +EOF +echo -e "${GREEN}✓ Databases created${NC}" + +# Step 4: Restore to cluster +echo -e "${YELLOW}[4/6] Restoring databases to cluster...${NC}" + +# Copy dumps to container +docker cp /tmp/db-migration/kong_db.dump $LOCAL_PG:/tmp/ +docker cp /tmp/db-migration/keycloak_db.dump $LOCAL_PG:/tmp/ + +# Restore kong_db +echo " - Restoring kong_db..." +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG pg_restore -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d kong_db --no-owner --no-acl --clean --if-exists /tmp/kong_db.dump 2>/dev/null || true + +# Restore keycloak_db +echo " - Restoring keycloak_db..." +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG pg_restore -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d keycloak_db --no-owner --no-acl --clean --if-exists /tmp/keycloak_db.dump 2>/dev/null || true + +echo -e "${GREEN}✓ Databases restored${NC}" + +# Step 5: Grant permissions +echo -e "${YELLOW}[5/6] Granting permissions...${NC}" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d kong_db -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO kong; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO kong;" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d keycloak_db -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO keycloak; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO keycloak;" +echo -e "${GREEN}✓ Permissions granted${NC}" + +# Step 6: Verify +echo -e "${YELLOW}[6/6] Verifying migration...${NC}" +echo "Kong tables:" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d kong_db -c "SELECT count(*) as tables FROM information_schema.tables WHERE table_schema = 'public';" +echo "Keycloak tables:" +docker exec -e PGPASSWORD=$CLUSTER_PASS $LOCAL_PG psql -h $CLUSTER_HOST -p $CLUSTER_PORT -U $CLUSTER_USER -d keycloak_db -c "SELECT count(*) as tables FROM information_schema.tables WHERE table_schema = 'public';" + +echo "" +echo -e "${GREEN}=== Migration Complete ===${NC}" +echo "" +echo "Next steps:" +echo " 1. Stop old containers: docker stop kong keycloak" +echo " 2. Remove old containers: docker rm kong keycloak" +echo " 3. Start new services: cd /home/admin365/didi_mono/backend/production && docker compose up -d" +echo "" diff --git a/backend/scripts/integration/fixtures/face_clip.mp4 b/backend/scripts/integration/fixtures/face_clip.mp4 new file mode 100644 index 0000000..7936dc0 Binary files /dev/null and b/backend/scripts/integration/fixtures/face_clip.mp4 differ diff --git a/backend/scripts/integration/fixtures/face_clip_3s.mp4 b/backend/scripts/integration/fixtures/face_clip_3s.mp4 new file mode 100644 index 0000000..e5e97dc Binary files /dev/null and b/backend/scripts/integration/fixtures/face_clip_3s.mp4 differ diff --git a/backend/scripts/integration/fixtures/fake_headline.jpg b/backend/scripts/integration/fixtures/fake_headline.jpg new file mode 100644 index 0000000..3c5724c Binary files /dev/null and b/backend/scripts/integration/fixtures/fake_headline.jpg differ diff --git a/backend/scripts/integration/fixtures/jfk_speech.wav b/backend/scripts/integration/fixtures/jfk_speech.wav new file mode 100644 index 0000000..3184d37 Binary files /dev/null and b/backend/scripts/integration/fixtures/jfk_speech.wav differ diff --git a/backend/scripts/integration/fixtures/test_clip.mp4 b/backend/scripts/integration/fixtures/test_clip.mp4 new file mode 100644 index 0000000..6a73ceb Binary files /dev/null and b/backend/scripts/integration/fixtures/test_clip.mp4 differ diff --git a/backend/scripts/integration/run-integration-tests.sh b/backend/scripts/integration/run-integration-tests.sh new file mode 100644 index 0000000..2a97f6e --- /dev/null +++ b/backend/scripts/integration/run-integration-tests.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# ============================================================================= +# DiDi — Teste de integrare Lot 1 (Platforma AI) <-> Lot 2 (Backend) +# ============================================================================= +# Demonstreaza ca cele doua loturi colaboreaza pe mediul live, non-distructiv. +# +# Trei niveluri: +# A. Conectivitate & contract — agent-v3 ajunge la fiecare serviciu Lot 1 +# B. End-to-end pe tip de continut — pipeline complet -> verdict persistat +# C. Proprietati transversale — fail-open, rutare model local, gateway +# +# Fiecare test capteaza 3 artefacte: (1) request-ul, (2) access-log-ul +# serviciului Lot 1 care dovedeste primirea, (3) verdictul persistat in PG. +# +# Utilizare: bash run-integration-tests.sh +# Rezultate: results/results_.json + results/evidence_/ +# ============================================================================= +set -uo pipefail + +# --- Config ----------------------------------------------------------------- +AGENT="${AGENT_URL:-http://localhost:24803}" +KONG="${KONG_URL:-http://127.0.0.1:18000}" +KC="${KEYCLOAK_URL:-http://localhost:28080}" +USER_ID="${TEST_USER:-14142351-ad1e-466b-ac5d-4a7a0ff562bf}" +PG_C="${PG_CONTAINER:-didi-postgres}" +POLL_MAX="${POLL_MAX:-60}" # nr. maxim de poll-uri +POLL_INT="${POLL_INT:-3}" # secunde intre poll-uri + +HERE="$(cd "$(dirname "$0")" && pwd)" +FIX="$HERE/fixtures" +TS="$(date +%Y%m%d_%H%M%S)" +OUT="$HERE/results" +EVID="$OUT/evidence_$TS" +RESULTS="$OUT/results_$TS.json" +mkdir -p "$EVID" + +PASS=0; FAIL=0; DEGR=0 +echo "[]" > "$RESULTS" + +# --- Helpers ---------------------------------------------------------------- +pg() { docker exec "$PG_C" psql -U bos_interface -d DIDI -tA -c "$1" 2>/dev/null; } + +# extrage un camp dintr-un JSON de pe stdin: jget '' ex: "data.status" +jget() { python3 -c "import sys,json; +try: d=json.load(sys.stdin) +except: print(''); sys.exit() +for k in '$1'.split('.'): + d = d.get(k, '') if isinstance(d, dict) else '' +print(d if d is not None else '')"; } + +# inregistreaza un rezultat de test in JSON-ul agregat +rec() { # id level name requirement target status detail evidence_file + python3 - "$RESULTS" "$@" <<'PY' +import json,sys +f=sys.argv[1]; ida,lvl,name,req,tgt,st,detail,ev=sys.argv[2:10] +data=json.load(open(f)) +data.append({"id":ida,"level":lvl,"name":name,"requirement":req, + "target":tgt,"status":st,"detail":detail,"evidence":ev}) +json.dump(data,open(f,'w'),ensure_ascii=False,indent=2) +PY + case "$6" in PASS) PASS=$((PASS+1));; DEGRADED) DEGR=$((DEGR+1));; *) FAIL=$((FAIL+1));; esac + printf ' [%-8s] %-4s %s\n' "$6" "$1" "$3" +} + +# probe de conectivitate din INTERIORUL agent-v3 (consumatorul real), via node fetch +probe_from_agent() { # url -> printeaza "STATUS " sau "ERR " + docker exec didi-agent-v3 node -e " + fetch('$1',{signal:AbortSignal.timeout(6000)}) + .then(r=>{console.log('STATUS '+r.status)}) + .catch(e=>{console.log('ERR '+e.message)})" 2>/dev/null +} + +# lanseaza o analiza async si asteapta verdictul; printeaza JSON-ul result +analyze() { # media_type json_body + local body="$2" + local resp sid st + resp="$(curl -sk --max-time 30 -X POST "$AGENT/api/v3/pipeline/analyze-async" \ + -H 'Content-Type: application/json' -d "$body" 2>/dev/null)" + sid="$(echo "$resp" | jget 'data.session_id')" + if [ -z "$sid" ]; then echo "{\"error\":\"dispatch_failed\",\"raw\":$(echo "$resp"|python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))')}"; return; fi + local i=0 + while [ $i -lt "$POLL_MAX" ]; do + st="$(curl -sk --max-time 10 "$AGENT/api/v3/pipeline/$sid/queue-status" | jget 'data.status')" + [ -z "$st" ] && st="$(curl -sk --max-time 10 "$AGENT/api/v3/pipeline/$sid/queue-status" | jget 'status')" + if [ "$st" = "completed" ] || [ "$st" = "failed" ]; then break; fi + sleep "$POLL_INT"; i=$((i+1)) + done + curl -sk --max-time 10 "$AGENT/api/v3/pipeline/$sid/result" + echo "$sid" > /tmp/.last_sid +} + +# upload un fisier media -> printeaza public_url +upload_media() { # filepath + curl -sk --max-time 60 -X POST "$AGENT/api/v3/media/upload" \ + -F "file=@$1" -F "user_id=$USER_ID" 2>/dev/null | jget 'data.public_url' +} + +echo "============================================================" +echo " DiDi — Teste integrare Lot1<->Lot2 $TS" +echo " Agent: $AGENT User: ${USER_ID:0:8}... Evidence: $EVID" +echo "============================================================" + +# ============================================================================= +# NIVEL A — Conectivitate & contract (agent-v3 -> servicii Lot 1) +# ============================================================================= +echo; echo "### NIVEL A — Conectivitate din agent-v3 catre serviciile Lot 1" + +declare -A A_SVC=( + [A1]="llm-api:14011|/health|LLM text (Qwen3.5)|extractoare/flux LLM" + [A3]="audio-api:54300|/health|Whisper transcriere|extractor Whisper" + [A4]="video-api:54600|/health|BusterX deepfake|extractor deepfake" + [A5]="extractors:54400|/health|EXIF/NER/YOLO/OCR|extractoare NER/YOLO/OCR" + [A6]="forensic:8080|/health|forensic media|analiza forensica media" + [A7]="web-api:51100|/health|cautare web claims|modul web-crawl/evidence" + [A8]="brain-api:8090|/health|RAG/fact-check cache|flux ML fact-check" +) +for id in A1 A3 A4 A5 A6 A7 A8; do + IFS='|' read -r hp path label req <<< "${A_SVC[$id]}" + out="$(probe_from_agent "http://$hp$path")" + echo "$id $hp$path -> $out" >> "$EVID/A_connectivity.log" + if echo "$out" | grep -q "STATUS 200"; then + rec "$id" "A" "$label ($hp)" "$req" "$hp" "PASS" "$out" "A_connectivity.log" + else + rec "$id" "A" "$label ($hp)" "$req" "$hp" "FAIL" "$out" "A_connectivity.log" + fi +done + +# A2 — LLM vision-capable: modelul qwen3.5 incarcat pe llm-api (rol vision) +models="$(docker exec didi-agent-v3 node -e "fetch('http://llm-api:14011/v1/models',{signal:AbortSignal.timeout(6000)}).then(r=>r.json()).then(d=>console.log(JSON.stringify(d))).catch(e=>console.log('ERR'))" 2>/dev/null)" +echo "A2 models: $models" >> "$EVID/A_connectivity.log" +if echo "$models" | grep -q 'qwen3.5'; then + rec "A2" "A" "LLM vision/OCR (llm-api)" "extractor OCR" "llm-api:14011" "PASS" "model qwen3.5 loaded" "A_connectivity.log" +else + rec "A2" "A" "LLM vision/OCR (llm-api)" "extractor OCR" "llm-api:14011" "FAIL" "$models" "A_connectivity.log" +fi + +# A9 — domain-check T4: apel real POST /api/v1/check/check +dc="$(docker exec didi-agent-v3 node -e " +fetch('http://domain-check-api:11000/api/v1/check/check',{method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({domain:'google.com',check_options:{whois:true,dns:true,ssl:true}}), + signal:AbortSignal.timeout(30000)}) + .then(r=>r.json()).then(d=>console.log(JSON.stringify({ok:d.success,risk:(d.data||{}).risk_score}))).catch(e=>console.log('ERR '+e.message))" 2>/dev/null)" +echo "A9 domain-check: $dc" >> "$EVID/A_connectivity.log" +if echo "$dc" | grep -q '"ok":true'; then + rec "A9" "A" "Domain-check T4 (WHOIS/DNS/SSL)" "scor credibilitate sursa" "domain-check-api:11000" "PASS" "$dc" "A_connectivity.log" +else + rec "A9" "A" "Domain-check T4 (WHOIS/DNS/SSL)" "scor credibilitate sursa" "domain-check-api:11000" "FAIL" "$dc" "A_connectivity.log" +fi + +# ============================================================================= +# NIVEL B — End-to-end pe tip de continut +# ============================================================================= +echo; echo "### NIVEL B — Analize end-to-end (pipeline complet -> verdict)" + +run_e2e() { # id name media_type body req logcontainer logpattern + local id="$1" name="$2" mt="$3" body="$4" req="$5" lc="$6" lp="$7" + local since res verdict cat score status sid + since="$(date -u +%Y-%m-%dT%H:%M:%S)" + res="$(analyze "$mt" "$body")" + sid="$(cat /tmp/.last_sid 2>/dev/null)" + echo "$res" > "$EVID/B_${id}_result.json" + status="$(echo "$res" | jget 'data.status')"; [ -z "$status" ] && status="$(echo "$res" | jget 'status')" + score="$(echo "$res" | jget 'data.risk_score')"; [ -z "$score" ] && score="$(echo "$res" | jget 'risk_score')" + cat="$(echo "$res" | jget 'data.risk_category')"; [ -z "$cat" ] && cat="$(echo "$res" | jget 'risk_category')" + # dovada access-log Lot 1 + if [ -n "$lc" ]; then + docker logs --since "$since" "$lc" 2>&1 | grep -iE "$lp" | tail -5 > "$EVID/B_${id}_lot1_${lc}.log" 2>/dev/null + fi + # dovada verdict din PG + pg "SELECT input_type||'|'||status||'|'||COALESCE(risk_category,'')||'|'||COALESCE(risk_score::text,'') FROM bos_analysis.analysis_session WHERE session_id='$sid';" > "$EVID/B_${id}_pg.txt" 2>/dev/null + local pgrow; pgrow="$(cat "$EVID/B_${id}_pg.txt")" + if [ "$status" = "completed" ]; then + rec "$id" "B" "$name" "$req" "$mt" "PASS" "verdict=$cat score=$score | PG:$pgrow" "B_${id}_result.json" + else + rec "$id" "B" "$name" "$req" "$mt" "FAIL" "status=$status | PG:$pgrow" "B_${id}_result.json" + fi +} + +# B1 — text dezinformare +run_e2e "B1" "Text dezinformare -> verdict LLM" "text" \ + "{\"media_type\":\"text\",\"text\":\"OMS a confirmat oficial ca vaccinurile anti-COVID contin microcipuri 5G folosite pentru controlul mintal al intregii populatii prin unde radio.\",\"user_id\":\"$USER_ID\"}" \ + "orchestrare + flux LLM" "didiAI-llm-api" "chat/completions" + +# B2 — URL real (domeniu + web) +run_e2e "B2" "URL -> componenta domain + web" "url" \ + "{\"media_type\":\"url\",\"url\":\"https://www.bbc.com/news\",\"user_id\":\"$USER_ID\"}" \ + "web-crawl/evidence + credibilitate sursa" "didiAI-domain-check" "check/check" + +# B3 — imagine (OCR/vision) +IMG_URL="$(upload_media "$FIX/fake_headline.jpg")" +echo "B3 image url: $IMG_URL" > "$EVID/B_B3_upload.txt" +run_e2e "B3" "Imagine -> OCR/vision + extractoare" "image" \ + "{\"media_type\":\"image\",\"media_url\":\"$IMG_URL\",\"user_id\":\"$USER_ID\"}" \ + "extractor OCR/vision" "didiAI-llm-api" "chat/completions" + +# B4 — audio (Whisper) +AUD_URL="$(upload_media "$FIX/jfk_speech.wav")" +echo "B4 audio url: $AUD_URL" > "$EVID/B_B4_upload.txt" +run_e2e "B4" "Audio -> transcriere Whisper -> verdict" "audio" \ + "{\"media_type\":\"audio\",\"media_url\":\"$AUD_URL\",\"user_id\":\"$USER_ID\"}" \ + "extractor Whisper" "didiAI-audio" "transcriptions|POST" + +# B5 — video (BusterX) +VID_URL="$(upload_media "$FIX/test_clip.mp4")" +echo "B5 video url: $VID_URL" > "$EVID/B_B5_upload.txt" +run_e2e "B5" "Video -> BusterX deepfake -> verdict" "video" \ + "{\"media_type\":\"video\",\"media_url\":\"$VID_URL\",\"user_id\":\"$USER_ID\"}" \ + "extractor deepfake" "didiAI-video-api" "POST|analyze|predict" + +# ============================================================================= +# NIVEL C — Proprietati transversale +# ============================================================================= +echo; echo "### NIVEL C — Fail-open, rutare model local, gateway" + +# C1 — FAIL-OPEN: opresc web-api, rulez o analiza, verific ca se TERMINA (degradat) +echo " [C1] opresc temporar didiAI-web-api pentru testul de fail-open..." +docker stop didiAI-web-api >/dev/null 2>&1 +sleep 2 +since="$(date -u +%Y-%m-%dT%H:%M:%S)" +res="$(analyze "text" "{\"media_type\":\"text\",\"text\":\"Presedintele a anuntat ieri o crestere economica de 15% intr-o singura luna, cel mai mare salt din istoria tarii.\",\"user_id\":\"$USER_ID\"}")" +echo "$res" > "$EVID/C1_failopen_result.json" +c1status="$(echo "$res" | jget 'data.status')"; [ -z "$c1status" ] && c1status="$(echo "$res" | jget 'status')" +docker start didiAI-web-api >/dev/null 2>&1 +echo " [C1] didiAI-web-api repornit." +if [ "$c1status" = "completed" ]; then + rec "C1" "C" "Fail-open (web-api oprit -> analiza se termina)" "reziliza/fail-open servicii AI" "didiAI-web-api" "DEGRADED" "analiza completa fara web-api: status=$c1status" "C1_failopen_result.json" +else + rec "C1" "C" "Fail-open (web-api oprit -> analiza se termina)" "reziliza/fail-open servicii AI" "didiAI-web-api" "FAIL" "status=$c1status (nu a degradat gratios)" "C1_failopen_result.json" +fi + +# C2 — RUTARE MODEL LOCAL: analiza text, dovada apel local qwen3.5, zero OpenRouter +since="$(date -u +%Y-%m-%dT%H:%M:%S)" +res="$(analyze "text" "{\"media_type\":\"text\",\"text\":\"Guvernul a decis marirea salariului minim incepand cu luna urmatoare, conform anuntului oficial.\",\"user_id\":\"$USER_ID\"}")" +sid="$(cat /tmp/.last_sid)" +docker logs --since "$since" didiAI-llm-api 2>&1 | grep -iE "chat/completions" | tail -5 > "$EVID/C2_llm_access.log" +llmhits="$(wc -l < "$EVID/C2_llm_access.log" 2>/dev/null | tr -d ' ')" +usage="$(pg "SELECT COALESCE(llm_usage::text,'{}') FROM bos_analysis.analysis_session WHERE session_id='$sid';")" +echo "$usage" > "$EVID/C2_llm_usage.json" +# provider din DB pentru modelul primar +prov="$(pg "SELECT p.provider_code||'|'||p.base_url FROM bos_parammgmt.llm_provider p JOIN bos_parammgmt.llm_model m ON m.provider_id=p.provider_id WHERE m.model_code='qwen3.5' LIMIT 1;")" +echo "provider: $prov" >> "$EVID/C2_llm_usage.json" +if [ "${llmhits:-0}" -ge 1 ] && echo "$prov" | grep -q 'llm-api:14011'; then + rec "C2" "C" "Rutare model local (Qwen3.5, fara fallback platit)" "flux LLM local" "llm-api:14011" "PASS" "llm-api hits=$llmhits provider=$prov" "C2_llm_access.log" +else + rec "C2" "C" "Rutare model local (Qwen3.5, fara fallback platit)" "flux LLM local" "llm-api:14011" "FAIL" "llm-api hits=$llmhits provider=$prov" "C2_llm_access.log" +fi + +# C3 — GATEWAY: Kong pazeste lantul AI (401 fara token pe calea pipeline) +code_notoken="$(curl -sk --max-time 10 -H 'Host: localhost' -o /dev/null -w '%{http_code}' \ + -X POST "$KONG/agent-v3/api/v3/pipeline/analyze-async" -H 'Content-Type: application/json' \ + -d '{"media_type":"text","text":"aaaaaaaaaa","user_id":"x"}' 2>/dev/null)" +echo "Kong /agent-v3/.../analyze-async fara token -> $code_notoken" > "$EVID/C3_gateway.log" +if [ "$code_notoken" = "401" ]; then + rec "C3" "C" "Gateway Kong pazeste lantul AI (401 fara JWT)" "API Gateway + securitate" "kong:8000" "PASS" "analyze-async fara token -> $code_notoken" "C3_gateway.log" +else + rec "C3" "C" "Gateway Kong pazeste lantul AI (401 fara JWT)" "API Gateway + securitate" "kong:8000" "FAIL" "cod neasteptat: $code_notoken" "C3_gateway.log" +fi + +# ============================================================================= +# Sumar +# ============================================================================= +echo; echo "============================================================" +echo " SUMAR: PASS=$PASS DEGRADED=$DEGR FAIL=$FAIL" +echo " JSON: $RESULTS" +echo " Dovezi: $EVID" +echo "============================================================" +python3 - "$RESULTS" "$PASS" "$DEGR" "$FAIL" "$TS" <<'PY' +import json,sys +f,p,d,fa,ts=sys.argv[1:6] +data=json.load(open(f)) +out={"timestamp":ts,"summary":{"pass":int(p),"degraded":int(d),"fail":int(fa),"total":len(data)},"tests":data} +json.dump(out,open(f,'w'),ensure_ascii=False,indent=2) +print("Scris:",f) +PY diff --git a/backend/services/README.md b/backend/services/README.md new file mode 100644 index 0000000..5607c4a --- /dev/null +++ b/backend/services/README.md @@ -0,0 +1,321 @@ +# DIDI Backend Services - Master Orchestration + +A pyramid-architecture microservices platform for misinformation detection with three distinct layers. + +## 🏗️ Architecture Overview + +``` +┌─────────────────────────────────────────────────┐ +│ Layer 3: Gateway & Auth Layer │ +│ (Kong API Gateway, Keycloak) │ +├─────────────────────────────────────────────────┤ +│ Layer 2: Orchestration Layer │ +│ (Orchestrator, Analysis Services) │ +├─────────────────────────────────────────────────┤ +│ Layer 1: Data Layer │ +│ (PostgreSQL, Redis, RabbitMQ, MinIO) │ +└─────────────────────────────────────────────────┘ +``` + +## 📋 Table of Contents +- [Quick Start](#-quick-start) +- [Service Layers](#-service-layers) +- [Commands](#-commands) +- [Service Ports](#-service-ports) +- [Health Monitoring](#-health-monitoring) +- [Troubleshooting](#-troubleshooting) +- [Development](#-development) + +## 🚀 Quick Start + +### Start Everything (Recommended) +```bash +# Start all layers in pyramid order with health checks +make pyramid +``` + +### Alternative Methods +```bash +# Start all services at once +make up + +# Start with logs visible +make dev + +# Check service status +make status + +# Check service health +make health +``` + +## 🏛️ Service Layers + +### Layer 1: Data Layer (Foundation) +The foundation of our pyramid - all data persistence and messaging services. + +| Service | Purpose | Port | Container Name | +|---------|---------|------|----------------| +| PostgreSQL | Primary database | 5436 | dataLayer-postgres | +| Redis | Cache & session store | 6379 | dataLayer-redis | +| RabbitMQ | Message broker | 5672/15672 | dataLayer-rabbitmq | +| MinIO | Object storage | 9000/9001 | dataLayer-minio | +| pgAdmin | Database management | 5051 | dataLayer-pgadmin | + +### Layer 2: Orchestration Layer (Processing) +The processing layer - handles business logic and analysis pipelines. + +| Service | Purpose | Port | Container Name | +|---------|---------|------|----------------| +| Orchestrator | Pipeline management | 8000 | orchestrationLayer-orchestrator | +| Analysis | Text analysis service | 8004 | orchestrationLayer-analysis | + +### Layer 3: Gateway & Auth Layer (Access) +The access layer - manages authentication and API routing. + +| Service | Purpose | Port | Container Name | +|---------|---------|------|----------------| +| Kong | API Gateway | 8100/8101 | gatewayAuthLayer-kong | +| Keycloak | Authentication | 8280 | gatewayAuthLayer-keycloak | + +## 📦 Commands + +### Main Commands +```bash +make help # Show all available commands +make pyramid # Start layers in correct order (recommended) +make up # Start all services +make down # Stop all services +make restart # Restart all services +make status # Show service status +make health # Check service health +make logs # Show all logs +make clean # Stop and remove everything (including data!) +``` + +### Layer-Specific Commands +```bash +# Data Layer +make data-up # Start only data layer +make data-down # Stop data layer + +# Orchestration Layer +make orch-up # Start only orchestration layer +make orch-down # Stop orchestration layer + +# Gateway Layer +make gateway-up # Start only gateway layer +make gateway-down # Stop gateway layer +``` + +### Service Logs +```bash +make logs-postgres # PostgreSQL logs +make logs-redis # Redis logs +make logs-rabbitmq # RabbitMQ logs +make logs-orchestrator # Orchestrator logs +make logs-kong # Kong Gateway logs +make logs-keycloak # Keycloak logs +``` + +### Development Commands +```bash +make dev # Start with logs visible +make test # Test all endpoints +make resources # Show resource usage +make tail # Tail last 100 log lines +``` + +## 🔌 Service Ports + +### External Access Points +- **Admin Dashboard**: http://localhost:3001 +- **Orchestrator API**: http://localhost:8000 +- **Kong Gateway**: http://localhost:8100 +- **Kong Admin**: http://localhost:8101 +- **Keycloak**: http://localhost:8280 +- **RabbitMQ Management**: http://localhost:15672 +- **MinIO Console**: http://localhost:9001 +- **pgAdmin**: http://localhost:5051 + +### Default Credentials + +| Service | Username | Password | +|---------|----------|----------| +| PostgreSQL | postgres | postgres_dev_password_123 | +| RabbitMQ | admin | rabbitmq_dev_password_123 | +| MinIO | minioadmin | minioadmin | +| pgAdmin | admin@admin.com | admin | +| Keycloak | admin | admin | + +## 🏥 Health Monitoring + +### Check All Services +```bash +make health +``` + +### Manual Health Checks +```bash +# PostgreSQL +docker exec dataLayer-postgres pg_isready -U postgres + +# Redis +docker exec dataLayer-redis redis-cli ping + +# RabbitMQ +curl -u admin:rabbitmq_dev_password_123 http://localhost:15672/api/health/checks/virtual-hosts + +# MinIO +curl http://localhost:9000/minio/health/live + +# Orchestrator +curl http://localhost:8000/health + +# Kong +curl http://localhost:8101/status +``` + +## 🔧 Troubleshooting + +### Services Won't Start +```bash +# Check if ports are already in use +netstat -an | grep -E "(5436|6379|5672|9000|8000|8100|8280)" + +# Check Docker resources +docker system df +docker system prune -a # Warning: removes all unused images + +# Check logs for specific service +make logs-postgres # Replace with service name +``` + +### Database Connection Issues +```bash +# Test PostgreSQL connection +docker exec dataLayer-postgres psql -U postgres -c "\l" + +# Check if database exists +docker exec dataLayer-postgres psql -U postgres -c "SELECT datname FROM pg_database;" +``` + +### Message Queue Issues +```bash +# Check RabbitMQ queues +docker exec dataLayer-rabbitmq rabbitmqctl list_queues + +# Check RabbitMQ connections +docker exec dataLayer-rabbitmq rabbitmqctl list_connections +``` + +### Reset Everything +```bash +# WARNING: This deletes all data! +make clean +make pyramid +``` + +## 🛠️ Development + +### Adding a New Service + +1. **Create service folder** in appropriate layer: + ```bash + mkdir -p {layer-name}/new-service + ``` + +2. **Add docker-compose.yml** to service folder + +3. **Update layer's docker-compose.yml** to include new service + +4. **Update this README** with new service details + +### Modifying Configurations + +1. **Environment Variables**: Edit `.env` files in each layer +2. **Docker Compose**: Edit `docker-compose.yml` in each layer +3. **Service Configs**: Edit configuration files in service folders + +### Building Custom Images +```bash +# Build all services +make build + +# Build specific layer +cd data-layer && make build +``` + +## 📊 Architecture Flow + +```mermaid +graph TB + subgraph "Layer 3: Gateway & Auth" + Kong[Kong Gateway :8100] + Keycloak[Keycloak :8280] + end + + subgraph "Layer 2: Orchestration" + Orchestrator[Orchestrator :8000] + Analysis[Analysis :8004] + end + + subgraph "Layer 1: Data" + PostgreSQL[(PostgreSQL :5436)] + Redis[(Redis :6379)] + RabbitMQ[RabbitMQ :5672] + MinIO[MinIO :9000] + end + + Kong --> Orchestrator + Kong --> Analysis + Orchestrator --> PostgreSQL + Orchestrator --> Redis + Orchestrator --> RabbitMQ + Analysis --> PostgreSQL + Analysis --> Redis + Analysis --> RabbitMQ + Analysis --> MinIO +``` + +## 🔒 Security Notes + +- **Change default passwords** before production deployment +- **Use environment variables** for sensitive data +- **Enable TLS/SSL** for all external connections +- **Configure firewall rules** to restrict access +- **Regular backup** of PostgreSQL and MinIO data + +## 📝 Layer Dependencies + +Each layer depends on the layers below it: + +1. **Data Layer**: Independent (foundation) +2. **Orchestration Layer**: Requires Data Layer +3. **Gateway Layer**: Requires both Data and Orchestration Layers + +Always start services from bottom to top (pyramid order) for proper initialization. + +## 🚦 Service Startup Order + +The `make pyramid` command ensures correct startup order: + +1. **Data Layer Services** (10s wait) + - PostgreSQL → Redis → RabbitMQ → MinIO +2. **Orchestration Services** (5s wait) + - Orchestrator → Analysis +3. **Gateway Services** (5s wait) + - Kong → Keycloak + +## 📚 Additional Resources + +- [Data Layer README](./data-layer/README.md) +- [Orchestration Layer README](./orchestration-layer/README.md) +- [Gateway & Auth Layer README](./gateway-auth-layer/README.md) +- [Admin Dashboard](../admin-dashboard/README.md) + +--- + +**Version**: 1.0.0 +**Architecture**: Pyramid Microservices +**Last Updated**: December 2024 \ No newline at end of file diff --git a/backend/services/data-layer/.env.example b/backend/services/data-layer/.env.example new file mode 100644 index 0000000..57e17e0 --- /dev/null +++ b/backend/services/data-layer/.env.example @@ -0,0 +1,16 @@ +# ============================================================================ +# DIDI Data Layer - Master Configuration +# ============================================================================ +# This file is for future use when we need unified configuration +# For now, each service has its own .env file in its directory + +# Docker Compose Project Name +COMPOSE_PROJECT_NAME=didibackend + +# Network Configuration +NETWORK_SUBNET=172.28.0.0/16 + +# Future: Unified credentials management +# DATABASE_PASSWORD=change_me +# CACHE_PASSWORD=change_me +# STORAGE_PASSWORD=change_me \ No newline at end of file diff --git a/backend/services/data-layer/README.md b/backend/services/data-layer/README.md new file mode 100644 index 0000000..96d622f --- /dev/null +++ b/backend/services/data-layer/README.md @@ -0,0 +1,350 @@ +# DIDI Backend - Data Layer 🗄️ + +## Quick Start 🚀 + +```bash +# Recommended: Start via unified deployment manager +./deploy/didi.sh staging start +``` + +All data services start automatically as part of the staging deployment. + +## What is the Data Layer? 🤔 + +The Data Layer provides **pure storage** for the DIDI Backend platform. No business logic, no routing, just reliable data storage. + +## Architecture Overview 📊 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ │ │ │ │ │ │ │ │ +│ │ PostgreSQL │ │ Redis │ │ MinIO │ │ RabbitMQ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ Database │ │ Cache │ │ Storage │ │ Queue │ │ +│ │ PgAdmin │ │ Commander │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ Ports: Ports: Ports: Ports: │ +│ 22001 (DB) 22301 (Cache) 27000 (API) 23100 (AMQP) │ +│ 29001 (UI) 29002 (UI) 27001 (Console) 23101 (UI) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Network: didi-backend (shared by ALL services) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +> **Note**: Ports shown are for staging (the primary deployment method via `./deploy/didi.sh staging`). + +## The Four Services + Management UIs 📦 + +### 1. **didiDatabase** (PostgreSQL) 🐘 +**Purpose**: Persistent structured data storage +- Stores all application data +- 5 schemas (users, catalog, pipelines, execution, analyses) +- ACID compliant transactions +- Port: **22001** (staging) +- **pgAdmin** (Port 29001): Web UI for database management + +### 2. **didiCache** (Redis) ⚡ +**Purpose**: High-speed temporary storage +- Pipeline execution status +- Real-time updates +- Session data +- 24-hour TTL for most keys +- Port: **22301** (staging) +- **Redis Commander** (Port 29002): Web UI for Redis management + +### 3. **didiStorage** (MinIO) 📁 +**Purpose**: Object/file storage +- 7 auto-created buckets +- Media files (images, videos, audio) +- Documents and backups +- Auto-expiry policies +- Ports: **27000** (API), **27001** (Console) (staging) + +### 4. **didiQueue** (RabbitMQ) 🐰 +**Purpose**: Message queue for async processing +- Pipeline job queuing +- Decouples API from processing +- Single unified queue for all analysis types +- Durable message storage +- Ports: **23100** (AMQP), **23101** (Management UI) (staging) + +## Quick Start - Entire Data Layer 🎯 + +### Start Everything +```bash +# From this directory +make up + +# Or with Docker Compose +docker compose up -d +``` + +### Stop Everything +```bash +make down +``` + +### Check Status +```bash +make status +``` + +### View Logs +```bash +make logs +``` + +## Individual Service Management 🔧 + +### Start Individual Services +```bash +make up-database # Start only PostgreSQL +make up-cache # Start only Redis +make up-storage # Start only MinIO +make up-queue # Start only RabbitMQ +``` + +### Check Individual Health +```bash +make health-database +make health-cache +make health-storage +make health-queue +``` + +## Access Points 🌐 (Staging) + +| Service | Type | Access URL | Credentials | +|---------|------|------------|-------------| +| PostgreSQL | Database | `localhost:22001` | postgres / postgres123 | +| pgAdmin | Web UI | `http://localhost:29001` | admin@example.com / admin123 | +| Redis | Cache | `localhost:22301` | Password: redis123 | +| Redis Commander | Web UI | `http://localhost:29002` | admin / commander123 | +| MinIO | API | `localhost:27000` | minioadmin / minio123 | +| MinIO | Console | `http://localhost:27001` | minioadmin / minio123 | +| RabbitMQ | AMQP | `localhost:23100` | admin / rabbitmq123 | +| RabbitMQ | Management UI | `http://localhost:23101` | admin / rabbitmq123 | + +## What Gets Auto-Created? ✨ + +When you run `make up`: + +### PostgreSQL +- ✅ 5 schemas +- ✅ All tables +- ✅ Indexes and triggers +- ✅ User subscription plans + +### Redis +- ✅ Configured with password +- ✅ Persistence enabled +- ✅ Memory limits set +- ✅ Ready for connections + +### MinIO +- ✅ 7 buckets created +- ✅ Versioning enabled +- ✅ Lifecycle policies +- ✅ Service access policies + +### RabbitMQ +- ✅ Unified analysis queue created +- ✅ Dead letter queue configured +- ✅ Management plugin enabled +- ✅ Message TTL policies set + +## Directory Structure 📂 + +``` +data-layer/ +├── README.md # This file +├── docker-compose.yml # Unified orchestration +├── Makefile # Simple commands +│ +├── didiDatabase/ # PostgreSQL Service +│ ├── docker-compose.yml +│ ├── init.sql # Database schema +│ ├── .env # Configuration +│ └── README.md # Service docs +│ +├── didiCache/ # Redis Service +│ ├── docker-compose.yml +│ ├── redis.conf # Redis config +│ ├── .env # Configuration +│ └── README.md # Service docs +│ +├── didiStorage/ # MinIO Service +│ ├── docker-compose.yml +│ ├── init-buckets.sh # Auto-setup +│ ├── .env # Configuration +│ └── README.md # Service docs +│ +└── didiQueue/ # RabbitMQ Service + ├── docker-compose.yml + ├── init-queues.sh # Queue setup + ├── .env # Configuration + └── README.md # Service docs +``` + +## Environment Variables 🔐 + +Each service has its own `.env` file. **Change these for production!** + +### Critical Passwords to Change: +- `POSTGRES_PASSWORD` in didiDatabase/.env +- `REDIS_PASSWORD` in didiCache/.env +- `HTTP_PASSWORD` for Redis Commander in docker-compose.yml +- `MINIO_ROOT_PASSWORD` in didiStorage/.env +- `RABBITMQ_PASSWORD` in didiQueue/.env + +## Testing the Data Layer 🧪 + +```bash +# Run all tests +make test + +# Test individual services +make test-database +make test-cache +make test-storage +make test-queue +``` + +## Production Deployment 🚀 + +```bash +# Check for default passwords +make check-security + +# Deploy with production settings +make prod +``` + +## Troubleshooting 🔧 + +### Service won't start? +```bash +# Check logs +make logs-database +make logs-cache +make logs-storage +make logs-queue +``` + +### Port conflicts? +Edit the `.env` file in the service directory and change the port. + +### Need a fresh start? +```bash +# WARNING: Deletes all data! +make clean +make up +``` + +## Resource Usage 📊 + +| Service | Memory Limit | CPU Limit | Disk Usage | +|---------|-------------|-----------|------------| +| PostgreSQL | 2GB | 1.0 CPU | ~500MB + data | +| Redis | 512MB | 0.5 CPU | ~100MB + cache | +| MinIO | 1GB | 0.5 CPU | ~200MB + files | +| RabbitMQ | 1GB | 0.5 CPU | ~100MB + messages | + +**Total**: ~4.5GB RAM, 2.5 CPUs + +## Network Architecture 🌐 + +All services communicate on the `didi-backend` network: +- **Shared by ALL backend services** (data layer, API layer, orchestration, etc.) +- Internal DNS resolution by service name +- Isolated from external access (except mapped ports) +- Services can reach each other by hostname +- Other Docker Compose projects can join this network using: + ```yaml + networks: + default: + external: true + name: didi-backend + ``` + +## Why This Architecture? 🎯 + +1. **Separation of Concerns** + - Each service does ONE thing well + - Easy to scale individually + - Simple to understand + +2. **Zero Configuration** + - Everything auto-configures + - No manual setup needed + - Production-ready defaults + +3. **Developer Friendly** + - One command to start + - Clear documentation + - Web UIs included + +## Next Steps 🏗️ + +The Data Layer is complete! Next layers to build: + +``` +✅ data-layer/ # Complete! +⏳ orchestration-layer/ # Next: RabbitMQ, Kong, Vault +⏳ service-layer/ # Then: Microservices +⏳ application-layer/ # Finally: UI/Apps +``` + +## Support & Maintenance 🛠️ + +### Daily Tasks +- Check logs: `make logs` +- Monitor usage: `make stats` +- Backup data: `make backup` + +### Weekly Tasks +- Review disk usage +- Check for updates +- Rotate passwords + +### Monthly Tasks +- Full backup +- Performance review +- Security audit + +--- + +## Quick Reference Card 📋 + +```bash +# Essential Commands +make up # Start everything +make down # Stop everything +make status # Check health +make logs # View logs +make clean # Delete all data + +# Individual Services +make up-database # Start PostgreSQL +make up-cache # Start Redis +make up-storage # Start MinIO + +# Utilities +make backup # Backup all data +make restore # Restore from backup +make test # Run tests +make prod # Production deploy +``` + +--- + +**🎉 Your Data Layer is Ready!** + +Simple. Reliable. Production-Ready. + +*Version: 1.0.0 | Last Updated: 2025-08-31* \ No newline at end of file diff --git a/backend/services/data-layer/didiCache/.env.example b/backend/services/data-layer/didiCache/.env.example new file mode 100644 index 0000000..92c40ae --- /dev/null +++ b/backend/services/data-layer/didiCache/.env.example @@ -0,0 +1,36 @@ +# ============================================================================ +# didiCache Environment Configuration +# ============================================================================ +# Copy this file to .env and update with your values + +# Docker Compose Project Name (groups containers in Docker Desktop) +COMPOSE_PROJECT_NAME=didibackend_datalayer + +# Redis Configuration +REDIS_PASSWORD=YOUR_SECURE_PASSWORD_HERE +REDIS_PORT=6380 # Using 6380 to avoid conflict with default 6379 + +# Memory Limits +REDIS_MEMORY_LIMIT=512M +REDIS_MEMORY_RESERVATION=256M + +# Performance Tuning +REDIS_MAXMEMORY=512mb +REDIS_MAXMEMORY_POLICY=allkeys-lru +REDIS_TIMEOUT=0 +REDIS_TCP_KEEPALIVE=300 +REDIS_DATABASES=16 + +# Persistence Configuration +REDIS_SAVE="900 1 300 10 60 10000" +REDIS_APPENDONLY=yes +REDIS_APPENDFSYNC=everysec + +# Logging +REDIS_LOGLEVEL=notice + +# Client Limits +REDIS_MAXCLIENTS=10000 + +# Timezone +TZ=UTC \ No newline at end of file diff --git a/backend/services/data-layer/didiCache/.gitignore b/backend/services/data-layer/didiCache/.gitignore new file mode 100644 index 0000000..cdbf653 --- /dev/null +++ b/backend/services/data-layer/didiCache/.gitignore @@ -0,0 +1,26 @@ +# Environment variables +.env +.env.local + +# Data directory +data/ +*.rdb +*.aof + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Backup files +*.bak +*.backup +*.old \ No newline at end of file diff --git a/backend/services/data-layer/didiCache/INDEX.md b/backend/services/data-layer/didiCache/INDEX.md new file mode 100644 index 0000000..e7229cc --- /dev/null +++ b/backend/services/data-layer/didiCache/INDEX.md @@ -0,0 +1,216 @@ +# didiCache - Index + +**Productia ruleaza pe Redis LOCAL** (container `didi-cache` pe `didi-network`). Decizie: stabilitate + zero dependinte externe + acces rapid. Clusterul Redis RAG (managed extern, HAProxy VIP peste rag01/02/03) ramane configurat ca **fallback de urgenta pentru HA** — activabil cu `redis-switch.sh cluster`, nu este folosit operational acum. + +**Productie (LOCAL — activ)**: +- Container: `didi-cache` (imagine `redis:7-alpine`) +- Host intern Docker: `didi-cache:6379` +- DB: `0` +- Parola: `REDIS_PASSWORD` din `.env` (default `redis123` in dev) +- Memorie: 512MB (eviction: allkeys-lru) +- Retea: `didi-network`; portul `6379` este expus pe host (`0.0.0.0:6379->6379`) +- Persistenta: RDB + AOF + +**Fallback HA (cluster RAG — disponibil dar inactiv)**: +- Host: `10.11.50.100` (HAProxy VIP) +- Port: `16379` +- DB: `0` +- User: `didi` +- Parola: din `.cluster-credentials.env` (gitignored) + +**Switch intre LOCAL si cluster**: `backend/services/orchestration-layer/scripts/redis-switch.sh {cluster|local|status}` rescrie fisierele `.env` ale serviciilor (agent-v3, didiFramework) + restart containere. Status curent: `local`. + +**Helper de conectare folosit de toate serviciile**: +- agent-v3 -> `src/shared/redis/connection.ts` (`createRedisConnection()`) +- didiFramework -> `src/config/redis.ts` (`createRedisConnection()`) + +--- + +## Ce face + +Stocheaza date temporare si configurari pentru platforma DIDI: +- Cache framework (parametri tehnici, verdicts, ponderi) -- scrise de didiFramework via sync-redis +- Configurare HIL Moderation (triage thresholds, brain client config, sensitive topics, roluri) -- scrise de didiFramework +- Sesiuni analiza (AnalysisSession JSON, TTL 7 zile) -- scrise de agent-v3 +- Rezultate intermediare per etapa (TTL 7 zile) -- scrise de agent-v3 +- Stare coada async (progres workeri) -- scrise de agent-v3 +- Lock-uri concurenta workeri (TTL 30s-5min) -- scrise de agent-v3 +- Chei API extensie browser (cache validare) -- scrise de didiFramework +- Configurare modele viziune -- scrise de didiFramework + +**Sync-redis scrie ~51 chei de configurare** (crescut de la 48 dupa adaugarea cheilor HIL Moderation in 2026-05-01). Plus `framework_keys`: 7 chei standard sau 8 daca include `providers`. + +--- + +## Cine scrie in Redis + +| Serviciu | Ce scrie | Chei Redis | +|----------|----------|------------| +| didiFramework (sync-redis) | Ierarhie tehnici, claims, verdicts, ponderi, surse, provideri | didi:framework:* | +| didiFramework (sync-redis) | Configurare componente pe etape | didi:config:* | +| didiFramework (extension-keys) | Cache validare chei API extensie | didi:extension:key:* | +| agent-v3 (sesiuni) | Sesiune completa JSON | didi:pipeline:{sessionId}:status | +| agent-v3 (executori) | Rezultate intermediare per etapa | agent:result:{sessionId}:{component}:{stage} | +| agent-v3 (coada) | Stare procesare async | didi:queue:session:{sessionId} | +| agent-v3 (workeri) | Lock-uri concurenta | didi:queue:lock:* | + +## Cine citeste din Redis + +| Serviciu | Ce citeste | Chei Redis | +|----------|-----------|------------| +| agent-v3 (executori) | Configurare framework (tehnici, ponderi, verdicts) | didi:framework:* | +| agent-v3 (executori) | Modele disponibile, stage assignments, prompturi | didi:config:* | +| agent-v3 (rute) | Sesiuni pentru polling status | didi:pipeline:{sessionId}:status | +| agent-v3 (rute) | Rezultate intermediare | agent:result:{sessionId}:* | +| agent-v3 (pipeline) | Validare cheie extensie | didi:extension:key:* | +| didiFramework (debug) | Verificare date sincronizate | didi:framework:* | + +--- + +## Chei Redis principale + +| Pattern | Scop | TTL | Scris de | +|---------|------|-----|----------| +| didi:framework:manifest | Index categorii + timestamp sync | permanent | didiFramework | +| didi:framework:techniques | Ierarhie completa tehnici (denormalizata) | permanent | didiFramework | +| didi:framework:claims | Parametri claims (tipuri, statusuri, confidence) | permanent | didiFramework | +| didi:framework:verdicts | Categorii verdict + risk mappings + severity | permanent | didiFramework | +| didi:framework:weights | Ponderi componente + scenarii + multiplicatori | permanent | didiFramework | +| didi:framework:sources | Evaluare surse | permanent | didiFramework | +| didi:framework:providers | Configurare LLM | permanent | didiFramework | +| didi:framework:dimensions_compact | Lista compacta dimensiuni (screening) | permanent | didiFramework | +| didi:config:{component}:v1:* | Config componenta (modele, etape, prompturi) | permanent | didiFramework | +| didi:config:techniques:v3:stage_assignments | **TIER-NESTED** `{stage: {free: {models}, premium: {models}}}` — chain-uri LLM per tier | permanent | didiFramework | +| didi:config:ai-tampered:v1:stage_assignments | **TIER-NESTED** stage assignments AI-Tampered | permanent | didiFramework | +| didi:config:claims:v1:stage_assignments | **TIER-NESTED** stage assignments Claims | permanent | didiFramework | +| didi:config:source-assessment:v1:stage_assignments | **TIER-NESTED** stage assignments Source Assessment | permanent | didiFramework | +| didi:config:vision:v1:stage_assignments | **TIER-NESTED** (Etapa 4) stage `image_analysis` — citit de `callVision()` cu tier param | permanent | didiFramework | +| didi:config:verdict:v1:stage_assignments | **TIER-NESTED** (Etapa 5) stage `verdict_review` — citit de `verdict-explanation.ts loadModels(tier)` | permanent | didiFramework | +| didi:config:ai-tampered:v1:vision_models | Legacy flat vision config (fallback pentru vision.ts daca `didi:config:vision:v1:stage_assignments` lipseste) | permanent | didiFramework | +| didi:config:vision:v1:prompts:extraction | Prompt viziune: extragere text din imagini | permanent | didiFramework | +| didi:config:vision:v1:prompts:video_frames | Prompt viziune: analiza cadre video | permanent | didiFramework | +| didi:config:vision:v1:prompts:ai_detection | Prompt viziune: detectie AI imagini | permanent | didiFramework | +| didi:config:pipeline:v1:component_config | Config componente pipeline + video track weights | permanent | didiFramework | +| didi:config:pipeline:v1:session_config | Config sesiune pipeline | permanent | didiFramework | +| didi:config:pipeline:v1:verdict_config | Override-uri verdict, synergy, confidence (globale). JSONB sincronizat din PG `bos_parammgmt.component_config WHERE (component_code='pipeline', config_key='verdict_config')`. Contine: `synergy` + override-uri (`false_claims`, `severe_techniques`, `undisclosed_ai`, `untrusted_domain`, `domain_red_flags`) + `confidence` + `confidence_levels`. Editabil din admin dashboard via `/api/verdicts/runtime-config` (GET/PUT/PATCH). | permanent | didiFramework | +| didi:config:pipeline:v1:input_profiles | **Profiluri verdict per input type (6 profile: ponderi, override-uri, INCONCLUSIVE)** | permanent | didiFramework | +| didi:config:techniques:v3:scoring_config | Parametri scoring techniques (count_scaler, intensity, severe_threshold) | permanent | didiFramework | +| didi:config:ai-tampered:v1:scoring_config | Parametri scoring AI (blend_weights, disclosure_impact, thresholds) | permanent | didiFramework | +| didi:config:claims:v1:scoring_config | Parametri scoring claims (status_weights, unverified behavior) | permanent | didiFramework | +| didi:config:source-assessment:v1:scoring_config | Parametri scoring source (axis_weights, verdict_thresholds) | permanent | didiFramework | +| didi:config:source-assessment:v1:available_models | Modele LLM source assessment (union free+premium) | permanent | didiFramework | +| didi:config:source-assessment:v1:prompts:extraction | Prompt extractie metadata sursa | permanent | didiFramework | +| didi:config:source-assessment:v1:prompts:evaluation | Prompt evaluare sursa | permanent | didiFramework | +| didi:config:verdict:v1:available_models | Legacy fallback pentru verdict reviewer (folosit daca `stage_assignments` lipseste) | permanent | didiFramework | + +### HIL Moderation (synced from PG by sync-redis) + +Adaugat in 2026-05-01. Citit de agent-v3 in `triage.ts` (cache local 60s) si `brain/client.ts` pentru configurare live. + +| Pattern | Scop | TTL | Scris de | +|---------|------|-----|----------| +| didi:config:moderation:v1:settings | Single-row config: triage thresholds + brain client config (brain_enabled, brain_url, lookup/write timeouts, brain_confidence_min_silver, brain_semantic_threshold, brain_per_component) | permanent | didiFramework | +| didi:config:moderation:v1:sensitive_topics | Active topics list (elections, health, war, covid, climate) | permanent | didiFramework | +| didi:config:moderation:v1:roles | Keycloak role -> permissions (moderator, senior_moderator) cu toggle flags | permanent | didiFramework | + +**Nota structura tier-nested** (aplicabila tuturor cheilor `stage_assignments`): +```json +{ + "techniques_screening": { + "free": { "models": [{"order":1,"model_key":"qwen35:Qwen3.5-397B-A17B",...}, ...] }, + "premium": { "models": [{"order":1,"model_key":"openrouter:google/gemini-3-flash-preview",...}, ...] } + }, + "techniques_deep": { "free": {...}, "premium": {...} } +} +``` + +Sync-redis scrie cheia intr-un singur SET (atomic). agent-v3 citeste cheia o singura data, apoi rezolva tier-ul local cu `stages[stageCode][tier] || stages[stageCode].free`. +| didi:pipeline:{sessionId}:status | Status executie pipeline (JSON: PipelineStatus) | 7 zile | agent-v3 | +| didi:pipeline:{sessionId}:{component} | Rezultat componenta (JSON: TechniquesResult etc.) | 7 zile | agent-v3 | +| didi:pipeline:{sessionId}:verdict | Rezultat verdict final (JSON: VerdictResult) | 7 zile | agent-v3 | +| didi:pipeline:history:entry:{sessionId} | Date intrare sesiune (input + metadata) | 7 zile | agent-v3 | +| didi:pipeline:history:user:{userId} | Sorted set istoric utilizator (score=timestamp) | 7 zile | agent-v3 | +| agent:result:{sessionId}:{comp}:{stage} | Rezultat intermediar etapa | 7 zile | agent-v3 | +| didi:queue:session:{sessionId} | Stare coada async | 24 ore | agent-v3 | +| didi:queue:lock:{sessionId}:{comp} | Lock worker per componenta | 5 min | agent-v3 | +| didi:queue:aggregator:{sessionId} | Lock agregator verdict | 30s | agent-v3 | +| didi:extension:key:{key} | Cache cheie API extensie | permanent | didiFramework | + +--- + +## Configurare + +### redis.conf + +- Bind: 0.0.0.0 (acces din Docker network) +- Protected mode: activat +- Max memorie: 512MB +- Eviction: allkeys-lru (sterge cele mai vechi chei cand se umple) +- Persistenta RDB: snapshot la 60s/10000 keys, 300s/10 keys, 900s/1 key +- Persistenta AOF: activat, sync everysec +- Max clienti: 10000 +- 16 baze de date +- Slow log: 10ms threshold +- Parola: obligatorie (requirepass) + +### Pornire in productie + +Nota: didi-cache NU este definit in data-layer/docker-compose.yml. Containerul Redis este definit in `production/docker-compose.yml` (alaturi de Kong si Keycloak). + +```yaml +# din production/docker-compose.yml +didi-cache: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} --save 60 1 --save 300 10 + volumes: + - didi-cache-data:/data + networks: + - didi-network + healthcheck: + test: redis-cli -a ${REDIS_PASSWORD} ping +``` + +Nota: redis.conf din directorul didiCache nu este montat in container in productie. Containerul foloseste parametrii din command line. + +### Conexiune + +**Productie (LOCAL — activ)**: +- Host: `didi-cache` (Docker DNS pe `didi-network`) +- Port: `6379` (expus si pe host: `0.0.0.0:6379->6379`) +- DB: `0` +- Parola: `redis123` (din `.env`, `REDIS_PASSWORD`) + +**Fallback HA (cluster RAG — disponibil dar inactiv, doar dupa `redis-switch.sh cluster`)**: +- Host: `10.11.50.100` (HAProxy VIP rag01/02/03) +- Port: `16379` +- DB: `0` +- Username: `didi` +- Parola: din `.cluster-credentials.env` (gitignored) + +### Administrare + +- Conexiune directa: `redis-cli -h didi-cache -a redis123` (sau `127.0.0.1:6379` de pe host). + +--- + +## Fisiere in directorul didiCache + +``` +redis.conf -- Configurare Redis completa (183 linii, nu e montata in productie) +.env.example -- Template variabile de mediu +.gitignore -- Exclude .env, data/, *.rdb, *.aof +README.md -- Documentatie +``` + +Zero cod custom. Zero module Redis. Zero scripturi. Doar configurare. + +--- + +## Ce NU face + +- Nu e Redis Cluster (instanta singulara) +- Nu e Redis Sentinel (fara failover automat) +- Nu are module custom +- Nu are replicare +- Nu are ACL (doar autentificare cu parola) +- Nu proceseaza nimic -- doar stocheaza si serveste date scrise de alte servicii diff --git a/backend/services/data-layer/didiCache/MIGRATION.md b/backend/services/data-layer/didiCache/MIGRATION.md new file mode 100644 index 0000000..08858c2 --- /dev/null +++ b/backend/services/data-layer/didiCache/MIGRATION.md @@ -0,0 +1,117 @@ +# Redis — migrat pe clusterul RAG (2026-04-22) + +> **TL;DR**: DIDI folosește **clusterul Redis RAG** (3 noduri Sentinel + HAProxy VIP). Containerul local `didi-cache` din `production/docker-compose.yml` e **oprit dar păstrat** ca fallback rapid. Conexiunea e centralizată via `agent-v3/src/shared/redis/connection.ts`. + +--- + +## Ce era aici (legacy) + +`didi-cache` — un container Redis 7 Alpine standalone pe `didi-network` Docker. Single-node, fără HA, fără replicare. 512MB max memory, allkeys-lru eviction. Servea toate sesiunile pipeline + cache-ul framework + lock-uri workeri. + +## Ce e acum + +### Cluster Redis RAG (productie) + +| Nod | Hostname | IP | Port Redis | Port Sentinel | +|---|---|---|---|---| +| rag01 (HAProxy VIP) | `rag01` | `10.11.50.101` | 6379 (replica) | 26379 | +| **rag02 (master curent)** | `rag02` | `10.11.50.102` | **6379 (master)** | 26379 | +| rag03 | `rag03` | `10.11.50.103` | 6379 (replica) | 26379 | + +**Endpoint-uri pentru aplicații:** + +| Scop | Endpoint | +|---|---| +| **WRITE** (auto-routing master) | `10.11.50.100:16379` | +| READ (replica locală rag01) | `10.11.50.100:6379` | +| Sentinel discovery | `10.11.50.100:16380` (master name `ragmaster`) | +| HAProxy stats | `http://10.11.50.100:8404/stats` | + +User ACL DIDI: `didi` cu prefix de chei `didi:*` și channels `didi.*`. Parola în vault-ul de credențiale `name='Redis ACL — didi'`. + +ACL persistent via `--aclfile /data/users.acl` pe toate 3 nodurile (fix permanent 2026-04-22). + +### Conexiune centralizată în cod + +`agent-v3/src/shared/redis/connection.ts` — toate conexiunile ioredis trec prin `createRedisConnection(label)`. Zero `new Redis({...})` inline. Auto-reconnect cu retryStrategy, reconnectOnError pentru `READONLY` / `MASTERDOWN` (failover). + +```typescript +// Folosire în cod: +import { createRedisConnection } from './shared/redis/connection'; +const redis = createRedisConnection('framework-cache'); +``` + +`didiFramework` are propria implementare în `src/lib/redis.ts`. + +## Containerul local `didi-cache` + +Definit în `production/docker-compose.yml`, configurat să pornească dar **manual oprit** ca parte din migrare. Volumul `didi-production-cache-data` e intact — datele anterioare (snapshot-uri RDB + AOF) sunt păstrate. + +Status curent: `Exited`. + +### De ce e păstrat? + +- **Fallback rapid** dacă cluster RAG e indisponibil (oprit pentru maintenance, network issue, etc.) +- Pentru a-l reactiva temporar, există un script switch: + ```bash + backend/services/orchestration-layer/scripts/redis-switch.sh local redis + ``` + Asta rescrie `.env` și restart agent-v3 stack pentru a folosi `didi-cache` în loc de cluster. + +## Switch rapid cluster ↔ local + +Script: `backend/services/orchestration-layer/scripts/redis-switch.sh` + +```bash +# Folosește local fallback (didi-cache) +./redis-switch.sh local redis + +# Folosește clusterul (default) +./redis-switch.sh cluster redis + +# Status +./redis-switch.sh status redis +``` + +Credentialele cluster în sidecar `.cluster-credentials.env` (gitignored). + +## Verificare cluster + +```bash +# Cu redis-cli din host (default user — pentru debug) +redis-cli -h 10.11.50.100 -p 16379 -a PING + +# Cu user DIDI +redis-cli -h 10.11.50.100 -p 16379 --user didi -a ACL WHOAMI + +# Sentinel master discovery +redis-cli -h 10.11.50.100 -p 16380 SENTINEL get-master-addr-by-name ragmaster +``` + +## Bootstrap automat la fresh deploy + +`didiFramework` detectează la pornire dacă `didi:framework:manifest` lipsește în Redis și auto-rulează `POST /api/sync-redis` (56 chei populate în ~700ms). Zero intervenție manuală pe cluster nou. + +## Chei Redis principale (orientativ) + +| Pattern | Scop | TTL | +|---|---|---| +| `didi:framework:*` | Config framework (tehnici, ponderi, verdicts) | permanent | +| `didi:config:{component}:v1:*` | Config componente, stage assignments tier-nested | permanent | +| `didi:pipeline:{sessionId}:*` | Sesiuni pipeline (status, rezultate) | 7 zile | +| `didi:queue:*` | Lock-uri workeri | 30s-5min | +| `agent:result:*` | Rezultate intermediare | 7 zile | +| `agent:media:*` | Cache media (transcript, vision) | 1 oră | + +## Linkuri rapide + +- Ghid utilizare cluster: `landingzone/redis-rag/README.md` (repo `git.finesynergy.eu/lucian/landingzone`) +- Onboarding ACL user nou: `landingzone/redis-rag/CLAUDE_PROMPT.md` +- HAProxy stats: `http://10.11.50.100:8404/stats` + +## Status + +- ✅ Migrare aplicată: 2026-04-22 +- ✅ User `didi` configurat cu prefix izolat +- ✅ Container local păstrat ca fallback (oprit, volum intact) +- ✅ Bootstrap framework automat la pornire diff --git a/backend/services/data-layer/didiCache/README.md b/backend/services/data-layer/didiCache/README.md new file mode 100644 index 0000000..6eb66b6 --- /dev/null +++ b/backend/services/data-layer/didiCache/README.md @@ -0,0 +1,147 @@ +# DIDI Cache Service 🚀 + +## Super Simple Start Guide ⚡ + +### Step 1: Set Your Password +```bash +# Copy the example file +cp .env.example .env + +# Edit .env and change this line: +REDIS_PASSWORD=YOUR_SECURE_PASSWORD_HERE +``` + +### Step 2: Start Redis +```bash +docker compose up -d +``` + +That's it! Your cache is running! 🎉 + +## Check If It's Working ✅ + +```bash +# See if container is healthy +docker ps + +# Look for: didi-cache (healthy) +``` + +## Connection Info 📡 + +- **Host**: localhost +- **Port**: 6380 (not 6379 to avoid conflicts!) +- **Password**: (what you set in .env) + +## Test the Connection 🔌 + +```bash +# Connect with Redis CLI +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD + +# Test it +127.0.0.1:6379> PING +PONG + +# Exit +127.0.0.1:6379> EXIT +``` + +## What's Inside? 📦 + +This Redis cache is configured for: +- ✅ 512MB memory (perfect for status & results) +- ✅ Auto-expiry support (services set TTL) +- ✅ Persistence enabled (survives restarts) +- ✅ Password protected + +### How DIDI Uses Redis 🔄 + +``` +Pipeline runs → Status stored here (24hr TTL) + → Results cached here + → UI polls for updates + → After 24hrs, auto-deleted +``` + +### Key Patterns We Store 📝 +- `run:{run_id}` - Pipeline execution status +- `node_status:{run_id}` - Each node's progress +- `results:{run_id}` - Analysis results + +## Common Commands 🛠️ + +```bash +# Stop cache +docker compose down + +# View logs +docker compose logs -f + +# Connect to Redis CLI +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD + +# Restart fresh (WARNING: Deletes all cache!) +docker compose down -v +docker compose up -d +``` + +## Monitor Cache Usage 📊 + +```bash +# Check memory usage +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD INFO memory + +# See all keys +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD KEYS "*" + +# Count keys +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD DBSIZE +``` + +## Troubleshooting 🔧 + +### Port 6380 already in use? +Edit `.env` and change `REDIS_PORT` to something else (like 6381) + +### Can't connect? +1. Check container is healthy: `docker ps` +2. Verify password in .env +3. Make sure you're using port 6380, not 6379 + +### Memory full? +Redis will auto-delete least recently used keys (LRU policy) + +## Part of Something Bigger 🏗️ + +This cache is part of the DIDI Backend data layer: + +``` +📁 data-layer/ + ├── 📁 didiDatabase/ (PostgreSQL - Done!) + ├── 📁 didiCache/ (Redis - You are here!) + ├── 📁 didiQueue/ (RabbitMQ - Next) + └── 📁 didiStorage/ (MinIO - Coming soon) +``` + +## Quick Health Check 🏥 + +```bash +# Is it running? +docker exec -it didi-cache redis-cli -a YOUR_PASSWORD PING + +# Response should be: +# PONG +``` + +## Why Port 6380? 🤔 + +The old monolithic setup uses port 6379. We use 6380 to: +- Avoid conflicts during migration +- Run both services side-by-side +- Easy rollback if needed + +--- +**That's all you need to know! Happy caching! ⚡** + +*Version: 1.0.0 | Redis 7-alpine* \ No newline at end of file diff --git a/backend/services/data-layer/didiCache/redis.conf b/backend/services/data-layer/didiCache/redis.conf new file mode 100644 index 0000000..3da50cd --- /dev/null +++ b/backend/services/data-layer/didiCache/redis.conf @@ -0,0 +1,183 @@ +# ============================================================================ +# DIDI Cache Service Configuration (Redis 7) +# Production-ready configuration for the DIDI Backend platform +# ============================================================================ + +# ============================================================================ +# NETWORK & SECURITY +# ============================================================================ + +# Listen on all interfaces (Docker container) +bind 0.0.0.0 + +# Enable protected mode +protected-mode yes + +# Port +port 6379 + +# TCP listen() backlog +tcp-backlog 511 + +# TCP keepalive +tcp-keepalive 300 + +# Timeout for idle clients (0 to disable) +timeout 0 + +# ============================================================================ +# GENERAL +# ============================================================================ + +# Don't run as daemon (Docker handles this) +daemonize no + +# Server verbosity (debug, verbose, notice, warning) +loglevel notice + +# Log to stdout for Docker +logfile "" + +# Number of databases (we use 0 for main cache) +databases 16 + +# ============================================================================ +# MEMORY MANAGEMENT +# ============================================================================ + +# Maximum memory (adjust based on container limits) +maxmemory 512mb + +# Eviction policy when max memory is reached +# allkeys-lru: Remove least recently used keys +maxmemory-policy allkeys-lru + +# LRU samples for eviction +maxmemory-samples 5 + +# ============================================================================ +# PERSISTENCE - RDB (Snapshots) +# ============================================================================ + +# Save snapshots: +# After 900 sec (15 min) if at least 1 key changed +save 900 1 +# After 300 sec (5 min) if at least 10 keys changed +save 300 10 +# After 60 sec if at least 10000 keys changed +save 60 10000 + +# Error handling for background save +stop-writes-on-bgsave-error yes + +# Compress RDB dumps +rdbcompression yes + +# Checksum RDB files +rdbchecksum yes + +# Filename for RDB +dbfilename dump.rdb + +# Directory for RDB and AOF files +dir /data + +# ============================================================================ +# PERSISTENCE - AOF (Append Only File) +# ============================================================================ + +# Enable AOF +appendonly yes + +# AOF filename +appendfilename "appendonly.aof" + +# AOF sync policy (everysec = good balance) +appendfsync everysec + +# Don't fsync during rewrites +no-appendfsync-on-rewrite no + +# Auto rewrite AOF +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb + +# Load truncated AOF +aof-load-truncated yes + +# Use RDB format in AOF for faster loading +aof-use-rdb-preamble yes + +# ============================================================================ +# SLOW LOG +# ============================================================================ + +# Log queries slower than (microseconds) +slowlog-log-slower-than 10000 + +# Maximum length of slow log +slowlog-max-len 128 + +# ============================================================================ +# LATENCY MONITORING +# ============================================================================ + +# Latency threshold in milliseconds +latency-monitor-threshold 100 + +# ============================================================================ +# CLIENT HANDLING +# ============================================================================ + +# Maximum number of clients +maxclients 10000 + +# ============================================================================ +# ADVANCED CONFIG +# ============================================================================ + +# Hash tables +hash-max-ziplist-entries 512 +hash-max-ziplist-value 64 + +# Lists +list-max-ziplist-size -2 +list-compress-depth 0 + +# Sets +set-max-intset-entries 512 + +# Sorted sets +zset-max-ziplist-entries 128 +zset-max-ziplist-value 64 + +# HyperLogLog +hll-sparse-max-bytes 3000 + +# Streams +stream-node-max-bytes 4096 +stream-node-max-entries 100 + +# Active rehashing +activerehashing yes + +# Client output buffer limits +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit replica 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 + +# Frequency of rehashing the main dictionary +hz 10 + +# LFU settings +lfu-log-factor 10 +lfu-decay-time 1 + +# ============================================================================ +# DISABLE DANGEROUS COMMANDS (Production) +# ============================================================================ + +# Uncomment these in production to disable dangerous commands +# rename-command FLUSHDB "" +# rename-command FLUSHALL "" +# rename-command CONFIG "" \ No newline at end of file diff --git a/backend/services/data-layer/didiDatabase/.gitignore b/backend/services/data-layer/didiDatabase/.gitignore new file mode 100644 index 0000000..9a8ce13 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/.gitignore @@ -0,0 +1,68 @@ +# Environment files +.env +.env.local +.env.production + +# Data directories +data/ +backups/ +pg_data/ +pgdata/ + +# Log files +*.log +logs/ +log/ + +# Certificates and keys +certs/ +*.crt +*.key +*.pem +*.p12 +*.pfx + +# Backup files +*.dump +*.sql +*.sql.gz +*.tar +*.tar.gz +*.backup + +# pgAdmin data +pgadmin_data/ +.pgadmin/ + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# IDE files +.idea/ +.vscode/ +*.iml + +# Docker volumes (local bindings) +/data +/backups +/pgadmin_data + +# Test data +test_data/ +*.test.sql + +# Migration tracking +.migrations_applied + +# Monitoring data +prometheus_data/ +grafana_data/ \ No newline at end of file diff --git a/backend/services/data-layer/didiDatabase/Dockerfile b/backend/services/data-layer/didiDatabase/Dockerfile new file mode 100644 index 0000000..3dd7e20 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/Dockerfile @@ -0,0 +1,72 @@ +# PostgreSQL 15 Alpine - Rolling tag for security updates +FROM postgres:15-alpine + +# Set environment variables +ENV POSTGRES_DB=misinformation_db +ENV POSTGRES_USER=postgres +ENV POSTGRES_PASSWORD=postgres_dev_password_123 +ENV PGDATA=/var/lib/postgresql/data/pgdata + +# Install additional packages for production use +RUN apk add --no-cache \ + bash \ + curl \ + postgresql-client \ + && rm -rf /var/cache/apk/* + +# Create necessary directories +RUN mkdir -p /docker-entrypoint-initdb.d \ + && mkdir -p /var/lib/postgresql/data \ + && mkdir -p /scripts \ + && mkdir -p /backups + +# Copy initialization script +COPY init.sql /docker-entrypoint-initdb.d/01-init.sql + +# Copy health check script +COPY health-check.sh /scripts/health-check.sh +RUN chmod +x /scripts/health-check.sh + +# Set proper permissions +RUN chown -R postgres:postgres /var/lib/postgresql/data \ + && chown -R postgres:postgres /docker-entrypoint-initdb.d \ + && chown -R postgres:postgres /scripts \ + && chown -R postgres:postgres /backups + +# PostgreSQL configuration for production +RUN echo "shared_preload_libraries = 'pg_stat_statements'" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "pg_stat_statements.track = all" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "log_statement = 'all'" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "log_duration = on" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "log_min_duration_statement = 100" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "shared_buffers = 256MB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "effective_cache_size = 1GB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "maintenance_work_mem = 64MB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "checkpoint_completion_target = 0.9" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "wal_buffers = 16MB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "default_statistics_target = 100" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "random_page_cost = 1.1" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "effective_io_concurrency = 200" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "work_mem = 4MB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "min_wal_size = 1GB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "max_wal_size = 4GB" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "max_worker_processes = 8" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "max_parallel_workers_per_gather = 4" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "max_parallel_workers = 8" >> /usr/local/share/postgresql/postgresql.conf.sample \ + && echo "max_parallel_maintenance_workers = 4" >> /usr/local/share/postgresql/postgresql.conf.sample + +# Expose PostgreSQL port +EXPOSE 5432 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD /scripts/health-check.sh || exit 1 + +# Use the postgres user +USER postgres + +# Volume for data persistence +VOLUME ["/var/lib/postgresql/data", "/backups"] + +# Start PostgreSQL +CMD ["postgres"] \ No newline at end of file diff --git a/backend/services/data-layer/didiDatabase/INDEX.md b/backend/services/data-layer/didiDatabase/INDEX.md new file mode 100644 index 0000000..c7ca839 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/INDEX.md @@ -0,0 +1,536 @@ +# didiDatabase - Index + +Documentatie completa pentru baza de date PostgreSQL a platformei DIDI. Baza principala DIDI ruleaza pe un **container LOCAL** (`didi-postgres`, PostgreSQL 17) pe masina de deployment, in reteaua Docker `didi-network`. Clusterul extern Patroni/HAProxy ramane configurat ca fallback HA, dar NU este folosit operational acum. + +--- + +## PostgreSQL LOCAL (PRODUCTIE — activ) + +| Parametru | Valoare | +|-----------|---------| +| Container | `didi-postgres` | +| Imagine | `postgres:17-alpine` | +| Host intern | `didi-postgres:5432` (Docker DNS pe `didi-network`) | +| Port host | `5432` expus pe `0.0.0.0:5432->5432` | +| Database | `DIDI` | +| User principal | `bos_interface` / `interface` | + +### Baze de date pe instanta + +Instanta `didi-postgres` contine o singura baza de business, `DIDI` (4 scheme + public, ~2012 sesiuni de analiza la data documentatiei). + +| Baza / consumator | User | Folosita de | Note | +|-------------------|------|-------------|------| +| DIDI | bos_interface | agent-v3, didiFramework | schemele `bos_*` | +| DIDI (schema `public`) | bos_interface | Keycloak IAM | `KC_DB_URL=jdbc:postgresql://didi-postgres:5432/DIDI?currentSchema=public` | +| — | — | Kong API Gateway | Kong ruleaza **DBless** (config declarativ), fara baza proprie | + +### Cine se conecteaza + +| Serviciu | Host | Port | Database | User | Fisier config | +|----------|------|------|----------|------|---------------| +| agent-v3 | didi-postgres | 5432 | DIDI | bos_interface | agent-v3/src/shared/persistence/pg-pool.ts | +| didiFramework | didi-postgres | 5432 | DIDI | bos_interface | didiFramework/src/config/database.ts | +| Keycloak | didi-postgres | 5432 | DIDI (schema public) | bos_interface | production/.env (`KC_DB_URL`) | + +Containerul local `didi-postgres` este unicul PostgreSQL de productie activ. Fostul container `staging-dataLayer-postgres` NU mai exista. Toate schemele `bos_*` + `public` sunt pe `didi-postgres`. + +agent-v3 acceseaza baza prin `shared/persistence/pg-pool.ts` (`didi-postgres:5432`, DB `DIDI`, user `bos_interface`). Dupa migration 011 scrie si in `bos_analysis.moderation_queue` (prin `moderation/queue-manager.ts`) si citeste coloanele HIL noi de pe `analysis_session`. + +didiFramework scrie in `bos_parammgmt.moderation_config`, `sensitive_topic`, `moderation_role` (introduse de migration 011). + +--- + +## Baza de date DIDI -- Schema completa + +4 scheme + public, ~50 tabele total. + +--- + +### Schema: bos_analysis (7 tabele + 1 view) + +Scrisa de agent-v3 (pg-adapter.ts, moderation/queue-manager.ts). Citita si de didiFramework (history.ts, sync-analysis.ts). + +#### analysis_session + +Tabelul central -- o inregistrare per analiza. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT PK | UUID sesiune | +| user_id | TEXT | ID utilizator | +| user_email | TEXT | Email utilizator | +| input_type | TEXT | text, url, image, audio, video | +| input_text | TEXT | Text de analizat | +| input_url | TEXT | URL analizat | +| input_media_url | TEXT | URL media MinIO | +| input_hash | TEXT | Hash input (deduplicare) | +| status | TEXT | running, completed, failed | +| components_run | TEXT[] | Componente rulate | +| components_skipped | TEXT[] | Componente sarite | +| risk_score | NUMERIC | Scor risc final (0-100) | +| risk_category | TEXT | Categorie risc | +| risk_level | TEXT | Nivel risc | +| confidence | NUMERIC | Incredere (0-100) | +| confidence_level | TEXT | Nivel incredere | +| started_at | TIMESTAMP | Start procesare | +| completed_at | TIMESTAMP | Sfarsit procesare | +| total_duration_ms | INTEGER | Durata totala ms | +| scenario_applied | TEXT | Scenariu ponderi aplicat | +| topic_applied | TEXT | Topic detectat | +| source_app | TEXT | web (default) | +| api_version | TEXT | v3 (default) | +| created_at | TIMESTAMP | Data creare | + +Coloane HIL adaugate prin migration 011 (2026-05-01): + +| Coloana | Tip | Scop | +|---------|-----|------| +| review_status | TEXT default 'none' (CHECK: none\|pending\|in_review\|resolved\|declined) | HIL state | +| human_corrected | BOOLEAN default false | true daca moderator a corectat | +| human_corrections | JSONB NULL | Diff-style corrections {verdict?, techniques?, ai_tampered?, claims?} | +| verified_by | TEXT NULL | keycloak_id moderator | +| verified_at | TIMESTAMPTZ NULL | When resolved | +| review_notes | TEXT NULL | Optional moderator notes | + +Index partial: `idx_analysis_session_review_status WHERE review_status != 'none'` -- majoritatea sesiunilor raman 'none', sunt sarite la scan. + +#### analysis_techniques + +O inregistrare per sesiune -- rezultat componenta tehnici de manipulare. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT FK | Referinta sesiune | +| manipulation_score | NUMERIC | Scor manipulare (0-100) | +| total_severity | NUMERIC | Severitate totala | +| dimensions_affected | TEXT[] | Dimensiuni afectate | +| techniques_count | INTEGER | Numar tehnici detectate | +| techniques_detected | JSONB | Lista tehnici cu detalii | +| coupling_context | JSONB | Context cuplare inter-tehnici | +| llm_screening | TEXT | Model LLM screening | +| llm_deep | TEXT | Model LLM deep analysis | +| screening_duration_ms | INTEGER | Durata screening | +| deep_analysis_duration_ms | INTEGER | Durata analiza profunda | +| total_duration_ms | INTEGER | Durata totala | +| fallbacks_screening | INTEGER | Fallback-uri screening | +| fallbacks_deep | INTEGER | Fallback-uri deep | + +#### analysis_ai_tampered + +O inregistrare per sesiune -- detectie continut AI/manipulat. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT FK | Referinta sesiune | +| ai_probability | NUMERIC | Probabilitate AI (0-100) | +| verdict | TEXT | Verdict AI detection | +| risk_score | NUMERIC | Scor risc AI | +| categories_affected | TEXT[] | Categorii afectate | +| indicators_count | INTEGER | Numar indicatori | +| disclosure_detected | BOOLEAN | Disclosure detectat | +| disclosure_explicit | BOOLEAN | Disclosure explicit | +| disclosure_text | TEXT | Text disclosure | +| indicators_detected | JSONB | Lista indicatori | +| coupling_context | JSONB | Context cuplare | +| llm_screening | TEXT | Model screening | +| llm_deep | TEXT | Model deep | +| screening_duration_ms | INTEGER | Durata screening | +| deep_analysis_duration_ms | INTEGER | Durata deep | +| total_duration_ms | INTEGER | Durata totala | +| fallbacks_screening | INTEGER | Fallback-uri screening | +| fallbacks_deep | INTEGER | Fallback-uri deep | +| content_type | TEXT | text, image, audio, video | +| image_analysis | JSONB | Rezultat analiza imagine | + +#### analysis_claims + +O inregistrare per sesiune -- verificare afirmatii. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT FK | Referinta sesiune | +| total_claims | INTEGER | Total afirmatii | +| verified_true | INTEGER | Verificate adevarate | +| verified_false | INTEGER | Verificate false | +| unverified | INTEGER | Neverificate | +| opinions | INTEGER | Opinii | +| credibility_score | NUMERIC | Scor credibilitate | +| interpretation | TEXT | Interpretare | +| claims_by_status | JSONB | Claims grupate pe status | +| claims_by_type | JSONB | Claims grupate pe tip | +| claims_verified | JSONB | Detalii verificare | +| llm_extraction | TEXT | Model extragere | +| llm_verification | TEXT | Model verificare | +| extraction_duration_ms | INTEGER | Durata extragere | +| verification_duration_ms | INTEGER | Durata verificare | +| total_duration_ms | INTEGER | Durata totala | +| web_searches_made | INTEGER | Cautari web efectuate | + +#### analysis_domain + +O inregistrare per sesiune -- analiza domeniu/sursa. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT FK | Referinta sesiune | +| domain | TEXT | Domeniu analizat | +| verdict | TEXT | Verdict domeniu | +| trust_score | NUMERIC | Scor incredere | +| risk_level | TEXT | Nivel risc | +| age_days | INTEGER | Varsta domeniu (zile) | +| age_category | TEXT | Categorie varsta | +| domain_created_at | TIMESTAMP | Data creare domeniu | +| is_blacklisted | BOOLEAN | Pe lista neagra | +| reputation_score | NUMERIC | Scor reputatie | +| has_ssl | BOOLEAN | Are SSL | +| ssl_valid | BOOLEAN | SSL valid | +| ssl_issuer | TEXT | Emitent SSL | +| registrar | TEXT | Registrar domeniu | +| organization | TEXT | Organizatie | +| country | TEXT | Tara | +| red_flags | TEXT[] | Semnale alarma | +| warnings | TEXT[] | Avertismente | +| duration_ms | INTEGER | Durata analiza | + +#### analysis_verdict + +O inregistrare per sesiune -- verdictul final agregat. + +| Coloana | Tip | Scop | +|---------|-----|------| +| session_id | TEXT FK | Referinta sesiune | +| risk_score | NUMERIC | Scor risc final | +| risk_category | TEXT | Categorie risc | +| risk_category_color | TEXT | Culoare categorie | +| risk_level | TEXT | Nivel risc | +| risk_level_color | TEXT | Culoare nivel | +| severity | TEXT | Severitate | +| recommended_action | TEXT | Actiune recomandata | +| confidence | NUMERIC | Incredere | +| confidence_level | TEXT | Nivel incredere | +| score_manipulation | NUMERIC | Scor componenta manipulare | +| score_claims | NUMERIC | Scor componenta claims | +| score_ai | NUMERIC | Scor componenta AI | +| score_source | NUMERIC | Scor componenta sursa | +| score_context | NUMERIC | Scor context | +| applied_weights | JSONB | Ponderi aplicate | +| override_applied | BOOLEAN | Override aplicat | +| override_type | TEXT | Tip override | +| override_reason | TEXT | Motiv override | +| override_adjustment | NUMERIC | Ajustare override | +| context_summary | JSONB | Sumar context | +| components_used | TEXT[] | Componente folosite | +| weights_source | TEXT | Sursa ponderi | +| duration_ms | INTEGER | Durata calcul | +| explanation_ro | TEXT | Explicatie romana (migration 001) | +| explanation_en | TEXT | Explicatie engleza (migration 001) | +| virality_score | NUMERIC | Scor viralitate (0-100) | +| virality_level | TEXT | Nivel viralitate | +| virality_factors | JSONB | Factori viralitate | + +#### moderation_queue (adaugat prin migration 011) + +Stare workflow HIL (Human-in-the-Loop). Un rand per sesiune marcata de triage pentru review uman. + +| Coloana | Tip | Scop | +|---------|-----|------| +| queue_id | BIGSERIAL PK | Auto-increment | +| session_id | UUID FK -> analysis_session(session_id) ON DELETE CASCADE | Referinta sesiune | +| priority | INTEGER (1-5) | 1=highest (user_flagged), 3=low_confidence, 4=sensitive_topic | +| enqueue_reason | TEXT | flagged \| low_confidence \| sensitive_topic \| mixed | +| enqueue_meta | JSONB | Triage metadata (risk_score, confidence, topic detected) | +| status | TEXT | pending \| in_review \| resolved \| declined \| auto_closed | +| assigned_to | TEXT | keycloak_id moderator | +| assigned_at | TIMESTAMPTZ | When claimed | +| resolved_at | TIMESTAMPTZ | When closed | +| resolved_by | TEXT | keycloak_id | +| resolution_action | TEXT | approved \| corrected \| rejected | +| time_in_queue_ms | INTEGER | enqueue -> start review | +| time_in_review_ms | INTEGER | start review -> resolved | +| created_at | TIMESTAMPTZ | Default now() | + +Indecsi: `idx_moderation_queue_status_priority` (partial WHERE status IN ('pending','in_review')), `idx_moderation_queue_session`, `idx_moderation_queue_assigned`. + +#### v_analysis_full (VIEW) + +JOIN pe toate 6 tabelele de analiza (session + techniques + ai_tampered + claims + domain + verdict). Definit in migration 001. Selecteaza doar coloane sumar (nu JSONB-uri grele): session metadata, verdict scores, techniques summary, ai probability, claims summary, domain summary + explanation_ro/en. + +Migration 011 NU modifica view-ul: coloanele HIL noi de pe `analysis_session` (review_status, human_corrected etc.) sunt acoperite automat de `SELECT s.*`. + +--- + +### Schema: bos_parammgmt (~40 tabele) + +Scrisa si citita exclusiv de didiFramework. Contine toti parametrii de configurare ai platformei. Sincronizata in Redis prin POST /api/sync-redis. + +Search path setat in database.ts: `SET search_path TO bos_parammgmt, public`. + +#### Tabel de baza + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| parameter | Tabel parinte versionare (parameter_id, parameter_type, valid_from/to) | intern (FK din toate celelalte) | + +#### Tehnici de manipulare (ierarhie 4 nivele) + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| dimension | Dimensiuni top-level (code, name, weight) | /api/dimensions | +| subdimension | Sub-dimensiuni (FK dimension) | /api/subdimensions | +| technique | Tehnici individuale (FK subdimension, severity, confidence, detectability) | /api/techniques | +| technique_indicator | Indicatori detectie per tehnica (name, description, max_intensity 1-3) | /api/indicators | +| technique_validation_rule | Reguli validare per tehnica | /api/validation-rules | + +#### Evaluare sursa + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| platform | Platforme social media (code, name, score) | /api/platforms | +| platform_modifier | Modificatori platforma (condition, score) | /api/platform-modifiers | +| source_credibility | Factori credibilitate sursa | /api/source-credibility | +| source_type | Tipuri sursa (base_score) | intern | +| source_assessment | Evaluare sursa | intern | +| domain_age_score | Scor varsta domeniu (range-uri, impact) | /api/domain-age-scores | +| domain_risk_level | Nivele risc domeniu (range-uri, interpretare) | /api/domain-risk-levels | +| domain_red_flag | Red flags domeniu (condition, severity, action) | /api/domain-red-flags | +| author_classification | Clasificari autor (code, name, score) | /api/author-classifications | +| author_credibility | Credibilitate autor (impact) | /api/author-credibility | + +#### Claims + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| claim | Statusuri claim (TRUE, FALSE, UNVERIFIED, OPINION) | /api/claims/status | +| claim_type | Tipuri claim (factual, statistic, cauzal, etc.) | /api/claims/types | +| confidence | Nivele incredere (level, color, action, range) | /api/claims/confidence | +| interpretation | Interpretare scor credibilitate (range-uri) | /api/claims/interpretation | + +#### Verdicte si scoruri + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| verdict_category | Categorii verdict (code, range, color) | /api/verdicts/categories | +| risk_mapping | Mapping risc (level, range, color) | /api/verdicts/risk | +| severity_assessment | Evaluare severitate (category, range, action) | /api/verdicts/severity | + +#### Ponderi + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| component_weight | Ponderi componente (manipulation, claims, source, ai, context) | /api/weights/components | +| weight_scenario | Scenarii ponderi (per topic: health, politics, etc.) | /api/weights/scenarios | +| multiplier | Multiplicatori (topic, temporal, reach) | /api/weights/multipliers | + +#### Provideri LLM + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| llm_provider | Configurare provideri (base_url, auth_type, rate_limit) | /api/providers/configs | +| llm_model | Modele LLM (context_window, cost, capabilities) | /api/providers/models | +| component_provider_assignment | Assignment componenta -> model (legacy, pre-migration-002) | /api/providers/assignments | +| provider_api_key | Chei API per provider (criptate, usage tracking) | /api/providers/keys | + +#### Configurare unificata componente (adaugat prin migration 002, extinsa cu tier prin 006) + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| component_stage_assignment | Assignment model pe etapa + **tier** (free/premium) cu fallback chain. Unique: `(component_code, stage_code, tier, fallback_order)`. | /api/providers/assignments (suporta `?tier=X` filter) | +| component_prompt | Prompturi LLM per componenta/etapa (system_prompt, user_template) | /api/providers/prompts | +| component_config | Config JSONB catch-all per componenta (scoring, patterns, vision models) | intern (sync-redis) | + +**Component codes prezente dupa migrations 006-009**: +- `techniques` (stages: techniques_screening, techniques_deep) +- `ai-tampered` (stages: ai_tampered_screening, ai_tampered_deep) +- `claims` (stages: claims_extraction, claims_verification) +- `source-assessment` (stages: source_assessment_extraction, source_assessment_evaluation) +- `vision` (stage: image_analysis — OCR + AI detection + video frames, Etapa 4) +- `verdict` (stage: verdict_review — LLM verdict reviewer care ajusteaza scorul final + explicatii RO/EN, Etapa 5) + +Fiecare componenta/stage are **2 tiers** (`free` + `premium`), fiecare cu propriul fallback chain (primary + 2-3 fallbacks). Ex: `techniques_screening` are 4 randuri `tier='free'` + 4 randuri `tier='premium'`. + +Tier-ul final folosit la runtime se deriveaza din `planType` al userului (returnat de check-credits): +- `plan_type` 1-3 (Freemium/Starter/Basic) → `tier='free'` +- `plan_type` 4-6 (Pro/Business/Enterprise) → `tier='premium'` + +#### Chei API extensie browser + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| extension_api_key | Chei API extensie browser (key, user_id, usage_count) | /api/extension-keys | + +#### Profiluri verdict per input type (adaugat 2026-03-21) + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| input_type_profile | 6 profiluri verdict (text, image, audio, video, url) cu ponderi per componenta, reguli INCONCLUSIVE, disclosure multipliers | /api/input-profiles | +| profile_override_config | Override-uri per profil (8 tipuri × 6 profiluri = 48 randuri) | /api/input-profiles/:code/overrides | + +Coloane noi in tabele existente: +- `claim_type.unverified_weight` NUMERIC(3,2) — ponderea UV per tip claim (0.25-0.50) +- `claim.credibility_weight` NUMERIC(3,2) — ponderea credibilitate per status claim (0.00-1.00) + +#### HIL Moderation config (adaugat prin migration 011, 2026-05-01) + +| Tabel | Scop | Rute CRUD | +|-------|------|-----------| +| moderation_config | Single-row settings (CHECK config_id=1): triage thresholds (confidence_low, risk_grey_min/max, queue_relax_at, queue_strict_at) + brain client config (brain_enabled, brain_url, lookup/write timeouts, brain_confidence_min_silver, brain_semantic_threshold, brain_per_component JSONB). 14 fields total. Sincronizat in Redis ca `didi:config:moderation:v1:settings`. | /api/moderation-config | +| sensitive_topic | Topics care declanseaza HIL review (seed: elections, health, war, covid, climate). topic_code regex `[a-z0-9_]+` UNIQUE; soft delete via is_active. Sincronizat in Redis ca `didi:config:moderation:v1:sensitive_topics`. | /api/sensitive-topics | +| moderation_role | Mapping Keycloak role -> HIL permissions (seed: moderator, senior_moderator). Toggles: can_resolve, can_escalate, can_force_gold_brain, is_active. role_code este PK (immutable). Sincronizat in Redis ca `didi:config:moderation:v1:roles`. | /api/moderation-roles | + +--- + +### Schema: bos_sysadmin (5 tabele) + +Scrisa si citita de didiFramework (auth.ts, admin.ts, subscriptions.ts). Management utilizatori si abonamente. + +| Tabel | Scop | Rute | +|-------|------|------| +| internet_user | Utilizator platforma (internet_user_id, person_id FK, credits_remained, credits_spent) | /api/auth/me (auto-creare), /api/admin/users | +| user_credential | Credentiale (email, keycloak_id, enrollment_type, subscription_status) | /api/auth/me, /api/admin/users | +| subscription | Abonament activ (internet_user_id FK, plan FK, status, activation_date) | /api/subscriptions | +| subscription_plan | Planuri abonament (plan_name, plan_type, price, credits, limite storage/media, costuri per tip) | /api/admin/plans | +| ai_credit_usage | Log consum credite (session_id, user_id, credits_used, input_type) | /api/auth/deduct-credits | + +--- + +### Schema: bos_subscriber (4 tabele) + +Scrisa de didiFramework la inregistrare utilizator. Date personale. + +| Tabel | Scop | +|-------|------| +| person | Entitate persoana (person_id, person_type, status) | +| address | Adresa (address_id, address_type) | +| persoana_fizica | Persoana fizica romaneasca (nume, prenume, FK person, FK address) | +| contact | Contact (person_id FK, contact_type_id, contact_info) | + +--- + +### Schema: public + +| Tabel | Scop | +|-------|------| +| waitlist | Lista de asteptare pre-lansare (vezi sectiunea container local) | + +--- + +## Migratii aplicate + +| Fisier | Ce face | Aplicata de | +|--------|---------|-------------| +| didiFramework/sql/migrations/001_add_explanation_columns.sql | Adauga explanation_ro, explanation_en la analysis_verdict + creeaza view v_analysis_full | didiFramework la pornire | +| didiFramework/sql/migrations/002_add_component_pilot_config.sql | Adauga tabele component_stage_assignment, component_prompt, component_config | didiFramework la pornire | +| didiFramework/sql/migrations/006_add_tier_column.sql | `component_stage_assignment.tier varchar(20) DEFAULT 'free'` + unique constraint pe (component_code, stage_code, tier, fallback_order) | Manual | +| didiFramework/sql/migrations/007_seed_premium_assignments.sql | Seed 32 rows `tier='premium'` pentru 8 stages LLM (techniques/ai-tampered/claims/source-assessment) | Manual | +| didiFramework/sql/migrations/008_seed_vision_assignments.sql | Seed 7 rows pentru component `vision` stage `image_analysis` (3 free + 4 premium) | Manual | +| didiFramework/sql/migrations/009_seed_verdict_assignments.sql | Seed 8 rows pentru component `verdict` stage `verdict_review` (4 free + 4 premium) | Manual | +| didiFramework/sql/migrations/011_add_moderation.sql | HIL Moderation foundation: 6 coloane pe `analysis_session`, tabela `moderation_queue`, 3 tabele config in bos_parammgmt (moderation_config, sensitive_topic, moderation_role) + seeds. Companion `011_rollback.sql`. session_id este UUID, FK foloseste UUID. | Manual | + +Migratiile ulterioare (012 topic_volatility, 013 user_audit_log, 014 atomic_path_prefix, 015 social_post, 016 input_profile_versions, 017 model_catalog_attributes) sunt incluse integral in seed-ul canonic `DIDI_full_export_2026-07-02.sql`. Un restore curat al seed-ului produce schema completa la zi (fara a mai rula migratiile manual). Cateva dintre ele sunt descrise mai jos in "Schema additions". + +--- + +## Container `staging-dataLayer-postgres` (ISTORIC — inexistent) + +> Nota istorica: un container `staging-dataLayer-postgres` (postgres:15-alpine, database `misinformation_db`) a servit candva doar tabela `public.waitlist` + ~22 tabele legacy goale din vechiul orchestrator Python. **Acest container NU mai exista.** Baza de business (inclusiv `public.waitlist`, daca este folosita) este acum in database-ul `DIDI` de pe containerul `didi-postgres`. Orice referinta la `staging-dataLayer-postgres`, `misinformation_db` sau la path-ul arhiva `didiDatabase-legacy/` este stale si nu mai reflecta realitatea. + +--- + +## Diagrama conexiuni + +``` + +-----------------------------------+ + | didi-postgres:5432 | + | Container LOCAL (postgres:17) | + | didi-network | + +-----------------------------------+ + | Database: DIDI | + +-----------------------------------+ + | bos_analysis (agent-v3) | + | bos_parammgmt (didiFramework) | + | bos_sysadmin (didiFramework) | + | bos_subscriber (didiFramework) | + | public (Keycloak schema) | + +-----------------------------------+ + ^ ^ ^ + | | | + agent-v3 didiFramework Keycloak + (schema public) + + Kong ruleaza DBless (fara baza proprie). +``` + +--- + +## Fisiere in directorul didiDatabase + +``` +DIDI_full_export_2026-07-02.sql -- SEED CANONIC (23 MB): pg_dump complet DIDI + (schema + date + migratiile 001-017). Restorabil + cu --clean --if-exists --no-owner. +Dockerfile -- Build imagine postgres cu init (pastrat pentru rebuild container) +MIGRATION.md -- Note migrare PostgreSQL (atentie: contine si sectiuni stale despre cluster) +REBUILD.md -- Reteta rebuild baza pe alt host din seed-ul canonic +ha-cluster/ -- Config optional HA (docker-compose + haproxy.cfg) pentru fallback cluster +.gitignore -- Exclude .env, data/ +INDEX.md -- Aceasta documentatie +``` + +Nota seed: fisierul canonic actual este `DIDI_full_export_2026-07-02.sql`. Seed-ul vechi `DIDI_full_export_2026-03-22.sql` (fara migratiile 016/017) si pachetul demo (`DIDI_demo_seed_2026-07-02.sql` + `demo-seed/`) au fost arhivate **in afara repo-ului** (`/home/admin365/didi_seed_archive_2026-07-08/`) — livrarea foloseste DOAR full seed-ul curent. + +--- + +## Ce NU face containerul local `didi-postgres` + +- Nu are replicare (instanta singulara); HA se obtine doar comutand pe fallback-ul cluster din `ha-cluster/` +- Nu are backup automat integrat (backup manual din seed / pg_dump) +- Nu are SSL/TLS intern +- Este sursa unica de adevar pentru datele DIDI; Redis (`didi:config:*`, `didi:framework:*`) e cache derivat, regenerat cu `sync-redis` + +--- + +## Schema additions (2026-05-04 → 2026-05-05) + +### `bos_parammgmt.sensitive_topic` — extins (migration 012) + +ALTER ADD: `volatility ('volatile'|'evolving'|'stable')`, `cache_ttl_hours integer (1-26280)`, `recency_window_days integer (1-365)`, `half_life_days numeric (>0)`. Seed: war/elections=volatile@24h/7d/3d, health/covid=evolving@168h/14d/14d, climate=stable@720h/180d/180d, fraud_test=defaults. Used by brain `topic_volatility.py` to override classifier TTL per topic. + +### `bos_sysadmin.user_audit_log` — nou (migration 013) + +``` +audit_id bigserial PK +internet_user_id integer (NULL pentru keycloak-only useri) +target_email text +target_keycloak_id text +actor_keycloak_id text -- extras din JWT (sub claim) +actor_email text +action text NOT NULL -- user.{update,delete,sync,email_verified,subscription,roles,group,reset_password} +payload jsonb DEFAULT '{}' -- diff before/after sau parametri operațiune +request_ip text +user_agent text +created_at timestamptz NOT NULL DEFAULT now() +``` + +4 indexuri: user (partial), actor, action+time, time. Powers tab "Audit Log" în UserManagement DIDI admin. + +### Brain tables (alongside Atomic, prefix `brain_*`, public schema) + +| Tabela | Scop | Cheie unique | +|---|---|---| +| `brain_analysis_atom` (existed) | Cache rezultate full-component LLM (techniques/ai_tampered/claims). +7 coloane noi: `volatility`, `topic_codes text[]`, `entity_bindings jsonb`, `ttl_hours_used`, `last_audited_at`, `audit_history jsonb` (last-50 cap), `consecutive_audit_passes` | `(content_hash, component, prompt_hash)` | +| `brain_verification_cache` (existed) | Cache verdict LLM per claim. Same +7 coloane | `(claim_hash, tier)` | +| `brain_fact_status` (NOU, 2026-05-04) | Current truth pentru triplete `(subject, predicate, object)`. Coloane: `current_truth bool|NULL`, `current_version_id`, `current_confidence`, `last_verified_at`, `last_evidence_urls jsonb`, `volatility`, `topic_codes`, `next_check_at`, `check_interval_hours`, `moderator_locked bool`, `moderator_user_id`, `moderator_notes` | `canonical_form_hash` | +| `brain_fact_version` (NOU) | Temporal versioning. `truth_value bool`, `confidence`, `valid_from`, `valid_to (NULL=current)`, `source_atom_ids text[]`, `evidence_urls jsonb`, `llm_reasoning`, `created_by ('auto'|'moderator'|'breaking_news_watcher'|'auditor'|'extractor')`, `moderator_user_id`, `notes` | bigserial; FK fact_id → fact_status ON DELETE CASCADE | +| `brain_audit_log` (NOU) | Cache mutation log: judge decisions, mass invalidations, gold promotions, fact truth changes. Coloane: `action`, `target_table`, `target_id`, `actor`, `payload jsonb` | bigserial | + +GIN index-uri pe `topic_codes` (pentru topic-scoped invalidate). Partial index pe `cache_tier IN ('gold','silver') AND volatility != 'stable'` pentru auditor sweep. Schema migrează idempotent la fiecare brain `db.connect()`. + +### DB live counts (2026-05-05, pe `didi-postgres`) + +``` +internet_users: 21 | brain_fact_status: 4 +user_credentials: 20 | brain_fact_version: 1 (Putin → TRUE locked smoke-admin) +subscriptions: 20 | brain_audit_log: ~10 (mostly fact_truth_changed + reset_password) +subscription_plans: 20 | user_audit_log: live (logged on every PUT/DELETE/role/group) +``` diff --git a/backend/services/data-layer/didiDatabase/MIGRATION.md b/backend/services/data-layer/didiDatabase/MIGRATION.md new file mode 100644 index 0000000..85d46cf --- /dev/null +++ b/backend/services/data-layer/didiDatabase/MIGRATION.md @@ -0,0 +1,101 @@ +# PostgreSQL — pe cluster Patroni HA (status curent) + +> **TL;DR**: DIDI folosește **clusterul Patroni** extern (3 noduri PG + 3 etcd + 2 HAProxy LB). Containerul local `staging-dataLayer-postgres` din `data-layer/docker-compose.yml` păstrează **doar tabelul `waitlist`** — toate datele de business sunt pe cluster. + +--- + +## Ce era aici (legacy) + +Cândva, `staging-dataLayer-postgres` (Postgres 15 Alpine, container Docker) servea toate datele DIDI. Avea ~22 tabele în schemele `analyses`, `catalog`, `execution`, `pipelines`, `users` — toate din vechiul orchestrator Python. Acum sunt **goale, nefolosite**, schema veche arhivată în `/home/admin365/old_deprecated_code_archive/didiDatabase-legacy/`. + +## Ce e acum + +### Cluster Patroni (productie) + +| Componentă | Hostname | IP | Port | Rol | +|---|---|---|---|---| +| pg-node1 | `pg-node1-test` | `10.11.50.160` | 5432 | Replica streaming | +| pg-node2 | `pg-node2-test` | `10.11.50.161` | 5432 | Replica streaming | +| **pg-node3** | `pg-node3-test` | `10.11.50.162` | 5432 | **Leader curent** | +| etcd-node1/2/3 | — | `10.11.50.163-165` | 2379 | Quorum | +| HAProxy LB1 | `haproxy-lb-test` | `10.11.50.166` | 5000 (RW), 5001 (RO) | Primary | +| HAProxy LB2 | `haproxy-lb2-test` | `10.11.50.169` | 5000, 5001 | Secondary | +| pgBackRest | `pg-backup-test` | `10.11.50.168` | — | Backup zilnic + NFS | + +**Endpoint-uri pentru aplicații DIDI:** + +| Scop | Endpoint | Notă | +|---|---|---| +| **WRITE** (orice modificare) | `10.11.50.167:5000` | DIDI configurat aici (HAProxy LB) | +| READ (raportări) | `10.11.50.167:5001` | replica load-balanced | + +> `.166`, `.167` și `.169` sunt toate HAProxy LB valide spre același cluster Patroni. DIDI folosește `.167` istoric. Verificat 2026-04-28: toate trei dau aceleași date (1782 sesiuni). + +### Database principal: `DIDI` + +User: `bos_interface` / parolă în vault-ul de credențiale `name='PostgreSQL Cluster Patroni (admin)'`. + +4 scheme + public: +- `bos_analysis` (6 tabele + view) — scrise de agent-v3 +- `bos_parammgmt` (~40 tabele) — scrise de didiFramework, sincronizate în Redis +- `bos_sysadmin` (5 tabele) — utilizatori, credite, abonamente +- `bos_subscriber` (4 tabele) — date personale +- `public.waitlist` — pe **containerul local**, nu cluster + +### Database-uri suplimentare pe același cluster + +- `kong_db` (user `kong`) — folosit de Kong **cluster** (vezi `gateway-auth-layer/didiKong/MIGRATION.md`) +- `keycloak_db` (user `keycloak`) — folosit de Keycloak + +## Ce mai e local (containerul `staging-dataLayer-postgres`) + +Definit în `data-layer/docker-compose.yml`. **Nu** e pe rețea externă — doar Docker network. Singurul tabel activ: `public.waitlist` în DB `misinformation_db` (3 înregistrări). + +Folosit doar de `didiFramework/src/routes/waitlist.ts` prin pool separat (`stagingPool` cu host `staging-dataLayer-postgres`). + +Schemele legacy (`analyses`, `catalog`, etc.) sunt goale. + +### De ce nu am migrat waitlist pe cluster? + +Decizie pragmatică: waitlist e public-facing (anyone-can-signup), volum mic, nu necesită HA. Containerul local e suficient. Migrare ulterioară opțională. + +## Connection patterns în cod + +```typescript +// agent-v3/src/shared/persistence/pg-pool.ts +host: '10.11.50.167', port: 5000, database: 'DIDI', user: 'bos_interface' + +// didiFramework/src/config/database.ts (production data) +host: '10.11.50.167', port: 5000, database: 'DIDI' + +// didiFramework/src/routes/waitlist.ts (special — local container) +host: 'staging-dataLayer-postgres', port: 5432, database: 'misinformation_db' +``` + +## Verificare connectivity + +```bash +# Cu psql container (din host) +docker run --rm --network host -e PGPASSWORD= postgres:15-alpine \ + psql -h 10.11.50.167 -p 5000 -U bos_interface -d DIDI \ + -c "SELECT inet_server_addr() AS leader, now()" + +# Patroni REST API status +curl -s http://10.11.50.162:8008/cluster | python3 -m json.tool +``` + +## Backup + +pgBackRest zilnic (full săptămânal + incremental zilnic) pe `10.11.50.168` cu storage NFS la `10.11.10.150`. Toate DB-urile DIDI intră automat în stanza globală — nu trebuie config per-app. + +## Linkuri rapide + +- Ghid utilizare cluster: `landingzone/postgres-patroni/README.md` (repo `git.finesynergy.eu/lucian/landingzone`) +- Onboarding aplicație nouă: `landingzone/postgres-patroni/CLAUDE_PROMPT.md` +- HAProxy stats: `http://10.11.50.166:7000/stats` + +## Status + +- ✅ Migrare făcută înaintea acestui mono-repo (cluster Patroni e canonical) +- ✅ Container local păstrat doar pentru waitlist +- ⚠️ DIDI configurat pe HAProxy LB `.167` (canonical landingzone e `.166`); ambele rutează la același leader — schimbare cosmetică opțională diff --git a/backend/services/data-layer/didiDatabase/REBUILD.md b/backend/services/data-layer/didiDatabase/REBUILD.md new file mode 100644 index 0000000..55e4867 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/REBUILD.md @@ -0,0 +1,63 @@ +# Rebuild bază de date pe alt host — rețetă + +## Ce e local vs derivat (important înainte de rebuild) + +| Store | Rol | Se seed-uiește? | +|---|---|---| +| **PostgreSQL** (`didi-postgres`, local pe didi11) | **sursă de adevăr** — 97 tabele, 4 scheme (bos_parammgmt, bos_analysis, bos_sysadmin, bos_subscriber) | **DA** — din dump-ul de mai jos | +| **Redis** (`didi-cache`) chei `didi:config:*` + `didi:framework:*` | **cache derivat** din Postgres (populat de `sync-redis` din `component_config/prompt/stage_assignment`, `llm_model`, `moderation_*`) | **NU** — se regenerează cu `sync-redis` | +| Redis `didi:pipeline:*` / `didi:queue:*` | stare runtime sesiuni (TTL) | NU — efemer | + +**Concluzie:** NU sunt date dublate în sensul de „două surse de adevăr". Seed-uiești +DOAR Postgres; Redis se reface singur dintr-o comandă. Nu există fișier de seed +pentru Redis și nici nu e nevoie. + +## Fișierul de seed + +`DIDI_full_export_2026-07-02.sql` (23 MB) — pg_dump complet: schema + date + toate +migrațiile (inclusiv 016 input_type_profile_version, 017 model catalog attributes). +Restorabil (`--clean --if-exists --no-owner`). Validat: restore curat pe Postgres +gol → 99 tabele, date reale (23 modele LLM, 83 stage assignments, 6 profiluri). + +> Seed-ul vechi `DIDI_full_export_2026-03-22.sql` (fără migrațiile 016/017) și pachetul +> demo (`DIDI_demo_seed_2026-07-02.sql` + `demo-seed/`) au fost arhivate în afara repo-ului +> (`/home/admin365/didi_seed_archive_2026-07-08/`) — livrarea folosește DOAR full seed-ul curent. + +## Pași rebuild + +```bash +# 1. Pornește un Postgres (local container SAU clusterul extern — vezi mai jos) +# Aici: containerul local, ca pe didi11. +docker compose -f services/data-layer/docker-compose.local.yml up -d didi-postgres +until docker exec didi-postgres pg_isready -U bos_interface; do sleep 2; done + +# 2. Restaurează schema + datele +docker exec -i didi-postgres psql -U bos_interface -d DIDI \ + < services/data-layer/didiDatabase/DIDI_full_export_2026-07-02.sql +# (un singur warning benign 'transaction_timeout' pe versiuni PG <17 — se ignoră) + +# 3. Pornește restul serviciilor (agent-v3, framework, workeri) — se conectează +# la didi-postgres prin PG_HOST/DB_HOST din compose. +cd services/orchestration-layer/agent-v3 && docker compose up -d +cd ../didiFramework && docker compose up -d didi-framework + +# 4. Regenerează cache-ul Redis din Postgres (config + framework params) +docker exec didi-framework sh -c 'wget -qO- --post-data="" http://127.0.0.1:3005/api/sync-redis' + +# 5. (verificare) Redis populat + un răspuns 200 pe framework +docker exec didi-cache redis-cli -a redis123 --no-auth-warning dbsize +curl -sf http://localhost:3005/health +``` + +## Local vs cluster extern + +Serviciile sunt agnostice — `PG_HOST`/`DB_HOST` din compose decid ținta: +- **didi11 (acum):** `didi-postgres` (container local, 5432). +- **Producție/cluster:** setează `PG_HOST=10.11.50.167 PG_PORT=5000` (VIP Patroni/HAProxy). +Același dump se restaurează în oricare; la cluster, restaurează pe leaderul RW (`:5000`). + +## HA opțional + +Dacă vrei Postgres HA pe noul host (nu single-node), vezi +`ha-cluster/` (Patroni + etcd + HAProxy) — restaurează dump-ul pe `:5000` după +`patronictl list` arată un leader. diff --git a/backend/services/data-layer/didiDatabase/ha-cluster/README.md b/backend/services/data-layer/didiDatabase/ha-cluster/README.md new file mode 100644 index 0000000..19c7e97 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/ha-cluster/README.md @@ -0,0 +1,104 @@ +# DIDI PostgreSQL HA — Patroni + etcd + HAProxy (IaC livrabil) + +Pachet **reproductibil** care livrează clusterul HA PostgreSQL al platformei DiDi +ca Infrastructure-as-Code. Aceeași arhitectură rulează în producție pe VM-uri +dedicate (vezi `../MIGRATION.md`); acest compose o reproduce integral pe un +singur host pentru demo, recepție, DR-rehearsal și medii de test. + +## Arhitectură + +``` + ┌────────────────────┐ + apps ──5000──▶ │ HAProxy │ ──▶ /primary (Patroni REST :8008) + apps ──5001──▶ │ (LB + healthcheck)│ ──▶ /replica + └─────────┬──────────┘ + ┌───────────────┼───────────────┐ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ pg-node1 │ │ pg-node2 │ │ pg-node3 │ Spilo = PostgreSQL 16 + │ Patroni │ │ Patroni │ │ Patroni │ + Patroni (Zalando) + └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ + └───────────────┼───────────────┘ + ┌────────▼────────┐ + │ etcd1/2/3 (DCS) │ quorum leader-election + └─────────────────┘ +``` + +| Rol | Producție (VM-uri) | Acest pachet | +|---|---|---| +| PG + Patroni ×3 | 10.11.50.160–162 | `pg-node1..3` (Spilo 16) | +| etcd quorum ×3 | 10.11.50.163–165 | `etcd1..3` (v3.5) | +| HAProxy | 10.11.50.166 + 169 (VIP .167) | `haproxy` :5000/:5001 | +| Backup | pgBackRest (10.11.50.168, NFS) | vezi §Backup | + +## Pornire + +```bash +docker compose up -d +# election durează ~30-60s; verifică: +docker exec didi-ha-pg1 patronictl list +``` + +Conectare (contract identic cu producția): + +```bash +PGPASSWORD=didi-super-secret psql -h localhost -p 5000 -U postgres # RW (leader) +PGPASSWORD=didi-super-secret psql -h localhost -p 5001 -U postgres # RO (replici) +``` + +Restaurare schema DIDI (bos_parammgmt / bos_analysis / bos_sysadmin / bos_subscriber): + +```bash +PGPASSWORD=didi-super-secret psql -h localhost -p 5000 -U postgres \ + -f ../DIDI_full_export_2026-07-02.sql +``` + +## Test failover (drill de recepție) + +```bash +# 1. află liderul +docker exec didi-ha-pg1 patronictl list +# 2. omoară-l +docker stop didi-ha-pg2 # (dacă pg2 e leader) +# 3. Patroni promovează o replică în secunde; HAProxy reroutează :5000 +# automat (healthcheck /primary la 3s, fall 3). Aplicațiile nu schimbă +# nimic — se reconectează pe același endpoint. +docker exec didi-ha-pg1 patronictl list +# 4. reintră nodul căzut ca replică: +docker start didi-ha-pg2 +``` + +Switchover planificat (fără downtime): + +```bash +docker exec didi-ha-pg1 patronictl switchover didi --force +``` + +## Parametri + +| Env | Default | Rol | +|---|---|---| +| `PG_SUPERUSER_PASSWORD` | `didi-super-secret` | postgres superuser | +| `PG_ADMIN_PASSWORD` | `didi-admin-secret` | admin role | +| `PG_STANDBY_PASSWORD` | `didi-standby-secret` | replicare streaming | + +**Schimbă-le obligatoriu în producție** (`.env` lângă compose). + +## Backup + +În producție backup-ul e pgBackRest (full zilnic + WAL archiving pe NFS, +nod dedicat). Pe acest pachet, echivalentul minim: + +```bash +docker exec didi-ha-pg1 su postgres -c \ + 'pg_basebackup -h localhost -p 5432 -D /tmp/didi-backup -Ft -z -Xs' +``` + +## Relația cu livrabilul Lot 2 + +- Modulul 5 (Baze de date SQL) cere PostgreSQL cu HA; oferta specifică + Patroni + HAProxy. Acest director este implementarea IaC livrată — + reproductibilă pe orice host Docker, plus instanțierea de producție + documentată în `MIGRATION.md`. +- Aplicațiile (agent-v3, didiFramework) sunt agnostice: `PG_HOST:PG_PORT` + arată fie spre VIP-ul de producție (`10.11.50.167:5000`), fie spre acest + cluster local (`localhost:5000`) — același contract, zero modificări de cod. diff --git a/backend/services/data-layer/didiDatabase/ha-cluster/docker-compose.yml b/backend/services/data-layer/didiDatabase/ha-cluster/docker-compose.yml new file mode 100644 index 0000000..a5203bd --- /dev/null +++ b/backend/services/data-layer/didiDatabase/ha-cluster/docker-compose.yml @@ -0,0 +1,116 @@ +# ============================================================================ +# DIDI PostgreSQL HA cluster — Patroni + etcd + HAProxy (IaC, reproducible) +# +# Containerized mirror of the production topology (see ../MIGRATION.md): +# prod: 3× PG/Patroni (10.11.50.160-162) + 3× etcd (163-165) +# + 2× HAProxy (166/169, VIP 167) + pgBackRest (168) +# here: 3× Spilo (Patroni+PG, Zalando) + 3× etcd + 1× HAProxy +# → same failover semantics, single-host footprint for +# demo/recepție/DR-rehearsal. +# +# Endpoints (identical contract to production): +# localhost:5000 → leader (read-write) [HAProxy checks Patroni /primary] +# localhost:5001 → replicas (read-only) [HAProxy checks Patroni /replica] +# localhost:7000 → HAProxy stats UI +# +# Usage: +# docker compose up -d +# # wait ~30s for leader election, then: +# psql -h localhost -p 5000 -U postgres # password: $PG_SUPERUSER_PASSWORD +# # restore DIDI schema: +# psql -h localhost -p 5000 -U postgres -f ../DIDI_full_export_2026-07-02.sql +# # failover drill: +# docker compose stop $(docker compose ps --format '{{.Name}}' | head -1) +# # → a replica is promoted in seconds; :5000 keeps serving writes. +# ============================================================================ + +x-etcd-common: &etcd-common + image: quay.io/coreos/etcd:v3.5.16 + restart: unless-stopped + networks: [didi-ha] + environment: &etcd-env + ETCD_INITIAL_CLUSTER: etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 + ETCD_INITIAL_CLUSTER_STATE: new + ETCD_INITIAL_CLUSTER_TOKEN: didi-pg-ha + ETCD_AUTO_COMPACTION_RETENTION: "1" + ETCD_ENABLE_V2: "true" + +x-spilo-common: &spilo-common + image: ghcr.io/zalando/spilo-16:3.3-p3 + restart: unless-stopped + networks: [didi-ha] + environment: &spilo-env + SCOPE: didi # Patroni cluster name (etcd namespace) + PGVERSION: "16" + ETCD3_HOSTS: "'etcd1:2379','etcd2:2379','etcd3:2379'" + PGPASSWORD_SUPERUSER: ${PG_SUPERUSER_PASSWORD:-didi-super-secret} + PGPASSWORD_ADMIN: ${PG_ADMIN_PASSWORD:-didi-admin-secret} + PGPASSWORD_STANDBY: ${PG_STANDBY_PASSWORD:-didi-standby-secret} + ALLOW_NOSSL: "true" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8008/health || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 60s + +services: + etcd1: + <<: *etcd-common + container_name: didi-ha-etcd1 + command: etcd --name etcd1 + --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd1:2380 + --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd1:2379 + etcd2: + <<: *etcd-common + container_name: didi-ha-etcd2 + command: etcd --name etcd2 + --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd2:2380 + --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd2:2379 + etcd3: + <<: *etcd-common + container_name: didi-ha-etcd3 + command: etcd --name etcd3 + --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://etcd3:2380 + --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd3:2379 + + pg-node1: + <<: *spilo-common + container_name: didi-ha-pg1 + hostname: pg-node1 + depends_on: [etcd1, etcd2, etcd3] + volumes: [pg1-data:/home/postgres/pgdata] + pg-node2: + <<: *spilo-common + container_name: didi-ha-pg2 + hostname: pg-node2 + depends_on: [etcd1, etcd2, etcd3] + volumes: [pg2-data:/home/postgres/pgdata] + pg-node3: + <<: *spilo-common + container_name: didi-ha-pg3 + hostname: pg-node3 + depends_on: [etcd1, etcd2, etcd3] + volumes: [pg3-data:/home/postgres/pgdata] + + haproxy: + image: haproxy:2.9-alpine + container_name: didi-ha-haproxy + restart: unless-stopped + networks: [didi-ha] + depends_on: [pg-node1, pg-node2, pg-node3] + ports: + - "5000:5000" # read-write → Patroni leader + - "5001:5001" # read-only → replicas + - "7000:7000" # stats UI + volumes: + - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + +volumes: + pg1-data: + pg2-data: + pg3-data: + +networks: + didi-ha: + name: didi-ha diff --git a/backend/services/data-layer/didiDatabase/ha-cluster/haproxy/haproxy.cfg b/backend/services/data-layer/didiDatabase/ha-cluster/haproxy/haproxy.cfg new file mode 100644 index 0000000..7010450 --- /dev/null +++ b/backend/services/data-layer/didiDatabase/ha-cluster/haproxy/haproxy.cfg @@ -0,0 +1,46 @@ +# HAProxy for DIDI PostgreSQL HA — routes by Patroni REST health checks. +# Mirrors the production LB config (10.11.50.166/169 → VIP 167). +# +# :5000 → the ONE node whose Patroni answers 200 on /primary (leader, RW) +# :5001 → nodes answering 200 on /replica (round-robin, RO) +# +# On failover Patroni flips the health endpoints; HAProxy reroutes in +# (inter × fall) ≈ 9s worst case without client config changes. + +global + maxconn 300 + log stdout format raw local0 + +defaults + log global + mode tcp + retries 2 + timeout client 30m + timeout connect 4s + timeout server 30m + timeout check 5s + +listen stats + mode http + bind *:7000 + stats enable + stats uri / + +listen postgres_write + bind *:5000 + option httpchk GET /primary + http-check expect status 200 + default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions + server pg-node1 pg-node1:5432 check port 8008 + server pg-node2 pg-node2:5432 check port 8008 + server pg-node3 pg-node3:5432 check port 8008 + +listen postgres_read + bind *:5001 + balance roundrobin + option httpchk GET /replica + http-check expect status 200 + default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions + server pg-node1 pg-node1:5432 check port 8008 + server pg-node2 pg-node2:5432 check port 8008 + server pg-node3 pg-node3:5432 check port 8008 diff --git a/backend/services/data-layer/didiQueue/.env.example b/backend/services/data-layer/didiQueue/.env.example new file mode 100644 index 0000000..f70aacc --- /dev/null +++ b/backend/services/data-layer/didiQueue/.env.example @@ -0,0 +1,13 @@ +# RabbitMQ Configuration +# CHANGE THESE FOR PRODUCTION! +RABBITMQ_USER=admin +RABBITMQ_PASSWORD=CHANGE_ME_IN_PRODUCTION +RABBITMQ_VHOST=/ + +# Port Configuration +RABBITMQ_PORT=5672 +RABBITMQ_MGMT_PORT=15672 + +# Resource Limits (optional) +RABBITMQ_VM_MEMORY_HIGH_WATERMARK=0.4 +RABBITMQ_DISK_FREE_LIMIT=1GB \ No newline at end of file diff --git a/backend/services/data-layer/didiQueue/INDEX.md b/backend/services/data-layer/didiQueue/INDEX.md new file mode 100644 index 0000000..9eb1630 --- /dev/null +++ b/backend/services/data-layer/didiQueue/INDEX.md @@ -0,0 +1,356 @@ +# didiQueue - Index + +Coada de mesaje RabbitMQ pentru procesarea asincrona a analizelor. Primeste task-uri de analiza de la agent-v3, le distribuie la workeri pe componente, si colecteaza rezultatele intr-un agregator de verdict. Nu contine cod custom -- doar container RabbitMQ cu script de initializare. + +**Productia ruleaza pe RabbitMQ LOCAL** (container `staging-dataLayer-rabbitmq`). Decizie: stabilitate + zero dependinte externe. Clusterul RabbitMQ RAG (managed extern) ramane configurat ca **fallback de urgenta pentru HA**, activabil cu `redis-switch.sh cluster --rabbit`, nu este folosit operational acum. + +**Productie (LOCAL — activ)**: +- Imagine: `rabbitmq:3.12-management-alpine` +- Container: `staging-dataLayer-rabbitmq` +- Port AMQP: 5672 (expus pe host: `0.0.0.0:5672->5672`) +- Port Management UI: 15672 (expus pe host: `0.0.0.0:15672->15672`) +- Credentiale: `admin` / `rabbitmq123` (din `.env`) +- Vhost: `/` +- Retea: `didi-network` + +**Fallback HA (cluster RAG — disponibil dar inactiv)**: +- Endpoint: `10.11.50.100:16672` (HAProxy VIP) +- Vhost: `/didi` +- User: `didi` +- Parola: din `.cluster-credentials.env` (gitignored) + +--- + +## Ce face + +1. **Primeste task-uri de analiza** -- publicate de agent-v3 dispatcher +2. **Distribuie catre workeri** -- fiecare componenta (techniques, ai_tampered, claims, domain) are cozi separate, plus media-preprocess pentru audio/video +3. **Prioritizeaza dupa plan** -- plan 1 (free) = prioritate 1, plan 6 (enterprise) = prioritate 10 +4. **Colecteaza rezultate** -- workerii publica in coada de rezultate, agregatorul face fan-in +5. **DLQ** -- mesajele care esueaza dupa 3 incercari merg in dead-letter queue +6. **TTL** -- mesajele expira dupa 24 ore + +**Nota HIL Moderation**: tabela `moderation_queue` (schema `bos_analysis`) este o tabela PostgreSQL pentru starea de review uman, NU o coada AMQP. RabbitMQ ramane folosit doar pentru dispatch-ul analizei asincrone (5 familii de cozi componente x 6 plan tiers = 30 cozi: media_preprocess, techniques, ai_tampered, claims, domain — plus coada `analysis.results` + DLQ). HIL nu introduce cozi noi. + +--- + +## Topologie cozi + +### Exchange + +| Nume | Tip | Durabil | Scop | +|------|-----|---------|------| +| analysis | topic | da | Ruteaza task-uri si rezultate | + +### Cozi componente (30 total = 5 cozi x 6 plan types) + +``` +analysis.media_preprocess.1 analysis.media_preprocess.2 ... analysis.media_preprocess.6 +analysis.techniques.1 analysis.techniques.2 ... analysis.techniques.6 +analysis.ai_tampered.1 analysis.ai_tampered.2 ... analysis.ai_tampered.6 +analysis.claims.1 analysis.claims.2 ... analysis.claims.6 +analysis.domain.1 analysis.domain.2 ... analysis.domain.6 +``` + +Configurare per coada: +- Durabil: da +- Max prioritate: 10 +- Dead-letter exchange: '' (default) +- Dead-letter routing key: analysis_dlq +- Message TTL: 86,400,000 ms (24 ore) + +### Coada rezultate (fan-in) + +| Coada | Bindings | Scop | +|-------|----------|------| +| analysis.results | analysis.results.techniques, analysis.results.ai_tampered, analysis.results.claims, analysis.results.domain | Colecteaza rezultate de la toti workerii | + +### Dead-letter queue + +| Coada | Scop | +|-------|------| +| analysis_dlq | Mesaje care au esuat dupa 3 retry-uri | + +--- + +## Prioritati per plan + +| Plan Type | Prioritate | Tip utilizator | +|-----------|------------|----------------| +| 1 | 1 | freemium | +| 2 | 2 | starter | +| 3 | 4 | basic | +| 4 | 6 | pro | +| 5 | 8 | business | +| 6 | 10 | enterprise | + +Mesajele cu prioritate mai mare sunt procesate primele din coada. + +--- + +## Format mesaje + +### Task message (Dispatcher -> Worker) + +Publicat de agent-v3 dispatcher in cozile de componente. + +``` +{ + sessionId: "uuid", + component: "techniques" | "ai_tampered" | "claims" | "domain", + planType: 1-6, + priority: 1-10, + input: { + content: "text de analizat", + url: "URL optional (video/domain)", + mediaPath: "cale MinIO optional (audio/video)", + inputType: "text" | "url" | "image" | "audio" | "video" + }, + userId: "string optional", + userEmail: "string optional", + timestamp: 1695312000000, + retryCount: 0 +} +``` + +AMQP properties: persistent=true, contentType=application/json, headers={sessionId, component, planType} + +### Result message (Worker -> Aggregator) + +Publicat de worker in coada analysis.results. + +``` +{ + sessionId: "uuid", + component: "techniques" | "ai_tampered" | "claims" | "domain", + success: true | false, + score: 0-100, + data: { ... rezultat flat componenta ... }, + error: "mesaj eroare daca success=false", + processingTime: 3500, + timestamp: 1695312003500 +} +``` + +--- + +## Cine publica mesaje + +| Cine | Ce publica | In ce coada | Logica in fisier | +|------|-----------|-------------|------------------| +| agent-v3 dispatcher | Task-uri de analiza | analysis.{component}.{planType} | agent-v3/src/queue/dispatcher.ts | +| Workeri componente | Rezultate analiza | analysis.results.{component} | agent-v3/src/queue/workers/component-worker.ts | + +## Cine consuma mesaje + +| Cine | Din ce coada | Ce face | Replici Docker | +|------|-------------|---------|----------------| +| worker-media-preprocess | analysis.media_preprocess.1-6 | Download yt-dlp + ffmpeg cadre + Whisper + Vision OCR, cache in Redis, dispatch task-uri componente | 2 | +| worker-techniques | analysis.techniques.1-6 | Ruleaza TechniquesV3Executor | 2 (prefetch 5) | +| worker-ai-tampered | analysis.ai_tampered.1-6 | Ruleaza AITamperedExecutor | 2 (prefetch 5) | +| worker-claims | analysis.claims.1-6 | Ruleaza ClaimsExecutor | 3 (prefetch 3) | +| worker-domain | analysis.domain.1-6 | Ruleaza analyzeDomain() | 2 (prefetch 10) | +| verdict-aggregator | analysis.results | Fan-in + VerdictCalculator | 2 (prefetch 10) | + +Claims are 3 replici (nu 2) pentru ca e cel mai lent (cautare web per claim). +Domain are prefetch 10 pentru ca e cel mai rapid (analiza locala, fara LLM). +Media-preprocess este nou (din 2026-03): centralizeaza download/transcribe/vision pentru audio+video, inlocuind logica per-worker. Workerii componente citesc media procesata din Redis (TTL 1h, chei `agent:media:{sessionId}:transcript`, `agent:media:{sessionId}:vision:misinformation`, etc.). + +--- + +## Fluxul complet async + +``` +Client POST /api/v3/pipeline/analyze-async { text, plan_type: 4 } + | + v +agent-v3 Dispatcher + |-- Salveaza SessionState in Redis (status: processing) + |-- Salveaza sesiune initiala in Redis + PostgreSQL + |-- Publica 4 task-uri in RabbitMQ: + | analysis.techniques.4 (prioritate 6) + | analysis.ai_tampered.4 (prioritate 6) + | analysis.claims.4 (prioritate 6) + | analysis.domain.4 (prioritate 6) + | + v +Response 202: { session_id, poll_url, result_url } + +--- In paralel, 4 workeri proceseaza --- + +Worker Techniques (consuma din analysis.techniques.4) + |-- Achizitioneaza lock Redis (300s TTL) + |-- Ruleaza TechniquesV3Executor (screening -> deep analysis) + |-- Publica rezultat in analysis.results.techniques + |-- ACK mesaj + +Worker AI-Tampered (consuma din analysis.ai_tampered.4) + |-- Ruleaza AITamperedExecutor + |-- Publica rezultat in analysis.results.ai_tampered + +Worker Claims (consuma din analysis.claims.4) + |-- Ruleaza ClaimsExecutor (extrage + verifica prin web) + |-- Publica rezultat in analysis.results.claims + +Worker Domain (consuma din analysis.domain.4) + |-- Ruleaza analyzeDomain() + |-- Publica rezultat in analysis.results.domain + +--- Agregatorul colecteaza --- + +Verdict Aggregator (consuma din analysis.results) + |-- Primeste rezultat componenta + |-- Achizitioneaza lock Redis (30s TTL) + |-- Actualizeaza SessionState in Redis (completedComponents++) + |-- Daca toate 4 componente gata: + | |-- VerdictCalculator.calculate() (functie pura) + | |-- VerdictExplanation.generate() (LLM, RO+EN) + | |-- PersistService.persist() (Redis + PostgreSQL) + |-- ACK mesaj + +--- Clientul polleaza --- + +GET /api/v3/pipeline/{sessionId}/queue-status + -> { progress: 75%, completed_components: ["techniques", "ai_tampered", "domain"] } + +GET /api/v3/pipeline/{sessionId}/result + -> AnalysisSession completa (cand status=completed) +``` + +--- + +## Retry si error handling + +| Situatie | Actiune | Rezultat | +|----------|---------|----------| +| Procesare reusita | channel.ack(msg) | Mesaj sters din coada | +| Eroare retryable + retryCount < 3 | channel.nack(msg, false, true) | Mesaj pus inapoi in coada | +| Eroare retryable + retryCount >= 3 | channel.nack(msg, false, false) | Mesaj trimis in analysis_dlq | +| Eroare non-retryable | channel.nack(msg, false, false) | Mesaj trimis in analysis_dlq | +| RabbitMQ indisponibil | Fallback sync | agent-v3 ruleaza analiza sincrona | + +Lock-uri Redis previn procesarea dubla: +- Lock componenta: `didi:queue:lock:{sessionId}:{component}` (TTL 300s) +- Lock agregator: `didi:queue:lock:aggregator:{sessionId}` (TTL 30s) + +--- + +## Procesare media in workeri + +Workerii proceseaza media inainte de analiza text: + +| Input type | Ce face workerul | Logica in | +|-----------|-----------------|-----------| +| text | Nimic, trimite direct la executor | component-worker.ts | +| audio | Transcriere via M17/Groq/OpenAI | shared/media/transcription.ts | +| video | Download yt-dlp + ffmpeg cadre + transcriere | shared/media/video-processor.ts | +| image | Extragere text via vision cascade (pentru techniques/claims) | shared/media/vision.ts | + +Timeout-uri worker: +- Video: 600,000 ms (10 minute) +- Default: 120,000 ms (2 minute) + +--- + +## Conexiune RabbitMQ (din agent-v3) + +Fisier: `agent-v3/src/queue/connection.ts` + `agent-v3/src/shared/queue/constants.ts` + +- Lazy initialization (conectare la prima utilizare) +- Doua canale: regular (consume) + confirm (publish cu confirmare) +- Auto-recovery la deconectare (`CONNECTION_RETRY_DELAY = 2s` pentru failover HA pe cluster) +- Graceful shutdown pe SIGTERM/SIGINT (stop consume, close channels, close connection) +- URL building foloseste `encodeURIComponent()` pentru vhost (`/didi` -> `%2Fdidi` in AMQP URI) + +``` +# Productie (LOCAL — activ) +URL: amqp://admin:rabbitmq123@staging-dataLayer-rabbitmq:5672/ (vhost `/`) + +# Fallback HA (cluster RAG, HAProxy VIP — disponibil dar inactiv) +URL: amqp://didi:@10.11.50.100:16672/%2Fdidi +``` + +### Switch intre cluster si local + +Script: `backend/services/orchestration-layer/scripts/redis-switch.sh` (denumirea istorica este `redis-switch`, dar suporta si RabbitMQ via flag `--rabbit`). + +``` +redis-switch.sh {cluster|local|status} [redis|rabbit|both] +``` + +Verificare topologie: `agent-v3/scripts/verify-rabbitmq-cluster.ts` (verifica privilegii vhost + topologia celor 30 cozi + exchange + DLQ). + +--- + +## Fisiere in directorul didiQueue + +``` +init-queues.sh -- Creeaza exchange + coada legacy singulara + DLQ + binding + policy (idempotent) +.env -- Credentiale + porturi +.env.example -- Template +README.md -- Documentatie +``` + +Zero cod custom. Doar container RabbitMQ standard cu management plugin. + +Nota: init-queues.sh creeaza topologia legacy cu o singura coada. Topologia actuala cu 30 cozi (5 componente x 6 planuri: media_preprocess, techniques, ai_tampered, claims, domain) + coada results + DLQ este creata dinamic de workerii agent-v3 la startup (vezi agent-v3/src/queue/connection.ts si constants.ts). + +--- + +## Fisiere cod integrare (in agent-v3) + +| Fisier | Rol | +|--------|-----| +| agent-v3/src/queue/connection.ts | Manager conexiune RabbitMQ (lazy, auto-recovery) | +| agent-v3/src/shared/queue/constants.ts | Nume exchange/cozi, prioritati, config workeri | +| agent-v3/src/queue/dispatcher.ts | Publica task-uri in cozi componente | +| agent-v3/src/queue/aggregator.ts | Consuma rezultate, calculeaza verdict, persista | +| agent-v3/src/queue/workers/component-worker.ts | Worker generic (lock, procesare, publish result, ack/nack) | +| agent-v3/src/worker-entrypoints/techniques.ts | Entry point Docker worker techniques | +| agent-v3/src/worker-entrypoints/ai-tampered.ts | Entry point Docker worker ai-tampered | +| agent-v3/src/worker-entrypoints/claims.ts | Entry point Docker worker claims | +| agent-v3/src/worker-entrypoints/domain.ts | Entry point Docker worker domain | +| agent-v3/src/worker-entrypoints/aggregator.ts | Entry point Docker verdict aggregator | + +--- + +## Configurare Docker + +```yaml +# din data-layer/docker-compose.yml +staging-dataLayer-rabbitmq: + image: rabbitmq:3.12-management-alpine + container_name: staging-dataLayer-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: rabbitmq123 + RABBITMQ_DEFAULT_VHOST: / + ports: + - "5672:5672" # AMQP (expus pe host) + - "15672:15672" # Management UI (expus pe host) + volumes: + - didi-staging-rabbitmq-data:/var/lib/rabbitmq + networks: + - didi-network + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s +``` + +Workerii sunt definiti in agent-v3/docker-compose.yml (vezi agent-v3/INDEX.md pentru detalii replici). + +--- + +## Ce NU face + +- Nu are cod custom (container RabbitMQ standard) +- Nu are clustering (instanta singulara) +- Nu are mirroring/quorum queues (nu e HA) +- Nu are SSL/TLS (AMQP plain text intern) +- Nu are ACL per serviciu (toti folosesc userul admin) +- Nu are delayed message plugin (retry prin requeue nativ) +- Nu are shovel/federation (nu transfera mesaje intre brokeri) diff --git a/backend/services/data-layer/didiQueue/MIGRATION.md b/backend/services/data-layer/didiQueue/MIGRATION.md new file mode 100644 index 0000000..893eef9 --- /dev/null +++ b/backend/services/data-layer/didiQueue/MIGRATION.md @@ -0,0 +1,147 @@ +# RabbitMQ — migrat pe clusterul RAG (2026-04-22) + +> **TL;DR**: DIDI folosește **clusterul RabbitMQ RAG** (3 noduri 3.13 + HAProxy VIP). Containerul local `staging-dataLayer-rabbitmq` din `data-layer/docker-compose.yml` e **oprit dar păstrat** ca fallback rapid. Conexiunea e centralizată via `agent-v3/src/queue/connection.ts`. + +--- + +## Ce era aici (legacy) + +`staging-dataLayer-rabbitmq` — un container RabbitMQ 3.12.6 management standalone pe `didi-network` Docker. Single-node, fără mirroring, vhost `/`. Topologie creată dinamic la startup workerilor. + +## Ce e acum + +### Cluster RabbitMQ RAG (producție) + +| Nod | Hostname | IP | AMQP | Mgmt UI | +|---|---|---|---|---| +| rag01 (HAProxy VIP) | `rag01` | `10.11.50.100` | 16672 (VIP), 5672 direct | 16673 (VIP), 15672 direct | +| rag02 | `rag02` | `10.11.50.102` | 5672 | 15672 | +| rag03 | `rag03` | `10.11.50.103` | 5672 | 15672 | + +**Endpoint-uri pentru aplicații:** + +| Scop | Endpoint | +|---|---| +| **AMQP VIP** (publish + consume) | `10.11.50.100:16672` | +| **Management UI** | `http://10.11.50.100:16673` (admin/``) | +| HAProxy stats | `http://10.11.50.100:8404/stats` | + +Vhost DIDI: `/didi` (izolare totală — alte vhost-uri (`notify`, `/hassio`) nu se văd). User: `didi` cu permisiuni full pe `/didi`. Parolă în `vault-ul de credențiale` `name='DIDI Platform RabbitMQ'`. + +URL AMQP în cod: `amqp://didi:@10.11.50.100:16672/%2Fdidi` (slash URL-encoded ca `%2F`). + +### Conexiune centralizată în cod + +`agent-v3/src/queue/connection.ts` — manager unic ioredis-style cu lazy initialization, două canale (regular consume + confirm publish), auto-recovery la 2s pentru failover HA, graceful shutdown pe SIGTERM/SIGINT. + +`agent-v3/src/shared/queue/constants.ts` — topologie cozi (exchange, routing keys, prioritați). + +## Topologie cozi (creată dinamic de workeri la startup) + +### Exchange + +| Nume | Tip | Durabil | +|---|---|---| +| `analysis` | topic | da | + +### Cozi componente (24 = 4 componente × 6 plan types) + +``` +analysis.techniques.{1..6} analysis.ai_tampered.{1..6} +analysis.claims.{1..6} analysis.domain.{1..6} +``` + +Fiecare cu max-priority=10, dead-letter exchange, message TTL 24h. + +### Coadă agregare + +`analysis.results` (fan-in) cu bindings de la fiecare componentă. + +### Prioritați per plan + +| Plan Type | Prioritate | Tip | +|---|---|---| +| 1 | 1 | freemium | +| 2 | 2 | starter | +| 3 | 4 | basic | +| 4 | 6 | pro | +| 5 | 8 | business | +| 6 | 10 | enterprise | + +## Containerul local `staging-dataLayer-rabbitmq` + +Definit în `data-layer/docker-compose.yml`, configurat să pornească dar **manual oprit** ca parte din migrare. Volumul `didi-staging-rabbitmq-data` e intact. + +Status curent: `Exited`. + +### De ce e păstrat? + +Fallback rapid dacă cluster RAG e indisponibil. Pentru reactivare temporară: + +```bash +backend/services/orchestration-layer/scripts/redis-switch.sh local rabbit +# (același script gestionează rabbit + redis) +``` + +## Switch rapid cluster ↔ local + +```bash +# Folosește local +./redis-switch.sh local rabbit + +# Cluster (default) +./redis-switch.sh cluster rabbit + +# Both +./redis-switch.sh cluster both + +# Status +./redis-switch.sh status both +``` + +## Verificare cluster + +```bash +# Mgmt UI (browser) +http://10.11.50.100:16673 # admin/ + +# Quick test prin rabbitmqctl pe nod cluster +ssh admin365@10.11.50.102 +sudo rabbitmqctl status +sudo rabbitmqctl list_vhosts +sudo rabbitmqctl list_queues -p /didi name messages consumers + +# Via HTTP API +curl -s -u admin: http://10.11.50.100:16673/api/overview | python3 -m json.tool +``` + +## Workerii (în `agent-v3` docker-compose) + +Servicii Docker care consumă din cluster: + +| Worker | Replici | Coadă | +|---|---|---| +| `worker-techniques` | 2 | `analysis.techniques.1-6` | +| `worker-ai-tampered` | 2 | `analysis.ai_tampered.1-6` | +| `worker-claims` | 3 | `analysis.claims.1-6` (mai multe replici, mai lent) | +| `worker-domain` | 2 | `analysis.domain.1-6` | +| `worker-media-preprocess` | 2 | `analysis.media_preprocess.*` | +| `verdict-aggregator` | 2 | `analysis.results` | + +## Verificări dispatcher + +Script verificare cluster ready: `agent-v3/scripts/verify-rabbitmq-cluster.ts` — testează conectivitate, vhost privileges, topology, în funcție de user `didi` și endpoint VIP. + +## Linkuri rapide + +- Ghid utilizare cluster: `landingzone/rabbitmq-rag/README.md` (repo `git.finesynergy.eu/lucian/landingzone`) +- Onboarding vhost nou: `landingzone/rabbitmq-rag/CLAUDE_PROMPT.md` +- Mgmt UI: `http://10.11.50.100:16673` + +## Status + +- ✅ Migrare aplicată: 2026-04-22 +- ✅ Vhost `/didi` izolat, user `didi` cu permisiuni minime +- ✅ Container local păstrat ca fallback (oprit, volum intact) +- ✅ Topologie creată dinamic la startup (24 cozi + results + DLQ) +- ✅ Workerii (în agent-v3) consumă cu prefetch ajustat per componentă diff --git a/backend/services/data-layer/didiQueue/README.md b/backend/services/data-layer/didiQueue/README.md new file mode 100644 index 0000000..08adbcf --- /dev/null +++ b/backend/services/data-layer/didiQueue/README.md @@ -0,0 +1,215 @@ +# didiQueue - RabbitMQ Message Queue Service 🐰 + +## Overview +RabbitMQ message broker for asynchronous communication between the Orchestrator and Analysis Service in the DIDI Backend platform. + +## 🎯 Purpose +Provides reliable message queuing for pipeline execution jobs, decoupling the API layer from the processing layer. + +## 🚀 Quick Start + +### Start the Service +```bash +# From this directory +docker compose up -d + +# Or from data-layer directory +make up-queue +``` + +### Access Points +- **AMQP Protocol**: `localhost:5672` +- **Management UI**: `http://localhost:15672` +- **Default Credentials**: `admin / rabbitmq123` + +## 📊 Queue Architecture + +Since we're merging all analysis services into one unified service, we use a **single queue**: + +``` +Orchestrator → publishes → analysis_queue → consumed by → Analysis Service +``` + +### Queue Configuration +- **Queue Name**: `analysis_queue` +- **Type**: Durable (survives restarts) +- **Dead Letter Queue**: `analysis_dlq` (for failed messages) +- **Message TTL**: 24 hours +- **Auto-delete**: No + +## 🔧 Configuration + +### Environment Variables +Edit `.env` file to customize: +```env +RABBITMQ_USER=admin +RABBITMQ_PASSWORD=rabbitmq123 # CHANGE IN PRODUCTION! +RABBITMQ_VHOST=/ +RABBITMQ_PORT=5672 +RABBITMQ_MGMT_PORT=15672 +``` + +### Resource Limits +```yaml +Memory: 1GB (max) / 512MB (reserved) +CPU: 0.5 cores (max) / 0.25 cores (reserved) +``` + +## 📝 Message Format + +Messages published to the queue follow this structure: +```json +{ + "run_id": "analysis_abc123_20250901_120000", + "pipeline_id": "uuid-here", + "pipeline_version": 1, + "input_data": { + "text": "Content to analyze", + "image": "base64_or_url", + "audio": "url_to_audio", + "video": "url_to_video" + }, + "media_type": "text|image|audio|video", + "created_at": "2025-09-01T12:00:00Z" +} +``` + +## 🔍 Management + +### View Queue Status +```bash +# Using Management UI +http://localhost:15672 + +# Using CLI +docker exec didi-queue rabbitmqctl list_queues + +# Check queue depth +docker exec didi-queue rabbitmqctl list_queues name messages_ready messages_unacknowledged +``` + +### Purge Queue (Development Only) +```bash +# Remove all messages from queue +docker exec didi-queue rabbitmqctl purge_queue analysis_queue +``` + +### Health Check +```bash +# Check if RabbitMQ is responsive +docker exec didi-queue rabbitmq-diagnostics -q ping + +# Detailed health check +docker exec didi-queue rabbitmq-diagnostics check_running +``` + +## 🏗️ Integration Points + +### Publishers (Orchestrator) +```python +import aio_pika + +# Connect +connection = await aio_pika.connect_robust( + "amqp://admin:rabbitmq123@localhost:5672/" +) +channel = await connection.channel() + +# Publish message +await channel.default_exchange.publish( + aio_pika.Message(body=json.dumps(message).encode()), + routing_key="analysis_queue" +) +``` + +### Consumers (Analysis Service) +```python +# Declare queue +queue = await channel.declare_queue("analysis_queue", durable=True) + +# Consume messages +async for message in queue: + async with message.process(): + body = json.loads(message.body.decode()) + # Process the message +``` + +## 🛠️ Troubleshooting + +### Queue is not created +The `init-queues.sh` script runs automatically on container start. Check logs: +```bash +docker logs didi-queue +``` + +### Messages not being consumed +1. Check if Analysis Service is running +2. Verify queue has messages: `docker exec didi-queue rabbitmqctl list_queues` +3. Check for dead letter queue: `docker exec didi-queue rabbitmqctl list_queues | grep dlq` + +### High memory usage +```bash +# Check memory usage +docker exec didi-queue rabbitmq-diagnostics memory_breakdown + +# Set memory limit +docker exec didi-queue rabbitmqctl set_vm_memory_high_watermark 0.4 +``` + +## 🔐 Security + +### Production Checklist +- [ ] Change default password in `.env` +- [ ] Enable SSL/TLS for connections +- [ ] Restrict management UI access +- [ ] Set up user permissions +- [ ] Configure firewall rules +- [ ] Enable audit logging + +### Create Production User +```bash +# Create new user +docker exec didi-queue rabbitmqctl add_user analysis_service SECURE_PASSWORD + +# Set permissions +docker exec didi-queue rabbitmqctl set_permissions -p / analysis_service ".*" ".*" ".*" + +# Set user tags +docker exec didi-queue rabbitmqctl set_user_tags analysis_service monitoring +``` + +## 📊 Monitoring + +### Key Metrics +- Queue depth (messages waiting) +- Message rates (publish/consume) +- Connection count +- Memory usage +- Disk usage + +### Prometheus Metrics +RabbitMQ exposes metrics at: `http://localhost:15692/metrics` + +## 🔄 Backup & Recovery + +### Backup +```bash +# Export definitions +docker exec didi-queue rabbitmqctl export_definitions /var/lib/rabbitmq/backup.json +docker cp didi-queue:/var/lib/rabbitmq/backup.json ./backup.json +``` + +### Restore +```bash +# Import definitions +docker cp ./backup.json didi-queue:/var/lib/rabbitmq/backup.json +docker exec didi-queue rabbitmqctl import_definitions /var/lib/rabbitmq/backup.json +``` + +## 📚 Related Documentation +- [Data Layer README](../README.md) +- [RabbitMQ Documentation](https://www.rabbitmq.com/documentation.html) +- [AMQP Protocol](https://www.amqp.org/) + +--- +*Part of the DIDI Backend Data Layer* \ No newline at end of file diff --git a/backend/services/data-layer/didiQueue/init-queues.sh b/backend/services/data-layer/didiQueue/init-queues.sh new file mode 100644 index 0000000..7b54ef5 --- /dev/null +++ b/backend/services/data-layer/didiQueue/init-queues.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Init script for RabbitMQ queue setup +# This runs automatically when the container starts + +set -e + +# Wait for RabbitMQ to be ready +until rabbitmqctl status > /dev/null 2>&1; do + echo "Waiting for RabbitMQ to start..." + sleep 2 +done + +echo "RabbitMQ is ready. Setting up queues..." + +# Since we're merging all analysis services into one, we only need ONE queue +# Create the unified analysis queue +rabbitmqctl eval ' + rabbit_exchange:declare( + {resource, <<"/">>, exchange, <<"analysis">>}, + topic, + true, + false, + false, + [] + ).' || true + +# Declare the unified analysis queue with proper settings +rabbitmqctl eval ' + rabbit_amqqueue:declare( + {resource, <<"/">>, queue, <<"analysis_queue">>}, + true, % Durable + false, % Not exclusive + false, % Not auto-delete + [], % No arguments + none % No owner + ).' || true + +# Bind the queue to the exchange +rabbitmqctl eval ' + rabbit_binding:add_explicit( + {binding, + {resource, <<"/">>, exchange, <<"analysis">>}, + <<"analysis.*">>, + {resource, <<"/">>, queue, <<"analysis_queue">>}, + [] + } + ).' || true + +# Optional: Create a dead letter queue for failed messages +rabbitmqctl eval ' + rabbit_amqqueue:declare( + {resource, <<"/">>, queue, <<"analysis_dlq">>}, + true, % Durable + false, % Not exclusive + false, % Not auto-delete + [], % No arguments + none % No owner + ).' || true + +echo "✅ Queue setup complete!" +echo "Created queues:" +echo " - analysis_queue (main queue for all analysis types)" +echo " - analysis_dlq (dead letter queue for failed messages)" + +# Set queue policies for message TTL and retry +rabbitmqctl set_policy analysis-retry \ + "analysis_queue" \ + '{"message-ttl":86400000, "dead-letter-exchange":"", "dead-letter-routing-key":"analysis_dlq"}' \ + --priority 0 \ + --apply-to queues || true + +echo "✅ Queue policies configured!" \ No newline at end of file diff --git a/backend/services/data-layer/didiStorage/.env.example b/backend/services/data-layer/didiStorage/.env.example new file mode 100644 index 0000000..5399aad --- /dev/null +++ b/backend/services/data-layer/didiStorage/.env.example @@ -0,0 +1,46 @@ +# ============================================================================ +# didiStorage Environment Configuration +# ============================================================================ +# Copy this file to .env and update with your values + +# Docker Compose Project Name (groups containers in Docker Desktop) +COMPOSE_PROJECT_NAME=didibackend_datalayer + +# MinIO Credentials (CHANGE THESE!) +MINIO_ROOT_USER=YOUR_ADMIN_USER_HERE +MINIO_ROOT_PASSWORD=YOUR_SECURE_PASSWORD_HERE + +# Ports (using 9002/9003 to avoid conflicts with existing MinIO) +MINIO_API_PORT=9002 # API endpoint +MINIO_CONSOLE_PORT=9003 # Web console + +# Region +MINIO_REGION=us-east-1 + +# Console Access +MINIO_BROWSER=on # Set to 'off' to disable web console + +# Resource Limits +MINIO_MEMORY_LIMIT=1G +MINIO_MEMORY_RESERVATION=512M + +# Storage Settings +MINIO_STORAGE_CLASS_STANDARD=EC:2 +MINIO_STORAGE_CLASS_RRS=EC:1 + +# Timezone +TZ=UTC + +# Bucket Lifecycle (days) +TEXT_FILES_EXPIRY=30 +AUDIO_FILES_EXPIRY=30 +VIDEO_FILES_EXPIRY=30 +IMAGE_FILES_EXPIRY=60 +DOCUMENT_FILES_EXPIRY=90 + +# Versioning +ENABLE_VERSIONING=true + +# Encryption (optional) +MINIO_KMS_SECRET_KEY= +MINIO_KMS_AUTO_ENCRYPTION=off \ No newline at end of file diff --git a/backend/services/data-layer/didiStorage/.gitignore b/backend/services/data-layer/didiStorage/.gitignore new file mode 100644 index 0000000..29e0d42 --- /dev/null +++ b/backend/services/data-layer/didiStorage/.gitignore @@ -0,0 +1,27 @@ +# Environment variables +.env +.env.local + +# Data directory +data/ + +# Config directory +config/ + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Backup files +*.bak +*.backup +*.old \ No newline at end of file diff --git a/backend/services/data-layer/didiStorage/INDEX.md b/backend/services/data-layer/didiStorage/INDEX.md new file mode 100644 index 0000000..18b0955 --- /dev/null +++ b/backend/services/data-layer/didiStorage/INDEX.md @@ -0,0 +1,352 @@ +# didiStorage - Index + +Stocare fisiere media pentru platforma DIDI. Container MinIO (S3-compatibil) local pe masina de deployment. Nu contine cod custom -- doar configurare si script de initializare. + +## Productie activa (LOCAL) + +DIDI scrie **LOCAL** pe containerul MinIO `staging-dataLayer-minio:9000` (pe `didi-network`), intr-un singur bucket `didi-prod`. Decizie: stabilitate + zero dependinte externe. Clusterul MinIO managed extern (4 noduri, erasure coding EC:2, HAProxy + keepalived VRRP) ramane configurat ca **fallback de urgenta pentru HA**, activabil cu `minio-switch.sh cluster`, dar nu este folosit operational acum. + +| Mediu | Endpoint | Bucket | Credentiale | +|-------|----------|--------|-------------| +| **Productie (LOCAL — activ)** | `staging-dataLayer-minio:9000` (expus `0.0.0.0:9000`) | `didi-prod` (single bucket) | `didi-prod` / `627074a6...` | +| Fallback HA (cluster — inactiv) | `:9000` (VIP `10.11.10.128`) | `didi-prod` | didi-prod / `.cluster-credentials.env` | + +Switch local/cluster: `agent-v3/scripts/minio-switch.sh local|cluster` (modifica `.env` + reseteaza containerele). Detalii migrare: `agent-v3/MIGRATION_MINIO.md`. + +Restrictia cheie mostenita din arhitectura: credentialele `didi-prod` au `s3:*` **doar pe bucket-ul propriu** — nu se creeaza bucket-uri noi. De aici single-bucket architecture (vezi sectiunea urmatoare), pastrata si local. + +## Container local (activ) + +**Imagine**: minio/minio:RELEASE.2024-08-29T01-40-52Z +**Container**: staging-dataLayer-minio +**Port API**: 9000 (Docker network + expus pe host `0.0.0.0:9000`) +**Port Console**: 9001 (expus pe host `0.0.0.0:9001`) +**Bucket DIDI**: `didi-prod` (singurul bucket) +**Credentiale DIDI**: `didi-prod` / `627074a6...` (din `.env`) +**Volume**: didi-staging-minio-data:/data + +--- + +## Ce stocheaza + +1. **Fisiere uploadate de utilizatori** -- imagini, audio, video, documente +2. **Fisiere procesate de agent-v3** -- video downloadat, cadre extrase, transcrieri +3. **Artefacte pipeline** -- rezultate analiza (cu versionare) +4. **Bucket-uri per utilizator** -- fisiere organizate pe foldere tipizate + +--- + +## Single-bucket architecture (refactor 2026-04-25) + +Productia foloseste un singur bucket `didi-prod`, iar separarea logica se face prin **prefix-uri**, nu bucket-uri distincte. Numele de prefix-uri sistem sunt identice cu numele bucket-urilor vechi pentru ca URL-urile vechi sa ramana interpretabile. + +``` +didi-prod/ + uploads/ -- upload-uri generale / fallback (legacy "uploads") + image-files/ -- imagini (legacy bucket "image-files") + audio-files/ -- audio (legacy bucket "audio-files") + video-files/ -- video (legacy bucket "video-files") + text-files/ -- text (legacy bucket "text-files") + document-files/ -- PDF, Office (legacy bucket "document-files") + pipeline-artifacts/ -- rezultate analiza (legacy bucket "pipeline-artifacts") + users/{userId}/ -- namespace per utilizator (inlocuieste bucket-urile "user-{id}") + images/ + videos/ + videos/frames/ -- cadre extrase din video (scrise de media-preprocess worker, citite de techniques + ai-tampered) + audio-files/ + text-files/ +``` + +### De ce single-bucket +- Arhitectura a fost proiectata pentru credentiale cu `s3:*` limitat la un singur bucket pre-creat (`didi-prod`), fara `s3:CreateBucket` — pastrata identic si pe MinIO local pentru portabilitate cluster. +- Quota tracking simplificat: nu mai depindem de tag-uri pe bucket; storage-ul utilizatorilor este urmarit in PG (`bos_sysadmin.internet_user.storage_used_bytes` + `storage_limit_bytes`, vezi migration `010_add_user_storage_quota.sql` din didiFramework). +- Separare logica prin prefix-uri, nu prin bucket-uri distincte — acelasi layout functioneaza local si pe cluster fara modificari de cod. + +### Backward compat +Caller-ii care paseaza bucket-uri vechi (`user-3`, `image-files`) sunt rezolvati automat la canonic `didi-prod/`: + +| URL primit | Bucket rezolvat | Key rezolvat | +|---|---|---| +| `user-3/images/abc.jpg` | `didi-prod` | `users/3/images/abc.jpg` | +| `image-files/foo.jpg` | `didi-prod` | `image-files/foo.jpg` | +| `didi-prod/users/3/images/abc.jpg` | `didi-prod` | `users/3/images/abc.jpg` (passthrough) | + +Implementat in: +- `didiFramework/src/config/minio.ts` -- `resolveBucketRequest(bucket, key)` + constanta `BUCKET = process.env.MINIO_BUCKET || 'didi-prod'`. +- `agent-v3/src/shared/media/media-service.ts` -- `proxyFile()` (ownership check accepta atat `user-{N}` cat si prefix `users/{N}/`) + `uploadFile()` foloseste prefix `users/{userId}/{folder}/`. + +### Lifecycle si versionare +- Politicile de lifecycle (90 zile pentru transient, retentie permanenta pentru `pipeline-artifacts/`, `backups/`) se aplica pe bucket-ul `didi-prod` prin prefix; pe MinIO local pot fi setate cu `mc ilm` (optional). +- Versionarea pentru `pipeline-artifacts/` si `backups/` este pastrata la nivel de bucket. +- Daca se comuta pe cluster (`minio-switch.sh cluster`), lifecycle-ul devine responsabilitatea operatorilor cluster-ului (nu detinem bucket-ul acolo). + +### Mod legacy (multi-bucket) +Pentru referinta — modul vechi avea 8 bucket-uri sistem (`uploads`, `text-files`, `image-files`, `audio-files`, `video-files`, `document-files`, `pipeline-artifacts`, `backups`) plus bucket-uri dinamice `user-{id}` create la primul login. Acest layout a fost inlocuit de single-bucket `didi-prod` cu prefix-uri. + +--- + +## Limite dimensiune fisiere + +| Tip | Limita | MIME types | +|-----|--------|------------| +| Imagini | 20 MB | image/jpeg, image/png, image/gif, image/webp, image/bmp, image/svg+xml | +| Audio | 100 MB | audio/mpeg, audio/wav, audio/ogg, audio/webm, audio/flac, audio/mp4, audio/x-m4a | +| Video | 500 MB | video/mp4, video/webm, video/quicktime, video/x-msvideo, video/x-matroska | +| Text | 10 MB | text/plain, text/html, text/markdown, text/csv | +| Documente | 50 MB | application/pdf, application/msword, application/vnd.openxmlformats-* | + +Rutarea automata: fisierul e pus in bucket-ul corespunzator MIME type-ului. + +--- + +## Cine scrie in MinIO + +| Serviciu | Ce scrie | Locatie (bucket `didi-prod` local) | Logica in fisier | +|----------|----------|--------|------------------| +| didiFramework (uploads) | Fisiere uploadate via API | `didi-prod/users/{id}/{mimeFolder}/...` (fallback `didi-prod/{mimeBucket}/`) | didiFramework/src/routes/uploads.ts | +| didiFramework (auth) | (Nu mai creeaza bucket) Logging registration; quota in PG | -- | didiFramework/src/routes/auth.ts + migration 010_add_user_storage_quota.sql | +| agent-v3 (media upload) | Fisiere uploadate direct sau via multer | `didi-prod/users/{userId}/...` (fallback `didi-prod/uploads/{userId}/`) | agent-v3/src/api/routes.ts -> media-service.ts | +| agent-v3 (media-preprocess worker) | Video downloadat, cadre extrase ffmpeg, audio extras pentru transcript | `didi-prod/users/{userId}/videos/frames/...` (cand frame-urile sunt persistate); altfel /tmp efemer | agent-v3/src/queue/workers/media-preprocess-worker.ts + shared/media/video-processor.ts | +| agent-v3 (pipeline) | Imagini downloadate din URL-uri | `didi-prod/users/{userId}/images/...` (fallback `didi-prod/uploads/{userId}/`) | agent-v3/src/api/pipeline-routes.ts | + +Nota media-preprocess: workerul ruleaza inaintea componentelor de analiza (techniques, ai-tampered, claims) si centralizeaza descarcarea + ffmpeg + transcript + 2× vision. Frame-urile extrase sunt apoi consumate de workerii de techniques / ai-tampered fara duplicare. Cand persistarea frame-urilor in MinIO este activa, prefixul folosit este `users/{userId}/videos/frames/` (cf. `USER_BUCKET_FOLDERS.FRAMES`). + +## Cine citeste din MinIO + +| Serviciu | Ce citeste | Cum | +|----------|-----------|-----| +| agent-v3 (media proxy) | Servire fisiere catre client | GET /api/v3/media/file/:bucket/:objectKey (proxy cu range support) | +| agent-v3 (vision) | Imagini pentru modele LLM locale | URL intern direct catre MinIO local (host-ul de deployment :9000/bucket/key) | +| agent-v3 (transcription) | Audio/video pentru transcriere | URL presemnat sau intern | +| didiFramework (uploads) | Info fisier + URL presemnat | GET /api/uploads/:fileId | +| Clienti externi | Download fisiere | URL presemnat (1 ora) sau proxy agent-v3 | + +--- + +## URL-uri si acces + +### URL public (prin proxy agent-v3) +``` +https://didi365.eu/api/v3/media/file/{bucket}/{objectKey} +Exemplu (canonic): https://didi365.eu/api/v3/media/file/didi-prod/users/3/audio-files/1771883851173-audio.mp3 +Exemplu (legacy): https://didi365.eu/api/v3/media/file/user-3/audio-files/1771883851173-audio.mp3 (rezolvat la didi-prod) +``` +Suporta HTTP Range requests (streaming audio/video). Proxy-ul rezolva atat URL-uri legacy (`user-{id}/...`, `image-files/...`) cat si forme canonice (`didi-prod/users/{id}/...`). + +### URL presemnat (direct MinIO local) +``` +http://:9000/didi-prod/{objectKey}?X-Amz-Algorithm=...&X-Amz-Signature=... +``` +Valabilitate: 1 ora (GET), 1 ora (PUT upload). Path-style obligatoriu (`forcePathStyle=true`). + +### URL intern (pentru modele LLM locale) +``` +http://:9000/didi-prod/{objectKey} +``` +Modelele locale (Qwen Vision, pe masinile GPU) nu pot accesa `didi365.eu`, asa ca URL-urile publice sunt convertite la URL-uri MinIO interne catre containerul local `staging-dataLayer-minio` (expus pe host-ul de deployment `:9000`). Logica: `agent-v3/src/shared/media/vision.ts` (`INTERNAL_MEDIA_BASE`). + +--- + +## Integrare cu serviciile + +### didiFramework -- configurare MinIO principala + +Fisier: `didiFramework/src/config/minio.ts` (refactor 2026-04-25 pentru single-bucket) + +Constante: +- `BUCKET` -- bucket fix din `MINIO_BUCKET` env (default `didi-prod`). +- `BUCKETS` -- prefix-uri sistem (`uploads`, `image-files`, `audio-files`, `video-files`, `text-files`, `document-files`, `pipeline-artifacts`). +- `USER_BUCKET_FOLDERS` -- foldere per utilizator (`images`, `videos`, `audio-files`, `text-files`, `videos/frames`). +- `MIME_TO_BUCKET` -- routing MIME -> prefix sistem. + +Exporta: +- `getMinioClient()` -- client singleton. +- `checkMinioHealth()` -- health check via `listBuckets()`. +- `resolveBucketRequest(bucket, key)` -- traduce input legacy (`user-3`, `image-files`) la `(BUCKET, fullKey)` canonic. +- `userObjectKey(userId, folder, filename)` -- construieste `users/{userId}/{folder}/{filename}`. +- `ensureBucket(name)` -- **no-op in single-bucket mode** (logging only). Pentru bucket-uri sistem legacy / `user-{N}` returneaza fara eroare. +- `uploadBuffer(bucket, name, buffer, mimeType, metadata)` -- upload (rezolva bucket-ul intern). +- `deleteObject(bucket, name)` / `getObjectInfo(bucket, name)` / `listObjects(bucket, prefix, maxKeys)`. +- `getPresignedUrl(bucket, name, expiry)` -- URL download (default 1 ora). +- `getPresignedPutUrl(bucket, name, expiry)` -- URL upload (default 1 ora). +- `getDirectUrl(bucket, name)` -- URL direct fara semnatura. +- `createUserBucket(userId, email, planId, planName, storageLimitGb)` -- **lazy in single-bucket mode**: namespace-ul `users/{id}/` "exista" doar cand are obiecte; functia logheaza si scrie quota in PG. +- `getUserBucketUsage(userId)` -- listObjects pe `users/{id}/`, returneaza bytes + count. +- `getUserBucketMetadata(userId)` -- thin shim (in single-bucket mode metadata e in PG, nu in tag-uri). +- `updateUserBucketMetadata(userId, planId, planName, storageLimitGb)` -- no-op pentru bucket tags; caller-ul scrie in PG. + +Quota tracking: migrarea `sql/migrations/010_add_user_storage_quota.sql` adauga coloanele `storage_used_bytes` si `storage_limit_bytes` la `bos_sysadmin.internet_user`. Tag-urile vechi (`storage-limit-gb` etc.) nu mai sunt folosite. + +### agent-v3 -- MediaService + +Fisier: `agent-v3/src/shared/media/media-service.ts` + +Exporta: +- uploadFile(userId, buffer, filename, contentType) -- upload cu rutare automata bucket +- getPresignedUploadUrl(userId, filename, contentType) -- URL presemnat PUT (1 ora) +- getPresignedDownloadUrl(objectKey, bucket) -- URL presemnat GET (1 ora) +- proxyFile(bucket, objectKey, ownerUserId, rangeHeader) -- proxy cu verificare proprietar + range support +- ensureBucket(name) -- creeaza daca nu exista + +Flow upload in agent-v3: +1. Rezolva bucket-ul utilizatorului din didiFramework (keycloak_id -> bucket + folder) +2. Fallback la uploads/{userId} daca framework indisponibil +3. Returneaza: download_url, public_url, object_key, bucket, filename, size + +### Python (shared layer) + +Fisier: `shared/minio_presigner.py` +- convert_media_url_for_llm(url) -- converteste URL-uri interne MinIO in URL-uri presemnate pentru LLM-uri externe +- parse_minio_url(url) -- parseaza formate: minio://bucket/path, /bucket/path, http://minio:9000/bucket/path + +Fisier: `shared/url_config.py` +- convert_minio_to_public_url(url) -- converteste URL-uri interne in URL-uri publice HTTP + +--- + +## Fluxul de upload (utilizator) + +``` +Utilizator uploadeaza fisier + | + v +POST /api/v3/media/upload (agent-v3, multer, max 50MB) + | + v +MediaService.uploadFile() + |-- Cere didiFramework /internal/get-bucket-info -> { bucketName: 'didi-prod', folder: 'users/{id}/{mimeFolder}' } + |-- Fallback: bucket = MINIO_BUCKET (didi-prod), prefix = uploads/{userId}/ + | + v +MinIO local: putObject('didi-prod', 'users/{id}/{mimeFolder}/{filename}', buffer) + | + v +Genereaza URL public: https://didi365.eu/api/v3/media/file/didi-prod/users/{id}/{mimeFolder}/{filename} + | + v +Returneaza: { download_url, public_url, object_key, bucket, size, content_type } +``` + +## Fluxul de inregistrare utilizator (single-bucket) + +``` +Utilizator face login prima data + | + v +GET /api/auth/me (didiFramework) + | + v +Utilizator nu exista in PG -> auto-inregistrare + | + v +createUserBucket(internetUserId, email, planId='1', planName='Free', storageLimitGb=1) + |-- (single-bucket mode) -- nu apeleaza MinIO makeBucket + |-- Logheaza initializarea + |-- Quota persistata in PG: bos_sysadmin.internet_user.storage_limit_bytes + | + v +Namespace logic users/{id}/ exista de cum primul fisier e uploadat. +``` + +## Fluxul media-preprocess (async, video/audio/imagine) + +``` +Job analiza pe URL/upload media + | + v +Dispatcher RabbitMQ -> media-preprocess queue (un singur worker per sesiune) + | + v +MediaPreprocessWorker: + |-- yt-dlp / fetch URL -> /tmp/video_{sessionId}_{ts}/source.mp4 + |-- ffmpeg extrage frame-uri uniform (max 10) -> /tmp/.../frame_%03d.jpg + |-- ffmpeg extrage audio -> /tmp/.../audio.mp3 + |-- transcript via Whisper (M17 -> Groq -> OpenAI) + |-- 2× vision call pe ACELEASI frame-uri (misinformation + ai_detection) + |-- (optional) upload frame-uri persistente -> didi-prod/users/{id}/videos/frames/ + | + v +Cache rezultatele in Redis (TTL 1h): + agent:media:{sessionId}:transcript + agent:media:{sessionId}:vision:misinformation + agent:media:{sessionId}:vision:ai_detection + agent:media:{sessionId}:merged_text + agent:media:{sessionId}:ready = "1" + | + v +Dispatch task-uri pentru techniques + ai-tampered + claims (citesc din Redis, nu reproceseaza media) +``` + +Beneficiu: 1 download + 1 ffmpeg + 1 transcript + 2 vision in loc de 3× pe fiecare component. + +--- + +## Fisiere in directorul didiStorage + +``` +init-buckets.sh -- Script initializare: creeaza 8 bucket-uri + lifecycle + versionare (136 linii) +.env.example -- Template variabile de mediu +README.md -- Documentatie (253 linii) +.gitignore -- Exclude .env, data/, config/ +``` + +Zero cod custom. Bucket-urile si politicile sunt create de init-buckets.sh la prima pornire. + +--- + +## Configurare Docker + +```yaml +# din data-layer/docker-compose.yml +staging-dataLayer-minio: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minio123 + MINIO_REGION_NAME: us-east-1 + MINIO_BROWSER: "on" + ports: + - "9000:9000" # API (expus pe host 0.0.0.0:9000) + - "9001:9001" # Console (expus pe host 0.0.0.0:9001) + volumes: + - didi-staging-minio-data:/data +``` + +Nota: Nu exista container `minio-init` in docker-compose.yml. Scriptul `init-buckets.sh` trebuie rulat manual dupa prima pornire a MinIO. + +--- + +## Variabile de mediu (conectare din alte servicii) + +Setarile actuale (productie LOCALA, valori confirmate din containerul `didi-agent-v3`): + +| Variabila | Valoare productie | Note | +|-----------|-------------------|------| +| MINIO_ENDPOINT | `staging-dataLayer-minio` | container local pe `didi-network`, path-style obligatoriu | +| MINIO_PORT | `9000` | expus si pe host (`0.0.0.0:9000`) | +| MINIO_USE_SSL | `false` | HTTP intern | +| MINIO_BUCKET | `didi-prod` | bucket fix, single-bucket arch (singurul bucket din instanta) | +| MINIO_ACCESS_KEY | `didi-prod` | full s3:* pe `didi-prod` | +| MINIO_SECRET_KEY | (in `.env`) | `627074a6...` | + +Switch rapid local <-> cluster: `backend/services/orchestration-layer/agent-v3/scripts/minio-switch.sh local|cluster` (citeste credentiale din `.cluster-credentials.env`, modifica `.env`-urile pentru agent-v3 + didiFramework, restart containere). Status curent: `local`. + +Fallback HA (cluster extern — inactiv, doar dupa `minio-switch.sh cluster`): + +| Serviciu | MINIO_ENDPOINT | MINIO_PORT | Credentiale | +|----------|---------------|------------|-------------| +| didiFramework | (VIP 10.11.10.128) | 9000 | didi-prod / `.cluster-credentials.env` | +| agent-v3 | :9000 | (inclus in endpoint) | didi-prod / `.cluster-credentials.env` | +| Python shared | :9000 | (inclus in endpoint) | didi-prod / `.cluster-credentials.env` | + +--- + +## Ce NU face + +- Containerul local nu are cod custom (doar MinIO standard + script init); productia DIDI ruleaza pe acest container local, iar cluster-ul CAI managed ramane fallback HA inactiv. +- Local este instanta singulara (fara replicare). Fallback-ul cluster are 4 noduri + erasure coding EC:2 (toleranta la 2 noduri pierdute), disponibil doar dupa `minio-switch.sh cluster`. +- Nu are encriptie at-rest dedicata pe DIDI. +- TLS intern: HTTP (fara TLS pe MinIO local). +- Nu enforce-uieste quota la nivel MinIO; quota utilizator (`storage_used_bytes` / `storage_limit_bytes`) este urmarita in PG (`bos_sysadmin.internet_user`) si verificata de didiFramework la upload. +- Nu mai face create-bucket per utilizator (single-bucket: namespace logic prin prefix). diff --git a/backend/services/data-layer/didiStorage/README.md b/backend/services/data-layer/didiStorage/README.md new file mode 100644 index 0000000..f7db550 --- /dev/null +++ b/backend/services/data-layer/didiStorage/README.md @@ -0,0 +1,253 @@ +# DIDI Storage Service 📦 + +## Super Simple Start Guide 🚀 + +### One Command - That's It! +```bash +docker compose up -d +``` + +**DONE!** Everything is automatically configured! 🎉 + +## What Just Happened? 🤔 + +When you ran that one command: +1. MinIO storage server started +2. A helper container automatically: + - Created 7 buckets for different file types + - Set up auto-deletion for old files + - Configured versioning for important data + - Created access policies + - Then exited (this is normal!) +3. Storage is now ready to use! + +## Check If It's Working ✅ + +```bash +docker ps + +# You should see: +# didi-storage (healthy) ← This is your storage server +``` + +**Note**: You might also see `didi-storage-init (Exited)` - that's the helper that set everything up. It's supposed to exit! + +## Access the Web Console 🖥️ + +1. Open your browser +2. Go to: **http://localhost:9003** +3. Login: + - Username: `minioadmin` + - Password: `minio123` +4. You'll see all your buckets ready! + +## Connection Info for Your Apps 📡 + +```python +# Python example +from minio import Minio + +client = Minio( + "localhost:9002", # API port + access_key="minioadmin", + secret_key="minio123", + secure=False +) +``` + +## The 7 Auto-Created Buckets 🗂️ + +| Bucket Name | What Goes Here | Auto-Delete After | +|------------|----------------|-------------------| +| `text-files` | Text documents, CSVs | 30 days | +| `image-files` | JPG, PNG, GIF | 60 days | +| `audio-files` | MP3, WAV, M4A | 30 days | +| `video-files` | MP4, AVI, MOV | 30 days | +| `document-files` | PDF, Word, Excel | Never | +| `pipeline-artifacts` | Analysis results | Never (versioned) | +| `backups` | System backups | Never (versioned) | + +## Quick Test - Upload a File 📤 + +```bash +# Create a test file +echo "Hello Storage!" > test.txt + +# Upload it (using docker) +docker exec didi-storage sh -c "echo 'Test' > /tmp/test.txt && mc cp /tmp/test.txt local/text-files/" + +# Check it's there +docker exec didi-storage mc ls local/text-files/ +``` + +## Common Tasks 🛠️ + +### Start Storage +```bash +docker compose up -d +# That's it! Everything auto-configures +``` + +### Stop Storage +```bash +docker compose down +# Data is preserved +``` + +### View Logs +```bash +docker compose logs -f didiStorage +``` + +### Check Storage Usage +```bash +docker exec didi-storage mc du local/ +``` + +### List All Files +```bash +docker exec didi-storage mc ls --recursive local/ +``` + +### Complete Fresh Start (WARNING: Deletes Everything!) +```bash +docker compose down -v +rm -rf data/ config/ +docker compose up -d +``` + +## What's Special About This Setup? ✨ + +### 1. **Zero Configuration** +You don't need to: +- Create buckets manually +- Set up policies +- Configure expiry rules +- Enable versioning + +It's ALL done automatically! + +### 2. **Smart File Management** +- Old files auto-delete (saves space) +- Important files keep versions (never lose data) +- Each service gets its own bucket + +### 3. **Ready for Production** +- Passwords in .env file (change them!) +- Resource limits configured +- Health checks included +- Logging configured + +## Troubleshooting 🔧 + +### "Port already in use" +Someone else is using port 9002 or 9003. Fix: +1. Edit `.env` +2. Change `MINIO_API_PORT=9004` +3. Change `MINIO_CONSOLE_PORT=9005` +4. Run `docker compose up -d` + +### "Can't access console" +1. Make sure you use `http://` not `https://` +2. Check container is running: `docker ps` +3. Try: http://localhost:9003 + +### "Buckets not created" +Check the init container logs: +```bash +docker logs didi-storage-init +``` +It should show "Initialization Complete!" + +### "Storage full" +Check usage: +```bash +docker exec didi-storage mc du local/ +``` +Files auto-delete after their expiry time! + +## For Your Services 🔌 + +### Python Upload Example +```python +from minio import Minio + +# Connect +client = Minio("localhost:9002", + access_key="minioadmin", + secret_key="minio123", + secure=False) + +# Upload image +client.fput_object("image-files", "photo.jpg", "/path/to/photo.jpg") + +# Upload with metadata +client.fput_object( + "document-files", + "report.pdf", + "/path/to/report.pdf", + metadata={"pipeline": "text-analysis", "user": "john"} +) +``` + +### Node.js Example +```javascript +const Minio = require('minio') + +const client = new Minio.Client({ + endPoint: 'localhost', + port: 9002, + useSSL: false, + accessKey: 'minioadmin', + secretKey: 'minio123' +}) + +// Upload +client.fPutObject('text-files', 'data.txt', '/path/to/data.txt') +``` + +## How DIDI Platform Uses This 📊 + +``` +User uploads file → Goes to appropriate bucket + ↓ +Pipeline processes it → Results go to pipeline-artifacts + ↓ +After 30-60 days → Media files auto-delete + ↓ +Artifacts & backups → Keep forever with versions +``` + +## Security Notes 🔒 + +**For Production:** +1. Change `minioadmin` username in .env +2. Change `minio123` password in .env +3. Use HTTPS (put behind nginx) +4. Restrict network access +5. Enable encryption + +## Part of the Data Layer 🏗️ + +``` +📁 data-layer/ + ├── 📁 didiDatabase/ ✅ PostgreSQL + ├── 📁 didiCache/ ✅ Redis + ├── 📁 didiStorage/ ✅ MinIO (You are here!) + └── 📁 didiQueue/ ⏳ RabbitMQ (Coming next!) +``` + +## Summary - Why This Rocks 🎸 + +1. **One Command**: `docker compose up -d` +2. **Zero Config**: Everything auto-setup +3. **Smart Storage**: Auto-expiry, versioning +4. **Production Ready**: Just change passwords +5. **Developer Friendly**: Web console included + +--- +**That's it! Your storage is ready! 📦** + +*No complex setup. No manual configuration. Just works!* + +*Version: 1.0.0 | MinIO RELEASE.2024-08-29* \ No newline at end of file diff --git a/backend/services/data-layer/didiStorage/init-buckets.sh b/backend/services/data-layer/didiStorage/init-buckets.sh new file mode 100644 index 0000000..d187f27 --- /dev/null +++ b/backend/services/data-layer/didiStorage/init-buckets.sh @@ -0,0 +1,137 @@ +#!/bin/sh +# ============================================================================ +# MinIO Bucket Initialization Script +# Automatically creates all required buckets and policies on startup +# ============================================================================ + +set -e + +echo "============================================" +echo "Starting MinIO Bucket Initialization" +echo "============================================" + +# Wait for MinIO to be ready +echo "→ Waiting for MinIO to be ready..." +sleep 5 + +# Configure MinIO client with credentials from environment +echo "→ Configuring MinIO client..." +mc alias set local http://${MINIO_HOST}:${MINIO_PORT} ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} + +# Create all required buckets +echo "→ Creating buckets..." +mc mb local/text-files --ignore-existing +mc mb local/image-files --ignore-existing +mc mb local/audio-files --ignore-existing +mc mb local/video-files --ignore-existing +mc mb local/document-files --ignore-existing +mc mb local/pipeline-artifacts --ignore-existing +mc mb local/uploads --ignore-existing +mc mb local/backups --ignore-existing +mc mb local/didi-prod --ignore-existing # single-bucket mode (MINIO_BUCKET=didi-prod) — media upload/download + +echo "✓ All buckets created" + +# Enable versioning for important buckets +echo "→ Enabling versioning..." +mc version enable local/pipeline-artifacts +mc version enable local/backups +echo "✓ Versioning enabled for pipeline-artifacts and backups" + +# Set lifecycle policies for temporary files +echo "→ Setting lifecycle policies..." +cat > /tmp/lifecycle-30days.json < /tmp/lifecycle-60days.json < /tmp/text-service-policy.json < /tmp/image-service-policy.json </dev/tcp/127.0.0.1/8080; echo -e 'GET /auth/realms/master HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; timeout 2 cat <&3 | grep -q '200 OK'"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + +volumes: + didi-postgres-data: + didi-redis-data: + didi-rabbitmq-data: + didi-minio-data: + +networks: + didi-network: + external: true diff --git a/backend/services/data-layer/docker-compose.yml b/backend/services/data-layer/docker-compose.yml new file mode 100644 index 0000000..6d40db5 --- /dev/null +++ b/backend/services/data-layer/docker-compose.yml @@ -0,0 +1,168 @@ +# ============================================================================= +# DIDI Platform - Data Layer Services +# ============================================================================= +# PostgreSQL, RabbitMQ, MinIO, Redis Commander, PgAdmin +# Admin UIs bound to 127.0.0.1 (VPN access only) +# ============================================================================= + +name: didi-data-layer + +services: + # =========================================================================== + # PostgreSQL - Local Staging Database + # =========================================================================== + staging-dataLayer-postgres: + image: postgres:15-alpine + container_name: staging-dataLayer-postgres + restart: unless-stopped + environment: + POSTGRES_DB: misinformation_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres123 + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - didi-staging-postgres-data:/var/lib/postgresql/data + - didi-staging-postgres-backups:/backups + # No ports exposed - accessible only within Docker network on port 5432 + networks: + - didi-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d misinformation_db"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # =========================================================================== + # RabbitMQ - Message Queue + # =========================================================================== + staging-dataLayer-rabbitmq: + image: rabbitmq:3.12.6-management-alpine + container_name: staging-dataLayer-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: rabbitmq123 + RABBITMQ_DEFAULT_VHOST: / + volumes: + - didi-staging-rabbitmq-data:/var/lib/rabbitmq + ports: + - "15672:15672" # RabbitMQ Management UI + - "127.0.0.1:5672:5672" # AMQP - localhost only + networks: + - didi-network + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # =========================================================================== + # MinIO - Object Storage + # =========================================================================== + staging-dataLayer-minio: + image: minio/minio:RELEASE.2024-08-29T01-40-52Z + container_name: staging-dataLayer-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minio123 + MINIO_REGION_NAME: us-east-1 + MINIO_BROWSER: "on" + ports: + - "9001:9001" # MinIO Console UI + - "9000:9000" + volumes: + - didi-staging-minio-data:/data + networks: + - didi-network + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # =========================================================================== + # PgAdmin - Database Administration + # =========================================================================== + staging-dataLayer-pgadmin: + image: dpage/pgadmin4:latest + container_name: staging-dataLayer-pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: "False" + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" + volumes: + - didi-staging-pgadmin-data:/var/lib/pgadmin + - ./pgadmin/servers.json:/pgadmin4/servers.json:ro + - ./pgadmin/pgpass:/pgadmin4/pgpass:ro + ports: + - "5050:80" # pgAdmin UI + networks: + - didi-network + + # =========================================================================== + # Redis Commander - Redis Administration + # =========================================================================== + staging-dataLayer-redis-commander: + image: rediscommander/redis-commander:latest + container_name: staging-dataLayer-redis-commander + restart: unless-stopped + environment: + REDIS_HOSTS: "production:didi-cache:6379:0:redis123" + URL_PREFIX: /redis-commander + # No external ports - access via Kong + networks: + - didi-network + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8081/"] + interval: 30s + timeout: 10s + retries: 3 + + # =========================================================================== + # Admin Dashboard - Infrastructure Monitoring (HTTPS) + # =========================================================================== + didi-admin: + image: didi-admin:latest + container_name: didi-admin + restart: unless-stopped + environment: + NODE_ENV: production + REACT_APP_STAGING_MODE: "false" + REACT_APP_HOST: "10.11.10.12" + REACT_APP_FRAMEWORK_API_URL: "http://didi-framework:3005" + REACT_APP_API_BASE_URL: "https://didi365.eu" + REACT_APP_KEYCLOAK_URL: "https://didi365.eu/auth" + REACT_APP_KEYCLOAK_REALM: "didi-admins" + REACT_APP_KEYCLOAK_CLIENT_ID: "admin-dashboard" + ports: + - "3000:443" + networks: + - didi-network # unified — all DIDI + AI platform on this single network + healthcheck: + test: ["CMD", "curl", "-k", "-f", "-s", "https://127.0.0.1:443/"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + didi-network: + external: true # single shared network for all DIDI + AI platform stacks + +volumes: + didi-staging-postgres-data: + external: true + didi-staging-postgres-backups: + name: didi-staging-postgres-backups + didi-staging-rabbitmq-data: + name: didi-staging-rabbitmq-data + didi-staging-minio-data: + external: true + didi-staging-pgadmin-data: + external: true diff --git a/backend/services/data-layer/pgadmin/servers.json b/backend/services/data-layer/pgadmin/servers.json new file mode 100644 index 0000000..a1806db --- /dev/null +++ b/backend/services/data-layer/pgadmin/servers.json @@ -0,0 +1,14 @@ +{ + "Servers": { + "1": { + "Name": "DIDI PostgreSQL Cluster", + "Group": "Production", + "Host": "10.11.50.167", + "Port": 5000, + "MaintenanceDB": "DIDI", + "Username": "bos_interface", + "SSLMode": "prefer", + "PassFile": "/pgadmin4/pgpass" + } + } +} diff --git a/backend/services/gateway-auth-layer/README.md b/backend/services/gateway-auth-layer/README.md new file mode 100644 index 0000000..4ddf8e5 --- /dev/null +++ b/backend/services/gateway-auth-layer/README.md @@ -0,0 +1,263 @@ +# Gateway & Auth Layer - DIDI Backend 🔐 + +## Quick Start 🚀 + +```bash +# Recommended: Start via unified deployment manager +./deploy/didi.sh staging start +``` + +**Staging URLs:** +| Service | URL | +|---------|-----| +| Kong Gateway | http://localhost:18100 | +| Kong Admin API | http://localhost:18101 | +| Kong Manager UI | http://localhost:18102 | +| Keycloak | http://localhost:18280 | + +## Overview 🔍 + +The Gateway & Auth Layer provides API management and authentication services: + +### Services + +| Service | Container Name | Staging Ports | Purpose | +|---------|---------------|---------------|---------| +| **didiKong** | `staging-gatewayAuthLayer-kong` | 18100, 18101, 18102 | API Gateway, routing, rate limiting | +| **didiKeycloak** | `staging-gatewayAuthLayer-keycloak` | 18280 | Identity provider, SSO, OAuth2/OIDC | + +## Architecture 🏗️ + +``` +┌─────────────────────────────────────────────────────────┐ +│ Gateway & Auth Layer │ +├───────────────────────┬─────────────────────────────────┤ +│ didiKong │ didiKeycloak │ +│ │ │ +│ • API Gateway │ • Identity Provider │ +│ • Route Management │ • User Management │ +│ • Rate Limiting │ • OAuth2/OIDC │ +│ • CORS Handling │ • Custom Theme │ +│ • Load Balancing │ • Realm Import │ +│ │ │ +│ Ports: 18100-18102 │ Port: 18280 │ +└───────────────────────┴─────────────────────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────┐ + │ Protected Services │ + │ • Orchestrator API (port 18000) │ + │ • Analysis Service (port 18004) │ + │ • Admin Dashboard (port 13003) │ + └──────────────────────────────────────┘ +``` + +## Service Communication Flow 📬 + +``` +Client Request → Kong Gateway (18100) → Route Rules → Backend Service + ↓ + Rate Limiting + ↓ + CORS Headers + ↓ + (Optional) Keycloak Auth +``` + +## Kong Configuration 🛠️ + +### DB-less Mode +Kong runs in declarative (DB-less) mode with configuration in `didiKong/declarative/kong.yml` + +### Configured Routes (Staging) +- `/api/v1/catalog/*` → Orchestrator (18000) +- `/api/v1/pipelines/*` → Orchestrator (18000) +- `/api/v1/runs/*` → Orchestrator (18000) +- `/orchestrator/health` → Orchestrator health check +- `/analysis/health` → Analysis service health check +- `/didiai/*` → AI Gateway (via DIDIAI_GATEWAY_URL) +- `/admin/*` → Admin Dashboard (13003) + +### Enabled Plugins +- **CORS**: Cross-origin resource sharing +- **Rate Limiting**: 100/min, 2000/hr, 10000/day +- **Request ID**: UUID tracking with X-Request-ID +- **Size Limiting**: 100MB max for media files +- **Response Transform**: Add gateway headers + +## Keycloak Configuration 🔑 + +### Admin Access +- **URL**: http://localhost:8280 (standalone) or http://localhost:18280 (staging) +- **Username**: admin +- **Password**: keycloak123 + +### Imported Realm +- **Realm Name**: misinformation +- **Theme**: misinformation-theme (custom) +- **Location**: `didiKeycloak/realm-import/misinformation-realm.json` + +### Pre-configured Elements +- Client applications +- User roles and groups +- Authentication flows +- Custom login theme + +## Quick Commands 🎯 + +```bash +# Service Management +make up # Start both services +make down # Stop both services +make restart # Restart both services +make status # Check service status +make logs # View logs for both services + +# Individual Service Control +make up-kong # Start only Kong +make up-keycloak # Start only Keycloak +make logs-kong # View Kong logs +make logs-keycloak # View Keycloak logs + +# Kong Management +make kong-reload # Reload Kong configuration +make kong-validate # Validate kong.yml syntax + +# Keycloak Management +make keycloak-export # Export current realm configuration + +# Maintenance +make clean # Remove containers and volumes +make rebuild # Rebuild all images +make health # Health check both services +``` + +## Environment Configuration 🔐 + +### Kong Environment +```env +KONG_DATABASE=off # DB-less mode +KONG_DECLARATIVE_CONFIG=/kong/declarative/kong.yml +KONG_PROXY_LISTEN=0.0.0.0:8000 +KONG_ADMIN_LISTEN=0.0.0.0:8001 +``` + +### Keycloak Environment +```env +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=keycloak123 +KC_DB_URL=jdbc:postgresql://dataLayer-postgres:5432/keycloak_db +KC_DB_USERNAME=postgres +KC_DB_PASSWORD=postgres123 +``` + +## Testing the Gateway 🧪 + +### Test Kong Gateway (Staging) +```bash +# Check Kong status +curl http://localhost:18101/status + +# Test orchestrator route through Kong +curl http://localhost:18100/orchestrator/health + +# Test analysis route through Kong +curl http://localhost:18100/analysis/health +``` + +### Test Keycloak (Staging) +```bash +# Check Keycloak health +curl http://localhost:18280/health/ready + +# Access Keycloak admin console +open http://localhost:18280 +``` + +## Troubleshooting 🔧 + +### Kong won't start? +```bash +# Check configuration validity +make kong-validate + +# Check logs +make logs-kong + +# Verify declarative config exists +ls -la didiKong/declarative/kong.yml +``` + +### Keycloak won't start? +```bash +# Check if database exists +docker exec dataLayer-postgres psql -U postgres -c "\l" | grep keycloak_db + +# Create database if missing +make init-db + +# Check logs +make logs-keycloak +``` + +### Services can't connect? +```bash +# Verify network exists +docker network ls | grep didi-backend + +# Check all services are on same network +docker inspect gatewayAuthLayer-kong | grep NetworkMode +``` + +## Security Considerations 🛡️ + +1. **Change default passwords** in production +2. **Enable HTTPS** for all services +3. **Configure proper CORS origins** (not wildcard) +4. **Set up proper rate limiting** per consumer +5. **Enable authentication** on sensitive routes +6. **Use secrets management** for credentials + +## Integration with Other Layers 🔗 + +### Prerequisites +- Data Layer must be running (PostgreSQL for Keycloak) +- Orchestration Layer services for API routing +- Network `didi-backend` must exist + +### Downstream Services +- UI Layer will use Kong Gateway for API access +- All services can integrate with Keycloak for SSO + +## Development 🛠️ + +### Access Service Shells +```bash +make shell-kong # Kong shell +make shell-keycloak # Keycloak shell +``` + +### Modify Kong Routes +1. Edit `didiKong/declarative/kong.yml` +2. Validate: `make kong-validate` +3. Reload: `make kong-reload` + +### Export Keycloak Configuration +```bash +make keycloak-export +# Exported to didiKeycloak/realm-export/ +``` + +## Next Steps 📋 + +1. Configure Keycloak clients for each service +2. Set up Kong OAuth2 plugin with Keycloak +3. Add service-specific rate limiting +4. Configure monitoring and alerting +5. Set up SSL/TLS termination + +--- + +**Version**: 1.0.0 +**Network**: `didi-backend` +**Project**: `didiBackend` \ No newline at end of file diff --git a/backend/services/gateway-auth-layer/didiKeycloak/INDEX.md b/backend/services/gateway-auth-layer/didiKeycloak/INDEX.md new file mode 100644 index 0000000..8281c7f --- /dev/null +++ b/backend/services/gateway-auth-layer/didiKeycloak/INDEX.md @@ -0,0 +1,362 @@ +# didiKeycloak - Index + +> **Deployment LOCAL (activ)**: Keycloak ruleaza ca un singur container `didi-keycloak` pe masina de deployment. Nu exista cluster SSO / Swarm. +> +> - Imagine: `quay.io/keycloak/keycloak:26.0`, pornit cu `start-dev --import-realm`. +> - Port: `28080` (host) -> `8080` (container), servit sub calea relativa `/auth` (`KC_HTTP_RELATIVE_PATH=/auth`). +> - `KC_HOSTNAME_STRICT=false`, `KC_PROXY_HEADERS=xforwarded` — hostname derivat din headerele proxy-ului din fata. +> - **Doua realm-uri** importate din `realm-import/`: `didi-clients` (useri finali) + `didi-admins` (operatori: admin / moderator / senior_moderator). +> - Temele custom sunt bind-mount-uite din folderul acesta in `/opt/keycloak/themes/`. +> - Master credentials: `admin/admin123` (`KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD`). + +Serviciul de autentificare si autorizare al platformei DIDI. Bazat pe Keycloak, gestioneaza utilizatori, roluri, grupuri, clienti OAuth2 si token-uri JWT. Include teme custom de login si template-uri email in romana. + +**Imagine**: quay.io/keycloak/keycloak:26.0 (container local `didi-keycloak`) +**Container**: didi-keycloak (activ, pe masina de deployment) +**Port**: 28080 (host) -> 8080 (container), sub `/auth` +**Realm-uri**: `didi-clients` (useri) + `didi-admins` (operatori) +**Baza de date**: PostgreSQL `didi-postgres:5432/DIDI` (`KC_DB=postgres`, user `bos_interface`) + +--- + +## Ce face + +1. **Autentificare OAuth2/OIDC** -- login, logout, refresh token, SSO +2. **Management utilizatori** -- creare, roluri, grupuri, tier-uri +3. **Emitere token-uri JWT** -- access token (10 min), refresh token, SSO session (2h) +4. **Validare JWT** -- Kong valideaza token-urile emise de Keycloak +5. **Securitate cont** -- brute force (lockout dupa 5 incercari esuate), MFA TOTP, password policy +6. **Deep linking mobil** -- redirectare catre app mobila dupa verificare email +7. **Teme custom** -- login page dark purple, emailuri in romana + +--- + +## Structura fisierelor + +``` +realm-import/ + didi-clients-realm.json -- Configurare completa realm (clienti, roluri, grupuri, utilizatori) +themes/ + didi-clients-theme/ -- Tema principala (dark purple) + login/ + theme.properties -- Configurare tema login + register.ftl -- Formular inregistrare + login-reset-password.ftl -- Resetare parola + login-verify-email.ftl -- Pagina verificare email + info.ftl -- Routing mobil/web dupa actiuni + register-commons.ftl -- Macro acceptare termeni + messages/ + messages_en.properties -- Etichete UI engleza + resources/ + css/login.css -- Stil dark purple (784 linii) + js/placeholders.js -- Placeholders formulare + email/ + theme.properties -- Configurare tema email + html/ + email-verification.ftl -- Template verificare email (romana, dark theme) + executeActions.ftl -- Template actiuni (dark purple gradient) + text/ + email-verification.ftl -- Versiune text plain + didi-ai-theme/ -- Tema alternativa (white, blue accents) + login/ + theme.properties + resources/ + css/login.css + img/logo.png + didi-backend-theme/ -- Tema backend (white, "didi - Backend") + login/ + theme.properties + resources/ + css/login.css + img/logo.png +``` + +Zero cod custom backend. Doar configurare realm JSON + teme FreeMarker/CSS. + +--- + +## Clienti OAuth2 (4) + +| Client ID | Tip | Scop | Flow-uri | PKCE | +|-----------|-----|------|----------|------| +| didi-web-app | Public | Frontend web utilizatori | Standard + Direct Access | nu | +| admin-dashboard | Public | Dashboard admin React | Standard + Direct Access | S256 | +| orchestrator-api | Confidential | Serviciu backend orchestrator | Direct Access + Service Account | nu | +| kong-api-gateway | Bearer Only | Gateway JWT validation | Service Account only | nu | + +### didi-web-app +- Redirect URIs: localhost:3001, localhost:5173, localhost:13001, localhost:33001 (+ 127.0.0.1) +- Web Origins: aceleasi + wildcard +- Scopes: web-origins, acr, profile, roles, email + +### admin-dashboard +- Root URL: http://localhost:13003 +- PKCE: S256 (obligatoriu) +- Redirect URIs: localhost:13003, localhost:3003, localhost:33003, localhost:33001, localhost:3001, 127.0.0.1:13003, 127.0.0.1:3003, 127.0.0.1:33003, 127.0.0.1:33001, 10.11.50.11:33003, 10.11.50.11:3003 +- Post Logout: localhost:13003, localhost:3003, localhost:33003, 10.11.50.11:33003 + +### orchestrator-api +- Secret: nmmImrmPAcADuPh-ZTqLY7GDhCAjfXsolDOM6TxZbHg +- Service Account: activat +- Bearer Only: implicit (confidential) + +### kong-api-gateway +- Secret: Fu1rJ8QsjCj4j4_qZiMXyx6Ewo3xC2ik7X5m_MvSLOE +- Bearer Only: da (nu face login, doar valideaza) +- Service Account: activat + +--- + +## Roluri (doua realm-uri) + +Operatorii (admin / moderator / senior_moderator) traiesc in realm-ul **`didi-admins`**; realm-ul **`didi-clients`** contine doar capabilitati de user si tier-uri de abonament. + +### Realm `didi-admins` (operatori) + +| Rol | Scop | +|-----|------| +| admin | Acces complet la platforma + admin dashboard | +| moderator | HIL moderator -- poate revendica si rezolva intrari din coada (admin dashboard /moderation) | +| senior_moderator | Senior HIL moderator -- poate escalada si forta gold atom in brain | + +### Realm `didi-clients` (useri finali) + +| Rol | Scop | +|-----|------| +| viewer | Poate vizualiza rezultate analize | +| analyst | Poate crea si gestiona analize | +| api_user | Poate accesa endpoint-uri API | +| free_tier | Privilegii tier gratuit | +| paid_tier | Privilegii tier platit | +| enterprise_tier | Privilegii tier enterprise | + +Roluri implicite la inregistrare (didi-clients): viewer + free_tier + +--- + +## Grupuri (6) + +| Grup | Roluri | Tier | Limita zilnica | Rate limit | +|------|--------|------|----------------|------------| +| free-users | free_tier, viewer, api_user | free | 10 | 10/min | +| paid-users | paid_tier, viewer, analyst, api_user | paid | 100 | 60/min | +| enterprise-users | enterprise_tier, viewer, analyst, api_user | enterprise | nelimitat | 600/min | +| administrators | admin, analyst, viewer, api_user, enterprise_tier | admin | nelimitat | nelimitat | + +| Grup | Roluri | Scop | +|------|--------|------| +| moderators-team | moderator | HIL review staff | +| senior-moderators-team | moderator + senior_moderator | Lead moderators with brain gold-promotion authority | + +Atributele de grup (tier, daily_limit, rate_limit) sunt disponibile in token-ul JWT si pot fi folosite de Kong/backend pentru rate limiting. + +--- + +## Acces admin dashboard + +| Pagina admin dashboard | viewer / paid_tier / etc | moderator | senior_moderator | admin | +|---|---|---|---|---| +| /admin/* (any) | 403 (Unauthorized page -> public app) | Dashboard + History + Moderation | same + force_gold_brain | tot | +| /users, /framework, /llm-components, /providers | nu | nu | nu | da | +| /history | nu | da | da | da | +| /moderation/* | nu | da | da | da | + +Note: `viewer` este rolul implicit asignat la toate signup-urile (`defaultRoles: [viewer, free_tier]`). End-userii (clientii) primesc acest rol; ei NU vad niciodata admin dashboard. + +--- + +## Utilizatori pre-configurati (5) + +| Email | Parola | Grup | Rol principal | +|-------|--------|------|---------------| +| admin@didi.local | admin123 | administrators | admin | +| demo@didi.local | Demo123! | free-users | viewer | +| free@didi.local | password123 | free-users | free_tier | +| paid@didi.local | password123 | paid-users | paid_tier | +| enterprise@didi.local | password123 | enterprise-users | enterprise_tier | + +Toti au emailVerified: true. Parolele nu sunt temporare. + +--- + +## Setari token + +| Parametru | Valoare | +|-----------|---------| +| Access Token Lifespan | 600s (10 minute) | +| Access Token Implicit | 900s (15 minute) | +| SSO Session Idle | 7200s (2 ore) | +| SSO Session Max | 86400s (24 ore) | +| Algoritm semnatura | RS256 | + +--- + +## Securitate + +Aplicata pe **ambele realm-uri** (`didi-clients` + `didi-admins`). + +### Brute force protection +- Activat (`bruteForceProtected: true`) +- Max incercari esuate: 5 (`failureFactor`) +- Timp asteptare: 60s (increment) / min quick-login wait 60s / quick-login check 1000ms +- Max wait: 900s (15 minute) +- Fereastra glisanta: 43200s (12 ore) +- Lockout permanent: dezactivat + +### MFA / TOTP (livrabil Lot 2) +- Politica OTP: `otpPolicyType=totp` (HmacSHA1, 6 cifre, perioada 30s) — pe ambele realm-uri. +- Required action `CONFIGURE_TOTP` **enabled** pe realm-ul `didi-admins` (operatorii sunt fortati sa configureze TOTP; userii noi de admin primesc `CONFIGURE_TOTP` in `requiredActions` la prima logare, alaturi de `UPDATE_PASSWORD`). +- Realm-ul `didi-clients` are politica TOTP configurata (MFA disponibil pentru enrolment). + +### Password policy (ambele realm-uri) +``` +length(10) and digits(1) and upperCase(1) and lowerCase(1) and notUsername and passwordHistory(3) +``` +Minim 10 caractere, cel putin o cifra, o majuscula, o minuscula, parola != username, fara reutilizarea ultimelor 3 parole. + +### Setari realm +- Inregistrare: dezactivata (registrationAllowed: false) +- Login cu email: da +- Email ca username: da +- Verificare email: dezactivata (verifyEmail: false) +- Editare username: nu +- Emailuri duplicate: nu +- Remember me: da +- Reset parola: da + +--- + +## Teme + +### didi-clients-theme (principala, dark purple) +- Background: #050510 (foarte inchis) +- Accent: #A855F7 -> #7C3AED -> #6D28D9 (gradient purple) +- Card: glassmorphism (backdrop blur, border semi-transparent) +- Logo: "didi" (48px, font Outfit) +- Subtitle: "Misinformation Detection Platform" +- Font: Outfit (display) + Inter (body) +- Butoane: gradient purple cu glow la hover +- Responsive: suporta mobile (100dvh) + +### didi-ai-theme (alternativa) +- Background: alb +- Accent: #0052CC (albastru) +- Subtitle: "didi - AI Platform" + +### didi-backend-theme (alternativa) +- Background: alb +- Accent: #0052CC (albastru) +- Subtitle: "didi - Backend" + +--- + +## Template-uri email + +### email-verification.ftl +- Limba: romana +- Titlu: "Verifica adresa de email" +- Stil: dark purple gradient header +- URL custom: https://didi365.eu/api/auth/verify-email?key=... +- Afiseaza timpul de expirare (convertit din secunde) +- Deep link mobil: didi://email-verified, com.didi365.app://email-verified + +### executeActions.ftl +- Stil: dark purple gradient +- Suporta actiuni multiple +- Deep linking mobil + +### info.ftl (routing dupa actiuni) +- Detecteaza client ID (didi-mobile-app vs didi-web-app) +- Mobile: deep link cu fallback dupa 1.5-3s +- Web: redirect la /email-verified dupa 2s +- Butoane: "Deschide in aplicatie" / "Continua in browser" + +--- + +## Fluxul de autentificare + +``` +Utilizator deschide aplicatia + | + v +Redirect la Keycloak login (tema didi-clients-theme) + | + v +Utilizatorul introduce email + parola + | + v +Keycloak valideaza + emite JWT (access token 10 min, refresh token) + | + v +Redirect inapoi la aplicatie cu authorization code + | + v +Aplicatia schimba codul in token-uri (PKCE pentru admin-dashboard) + | + v +Requesturi API cu Authorization: Bearer {access_token} + | + v +Kong valideaza JWT-ul (plugin jwt, consumer didi-keycloak-users, RS256, match pe iss) + | + v +Backend-ul decodeaza JWT pentru user_id/email (fara re-validare) + | + v +La fiecare 30s, aplicatia face refresh token daca expira in < 70s +``` + +--- + +## Cum comunica cu restul platformei + +| Cine | Ce face | Cum | +|------|---------|-----| +| admin-dashboard | Login/logout utilizator | OAuth2 Standard Flow + PKCE | +| didi-web-app (frontend) | Login/logout utilizator | OAuth2 Standard Flow | +| Kong | Valideaza JWT pe fiecare request (RS256, match pe iss) | plugin jwt + consumer didi-keycloak-users | +| didiFramework (auth.ts) | Auto-inregistrare utilizator, Keycloak Admin API | Direct Access + Admin credentials | +| didiFramework (admin.ts) | Lista utilizatori, update emailVerified | Keycloak Admin API | +| agent-v3 | Decodeaza JWT din header (sub, email) | Doar decodare, fara validare (Kong a validat deja) | + +--- + +## Admin API folosit de automatizari + +- Admin API base: `http://localhost:28080/auth/admin/realms/{didi-clients|didi-admins}/` (Keycloak local, sub `/auth`) +- Master token via `POST /auth/realms/master/protocol/openid-connect/token` cu `client_id=admin-cli, username=admin, password=admin123` +- Folosit de fluxul de auto-inregistrare didiFramework + scripturi viitoare de automatizare. + +--- + +## Roluri JWT in token-urile clientilor + +Token-ul JWT contine acum array-ul `realm_access.roles`, parsat de agent-v3 (`req.jwtRoles`) pentru verificarile de rol pe endpoint-urile de moderare. Token-ul se reimprospateaza automat la fiecare 30s (comportament existent). + +--- + +## Baza de date + +Keycloak foloseste PostgreSQL local, aceeasi instanta ca restul platformei: +- `KC_DB=postgres` +- `KC_DB_URL=jdbc:postgresql://didi-postgres:5432/DIDI` (schema `public`) +- User: `bos_interface` +- Schema proprie Keycloak (gestionata automat) + +Datele stocate: realm config, utilizatori, sesiuni, events, client sessions. + +--- + +## Audit si evenimente + +- Evenimente utilizator: activate (jboss-logging) +- Evenimente admin: activate cu detalii +- Logare: in stdout Docker (accesibil prin docker logs) + +## Recent Changes + +- **MFA / TOTP (livrabil Lot 2)**: `otpPolicyType=totp` pe ambele realm-uri; required action `CONFIGURE_TOTP` enabled pe `didi-admins` (operatorii sunt fortati sa configureze TOTP la prima logare, alaturi de `UPDATE_PASSWORD`). +- **Password policy** pe ambele realm-uri: `length(10) and digits(1) and upperCase(1) and lowerCase(1) and notUsername and passwordHistory(3)`. +- **Realm `didi-admins` (operatori)**: 3 roluri `admin` / `moderator` / `senior_moderator`; clienti publici `admin-dashboard` + `ai-platform-dashboard`; useri de test `moderator.test@didi.local`, `senior.moderator.test@didi.local`. +- **Realm `didi-clients` (useri finali)**: capabilitati `viewer`, `analyst`, `api_user` + tier-uri `free_tier`, `paid_tier`, `enterprise_tier`; clienti `didi-web-app`, `admin-dashboard`, `orchestrator-api`, `kong-api-gateway`. +- **Deployment local**: container unic `didi-keycloak` (`quay.io/keycloak/keycloak:26.0`, `start-dev --import-realm`), port `28080` sub `/auth`, `KC_HOSTNAME_STRICT=false`, `KC_PROXY_HEADERS=xforwarded`, DB `didi-postgres:5432/DIDI`. Fara cluster SSO / Swarm / Infinispan. diff --git a/backend/services/gateway-auth-layer/didiKeycloak/MIGRATION.md b/backend/services/gateway-auth-layer/didiKeycloak/MIGRATION.md new file mode 100644 index 0000000..b49ce13 --- /dev/null +++ b/backend/services/gateway-auth-layer/didiKeycloak/MIGRATION.md @@ -0,0 +1,203 @@ +# Keycloak — migrat pe SSO cluster (2026-04-30) + +> **TL;DR**: containerul local `keycloak` (Keycloak 22) nu mai rulează. DIDI folosește acum **SSO cluster** la `https://` (Keycloak 26 HA, 3 replicas pe Dev Docker Swarm). Realm `didi-clients` migrat cu toate datele (users, clients, groups). Theme custom `didi-clients-theme` deployed pe SSO via bind mount pe nodurile Swarm. + +--- + +## Ce era aici (înainte de 2026-04-30) + +Container `keycloak` (Keycloak 22, image `quay.io/keycloak/keycloak:22.0`) definit în `backend/production/docker-compose.yml`. Single-instance pe didi12 (10.11.10.12:28000). DB pe Patroni cluster (`keycloak_db`). Hostname fix `KC_HOSTNAME_URL=https://didi365.eu/auth`. + +Folosit doar de DIDI. Theme custom `didi-clients-theme` (purple gradient). + +## De ce migrare + +1. **Single-tenant lock-in**: Keycloak local servea doar didi365.eu. Pentru alte produse (lege365, rafai, etc.) ar fi trebuit instanțe separate sau hostname dinamic complex. +2. **Single-point-of-failure**: 1 container, 1 host. Down când didi12 down. +3. **DB password issue**: 2026-04-29 cineva a rotat parola `keycloak` user în Patroni → connection pool fail → service degraded. +4. **SSO cluster live**: 2026-04-29 Lucian a deploy-uit Keycloak 26 HA pe Dev Swarm, cu hostname public ``. + +## Ce e acum + +### SSO Cluster + +| Componentă | Detaliu | +|---|---| +| Hostname public | `` (DNS public, cert Let's Encrypt valid) | +| Hostname intern | `` (admin URL via `KC_HOSTNAME_ADMIN`) | +| IP public | `82.79.147.181` (port-forward la Traefik intern) | +| Edge router | Traefik central (`10.11.10.171:443`) | +| Keycloak version | 26.0 (`quay.io/keycloak/keycloak:26.0`) | +| HA | 3 replicas, max 1 per node | +| Cluster | Dev Docker Swarm (`10.11.50.151-154`) | +| DB | Patroni cluster (`10.11.50.166:5000/keycloak_db`) | +| Cache | ispn (Infinispan, dns.query=tasks.keycloak) | +| Stack name | `keycloak-cluster` (`docker service ls`) | + +### Hostname configuration + +```yaml +KC_HOSTNAME: https:// # public URL (used in tokens, redirects) +KC_HOSTNAME_ADMIN: https:// # admin endpoints (internal-only via 307 redirect) +KC_HOSTNAME_STRICT_BACKCHANNEL: false +KC_PROXY_HEADERS: xforwarded +``` + +Issuer in tokens: `https:///realms/didi-clients`. Endpoints (no `/auth/` prefix in K26): +- `/realms/didi-clients/.well-known/openid-configuration` +- `/realms/didi-clients/protocol/openid-connect/auth` +- `/realms/didi-clients/protocol/openid-connect/token` +- `/realms/didi-clients/protocol/openid-connect/certs` (JWKS) + +## Realm-uri pe SSO + +- `master` — admin Keycloak (NU folosi pentru apps) +- **`didi-clients`** — DIDI customer-facing app (migrat 1:1 din local) +- `didi-admins` — DIDI admin panel (creat de Lucian, neutilizat încă) + +## Theme deployment (didi-clients-theme) + +Themes sunt mounted ca **bind mount** pe fiecare nod Swarm: + +```yaml +mount: + type: bind + source: /var/keycloak-themes/didi-clients-theme + target: /opt/keycloak/themes/didi-clients-theme + readonly: true +``` + +Adăugat via `docker service update --mount-add` (nu via stack file). Pentru ca toate 3 replicas să găsească tema, fișierele trebuie pe **toate 4 nodurile** Swarm (10.11.50.151-154). + +### Procedură deploy theme update + +1. Pack theme local pe didi12: + ```bash + cd backend/services/gateway-auth-layer/didiKeycloak/themes + tar -czf /home/admin365/didi-clients-theme.tar.gz didi-clients-theme/ + ``` + +2. Pe `dev-docker-mgr` (Swarm manager — 10.11.50.151): + ```bash + scp admin365@10.11.10.12:/home/admin365/didi-clients-theme.tar.gz /tmp/ + sudo tar -xzf /tmp/didi-clients-theme.tar.gz -C /var/keycloak-themes/ + for n in 152 153 154; do + scp /tmp/didi-clients-theme.tar.gz admin365@10.11.50.$n:/tmp/ + ssh -t admin365@10.11.50.$n 'sudo tar -xzf /tmp/didi-clients-theme.tar.gz -C /var/keycloak-themes/' + done + sudo docker service update --force keycloak-cluster_keycloak + ``` + +3. Verify: + ```bash + curl -ksm 5 https:///resources//login/didi-clients-theme/css/login.css | head + ``` + +### Theme structure (PatternFly v4 specific) + +Keycloak 26 default theme (`keycloak`) folosește PatternFly v4 markup. Custom theme cu `parent=keycloak` moștenește template-urile, dar PF4 are reguli CSS specifice care necesită overrides în login.css: + +- **Password input wrap** — în `
` cu eye-icon button. Necesită CSS specific pentru `.pf-c-input-group .pf-c-form-control` +- **Pseudo-element `::after` pe button** — PF4 button-uri au ` + +

+
+ + + + + `); +}); + +/** + * POST /api/auth/verify-email + * Actually verifies the email - called when user clicks the button + */ +router.post('/verify-email', async (req: Request, res: Response) => { + const token = req.body.key as string; + + log.info('[AUTH] verify-email POST (actual verification):', token ? `${token.substring(0, 50)}...` : 'none'); + + if (!token) { + return res.status(400).send(` + + + Eroare verificare + +

❌ Link invalid

+

Link-ul de verificare este invalid sau expirat.

+

Înapoi la aplicație

+ + + `); + } + + try { + // 1. Decode the action token to get user info + const tokenPayload = decodeActionToken(token); + + if (!tokenPayload || !tokenPayload.sub) { + log.error('[AUTH] Invalid token payload:', tokenPayload); + return res.status(400).send(` + + + Eroare verificare + +

❌ Token invalid

+

Token-ul de verificare nu poate fi procesat.

+

Înapoi la aplicație

+ + + `); + } + + const userId = tokenPayload.sub; + const clientId = tokenPayload.azp; // Client that initiated the action (didi-mobile-app, didi-web-app) + + log.info(`[AUTH] Verifying email for user: ${userId}, client: ${clientId}`); + + // 2. Get Keycloak admin token + const adminToken = await getKeycloakAdminToken(); + + if (!adminToken) { + log.error('[AUTH] Failed to get admin token'); + return res.status(500).send(` + + + Eroare server + +

❌ Eroare internă

+

Nu s-a putut conecta la serverul de autentificare.

+

Te rugăm să încerci din nou mai târziu.

+ + + `); + } + + // 3. Verify email via Keycloak Admin API + const verifyResponse = await fetch( + `${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${userId}`, + { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${adminToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + emailVerified: true, + requiredActions: [] // Clear VERIFY_EMAIL required action + }) + } + ); + + if (!verifyResponse.ok) { + const errorText = await verifyResponse.text(); + log.error('[AUTH] Failed to verify email:', verifyResponse.status, errorText); + return res.status(500).send(` + + + Eroare verificare + +

❌ Verificare eșuată

+

Nu s-a putut verifica adresa de email.

+

Eroare: ${verifyResponse.status}

+

Înapoi la aplicație

+ + + `); + } + + log.info(`[AUTH] Email verified successfully for user: ${userId}`); + + // 4. Determine redirect based on client or user-agent + const userAgent = req.headers['user-agent']; + const isMobile = isMobileRequest(userAgent) || clientId === 'didi-mobile-app'; + + // Show success page with redirect options + const mobileLink = `${MOBILE_APP_SCHEME}email-verified`; + const webLink = `${WEB_APP_URL}/?verified=true`; + + // Auto-redirect based on platform + const autoRedirectUrl = isMobile ? mobileLink : webLink; + + return res.send(` + + + + + + Email verificat! + + + +
+
+

Email verificat!

+

Contul tău DIDI a fost activat cu succes.

+

Vei fi redirecționat în aplicația DIDI

+
+ + + + + `); + + } catch (error: any) { + log.error('[AUTH] Error in verify-email:', error); + return res.status(500).send(` + + + Eroare + +

❌ Eroare neașteptată

+

${error.message || 'A apărut o eroare la verificarea email-ului.'}

+

Înapoi la aplicație

+ + + `); + } +}); +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/auth/index.ts b/backend/services/orchestration-layer/didiFramework/src/routes/auth/index.ts new file mode 100644 index 0000000..66047c9 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/auth/index.ts @@ -0,0 +1,23 @@ +/** + * Auth route barrel. + * + * Original 1298-line auth.ts split (this PR) into: + * _helpers.ts — JWT decode, Keycloak token, credit cost lookup, types + * me-profile.ts — GET /me + PUT /profile + * registration.ts — POST /register + * credits.ts — /credits, /use-credit, /internal/* (agent-v3 calls) + * email-verify.ts — GET + POST /verify-email + */ +import { Router } from 'express'; +import meProfileRouter from './me-profile'; +import registrationRouter from './registration'; +import creditsRouter from './credits'; +import emailVerifyRouter from './email-verify'; + +const router = Router(); +router.use(meProfileRouter); +router.use(registrationRouter); +router.use(creditsRouter); +router.use(emailVerifyRouter); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/auth/me-profile.ts b/backend/services/orchestration-layer/didiFramework/src/routes/auth/me-profile.ts new file mode 100644 index 0000000..33c8f79 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/auth/me-profile.ts @@ -0,0 +1,327 @@ +/** + * Auth: /me + /profile — read + update user profile. + */ +import { Router, Request, Response } from 'express'; +import { createUserBucket } from '../../config/minio'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import pool from '../../config/database'; +import { + type UserProfile, + extractJWTPayload, + getNextId, +} from './_helpers'; + +const router = Router(); + + +/** + * GET /api/auth/me + * Returns user profile from bos_* tables based on keycloak_id in JWT + * AUTO-CREATES user in PostgreSQL if they exist in Keycloak but not in DB (Free tier, 100 credits) + */ +router.get('/me', async (req: Request, res: Response) => { + log.info('[AUTH] /me called'); + const client = await pool.connect(); + + try { + const jwtPayload = extractJWTPayload(req.headers.authorization); + log.info('[AUTH] JWT payload:', jwtPayload?.email, jwtPayload?.sub); + + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ + success: false, + error: 'Missing or invalid Authorization header' + }); + } + + const keycloakId = jwtPayload.sub; + const email = jwtPayload.email; + + // Query user profile + let result = await client.query(` + SELECT + uc.internet_user_id, + uc.email, + uc.keycloak_id, + uc.cellular_phone_no as phone, + uc.subscription_status, + uc.activation_date, + iu.person_id, + iu.credits_remained, + iu.credits_spent, + pf.nume as last_name, + pf.prenume as first_name, + s.subscription_plan_id, + s.is_active, + sp.plan_name, + sp.credits_per_cycle + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id + LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id + WHERE uc.keycloak_id = $1 + `, [keycloakId]); + + // AUTO-RECONNECT: User exists in PG under a different keycloak_id (e.g. Keycloak + // realm was reset or user was deleted + recreated). Same email, new Keycloak ID. + // Update the keycloak_id in place — keeps credits, subscription, bucket, history. + if (result.rows.length === 0 && email) { + const emailMatch = await client.query( + `SELECT internet_user_id, keycloak_id FROM bos_sysadmin.user_credential + WHERE email = $1 AND "next$internet_user_id" IS NULL LIMIT 1`, + [email] + ); + if (emailMatch.rows.length > 0) { + const oldKeycloakId = emailMatch.rows[0].keycloak_id; + log.info(`[AUTH] Reconnecting existing user ${email}: keycloak_id ${oldKeycloakId} → ${keycloakId}`); + await client.query( + `UPDATE bos_sysadmin.user_credential SET keycloak_id = $1 + WHERE email = $2 AND "next$internet_user_id" IS NULL`, + [keycloakId, email] + ); + // Re-run the main query — now finds the user + result = await client.query(` + SELECT + uc.internet_user_id, uc.email, uc.keycloak_id, + uc.cellular_phone_no as phone, uc.subscription_status, uc.activation_date, + iu.person_id, iu.credits_remained, iu.credits_spent, + pf.nume as last_name, pf.prenume as first_name, + s.subscription_plan_id, s.is_active, + sp.plan_name, sp.credits_per_cycle + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id + LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id + WHERE uc.keycloak_id = $1 + `, [keycloakId]); + } + } + + // AUTO-REGISTER: If user not in PostgreSQL but authenticated via Keycloak, create them + if (result.rows.length === 0) { + log.info('[AUTH] User not found in PostgreSQL, auto-registering:', email); + log.info(`[AUTH] Auto-registering Keycloak user: ${email} (${keycloakId})`); + + const firstName = jwtPayload.given_name || ''; + const lastName = jwtPayload.family_name || ''; + + await client.query('BEGIN'); + + try { + // 1. Create person + const personId = await getNextId(client, 'person', 'person_id', 'bos_subscriber'); + await client.query(` + INSERT INTO bos_subscriber.person (person_id, person_type, status) + VALUES ($1, 0, 1) + `, [personId]); + + // 2. Create address + const addressId = await getNextId(client, 'address', 'address_id', 'bos_subscriber'); + await client.query(` + INSERT INTO bos_subscriber.address (address_id, address_type) + VALUES ($1, 0) + `, [addressId]); + + // 3. Create persoana_fizica + await client.query(` + INSERT INTO bos_subscriber.persoana_fizica (individual_id, tip_persoana, nume, prenume, ro_official_address_id) + VALUES ($1, 2, $2, $3, $4) + `, [personId, lastName, firstName, addressId]); + + // 4. Create internet_user with credits from Free plan (DB-driven) + const planRow = await client.query( + `SELECT credits_per_cycle, storage_limit_gb FROM bos_sysadmin.subscription_plan WHERE subscription_plan_id = 1` + ); + const freeCredits = planRow.rows[0]?.credits_per_cycle ?? 5; + const freeStorageGb = planRow.rows[0]?.storage_limit_gb ?? 1; + + const internetUserId = await getNextId(client, 'internet_user', 'internet_user_id', 'bos_sysadmin'); + await client.query(` + INSERT INTO bos_sysadmin.internet_user (internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes) + VALUES ($1, $2, $3, 0, $4) + `, [internetUserId, personId, freeCredits, freeStorageGb * 1073741824]); + + // 5. Create user_credential + await client.query(` + INSERT INTO bos_sysadmin.user_credential + (internet_user_id, email, keycloak_id, enrollment_type, cellular_phone_no, no_attempts_failed, subscription_status, activation_date) + VALUES ($1, $2, $3, 1, '', 0, 1, CURRENT_DATE) + `, [internetUserId, email, keycloakId]); + + // 6. Create subscription with Free plan + const subscriptionId = await getNextId(client, 'subscription', 'subscription_id', 'bos_sysadmin'); + await client.query(` + INSERT INTO bos_sysadmin.subscription + (subscription_id, internet_user_id, subscription_plan_id, subscription_status, is_active, activation_date, deactivation_date, created_time, updated_time) + VALUES ($1, $2, 1, 1, true, CURRENT_DATE, '2099-12-31', CURRENT_DATE, CURRENT_DATE) + `, [subscriptionId, internetUserId]); + + // 7. Create contact entry for email + await client.query(` + INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info) + VALUES ($1, 2, $2) + `, [personId, email]); + + await client.query('COMMIT'); + log.info(`[AUTH] Auto-registered user ${email} with ID ${internetUserId}`); + + // 8. Create MinIO bucket for user with subscription metadata + try { + // Free plan: ID=1, storage_limit_gb=1 + await createUserBucket(internetUserId, email, 1, 'Free', 1); + } catch (bucketError: any) { + log.error(`[AUTH] Failed to create MinIO bucket for user ${internetUserId}:`, bucketError.message); + // Don't fail registration if bucket creation fails + } + + // Re-query to get full profile + result = await client.query(` + SELECT + uc.internet_user_id, + uc.email, + uc.keycloak_id, + uc.cellular_phone_no as phone, + uc.subscription_status, + uc.activation_date, + iu.person_id, + iu.credits_remained, + iu.credits_spent, + pf.nume as last_name, + pf.prenume as first_name, + s.subscription_plan_id, + s.is_active, + sp.plan_name, + sp.credits_per_cycle + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica pf ON iu.person_id = pf.individual_id + LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id + WHERE uc.keycloak_id = $1 + `, [keycloakId]); + + } catch (regError: any) { + await client.query('ROLLBACK'); + log.error('[AUTH] Auto-register failed:', regError); + return res.status(500).json({ + success: false, + error: 'Failed to auto-register user: ' + regError.message + }); + } + } + + const user = result.rows[0]; + + const profile: UserProfile = { + personId: user.person_id, + internetUserId: user.internet_user_id, + email: user.email, + firstName: user.first_name || jwtPayload.given_name || '', + lastName: user.last_name || jwtPayload.family_name || '', + phone: user.phone || '', + creditsRemained: user.credits_remained, + creditsSpent: user.credits_spent, + creditsTotal: (user.credits_remained || 0) + (user.credits_spent || 0), + creditsPerCycle: user.credits_per_cycle || 5, + subscriptionPlanId: user.subscription_plan_id || 1, + subscriptionPlanName: user.plan_name || 'Free', + subscriptionStatus: user.subscription_status, + isActive: user.is_active ?? true, + keycloakId: user.keycloak_id + }; + + res.json({ success: true, data: profile }); + + } catch (error: any) { + log.error('Error in /auth/me:', error); + internalError(res, error); + } finally { + client.release(); + } +}); +/** + * PUT /api/auth/profile + * Updates user profile (name, phone) + */ +router.put('/profile', async (req: Request, res: Response) => { + const client = await pool.connect(); + + try { + const jwtPayload = extractJWTPayload(req.headers.authorization); + + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ + success: false, + error: 'Missing or invalid Authorization header' + }); + } + + const { firstName, lastName, phone } = req.body; + + // Get user IDs + const userResult = await client.query(` + SELECT uc.internet_user_id, iu.person_id + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id + WHERE uc.keycloak_id = $1 + `, [jwtPayload.sub]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'User not found' }); + } + + const { internet_user_id, person_id } = userResult.rows[0]; + + await client.query('BEGIN'); + + // Update persoana_fizica if name provided + if (firstName || lastName) { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (firstName) { + updates.push(`prenume = $${paramIndex++}`); + values.push(firstName); + } + if (lastName) { + updates.push(`nume = $${paramIndex++}`); + values.push(lastName); + } + + values.push(person_id); + + await client.query(` + UPDATE bos_subscriber.persoana_fizica + SET ${updates.join(', ')} + WHERE individual_id = $${paramIndex} + `, values); + } + + // Update phone if provided + if (phone) { + await client.query(` + UPDATE bos_sysadmin.user_credential + SET cellular_phone_no = $1 + WHERE internet_user_id = $2 + `, [phone, internet_user_id]); + } + + await client.query('COMMIT'); + + res.json({ success: true, message: 'Profile updated successfully' }); + + } catch (error: any) { + await client.query('ROLLBACK'); + log.error('Error in /auth/profile:', error); + internalError(res, error); + } finally { + client.release(); + } +}); + + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/auth/registration.ts b/backend/services/orchestration-layer/didiFramework/src/routes/auth/registration.ts new file mode 100644 index 0000000..a506733 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/auth/registration.ts @@ -0,0 +1,164 @@ +/** + * Auth: POST /register — Creates user in bos_* tables after Keycloak signup. + */ +import { Router, Request, Response } from 'express'; +import { createUserBucket } from '../../config/minio'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import pool from '../../config/database'; +import { + type RegisterData, + extractJWTPayload, + getNextId, +} from './_helpers'; + +const router = Router(); + +/** + * POST /api/auth/register + * Creates user in bos_* tables after Keycloak registration + * Automatically assigns Free tier with 100 credits + */ +router.post('/register', async (req: Request, res: Response) => { + const client = await pool.connect(); + + try { + const jwtPayload = extractJWTPayload(req.headers.authorization); + + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ + success: false, + error: 'Missing or invalid Authorization header' + }); + } + + const keycloakId = jwtPayload.sub; + const email = jwtPayload.email; + + // Check if user already exists + const existingUser = await client.query( + 'SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE keycloak_id = $1', + [keycloakId] + ); + + if (existingUser.rows.length > 0) { + return res.status(409).json({ + success: false, + error: 'User already registered', + internetUserId: existingUser.rows[0].internet_user_id + }); + } + + const data: RegisterData = req.body; + const firstName = data.firstName || jwtPayload.given_name || ''; + const lastName = data.lastName || jwtPayload.family_name || ''; + const phone = data.phone || ''; + + await client.query('BEGIN'); + + // 1. Create person + const personId = await getNextId(client, 'person', 'person_id', 'bos_subscriber'); + await client.query(` + INSERT INTO bos_subscriber.person (person_id, person_type, status) + VALUES ($1, 0, 1) + `, [personId]); + + // 2. Create address entry for this person + const addressId = await getNextId(client, 'address', 'address_id', 'bos_subscriber'); + await client.query(` + INSERT INTO bos_subscriber.address (address_id, address_type) + VALUES ($1, 0) + `, [addressId]); + + // 3. Create persoana_fizica linked to the address + await client.query(` + INSERT INTO bos_subscriber.persoana_fizica + (individual_id, tip_persoana, nume, prenume, ro_official_address_id) + VALUES ($1, 2, $2, $3, $4) + `, [personId, lastName, firstName, addressId]); + + // 4. Create internet_user with credits_per_cycle from Free plan (DB-driven) + const planRow = await client.query( + `SELECT credits_per_cycle, storage_limit_gb FROM bos_sysadmin.subscription_plan WHERE subscription_plan_id = 1` + ); + const freeCredits = planRow.rows[0]?.credits_per_cycle ?? 10; + const freeStorageGb = planRow.rows[0]?.storage_limit_gb ?? 1; + + const internetUserId = await getNextId(client, 'internet_user', 'internet_user_id', 'bos_sysadmin'); + await client.query(` + INSERT INTO bos_sysadmin.internet_user + (internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes) + VALUES ($1, $2, $3, 0, $4) + `, [internetUserId, personId, freeCredits, freeStorageGb * 1073741824]); + + // 5. Create user_credential with keycloak_id + await client.query(` + INSERT INTO bos_sysadmin.user_credential + (internet_user_id, email, keycloak_id, enrollment_type, + cellular_phone_no, no_attempts_failed, subscription_status, activation_date) + VALUES ($1, $2, $3, 1, $4, 0, 1, CURRENT_DATE) + `, [internetUserId, email, keycloakId, phone]); + + // 6. Create subscription with Free plan (plan_id = 1) + const subscriptionId = await getNextId(client, 'subscription', 'subscription_id', 'bos_sysadmin'); + await client.query(` + INSERT INTO bos_sysadmin.subscription + (subscription_id, internet_user_id, subscription_plan_id, + subscription_status, is_active, activation_date, deactivation_date, + created_time, updated_time) + VALUES ($1, $2, 1, 1, true, CURRENT_DATE, '2099-12-31', CURRENT_DATE, CURRENT_DATE) + `, [subscriptionId, internetUserId]); + + // 7. Create contact entries (email + phone) + // contact table has composite PK: (contact_type_id, person_id) + await client.query(` + INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info) + VALUES ($1, 2, $2) + `, [personId, email]); + + if (phone) { + await client.query(` + INSERT INTO bos_subscriber.contact (person_id, contact_type_id, contact_info) + VALUES ($1, 5, $2) + `, [personId, phone]); + } + + await client.query('COMMIT'); + + // 8. Create MinIO bucket for user with subscription metadata + let bucketCreated = false; + try { + const bucketResult = await createUserBucket(internetUserId, email, 1, 'Free', freeStorageGb); + bucketCreated = bucketResult.created; + } catch (bucketError: any) { + log.error(`[AUTH] Failed to create MinIO bucket for user ${internetUserId}:`, bucketError.message); + // Don't fail registration if bucket creation fails + } + + res.status(201).json({ + success: true, + message: 'User registered successfully', + data: { + personId, + internetUserId, + subscriptionId, + email, + firstName, + lastName, + credits: freeCredits, + plan: 'Free', + storageBucket: `user-${internetUserId}`, + storageBucketCreated: bucketCreated + } + }); + + } catch (error: any) { + await client.query('ROLLBACK'); + log.error('Error in /auth/register:', error); + internalError(res, error); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/claims.ts b/backend/services/orchestration-layer/didiFramework/src/routes/claims.ts new file mode 100644 index 0000000..ed30b88 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/claims.ts @@ -0,0 +1,480 @@ +/** + * Claims Routes - FULL CRUD + * + * All claim-related tables are leaf nodes (no children), can be deleted directly: + * - Claim Status (VT, LT, UV, LF, VF, OP, NV) + * - Claim Type (EF, VF, RE, SC, QA, CC, PC, OF, VC) + * - Confidence levels + * - Interpretation (source concordance) + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +const createParameter = async (client: PoolClient, parameterType: number): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +}; + +const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +// ============================================================================ +// CLAIM STATUS (Verification Outcome) +// Leaf node - no children +// ============================================================================ + +interface ClaimStatus { + claim_id: number; + claim_code: string; + claim_name: string; + claim_color: string; + start_range: number | null; + end_range: number | null; + parameter_id: number; +} + +router.get('/status', async (req: Request, res: Response) => { + try { + const data = await query( + 'SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id FROM claim ORDER BY claim_id' + ); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/status/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id FROM claim WHERE claim_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/status', async (req: Request, res: Response) => { + try { + const { claim_code, claim_name, claim_color, start_range, end_range } = req.body; + if (!claim_code || !claim_name) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: claim_code, claim_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 10); // Parameter type for claims + const id = await getNextId(client, 'claim', 'claim_id'); + const insertResult = await client.query(` + INSERT INTO claim (claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id + `, [id, claim_code, claim_name, claim_color || '#808080', start_range, end_range, parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Claim status creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/status/:id', async (req: Request, res: Response) => { + try { + const { claim_code, claim_name, claim_color, start_range, end_range } = req.body; + const result = await queryOne(` + UPDATE claim + SET claim_code = COALESCE($1, claim_code), + claim_name = COALESCE($2, claim_name), + claim_color = COALESCE($3, claim_color), + start_range = COALESCE($4, start_range), + end_range = COALESCE($5, end_range) + WHERE claim_id = $6 + RETURNING claim_id, claim_code, claim_name, claim_color, start_range, end_range, parameter_id + `, [claim_code, claim_name, claim_color, start_range, end_range, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Claim status actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/status/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM claim WHERE claim_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Claim status nu a fost găsit' }); + } + res.json({ success: true, message: 'Claim status șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// CLAIM TYPE (Type of Claim with Base Weight) +// Leaf node - no children +// ============================================================================ + +interface ClaimType { + claim_type_id: number; + claim_type_code: string; + claim_type_name: string; + base_weight: number; + description: string; + verification_method: string; + parameter_id: number; +} + +router.get('/types', async (req: Request, res: Response) => { + try { + const data = await query( + 'SELECT claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method, parameter_id FROM claim_type ORDER BY base_weight DESC' + ); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/types/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM claim_type WHERE claim_type_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/types', async (req: Request, res: Response) => { + try { + const { claim_type_code, claim_type_name, base_weight, description, verification_method } = req.body; + if (!claim_type_code || !claim_type_name) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: claim_type_code, claim_type_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 11); // Parameter type for claim types + const id = await getNextId(client, 'claim_type', 'claim_type_id'); + const insertResult = await client.query(` + INSERT INTO claim_type (claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [id, claim_type_code, claim_type_name, base_weight || 1, description || '', verification_method || '', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Claim type creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/types/:id', async (req: Request, res: Response) => { + try { + const { claim_type_code, claim_type_name, base_weight, description, verification_method } = req.body; + const result = await queryOne(` + UPDATE claim_type + SET claim_type_code = COALESCE($1, claim_type_code), + claim_type_name = COALESCE($2, claim_type_name), + base_weight = COALESCE($3, base_weight), + description = COALESCE($4, description), + verification_method = COALESCE($5, verification_method) + WHERE claim_type_id = $6 + RETURNING * + `, [claim_type_code, claim_type_name, base_weight, description, verification_method, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Claim type actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/types/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM claim_type WHERE claim_type_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Claim type nu a fost găsit' }); + } + res.json({ success: true, message: 'Claim type șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// CONFIDENCE LEVELS +// Leaf node - no children +// ============================================================================ + +interface ConfidenceLevel { + confidence_id: number; + confidence_name: string; + confidence_level: number; + confidence_color: string; + action: string; + start_range: number; + end_range: number; + parameter_id: number; +} + +router.get('/confidence', async (req: Request, res: Response) => { + try { + const data = await query( + 'SELECT * FROM confidence ORDER BY confidence_level' + ); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/confidence/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM confidence WHERE confidence_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/confidence', async (req: Request, res: Response) => { + try { + const { confidence_name, confidence_level, confidence_color, action, start_range, end_range } = req.body; + if (!confidence_name || confidence_level === undefined) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: confidence_name, confidence_level' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 12); // Parameter type for confidence + const id = await getNextId(client, 'confidence', 'confidence_id'); + const insertResult = await client.query(` + INSERT INTO confidence (confidence_id, confidence_name, confidence_level, confidence_color, action, start_range, end_range, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, [id, confidence_name, confidence_level, confidence_color || '#808080', action || '', start_range || 0, end_range || 100, parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Confidence level creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/confidence/:id', async (req: Request, res: Response) => { + try { + const { confidence_name, confidence_level, confidence_color, action, start_range, end_range } = req.body; + const result = await queryOne(` + UPDATE confidence + SET confidence_name = COALESCE($1, confidence_name), + confidence_level = COALESCE($2, confidence_level), + confidence_color = COALESCE($3, confidence_color), + action = COALESCE($4, action), + start_range = COALESCE($5, start_range), + end_range = COALESCE($6, end_range) + WHERE confidence_id = $7 + RETURNING * + `, [confidence_name, confidence_level, confidence_color, action, start_range, end_range, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Confidence level actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/confidence/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM confidence WHERE confidence_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Confidence level nu a fost găsit' }); + } + res.json({ success: true, message: 'Confidence level șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// INTERPRETATION (Source Concordance) +// Leaf node - no children +// ============================================================================ + +interface Interpretation { + interpretation_id: number; + interpretation: string; + start_range: number; + end_range: number; + parameter_id: number; +} + +router.get('/interpretation', async (req: Request, res: Response) => { + try { + const data = await query( + 'SELECT interpretation_id, interpretation, start_range, end_range, parameter_id FROM interpretation ORDER BY interpretation_id' + ); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/interpretation/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM interpretation WHERE interpretation_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/interpretation', async (req: Request, res: Response) => { + try { + const { interpretation, start_range, end_range } = req.body; + if (!interpretation) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: interpretation' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 13); // Parameter type for interpretation + const id = await getNextId(client, 'interpretation', 'interpretation_id'); + const insertResult = await client.query(` + INSERT INTO interpretation (interpretation_id, interpretation, start_range, end_range, parameter_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [id, interpretation, start_range || 0, end_range || 100, parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Interpretation creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/interpretation/:id', async (req: Request, res: Response) => { + try { + const { interpretation, start_range, end_range } = req.body; + const result = await queryOne(` + UPDATE interpretation + SET interpretation = COALESCE($1, interpretation), + start_range = COALESCE($2, start_range), + end_range = COALESCE($3, end_range) + WHERE interpretation_id = $4 + RETURNING * + `, [interpretation, start_range, end_range, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Interpretation actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/interpretation/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM interpretation WHERE interpretation_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Interpretation nu a fost găsit' }); + } + res.json({ success: true, message: 'Interpretation șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// COMBINED: GET ALL CLAIMS DATA +// ============================================================================ + +router.get('/all', async (req: Request, res: Response) => { + try { + const [ + claimStatus, + claimTypes, + confidence, + interpretation + ] = await Promise.all([ + query('SELECT claim_id, claim_code, claim_name, claim_color, start_range, end_range FROM claim ORDER BY claim_id'), + query('SELECT claim_type_id, claim_type_code, claim_type_name, base_weight, description, verification_method FROM claim_type ORDER BY base_weight DESC'), + query('SELECT * FROM confidence ORDER BY confidence_level'), + query('SELECT interpretation_id, interpretation, start_range, end_range FROM interpretation ORDER BY interpretation_id') + ]); + + res.json({ + success: true, + data: { + claimStatus, + claimTypes, + confidence, + interpretation + }, + counts: { + claimStatus: claimStatus.length, + claimTypes: claimTypes.length, + confidence: confidence.length, + interpretation: interpretation.length, + total: claimStatus.length + claimTypes.length + confidence.length + interpretation.length + } + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/dimensions.ts b/backend/services/orchestration-layer/didiFramework/src/routes/dimensions.ts new file mode 100644 index 0000000..8f14483 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/dimensions.ts @@ -0,0 +1,295 @@ +/** + * Dimensions Routes - FULL CRUD with Safety + * + * Dimensions are the top-level categories in the analysis hierarchy: + * dimension -> subdimension -> technique -> indicator/validation_rule + * + * DELETE is protected - cannot delete dimension with subdimensions + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { checkDependencies, safeDelete } from '../utils/dependency-checker'; +import { Dimension, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Parameter type for dimensions +const PARAMETER_TYPE_DIMENSION = 1; + +// Helper: Create parameter entry +const createParameter = async (client: PoolClient): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, PARAMETER_TYPE_DIMENSION]); + + return nextParamId; +}; + +// Helper: Get next dimension_id +const getNextDimensionId = async (client: PoolClient): Promise => { + const result = await client.query('SELECT COALESCE(MAX(dimension_id), 0) + 1 as next_id FROM dimension'); + return result.rows[0].next_id; +}; + +// GET all dimensions +router.get('/', async (req: Request, res: Response) => { + try { + const dimensions = await query( + 'SELECT * FROM dimension ORDER BY dimension_id' + ); + res.json({ + success: true, + data: dimensions, + count: dimensions.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET all dimensions with subdimension counts +router.get('/with-counts', async (req: Request, res: Response) => { + try { + const dimensions = await query(` + SELECT d.*, + (SELECT COUNT(*) FROM subdimension s WHERE s.dimension_id = d.dimension_id) as subdimension_count, + (SELECT COUNT(*) FROM technique t + JOIN subdimension s ON t.subdimension_id = s.subdimension_id + WHERE s.dimension_id = d.dimension_id) as technique_count + FROM dimension d + ORDER BY d.dimension_id + `); + res.json({ + success: true, + data: dimensions, + count: dimensions.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET dimension by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const dimension = await queryOne( + 'SELECT * FROM dimension WHERE dimension_id = $1', + [req.params.id] + ); + if (!dimension) { + return res.status(404).json({ + success: false, + error: 'Dimensiunea nu a fost găsită' + } as ApiResponse); + } + res.json({ + success: true, + data: dimension + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET dependency check before delete +router.get('/:id/dependencies', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // Check if dimension exists + const dimension = await queryOne( + 'SELECT * FROM dimension WHERE dimension_id = $1', + [id] + ); + + if (!dimension) { + return res.status(404).json({ + success: false, + error: 'Dimensiunea nu a fost găsită' + }); + } + + // Check dependencies + const depCheck = await checkDependencies('dimension', 'dimension_id', id); + + // Get detailed subdimension info if there are children + let subdimensions: any[] = []; + if (depCheck.hasChildren) { + subdimensions = await query(` + SELECT s.subdimension_id, s.subdmiension_name as subdimension_name, s.subdimension_code, + (SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count + FROM subdimension s + WHERE s.dimension_id = $1 + ORDER BY s.subdimension_id + `, [id]); + } + + res.json({ + success: true, + data: { + dimension, + ...depCheck, + childDetails: subdimensions + } + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST create dimension +router.post('/', async (req: Request, res: Response) => { + try { + const { dimension_code, dimension_name, description, weight } = req.body; + + // Validation + if (!dimension_code || !dimension_name) { + return res.status(400).json({ + success: false, + error: 'Câmpuri obligatorii: dimension_code, dimension_name' + }); + } + + const result = await transaction(async (client) => { + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next dimension_id + const dimensionId = await getNextDimensionId(client); + + // Insert dimension + const insertResult = await client.query(` + INSERT INTO dimension (dimension_id, dimension_code, dimension_name, description, weight, parameter_id, + dimension_name_ro, dimension_name_en, description_ro, description_en) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `, [dimensionId, dimension_code, dimension_name, description || '', weight || 0, parameterId, + req.body.dimension_name_ro || null, req.body.dimension_name_en || dimension_name, + req.body.description_ro || null, req.body.description_en || description || '']); + + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result, + message: 'Dimensiunea a fost creată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT update dimension +router.put('/:id', async (req: Request, res: Response) => { + try { + const { dimension_code, dimension_name, description, weight, + dimension_name_ro, dimension_name_en, description_ro, description_en } = req.body; + + // Check if dimension exists + const existing = await queryOne( + 'SELECT * FROM dimension WHERE dimension_id = $1', + [req.params.id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Dimensiunea nu a fost găsită' + }); + } + + const dimension = await queryOne( + `UPDATE dimension + SET dimension_code = COALESCE($1, dimension_code), + dimension_name = COALESCE($2, dimension_name), + description = COALESCE($3, description), + weight = COALESCE($4, weight), + dimension_name_ro = COALESCE($6, dimension_name_ro), + dimension_name_en = COALESCE($7, dimension_name_en), + description_ro = COALESCE($8, description_ro), + description_en = COALESCE($9, description_en), + updated_date = CURRENT_DATE + WHERE dimension_id = $5 + RETURNING *`, + [dimension_code, dimension_name, description, weight, req.params.id, + dimension_name_ro, dimension_name_en, description_ro, description_en] + ); + + res.json({ + success: true, + data: dimension, + message: 'Dimensiunea a fost actualizată cu succes' + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// DELETE dimension (with safety check) +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const force = req.query.force === 'true'; + + // Check if dimension exists + const existing = await queryOne( + 'SELECT * FROM dimension WHERE dimension_id = $1', + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Dimensiunea nu a fost găsită' + }); + } + + // Use safe delete + const deleteResult = await safeDelete('dimension', 'dimension_id', id, force); + + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + canDelete: false, + dependencies: deleteResult.dependencyDetails?.dependencies || [], + hint: 'Ștergeți mai întâi toate subdimensiunile asociate acestei dimensiuni' + }); + } + + res.json({ + success: true, + message: 'Dimensiunea a fost ștearsă cu succes', + deleted: true + }); + } catch (error: any) { + if (error.code === '23503') { + return res.status(409).json({ + success: false, + error: 'Nu se poate șterge: există subdimensiuni asociate', + canDelete: false + }); + } + + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/_shared.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/_shared.ts new file mode 100644 index 0000000..ba6728b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/_shared.ts @@ -0,0 +1,14 @@ +/** + * Shared infra for extension-keys routes: + * - Redis cache prefix + TTL for the api_key → user mapping (read fast-path + * used by /validate and write-through by create/update/delete). + * - Lazy Redis connection helper (one connection per request — disconnected + * in caller's finally block). + */ +import type Redis from 'ioredis'; +import { createRedisConnection } from '../../config/redis'; + +export const CACHE_PREFIX = 'didi:extension:key:'; +export const CACHE_TTL = 3600; // 1 hour + +export const getRedis = (): Redis => createRedisConnection({ label: 'extension-keys' }); diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/create.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/create.ts new file mode 100644 index 0000000..5656b73 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/create.ts @@ -0,0 +1,80 @@ +/** + * POST / — Create a new browser-extension API key. + * + * Generates `didi_ext_<48 hex>` (24 random bytes), stores in PostgreSQL, + * and write-through caches in Redis so /validate hits the fast-path on first + * use without a DB round-trip. + */ +import { Router, Request, Response } from 'express'; +import crypto from 'crypto'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared'; + +const router = Router(); + +router.post('/', async (req: Request, res: Response) => { + const { user_id, user_email, name } = req.body; + + if (!user_id || !name) { + return res.status(400).json({ + success: false, + error: 'user_id and name are required', + }); + } + + const client = await pool.connect(); + const redis = getRedis(); + + try { + const apiKey = `didi_ext_${crypto.randomBytes(24).toString('hex')}`; + const keyPrefix = apiKey.substring(0, 16); + + const result = await client.query(` + INSERT INTO bos_parammgmt.extension_api_key + (api_key, key_prefix, user_id, user_email, name) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, api_key, key_prefix, user_id, user_email, name, is_active, created_at + `, [apiKey, keyPrefix, user_id, user_email || null, name]); + + const key = result.rows[0]; + + await redis.setex( + `${CACHE_PREFIX}${apiKey}`, + CACHE_TTL, + JSON.stringify({ + id: key.id, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + is_active: key.is_active, + }) + ); + + redis.quit(); + + res.status(201).json({ + success: true, + data: { + id: key.id, + api_key: key.api_key, + key_prefix: key.key_prefix, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + created_at: key.created_at, + usage: { + header: 'X-API-Key', + example: `curl -H "X-API-Key: ${apiKey}" ...`, + }, + }, + }); + } catch (error) { + redis.quit(); + internalError(res, error, 'extension_keys_create'); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/delete.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/delete.ts new file mode 100644 index 0000000..fd9fc96 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/delete.ts @@ -0,0 +1,56 @@ +/** + * DELETE /:id — Delete a key from PG + remove its Redis cache entry. + * + * Loads the api_key plaintext first (we only have id) so we know which Redis + * key to evict. Hard delete — no soft-delete column on this table. + */ +import { Router, Request, Response } from 'express'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { CACHE_PREFIX, getRedis } from './_shared'; + +const router = Router(); + +router.delete('/:id', async (req: Request, res: Response) => { + const { id } = req.params; + const client = await pool.connect(); + const redis = getRedis(); + + try { + const keyResult = await client.query( + 'SELECT api_key FROM bos_parammgmt.extension_api_key WHERE id = $1', + [id] + ); + + if (keyResult.rows.length === 0) { + redis.quit(); + return res.status(404).json({ + success: false, + error: 'Key not found', + }); + } + + const apiKey = keyResult.rows[0].api_key; + + await client.query( + 'DELETE FROM bos_parammgmt.extension_api_key WHERE id = $1', + [id] + ); + + await redis.del(`${CACHE_PREFIX}${apiKey}`); + + redis.quit(); + + res.json({ + success: true, + message: 'API key deleted successfully', + }); + } catch (error) { + redis.quit(); + internalError(res, error, 'extension_keys_delete'); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/index.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/index.ts new file mode 100644 index 0000000..964d8d5 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/index.ts @@ -0,0 +1,36 @@ +/** + * EXTENSION-KEYS — barrel router. + * + * Original 530-line extension-keys.ts split into: + * _shared.ts — CACHE_PREFIX, CACHE_TTL, getRedis() + * create.ts — POST / + * list.ts — GET /, GET /user/:userId + * validate.ts — GET /validate (cache-first hot-path) + * update.ts — PUT /:id + * delete.ts — DELETE /:id + * usage.ts — POST /:id/usage, POST /usage-by-key + * + * Mount-order matters: validate must be registered BEFORE update/delete so the + * literal path `/validate` is matched before the `/:id` placeholder. + * + * Mounted at /api/extension-keys in src/server.ts. + */ +import { Router } from 'express'; +import createRouter from './create'; +import listRouter from './list'; +import validateRouter from './validate'; +import updateRouter from './update'; +import deleteRouter from './delete'; +import usageRouter from './usage'; + +const router = Router(); + +// /validate before /:id-style routes (literal vs placeholder collision) +router.use(validateRouter); +router.use(createRouter); +router.use(listRouter); +router.use(usageRouter); +router.use(updateRouter); +router.use(deleteRouter); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/list.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/list.ts new file mode 100644 index 0000000..70ba5ea --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/list.ts @@ -0,0 +1,85 @@ +/** + * GET / — admin list with optional ?user_id=&active_only=true filters + * GET /user/:userId — list all keys for a specific user + * + * Neither endpoint returns the `api_key` plaintext — only `key_prefix` (first 16 chars). + */ +import { Router, Request, Response } from 'express'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; + +const router = Router(); + +router.get('/', async (req: Request, res: Response) => { + const { user_id, active_only } = req.query; + const client = await pool.connect(); + + try { + let query = ` + SELECT id, key_prefix, user_id, user_email, name, is_active, + created_at, last_used_at, usage_count + FROM bos_parammgmt.extension_api_key + `; + const params: any[] = []; + const conditions: string[] = []; + + if (user_id) { + conditions.push(`user_id = $${params.length + 1}`); + params.push(user_id); + } + + if (active_only === 'true') { + conditions.push('is_active = true'); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY created_at DESC'; + + const result = await client.query(query, params); + + res.json({ + success: true, + data: { + total: result.rows.length, + keys: result.rows, + }, + }); + } catch (error) { + internalError(res, error, 'extension_keys_list'); + } finally { + client.release(); + } +}); + +router.get('/user/:userId', async (req: Request, res: Response) => { + const { userId } = req.params; + const client = await pool.connect(); + + try { + const result = await client.query(` + SELECT id, key_prefix, user_id, user_email, name, is_active, + created_at, last_used_at, usage_count + FROM bos_parammgmt.extension_api_key + WHERE user_id = $1 + ORDER BY created_at DESC + `, [userId]); + + res.json({ + success: true, + data: { + user_id: userId, + total: result.rows.length, + keys: result.rows, + }, + }); + } catch (error) { + internalError(res, error, 'extension_keys_list_for_user'); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/update.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/update.ts new file mode 100644 index 0000000..9efb8a9 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/update.ts @@ -0,0 +1,104 @@ +/** + * PUT /:id — Update is_active / name / user_email on an existing key. + * + * Builds a dynamic SET clause from whichever fields are present in the body. + * Refreshes the Redis cache with the new is_active state so /validate doesn't + * keep returning the stale value for up to 1h. + */ +import { Router, Request, Response } from 'express'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared'; + +const router = Router(); + +router.put('/:id', async (req: Request, res: Response) => { + const { id } = req.params; + const { is_active, name, user_email } = req.body; + + const client = await pool.connect(); + const redis = getRedis(); + + try { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (is_active !== undefined) { + updates.push(`is_active = $${paramIndex}`); + params.push(is_active); + paramIndex++; + } + + if (name !== undefined) { + updates.push(`name = $${paramIndex}`); + params.push(name); + paramIndex++; + } + + if (user_email !== undefined) { + updates.push(`user_email = $${paramIndex}`); + params.push(user_email); + paramIndex++; + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: 'No fields to update', + }); + } + + params.push(id); + + const result = await client.query(` + UPDATE bos_parammgmt.extension_api_key + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING id, api_key, key_prefix, user_id, user_email, name, is_active + `, params); + + if (result.rows.length === 0) { + redis.quit(); + return res.status(404).json({ + success: false, + error: 'Key not found', + }); + } + + const key = result.rows[0]; + + await redis.setex( + `${CACHE_PREFIX}${key.api_key}`, + CACHE_TTL, + JSON.stringify({ + id: key.id, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + is_active: key.is_active, + }) + ); + + redis.quit(); + + res.json({ + success: true, + data: { + id: key.id, + key_prefix: key.key_prefix, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + is_active: key.is_active, + }, + }); + } catch (error) { + redis.quit(); + internalError(res, error, 'extension_keys_update'); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/usage.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/usage.ts new file mode 100644 index 0000000..dddefea --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/usage.ts @@ -0,0 +1,83 @@ +/** + * Usage telemetry endpoints — increment `usage_count` and bump `last_used_at`. + * + * POST /:id/usage — by row id (admin / internal) + * POST /usage-by-key — by X-API-Key header or body.api_key (extension hot-path) + * + * Both atomic via single SQL UPDATE; no Redis touch (cache only stores user info). + */ +import { Router, Request, Response } from 'express'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; + +const router = Router(); + +router.post('/:id/usage', async (req: Request, res: Response) => { + const { id } = req.params; + const client = await pool.connect(); + + try { + const result = await client.query(` + UPDATE bos_parammgmt.extension_api_key + SET usage_count = usage_count + 1, last_used_at = NOW() + WHERE id = $1 + RETURNING id, usage_count, last_used_at + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'Key not found', + }); + } + + res.json({ + success: true, + data: result.rows[0], + }); + } catch (error) { + internalError(res, error, 'extension_keys_usage_by_id'); + } finally { + client.release(); + } +}); + +router.post('/usage-by-key', async (req: Request, res: Response) => { + const apiKey = req.headers['x-api-key'] as string || req.body.api_key; + + if (!apiKey) { + return res.status(400).json({ + success: false, + error: 'API key required', + }); + } + + const client = await pool.connect(); + + try { + const result = await client.query(` + UPDATE bos_parammgmt.extension_api_key + SET usage_count = usage_count + 1, last_used_at = NOW() + WHERE api_key = $1 + RETURNING id, usage_count, last_used_at + `, [apiKey]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'Key not found', + }); + } + + res.json({ + success: true, + data: result.rows[0], + }); + } catch (error) { + internalError(res, error, 'extension_keys_usage_by_key'); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/validate.ts b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/validate.ts new file mode 100644 index 0000000..420d8c2 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/extension-keys/validate.ts @@ -0,0 +1,111 @@ +/** + * GET /validate — Cache-first validation of an X-API-Key header (or ?api_key=). + * + * Hot-path for the browser extension. Strategy: + * 1. Read Redis cache (`didi:extension:key:`) — if found and active, return. + * 2. Otherwise fall through to PostgreSQL; on hit, write back to cache (TTL 1h). + * 3. Inactive keys → 401 even if present in DB. + * + * The `source` field in the response tells the caller whether the answer came + * from cache or DB (useful for monitoring cache hit-rate). + */ +import { Router, Request, Response } from 'express'; +import pool from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { CACHE_PREFIX, CACHE_TTL, getRedis } from './_shared'; + +const router = Router(); + +router.get('/validate', async (req: Request, res: Response) => { + const apiKey = req.headers['x-api-key'] as string || req.query.api_key as string; + + if (!apiKey) { + return res.status(400).json({ + success: false, + error: 'API key required (X-API-Key header or api_key query param)', + }); + } + + const redis = getRedis(); + + try { + const cached = await redis.get(`${CACHE_PREFIX}${apiKey}`); + if (cached) { + const data = JSON.parse(cached); + if (data.is_active) { + redis.quit(); + return res.json({ + success: true, + data: { + valid: true, + user_id: data.user_id, + user_email: data.user_email, + name: data.name, + source: 'cache', + }, + }); + } + } + + const client = await pool.connect(); + try { + const result = await client.query(` + SELECT id, user_id, user_email, name, is_active + FROM bos_parammgmt.extension_api_key + WHERE api_key = $1 + `, [apiKey]); + + if (result.rows.length === 0) { + redis.quit(); + return res.status(401).json({ + success: false, + data: { valid: false }, + error: 'Invalid API key', + }); + } + + const key = result.rows[0]; + + if (!key.is_active) { + redis.quit(); + return res.status(401).json({ + success: false, + data: { valid: false }, + error: 'API key is deactivated', + }); + } + + await redis.setex( + `${CACHE_PREFIX}${apiKey}`, + CACHE_TTL, + JSON.stringify({ + id: key.id, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + is_active: key.is_active, + }) + ); + + redis.quit(); + + res.json({ + success: true, + data: { + valid: true, + user_id: key.user_id, + user_email: key.user_email, + name: key.name, + source: 'database', + }, + }); + } finally { + client.release(); + } + } catch (error) { + redis.quit(); + internalError(res, error, 'extension_keys_validate'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/history.ts b/backend/services/orchestration-layer/didiFramework/src/routes/history.ts new file mode 100644 index 0000000..46d04b5 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/history.ts @@ -0,0 +1,302 @@ +/** + * History Routes - Analysis history API + * + * Returns flat canonical types matching AnalysisSession format. + * Detail endpoint returns SAME shape as GET /api/v3/pipeline/:sessionId/result. + * + * Endpoints: + * - GET /api/history/admin - Admin paginated list with filters + * - GET /api/history - User paginated list + * - GET /api/history/:sessionId - Full analysis detail (flat canonical types) + * - DELETE /api/history/:sessionId - Delete analysis + */ + +import { Router, Request, Response } from 'express'; +import { log } from '../config/logger'; +import { internalError } from '../config/error-response'; +import pool from '../config/database'; + +const router = Router(); + +const S = 'bos_analysis'; + +// ============================================================================ +// Shared: list query helper +// ============================================================================ + +const LIST_SELECT = ` + s.session_id, s.user_id, s.user_email, s.input_type, + CASE WHEN s.input_text IS NULL THEN NULL + WHEN char_length(s.input_text) > 200 + THEN left(convert_from(convert_to(s.input_text, 'UTF8'), 'UTF8'), 200) || '...' + ELSE convert_from(convert_to(s.input_text, 'UTF8'), 'UTF8') + END as input_preview, + s.input_url, s.status, s.started_at, s.completed_at, s.total_duration_ms, s.source_app, + v.risk_score, v.risk_category, v.risk_level, v.confidence, v.confidence_level, + v.explanation_ro, v.explanation_en, + t.manipulation_score as techniques_score, t.techniques_count, + ai.ai_probability, ai.verdict as ai_verdict, + c.total_claims, c.verified_true, c.verified_false, c.credibility_score as claims_score, + d.domain, d.verdict as domain_verdict, d.trust_score as domain_trust_score, + sa.trust_score as source_trust_score, sa.verdict as source_verdict, + sa.publication->>'name' as source_publication, + sa.author->>'name' as source_author, + sa.platform->>'name' as source_platform`; + +const LIST_JOINS = ` + FROM ${S}.analysis_session s + LEFT JOIN ${S}.analysis_verdict v ON s.session_id = v.session_id + LEFT JOIN ${S}.analysis_techniques t ON s.session_id = t.session_id + LEFT JOIN ${S}.analysis_ai_tampered ai ON s.session_id = ai.session_id + LEFT JOIN ${S}.analysis_claims c ON s.session_id = c.session_id + LEFT JOIN ${S}.analysis_domain d ON s.session_id = d.session_id + LEFT JOIN ${S}.analysis_source_assessment sa ON s.session_id = sa.session_id`; + +interface ListParams { + user_id?: string; + search?: string; + status?: string; + risk_level?: string; + from_date?: string; + to_date?: string; + page: number; + limit: number; +} + +function parseListParams(query: any, requireUserId: boolean): ListParams | null { + const page = Math.max(1, parseInt(query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(query.limit as string, 10) || 20)); + if (requireUserId && !query.user_id) return null; + return { user_id: query.user_id, search: query.search, status: query.status, risk_level: query.risk_level, from_date: query.from_date, to_date: query.to_date, page, limit }; +} + +async function queryList(p: ListParams, isAdmin: boolean) { + const conds: string[] = []; + const vals: any[] = []; + let i = 1; + + if (p.user_id) { conds.push(`s.user_id = $${i++}`); vals.push(p.user_id); } + if (isAdmin && p.search) { conds.push(`(s.user_email ILIKE $${i} OR s.user_id ILIKE $${i})`); vals.push(`%${p.search}%`); i++; } + if (p.status) { conds.push(`s.status = $${i++}`); vals.push(p.status); } + if (p.risk_level) { conds.push(`v.risk_level = $${i++}`); vals.push(p.risk_level); } + if (p.from_date) { conds.push(`s.created_at >= $${i++}`); vals.push(p.from_date); } + if (p.to_date) { conds.push(`s.created_at <= $${i++}`); vals.push(p.to_date); } + + const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; + const offset = (p.page - 1) * p.limit; + const client = await pool.connect(); + try { + const countRes = await client.query(`SELECT COUNT(*) as total FROM ${S}.analysis_session s LEFT JOIN ${S}.analysis_verdict v ON s.session_id = v.session_id ${where}`, vals); + const total = parseInt(countRes.rows[0].total, 10); + const dataRes = await client.query(`SELECT ${LIST_SELECT} ${LIST_JOINS} ${where} ORDER BY s.created_at DESC LIMIT $${i} OFFSET $${i + 1}`, [...vals, p.limit, offset]); + return { + items: dataRes.rows, + pagination: { page: p.page, limit: p.limit, total, total_pages: Math.ceil(total / p.limit), has_next: p.page * p.limit < total, has_prev: p.page > 1 }, + }; + } finally { + client.release(); + } +} + +// ============================================================================ +// Flat canonical mappers (same output as PgAdapter in agent-v3) +// ============================================================================ + +export function mapTechniques(r: any) { + if (!r) return null; + return { + manipulation_score: Number(r.manipulation_score), total_severity: r.total_severity, + dimensions_affected: r.dimensions_affected || [], techniques_count: r.techniques_count, + techniques_detected: r.techniques_detected || [], coupling_context: r.coupling_context || {}, + llm_screening: r.llm_screening, llm_deep: r.llm_deep, + screening_duration_ms: r.screening_duration_ms, deep_analysis_duration_ms: r.deep_analysis_duration_ms, + total_duration_ms: r.total_duration_ms, + fallbacks_screening: r.fallbacks_screening ?? 0, fallbacks_deep: r.fallbacks_deep ?? 0, + }; +} + +export function mapAiTampered(r: any) { + if (!r) return null; + return { + ai_probability: Number(r.ai_probability), verdict: r.verdict, risk_score: Number(r.risk_score), + categories_affected: r.categories_affected || [], indicators_count: r.indicators_count, + disclosure_detected: r.disclosure_detected ?? false, disclosure_explicit: r.disclosure_explicit ?? false, + disclosure_text: r.disclosure_text, + indicators_detected: r.indicators_detected || [], coupling_context: r.coupling_context || {}, + llm_screening: r.llm_screening, llm_deep: r.llm_deep, + screening_duration_ms: r.screening_duration_ms, deep_analysis_duration_ms: r.deep_analysis_duration_ms, + total_duration_ms: r.total_duration_ms, + fallbacks_screening: r.fallbacks_screening ?? 0, fallbacks_deep: r.fallbacks_deep ?? 0, + content_type: r.content_type || 'text', image_analysis: r.image_analysis ?? null, + }; +} + +export function mapClaims(r: any) { + if (!r) return null; + return { + total_claims: r.total_claims, verified_true: r.verified_true, verified_false: r.verified_false, + unverified: r.unverified, opinions: r.opinions, + credibility_score: r.credibility_score != null ? Number(r.credibility_score) : null, + interpretation: r.interpretation, + claims_by_status: r.claims_by_status || {}, claims_by_type: r.claims_by_type || {}, + claims_verified: r.claims_verified || [], + llm_extraction: r.llm_extraction, llm_verification: r.llm_verification, + extraction_duration_ms: r.extraction_duration_ms, verification_duration_ms: r.verification_duration_ms, + total_duration_ms: r.total_duration_ms, web_searches_made: r.web_searches_made ?? 0, + }; +} + +export function mapDomain(r: any) { + if (!r) return null; + return { + domain: r.domain, verdict: r.verdict, trust_score: r.trust_score, risk_level: r.risk_level, + age_days: r.age_days, age_category: r.age_category, domain_created_at: r.domain_created_at ?? null, + is_blacklisted: r.is_blacklisted ?? false, reputation_score: r.reputation_score, + has_ssl: r.has_ssl, ssl_valid: r.ssl_valid, ssl_issuer: r.ssl_issuer, + registrar: r.registrar, organization: r.organization, country: r.country, + red_flags: r.red_flags || [], warnings: r.warnings || [], duration_ms: r.duration_ms, + }; +} + +export function mapSourceAssessment(r: any) { + if (!r) return null; + return { + trust_score: Number(r.trust_score), verdict: r.verdict, risk_level: r.risk_level, + publication: r.publication || { name: null, source_type: 'Anonymous source', source_type_id: 11, score: 20, confirmed: false }, + author: r.author || { name: null, classification: 'Anonymous', classification_code: 'AUTH_ANON', score: 60, confirmed: false, credibility_indicators: [] }, + platform: r.platform || { code: 'PLAT_UNKNOWN', name: 'Unknown/Other', score: 30, modifiers: [] }, + domain: r.domain || { name: null, age_days: null, risk_score: null, score: 50, has_ssl: null, is_blacklisted: false, registrar: null, organization: null, country: null, red_flags: [] }, + formula: r.formula || { publication_weight: 0.35, domain_weight: 0.25, author_weight: 0.25, platform_weight: 0.15, breakdown: '' }, + warnings: r.warnings || [], red_flags: r.red_flags || [], + search_queries_used: r.search_queries_used || [], search_results_count: r.search_results_count ?? 0, + duration_ms: r.duration_ms ?? 0, llm_model_used: r.llm_model_used, + }; +} + +export function mapVerdict(r: any) { + if (!r) return null; + return { + risk_score: r.risk_score, risk_category: r.risk_category, risk_category_color: r.risk_category_color, + risk_level: r.risk_level, risk_level_color: r.risk_level_color, + severity: r.severity, recommended_action: r.recommended_action, + confidence: r.confidence, confidence_level: r.confidence_level, + score_manipulation: r.score_manipulation != null ? Number(r.score_manipulation) : null, + score_claims: r.score_claims != null ? Number(r.score_claims) : null, + score_ai: r.score_ai != null ? Number(r.score_ai) : null, + score_source: r.score_source != null ? Number(r.score_source) : null, + score_context: r.score_context != null ? Number(r.score_context) : null, + applied_weights: r.applied_weights || {}, override_applied: r.override_applied ?? false, + override_type: r.override_type, override_reason: r.override_reason, + override_adjustment: r.override_adjustment, + context_summary: r.context_summary || {}, components_used: r.components_used || [], + weights_source: r.weights_source, duration_ms: r.duration_ms, + explanation_ro: r.explanation_ro ?? null, explanation_en: r.explanation_en ?? null, + }; +} + +// ============================================================================ +// GET /api/history/admin - Admin paginated list with filters +// ============================================================================ + +router.get('/admin', async (req: Request, res: Response) => { + try { + const p = parseListParams(req.query, false); + if (!p) return res.status(400).json({ success: false, error: 'Invalid parameters' }); + const result = await queryList(p, true); + res.json({ success: true, data: result }); + } catch (error) { + log.error('[History Admin] Error:', error); + internalError(res, error); + } +}); + +// ============================================================================ +// GET /api/history - User paginated list +// ============================================================================ + +router.get('/', async (req: Request, res: Response) => { + try { + const p = parseListParams(req.query, true); + if (!p) return res.status(400).json({ success: false, error: 'user_id is required' }); + const result = await queryList(p, false); + res.json({ success: true, data: result }); + } catch (error) { + log.error('[History] Error:', error); + internalError(res, error); + } +}); + +// ============================================================================ +// GET /api/history/:sessionId - Full analysis detail (flat canonical types) +// ============================================================================ + +router.get('/:sessionId', async (req: Request, res: Response) => { + const { sessionId } = req.params; + const client = await pool.connect(); + try { + const sRes = await client.query(`SELECT * FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]); + if (sRes.rows.length === 0) return res.status(404).json({ success: false, error: 'Session not found' }); + + const [tRes, aiRes, cRes, dRes, saRes, vRes] = await Promise.all([ + client.query(`SELECT * FROM ${S}.analysis_techniques WHERE session_id = $1`, [sessionId]), + client.query(`SELECT * FROM ${S}.analysis_ai_tampered WHERE session_id = $1`, [sessionId]), + client.query(`SELECT * FROM ${S}.analysis_claims WHERE session_id = $1`, [sessionId]), + client.query(`SELECT * FROM ${S}.analysis_domain WHERE session_id = $1`, [sessionId]), + client.query(`SELECT * FROM ${S}.analysis_source_assessment WHERE session_id = $1`, [sessionId]), + client.query(`SELECT * FROM ${S}.analysis_verdict WHERE session_id = $1`, [sessionId]), + ]); + + const s = sRes.rows[0]; + res.json({ + success: true, + data: { + session_id: s.session_id, user_id: s.user_id, user_email: s.user_email, + input_type: s.input_type, input_text: s.input_text, input_url: s.input_url, + input_media_url: s.input_media_url, input_hash: s.input_hash, + status: s.status, components_run: s.components_run || [], components_skipped: s.components_skipped || [], + risk_score: s.risk_score, risk_category: s.risk_category, risk_level: s.risk_level, + confidence: s.confidence, confidence_level: s.confidence_level, + started_at: s.started_at, completed_at: s.completed_at, total_duration_ms: s.total_duration_ms, + scenario_applied: s.scenario_applied, topic_applied: s.topic_applied, + source_app: s.source_app || 'web', api_version: s.api_version || 'v3', created_at: s.created_at, + techniques: mapTechniques(tRes.rows[0]), + ai_tampered: mapAiTampered(aiRes.rows[0]), + claims: mapClaims(cRes.rows[0]), + domain: mapDomain(dRes.rows[0]), + source_assessment: saRes.rows[0] ? mapSourceAssessment(saRes.rows[0]) : null, + verdict: mapVerdict(vRes.rows[0]), + }, + }); + } catch (error) { + log.error('[History Detail] Error:', error); + internalError(res, error); + } finally { + client.release(); + } +}); + +// ============================================================================ +// DELETE /api/history/:sessionId - Delete analysis +// ============================================================================ + +router.delete('/:sessionId', async (req: Request, res: Response) => { + const { sessionId } = req.params; + const { user_id } = req.query; + if (!user_id) return res.status(400).json({ success: false, error: 'user_id is required' }); + + const client = await pool.connect(); + try { + const check = await client.query(`SELECT user_id FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]); + if (check.rows.length === 0) return res.status(404).json({ success: false, error: 'Session not found' }); + if (check.rows[0].user_id !== user_id) return res.status(403).json({ success: false, error: 'Not authorized to delete this session' }); + await client.query(`DELETE FROM ${S}.analysis_session WHERE session_id = $1`, [sessionId]); + res.json({ success: true, message: 'Analysis deleted successfully' }); + } catch (error) { + log.error('[History Delete] Error:', error); + internalError(res, error); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/indicators.ts b/backend/services/orchestration-layer/didiFramework/src/routes/indicators.ts new file mode 100644 index 0000000..fcfbafb --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/indicators.ts @@ -0,0 +1,380 @@ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { TechniqueIndicator, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Parameter type for indicators +const PARAMETER_TYPE_INDICATOR = 4; + +// Helper: Create parameter entry and return parameter_id +const createParameter = async (client: PoolClient): Promise => { + // Get next parameter_id + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, PARAMETER_TYPE_INDICATOR]); + + return nextParamId; +}; + +// Helper: Get next indicator_id for a technique +const getNextIndicatorId = async (client: PoolClient, techniqueId: number): Promise => { + const result = await client.query(` + SELECT COALESCE(MAX(indicator_id), 0) + 1 as next_id + FROM technique_indicator + WHERE technique_id = $1 + `, [techniqueId]); + return result.rows[0].next_id; +}; + +// GET all indicators +router.get('/', async (req: Request, res: Response) => { + try { + const indicators = await query( + 'SELECT * FROM technique_indicator ORDER BY technique_id, indicator_id' + ); + res.json({ + success: true, + data: indicators, + count: indicators.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET indicators by technique_id +router.get('/by-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const indicators = await query( + 'SELECT * FROM technique_indicator WHERE technique_id = $1 ORDER BY indicator_id', + [req.params.techniqueId] + ); + res.json({ + success: true, + data: indicators, + count: indicators.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET techniques without indicators (for bulk creation planning) +router.get('/missing', async (req: Request, res: Response) => { + try { + const techniques = await query(` + SELECT t.technique_id, t.technique_name, t.severity, + d.dimension_code, d.dimension_name, + s.subdimension_code, s.subdmiension_name as subdimension_name + FROM technique t + JOIN subdimension s ON t.subdimension_id = s.subdimension_id + JOIN dimension d ON s.dimension_id = d.dimension_id + WHERE t.technique_id NOT IN ( + SELECT DISTINCT technique_id FROM technique_indicator + ) + ORDER BY d.dimension_id, s.subdimension_id, t.technique_id + `); + res.json({ + success: true, + data: techniques, + count: techniques.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET statistics +router.get('/stats', async (req: Request, res: Response) => { + try { + const stats = await query(` + SELECT + (SELECT COUNT(*) FROM technique) as total_techniques, + (SELECT COUNT(DISTINCT technique_id) FROM technique_indicator) as techniques_with_indicators, + (SELECT COUNT(*) FROM technique_indicator) as total_indicators, + (SELECT COUNT(*) FROM technique WHERE technique_id NOT IN (SELECT DISTINCT technique_id FROM technique_indicator)) as techniques_missing_indicators + `); + res.json({ + success: true, + data: stats[0] + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST - Create single indicator +router.post('/', async (req: Request, res: Response) => { + try { + const { technique_id, indicator_name, description, max_intensity } = req.body; + + if (!technique_id || !indicator_name || !description || !max_intensity) { + return res.status(400).json({ + success: false, + error: 'Missing required fields: technique_id, indicator_name, description, max_intensity' + }); + } + + const result = await transaction(async (client) => { + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next indicator_id for this technique + const indicatorId = await getNextIndicatorId(client, technique_id); + + // Insert indicator + const insertResult = await client.query(` + INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [technique_id, indicatorId, indicator_name, description, max_intensity, parameterId]); + + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST /bulk - Create multiple indicators for one or more techniques +router.post('/bulk', async (req: Request, res: Response) => { + try { + const { indicators } = req.body; + + if (!indicators || !Array.isArray(indicators) || indicators.length === 0) { + return res.status(400).json({ + success: false, + error: 'Missing required field: indicators (array of {technique_id, indicator_name, description, max_intensity})' + }); + } + + // Validate all indicators + for (const ind of indicators) { + if (!ind.technique_id || !ind.indicator_name || !ind.description || !ind.max_intensity) { + return res.status(400).json({ + success: false, + error: `Invalid indicator: ${JSON.stringify(ind)}. Required: technique_id, indicator_name, description, max_intensity` + }); + } + } + + const result = await transaction(async (client) => { + const created: any[] = []; + + // Group by technique_id to manage indicator_id sequences + const byTechnique: Record = {}; + for (const ind of indicators) { + if (!byTechnique[ind.technique_id]) { + byTechnique[ind.technique_id] = []; + } + byTechnique[ind.technique_id].push(ind); + } + + // Process each technique's indicators + for (const [techIdStr, techIndicators] of Object.entries(byTechnique)) { + const techId = parseInt(techIdStr); + let nextIndicatorId = await getNextIndicatorId(client, techId); + + for (const ind of techIndicators) { + // Create parameter entry + const parameterId = await createParameter(client); + + // Insert indicator + const insertResult = await client.query(` + INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [techId, nextIndicatorId, ind.indicator_name, ind.description, ind.max_intensity, parameterId]); + + created.push(insertResult.rows[0]); + nextIndicatorId++; + } + } + + return created; + }); + + res.status(201).json({ + success: true, + data: result, + count: result.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST /bulk-for-technique - Create multiple indicators for a single technique (simpler format) +router.post('/bulk-for-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const techniqueId = parseInt(req.params.techniqueId); + const { indicators } = req.body; + + if (!indicators || !Array.isArray(indicators) || indicators.length === 0) { + return res.status(400).json({ + success: false, + error: 'Missing required field: indicators (array of {indicator_name, description, max_intensity})' + }); + } + + const result = await transaction(async (client) => { + const created: any[] = []; + let nextIndicatorId = await getNextIndicatorId(client, techniqueId); + + for (const ind of indicators) { + if (!ind.indicator_name || !ind.description || !ind.max_intensity) { + throw new Error(`Invalid indicator: ${JSON.stringify(ind)}`); + } + + // Create parameter entry + const parameterId = await createParameter(client); + + // Insert indicator + const insertResult = await client.query(` + INSERT INTO technique_indicator (technique_id, indicator_id, indicator_name, description, max_intensity, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [techniqueId, nextIndicatorId, ind.indicator_name, ind.description, ind.max_intensity, parameterId]); + + created.push(insertResult.rows[0]); + nextIndicatorId++; + } + + return created; + }); + + res.status(201).json({ + success: true, + data: result, + count: result.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT - Update indicator +router.put('/:techniqueId/:indicatorId', async (req: Request, res: Response) => { + try { + const { techniqueId, indicatorId } = req.params; + const { indicator_name, description, max_intensity } = req.body; + + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (indicator_name) { + updates.push(`indicator_name = $${paramIndex++}`); + values.push(indicator_name); + } + if (description) { + updates.push(`description = $${paramIndex++}`); + values.push(description); + } + if (max_intensity) { + updates.push(`max_intensity = $${paramIndex++}`); + values.push(max_intensity); + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: 'No fields to update' + }); + } + + values.push(techniqueId, indicatorId); + + const result = await query(` + UPDATE technique_indicator + SET ${updates.join(', ')} + WHERE technique_id = $${paramIndex++} AND indicator_id = $${paramIndex} + RETURNING * + `, values); + + if (result.length === 0) { + return res.status(404).json({ + success: false, + error: 'Indicator not found' + }); + } + + res.json({ + success: true, + data: result[0] + }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE all indicators for a technique — declarat înainte de '/:techniqueId/:indicatorId', +// altfel 'by-technique' e capturat ca :techniqueId și ruta devine inaccesibilă +router.delete('/by-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const { techniqueId } = req.params; + + const result = await query(` + DELETE FROM technique_indicator + WHERE technique_id = $1 + RETURNING * + `, [techniqueId]); + + res.json({ + success: true, + data: result, + count: result.length, + message: `Deleted ${result.length} indicators for technique ${techniqueId}` + }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE - Delete indicator +router.delete('/:techniqueId/:indicatorId', async (req: Request, res: Response) => { + try { + const { techniqueId, indicatorId } = req.params; + + const result = await query(` + DELETE FROM technique_indicator + WHERE technique_id = $1 AND indicator_id = $2 + RETURNING * + `, [techniqueId, indicatorId]); + + if (result.length === 0) { + return res.status(404).json({ + success: false, + error: 'Indicator not found' + }); + } + + res.json({ + success: true, + data: result[0], + message: 'Indicator deleted' + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/input-profiles.ts b/backend/services/orchestration-layer/didiFramework/src/routes/input-profiles.ts new file mode 100644 index 0000000..ccc5008 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/input-profiles.ts @@ -0,0 +1,553 @@ +/** + * INPUT PROFILES ROUTES — pipeline definitions (CRUD + lifecycle). + * + * Un input profile este DEFINIȚIA DE PIPELINE a platformei: ce componente + * rulează, cu ce ponderi/roluri/praguri, per tip de input. Mounted at both + * /api/input-profiles and /api/pipelines (alias, see server.ts). + * + * GET / — list all profiles with overrides + * GET /:code — single profile with overrides + * PUT /:code — update (snapshots previous state as a version) + * POST /:code/clone — clone into a new (inactive) profile + * POST /:code/activate — publish (is_active=true) + * POST /:code/deactivate — unpublish (is_active=false) + * GET /:code/versions — version history (snapshots) + * POST /:code/versions/:id/restore — restore a snapshot (current state is versioned first) + * GET /:code/overrides — override configs for profile + * PUT /:code/overrides — update override configs + * GET/PUT /scoring-config/:component — scoring config (component_config PG) + */ + +import { Router, type Request, type Response } from 'express'; +import { query } from '../config/database'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Identity for the version audit trail. The JWT signature was already +// verified by the global gate (config/jwt-verify.ts) — this only reads claims. +function changedBy(req: Request): string | null { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return null; + const parts = auth.slice(7).split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')); + return payload.email || payload.preferred_username || payload.sub || null; + } catch { + return null; + } +} + +/** Snapshot the CURRENT state of a profile (+overrides) into the version table. */ +async function snapshotProfile(code: string, user: string | null, note: string): Promise { + const rows = await query('SELECT * FROM input_type_profile WHERE profile_code = $1', [code]); + if (rows.length === 0) return null; + const overrides = await query( + 'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code', + [code], + ); + const result = await query( + `INSERT INTO input_type_profile_version (profile_code, version_no, snapshot, changed_by, change_note) + VALUES ($1::varchar, + COALESCE((SELECT MAX(version_no) FROM input_type_profile_version WHERE profile_code = $1::varchar), 0) + 1, + $2, $3, $4) + RETURNING version_no`, + [code, JSON.stringify({ ...rows[0], overrides }), user, note], + ); + return result[0].version_no; +} + +// Fields copied verbatim when importing a pipeline JSON (whitelist — ignore +// profile_id/created_date and any unknown keys so an export round-trips safely). +const IMPORTABLE_FIELDS = [ + 'profile_name', 'description', + 'weight_techniques', 'weight_claims', 'weight_ai_tampered', 'weight_source', + 'role_techniques', 'role_claims', 'role_ai_tampered', 'role_source', + 'min_components', 'primary_components', 'required_any', + 'missing_techniques', 'missing_claims', 'missing_ai_tampered', 'missing_source', + 'override_cap', 'confidence_config', 'ai_disclosure_multipliers', +] as const; + +// ============================================================================ +// POST /import — Create a pipeline from an exported JSON definition +// ============================================================================ + +router.post('/import', async (req: Request, res: Response) => { + try { + const body = req.body || {}; + // Accept either a raw profile object or { profile, overrides } (export shape). + const profile = body.profile || body; + const overrides = Array.isArray(body.overrides) ? body.overrides + : Array.isArray(profile.overrides) ? profile.overrides : []; + const code = body.new_code || profile.profile_code; + + if (!code || !/^[a-z0-9_-]{2,50}$/.test(code)) { + return res.status(400).json({ success: false, error: 'profile_code / new_code required (lowercase, [a-z0-9_-], 2-50 chars)' }); + } + const exists = await query('SELECT 1 FROM input_type_profile WHERE profile_code = $1', [code]); + if (exists.length > 0) { + return res.status(409).json({ success: false, error: `Profile '${code}' already exists (delete or pick a new_code)` }); + } + + // Weight sum guard (same rule as PUT) when all four are present. + const w = ['weight_techniques', 'weight_claims', 'weight_ai_tampered', 'weight_source']; + if (w.every(k => profile[k] != null)) { + const total = w.reduce((s, k) => s + Number(profile[k]), 0); + if (total !== 100) return res.status(400).json({ success: false, error: `Weights must sum to 100 (got ${total})` }); + } + + const cols = ['profile_code']; + const vals: any[] = [code]; + for (const f of IMPORTABLE_FIELDS) { + if (profile[f] === undefined) continue; + cols.push(f); + vals.push(f.includes('config') || f.includes('multipliers') + ? (typeof profile[f] === 'string' ? profile[f] : JSON.stringify(profile[f])) + : profile[f]); + } + cols.push('is_active'); vals.push(false); // imports land INACTIVE — activate explicitly + cols.push('created_date'); vals.push(new Date().toISOString().slice(0, 10)); + + const placeholders = vals.map((_, i) => `$${i + 1}`).join(', '); + const created = await query( + `INSERT INTO input_type_profile (${cols.join(', ')}) VALUES (${placeholders}) RETURNING *`, + vals, + ); + + // Import overrides too (best-effort: only rows whose override_code exists in schema). + for (const ov of overrides) { + if (!ov?.override_code) continue; + await query( + `INSERT INTO profile_override_config (profile_code, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT DO NOTHING`, + [code, ov.override_code, ov.enabled ?? true, ov.threshold ?? null, ov.bonus_per_unit ?? null, ov.bonus_fixed ?? null, ov.max_bonus ?? null], + ); + } + + await snapshotProfile(code, changedBy(req), 'imported from JSON'); + res.status(201).json({ success: true, data: created[0], message: `Pipeline '${code}' imported (inactive — activate to publish)` }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// GET / — List all profiles with override summary +// ============================================================================ + +router.get('/', async (_req: Request, res: Response) => { + try { + const profiles = await query(` + SELECT p.*, + (SELECT json_agg(json_build_object( + 'override_code', o.override_code, + 'enabled', o.enabled, + 'threshold', o.threshold, + 'bonus_per_unit', o.bonus_per_unit, + 'bonus_fixed', o.bonus_fixed, + 'max_bonus', o.max_bonus + ) ORDER BY o.override_code) + FROM profile_override_config o WHERE o.profile_code = p.profile_code + ) as overrides + FROM input_type_profile p + ORDER BY p.profile_id + `); + + res.json({ success: true, data: profiles, count: profiles.length }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// GET /:code — Single profile with overrides +// ============================================================================ + +router.get('/:code', async (req: Request, res: Response) => { + try { + const { code } = req.params; + + const profiles = await query( + 'SELECT * FROM input_type_profile WHERE profile_code = $1', + [code] + ); + if (profiles.length === 0) { + return res.status(404).json({ success: false, error: `Profile '${code}' not found` }); + } + + const overrides = await query( + 'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code', + [code] + ); + + res.json({ success: true, data: { ...profiles[0], overrides } }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// PUT /:code — Update profile weights, rules, confidence +// ============================================================================ + +router.put('/:code', async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { + profile_name, description, + weight_techniques, weight_claims, weight_ai_tampered, weight_source, + role_techniques, role_claims, role_ai_tampered, role_source, + min_components, primary_components, required_any, + missing_techniques, missing_claims, missing_ai_tampered, missing_source, + override_cap, confidence_config, ai_disclosure_multipliers, + } = req.body; + + // Validate weights sum to 100 if all provided + if (weight_techniques != null && weight_claims != null && weight_ai_tampered != null && weight_source != null) { + const total = weight_techniques + weight_claims + weight_ai_tampered + weight_source; + if (total !== 100) { + return res.status(400).json({ success: false, error: `Weights must sum to 100 (got ${total})` }); + } + } + + // Build SET clause dynamically (only update provided fields) + const updates: string[] = []; + const values: any[] = []; + let paramIdx = 1; + + const addField = (field: string, value: any) => { + if (value !== undefined) { + updates.push(`${field} = $${paramIdx++}`); + values.push(field.includes('config') || field.includes('multipliers') + ? (typeof value === 'string' ? value : JSON.stringify(value)) + : value + ); + } + }; + + addField('profile_name', profile_name); + addField('description', description); + addField('weight_techniques', weight_techniques); + addField('weight_claims', weight_claims); + addField('weight_ai_tampered', weight_ai_tampered); + addField('weight_source', weight_source); + addField('role_techniques', role_techniques); + addField('role_claims', role_claims); + addField('role_ai_tampered', role_ai_tampered); + addField('role_source', role_source); + addField('min_components', min_components); + addField('primary_components', primary_components); + addField('required_any', required_any); + addField('missing_techniques', missing_techniques); + addField('missing_claims', missing_claims); + addField('missing_ai_tampered', missing_ai_tampered); + addField('missing_source', missing_source); + addField('override_cap', override_cap); + addField('confidence_config', confidence_config); + addField('ai_disclosure_multipliers', ai_disclosure_multipliers); + + if (updates.length === 0) { + return res.status(400).json({ success: false, error: 'No fields to update' }); + } + + updates.push(`updated_date = CURRENT_DATE`); + values.push(code); + + // Version the previous state BEFORE mutating — GET /:code/versions shows + // the full change history; restore brings any snapshot back. + const versionNo = await snapshotProfile(code, changedBy(req), req.body.change_note || 'update'); + if (versionNo === null) { + return res.status(404).json({ success: false, error: `Profile '${code}' not found` }); + } + + const result = await query( + `UPDATE input_type_profile SET ${updates.join(', ')} WHERE profile_code = $${paramIdx} RETURNING *`, + values + ); + + if (result.length === 0) { + return res.status(404).json({ success: false, error: `Profile '${code}' not found` }); + } + + res.json({ success: true, data: result[0], version_saved: versionNo, message: `Profile '${code}' updated` }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// POST /:code/clone — Clone a profile into a new (inactive) pipeline definition +// ============================================================================ + +router.post('/:code/clone', async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { new_code, new_name } = req.body; + + if (!new_code || !/^[a-z0-9_-]{2,50}$/.test(new_code)) { + return res.status(400).json({ success: false, error: 'new_code is required (lowercase, [a-z0-9_-], 2-50 chars)' }); + } + + const source = await query('SELECT * FROM input_type_profile WHERE profile_code = $1', [code]); + if (source.length === 0) { + return res.status(404).json({ success: false, error: `Profile '${code}' not found` }); + } + const existing = await query('SELECT 1 FROM input_type_profile WHERE profile_code = $1', [new_code]); + if (existing.length > 0) { + return res.status(409).json({ success: false, error: `Profile '${new_code}' already exists` }); + } + + // Clone the profile row — new clones start UNPUBLISHED (is_active=false); + // activation is an explicit lifecycle step. + const cloned = await query( + `INSERT INTO input_type_profile ( + profile_code, profile_name, description, + weight_techniques, weight_claims, weight_ai_tampered, weight_source, + role_techniques, role_claims, role_ai_tampered, role_source, + min_components, primary_components, required_any, + missing_techniques, missing_claims, missing_ai_tampered, missing_source, + override_cap, confidence_config, ai_disclosure_multipliers, + is_active, created_date, updated_date) + SELECT $2, $3, description, + weight_techniques, weight_claims, weight_ai_tampered, weight_source, + role_techniques, role_claims, role_ai_tampered, role_source, + min_components, primary_components, required_any, + missing_techniques, missing_claims, missing_ai_tampered, missing_source, + override_cap, confidence_config, ai_disclosure_multipliers, + false, CURRENT_DATE, CURRENT_DATE + FROM input_type_profile WHERE profile_code = $1 + RETURNING *`, + [code, new_code, new_name || `${source[0].profile_name} (clone)`], + ); + + // Clone the override configs too + await query( + `INSERT INTO profile_override_config (profile_code, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus) + SELECT $2, override_code, enabled, threshold, bonus_per_unit, bonus_fixed, max_bonus + FROM profile_override_config WHERE profile_code = $1`, + [code, new_code], + ); + + await snapshotProfile(new_code, changedBy(req), `cloned from '${code}'`); + + res.status(201).json({ + success: true, + data: cloned[0], + message: `Profile '${new_code}' cloned from '${code}' (inactive — activate to publish)`, + }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// POST /:code/activate | /:code/deactivate — publish / unpublish +// ============================================================================ + +for (const action of ['activate', 'deactivate'] as const) { + router.post(`/:code/${action}`, async (req: Request, res: Response) => { + try { + const { code } = req.params; + const result = await query( + 'UPDATE input_type_profile SET is_active = $1, updated_date = CURRENT_DATE WHERE profile_code = $2 RETURNING profile_code, profile_name, is_active', + [action === 'activate', code], + ); + if (result.length === 0) { + return res.status(404).json({ success: false, error: `Profile '${code}' not found` }); + } + await snapshotProfile(code, changedBy(req), action); + res.json({ success: true, data: result[0], message: `Profile '${code}' ${action}d` }); + } catch (error) { + internalError(res, error); + } + }); +} + +// ============================================================================ +// GET /:code/versions — version history (snapshots, newest first) +// ============================================================================ + +router.get('/:code/versions', async (req: Request, res: Response) => { + try { + const versions = await query( + `SELECT version_id, version_no, changed_at, changed_by, change_note, snapshot + FROM input_type_profile_version + WHERE profile_code = $1 + ORDER BY version_no DESC`, + [req.params.code], + ); + res.json({ success: true, data: versions, count: versions.length }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// POST /:code/versions/:versionId/restore — roll back to a snapshot +// ============================================================================ + +router.post('/:code/versions/:versionId/restore', async (req: Request, res: Response) => { + try { + const { code, versionId } = req.params; + + const versions = await query( + 'SELECT snapshot, version_no FROM input_type_profile_version WHERE profile_code = $1 AND version_id = $2', + [code, versionId], + ); + if (versions.length === 0) { + return res.status(404).json({ success: false, error: `Version ${versionId} not found for '${code}'` }); + } + const snap = typeof versions[0].snapshot === 'string' ? JSON.parse(versions[0].snapshot) : versions[0].snapshot; + + // Version current state first so restore itself is reversible. + await snapshotProfile(code, changedBy(req), `pre-restore of v${versions[0].version_no}`); + + const result = await query( + `UPDATE input_type_profile SET + profile_name = $1, description = $2, + weight_techniques = $3, weight_claims = $4, weight_ai_tampered = $5, weight_source = $6, + role_techniques = $7, role_claims = $8, role_ai_tampered = $9, role_source = $10, + min_components = $11, primary_components = $12, required_any = $13, + missing_techniques = $14, missing_claims = $15, missing_ai_tampered = $16, missing_source = $17, + override_cap = $18, confidence_config = $19, ai_disclosure_multipliers = $20, + is_active = $21, updated_date = CURRENT_DATE + WHERE profile_code = $22 RETURNING *`, + [ + snap.profile_name, snap.description, + snap.weight_techniques, snap.weight_claims, snap.weight_ai_tampered, snap.weight_source, + snap.role_techniques, snap.role_claims, snap.role_ai_tampered, snap.role_source, + snap.min_components, snap.primary_components, snap.required_any, + snap.missing_techniques, snap.missing_claims, snap.missing_ai_tampered, snap.missing_source, + snap.override_cap, + snap.confidence_config ? JSON.stringify(snap.confidence_config) : null, + snap.ai_disclosure_multipliers ? JSON.stringify(snap.ai_disclosure_multipliers) : null, + snap.is_active, code, + ], + ); + + // Restore overrides from the snapshot as well + if (Array.isArray(snap.overrides)) { + for (const ov of snap.overrides) { + await query( + `UPDATE profile_override_config + SET enabled = $1, threshold = $2, bonus_per_unit = $3, bonus_fixed = $4, max_bonus = $5 + WHERE profile_code = $6 AND override_code = $7`, + [ov.enabled, ov.threshold, ov.bonus_per_unit, ov.bonus_fixed, ov.max_bonus, code, ov.override_code], + ); + } + } + + res.json({ + success: true, + data: result[0], + message: `Profile '${code}' restored to version ${versions[0].version_no}`, + }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// GET /:code/overrides — Override configs for a profile +// ============================================================================ + +router.get('/:code/overrides', async (req: Request, res: Response) => { + try { + const overrides = await query( + 'SELECT * FROM profile_override_config WHERE profile_code = $1 ORDER BY override_code', + [req.params.code] + ); + + res.json({ success: true, data: overrides, count: overrides.length }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// PUT /:code/overrides — Update override configs for a profile +// ============================================================================ + +router.put('/:code/overrides', async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { overrides } = req.body; + + if (!Array.isArray(overrides)) { + return res.status(400).json({ success: false, error: 'overrides must be an array' }); + } + + const results: any[] = []; + for (const ov of overrides) { + const result = await query( + `UPDATE profile_override_config + SET enabled = COALESCE($1, enabled), + threshold = COALESCE($2, threshold), + bonus_per_unit = COALESCE($3, bonus_per_unit), + bonus_fixed = COALESCE($4, bonus_fixed), + max_bonus = COALESCE($5, max_bonus) + WHERE profile_code = $6 AND override_code = $7 + RETURNING *`, + [ov.enabled, ov.threshold, ov.bonus_per_unit, ov.bonus_fixed, ov.max_bonus, code, ov.override_code] + ); + if (result.length > 0) results.push(result[0]); + } + + res.json({ success: true, data: results, message: `Updated ${results.length} overrides for '${code}'` }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// GET /scoring-config/:component — Read scoring_config from component_config PG +// ============================================================================ + +router.get('/scoring-config/:component', async (req: Request, res: Response) => { + try { + const rows = await query( + 'SELECT config_value FROM component_config WHERE component_code = $1 AND config_key = $2', + [req.params.component, 'scoring_config'] + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: `No scoring_config for ${req.params.component}` }); + } + const value = typeof rows[0].config_value === 'string' + ? JSON.parse(rows[0].config_value) + : rows[0].config_value; + res.json({ success: true, data: value }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// PUT /scoring-config/:component — Update scoring_config in component_config PG +// ============================================================================ + +router.put('/scoring-config/:component', async (req: Request, res: Response) => { + try { + const { component } = req.params; + const configValue = req.body; + if (!configValue || Object.keys(configValue).length === 0) { + return res.status(400).json({ success: false, error: 'Request body must contain the scoring config' }); + } + + const result = await query( + `UPDATE component_config SET config_value = $1 WHERE component_code = $2 AND config_key = 'scoring_config' RETURNING component_code, config_key`, + [JSON.stringify(configValue), component] + ); + + if (result.length === 0) { + return res.status(404).json({ success: false, error: `No scoring_config for ${component}` }); + } + + res.json({ success: true, message: `Scoring config updated for ${component}. Sync to Redis to apply.` }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/moderation-config.ts b/backend/services/orchestration-layer/didiFramework/src/routes/moderation-config.ts new file mode 100644 index 0000000..344a7a5 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/moderation-config.ts @@ -0,0 +1,115 @@ +/** + * MODERATION CONFIG ROUTES — single-row settings for HIL triage + brain client + * + * GET /api/moderation-config — read full config + * PUT /api/moderation-config — update fields (any subset), trigger sync to Redis + * + * Stored in bos_parammgmt.moderation_config (single row, config_id=1). + * Synced to Redis as didi:config:moderation:v1:settings. + * + * No POST/DELETE — config is single-row, fixed. + */ + +import { Router, type Request, type Response } from 'express'; +import { query } from '../config/database'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Whitelist of fields that can be updated (defense in depth — DB also has CHECKs) +const UPDATABLE_FIELDS = [ + 'triage_enabled', + 'confidence_low', + 'risk_grey_min', + 'risk_grey_max', + 'queue_relax_at', + 'queue_strict_at', + 'auto_tune_enabled', + 'brain_enabled', + 'brain_url', + 'brain_lookup_timeout_ms', + 'brain_write_timeout_ms', + 'brain_confidence_min_silver', + 'brain_semantic_threshold', + 'brain_per_component', +] as const; + +// ============================================================================ +// GET / — read full config (single row) +// ============================================================================ + +router.get('/', async (_req: Request, res: Response) => { + try { + const rows = await query( + `SELECT * FROM bos_parammgmt.moderation_config WHERE config_id = 1` + ); + if (rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'moderation_config row missing — did migration 011 run?', + }); + } + res.json({ success: true, data: rows[0] }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// PUT / — update any subset of allowed fields +// ============================================================================ + +router.put('/', async (req: Request, res: Response) => { + try { + const body = req.body ?? {}; + if (typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).json({ success: false, error: 'Body must be a JSON object' }); + } + + // Filter to whitelisted fields only — silently drop anything else + const updates: string[] = []; + const values: unknown[] = []; + let i = 1; + for (const field of UPDATABLE_FIELDS) { + if (Object.prototype.hasOwnProperty.call(body, field)) { + updates.push(`${field} = $${i}`); + values.push(field === 'brain_per_component' ? JSON.stringify(body[field]) : body[field]); + i++; + } + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: `No updatable fields in body. Allowed: ${UPDATABLE_FIELDS.join(', ')}`, + }); + } + + // Audit + const updatedBy = (req.headers['x-user-id'] as string | undefined) ?? null; + updates.push(`updated_by = $${i}`); + values.push(updatedBy); + i++; + + const sql = ` + UPDATE bos_parammgmt.moderation_config + SET ${updates.join(', ')} + WHERE config_id = 1 + RETURNING * + `; + const rows = await query(sql, values); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'moderation_config row missing' }); + } + + res.json({ + success: true, + data: rows[0], + message: 'Config updated. Sync to Redis to apply (POST /api/sync-redis).', + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/moderation-roles.ts b/backend/services/orchestration-layer/didiFramework/src/routes/moderation-roles.ts new file mode 100644 index 0000000..8fddad4 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/moderation-roles.ts @@ -0,0 +1,95 @@ +/** + * MODERATION ROLES ROUTES — Keycloak role → HIL permission mapping + * + * GET /api/moderation-roles — list all roles + * PUT /api/moderation-roles/:code — update permissions on a role + * + * Stored in bos_parammgmt.moderation_role. + * Synced to Redis as didi:config:moderation:v1:roles. + * + * No POST/DELETE — roles are fixed (moderator, senior_moderator). Permissions only toggle. + */ + +import { Router, type Request, type Response } from 'express'; +import { query } from '../config/database'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +const TOGGLE_FIELDS = ['can_resolve', 'can_escalate', 'can_force_gold_brain', 'is_active'] as const; +const LABEL_FIELD = 'role_label'; + +// ============================================================================ +// GET / — list all roles +// ============================================================================ + +router.get('/', async (_req: Request, res: Response) => { + try { + const rows = await query( + `SELECT role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active, created_at, updated_at + FROM bos_parammgmt.moderation_role ORDER BY role_code` + ); + res.json({ success: true, data: rows, count: rows.length }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// PUT /:code — update permissions (role_code immutable) +// ============================================================================ + +router.put('/:code', async (req: Request, res: Response) => { + try { + const { code } = req.params; + const body = req.body ?? {}; + + const updates: string[] = []; + const values: unknown[] = []; + let i = 1; + + for (const field of TOGGLE_FIELDS) { + if (Object.prototype.hasOwnProperty.call(body, field)) { + if (typeof body[field] !== 'boolean') { + return res.status(400).json({ success: false, error: `${field} must be boolean` }); + } + updates.push(`${field} = $${i}`); + values.push(body[field]); + i++; + } + } + + if (Object.prototype.hasOwnProperty.call(body, LABEL_FIELD)) { + if (typeof body[LABEL_FIELD] !== 'string' || !body[LABEL_FIELD].trim()) { + return res.status(400).json({ success: false, error: 'role_label must be non-empty string' }); + } + updates.push(`${LABEL_FIELD} = $${i}`); + values.push(body[LABEL_FIELD]); + i++; + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: `Nothing to update. Allowed fields: ${[...TOGGLE_FIELDS, LABEL_FIELD].join(', ')}`, + }); + } + + values.push(code); + const rows = await query( + `UPDATE bos_parammgmt.moderation_role + SET ${updates.join(', ')} + WHERE role_code = $${i} + RETURNING role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active, updated_at`, + values + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: `Role '${code}' not found` }); + } + res.json({ success: true, data: rows[0] }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/notifications.ts b/backend/services/orchestration-layer/didiFramework/src/routes/notifications.ts new file mode 100644 index 0000000..89ec350 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/notifications.ts @@ -0,0 +1,55 @@ +/** + * Admin/test endpoints for the email notification system. + * GET /api/notifications/health — verify SMTP connection (does not send) + * POST /api/notifications/test — send a test email + * POST /api/notifications/credit-reset — manually trigger Free-credit reset (debug) + */ +import { Router, Request, Response } from 'express'; +import { log } from '../config/logger'; +import { internalError } from '../config/error-response'; +import { isEmailEnabled, sendEmail, verifyEmailConnection } from '../config/email'; +import { resetFreeUserCredits } from '../services/credit-reset-cron'; + +const router = Router(); + +router.get('/health', async (req: Request, res: Response) => { + if (!isEmailEnabled()) { + return res.json({ success: false, configured: false, error: 'SMTP env vars not set' }); + } + const result = await verifyEmailConnection(); + res.json({ success: result.ok, configured: true, error: result.error }); +}); + +router.post('/test', async (req: Request, res: Response) => { + try { + const to = (req.body?.to as string) || process.env.SMTP_FROM_EMAIL; + if (!to) { + return res.status(400).json({ success: false, error: 'Missing "to" email in body' }); + } + const result = await sendEmail({ + to, + subject: 'DIDI · SMTP test email', + html: ` +

SMTP test successful

+

Your DIDI notification stack can reach ${process.env.SMTP_HOST}:${process.env.SMTP_PORT}.

+

Sent at ${new Date().toISOString()}

`, + text: `SMTP test successful. DIDI notifications stack can reach ${process.env.SMTP_HOST}:${process.env.SMTP_PORT}. Sent at ${new Date().toISOString()}`, + }); + res.json({ success: result.ok, ...result }); + } catch (error: any) { + log.error('Error in /notifications/test:', error); + internalError(res, error); + } +}); + +router.post('/credit-reset', async (req: Request, res: Response) => { + try { + const result = await resetFreeUserCredits(); + res.json({ success: true, data: result }); + } catch (error: any) { + log.error('Error in /notifications/credit-reset:', error); + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/overview.ts b/backend/services/orchestration-layer/didiFramework/src/routes/overview.ts new file mode 100644 index 0000000..7bbc34b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/overview.ts @@ -0,0 +1,87 @@ +import { Router, Request, Response } from 'express'; +import { query, checkHealth } from '../config/database'; +import { ApiResponse } from '../types'; + +const router = Router(); + +// GET framework overview statistics +router.get('/stats', async (req: Request, res: Response) => { + try { + const [ + dimensions, + techniques, + verdicts, + riskMappings, + sourceTypes, + platforms + ] = await Promise.all([ + query('SELECT COUNT(*) as count FROM dimension'), + query('SELECT COUNT(*) as count FROM technique'), + query('SELECT COUNT(*) as count FROM verdict_category'), + query('SELECT COUNT(*) as count FROM risk_mapping'), + query('SELECT COUNT(*) as count FROM source_type'), + query('SELECT COUNT(*) as count FROM platform') + ]); + + const stats = { + dimensions: parseInt(dimensions[0].count), + techniques: parseInt(techniques[0].count), + verdicts: parseInt(verdicts[0].count), + riskMappings: parseInt(riskMappings[0].count), + sourceTypes: parseInt(sourceTypes[0].count), + platforms: parseInt(platforms[0].count) + }; + + res.json({ + success: true, + data: stats + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET health check +router.get('/health', async (req: Request, res: Response) => { + const isHealthy = await checkHealth(); + res.json({ + success: isHealthy, + status: isHealthy ? 'healthy' : 'unhealthy', + service: 'didiFramework', + timestamp: new Date().toISOString() + }); +}); + +// GET docker containers health status +router.get('/docker-health', async (req: Request, res: Response) => { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + try { + const { stdout } = await execAsync('docker ps --format "{{.Names}}|{{.Status}}"'); + const services: Record = {}; + + stdout.trim().split('\n').forEach(line => { + const [name, status] = line.split('|'); + if (name && status) { + const isHealthy = status.includes('healthy') || + (status.includes('Up') && !status.includes('unhealthy')); + services[name] = isHealthy ? 'healthy' : + status.includes('unhealthy') ? 'unhealthy' : 'unknown'; + } + }); + + res.json({ success: true, services, timestamp: new Date().toISOString() }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Docker not available' + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/platforms.ts b/backend/services/orchestration-layer/didiFramework/src/routes/platforms.ts new file mode 100644 index 0000000..f891c0e --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/platforms.ts @@ -0,0 +1,131 @@ +/** + * Platforms Routes - FULL CRUD + * + * Platforms are leaf nodes (no children), can be deleted directly. + * They reference platform_modifier which needs safety check. + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { Platform, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Helper functions +const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +// GET all platforms with modifier info +router.get('/', async (req: Request, res: Response) => { + try { + const platforms = await query(` + SELECT p.*, pm.platform_modifier as modifier_name + FROM platform p + LEFT JOIN platform_modifier pm ON p.platform_modifier_id = pm.platform_modifier_id + ORDER BY p.platform_id + `); + res.json({ success: true, data: platforms, count: platforms.length }); + } catch (error) { + internalError(res, error); + } +}); + +// GET platform by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const platform = await queryOne( + 'SELECT * FROM platform WHERE platform_id = $1', + [req.params.id] + ); + if (!platform) { + return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' }); + } + res.json({ success: true, data: platform }); + } catch (error) { + internalError(res, error); + } +}); + +// POST create platform +router.post('/', async (req: Request, res: Response) => { + try { + const { platform_code, platform_name, platform_modifier_id, platform_score, notes } = req.body; + if (!platform_code || !platform_name) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: platform_code, platform_name' }); + } + + // Verify platform_modifier exists if provided + if (platform_modifier_id) { + const modifier = await queryOne('SELECT platform_modifier_id FROM platform_modifier WHERE platform_modifier_id = $1', [platform_modifier_id]); + if (!modifier) { + return res.status(400).json({ success: false, error: 'Platform modifier specificat nu există' }); + } + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'platform', 'platform_id'); + const insertResult = await client.query(` + INSERT INTO platform (platform_id, platform_code, platform_name, platform_modifier_id, platform_score, notes) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [id, platform_code, platform_name, platform_modifier_id || 1, platform_score || 0, notes || '']); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Platforma a fost creată cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT update platform +router.put('/:id', async (req: Request, res: Response) => { + try { + const { platform_code, platform_name, platform_modifier_id, platform_score, notes } = req.body; + + // Verify platform_modifier exists if provided + if (platform_modifier_id) { + const modifier = await queryOne('SELECT platform_modifier_id FROM platform_modifier WHERE platform_modifier_id = $1', [platform_modifier_id]); + if (!modifier) { + return res.status(400).json({ success: false, error: 'Platform modifier specificat nu există' }); + } + } + + const platform = await queryOne(` + UPDATE platform + SET platform_code = COALESCE($1, platform_code), + platform_name = COALESCE($2, platform_name), + platform_modifier_id = COALESCE($3, platform_modifier_id), + platform_score = COALESCE($4, platform_score), + notes = COALESCE($5, notes) + WHERE platform_id = $6 + RETURNING * + `, [platform_code, platform_name, platform_modifier_id, platform_score, notes, req.params.id]); + + if (!platform) { + return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' }); + } + res.json({ success: true, data: platform, message: 'Platforma a fost actualizată cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE platform +router.delete('/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM platform WHERE platform_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Platforma nu a fost găsită' }); + } + res.json({ success: true, message: 'Platforma a fost ștearsă cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/prompts.ts b/backend/services/orchestration-layer/didiFramework/src/routes/prompts.ts new file mode 100644 index 0000000..be94603 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/prompts.ts @@ -0,0 +1,184 @@ +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; + +const router = Router(); + +// Calea către fișierele MD din agent +// DIDI pipeline: workspace/pipelines/didi/ +// Legacy: missinfo_docs/ (pentru techniques, sources, claims, verdict) +const DIDI_PIPELINE_PATH = process.env.DIDI_PIPELINE_PATH || '/app/pipelines/didi'; +const MISSINFO_DOCS_PATH = process.env.MISSINFO_DOCS_PATH || '/app/missinfo_docs'; + +// Mapare step -> {basePath, filename} +interface StepFile { + basePath: string; + filename: string; +} + +const STEP_FILES: Record = { + 'intake': { basePath: DIDI_PIPELINE_PATH, filename: 'intake_eligibility.md' }, + 'techniques': { basePath: MISSINFO_DOCS_PATH, filename: 'manipulation_techniques_v3.md' }, + 'sources': { basePath: MISSINFO_DOCS_PATH, filename: 'source_assessment.md' }, + 'claims': { basePath: MISSINFO_DOCS_PATH, filename: 'claims_analysis.md' }, + 'verdict': { basePath: MISSINFO_DOCS_PATH, filename: 'main_verdict.md' }, +}; + +// GET /api/prompts - Lista toate fișierele disponibile +router.get('/', async (req: Request, res: Response) => { + try { + const files = Object.entries(STEP_FILES).map(([step, stepFile]) => { + const filePath = path.join(stepFile.basePath, stepFile.filename); + const exists = fs.existsSync(filePath); + let size = 0; + if (exists) { + const stats = fs.statSync(filePath); + size = stats.size; + } + return { + step, + filename: stepFile.filename, + exists, + size, + path: filePath, + basePath: stepFile.basePath, + }; + }); + + res.json({ + success: true, + data: files, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list prompts', + }); + } +}); + +// GET /api/prompts/:step - Conținutul pentru un pas specific +router.get('/:step', async (req: Request, res: Response) => { + try { + const { step } = req.params; + + if (!STEP_FILES[step]) { + return res.status(404).json({ + success: false, + error: `Unknown step: ${step}. Valid steps: ${Object.keys(STEP_FILES).join(', ')}`, + }); + } + + const stepFile = STEP_FILES[step]; + const filePath = path.join(stepFile.basePath, stepFile.filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + error: `File not found: ${stepFile.filename}`, + path: filePath, + }); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + + res.json({ + success: true, + data: { + step, + filename: stepFile.filename, + content, + charCount: content.length, + lineCount: content.split('\n').length, + basePath: stepFile.basePath, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to read prompt file', + }); + } +}); + +// GET /api/prompts/:step/sections - Extrage secțiuni specifice din fișier +router.get('/:step/sections', async (req: Request, res: Response) => { + try { + const { step } = req.params; + + if (!STEP_FILES[step]) { + return res.status(404).json({ + success: false, + error: `Unknown step: ${step}`, + }); + } + + const stepFile = STEP_FILES[step]; + const filePath = path.join(stepFile.basePath, stepFile.filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + error: `File not found: ${stepFile.filename}`, + }); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + + // Parsează secțiunile (bazat pe headings #) + const sections: Array<{ level: number; title: string; content: string }> = []; + const lines = content.split('\n'); + let currentSection: { level: number; title: string; content: string[] } | null = null; + + for (const line of lines) { + const headingMatch = line.match(/^(#{1,3})\s+(.+)$/); + + if (headingMatch) { + // Save previous section + if (currentSection) { + sections.push({ + level: currentSection.level, + title: currentSection.title, + content: currentSection.content.join('\n').trim(), + }); + } + + // Start new section + currentSection = { + level: headingMatch[1].length, + title: headingMatch[2], + content: [], + }; + } else if (currentSection) { + currentSection.content.push(line); + } + } + + // Don't forget last section + if (currentSection) { + sections.push({ + level: currentSection.level, + title: currentSection.title, + content: currentSection.content.join('\n').trim(), + }); + } + + res.json({ + success: true, + data: { + step, + filename: stepFile.filename, + sections, + sectionCount: sections.length, + basePath: stepFile.basePath, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to parse sections', + }); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers.ts new file mode 100644 index 0000000..50fe1b9 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers.ts @@ -0,0 +1,6 @@ +/** + * Re-export of the new providers barrel. Kept at this path so server.ts + * (which imports `./routes/providers`) continues to work unchanged after + * the 921-LOC → 8-file split. See ./providers/index.ts for the routing map. + */ +export { default } from './providers/index'; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/_helpers.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/_helpers.ts new file mode 100644 index 0000000..eaf1b4c --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/_helpers.ts @@ -0,0 +1,98 @@ +/** + * Shared types + helpers for providers/ sub-routers (configs, models, + * assignments, keys, all, prompts, test). + * + * `createParameter` allocates a new row in the generic `parameter` table + * with parameter_type chosen by caller (40=provider, 41=model, 42=assignment, + * 43=key, 44=prompt). Used by every POST endpoint. + * + * `maskApiKey` is the canonical display format — never return raw keys. + */ +import { PoolClient } from 'pg'; + +// ============================================================================ +// SHARED TYPES +// ============================================================================ + +export interface LlmProvider { + provider_id: number; + provider_code: string; + provider_name: string; + base_url: string; + auth_type: string; + is_active: boolean; + priority: number; + rate_limit_rpm: number; + rate_limit_tpm: number; + description: string; + parameter_id: number; +} + +export interface LlmModel { + model_id: number; + provider_id: number; + model_code: string; + model_name: string; + context_window: number; + max_output_tokens: number; + input_cost_per_1m: number; + output_cost_per_1m: number; + supports_streaming: boolean; + supports_tools: boolean; + supports_vision: boolean; + is_active: boolean; + description: string; +} + +export interface ComponentAssignment { + assignment_id: number; + component_code: string; + component_name: string; + provider_id: number; + model_id: number; + fallback_provider_id: number; + fallback_model_id: number; + temperature: number; + max_tokens: number; + timeout_ms: number; + is_enabled: boolean; + description: string; +} + +export interface ApiKey { + api_key_id: number; + provider_id: number; + key_name: string; + api_key_value: string; + key_prefix: string; + is_active: boolean; + usage_count: number; + last_used_at: string; + expires_at: string; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** + * Allocate a new parameter row. Used inside transactions when creating + * a new provider/model/assignment/key/prompt to satisfy the parameter FK. + */ +export const createParameter = async (client: PoolClient, parameterType: number): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +}; + +/** Mask API key for display: keep first 7 + last 4 chars. */ +export const maskApiKey = (key: string): string => { + if (!key || key.length < 8) return '***'; + return key.substring(0, 7) + '...' + key.substring(key.length - 4); +}; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/all.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/all.ts new file mode 100644 index 0000000..2f2e524 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/all.ts @@ -0,0 +1,62 @@ +/** + * Providers/all.ts — combined endpoint that returns ALL provider data + * (providers + models + assignments + keys + prompts) in a single call. + * Used by the admin dashboard to populate the "Providers Management" page + * without making 5 separate fetch calls. + * + * GET /all + */ +import { Router, Request, Response } from 'express'; +import { query } from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { maskApiKey } from './_helpers'; + +const router = Router(); + +router.get('/all', async (req: Request, res: Response) => { + try { + const [providers, models, assignments, keys] = await Promise.all([ + query('SELECT * FROM llm_provider ORDER BY priority'), + query(` + SELECT m.*, p.provider_code, p.provider_name + FROM llm_model m + JOIN llm_provider p ON m.provider_id = p.provider_id + ORDER BY p.priority, m.model_name + `), + query(` + SELECT csa.*, p.provider_code, p.provider_name, m.model_code, m.model_name + FROM component_stage_assignment csa + JOIN llm_provider p ON csa.provider_id = p.provider_id + JOIN llm_model m ON csa.model_id = m.model_id + ORDER BY csa.component_code, csa.stage_code, csa.fallback_order + `), + query(` + SELECT k.api_key_id, k.provider_id, k.key_name, k.key_prefix, k.is_active, + k.usage_count, k.last_used_at, k.expires_at, p.provider_code + FROM provider_api_key k + JOIN llm_provider p ON k.provider_id = p.provider_id + ORDER BY p.provider_name, k.key_name + `), + ]); + + res.json({ + success: true, + data: { + providers, + models, + assignments, + keys, + }, + counts: { + providers: providers.length, + models: models.length, + assignments: assignments.length, + keys: keys.length, + }, + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/assignments.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/assignments.ts new file mode 100644 index 0000000..42ada11 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/assignments.ts @@ -0,0 +1,182 @@ +/** + * Providers/assignments.ts — Component → (provider, model) assignments CRUD. + * Each row says "for component X use this provider+model with these LLM params". + * Worker pipeline reads these to decide which model to call per component. + * + * GET /assignments — list (joins provider + model + fallbacks) + * GET /assignments/:id — single + * POST /assignments — create (parameter type 42) + * PUT /assignments/:id — partial update + * DELETE /assignments/:id + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { createParameter, type ComponentAssignment } from './_helpers'; + +const router = Router(); + +router.get('/assignments', async (req: Request, res: Response) => { + try { + // Optional ?tier=free|premium filter + const tier = typeof req.query.tier === 'string' ? req.query.tier : null; + const params: any[] = []; + let tierFilter = ''; + if (tier === 'free' || tier === 'premium') { + tierFilter = ' AND csa.tier = $1'; + params.push(tier); + } + + const assignments = await query(` + SELECT + csa.stage_id, + csa.component_code, + csa.stage_code, + csa.stage_name, + csa.fallback_order, + csa.tier, + csa.temperature, + csa.max_tokens, + csa.timeout_ms, + csa.is_enabled, + csa.description, + p.provider_id, + p.provider_code, + p.provider_name, + m.model_id, + m.model_code, + m.model_name, + m.context_window, + m.input_cost_per_1m, + m.output_cost_per_1m + FROM component_stage_assignment csa + JOIN llm_provider p ON csa.provider_id = p.provider_id + JOIN llm_model m ON csa.model_id = m.model_id + WHERE csa.is_enabled = true${tierFilter} + ORDER BY csa.component_code, csa.stage_code, csa.tier, csa.fallback_order + `, params); + res.json({ success: true, data: assignments, count: assignments.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/assignments/:id', async (req: Request, res: Response) => { + try { + const assignment = await queryOne(` + SELECT + csa.stage_id, + csa.component_code, + csa.stage_code, + csa.stage_name, + csa.fallback_order, + csa.tier, + csa.temperature, + csa.max_tokens, + csa.timeout_ms, + csa.is_enabled, + csa.description, + p.provider_id, + p.provider_code, + p.provider_name, + m.model_id, + m.model_code, + m.model_name + FROM component_stage_assignment csa + JOIN llm_provider p ON csa.provider_id = p.provider_id + JOIN llm_model m ON csa.model_id = m.model_id + WHERE csa.stage_id = $1 + `, [req.params.id]); + + if (!assignment) { + return res.status(404).json({ success: false, error: 'Assignment not found' }); + } + res.json({ success: true, data: assignment }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/assignments', async (req: Request, res: Response) => { + try { + const { + component_code, stage_code, stage_name, fallback_order, tier, + provider_id, model_id, temperature, max_tokens, + timeout_ms, is_enabled, description + } = req.body; + + if (!component_code || !stage_code || !provider_id || !model_id) { + return res.status(400).json({ + success: false, + error: 'Required fields: component_code, stage_code, provider_id, model_id' + }); + } + + // Validate tier (defaults to 'free' if missing) + const resolvedTier = tier === 'premium' ? 'premium' : 'free'; + + const result = await query(` + INSERT INTO component_stage_assignment ( + component_code, stage_code, stage_name, fallback_order, tier, + provider_id, model_id, temperature, max_tokens, + timeout_ms, is_enabled, description + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `, [ + component_code, stage_code, stage_name || stage_code, + fallback_order || 1, resolvedTier, provider_id, model_id, + temperature || 0, max_tokens || 4096, timeout_ms || 120000, + is_enabled !== false, description || 'primary' + ]); + + res.status(201).json({ success: true, data: result[0], message: 'Assignment created successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/assignments/:id', async (req: Request, res: Response) => { + try { + const { provider_id, model_id, temperature, max_tokens, timeout_ms, is_enabled, description, tier } = req.body; + + // Only accept valid tier values (free/premium) or null/undefined to preserve current value + const tierParam = (tier === 'free' || tier === 'premium') ? tier : null; + + const assignment = await queryOne(` + UPDATE component_stage_assignment + SET provider_id = COALESCE($1, provider_id), + model_id = COALESCE($2, model_id), + temperature = COALESCE($3, temperature), + max_tokens = COALESCE($4, max_tokens), + timeout_ms = COALESCE($5, timeout_ms), + is_enabled = COALESCE($6, is_enabled), + description = COALESCE($7, description), + tier = COALESCE($8, tier), + updated_date = CURRENT_DATE + WHERE stage_id = $9 + RETURNING * + `, [provider_id, model_id, temperature, max_tokens, timeout_ms, is_enabled, description, tierParam, req.params.id]); + + if (!assignment) { + return res.status(404).json({ success: false, error: 'Assignment not found' }); + } + res.json({ success: true, data: assignment, message: 'Assignment updated successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/assignments/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM component_stage_assignment WHERE stage_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Assignment not found' }); + } + res.json({ success: true, message: 'Assignment deleted successfully', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/configs.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/configs.ts new file mode 100644 index 0000000..c17a94b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/configs.ts @@ -0,0 +1,126 @@ +/** + * Providers/configs.ts — LLM Providers CRUD. + * GET /configs — list all + * GET /configs/:id — single + * POST /configs — create (allocates parameter row, type 40) + * PUT /configs/:id — partial update + * DELETE /configs/:id — refuses if any models or assignments depend on it + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import { createParameter, type LlmProvider } from './_helpers'; + +const router = Router(); + +router.get('/configs', async (req: Request, res: Response) => { + try { + const providers = await query( + 'SELECT * FROM llm_provider ORDER BY priority, provider_id' + ); + res.json({ success: true, data: providers, count: providers.length }); + } catch (error) { + log.error('[providers] Error fetching providers:', error); + internalError(res, error); + } +}); + +router.get('/configs/:id', async (req: Request, res: Response) => { + try { + const provider = await queryOne( + 'SELECT * FROM llm_provider WHERE provider_id = $1', + [req.params.id] + ); + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider not found' }); + } + res.json({ success: true, data: provider }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/configs', async (req: Request, res: Response) => { + try { + const { provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description } = req.body; + + if (!provider_code || !provider_name) { + return res.status(400).json({ success: false, error: 'Required fields: provider_code, provider_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 40); // 40 = provider parameter type + const insertResult = await client.query(` + INSERT INTO llm_provider (provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `, [provider_code, provider_name, base_url || '', auth_type || 'bearer', is_active !== false, priority || 100, rate_limit_rpm || 60, rate_limit_tpm || 100000, description || '', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Provider created successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/configs/:id', async (req: Request, res: Response) => { + try { + const { provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description } = req.body; + + const provider = await queryOne(` + UPDATE llm_provider + SET provider_code = COALESCE($1, provider_code), + provider_name = COALESCE($2, provider_name), + base_url = COALESCE($3, base_url), + auth_type = COALESCE($4, auth_type), + is_active = COALESCE($5, is_active), + priority = COALESCE($6, priority), + rate_limit_rpm = COALESCE($7, rate_limit_rpm), + rate_limit_tpm = COALESCE($8, rate_limit_tpm), + description = COALESCE($9, description), + updated_date = CURRENT_DATE + WHERE provider_id = $10 + RETURNING * + `, [provider_code, provider_name, base_url, auth_type, is_active, priority, rate_limit_rpm, rate_limit_tpm, description, req.params.id]); + + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider not found' }); + } + res.json({ success: true, data: provider, message: 'Provider updated successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/configs/:id', async (req: Request, res: Response) => { + try { + // Check if provider has models or assignments + const [models, assignments] = await Promise.all([ + query('SELECT model_id FROM llm_model WHERE provider_id = $1', [req.params.id]), + query('SELECT stage_id FROM component_stage_assignment WHERE provider_id = $1', [req.params.id]), + ]); + + if (models.length > 0 || assignments.length > 0) { + return res.status(409).json({ + success: false, + error: 'Cannot delete provider with existing models or assignments', + dependencies: { + models: models.length, + assignments: assignments.length, + }, + }); + } + + const result = await query('DELETE FROM llm_provider WHERE provider_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Provider not found' }); + } + res.json({ success: true, message: 'Provider deleted successfully', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/index.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/index.ts new file mode 100644 index 0000000..ce88f89 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/index.ts @@ -0,0 +1,30 @@ +/** + * didiFramework — /api/providers barrel router. + * + * The original 921-line providers.ts was split into 7 sub-routers (configs, + * models, assignments, keys, all, prompts, test) + _helpers (shared types + + * createParameter + maskApiKey). + * + * server.ts mounts this at `/api/providers` so all the same paths + * (/api/providers/configs, /api/providers/models, etc.) work unchanged. + */ +import { Router } from 'express'; +import configsRouter from './configs'; +import modelsRouter from './models'; +import assignmentsRouter from './assignments'; +import keysRouter from './keys'; +import allRouter from './all'; +import promptsRouter from './prompts'; +import testRouter from './test'; + +const router = Router(); + +router.use(configsRouter); +router.use(modelsRouter); +router.use(assignmentsRouter); +router.use(keysRouter); +router.use(allRouter); +router.use(promptsRouter); +router.use(testRouter); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/keys.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/keys.ts new file mode 100644 index 0000000..2001278 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/keys.ts @@ -0,0 +1,147 @@ +/** + * Providers/keys.ts — API Keys CRUD. + * Keys are stored encrypted; GET endpoints return them masked via maskApiKey. + * + * GET /keys — list (masked, JOIN provider) + * GET /keys/:id — single (masked) + * POST /keys — create (parameter type 43) + * PUT /keys/:id — rotate / metadata update + * DELETE /keys/:id + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { createParameter, maskApiKey, type ApiKey } from './_helpers'; + +const router = Router(); + +router.get('/keys', async (req: Request, res: Response) => { + try { + const { provider_id } = req.query; + let sql = ` + SELECT k.api_key_id, k.provider_id, k.key_name, k.key_prefix, k.is_active, + k.usage_count, k.last_used_at, k.expires_at, k.created_by, k.created_date, + p.provider_code, p.provider_name + FROM provider_api_key k + JOIN llm_provider p ON k.provider_id = p.provider_id + `; + const params: any[] = []; + + if (provider_id) { + sql += ' WHERE k.provider_id = $1'; + params.push(provider_id); + } + + sql += ' ORDER BY p.provider_name, k.key_name'; + + const keys = await query(sql, params); + + // Don't expose actual API key values in list + res.json({ success: true, data: keys, count: keys.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/keys/:id', async (req: Request, res: Response) => { + try { + const key = await queryOne(` + SELECT k.*, p.provider_code, p.provider_name + FROM provider_api_key k + JOIN llm_provider p ON k.provider_id = p.provider_id + WHERE k.api_key_id = $1 + `, [req.params.id]); + + if (!key) { + return res.status(404).json({ success: false, error: 'API key not found' }); + } + + // Return masked key + res.json({ + success: true, + data: { + ...key, + api_key_value: maskApiKey(key.api_key_value), + }, + }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/keys', async (req: Request, res: Response) => { + try { + const { provider_id, key_name, api_key_value, is_active, expires_at, created_by } = req.body; + + if (!provider_id || !key_name || !api_key_value) { + return res.status(400).json({ + success: false, + error: 'Required fields: provider_id, key_name, api_key_value' + }); + } + + // Extract key prefix (first 7 chars for display) + const key_prefix = api_key_value.substring(0, 7); + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 43); // 43 = api key parameter type + const insertResult = await client.query(` + INSERT INTO provider_api_key (provider_id, key_name, api_key_value, key_prefix, is_active, expires_at, created_by, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING api_key_id, provider_id, key_name, key_prefix, is_active, usage_count, expires_at, created_by, created_date + `, [provider_id, key_name, api_key_value, key_prefix, is_active !== false, expires_at || null, created_by || 'admin', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'API key created successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/keys/:id', async (req: Request, res: Response) => { + try { + const { key_name, api_key_value, is_active, expires_at } = req.body; + + let sql = ` + UPDATE provider_api_key + SET key_name = COALESCE($1, key_name), + is_active = COALESCE($2, is_active), + expires_at = $3, + updated_date = CURRENT_DATE + `; + const params: any[] = [key_name, is_active, expires_at]; + + // Only update key value if provided + if (api_key_value) { + sql += `, api_key_value = $4, key_prefix = $5`; + params.push(api_key_value, api_key_value.substring(0, 7)); + } + + sql += ` WHERE api_key_id = $${params.length + 1} RETURNING api_key_id, provider_id, key_name, key_prefix, is_active, usage_count, expires_at, created_by, created_date`; + params.push(req.params.id); + + const key = await queryOne(sql, params); + + if (!key) { + return res.status(404).json({ success: false, error: 'API key not found' }); + } + res.json({ success: true, data: key, message: 'API key updated successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/keys/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM provider_api_key WHERE api_key_id = $1 RETURNING api_key_id', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'API key not found' }); + } + res.json({ success: true, message: 'API key deleted successfully', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/models.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/models.ts new file mode 100644 index 0000000..a457544 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/models.ts @@ -0,0 +1,169 @@ +/** + * Providers/models.ts — LLM Models CRUD. + * GET /models — list (optional ?provider_id filter), JOIN provider + * GET /models/:id — single, JOIN provider + * POST /models — create (parameter type 41) + * PUT /models/:id — partial update + * DELETE /models/:id — refuses if any assignments depend on it + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { internalError } from '../../config/error-response'; +import { createParameter, type LlmModel } from './_helpers'; + +const router = Router(); + +router.get('/models', async (req: Request, res: Response) => { + try { + const { provider_id } = req.query; + let sql = ` + SELECT m.*, p.provider_code, p.provider_name + FROM llm_model m + JOIN llm_provider p ON m.provider_id = p.provider_id + `; + const params: any[] = []; + + if (provider_id) { + sql += ' WHERE m.provider_id = $1'; + params.push(provider_id); + } + + sql += ' ORDER BY p.priority, m.model_name'; + + const models = await query(sql, params); + res.json({ success: true, data: models, count: models.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/models/:id', async (req: Request, res: Response) => { + try { + const model = await queryOne(` + SELECT m.*, p.provider_code, p.provider_name + FROM llm_model m + JOIN llm_provider p ON m.provider_id = p.provider_id + WHERE m.model_id = $1 + `, [req.params.id]); + + if (!model) { + return res.status(404).json({ success: false, error: 'Model not found' }); + } + res.json({ success: true, data: model }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/models', async (req: Request, res: Response) => { + try { + const { + provider_id, model_code, model_name, context_window, max_output_tokens, + input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools, + supports_vision, is_active, description, + deployment, compute_target, quantization, capabilities + } = req.body; + + if (!provider_id || !model_code || !model_name) { + return res.status(400).json({ success: false, error: 'Required fields: provider_id, model_code, model_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 41); // 41 = model parameter type + const insertResult = await client.query(` + INSERT INTO llm_model ( + provider_id, model_code, model_name, context_window, max_output_tokens, + input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools, + supports_vision, is_active, description, parameter_id, + deployment, compute_target, quantization, capabilities + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING * + `, [ + provider_id, model_code, model_name, context_window || 32000, max_output_tokens || 4096, + input_cost_per_1m || 0, output_cost_per_1m || 0, supports_streaming !== false, + supports_tools !== false, supports_vision === true, is_active !== false, + description || '', parameterId, + deployment || 'remote', compute_target || null, quantization || null, + JSON.stringify(Array.isArray(capabilities) ? capabilities : ['text']) + ]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Model created successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/models/:id', async (req: Request, res: Response) => { + try { + const { + provider_id, model_code, model_name, context_window, max_output_tokens, + input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools, + supports_vision, is_active, description, + deployment, compute_target, quantization, capabilities + } = req.body; + + const model = await queryOne(` + UPDATE llm_model + SET provider_id = COALESCE($1, provider_id), + model_code = COALESCE($2, model_code), + model_name = COALESCE($3, model_name), + context_window = COALESCE($4, context_window), + max_output_tokens = COALESCE($5, max_output_tokens), + input_cost_per_1m = COALESCE($6, input_cost_per_1m), + output_cost_per_1m = COALESCE($7, output_cost_per_1m), + supports_streaming = COALESCE($8, supports_streaming), + supports_tools = COALESCE($9, supports_tools), + supports_vision = COALESCE($10, supports_vision), + is_active = COALESCE($11, is_active), + description = COALESCE($12, description), + deployment = COALESCE($13, deployment), + compute_target = COALESCE($14, compute_target), + quantization = COALESCE($15, quantization), + capabilities = COALESCE($16, capabilities), + updated_date = CURRENT_DATE + WHERE model_id = $17 + RETURNING * + `, [provider_id, model_code, model_name, context_window, max_output_tokens, input_cost_per_1m, output_cost_per_1m, supports_streaming, supports_tools, supports_vision, is_active, description, + deployment, compute_target, quantization, + capabilities !== undefined ? JSON.stringify(capabilities) : null, + req.params.id]); + + if (!model) { + return res.status(404).json({ success: false, error: 'Model not found' }); + } + res.json({ success: true, data: model, message: 'Model updated successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/models/:id', async (req: Request, res: Response) => { + try { + // Check if model is used in assignments + const assignments = await query( + 'SELECT stage_id FROM component_stage_assignment WHERE model_id = $1', + [req.params.id] + ); + + if (assignments.length > 0) { + return res.status(409).json({ + success: false, + error: 'Cannot delete model used in component assignments', + dependencies: { assignments: assignments.length }, + }); + } + + const result = await query('DELETE FROM llm_model WHERE model_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Model not found' }); + } + res.json({ success: true, message: 'Model deleted successfully', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/prompts.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/prompts.ts new file mode 100644 index 0000000..e2aac26 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/prompts.ts @@ -0,0 +1,147 @@ +/** + * Providers/prompts.ts — Component prompts CRUD. + * System + user-template prompts per component (techniques screening, + * claims extraction, etc). Stored in DB, synced to Redis at /api/sync-redis. + * + * GET /prompts — list all + * GET /prompts/:id — single + * POST /prompts — create (parameter type 44) + * PUT /prompts/:id — partial update + * DELETE /prompts/:id + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import { createParameter } from './_helpers'; + +const router = Router(); + +router.get('/prompts', async (req: Request, res: Response) => { + try { + const { component, stage } = req.query; + let sql = 'SELECT * FROM bos_parammgmt.component_prompt'; + const params: any[] = []; + const conditions: string[] = []; + + if (component) { + params.push(component); + conditions.push(`component_code = $${params.length}`); + } + if (stage) { + params.push(stage); + conditions.push(`stage_code = $${params.length}`); + } + + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND '); + } + sql += ' ORDER BY component_code, stage_code'; + + const prompts = await query(sql, params); + res.json({ success: true, data: prompts, count: prompts.length }); + } catch (error) { + log.error('[providers] Error fetching prompts:', error); + internalError(res, error); + } +}); + +router.get('/prompts/:id', async (req: Request, res: Response) => { + try { + const prompt = await queryOne( + 'SELECT * FROM bos_parammgmt.component_prompt WHERE prompt_id = $1', + [req.params.id] + ); + if (!prompt) { + return res.status(404).json({ success: false, error: 'Prompt not found' }); + } + res.json({ success: true, data: prompt }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/prompts', async (req: Request, res: Response) => { + try { + const { component_code, stage_code, system_prompt, user_template, description } = req.body; + + if (!component_code || !stage_code || !system_prompt || !user_template) { + return res.status(400).json({ + success: false, + error: 'Required fields: component_code, stage_code, system_prompt, user_template', + }); + } + + // Check if prompt already exists for this component+stage + const existing = await queryOne( + 'SELECT prompt_id FROM bos_parammgmt.component_prompt WHERE component_code = $1 AND stage_code = $2', + [component_code, stage_code] + ); + + if (existing) { + return res.status(409).json({ + success: false, + error: `Prompt already exists for ${component_code}/${stage_code}. Use PUT to update.`, + }); + } + + const maxResult = await queryOne<{ next_id: number }>( + 'SELECT COALESCE(MAX(prompt_id), 0) + 1 as next_id FROM bos_parammgmt.component_prompt' + ); + const nextId = maxResult?.next_id || 1; + + const result = await queryOne(` + INSERT INTO bos_parammgmt.component_prompt (prompt_id, component_code, stage_code, system_prompt, user_template, description, created_date, updated_date, + system_prompt_ro, user_template_ro) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE, $7, $8) + RETURNING * + `, [nextId, component_code, stage_code, system_prompt, user_template, description || `${component_code} ${stage_code}`, + req.body.system_prompt_ro || null, req.body.user_template_ro || null]); + + res.status(201).json({ success: true, data: result, message: 'Prompt created successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/prompts/:id', async (req: Request, res: Response) => { + try { + const { system_prompt, user_template, description, system_prompt_ro, user_template_ro } = req.body; + + const prompt = await queryOne(` + UPDATE bos_parammgmt.component_prompt + SET system_prompt = COALESCE($1, system_prompt), + user_template = COALESCE($2, user_template), + description = COALESCE($3, description), + system_prompt_ro = COALESCE($5, system_prompt_ro), + user_template_ro = COALESCE($6, user_template_ro), + updated_date = CURRENT_DATE + WHERE prompt_id = $4 + RETURNING * + `, [system_prompt, user_template, description, req.params.id, system_prompt_ro, user_template_ro]); + + if (!prompt) { + return res.status(404).json({ success: false, error: 'Prompt not found' }); + } + res.json({ success: true, data: prompt, message: 'Prompt updated successfully' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/prompts/:id', async (req: Request, res: Response) => { + try { + const result = await query( + 'DELETE FROM bos_parammgmt.component_prompt WHERE prompt_id = $1 RETURNING *', + [req.params.id] + ); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Prompt not found' }); + } + res.json({ success: true, message: 'Prompt deleted successfully', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/providers/test.ts b/backend/services/orchestration-layer/didiFramework/src/routes/providers/test.ts new file mode 100644 index 0000000..bffca71 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/providers/test.ts @@ -0,0 +1,98 @@ +/** + * Providers/test.ts — utility endpoint to test a provider connection. + * POST /test/:providerId — sends a small "hello" request to the provider's + * base_url with the active API key. Returns success/failure + latency. + * Used by the admin dashboard to verify a provider is reachable before + * relying on it in production analyses. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne } from '../../config/database'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import type { ApiKey, LlmProvider } from './_helpers'; + +const router = Router(); + +router.post('/test/:providerId', async (req: Request, res: Response) => { + try { + const provider = await queryOne( + 'SELECT * FROM llm_provider WHERE provider_id = $1', + [req.params.providerId] + ); + + if (!provider) { + return res.status(404).json({ success: false, error: 'Provider not found' }); + } + + // Get active API key for this provider + const apiKey = await queryOne( + 'SELECT * FROM provider_api_key WHERE provider_id = $1 AND is_active = true ORDER BY api_key_id LIMIT 1', + [req.params.providerId] + ); + + if (!apiKey && provider.auth_type !== 'none') { + return res.status(400).json({ + success: false, + error: 'No active API key configured for this provider' + }); + } + + // Basic connectivity test based on provider type + const startTime = Date.now(); + let testResult: { success: boolean; latencyMs?: number; error?: string; details?: any } = { success: false }; + + try { + if (provider.provider_code === 'qwen' || provider.provider_code === 'm17') { + // Local provider - just check health endpoint + const response = await fetch(`${provider.base_url}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + testResult = { + success: response.ok, + latencyMs: Date.now() - startTime, + details: { status: response.status }, + }; + } else if (provider.provider_code === 'openrouter') { + // OpenRouter - check models endpoint + const response = await fetch('https://openrouter.ai/api/v1/models', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey?.api_key_value}`, + }, + signal: AbortSignal.timeout(10000), + }); + testResult = { + success: response.ok, + latencyMs: Date.now() - startTime, + details: { status: response.status, modelsAvailable: response.ok }, + }; + } else { + // Generic test - assume success if we have config + testResult = { + success: true, + latencyMs: Date.now() - startTime, + details: { message: 'Provider configured, direct test not implemented' }, + }; + } + } catch (error) { + testResult = { + success: false, + latencyMs: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Connection failed', + }; + } + + res.json({ + success: true, + data: { + provider: provider.provider_name, + ...testResult, + }, + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sensitive-topics.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sensitive-topics.ts new file mode 100644 index 0000000..22697e6 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sensitive-topics.ts @@ -0,0 +1,273 @@ +/** + * SENSITIVE TOPICS ROUTES — list of topics that trigger HIL review + + * volatility taxonomy that drives brain cache TTL and recency boost. + * + * GET /api/sensitive-topics — list (filter ?active=true|false|all, default 'true') + * POST /api/sensitive-topics — create new topic (volatility fields optional) + * PUT /api/sensitive-topics/:id — update any of: label, active, volatility, ttl, recency, half_life + * DELETE /api/sensitive-topics/:id — soft delete (set is_active=false) + * + * Stored in bos_parammgmt.sensitive_topic. Migration 011 created the table; + * migration 012 added (volatility, cache_ttl_hours, recency_window_days, + * half_life_days) for the brain cache freshness defense (Phase D1). + * + * Synced to Redis under TWO keys: + * - didi:config:moderation:v1:sensitive_topics — used by HIL agent-v3 + * (only topic_code + topic_label, unchanged shape, backwards compatible) + * - didi:config:topics:volatility — used by brain cache freshness logic, + * includes volatility/ttl/recency_window/half_life per topic (D1 addition) + */ + +import { Router, type Request, type Response } from 'express'; +import { query } from '../config/database'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +const TOPIC_CODE_REGEX = /^[a-z0-9_]+$/; +const ALLOWED_VOLATILITY = new Set(['volatile', 'evolving', 'stable']); + +// Atomic taxonomy paths look like "Topics/Health/" or "Topics/Politics/Elections". +// We keep validation light because atomic taxonomy is dynamic — operators may +// create new namespaces in atomic-server that we don't know about yet. We only +// reject obviously bogus shapes (whitespace, leading slash, length). +const ATOMIC_PATH_REGEX = /^[A-Za-z][A-Za-z0-9_/-]*\/?$/; +const ATOMIC_PATH_MAX_LEN = 200; + +// ---------------------------------------------------------------------------- +// Validation helpers — keep schema-side CHECK constraints aligned with API. +// ---------------------------------------------------------------------------- + +function validateVolatilityFields(body: Record): string | null { + const { volatility, cache_ttl_hours, recency_window_days, half_life_days } = body; + if (volatility !== undefined && (typeof volatility !== 'string' || !ALLOWED_VOLATILITY.has(volatility))) { + return 'volatility must be one of: volatile, evolving, stable'; + } + if (cache_ttl_hours !== undefined) { + const v = Number(cache_ttl_hours); + if (!Number.isFinite(v) || v < 1 || v > 26280) { + return 'cache_ttl_hours must be between 1 and 26280'; + } + } + if (recency_window_days !== undefined) { + const v = Number(recency_window_days); + if (!Number.isFinite(v) || v < 1 || v > 365) { + return 'recency_window_days must be between 1 and 365'; + } + } + if (half_life_days !== undefined) { + const v = Number(half_life_days); + if (!Number.isFinite(v) || v <= 0) { + return 'half_life_days must be greater than 0'; + } + } + return null; +} + +function validateAtomicPathPrefix(body: Record): string | null { + const v = body.atomic_path_prefix; + if (v === undefined || v === null || v === '') return null; // optional, NULL allowed + if (typeof v !== 'string') return 'atomic_path_prefix must be a string or null'; + if (v.length > ATOMIC_PATH_MAX_LEN) { + return `atomic_path_prefix too long (max ${ATOMIC_PATH_MAX_LEN} chars)`; + } + if (!ATOMIC_PATH_REGEX.test(v)) { + return 'atomic_path_prefix must look like "Topics/Health/" or "Topics/Politics/Elections" (no leading slash, no whitespace)'; + } + return null; +} + +// All columns that PUT may modify, in the order the SQL below references +// them via $1..$7. Keeping this list as the single source of truth lets the +// validation pass loop over it without drifting from the SQL. +const PUT_FIELDS = [ + 'topic_label', + 'is_active', + 'volatility', + 'cache_ttl_hours', + 'recency_window_days', + 'half_life_days', + 'atomic_path_prefix', +] as const; + +// ============================================================================ +// GET / — list topics +// ============================================================================ + +router.get('/', async (req: Request, res: Response) => { + try { + const filter = (req.query.active as string | undefined) ?? 'true'; + let where = ''; + if (filter === 'true') where = 'WHERE is_active = true'; + else if (filter === 'false') where = 'WHERE is_active = false'; + else if (filter !== 'all') { + return res.status(400).json({ success: false, error: 'Invalid ?active value (use true|false|all)' }); + } + + const rows = await query( + `SELECT topic_id, topic_code, topic_label, is_active, + volatility, cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix, + created_at, updated_at + FROM bos_parammgmt.sensitive_topic ${where} + ORDER BY topic_id` + ); + res.json({ success: true, data: rows, count: rows.length }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// POST / — create new topic (volatility fields optional, fall to defaults) +// ============================================================================ + +router.post('/', async (req: Request, res: Response) => { + try { + const body = (req.body ?? {}) as Record; + const { topic_code, topic_label, is_active, volatility, cache_ttl_hours, recency_window_days, half_life_days, atomic_path_prefix } = body; + if (!topic_code || typeof topic_code !== 'string') { + return res.status(400).json({ success: false, error: 'topic_code (string) required' }); + } + if (!TOPIC_CODE_REGEX.test(topic_code)) { + return res.status(400).json({ success: false, error: 'topic_code must match [a-z0-9_]+' }); + } + if (!topic_label || typeof topic_label !== 'string') { + return res.status(400).json({ success: false, error: 'topic_label (string) required' }); + } + const volErr = validateVolatilityFields(body); + if (volErr) return res.status(400).json({ success: false, error: volErr }); + const pathErr = validateAtomicPathPrefix(body); + if (pathErr) return res.status(400).json({ success: false, error: pathErr }); + + const rows = await query( + `INSERT INTO bos_parammgmt.sensitive_topic + (topic_code, topic_label, is_active, + volatility, cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix) + VALUES ($1, $2, COALESCE($3, true), + COALESCE($4, 'evolving'), COALESCE($5, 720), + COALESCE($6, 30), COALESCE($7, 30.0), + $8) + RETURNING topic_id, topic_code, topic_label, is_active, + volatility, cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix, + created_at, updated_at`, + [topic_code, topic_label, is_active, + volatility, cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix ?? null] + ); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error) { + const err = error as { code?: string; message: string }; + if (err.code === '23505') { + return res.status(409).json({ success: false, error: 'topic_code already exists' }); + } + if (err.code === '23514') { + return res.status(400).json({ success: false, error: `check constraint failed: ${err.message}` }); + } + internalError(res, err); + } +}); + +// ============================================================================ +// PUT /:id — update any of: label, active, volatility, ttl, recency, half_life +// (topic_code is immutable) +// ============================================================================ + +router.put('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (Number.isNaN(id)) { + return res.status(400).json({ success: false, error: 'Invalid id' }); + } + const body = (req.body ?? {}) as Record; + const provided = PUT_FIELDS.filter((k) => body[k] !== undefined); + if (provided.length === 0) { + return res.status(400).json({ + success: false, + error: `Nothing to update (provide one of: ${PUT_FIELDS.join(', ')})`, + }); + } + const volErr = validateVolatilityFields(body); + if (volErr) return res.status(400).json({ success: false, error: volErr }); + const pathErr = validateAtomicPathPrefix(body); + if (pathErr) return res.status(400).json({ success: false, error: pathErr }); + + // Build params: first 6 PUT_FIELDS (topic_label..half_life_days), then + // conditionally the atomic_path_prefix, then id last. We explicitly omit + // $7 from params when clearAtomic is true so Postgres doesn't complain + // about an unused, untyped parameter. + const baseParams: unknown[] = PUT_FIELDS.slice(0, 6).map((k) => + body[k] === undefined ? null : body[k], + ); + const clearAtomic = body.atomic_path_prefix === null || body.atomic_path_prefix === ''; + + let atomicSql: string; + const params: unknown[] = [...baseParams]; + if (clearAtomic) { + atomicSql = 'NULL'; + params.push(id); + } else if (body.atomic_path_prefix === undefined) { + atomicSql = 'atomic_path_prefix'; // no-op: keep existing value + params.push(id); + } else { + atomicSql = '$7'; + params.push(body.atomic_path_prefix, id); + } + const idIdx = params.length; + + const rows = await query( + `UPDATE bos_parammgmt.sensitive_topic + SET topic_label = COALESCE($1, topic_label), + is_active = COALESCE($2, is_active), + volatility = COALESCE($3, volatility), + cache_ttl_hours = COALESCE($4, cache_ttl_hours), + recency_window_days = COALESCE($5, recency_window_days), + half_life_days = COALESCE($6, half_life_days), + atomic_path_prefix = ${atomicSql} + WHERE topic_id = $${idIdx} + RETURNING topic_id, topic_code, topic_label, is_active, + volatility, cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix, + created_at, updated_at`, + params + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: `Topic id=${id} not found` }); + } + res.json({ success: true, data: rows[0] }); + } catch (error) { + const err = error as { code?: string; message: string }; + if (err.code === '23514') { + return res.status(400).json({ success: false, error: `check constraint failed: ${err.message}` }); + } + internalError(res, err); + } +}); + +// ============================================================================ +// DELETE /:id — soft delete (is_active=false) +// ============================================================================ + +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + if (Number.isNaN(id)) { + return res.status(400).json({ success: false, error: 'Invalid id' }); + } + const rows = await query( + `UPDATE bos_parammgmt.sensitive_topic SET is_active = false WHERE topic_id = $1 + RETURNING topic_id, topic_code`, + [id] + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: `Topic id=${id} not found` }); + } + res.json({ success: true, message: `Topic '${rows[0].topic_code}' deactivated` }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/skills.ts b/backend/services/orchestration-layer/didiFramework/src/routes/skills.ts new file mode 100644 index 0000000..a6cd572 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/skills.ts @@ -0,0 +1,99 @@ +/** + * SKILLS CATALOG — read-only registry of executable resources (Modul 1: + * „catalog resurse AI: modele / skills / code-jobs"). + * + * - analysis_components: nodurile pipeline-ului de analiză (agent-v3), + * configurabile prin didiFramework (prompturi/modele/ponderi). + * - extractor_skills: modulele platformei AI (host configurabil) — fiecare un + * serviciu Python izolat în propriul container, apelabil prin API. + * Include un health probe live (fail-open). + * - code_jobs: execuția de cod = aceleași module Python containerizate + * (izolare la nivel de container); job-uri ad-hoc definite de utilizator + * rămân pe roadmap. + * + * Modelele LLM au catalogul lor CRUD la /api/providers/models (deployment, + * compute_target, quantization, capabilities — vezi migrația 017). + * + * Env: AI_PLATFORM_HOST (host platformei AI), AI_PLATFORM_TOKEN (optional + * Bearer pentru gateway/catalog-api). + */ +import { Router, type Request, type Response } from 'express'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +const AI_HOST = process.env.AI_PLATFORM_HOST || 'localhost'; + +interface SkillDef { + code: string; + name: string; + runtime: string; + port: number; + health_path: string; + capabilities: string[]; + description: string; +} + +const ANALYSIS_COMPONENTS = [ + { code: 'media_preprocess', name: 'Media Pre-processing', queue: 'analysis.media_preprocess.{plan}', inputs: ['video', 'audio', 'image'], outputs: ['transcript', 'frames', 'ocr_text', 'buster_verdict', 'forensic_features', 'metadata', 'ner', 'sentiment'], configurable_via: ['component_stage_assignment', 'vision prompts'] }, + { code: 'techniques', name: 'Manipulation Techniques Detection', queue: 'analysis.techniques.{plan}', inputs: ['text'], outputs: ['techniques_score', 'detected_techniques'], configurable_via: ['component_prompt', 'component_stage_assignment', 'weights', 'dimensions/indicators CRUD'] }, + { code: 'ai_tampered', name: 'AI-Generated Content Detection', queue: 'analysis.ai_tampered.{plan}', inputs: ['text', 'image', 'video'], outputs: ['ai_probability', 'categories'], configurable_via: ['component_prompt', 'component_stage_assignment', 'scoring_config'] }, + { code: 'claims', name: 'Claim Extraction & Verification', queue: 'analysis.claims.{plan}', inputs: ['text'], outputs: ['claims', 'verification_status'], configurable_via: ['component_prompt', 'component_stage_assignment', 'claim types CRUD'] }, + { code: 'domain', name: 'Source Credibility Assessment', queue: 'analysis.domain.{plan}', inputs: ['url', 'text'], outputs: ['source_score', 'credibility'], configurable_via: ['component_prompt', 'sources CRUD'] }, + { code: 'verdict_aggregator', name: 'Verdict Aggregation', queue: 'analysis.results', inputs: ['component_results'], outputs: ['risk_score', 'risk_category', 'explanations RO+EN'], configurable_via: ['input_type_profile (pipeline definition)', 'weights', 'verdicts CRUD'] }, +]; + +const EXTRACTOR_SKILLS: SkillDef[] = [ + { code: 'llm-inference', name: 'LLM Router (Qwen3.5 text+vision+OCR)', runtime: 'python-container', port: 14011, health_path: '/health', capabilities: ['text', 'vision', 'ocr', 'streaming'], description: 'Router LLM OpenAI-compatible; vLLM GPU / llama.cpp CPU / LiteLLM cloud' }, + { code: 'embeddings', name: 'Embeddings BGE-M3', runtime: 'python-container', port: 14100, health_path: '/health', capabilities: ['embeddings'], description: 'Vectori 1024-dim, max 8192 tokeni' }, + { code: 'rerank', name: 'Reranker BGE-v2-m3', runtime: 'python-container', port: 14200, health_path: '/health', capabilities: ['rerank'], description: 'Sortare documente după relevanță (Cohere/Jina-compatible)' }, + { code: 'audio', name: 'Speech-to-Text (Whisper large-v3-turbo)', runtime: 'python-container', port: 54300, health_path: '/health', capabilities: ['transcription'], description: '99+ limbi, VAD, faster-whisper' }, + { code: 'video-analysis', name: 'Video Analysis + Deepfake (BusterX++)', runtime: 'python-container', port: 54600, health_path: '/health', capabilities: ['deepfake', 'video-semantic'], description: 'Verdict REAL/FAKE/UNCERTAIN + analiză semantică pe chunk-uri' }, + { code: 'extractors', name: 'Multi-signal Extractors', runtime: 'python-container', port: 54400, health_path: '/health', capabilities: ['exif', 'ela', 'c2pa', 'ner', 'object-detection', 'ocr', 'sentiment'], description: 'EXIF/ELA/C2PA/SHA256, GLiNER NER, YOLOv8, OCR, sentiment' }, + { code: 'forensic-features', name: 'Forensic Features (m25–m29)', runtime: 'python-container', port: 8085, health_path: '/health', capabilities: ['rppg', 'lip-sync', 'ai-detector', 'forgery-heatmap', 'lighting-3d'], description: 'Semnale forensice obiective pentru LLM (nu dă verdict)' }, + { code: 'web', name: 'Web Evidence / Fact-check Pipeline', runtime: 'python-container', port: 51100, health_path: '/health', capabilities: ['search', 'fetch', 'evidence-packing'], description: 'SearXNG multi-round + Playwright + Vision fallback, protecție SSRF' }, + { code: 'cloak', name: 'Stealth SERP Scraper', runtime: 'python-container', port: 8770, health_path: '/health', capabilities: ['serp'], description: 'Google/Bing/DDG via CloakBrowser (tier-3 fallback)' }, + { code: 'didi-brain', name: 'Knowledge Brain (RAG + verification cache)', runtime: 'python-container', port: 8090, health_path: '/health', capabilities: ['rag', 'verification-cache', 'fact-status'], description: 'pgvector, analysis atoms gold/silver/bronze, invalidare TTL' }, +]; + +async function probeHealth(skill: SkillDef): Promise { + try { + const headers: Record = {}; + if (process.env.AI_PLATFORM_TOKEN) headers.Authorization = `Bearer ${process.env.AI_PLATFORM_TOKEN}`; + const resp = await fetch(`http://${AI_HOST}:${skill.port}${skill.health_path}`, { + headers, signal: AbortSignal.timeout(1500), + }); + return resp.ok ? 'healthy' : `http_${resp.status}`; + } catch { + return 'unreachable'; + } +} + +// GET /api/skills — full catalog (health probe optional via ?health=true) +router.get('/', async (req: Request, res: Response) => { + try { + const withHealth = req.query.health === 'true'; + const extractors = withHealth + ? await Promise.all(EXTRACTOR_SKILLS.map(async s => ({ ...s, host: AI_HOST, health: await probeHealth(s) }))) + : EXTRACTOR_SKILLS.map(s => ({ ...s, host: AI_HOST })); + + res.json({ + success: true, + data: { + analysis_components: ANALYSIS_COMPONENTS, + extractor_skills: extractors, + code_jobs: { + status: 'container-isolated', + note: 'Execuția de cod rulează ca module Python izolate per container (extractor_skills de mai sus). Job-uri ad-hoc definite de utilizator: roadmap.', + }, + models_catalog: '/api/providers/models', + pipelines_catalog: '/api/pipelines', + }, + counts: { analysis_components: ANALYSIS_COMPONENTS.length, extractor_skills: EXTRACTOR_SKILLS.length }, + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/_shared.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/_shared.ts new file mode 100644 index 0000000..81d7deb --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/_shared.ts @@ -0,0 +1,86 @@ +/** + * Shared infra for source-assessment routes: + * - Helpers: createParameter (parameter-row factory), getNextId (cheap MAX+1). + * - 7 row-type interfaces — kept here because they're used by `overview.ts` + * which queries all 7 tables in parallel; collocating prevents cycles + * between sibling files. + */ +import type { PoolClient } from 'pg'; + +export const createParameter = async (client: PoolClient, parameterType: number): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +}; + +export const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +export interface PlatformModifier { + platform_modifier_id: number; + platform_modifier: string; + condition: string; + score: number; +} + +export interface SourceCredibility { + source_credibility_id: number; + source_credibility: string; + factor: number; + condition: string; +} + +export interface DomainAgeScore { + domain_age_score: number; + start_range: number; + end_range: number; + description: string; + score_impact: number; +} + +export interface DomainRiskLevel { + domain_risk_level_id: number; + domain_risk_level: string; + start_range: number; + end_range: number; + interpretation: string; + score_impact: number; +} + +export interface DomainRedFlag { + domain_red_flag_id: number; + domain_red_flag: string; + condition: string; + severity: number; + action: string; +} + +export interface AuthorClassification { + author_classification_id: number; + author_classification_code: string; + author_classification_name: string; + score: number; +} + +export interface AuthorCredibility { + author_credibility_id: number; + author_credibility: string; + impact: number; +} + +export interface SourceAssessmentRange { + source_assessment_id: number; + source_assessment: string; + description: string; + calculation_start: number; + calcularion_end: number; + parameter_id: number; +} diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-classifications.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-classifications.ts new file mode 100644 index 0000000..61fc91c --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-classifications.ts @@ -0,0 +1,102 @@ +/** + * Author classifications — classification codes/names + base score. + * Has children: author.author_classification_id. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type AuthorClassification } from './_shared'; + +const router = Router(); + +router.get('/author-classifications', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT ac.*, + (SELECT COUNT(*) FROM author a WHERE a.author_classification_id = ac.author_classification_id) as usage_count + FROM author_classification ac + ORDER BY ac.author_classification_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_author_classifications_list'); + } +}); + +router.get('/author-classifications/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM author_classification WHERE author_classification_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Author classification nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_author_classifications_get'); + } +}); + +router.post('/author-classifications', async (req: Request, res: Response) => { + try { + const { author_classification_code, author_classification_name, score } = req.body; + if (!author_classification_code || !author_classification_name) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: author_classification_code, author_classification_name' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'author_classification', 'author_classification_id'); + const insertResult = await client.query(` + INSERT INTO author_classification (author_classification_id, author_classification_code, author_classification_name, score) + VALUES ($1, $2, $3, $4) + RETURNING * + `, [id, author_classification_code, author_classification_name, score || 0]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Author classification creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_author_classifications_create'); + } +}); + +router.put('/author-classifications/:id', async (req: Request, res: Response) => { + try { + const { author_classification_code, author_classification_name, score } = req.body; + const result = await queryOne(` + UPDATE author_classification + SET author_classification_code = COALESCE($1, author_classification_code), + author_classification_name = COALESCE($2, author_classification_name), + score = COALESCE($3, score) + WHERE author_classification_id = $4 + RETURNING * + `, [author_classification_code, author_classification_name, score, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Author classification nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Author classification actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_author_classifications_update'); + } +}); + +router.delete('/author-classifications/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('author_classification', 'author_classification_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Author classification șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_author_classifications_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-credibility.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-credibility.ts new file mode 100644 index 0000000..a02238b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/author-credibility.ts @@ -0,0 +1,101 @@ +/** + * Author credibility — credibility tier per author with score impact. + * Has children: author.author_credibility_id. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type AuthorCredibility } from './_shared'; + +const router = Router(); + +router.get('/author-credibility', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT acr.*, + (SELECT COUNT(*) FROM author a WHERE a.author_credibility_id = acr.author_credibility_id) as usage_count + FROM author_credibility acr + ORDER BY acr.author_credibility_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_author_credibility_list'); + } +}); + +router.get('/author-credibility/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM author_credibility WHERE author_credibility_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Author credibility nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_author_credibility_get'); + } +}); + +router.post('/author-credibility', async (req: Request, res: Response) => { + try { + const { author_credibility, impact } = req.body; + if (!author_credibility) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: author_credibility' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'author_credibility', 'author_credibility_id'); + const insertResult = await client.query(` + INSERT INTO author_credibility (author_credibility_id, author_credibility, impact) + VALUES ($1, $2, $3) + RETURNING * + `, [id, author_credibility, impact || 0]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Author credibility creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_author_credibility_create'); + } +}); + +router.put('/author-credibility/:id', async (req: Request, res: Response) => { + try { + const { author_credibility, impact } = req.body; + const result = await queryOne(` + UPDATE author_credibility + SET author_credibility = COALESCE($1, author_credibility), + impact = COALESCE($2, impact) + WHERE author_credibility_id = $3 + RETURNING * + `, [author_credibility, impact, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Author credibility nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Author credibility actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_author_credibility_update'); + } +}); + +router.delete('/author-credibility/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('author_credibility', 'author_credibility_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Author credibility șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_author_credibility_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-age-scores.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-age-scores.ts new file mode 100644 index 0000000..986782a --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-age-scores.ts @@ -0,0 +1,102 @@ +/** + * Domain age scores — score buckets keyed by registered-domain age (years). + * Has children: domain_attribute.domain_age_score. + * + * Note: PK column is `domain_age_score` itself (not a generated id), so the + * insert path takes domain_age_score from the request body — no MAX+1 helper. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import type { DomainAgeScore } from './_shared'; + +const router = Router(); + +router.get('/domain-age-scores', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT das.*, + (SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_age_score = das.domain_age_score) as usage_count + FROM domain_age_score das + ORDER BY das.domain_age_score + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_domain_age_scores_list'); + } +}); + +router.get('/domain-age-scores/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM domain_age_score WHERE domain_age_score = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Domain age score nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_domain_age_scores_get'); + } +}); + +router.post('/domain-age-scores', async (req: Request, res: Response) => { + try { + const { domain_age_score, start_range, end_range, description, score_impact } = req.body; + if (domain_age_score === undefined || start_range === undefined || end_range === undefined) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: domain_age_score, start_range, end_range' }); + } + + const result = await queryOne(` + INSERT INTO domain_age_score (domain_age_score, start_range, end_range, description, score_impact) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [domain_age_score, start_range, end_range, description || '', score_impact || 0]); + + res.status(201).json({ success: true, data: result, message: 'Domain age score creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_age_scores_create'); + } +}); + +router.put('/domain-age-scores/:id', async (req: Request, res: Response) => { + try { + const { start_range, end_range, description, score_impact } = req.body; + const result = await queryOne(` + UPDATE domain_age_score + SET start_range = COALESCE($1, start_range), + end_range = COALESCE($2, end_range), + description = COALESCE($3, description), + score_impact = COALESCE($4, score_impact) + WHERE domain_age_score = $5 + RETURNING * + `, [start_range, end_range, description, score_impact, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Domain age score nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Domain age score actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_age_scores_update'); + } +}); + +router.delete('/domain-age-scores/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('domain_age_score', 'domain_age_score', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Domain age score șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_domain_age_scores_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-red-flags.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-red-flags.ts new file mode 100644 index 0000000..4f5379e --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-red-flags.ts @@ -0,0 +1,103 @@ +/** + * Domain red flags — discrete red-flag rules with severity + recommended action. + * Has children: domain_attribute.domain_red_flag_id. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type DomainRedFlag } from './_shared'; + +const router = Router(); + +router.get('/domain-red-flags', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT drf.*, + (SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_red_flag_id = drf.domain_red_flag_id) as usage_count + FROM domain_red_flag drf + ORDER BY drf.domain_red_flag_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_domain_red_flags_list'); + } +}); + +router.get('/domain-red-flags/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM domain_red_flag WHERE domain_red_flag_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Domain red flag nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_domain_red_flags_get'); + } +}); + +router.post('/domain-red-flags', async (req: Request, res: Response) => { + try { + const { domain_red_flag, condition, severity, action } = req.body; + if (!domain_red_flag) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: domain_red_flag' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'domain_red_flag', 'domain_red_flag_id'); + const insertResult = await client.query(` + INSERT INTO domain_red_flag (domain_red_flag_id, domain_red_flag, condition, severity, action) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [id, domain_red_flag, condition || '', severity || 0, action || '']); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Domain red flag creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_red_flags_create'); + } +}); + +router.put('/domain-red-flags/:id', async (req: Request, res: Response) => { + try { + const { domain_red_flag, condition, severity, action } = req.body; + const result = await queryOne(` + UPDATE domain_red_flag + SET domain_red_flag = COALESCE($1, domain_red_flag), + condition = COALESCE($2, condition), + severity = COALESCE($3, severity), + action = COALESCE($4, action) + WHERE domain_red_flag_id = $5 + RETURNING * + `, [domain_red_flag, condition, severity, action, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Domain red flag nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Domain red flag actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_red_flags_update'); + } +}); + +router.delete('/domain-red-flags/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('domain_red_flag', 'domain_red_flag_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Domain red flag șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_domain_red_flags_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-risk-levels.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-risk-levels.ts new file mode 100644 index 0000000..dde087b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/domain-risk-levels.ts @@ -0,0 +1,104 @@ +/** + * Domain risk levels — risk-tier ranges (start..end) with interpretation text. + * Has children: domain_attribute.domain_risk_level_id. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type DomainRiskLevel } from './_shared'; + +const router = Router(); + +router.get('/domain-risk-levels', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT drl.*, + (SELECT COUNT(*) FROM domain_attribute da WHERE da.domain_risk_level_id = drl.domain_risk_level_id) as usage_count + FROM domain_risk_level drl + ORDER BY drl.domain_risk_level_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_domain_risk_levels_list'); + } +}); + +router.get('/domain-risk-levels/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM domain_risk_level WHERE domain_risk_level_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Domain risk level nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_domain_risk_levels_get'); + } +}); + +router.post('/domain-risk-levels', async (req: Request, res: Response) => { + try { + const { domain_risk_level, start_range, end_range, interpretation, score_impact } = req.body; + if (!domain_risk_level) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: domain_risk_level' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'domain_risk_level', 'domain_risk_level_id'); + const insertResult = await client.query(` + INSERT INTO domain_risk_level (domain_risk_level_id, domain_risk_level, start_range, end_range, interpretation, score_impact) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [id, domain_risk_level, start_range || 0, end_range || 0, interpretation || '', score_impact || 0]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Domain risk level creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_risk_levels_create'); + } +}); + +router.put('/domain-risk-levels/:id', async (req: Request, res: Response) => { + try { + const { domain_risk_level, start_range, end_range, interpretation, score_impact } = req.body; + const result = await queryOne(` + UPDATE domain_risk_level + SET domain_risk_level = COALESCE($1, domain_risk_level), + start_range = COALESCE($2, start_range), + end_range = COALESCE($3, end_range), + interpretation = COALESCE($4, interpretation), + score_impact = COALESCE($5, score_impact) + WHERE domain_risk_level_id = $6 + RETURNING * + `, [domain_risk_level, start_range, end_range, interpretation, score_impact, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Domain risk level nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Domain risk level actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_domain_risk_levels_update'); + } +}); + +router.delete('/domain-risk-levels/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('domain_risk_level', 'domain_risk_level_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Domain risk level șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_domain_risk_levels_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/index.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/index.ts new file mode 100644 index 0000000..46a6c7d --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/index.ts @@ -0,0 +1,38 @@ +/** + * SOURCE ASSESSMENT — barrel router. + * + * Original 851-line source-assessment.ts split per entity: + * _shared.ts — createParameter + getNextId helpers + 8 row types + * platform-modifiers.ts — 5 CRUD + /:id/dependencies (only entity exposing deps) + * source-credibility.ts — 5 CRUD + * domain-age-scores.ts — 5 CRUD (PK = domain_age_score, no MAX+1 helper) + * domain-risk-levels.ts — 5 CRUD + * domain-red-flags.ts — 5 CRUD + * author-classifications.ts — 5 CRUD + * author-credibility.ts — 5 CRUD + * overview.ts — GET /source-assessment-ranges + /source-assessment/all + * + * Mounted at /api/source-assessment in src/server.ts (router-level prefix). + */ +import { Router } from 'express'; +import platformModifiersRouter from './platform-modifiers'; +import sourceCredibilityRouter from './source-credibility'; +import domainAgeScoresRouter from './domain-age-scores'; +import domainRiskLevelsRouter from './domain-risk-levels'; +import domainRedFlagsRouter from './domain-red-flags'; +import authorClassificationsRouter from './author-classifications'; +import authorCredibilityRouter from './author-credibility'; +import overviewRouter from './overview'; + +const router = Router(); + +router.use(platformModifiersRouter); +router.use(sourceCredibilityRouter); +router.use(domainAgeScoresRouter); +router.use(domainRiskLevelsRouter); +router.use(domainRedFlagsRouter); +router.use(authorClassificationsRouter); +router.use(authorCredibilityRouter); +router.use(overviewRouter); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/overview.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/overview.ts new file mode 100644 index 0000000..54c6152 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/overview.ts @@ -0,0 +1,86 @@ +/** + * Read-only overview endpoints: + * + * GET /source-assessment-ranges — leaf-table lookup of score-band ranges. + * GET /source-assessment/all — combined snapshot of all 7 entity tables + * in parallel (used by the admin dashboard's + * summary view to avoid 7 round-trips). + */ +import { Router, Request, Response } from 'express'; +import { query } from '../../config/database'; +import { internalError } from '../../config/error-response'; +import type { + PlatformModifier, + SourceCredibility, + DomainAgeScore, + DomainRiskLevel, + DomainRedFlag, + AuthorClassification, + AuthorCredibility, + SourceAssessmentRange, +} from './_shared'; + +const router = Router(); + +router.get('/source-assessment-ranges', async (_req: Request, res: Response) => { + try { + const data = await query( + 'SELECT * FROM source_assessment ORDER BY source_assessment_id' + ); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_ranges_list'); + } +}); + +router.get('/source-assessment/all', async (_req: Request, res: Response) => { + try { + const [ + platformModifiers, + sourceCredibility, + domainAgeScores, + domainRiskLevels, + domainRedFlags, + authorClassifications, + authorCredibility + ] = await Promise.all([ + query('SELECT * FROM platform_modifier ORDER BY platform_modifier_id'), + query('SELECT * FROM source_credibility ORDER BY source_credibility_id'), + query('SELECT * FROM domain_age_score ORDER BY domain_age_score'), + query('SELECT * FROM domain_risk_level ORDER BY domain_risk_level_id'), + query('SELECT * FROM domain_red_flag ORDER BY domain_red_flag_id'), + query('SELECT * FROM author_classification ORDER BY author_classification_id'), + query('SELECT * FROM author_credibility ORDER BY author_credibility_id') + ]); + + res.json({ + success: true, + data: { + platformModifiers, + sourceCredibility, + domainAgeScores, + domainRiskLevels, + domainRedFlags, + authorClassifications, + authorCredibility + }, + counts: { + platformModifiers: platformModifiers.length, + sourceCredibility: sourceCredibility.length, + domainAgeScores: domainAgeScores.length, + domainRiskLevels: domainRiskLevels.length, + domainRedFlags: domainRedFlags.length, + authorClassifications: authorClassifications.length, + authorCredibility: authorCredibility.length, + total: platformModifiers.length + sourceCredibility.length + + domainAgeScores.length + domainRiskLevels.length + + domainRedFlags.length + authorClassifications.length + + authorCredibility.length + } + }); + } catch (error) { + internalError(res, error, 'sa_all'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/platform-modifiers.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/platform-modifiers.ts new file mode 100644 index 0000000..d6142b2 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/platform-modifiers.ts @@ -0,0 +1,113 @@ +/** + * Platform modifiers — adjustments to scoring based on the publishing platform. + * + * Has children: platform.platform_modifier_id (FK). + * /:id/dependencies endpoint exposes the FK references for safe-delete preview. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { checkDependencies, safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type PlatformModifier } from './_shared'; + +const router = Router(); + +router.get('/platform-modifiers', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT pm.*, + (SELECT COUNT(*) FROM platform p WHERE p.platform_modifier_id = pm.platform_modifier_id) as platform_count + FROM platform_modifier pm + ORDER BY pm.platform_modifier_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_list'); + } +}); + +router.get('/platform-modifiers/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM platform_modifier WHERE platform_modifier_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Platform modifier nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_get'); + } +}); + +router.get('/platform-modifiers/:id/dependencies', async (req: Request, res: Response) => { + try { + const depCheck = await checkDependencies('platform_modifier', 'platform_modifier_id', req.params.id); + res.json({ success: true, data: depCheck }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_deps'); + } +}); + +router.post('/platform-modifiers', async (req: Request, res: Response) => { + try { + const { platform_modifier, condition, score } = req.body; + if (!platform_modifier) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: platform_modifier' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'platform_modifier', 'platform_modifier_id'); + const insertResult = await client.query(` + INSERT INTO platform_modifier (platform_modifier_id, platform_modifier, condition, score) + VALUES ($1, $2, $3, $4) + RETURNING * + `, [id, platform_modifier, condition || '', score || 0]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Platform modifier creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_create'); + } +}); + +router.put('/platform-modifiers/:id', async (req: Request, res: Response) => { + try { + const { platform_modifier, condition, score } = req.body; + const result = await queryOne(` + UPDATE platform_modifier + SET platform_modifier = COALESCE($1, platform_modifier), + condition = COALESCE($2, condition), + score = COALESCE($3, score) + WHERE platform_modifier_id = $4 + RETURNING * + `, [platform_modifier, condition, score, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Platform modifier nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Platform modifier actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_update'); + } +}); + +router.delete('/platform-modifiers/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('platform_modifier', 'platform_modifier_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Platform modifier șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_platform_modifiers_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/source-credibility.ts b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/source-credibility.ts new file mode 100644 index 0000000..f794ca3 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/source-assessment/source-credibility.ts @@ -0,0 +1,102 @@ +/** + * Source credibility tiers — rate the credibility of the publishing source. + * Has children: domain_attribute.source_credibility_id. + */ +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../../config/database'; +import { safeDelete } from '../../utils/dependency-checker'; +import { internalError } from '../../config/error-response'; +import { getNextId, type SourceCredibility } from './_shared'; + +const router = Router(); + +router.get('/source-credibility', async (_req: Request, res: Response) => { + try { + const data = await query(` + SELECT sc.*, + (SELECT COUNT(*) FROM domain_attribute da WHERE da.source_credibility_id = sc.source_credibility_id) as usage_count + FROM source_credibility sc + ORDER BY sc.source_credibility_id + `); + res.json({ success: true, data, count: data.length }); + } catch (error) { + internalError(res, error, 'sa_source_credibility_list'); + } +}); + +router.get('/source-credibility/:id', async (req: Request, res: Response) => { + try { + const data = await queryOne( + 'SELECT * FROM source_credibility WHERE source_credibility_id = $1', + [req.params.id] + ); + if (!data) { + return res.status(404).json({ success: false, error: 'Source credibility nu a fost găsit' }); + } + res.json({ success: true, data }); + } catch (error) { + internalError(res, error, 'sa_source_credibility_get'); + } +}); + +router.post('/source-credibility', async (req: Request, res: Response) => { + try { + const { source_credibility, factor, condition } = req.body; + if (!source_credibility) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: source_credibility' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'source_credibility', 'source_credibility_id'); + const insertResult = await client.query(` + INSERT INTO source_credibility (source_credibility_id, source_credibility, factor, condition) + VALUES ($1, $2, $3, $4) + RETURNING * + `, [id, source_credibility, factor || 0, condition || '']); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Source credibility creat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_source_credibility_create'); + } +}); + +router.put('/source-credibility/:id', async (req: Request, res: Response) => { + try { + const { source_credibility, factor, condition } = req.body; + const result = await queryOne(` + UPDATE source_credibility + SET source_credibility = COALESCE($1, source_credibility), + factor = COALESCE($2, factor), + condition = COALESCE($3, condition) + WHERE source_credibility_id = $4 + RETURNING * + `, [source_credibility, factor, condition, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Source credibility nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Source credibility actualizat cu succes' }); + } catch (error) { + internalError(res, error, 'sa_source_credibility_update'); + } +}); + +router.delete('/source-credibility/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('source_credibility', 'source_credibility_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Source credibility șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error, 'sa_source_credibility_delete'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sources.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sources.ts new file mode 100644 index 0000000..2d7d655 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sources.ts @@ -0,0 +1,125 @@ +/** + * Source Types Routes - FULL CRUD + * + * Source types have children in domain_attribute, need safety check before delete. + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { checkDependencies, safeDelete } from '../utils/dependency-checker'; +import { SourceType, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Helper functions +const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +// GET all source types with usage count +router.get('/', async (req: Request, res: Response) => { + try { + const sources = await query(` + SELECT st.*, + (SELECT COUNT(*) FROM domain_attribute da WHERE da.source_type_id = st.source_type_id) as usage_count + FROM source_type st + ORDER BY st.source_type_id + `); + res.json({ success: true, data: sources, count: sources.length }); + } catch (error) { + internalError(res, error); + } +}); + +// GET source type by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const source = await queryOne( + 'SELECT * FROM source_type WHERE source_type_id = $1', + [req.params.id] + ); + if (!source) { + return res.status(404).json({ success: false, error: 'Source type nu a fost găsit' }); + } + res.json({ success: true, data: source }); + } catch (error) { + internalError(res, error); + } +}); + +// GET dependency check +router.get('/:id/dependencies', async (req: Request, res: Response) => { + try { + const depCheck = await checkDependencies('source_type', 'source_type_id', req.params.id); + res.json({ success: true, data: depCheck }); + } catch (error) { + internalError(res, error); + } +}); + +// POST create source type +router.post('/', async (req: Request, res: Response) => { + try { + const { source_type, base_score } = req.body; + if (!source_type) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: source_type' }); + } + + const result = await transaction(async (client) => { + const id = await getNextId(client, 'source_type', 'source_type_id'); + const insertResult = await client.query(` + INSERT INTO source_type (source_type_id, source_type, base_score) + VALUES ($1, $2, $3) + RETURNING * + `, [id, source_type, base_score || 0]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Source type creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT update source type +router.put('/:id', async (req: Request, res: Response) => { + try { + const { source_type, base_score } = req.body; + const source = await queryOne(` + UPDATE source_type + SET source_type = COALESCE($1, source_type), + base_score = COALESCE($2, base_score) + WHERE source_type_id = $3 + RETURNING * + `, [source_type, base_score, req.params.id]); + + if (!source) { + return res.status(404).json({ success: false, error: 'Source type nu a fost găsit' }); + } + res.json({ success: true, data: source, message: 'Source type actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE source type (with safety check) +router.delete('/:id', async (req: Request, res: Response) => { + try { + const deleteResult = await safeDelete('source_type', 'source_type_id', req.params.id); + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + dependencies: deleteResult.dependencyDetails?.dependencies + }); + } + res.json({ success: true, message: 'Source type șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/subdimensions.ts b/backend/services/orchestration-layer/didiFramework/src/routes/subdimensions.ts new file mode 100644 index 0000000..c8b2e47 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/subdimensions.ts @@ -0,0 +1,342 @@ +/** + * Subdimensions Routes - FULL CRUD with Safety + * + * Subdimensions are children of dimensions and parents of techniques: + * dimension -> subdimension -> technique -> indicator/validation_rule + * + * DELETE is protected - cannot delete subdimension with techniques + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { checkDependencies, safeDelete } from '../utils/dependency-checker'; +import { Subdimension, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Parameter type for subdimensions +const PARAMETER_TYPE_SUBDIMENSION = 2; + +// Helper: Create parameter entry +const createParameter = async (client: PoolClient): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, PARAMETER_TYPE_SUBDIMENSION]); + + return nextParamId; +}; + +// Helper: Get next subdimension_id +const getNextSubdimensionId = async (client: PoolClient): Promise => { + const result = await client.query('SELECT COALESCE(MAX(subdimension_id), 0) + 1 as next_id FROM subdimension'); + return result.rows[0].next_id; +}; + +// Fixed column name (database has typo: subdmiension_name) +const SELECT_COLUMNS = `subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description`; + +// GET all subdimensions +router.get('/', async (req: Request, res: Response) => { + try { + const subdimensions = await query( + `SELECT ${SELECT_COLUMNS} FROM subdimension ORDER BY subdimension_id` + ); + res.json({ + success: true, + data: subdimensions, + count: subdimensions.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET all subdimensions with counts +router.get('/with-counts', async (req: Request, res: Response) => { + try { + const subdimensions = await query(` + SELECT s.subdimension_id, s.dimension_id, s.subdmiension_name as subdimension_name, + s.subdimension_code, s.description, + d.dimension_name, d.dimension_code, + (SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count + FROM subdimension s + JOIN dimension d ON s.dimension_id = d.dimension_id + ORDER BY d.dimension_id, s.subdimension_id + `); + res.json({ + success: true, + data: subdimensions, + count: subdimensions.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET subdimensions by dimension_id +router.get('/by-dimension/:dimensionId', async (req: Request, res: Response) => { + try { + const subdimensions = await query( + `SELECT ${SELECT_COLUMNS}, + (SELECT COUNT(*) FROM technique t WHERE t.subdimension_id = s.subdimension_id) as technique_count + FROM subdimension s + WHERE s.dimension_id = $1 + ORDER BY s.subdimension_id`, + [req.params.dimensionId] + ); + res.json({ + success: true, + data: subdimensions, + count: subdimensions.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET subdimension by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const subdimension = await queryOne( + `SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`, + [req.params.id] + ); + if (!subdimension) { + return res.status(404).json({ + success: false, + error: 'Subdimensiunea nu a fost găsită' + } as ApiResponse); + } + res.json({ + success: true, + data: subdimension + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET dependency check before delete +router.get('/:id/dependencies', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // Check if subdimension exists + const subdimension = await queryOne( + `SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`, + [id] + ); + + if (!subdimension) { + return res.status(404).json({ + success: false, + error: 'Subdimensiunea nu a fost găsită' + }); + } + + // Check dependencies + const depCheck = await checkDependencies('subdimension', 'subdimension_id', id); + + // Get detailed technique info if there are children + let techniques: any[] = []; + if (depCheck.hasChildren) { + techniques = await query(` + SELECT t.technique_id, t.technique_name, t.severity, + (SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count, + (SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count + FROM technique t + WHERE t.subdimension_id = $1 + ORDER BY t.technique_id + `, [id]); + } + + res.json({ + success: true, + data: { + subdimension, + ...depCheck, + childDetails: techniques + } + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST create subdimension +router.post('/', async (req: Request, res: Response) => { + try { + const { dimension_id, subdimension_name, subdimension_code, description } = req.body; + + // Validation + if (!dimension_id || !subdimension_name || !subdimension_code) { + return res.status(400).json({ + success: false, + error: 'Câmpuri obligatorii: dimension_id, subdimension_name, subdimension_code' + }); + } + + // Verify dimension exists + const dimension = await queryOne('SELECT dimension_id FROM dimension WHERE dimension_id = $1', [dimension_id]); + if (!dimension) { + return res.status(400).json({ + success: false, + error: 'Dimensiunea specificată nu există' + }); + } + + const result = await transaction(async (client) => { + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next subdimension_id + const subdimensionId = await getNextSubdimensionId(client); + + // Insert subdimension (note: column name is subdmiension_name with typo) + const insertResult = await client.query(` + INSERT INTO subdimension (subdimension_id, dimension_id, subdmiension_name, subdimension_code, description, + subdimension_name_ro, subdimension_name_en, description_ro, description_en) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description, + subdimension_name_ro, subdimension_name_en, description_ro, description_en + `, [subdimensionId, dimension_id, subdimension_name, subdimension_code, description || '', + req.body.subdimension_name_ro || null, req.body.subdimension_name_en || subdimension_name, + req.body.description_ro || null, req.body.description_en || description || '']); + + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result, + message: 'Subdimensiunea a fost creată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT update subdimension +router.put('/:id', async (req: Request, res: Response) => { + try { + const { dimension_id, subdimension_name, subdimension_code, description, + subdimension_name_ro, subdimension_name_en, description_ro, description_en } = req.body; + + // Check if subdimension exists + const existing = await queryOne( + `SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`, + [req.params.id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Subdimensiunea nu a fost găsită' + }); + } + + // If changing dimension_id, verify it exists + if (dimension_id) { + const dimension = await queryOne('SELECT dimension_id FROM dimension WHERE dimension_id = $1', [dimension_id]); + if (!dimension) { + return res.status(400).json({ + success: false, + error: 'Dimensiunea specificată nu există' + }); + } + } + + // Note: column name is subdmiension_name with typo in DB + const subdimension = await queryOne( + `UPDATE subdimension + SET dimension_id = COALESCE($1, dimension_id), + subdmiension_name = COALESCE($2, subdmiension_name), + subdimension_code = COALESCE($3, subdimension_code), + description = COALESCE($4, description), + subdimension_name_ro = COALESCE($6, subdimension_name_ro), + subdimension_name_en = COALESCE($7, subdimension_name_en), + description_ro = COALESCE($8, description_ro), + description_en = COALESCE($9, description_en) + WHERE subdimension_id = $5 + RETURNING subdimension_id, dimension_id, subdmiension_name as subdimension_name, subdimension_code, description, + subdimension_name_ro, subdimension_name_en, description_ro, description_en`, + [dimension_id, subdimension_name, subdimension_code, description, req.params.id, + subdimension_name_ro, subdimension_name_en, description_ro, description_en] + ); + + res.json({ + success: true, + data: subdimension, + message: 'Subdimensiunea a fost actualizată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE subdimension (with safety check) +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const force = req.query.force === 'true'; + + // Check if subdimension exists + const existing = await queryOne( + `SELECT ${SELECT_COLUMNS} FROM subdimension WHERE subdimension_id = $1`, + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Subdimensiunea nu a fost găsită' + }); + } + + // Use safe delete + const deleteResult = await safeDelete('subdimension', 'subdimension_id', id, force); + + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + canDelete: false, + dependencies: deleteResult.dependencyDetails?.dependencies || [], + hint: 'Ștergeți mai întâi toate tehnicile asociate acestei subdimensiuni' + }); + } + + res.json({ + success: true, + message: 'Subdimensiunea a fost ștearsă cu succes', + deleted: true + }); + } catch (error: any) { + if (error.code === '23503') { + return res.status(409).json({ + success: false, + error: 'Nu se poate șterge: există tehnici asociate', + canDelete: false + }); + } + + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/subscriptions.ts b/backend/services/orchestration-layer/didiFramework/src/routes/subscriptions.ts new file mode 100644 index 0000000..7a28e58 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/subscriptions.ts @@ -0,0 +1,385 @@ +/** + * Subscriptions Routes + * + * API for subscription and usage information + * Endpoints: + * - GET /api/subscriptions/usage - Get user's usage stats (credits, plan info) + * - GET /api/subscriptions/plans - List available plans + */ + +import { Router, Request, Response } from 'express'; +import { log } from '../config/logger'; +import { internalError } from '../config/error-response'; +import pool from '../config/database'; +import { getStripe, isStripeEnabled } from '../config/stripe'; + +const router = Router(); + +// ============================================================================ +// HELPERS +// ============================================================================ + +interface JWTPayload { + sub: string; + email: string; + preferred_username?: string; + given_name?: string; + family_name?: string; +} + +function extractJWTPayload(authHeader: string | undefined): JWTPayload | null { + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.substring(7); + const parts = token.split('.'); + + if (parts.length !== 3) { + return null; + } + + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8')); + return payload as JWTPayload; + } catch (e) { + return null; + } +} + +// ============================================================================ +// ROUTES +// ============================================================================ + +/** + * GET /api/subscriptions/usage + * Returns user's current usage stats including credits and plan info + */ +router.get('/usage', async (req: Request, res: Response) => { + try { + const jwtPayload = extractJWTPayload(req.headers.authorization); + + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ + success: false, + error: 'Missing or invalid Authorization header' + }); + } + + const keycloakId = jwtPayload.sub; + + const result = await pool.query(` + SELECT + iu.internet_user_id, + iu.credits_remained, + iu.credits_spent, + sp.subscription_plan_id, + sp.plan_name, + sp.plan_type, + sp.credits_per_cycle, + sp.max_images, + sp.max_video_minutes, + sp.storage_limit_gb, + sp.price_amount, + s.is_active, + s.activation_date, + s.deactivation_date + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_sysadmin.subscription s ON iu.internet_user_id = s.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON s.subscription_plan_id = sp.subscription_plan_id + WHERE uc.keycloak_id = $1 + `, [keycloakId]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'User not found' + }); + } + + const user = result.rows[0]; + + // Calculate usage percentage + const creditsUsed = user.credits_spent || 0; + const creditsRemaining = user.credits_remained || 0; + const creditsTotal = creditsRemaining + creditsUsed; + const usagePercent = creditsTotal > 0 ? Math.round((creditsUsed / creditsTotal) * 100) : 0; + + res.json({ + success: true, + data: { + // Credits info + credits: { + remaining: creditsRemaining, + spent: creditsUsed, + total: creditsTotal, + usagePercent: usagePercent + }, + // Plan info + plan: { + id: user.subscription_plan_id || 1, + name: user.plan_name || 'Free', + type: user.plan_type ?? 1, + creditsPerCycle: user.credits_per_cycle || 5, + maxImages: user.max_images || 20, + maxVideoMinutes: user.max_video_minutes || 0, + storageLimitGb: user.storage_limit_gb || 1, + priceAmount: user.price_amount || 0 + }, + // Subscription status + subscription: { + isActive: user.is_active ?? true, + activationDate: user.activation_date, + deactivationDate: user.deactivation_date + } + } + }); + + } catch (error: any) { + log.error('Error in /subscriptions/usage:', error); + internalError(res, error); + } +}); + +/** + * GET /api/subscriptions/plans + * Returns recurring subscription plans only (Free + 5 paid tiers). + * One-time micro-purchases (plan_type=7, e.g. "Techniques - Text") are excluded — + * those are exposed via /api/subscriptions/one-time-products if needed. + */ +router.get('/plans', async (req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + subscription_plan_id as id, + plan_name as name, + plan_type as type, + billing_period as "billingPeriod", + price_amount as price, + credits_per_cycle as "creditsPerCycle", + max_images as "maxImages", + max_video_minutes as "maxVideoMinutes", + storage_limit_gb as "storageLimitGb", + subscription_status as status, + stripe_price_id as "stripePriceIdMonthly", + stripe_price_id_yearly as "stripePriceIdYearly" + FROM bos_sysadmin.subscription_plan + WHERE subscription_status = 1 + AND COALESCE(is_one_time, false) = false + AND plan_type BETWEEN 1 AND 6 + ORDER BY plan_type + `); + + const plans = result.rows.map(plan => ({ + ...plan, + priceUsd: plan.price / 100, + priceMonthly: plan.price / 100, + priceYearly: plan.price === 0 ? 0 : (plan.price * 10) / 100, // yearly = monthly × 10 (17% off) + })); + + res.json({ + success: true, + data: plans + }); + + } catch (error: any) { + log.error('Error in /subscriptions/plans:', error); + internalError(res, error); + } +}); + +/** + * GET /api/subscriptions/one-time-products + * Returns pay-per-use micro-purchases (plan_type=7). + * Separate from recurring plans to avoid mixing in upgrade UI. + */ +router.get('/one-time-products', async (req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + subscription_plan_id as id, + plan_name as name, + price_amount as price, + credits_per_cycle as credits, + component_name as component + FROM bos_sysadmin.subscription_plan + WHERE subscription_status = 1 + AND COALESCE(is_one_time, false) = true + ORDER BY price_amount, plan_name + `); + + const products = result.rows.map(p => ({ + ...p, + priceUsd: p.price / 100, + })); + + res.json({ success: true, data: products }); + + } catch (error: any) { + log.error('Error in /subscriptions/one-time-products:', error); + internalError(res, error); + } +}); + +/** + * POST /api/subscriptions/upgrade + * Body: { plan_id: number, interval?: 'month' | 'year' } + * Returns: { url: string } — redirect URL to Stripe Checkout + * + * Flow: + * 1. Validate JWT, find user. + * 2. Ensure Stripe customer exists (create if missing, with keycloak_id metadata). + * 3. Resolve plan_id → stripe_price_id (monthly or yearly). + * 4. Create Stripe Checkout Session. + * 5. Return URL — frontend redirects user there. + * 6. On success/cancel, Stripe redirects user back to our app; webhook handles state sync. + */ +router.post('/upgrade', async (req: Request, res: Response) => { + try { + if (!isStripeEnabled()) { + return res.status(503).json({ success: false, error: 'Stripe not configured' }); + } + + const jwtPayload = extractJWTPayload(req.headers.authorization); + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ success: false, error: 'Missing or invalid Authorization header' }); + } + + const { plan_id, interval } = req.body as { plan_id?: number; interval?: 'month' | 'year' }; + if (!plan_id || typeof plan_id !== 'number') { + return res.status(400).json({ success: false, error: 'plan_id (number) is required in body' }); + } + const billingInterval = interval === 'year' ? 'year' : 'month'; + + // Resolve user + plan + const userResult = await pool.query( + `SELECT iu.internet_user_id, iu.stripe_customer_id, uc.email + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id + WHERE uc.keycloak_id = $1`, + [jwtPayload.sub] + ); + if (userResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'User not found' }); + } + const user = userResult.rows[0]; + + const planResult = await pool.query( + `SELECT subscription_plan_id, plan_name, stripe_product_id, stripe_price_id, stripe_price_id_yearly + FROM bos_sysadmin.subscription_plan + WHERE subscription_plan_id = $1`, + [plan_id] + ); + if (planResult.rows.length === 0) { + return res.status(404).json({ success: false, error: `Plan ${plan_id} not found` }); + } + const plan = planResult.rows[0]; + const priceId = billingInterval === 'year' ? plan.stripe_price_id_yearly : plan.stripe_price_id; + if (!priceId) { + return res.status(400).json({ + success: false, + error: `Plan "${plan.plan_name}" has no ${billingInterval}ly Stripe price configured` + }); + } + + const stripe = getStripe(); + + // Ensure customer exists + let customerId = user.stripe_customer_id; + if (!customerId) { + const fullName = [jwtPayload.given_name, jwtPayload.family_name].filter(Boolean).join(' ').trim(); + const customer = await stripe.customers.create({ + email: jwtPayload.email || user.email, + name: fullName || undefined, + metadata: { + keycloak_id: jwtPayload.sub, + internet_user_id: String(user.internet_user_id), + project: 'didi', + }, + }); + customerId = customer.id; + // Persist immediately (webhook customer.created may race; this guarantees our DB has it) + await pool.query( + `UPDATE bos_sysadmin.internet_user SET stripe_customer_id = $1 WHERE internet_user_id = $2`, + [customerId, user.internet_user_id] + ); + log.info(`[subscriptions/upgrade] created Stripe customer ${customerId} for user ${user.internet_user_id}`); + } + + // Build success/cancel URLs (origin from request — works for prod + dev) + const origin = req.headers.origin || `https://${req.headers.host}`; + const successUrl = `${origin}/dashboard?upgrade=success&session_id={CHECKOUT_SESSION_ID}`; + const cancelUrl = `${origin}/dashboard?upgrade=cancelled`; + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + allow_promotion_codes: true, + metadata: { + plan_id: String(plan_id), + keycloak_id: jwtPayload.sub, + interval: billingInterval, + }, + subscription_data: { + metadata: { + keycloak_id: jwtPayload.sub, + plan_id: String(plan_id), + project: 'didi', + }, + }, + }); + + log.info(`[subscriptions/upgrade] checkout session ${session.id} for user ${user.internet_user_id} → ${plan.plan_name} (${billingInterval})`); + res.json({ success: true, data: { url: session.url, sessionId: session.id } }); + + } catch (error: any) { + log.error('Error in /subscriptions/upgrade:', error); + internalError(res, error); + } +}); + +/** + * POST /api/subscriptions/portal + * Returns: { url: string } — Stripe Customer Portal URL for self-service + * (cancel, update card, view invoices). Activate Portal in Stripe Dashboard first. + */ +router.post('/portal', async (req: Request, res: Response) => { + try { + if (!isStripeEnabled()) { + return res.status(503).json({ success: false, error: 'Stripe not configured' }); + } + const jwtPayload = extractJWTPayload(req.headers.authorization); + if (!jwtPayload || !jwtPayload.sub) { + return res.status(401).json({ success: false, error: 'Missing or invalid Authorization header' }); + } + + const r = await pool.query( + `SELECT iu.stripe_customer_id + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id + WHERE uc.keycloak_id = $1`, + [jwtPayload.sub] + ); + const customerId = r.rows[0]?.stripe_customer_id; + if (!customerId) { + return res.status(400).json({ success: false, error: 'No Stripe customer linked to this user' }); + } + + const origin = req.headers.origin || `https://${req.headers.host}`; + const session = await getStripe().billingPortal.sessions.create({ + customer: customerId, + return_url: `${origin}/dashboard`, + }); + + res.json({ success: true, data: { url: session.url } }); + } catch (error: any) { + log.error('Error in /subscriptions/portal:', error); + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-analysis.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-analysis.ts new file mode 100644 index 0000000..e10f9bd --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-analysis.ts @@ -0,0 +1,382 @@ +/** + * Sync Analysis Route (LEGACY) + * + * Syncs analysis results from Redis → PostgreSQL for the OLD code path + * (routes.ts saveAndSyncComponent). The NEW pipeline path uses PersistService + * which writes directly to PG, making this sync redundant for new analyses. + * + * History endpoints REMOVED (Task 8.2) - use /api/history (history.ts) instead. + * + * Endpoints: + * - POST /api/sync-analysis/:sessionId - Sync a session from Redis to PG + * - POST /api/sync-analysis/batch - Sync multiple sessions + * - GET /api/sync-analysis/pending - List completed sessions in Redis + * - GET /api/sync-analysis/stats - Analysis statistics from PG + */ + +import { Router, Request, Response } from 'express'; +import type Redis from 'ioredis'; +import { createRedisConnection } from '../config/redis'; +import * as crypto from 'crypto'; +import { log } from '../config/logger'; +import { internalError } from '../config/error-response'; +import pool from '../config/database'; + +const router = Router(); + +const getRedisClient = (): Redis => { + return createRedisConnection({ label: 'sync-analysis' }); +}; + +const REDIS_KEYS = { + sessionStatus: (sid: string) => `didi:pipeline:${sid}:status`, + sessionResult: (sid: string, comp: string) => `didi:pipeline:${sid}:${comp}`, + sessionVerdict: (sid: string) => `didi:pipeline:${sid}:verdict`, + historyEntry: (sid: string) => `didi:pipeline:history:entry:${sid}`, + historyUser: (uid: string) => `didi:pipeline:history:user:${uid}`, +}; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface PipelineStatus { + session_id: string; + status: 'running' | 'completed' | 'failed'; + started_at: number; + completed_at?: number; + components: Record; +} + +interface SyncResult { + session_id: string; + success: boolean; + tables_inserted: string[]; + error?: string; + redis_keys_deleted?: number; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function inputHash(text?: string, url?: string): string { + return crypto.createHash('sha256').update(text || url || '').digest('hex'); +} + +function componentsWithStatus(status: PipelineStatus, s: string): string[] { + return Object.entries(status.components).filter(([_, v]) => v.status === s).map(([k]) => k); +} + +// ============================================================================ +// SYNC SESSION: Redis → PostgreSQL +// All scores are already 0-100 (executors produce correct scale since Task 2.x). +// ============================================================================ + +async function syncSession(redis: Redis, sessionId: string, input?: any): Promise { + const client = await pool.connect(); + const tables: string[] = []; + + try { + const [statusJson, techJson, aiJson, claimsJson, domainJson, verdictJson, historyJson] = await Promise.all([ + redis.get(REDIS_KEYS.sessionStatus(sessionId)), + redis.get(REDIS_KEYS.sessionResult(sessionId, 'techniques')), + redis.get(REDIS_KEYS.sessionResult(sessionId, 'ai_tampered')), + redis.get(REDIS_KEYS.sessionResult(sessionId, 'claims')), + redis.get(REDIS_KEYS.sessionResult(sessionId, 'domain')), + redis.get(REDIS_KEYS.sessionVerdict(sessionId)), + redis.get(REDIS_KEYS.historyEntry(sessionId)), + ]); + + if (!statusJson) { + return { session_id: sessionId, success: false, tables_inserted: [], error: 'Session not found in Redis' }; + } + + const status: PipelineStatus = JSON.parse(statusJson); + const techniques = techJson ? JSON.parse(techJson) : null; + const aiTampered = aiJson ? JSON.parse(aiJson) : null; + const claims = claimsJson ? JSON.parse(claimsJson) : null; + const domain = domainJson ? JSON.parse(domainJson) : null; + const verdict = verdictJson ? JSON.parse(verdictJson) : null; + const history = historyJson ? JSON.parse(historyJson) : null; + const inp = input || history || {}; + + await client.query('BEGIN'); + + // analysis_session + await client.query(` + INSERT INTO bos_analysis.analysis_session ( + session_id, user_id, user_email, input_type, input_text, input_url, input_media_url, input_hash, + status, components_run, components_skipped, + risk_score, risk_category, risk_level, confidence, confidence_level, + started_at, completed_at, total_duration_ms, + scenario_applied, topic_applied, source_app, api_version + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) + ON CONFLICT (session_id) DO UPDATE SET + status = EXCLUDED.status, risk_score = EXCLUDED.risk_score, + risk_category = EXCLUDED.risk_category, completed_at = EXCLUDED.completed_at, + total_duration_ms = EXCLUDED.total_duration_ms + `, [ + sessionId, inp.user_id || null, inp.user_email || null, + inp.input_type || history?.input_type || 'text', + inp.input_text || inp.input_preview || null, + inp.input_url || inp.url || null, inp.media_url || null, + inputHash(inp.input_text, inp.input_url), + status.status, componentsWithStatus(status, 'completed'), componentsWithStatus(status, 'skipped'), + verdict?.risk_score ?? history?.risk_score ?? null, + verdict?.risk_category ?? history?.risk_category ?? null, verdict?.risk_level ?? null, + verdict?.confidence ?? history?.confidence ?? null, verdict?.confidence_level ?? null, + new Date(status.started_at), + status.completed_at ? new Date(status.completed_at) : null, + status.completed_at ? status.completed_at - status.started_at : null, + inp.options?.scenario || null, inp.options?.topic || null, inp.source_app || 'web', 'v3', + ]); + tables.push('analysis_session'); + + // analysis_techniques + if (techniques?.result || techniques?.techniques) { + const t = techniques.result || techniques; + await client.query(` + INSERT INTO bos_analysis.analysis_techniques ( + session_id, manipulation_score, total_severity, dimensions_affected, techniques_count, + techniques_detected, coupling_context, llm_screening, llm_deep, + screening_duration_ms, deep_analysis_duration_ms, total_duration_ms, + fallbacks_screening, fallbacks_deep + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING + `, [ + sessionId, Math.round(t.manipulation_score || 0), t.total_severity || 0, + t.dimensions_affected || [], t.techniques?.length || 0, + JSON.stringify(t.techniques || []), JSON.stringify(t.coupling_context || {}), + t.metadata?.llm_screening || null, t.metadata?.llm_deep || null, + t.metadata?.screening_duration_ms || null, t.metadata?.deep_analysis_duration_ms || null, + t.metadata?.total_duration_ms || null, + t.metadata?.fallbacks_used?.screening || 0, t.metadata?.fallbacks_used?.deep || 0, + ]); + tables.push('analysis_techniques'); + } + + // analysis_ai_tampered + if (aiTampered?.result || aiTampered?.ai_probability !== undefined) { + const a = aiTampered.result || aiTampered; + await client.query(` + INSERT INTO bos_analysis.analysis_ai_tampered ( + session_id, ai_probability, verdict, risk_score, categories_affected, indicators_count, + disclosure_detected, disclosure_explicit, disclosure_text, + indicators_detected, coupling_context, llm_screening, llm_deep, + screening_duration_ms, deep_analysis_duration_ms, total_duration_ms, + fallbacks_screening, fallbacks_deep, content_type, image_analysis + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT DO NOTHING + `, [ + sessionId, a.ai_probability || 0, a.verdict || 'LIKELY_HUMAN', Math.round(a.risk_score || 0), + a.categories_affected || [], a.detected_indicators?.length || 0, + a.disclosure?.disclosed || false, a.disclosure?.type === 'explicit', + a.disclosure?.tool_mentioned || null, + JSON.stringify(a.detected_indicators || []), JSON.stringify(a.coupling_context || {}), + a.metadata?.llm_screening || null, a.metadata?.llm_deep || null, + a.metadata?.screening_duration_ms || null, a.metadata?.deep_analysis_duration_ms || null, + a.metadata?.total_duration_ms || null, + a.metadata?.fallbacks_used?.screening || 0, a.metadata?.fallbacks_used?.deep || 0, + a.metadata?.content_type || 'text', + a.image_analysis ? JSON.stringify(a.image_analysis) : null, + ]); + tables.push('analysis_ai_tampered'); + } + + // analysis_claims + if (claims?.result || claims?.claims) { + const c = claims.result || claims; + const credScore = c.credibility_score != null && c.credibility_score >= 0 ? Math.round(c.credibility_score) : null; + await client.query(` + INSERT INTO bos_analysis.analysis_claims ( + session_id, total_claims, verified_true, verified_false, unverified, opinions, + credibility_score, interpretation, claims_by_status, claims_by_type, claims_verified, + llm_extraction, llm_verification, extraction_duration_ms, verification_duration_ms, + total_duration_ms, web_searches_made + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT DO NOTHING + `, [ + sessionId, c.total_claims || 0, c.verified_true || 0, c.verified_false || 0, + c.unverified || 0, c.opinions || 0, credScore, c.interpretation || null, + JSON.stringify(c.claims_by_status || {}), JSON.stringify(c.claims_by_type || {}), + JSON.stringify(c.claims || []), + c.metadata?.llm_extraction || null, c.metadata?.llm_verification || null, + c.metadata?.extraction_duration_ms || null, c.metadata?.verification_duration_ms || null, + c.metadata?.total_duration_ms || null, c.metadata?.web_searches_made || 0, + ]); + tables.push('analysis_claims'); + } + + // analysis_domain + if (domain?.result || domain?.domain) { + const d = domain.result || domain; + await client.query(` + INSERT INTO bos_analysis.analysis_domain ( + session_id, domain, verdict, trust_score, risk_level, + age_days, age_category, domain_created_at, is_blacklisted, reputation_score, + has_ssl, ssl_valid, ssl_issuer, registrar, organization, country, + red_flags, warnings, duration_ms + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING + `, [ + sessionId, d.domain || 'unknown', d.verdict || 'NEUTRAL', d.trust_score || 50, + d.risk_level || null, + d.age?.days || null, d.age?.category || null, + d.age?.created_at ? new Date(d.age.created_at) : null, + d.blacklist?.is_blacklisted || false, d.blacklist?.reputation_score || null, + d.ssl?.has_ssl || null, d.ssl?.is_valid || null, d.ssl?.issuer || null, + d.ownership?.registrar || null, d.ownership?.organization || null, d.ownership?.country || null, + d.red_flags || [], d.warnings || [], d.metadata?.duration_ms || null, + ]); + tables.push('analysis_domain'); + } + + // analysis_verdict + if (verdict) { + await client.query(` + INSERT INTO bos_analysis.analysis_verdict ( + session_id, risk_score, risk_category, risk_category_color, risk_level, risk_level_color, + severity, recommended_action, confidence, confidence_level, + score_manipulation, score_claims, score_ai, score_source, score_context, + applied_weights, override_applied, override_type, override_reason, override_adjustment, + context_summary, components_used, weights_source, duration_ms + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) + ON CONFLICT DO NOTHING + `, [ + sessionId, verdict.risk_score || 0, verdict.risk_category || 'UNKNOWN', + verdict.risk_category_color || null, verdict.risk_level || 'LOW', + verdict.risk_level_color || null, verdict.severity || null, + verdict.recommended_action || null, verdict.confidence || 0, + verdict.confidence_level || 'LOW', + verdict.component_scores?.manipulation >= 0 ? verdict.component_scores.manipulation : null, + verdict.component_scores?.claims >= 0 ? verdict.component_scores.claims : null, + verdict.component_scores?.ai >= 0 ? verdict.component_scores.ai : null, + verdict.component_scores?.source >= 0 ? verdict.component_scores.source : null, + verdict.component_scores?.context >= 0 ? verdict.component_scores.context : null, + JSON.stringify(verdict.applied_weights || {}), + verdict.override?.applied || false, verdict.override?.type || null, + verdict.override?.reason || null, verdict.override?.adjustment || null, + JSON.stringify(verdict.context_summary || {}), + verdict.metadata?.components_used || [], verdict.metadata?.weights_source || null, + verdict.metadata?.duration_ms || null, + ]); + tables.push('analysis_verdict'); + } + + await client.query('COMMIT'); + + // Clean up Redis after successful PG insert + const deleted = await redis.del( + REDIS_KEYS.sessionStatus(sessionId), + REDIS_KEYS.sessionResult(sessionId, 'techniques'), + REDIS_KEYS.sessionResult(sessionId, 'ai_tampered'), + REDIS_KEYS.sessionResult(sessionId, 'claims'), + REDIS_KEYS.sessionResult(sessionId, 'domain'), + REDIS_KEYS.sessionVerdict(sessionId), + REDIS_KEYS.historyEntry(sessionId), + ); + if (inp.user_id) await redis.zrem(REDIS_KEYS.historyUser(inp.user_id), sessionId); + + return { session_id: sessionId, success: true, tables_inserted: tables, redis_keys_deleted: deleted }; + } catch (error) { + await client.query('ROLLBACK'); + log.error(`[Sync] Error syncing session ${sessionId}:`, error); + return { session_id: sessionId, success: false, tables_inserted: [], error: (error as Error).message }; + } finally { + client.release(); + } +} + +// ============================================================================ +// ROUTES +// ============================================================================ + +/** POST /api/sync-analysis/batch - Sync multiple sessions + * Declarat înainte de '/:sessionId', altfel 'batch' e capturat ca sessionId */ +router.post('/batch', async (req: Request, res: Response) => { + const { session_ids } = req.body; + if (!session_ids?.length) return res.status(400).json({ success: false, error: 'session_ids array is required' }); + + const redis = getRedisClient(); + try { + const results: SyncResult[] = []; + for (const sid of session_ids) results.push(await syncSession(redis, sid)); + redis.quit(); + const ok = results.filter(r => r.success).length; + res.json({ success: true, message: `Batch: ${ok}/${session_ids.length} synced`, data: { total: session_ids.length, successful: ok, failed: session_ids.length - ok, results } }); + } catch (error) { + redis.quit(); + internalError(res, error); + } +}); + +/** POST /api/sync-analysis/:sessionId - Sync single session from Redis to PG */ +router.post('/:sessionId', async (req: Request, res: Response) => { + const { sessionId } = req.params; + if (!sessionId) return res.status(400).json({ success: false, error: 'sessionId is required' }); + + const redis = getRedisClient(); + try { + const result = await syncSession(redis, sessionId, req.body); + redis.quit(); + result.success + ? res.json({ success: true, message: `Session ${sessionId} synced`, data: result }) + : res.status(400).json({ success: false, error: result.error, session_id: sessionId }); + } catch (error) { + redis.quit(); + internalError(res, error); + } +}); + +/** GET /api/sync-analysis/pending - List completed sessions still in Redis */ +router.get('/pending', async (_req: Request, res: Response) => { + const redis = getRedisClient(); + try { + const keys: string[] = []; + let cursor = '0'; + do { + const [next, found] = await redis.scan(cursor, 'MATCH', 'didi:pipeline:*:status', 'COUNT', 100); + cursor = next; + keys.push(...found); + } while (cursor !== '0'); + + const pending: { session_id: string; status: string; completed_at: string }[] = []; + for (const key of keys) { + const data = await redis.get(key); + if (data) { + const s = JSON.parse(data); + if (s.status === 'completed') { + pending.push({ session_id: key.replace('didi:pipeline:', '').replace(':status', ''), status: s.status, completed_at: s.completed_at ? new Date(s.completed_at).toISOString() : 'unknown' }); + } + } + } + redis.quit(); + res.json({ success: true, data: { count: pending.length, sessions: pending } }); + } catch (error) { + redis.quit(); + internalError(res, error); + } +}); + +/** GET /api/sync-analysis/stats - Analysis statistics */ +router.get('/stats', async (_req: Request, res: Response) => { + const client = await pool.connect(); + try { + const [total, today, byStatus] = await Promise.all([ + client.query('SELECT COUNT(*) as total FROM bos_analysis.analysis_session'), + client.query('SELECT COUNT(*) as today FROM bos_analysis.analysis_session WHERE created_at >= CURRENT_DATE'), + client.query('SELECT status, COUNT(*) as count FROM bos_analysis.analysis_session GROUP BY status'), + ]); + res.json({ + success: true, + data: { + total_sessions: parseInt(total.rows[0].total), + sessions_today: parseInt(today.rows[0].today), + by_status: byStatus.rows.reduce((acc: Record, r) => { acc[r.status] = parseInt(r.count); return acc; }, {}), + }, + }); + } catch (error) { + internalError(res, error); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis.ts new file mode 100644 index 0000000..be472b1 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis.ts @@ -0,0 +1,6 @@ +/** + * Re-export of the new sync-redis barrel. Kept at this path so server.ts + * (which imports `./routes/sync-redis`) continues to work unchanged after + * the 898-LOC → 7-file split. See ./sync-redis/index.ts for the routing map. + */ +export { default } from './sync-redis/index'; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/_shared.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/_shared.ts new file mode 100644 index 0000000..031f3e2 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/_shared.ts @@ -0,0 +1,83 @@ +/** + * Shared Redis client + framework key prefix + parameter type interfaces + * used across the sync-redis sub-routers. + * + * Each call to `getRedisClient()` opens a NEW connection — this matches + * the original behavior (open per request, close at end of route). Callers + * must `quit()` after use. + */ +import type Redis from 'ioredis'; +import { createRedisConnection } from '../../config/redis'; + +export const KEY_PREFIX = 'didi:framework'; + +export const getRedisClient = (): Redis => { + return createRedisConnection({ + label: 'sync-redis', + overrides: { keyPrefix: '' }, + }); +}; + +// ============================================================================ +// FRAMEWORK PARAMETER INTERFACES (used by fetch-data.ts) +// ============================================================================ + +export interface Dimension { + dimension_id: number; + dimension_code: string; + dimension_name: string; + description: string; + weight: number; + dimension_name_ro?: string; + dimension_name_en?: string; + description_ro?: string; + description_en?: string; +} + +export interface Subdimension { + subdimension_id: number; + dimension_id: number; + subdimension_code: string; + subdimension_name: string; + description: string; + subdimension_name_ro?: string; + subdimension_name_en?: string; + description_ro?: string; + description_en?: string; +} + +export interface Technique { + technique_id: number; + subdimension_id: number; + technique_key: number; + technique_name: string; + severity: number; + confidence: number; + detectability: number; + technique_name_ro?: string; + technique_name_en?: string; +} + +export interface Indicator { + technique_id: number; + indicator_id: number; + indicator_name: string; + description: string; + max_intensity: number; + indicator_name_ro?: string; + indicator_name_en?: string; + description_ro?: string; + description_en?: string; +} + +export interface ValidationRule { + technique_valid_rule_id: number; + technique_id: number; + rule_name: string; + rule_value: string; + description: string; + rule_name_ro?: string; + rule_name_en?: string; + description_ro?: string; + description_en?: string; +} diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/data.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/data.ts new file mode 100644 index 0000000..364946c --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/data.ts @@ -0,0 +1,53 @@ +/** + * GET /api/sync-redis/data/:category + * + * Read a single framework key from Redis for debugging — returns parsed + * JSON. Categories: manifest | techniques | sources | claims | verdicts | + * weights | providers. + */ +import { Router, Request, Response } from 'express'; +import { internalError } from '../../config/error-response'; +import { getRedisClient, KEY_PREFIX } from './_shared'; + +const router = Router(); + +const VALID_CATEGORIES = ['manifest', 'techniques', 'sources', 'claims', 'verdicts', 'weights', 'providers']; + +router.get('/data/:category', async (req: Request, res: Response) => { + const redis = getRedisClient(); + const { category } = req.params; + + if (!VALID_CATEGORIES.includes(category)) { + await redis.quit(); + return res.status(400).json({ + success: false, + error: `Invalid category. Valid options: ${VALID_CATEGORIES.join(', ')}`, + }); + } + + try { + const data = await redis.get(`${KEY_PREFIX}:${category}`); + await redis.quit(); + + if (!data) { + return res.status(404).json({ + success: false, + error: `No data found for category: ${category}`, + }); + } + + res.json({ + success: true, + data: JSON.parse(data), + }); + } catch (error) { + try { + await redis.quit(); + } catch { + // ignore + } + internalError(res, error, 'sync_redis_data'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-config.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-config.ts new file mode 100644 index 0000000..60808e0 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-config.ts @@ -0,0 +1,123 @@ +/** + * Component config fetchers — pull stage assignments / prompts / configs / + * input profiles from PG, return shapes that the agent reads from Redis at + * `didi:config:{component}:v1:*`. + * + * Each fetcher returns an empty object / null when its table doesn't exist + * (graceful degradation for fresh deploys). + */ +import { query } from '../../config/database'; +import { log } from '../../config/logger'; + +/** + * Fetches stage assignments grouped by component → stage → tier → models. + * Returns: { [component]: { [stage]: { [tier]: { stage, description, models[] } } } } + * Tiers: 'free' | 'premium' + */ +export async function fetchStageAssignments(): Promise>>> { + try { + const rows = await query(` + SELECT csa.component_code, csa.stage_code, csa.stage_name, csa.fallback_order, csa.tier, + p.provider_code, p.base_url, p.auth_type, + m.model_code, m.model_name, m.context_window, m.max_output_tokens, + csa.temperature, csa.max_tokens, csa.timeout_ms, csa.description as role + FROM bos_parammgmt.component_stage_assignment csa + JOIN bos_parammgmt.llm_provider p ON csa.provider_id = p.provider_id + JOIN bos_parammgmt.llm_model m ON csa.model_id = m.model_id + WHERE csa.is_enabled = true + ORDER BY csa.component_code, csa.stage_code, csa.tier, csa.fallback_order + `); + const result: Record>> = {}; + for (const r of rows) { + const tier = r.tier || 'free'; + if (!result[r.component_code]) result[r.component_code] = {}; + if (!result[r.component_code][r.stage_code]) result[r.component_code][r.stage_code] = {}; + if (!result[r.component_code][r.stage_code][tier]) { + result[r.component_code][r.stage_code][tier] = { + stage: r.stage_code, + description: r.stage_name, + models: [], + }; + } + result[r.component_code][r.stage_code][tier].models.push({ + order: r.fallback_order, + role: r.role || (r.fallback_order === 1 ? 'primary' : `fallback_${r.fallback_order - 1}`), + model_key: `${r.provider_code}:${r.model_code}`, + provider: r.provider_code, + provider_config: { base_url: r.base_url, auth_type: r.auth_type }, + model_code: r.model_code, + model_name: r.model_name, + context_window: r.context_window, + max_output_tokens: r.max_output_tokens, + temperature: parseFloat(r.temperature), + max_tokens: r.max_tokens, + timeout_ms: r.timeout_ms, + }); + } + return result; + } catch (error) { + log.info('[sync-redis] component_stage_assignment table not found, skipping...'); + return {}; + } +} + +export async function fetchPrompts(): Promise>> { + try { + const rows = await query('SELECT component_code, stage_code, system_prompt, user_template, system_prompt_ro, user_template_ro FROM bos_parammgmt.component_prompt ORDER BY component_code, stage_code'); + const result: Record> = {}; + for (const r of rows) { + if (!result[r.component_code]) result[r.component_code] = {}; + result[r.component_code][r.stage_code] = { + system: r.system_prompt, + user_template: r.user_template, + // Bilingual: RO variants (null if not yet translated) + system_ro: r.system_prompt_ro || null, + user_template_ro: r.user_template_ro || null, + }; + } + return result; + } catch (error) { + log.info('[sync-redis] component_prompt table not found, skipping...'); + return {}; + } +} + +export async function fetchComponentConfigs(): Promise>> { + try { + const rows = await query('SELECT component_code, config_key, config_value FROM bos_parammgmt.component_config ORDER BY component_code, config_key'); + const result: Record> = {}; + for (const r of rows) { + if (!result[r.component_code]) result[r.component_code] = {}; + result[r.component_code][r.config_key] = r.config_value; + } + return result; + } catch (error) { + log.info('[sync-redis] component_config table not found, skipping...'); + return {}; + } +} + +export async function fetchInputProfiles(): Promise { + try { + const profiles = await query(` + SELECT p.*, + (SELECT json_agg(json_build_object( + 'override_code', o.override_code, + 'enabled', o.enabled, + 'threshold', o.threshold, + 'bonus_per_unit', o.bonus_per_unit, + 'bonus_fixed', o.bonus_fixed, + 'max_bonus', o.max_bonus + ) ORDER BY o.override_code) + FROM profile_override_config o WHERE o.profile_code = p.profile_code + ) as overrides + FROM input_type_profile p + WHERE p.is_active = true + ORDER BY p.profile_id + `); + return profiles; + } catch (error) { + log.info('[sync-redis] input_type_profile table not found, skipping...'); + return null; + } +} diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-data.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-data.ts new file mode 100644 index 0000000..f8b1b2c --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/fetch-data.ts @@ -0,0 +1,260 @@ +/** + * Framework data fetchers — pull each PG table into the shape that ends up in + * Redis under `didi:framework:{techniques,sources,claims,verdicts,weights,providers}`. + * + * Each function returns a JSON-ready object (or null if its tables don't exist + * yet — gracefully skipped during sync so a fresh deploy doesn't crash). + */ +import { query } from '../../config/database'; +import { log } from '../../config/logger'; +import type { Dimension, Indicator, Subdimension, Technique, ValidationRule } from './_shared'; + +// Fetch techniques hierarchy (denormalized) +export async function fetchTechniquesHierarchy() { + // Get all data in parallel + const [dimensions, subdimensions, techniques, indicators, rules] = await Promise.all([ + query('SELECT * FROM dimension ORDER BY dimension_id'), + query(`SELECT subdimension_id, dimension_id, subdimension_code, + subdmiension_name as subdimension_name, description, + subdimension_name_ro, subdimension_name_en, description_ro, description_en + FROM subdimension ORDER BY subdimension_id`), + query('SELECT * FROM technique ORDER BY technique_id'), + query('SELECT * FROM technique_indicator ORDER BY technique_id, indicator_id'), + query('SELECT * FROM technique_validation_rule ORDER BY technique_id'), + ]); + + // Build hierarchy + const techniquesMap = new Map(); + techniques.forEach(t => { + techniquesMap.set(t.technique_id, { + ...t, + indicators: [], + validation_rules: [], + }); + }); + + // Add indicators to techniques - EXACT DB structure + indicators.forEach(i => { + const technique = techniquesMap.get(i.technique_id); + if (technique) { + technique.indicators.push({ ...i }); + } + }); + + // Add validation rules to techniques - EXACT DB structure + rules.forEach(r => { + const technique = techniquesMap.get(r.technique_id); + if (technique) { + technique.validation_rules.push({ ...r }); + } + }); + + // Build subdimensions with techniques - EXACT DB structure + const subdimensionsMap = new Map(); + subdimensions.forEach(sd => { + subdimensionsMap.set(sd.subdimension_id, { + ...sd, + techniques: [], + }); + }); + + // Add techniques to subdimensions - EXACT DB structure + techniquesMap.forEach(t => { + const subdimension = subdimensionsMap.get(t.subdimension_id); + if (subdimension) { + subdimension.techniques.push(t); + } + }); + + // Build final hierarchy - EXACT DB structure + return dimensions.map(d => ({ + ...d, + subdimensions: Array.from(subdimensionsMap.values()) + .filter(sd => sd.dimension_id === d.dimension_id), + })); +} + +// Fetch source assessment data +export async function fetchSourceAssessment() { + const [ + platforms, + platformModifiers, + sourceCredibility, + sourceTypes, + sourceAssessment, + domainAgeScores, + domainRiskLevels, + domainRedFlags, + authorClassifications, + authorCredibility, + ] = await Promise.all([ + query('SELECT * FROM platform ORDER BY platform_id'), + query('SELECT * FROM platform_modifier ORDER BY platform_modifier_id'), + query('SELECT * FROM source_credibility ORDER BY source_credibility_id'), + query('SELECT * FROM source_type ORDER BY source_type_id'), + query('SELECT * FROM source_assessment ORDER BY source_assessment_id'), + query('SELECT * FROM domain_age_score ORDER BY domain_age_score'), + query('SELECT * FROM domain_risk_level ORDER BY domain_risk_level_id'), + query('SELECT * FROM domain_red_flag ORDER BY domain_red_flag_id'), + query('SELECT * FROM author_classification ORDER BY author_classification_id'), + query('SELECT * FROM author_credibility ORDER BY author_credibility_id'), + ]); + + return { + platforms, + platform_modifiers: platformModifiers, + source_credibility: sourceCredibility, + source_types: sourceTypes, + source_assessment: sourceAssessment, + domain_age_scores: domainAgeScores, + domain_risk_levels: domainRiskLevels, + domain_red_flags: domainRedFlags, + author_classifications: authorClassifications, + author_credibility: authorCredibility, + }; +} + +// Fetch claims data +export async function fetchClaims() { + const [status, types, confidence, interpretation] = await Promise.all([ + query('SELECT * FROM claim ORDER BY claim_id'), + query('SELECT * FROM claim_type ORDER BY claim_type_id'), + query('SELECT * FROM confidence ORDER BY confidence_id'), + query('SELECT * FROM interpretation ORDER BY interpretation_id'), + ]); + + return { + status, + types, + confidence, + interpretation, + }; +} + +// Fetch verdicts data +export async function fetchVerdicts() { + const [categories, risk, severity] = await Promise.all([ + query('SELECT * FROM verdict_category ORDER BY verdict_category_id'), + query('SELECT * FROM risk_mapping ORDER BY risk_mapping_id'), + query('SELECT * FROM severity_assessment ORDER BY severity_id'), + ]); + + return { + categories, + risk_mappings: risk, + severity_assessments: severity, + }; +} + +// Fetch weights data +export async function fetchWeights() { + const [components, scenarios, multipliers] = await Promise.all([ + query('SELECT * FROM component_weight ORDER BY component_weight_id'), + query('SELECT * FROM weight_scenario ORDER BY scenario_id'), + query('SELECT * FROM multiplier ORDER BY multiplier_id'), + ]); + + return { + components, + scenarios, + multipliers, + }; +} + +/** + * Build dimensions_compact from techniques hierarchy + * This generates the compact dimension list used by the screening stage + * to ensure consistency between screening and deep analysis + */ +export function buildDimensionsCompact(techniques: any[]): { + _description: string; + dimensions: { code: string; name: string; name_ro: string; short_description: string; short_description_ro: string }[]; +} { + return { + _description: 'Compact dimension list for screening prompt - auto-generated from framework DB (bilingual EN/RO)', + dimensions: techniques.map(dim => { + // Get subdimension names for short_description (EN) + const subdimNames = dim.subdimensions + .slice(0, 4) + .map((sd: any) => sd.subdimension_name) + .join(', '); + + // Get subdimension names for short_description (RO) + const subdimNamesRo = dim.subdimensions + .slice(0, 4) + .map((sd: any) => sd.subdimension_name_ro || sd.subdimension_name) + .join(', '); + + // Capitalize dimension name properly (EN) + const formattedName = dim.dimension_name + .split('_') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + return { + code: dim.dimension_code, + name: formattedName, + name_ro: dim.dimension_name_ro || formattedName, + short_description: subdimNames || dim.description, + short_description_ro: subdimNamesRo || dim.description_ro || dim.description, + }; + }), + }; +} +// Fetch providers data (LLM configuration per component) +export async function fetchProviders() { + try { + const [providers, models, assignments, keys] = await Promise.all([ + query('SELECT * FROM llm_provider WHERE is_active = true ORDER BY priority'), + query(` + SELECT m.*, p.provider_code, p.provider_name + FROM llm_model m + JOIN llm_provider p ON m.provider_id = p.provider_id + WHERE m.is_active = true AND p.is_active = true + ORDER BY p.priority, m.model_name + `), + query(` + SELECT + ca.component_code, ca.component_name, ca.temperature, ca.max_tokens, ca.timeout_ms, ca.is_enabled, + p.provider_code, p.provider_name, p.base_url, p.auth_type, + m.model_code, m.model_name, m.context_window, m.max_output_tokens, + fp.provider_code AS fallback_provider_code, fp.base_url AS fallback_base_url, + fm.model_code AS fallback_model_code + FROM component_provider_assignment ca + JOIN llm_provider p ON ca.provider_id = p.provider_id + JOIN llm_model m ON ca.model_id = m.model_id + LEFT JOIN llm_provider fp ON ca.fallback_provider_id = fp.provider_id + LEFT JOIN llm_model fm ON ca.fallback_model_id = fm.model_id + WHERE ca.is_enabled = true + ORDER BY ca.component_code + `), + query(` + SELECT k.provider_id, k.api_key_value, p.provider_code + FROM provider_api_key k + JOIN llm_provider p ON k.provider_id = p.provider_id + WHERE k.is_active = true + ORDER BY k.api_key_id + `), + ]); + + // Group API keys by provider + const keysByProvider: Record = {}; + keys.forEach((k: any) => { + if (!keysByProvider[k.provider_code]) { + keysByProvider[k.provider_code] = []; + } + keysByProvider[k.provider_code].push(k.api_key_value); + }); + + return { + providers, + models, + assignments, + keys: keysByProvider, // provider_code -> [keys] + }; + } catch (error) { + // Tables might not exist yet + log.info('[sync-redis] Providers tables not found, skipping...'); + return null; + } +} diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/index.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/index.ts new file mode 100644 index 0000000..4f26dae --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/index.ts @@ -0,0 +1,26 @@ +/** + * didiFramework — /api/sync-redis barrel router. + * + * The original 898-line sync-redis.ts was split into 6 files: + * _shared.ts — Redis client + KEY_PREFIX + 5 PG row interfaces + * fetch-data.ts — 7 framework data fetchers (techniques, sources, ...) + * fetch-config.ts — 4 component config fetchers (stage assignments, prompts, ...) + * sync.ts — POST / (master sync orchestrator, ~330 LOC) + * status.ts — GET /status (read-only health check) + * data.ts — GET /data/:category (debug — read a single key) + * + * server.ts mounts this barrel at `/api/sync-redis` so all the same paths + * keep working unchanged. + */ +import { Router } from 'express'; +import syncRouter from './sync'; +import statusRouter from './status'; +import dataRouter from './data'; + +const router = Router(); + +router.use(syncRouter); +router.use(statusRouter); +router.use(dataRouter); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/status.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/status.ts new file mode 100644 index 0000000..8f1f92d --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/status.ts @@ -0,0 +1,73 @@ +/** + * GET /api/sync-redis/status + * + * Read-only health check — does Redis have the framework manifest? are all + * 6 framework keys present? Returns the manifest's `last_sync` timestamp so + * the admin dashboard can show "synced 2h ago" badges. + */ +import { Router, Request, Response } from 'express'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import { getRedisClient, KEY_PREFIX } from './_shared'; + +const router = Router(); + +router.get('/status', async (_req: Request, res: Response) => { + const redis = getRedisClient(); + + try { + const manifestRaw = await redis.get(`${KEY_PREFIX}:manifest`); + + if (!manifestRaw) { + await redis.quit(); + return res.json({ + success: true, + data: { + synced: false, + message: 'No framework data in Redis. Click "Sync to Redis" to load.', + }, + }); + } + + const manifest = JSON.parse(manifestRaw); + + const keysExist = await Promise.all([ + redis.exists(`${KEY_PREFIX}:techniques`), + redis.exists(`${KEY_PREFIX}:sources`), + redis.exists(`${KEY_PREFIX}:claims`), + redis.exists(`${KEY_PREFIX}:verdicts`), + redis.exists(`${KEY_PREFIX}:weights`), + redis.exists(`${KEY_PREFIX}:providers`), + ]); + + const allKeysExist = keysExist.every(v => v === 1); + + await redis.quit(); + + res.json({ + success: true, + data: { + synced: true, + complete: allKeysExist, + last_sync: manifest.last_sync, + version: manifest.version, + categories: Object.keys(manifest.categories).map(key => ({ + name: key, + key: manifest.categories[key].key, + exists: keysExist[Object.keys(manifest.categories).indexOf(key)] === 1, + counts: manifest.categories[key].counts, + })), + }, + }); + } catch (error) { + log.error('[sync-redis] Status check error:', error); + try { + await redis.quit(); + } catch { + // ignore quit errors + } + internalError(res, error, 'sync_redis_status'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/sync.ts b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/sync.ts new file mode 100644 index 0000000..98b9c3d --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/sync-redis/sync.ts @@ -0,0 +1,328 @@ +/** + * POST /api/sync-redis + * + * Master sync endpoint: fetches all framework + component config from PG and + * writes it to Redis under `didi:framework:*` and `didi:config:*` in a single + * pipeline (atomic). Called by: + * - Admin dashboard "Sync to Redis" button. + * - Bootstrap on container start. + * - Hooks after framework edits (some POST/PUT routes call this internally). + * + * Skips silently when newer tables (HIL moderation, topic volatility) don't + * exist yet — fresh deploys can still sync the core framework. + */ +import { Router, Request, Response } from 'express'; +import { query } from '../../config/database'; +import { log } from '../../config/logger'; +import { internalError } from '../../config/error-response'; +import { getRedisClient, KEY_PREFIX } from './_shared'; +import { + fetchClaims, fetchProviders, fetchSourceAssessment, fetchTechniquesHierarchy, + fetchVerdicts, fetchWeights, buildDimensionsCompact, +} from './fetch-data'; +import { + fetchComponentConfigs, fetchInputProfiles, fetchPrompts, fetchStageAssignments, +} from './fetch-config'; + +const router = Router(); + +router.post('/', async (req: Request, res: Response) => { + const redis = getRedisClient(); + const startTime = Date.now(); + + try { + log.info('[sync-redis] Starting full framework sync to Redis...'); + + // Fetch all data in parallel + const [techniques, sources, claims, verdicts, weights, providers, stageAssignments, prompts, componentConfigs, inputProfiles] = await Promise.all([ + fetchTechniquesHierarchy(), + fetchSourceAssessment(), + fetchClaims(), + fetchVerdicts(), + fetchWeights(), + fetchProviders(), + fetchStageAssignments(), + fetchPrompts(), + fetchComponentConfigs(), + fetchInputProfiles(), + ]); + + // Calculate counts + const counts = { + techniques: { + dimensions: techniques.length, + subdimensions: techniques.reduce((acc, d) => acc + d.subdimensions.length, 0), + techniques: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a, sd) => a + sd.techniques.length, 0), 0), + indicators: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a: number, sd: any) => a + sd.techniques.reduce((t: number, tech: any) => t + tech.indicators.length, 0), 0), 0), + validation_rules: techniques.reduce((acc, d) => acc + d.subdimensions.reduce((a: number, sd: any) => a + sd.techniques.reduce((t: number, tech: any) => t + tech.validation_rules.length, 0), 0), 0), + }, + sources: { + platforms: sources.platforms.length, + platform_modifiers: sources.platform_modifiers.length, + source_credibility: sources.source_credibility.length, + source_types: sources.source_types.length, + source_assessment: sources.source_assessment.length, + domain_age_scores: sources.domain_age_scores.length, + domain_risk_levels: sources.domain_risk_levels.length, + domain_red_flags: sources.domain_red_flags.length, + author_classifications: sources.author_classifications.length, + author_credibility: sources.author_credibility.length, + }, + claims: { + status: claims.status.length, + types: claims.types.length, + confidence: claims.confidence.length, + interpretation: claims.interpretation.length, + }, + verdicts: { + categories: verdicts.categories.length, + risk_mappings: verdicts.risk_mappings.length, + severity_assessments: verdicts.severity_assessments.length, + }, + weights: { + components: weights.components.length, + scenarios: weights.scenarios.length, + multipliers: weights.multipliers.length, + }, + providers: providers ? { + providers: providers.providers.length, + models: providers.models.length, + assignments: providers.assignments.length, + keys: Object.keys(providers.keys).length, + } : null, + }; + + // Build manifest + const manifest = { + version: '1.0.0', + last_sync: new Date().toISOString(), + synced_by: 'didiFramework', + categories: { + techniques: { + key: `${KEY_PREFIX}:techniques`, + description: 'Manipulation techniques hierarchy (dimensions → subdimensions → techniques with indicators and rules)', + use_when: 'analyzing content for manipulation patterns, identifying deceptive techniques', + counts: counts.techniques, + }, + sources: { + key: `${KEY_PREFIX}:sources`, + description: 'Source credibility assessment parameters (platforms, modifiers, domain scoring, author credibility)', + use_when: 'evaluating source reliability, checking platform credibility, assessing author trustworthiness', + counts: counts.sources, + }, + claims: { + key: `${KEY_PREFIX}:claims`, + description: 'Claim verification parameters (status codes, claim types, confidence levels, interpretations)', + use_when: 'verifying factual claims, determining claim veracity, assessing confidence', + counts: counts.claims, + }, + verdicts: { + key: `${KEY_PREFIX}:verdicts`, + description: 'Final verdict and risk scoring (verdict categories, risk mappings, severity levels)', + use_when: 'generating final assessment, calculating risk scores, determining severity', + counts: counts.verdicts, + }, + weights: { + key: `${KEY_PREFIX}:weights`, + description: 'Scoring weights and multipliers (component weights, scenarios, topic/temporal/reach multipliers)', + use_when: 'calculating final scores, applying scenario-specific weights, adjusting for context', + counts: counts.weights, + }, + ...(providers ? { + providers: { + key: `${KEY_PREFIX}:providers`, + description: 'LLM provider configurations per analysis component (provider/model assignments, API keys)', + use_when: 'selecting which LLM provider/model to use for each analysis step', + counts: counts.providers, + }, + } : {}), + }, + }; + + // Use pipeline for atomic writes + const pipeline = redis.pipeline(); + + // Build dimensions_compact from techniques hierarchy (ensures consistency) + const dimensionsCompact = buildDimensionsCompact(techniques); + + // Write all data + pipeline.set(`${KEY_PREFIX}:manifest`, JSON.stringify(manifest)); + pipeline.set(`${KEY_PREFIX}:techniques`, JSON.stringify({ dimensions: techniques })); + pipeline.set(`${KEY_PREFIX}:sources`, JSON.stringify(sources)); + pipeline.set(`${KEY_PREFIX}:claims`, JSON.stringify(claims)); + pipeline.set(`${KEY_PREFIX}:verdicts`, JSON.stringify(verdicts)); + pipeline.set(`${KEY_PREFIX}:weights`, JSON.stringify(weights)); + if (providers) { + pipeline.set(`${KEY_PREFIX}:providers`, JSON.stringify(providers)); + } + + // Write dimensions_compact (canonical framework location) + pipeline.set(`${KEY_PREFIX}:dimensions_compact`, JSON.stringify(dimensionsCompact)); + + // ================================================================ + // Write component configs to didi:config:* (unified config prefix) + // ================================================================ + const CONFIG_PREFIX = 'didi:config'; + const VERSION_MAP: Record = { 'techniques': 'v3', 'ai-tampered': 'v1', 'claims': 'v1', 'pipeline': 'v1', 'vision': 'v1', 'source-assessment': 'v1', 'verdict': 'v1' }; + let configKeysWritten = 0; + + // Stage assignments (tier-nested structure) + available_models per component + // Structure: { [stage]: { free: {models:[...]}, premium: {models:[...]} } } + for (const [comp, stages] of Object.entries(stageAssignments)) { + const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`; + pipeline.set(`${prefix}:stage_assignments`, JSON.stringify(stages)); + configKeysWritten++; + // Build available_models from all unique models across all stages + tiers. + // tierConfig has shape { models: Array<{ model_key: string, ... }> }. + interface TierConfig { models: Array<{ model_key: string; [k: string]: unknown }> } + const allModels = new Map(); + for (const stage of Object.values(stages)) { + for (const tierConfig of Object.values(stage as Record)) { + for (const m of tierConfig.models) { + allModels.set(m.model_key, m); + } + } + } + pipeline.set(`${prefix}:available_models`, JSON.stringify({ models: Array.from(allModels.values()) })); + configKeysWritten++; + } + + // Prompts per stage + for (const [comp, stagePrompts] of Object.entries(prompts)) { + const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`; + for (const [stageCode, prompt] of Object.entries(stagePrompts)) { + const shortStage = stageCode.replace(`${comp.replace('-', '_')}_`, ''); + pipeline.set(`${prefix}:prompts:${shortStage}`, JSON.stringify(prompt)); + configKeysWritten++; + } + } + + // JSONB configs + for (const [comp, configs] of Object.entries(componentConfigs)) { + const prefix = `${CONFIG_PREFIX}:${comp}:${VERSION_MAP[comp] || 'v1'}`; + for (const [key, value] of Object.entries(configs)) { + pipeline.set(`${prefix}:${key}`, JSON.stringify(value)); + configKeysWritten++; + } + } + + // Input profiles (verdict per input type) + if (inputProfiles && inputProfiles.length > 0) { + pipeline.set(`${CONFIG_PREFIX}:pipeline:v1:input_profiles`, JSON.stringify({ profiles: inputProfiles })); + configKeysWritten++; + log.info(`[sync-redis] Input profiles: ${inputProfiles.length} profiles synced`); + } + + // ================================================================ + // HIL Moderation config (triage + brain client + sensitive topics + roles) + // Migration 011 introduces these tables. Skip silently if missing. + // ================================================================ + try { + const modConfig = await query>( + 'SELECT * FROM bos_parammgmt.moderation_config WHERE config_id = 1' + ); + if (modConfig.length > 0) { + pipeline.set(`${CONFIG_PREFIX}:moderation:v1:settings`, JSON.stringify(modConfig[0])); + configKeysWritten++; + log.info('[sync-redis] Moderation config: 1 row synced'); + } + + // Legacy HIL key — exact same shape as before so agent-v3 triage + // (which reads this key on every analysis) stays bit-identical. + const topics = await query<{ topic_code: string; topic_label: string }>( + 'SELECT topic_code, topic_label FROM bos_parammgmt.sensitive_topic WHERE is_active = true ORDER BY topic_id' + ); + pipeline.set(`${CONFIG_PREFIX}:moderation:v1:sensitive_topics`, JSON.stringify({ topics })); + configKeysWritten++; + log.info(`[sync-redis] Sensitive topics: ${topics.length} active topics synced`); + + // D1 — full volatility taxonomy for brain cache freshness. + // Brain reads this key with cache 60s (services/topic_volatility.py). + // Agent-v3 does NOT read this key — it's brain-internal. + // Wrapped in its own try so missing migration 012 columns degrade + // gracefully (brain falls back to its hardcoded defaults). + try { + const topicVolatility = await query<{ + topic_code: string; + topic_label: string; + volatility: string; + cache_ttl_hours: number; + recency_window_days: number; + half_life_days: number; + atomic_path_prefix: string | null; + }>( + `SELECT topic_code, topic_label, volatility, + cache_ttl_hours, recency_window_days, half_life_days, + atomic_path_prefix + FROM bos_parammgmt.sensitive_topic + WHERE is_active = true + ORDER BY topic_id` + ); + pipeline.set( + `${CONFIG_PREFIX}:topics:volatility`, + JSON.stringify({ + topics: topicVolatility, + synced_at: new Date().toISOString(), + }) + ); + configKeysWritten++; + log.info(`[sync-redis] Topic volatility: ${topicVolatility.length} topics synced`); + } catch (innerErr) { + log.info( + '[sync-redis] Topic volatility columns not found, skipping ' + + '(migration 012 may not have run; brain falls back to defaults)' + ); + } + + const roles = await query>( + 'SELECT role_code, role_label, can_resolve, can_escalate, can_force_gold_brain, is_active FROM bos_parammgmt.moderation_role ORDER BY role_code' + ); + pipeline.set(`${CONFIG_PREFIX}:moderation:v1:roles`, JSON.stringify({ roles })); + configKeysWritten++; + log.info(`[sync-redis] Moderation roles: ${roles.length} roles synced`); + } catch (e) { + log.info('[sync-redis] Moderation tables not found, skipping (migration 011 may not have run)'); + } + + await pipeline.exec(); + + const duration = Date.now() - startTime; + + const frameworkKeysWritten = providers ? 8 : 7; // manifest + techniques + sources + claims + verdicts + weights + dimensions_compact + providers + const totalKeysWritten = frameworkKeysWritten + configKeysWritten; + + log.info(`[sync-redis] Sync completed in ${duration}ms`); + log.info(`[sync-redis] Synced: ${counts.techniques.techniques} techniques, ${counts.sources.platforms} platforms, ${counts.claims.status} claim statuses, ${counts.verdicts.categories} verdict categories${providers ? `, ${counts.providers?.assignments} provider assignments` : ''}`); + log.info(`[sync-redis] Generated dimensions_compact: ${dimensionsCompact.dimensions.length} dimensions`); + log.info(`[sync-redis] Config keys written: ${configKeysWritten} (stage_assignments, available_models, prompts, configs)`); + + await redis.quit(); + + res.json({ + success: true, + message: 'Framework data synced to Redis successfully', + data: { + duration_ms: duration, + last_sync: manifest.last_sync, + keys_written: totalKeysWritten, + framework_keys: frameworkKeysWritten, + config_keys: configKeysWritten, + counts, + dimensions_compact: dimensionsCompact.dimensions.map(d => `${d.code}: ${d.name}`), + }, + }); + + } catch (error) { + log.error('[sync-redis] Error:', error); + + try { + await redis.quit(); + } catch { + // ignore quit errors + } + + internalError(res, error, 'sync_redis_main'); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/techniques.ts b/backend/services/orchestration-layer/didiFramework/src/routes/techniques.ts new file mode 100644 index 0000000..2d3e73b --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/techniques.ts @@ -0,0 +1,393 @@ +/** + * Techniques Routes - FULL CRUD with Safety + * + * Techniques are children of subdimensions and parents of indicators/validation_rules: + * dimension -> subdimension -> technique -> indicator/validation_rule + * + * DELETE is protected - cannot delete technique with indicators or validation rules + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { checkDependencies, safeDelete } from '../utils/dependency-checker'; +import { Technique, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Parameter type for techniques +const PARAMETER_TYPE_TECHNIQUE = 3; + +// Helper: Create parameter entry +const createParameter = async (client: PoolClient): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, PARAMETER_TYPE_TECHNIQUE]); + + return nextParamId; +}; + +// Helper: Get next technique_id +const getNextTechniqueId = async (client: PoolClient): Promise => { + const result = await client.query('SELECT COALESCE(MAX(technique_id), 0) + 1 as next_id FROM technique'); + return result.rows[0].next_id; +}; + +// GET all techniques +router.get('/', async (req: Request, res: Response) => { + try { + const techniques = await query( + 'SELECT * FROM technique ORDER BY technique_id' + ); + res.json({ + success: true, + data: techniques, + count: techniques.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET all techniques with full hierarchy and counts +router.get('/with-hierarchy', async (req: Request, res: Response) => { + try { + const techniques = await query(` + SELECT t.*, + s.subdmiension_name as subdimension_name, s.subdimension_code, + d.dimension_id, d.dimension_code, d.dimension_name, + (SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count, + (SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count + FROM technique t + JOIN subdimension s ON t.subdimension_id = s.subdimension_id + JOIN dimension d ON s.dimension_id = d.dimension_id + ORDER BY d.dimension_id, s.subdimension_id, t.technique_id + `); + res.json({ + success: true, + data: techniques, + count: techniques.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET techniques by subdimension_id +router.get('/by-subdimension/:subdimensionId', async (req: Request, res: Response) => { + try { + const techniques = await query(` + SELECT t.*, + (SELECT COUNT(*) FROM technique_indicator ti WHERE ti.technique_id = t.technique_id) as indicator_count, + (SELECT COUNT(*) FROM technique_validation_rule tr WHERE tr.technique_id = t.technique_id) as rule_count + FROM technique t + WHERE t.subdimension_id = $1 + ORDER BY t.technique_id + `, [req.params.subdimensionId]); + res.json({ + success: true, + data: techniques, + count: techniques.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET technique by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const technique = await queryOne( + 'SELECT * FROM technique WHERE technique_id = $1', + [req.params.id] + ); + if (!technique) { + return res.status(404).json({ + success: false, + error: 'Tehnica nu a fost găsită' + } as ApiResponse); + } + res.json({ + success: true, + data: technique + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET dependency check before delete +router.get('/:id/dependencies', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // Check if technique exists + const technique = await queryOne( + 'SELECT * FROM technique WHERE technique_id = $1', + [id] + ); + + if (!technique) { + return res.status(404).json({ + success: false, + error: 'Tehnica nu a fost găsită' + }); + } + + // Check dependencies + const depCheck = await checkDependencies('technique', 'technique_id', id); + + // Get detailed child info if there are children + let indicators: any[] = []; + let validationRules: any[] = []; + + if (depCheck.hasChildren) { + [indicators, validationRules] = await Promise.all([ + query(` + SELECT technique_indicator_id, indicator_name, max_intensity + FROM technique_indicator + WHERE technique_id = $1 + ORDER BY indicator_id + `, [id]), + query(` + SELECT technique_valid_rule_id, rule_name, rule_value + FROM technique_validation_rule + WHERE technique_id = $1 + ORDER BY technique_valid_rule_id + `, [id]) + ]); + } + + res.json({ + success: true, + data: { + technique, + ...depCheck, + childDetails: { + indicators, + validationRules + } + } + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST create technique +router.post('/', async (req: Request, res: Response) => { + try { + const { subdimension_id, technique_key, technique_name, severity, confidence, detectability } = req.body; + + // Validation + if (!subdimension_id || !technique_name) { + return res.status(400).json({ + success: false, + error: 'Câmpuri obligatorii: subdimension_id, technique_name' + }); + } + + // Verify subdimension exists + const subdimension = await queryOne('SELECT subdimension_id FROM subdimension WHERE subdimension_id = $1', [subdimension_id]); + if (!subdimension) { + return res.status(400).json({ + success: false, + error: 'Subdimensiunea specificată nu există' + }); + } + + const result = await transaction(async (client) => { + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next technique_id + const techniqueId = await getNextTechniqueId(client); + + // Determine technique_key (auto-increment within subdimension if not provided) + let techKey = technique_key; + if (!techKey) { + const maxKeyResult = await client.query( + 'SELECT COALESCE(MAX(technique_key), 0) + 1 as next_key FROM technique WHERE subdimension_id = $1', + [subdimension_id] + ); + techKey = maxKeyResult.rows[0].next_key; + } + + // Insert technique + const insertResult = await client.query(` + INSERT INTO technique (technique_id, subdimension_id, technique_key, technique_name, severity, confidence, detectability, parameter_id, + technique_name_ro, technique_name_en) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `, [techniqueId, subdimension_id, techKey, technique_name, severity || 5, confidence || 5, detectability || 5, parameterId, + req.body.technique_name_ro || null, req.body.technique_name_en || technique_name]); + + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result, + message: 'Tehnica a fost creată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT update technique +router.put('/:id', async (req: Request, res: Response) => { + try { + const { subdimension_id, technique_key, technique_name, severity, confidence, detectability, + technique_name_ro, technique_name_en } = req.body; + + // Check if technique exists + const existing = await queryOne( + 'SELECT * FROM technique WHERE technique_id = $1', + [req.params.id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Tehnica nu a fost găsită' + }); + } + + // If changing subdimension_id, verify it exists + if (subdimension_id) { + const subdimension = await queryOne('SELECT subdimension_id FROM subdimension WHERE subdimension_id = $1', [subdimension_id]); + if (!subdimension) { + return res.status(400).json({ + success: false, + error: 'Subdimensiunea specificată nu există' + }); + } + } + + const technique = await queryOne( + `UPDATE technique + SET subdimension_id = COALESCE($1, subdimension_id), + technique_key = COALESCE($2, technique_key), + technique_name = COALESCE($3, technique_name), + severity = COALESCE($4, severity), + confidence = COALESCE($5, confidence), + detectability = COALESCE($6, detectability), + technique_name_ro = COALESCE($8, technique_name_ro), + technique_name_en = COALESCE($9, technique_name_en), + updated_date = CURRENT_DATE + WHERE technique_id = $7 + RETURNING *`, + [subdimension_id, technique_key, technique_name, severity, confidence, detectability, req.params.id, + technique_name_ro, technique_name_en] + ); + + res.json({ + success: true, + data: technique, + message: 'Tehnica a fost actualizată cu succes' + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// DELETE technique (with safety check) +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const force = req.query.force === 'true'; + const cascade = req.query.cascade === 'true'; + + // Check if technique exists + const existing = await queryOne( + 'SELECT * FROM technique WHERE technique_id = $1', + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Tehnica nu a fost găsită' + }); + } + + // Check dependencies first + const depCheck = await checkDependencies('technique', 'technique_id', id); + + // If has children and cascade is requested, delete children first + if (depCheck.hasChildren && cascade) { + await transaction(async (client) => { + // Delete indicators first + await client.query('DELETE FROM technique_indicator WHERE technique_id = $1', [id]); + // Delete validation rules + await client.query('DELETE FROM technique_validation_rule WHERE technique_id = $1', [id]); + // Delete technique + await client.query('DELETE FROM technique WHERE technique_id = $1', [id]); + }); + + return res.json({ + success: true, + message: `Tehnica și toate dependențele (${depCheck.totalChildren} înregistrări) au fost șterse cu succes`, + deleted: true, + cascadeDeleted: depCheck.dependencies + }); + } + + // Use safe delete + const deleteResult = await safeDelete('technique', 'technique_id', id, force); + + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + canDelete: false, + dependencies: deleteResult.dependencyDetails?.dependencies || [], + hints: [ + 'Ștergeți mai întâi indicatorii și regulile de validare', + 'Sau folosiți ?cascade=true pentru a șterge totul automat' + ] + }); + } + + res.json({ + success: true, + message: 'Tehnica a fost ștearsă cu succes', + deleted: true + }); + } catch (error: any) { + if (error.code === '23503') { + return res.status(409).json({ + success: false, + error: 'Nu se poate șterge: există indicatori sau reguli de validare asociate', + canDelete: false + }); + } + + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/uploads.ts b/backend/services/orchestration-layer/didiFramework/src/routes/uploads.ts new file mode 100644 index 0000000..62d78b9 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/uploads.ts @@ -0,0 +1,441 @@ +/** + * Upload Routes + * File upload, download, and management with MinIO storage + */ + +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import { v4 as uuidv4 } from 'uuid'; +import path from 'path'; +import { log } from '../config/logger'; +import { + getMinioClient, + getBucketForMime, + getPresignedUrl, + uploadBuffer, + deleteObject, + getObjectInfo, + listObjects, + checkMinioHealth, + ALLOWED_MIME_TYPES, + getMaxSizeForMime, + BUCKETS, +} from '../config/minio'; + +const router = Router(); + +// Configure multer for memory storage (buffer) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 500 * 1024 * 1024, // 500MB max (will be validated per type later) + }, + fileFilter: (req, file, cb) => { + // Check if MIME type is allowed + if (ALLOWED_MIME_TYPES.has(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`File type not allowed: ${file.mimetype}`)); + } + }, +}); + +// In-memory metadata store (in production, use PostgreSQL) +interface FileMetadata { + id: string; + originalName: string; + mimeType: string; + size: number; + bucket: string; + objectName: string; + uploadedAt: string; + expiresAt?: string; + metadata?: Record; +} + +const fileMetadataStore = new Map(); + +/** + * GET /health + * Check MinIO connection health + */ +router.get('/health', async (req: Request, res: Response) => { + try { + const healthy = await checkMinioHealth(); + res.json({ + success: true, + minio: healthy ? 'connected' : 'disconnected', + timestamp: new Date().toISOString(), + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * POST / + * Upload a file + * Returns: fileId, presignedUrl, contentType, size + */ +router.post('/', upload.single('file'), async (req: Request, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ + success: false, + error: 'No file provided', + }); + } + + const file = req.file; + const fileId = uuidv4(); + const bucket = getBucketForMime(file.mimetype); + const ext = path.extname(file.originalname) || ''; + const objectName = `${fileId}${ext}`; + + // Check file size against type-specific limit + const maxSize = getMaxSizeForMime(file.mimetype); + if (file.size > maxSize) { + return res.status(413).json({ + success: false, + error: `File too large. Max size for ${file.mimetype}: ${Math.round(maxSize / 1024 / 1024)}MB`, + }); + } + + // Upload to MinIO + const uploadResult = await uploadBuffer( + bucket, + objectName, + file.buffer, + file.mimetype, + { + 'X-Original-Name': encodeURIComponent(file.originalname), + 'X-Upload-Id': fileId, + } + ); + + // Generate presigned URL for access (1 hour default) + const expirySeconds = parseInt(req.query.expiry as string) || 3600; + const presignedUrl = await getPresignedUrl(bucket, objectName, expirySeconds); + + // Store metadata + const metadata: FileMetadata = { + id: fileId, + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + bucket, + objectName, + uploadedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(), + }; + fileMetadataStore.set(fileId, metadata); + + log.info(`[Upload] File uploaded: ${fileId} -> ${bucket}/${objectName} (${file.size} bytes)`); + + res.json({ + success: true, + data: { + fileId, + presignedUrl, + contentType: file.mimetype, + size: file.size, + bucket, + objectName, + originalName: file.originalname, + uploadedAt: metadata.uploadedAt, + expiresAt: metadata.expiresAt, + }, + }); + } catch (error: any) { + log.error('[Upload] Error:', error); + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * POST /multipart + * Upload multiple files + */ +router.post('/multipart', upload.array('files', 10), async (req: Request, res: Response) => { + try { + const files = req.files as Express.Multer.File[]; + if (!files || files.length === 0) { + return res.status(400).json({ + success: false, + error: 'No files provided', + }); + } + + const results = await Promise.all( + files.map(async (file) => { + const fileId = uuidv4(); + const bucket = getBucketForMime(file.mimetype); + const ext = path.extname(file.originalname) || ''; + const objectName = `${fileId}${ext}`; + + await uploadBuffer(bucket, objectName, file.buffer, file.mimetype, { + 'X-Original-Name': encodeURIComponent(file.originalname), + 'X-Upload-Id': fileId, + }); + + const presignedUrl = await getPresignedUrl(bucket, objectName, 3600); + + const metadata: FileMetadata = { + id: fileId, + originalName: file.originalname, + mimeType: file.mimetype, + size: file.size, + bucket, + objectName, + uploadedAt: new Date().toISOString(), + }; + fileMetadataStore.set(fileId, metadata); + + return { + fileId, + presignedUrl, + contentType: file.mimetype, + size: file.size, + originalName: file.originalname, + }; + }) + ); + + log.info(`[Upload] ${results.length} files uploaded`); + + res.json({ + success: true, + data: { + count: results.length, + files: results, + }, + }); + } catch (error: any) { + log.error('[Upload] Multipart error:', error); + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * GET /:fileId + * Get file info and fresh presigned URL + */ +router.get('/:fileId', async (req: Request, res: Response) => { + try { + const { fileId } = req.params; + const metadata = fileMetadataStore.get(fileId); + + if (!metadata) { + return res.status(404).json({ + success: false, + error: 'File not found', + }); + } + + // Check if file still exists in MinIO + const objectInfo = await getObjectInfo(metadata.bucket, metadata.objectName); + if (!objectInfo) { + fileMetadataStore.delete(fileId); + return res.status(404).json({ + success: false, + error: 'File no longer exists in storage', + }); + } + + // Generate fresh presigned URL + const expirySeconds = parseInt(req.query.expiry as string) || 3600; + const presignedUrl = await getPresignedUrl(metadata.bucket, metadata.objectName, expirySeconds); + + res.json({ + success: true, + data: { + ...metadata, + presignedUrl, + expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(), + }, + }); + } catch (error: any) { + log.error('[Upload] Get error:', error); + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * GET /:fileId/url + * Get only the presigned URL (for quick access) + */ +router.get('/:fileId/url', async (req: Request, res: Response) => { + try { + const { fileId } = req.params; + const metadata = fileMetadataStore.get(fileId); + + if (!metadata) { + return res.status(404).json({ + success: false, + error: 'File not found', + }); + } + + const expirySeconds = parseInt(req.query.expiry as string) || 3600; + const presignedUrl = await getPresignedUrl(metadata.bucket, metadata.objectName, expirySeconds); + + res.json({ + success: true, + data: { + fileId, + presignedUrl, + contentType: metadata.mimeType, + expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString(), + }, + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * DELETE /:fileId + * Delete a file + */ +router.delete('/:fileId', async (req: Request, res: Response) => { + try { + const { fileId } = req.params; + const metadata = fileMetadataStore.get(fileId); + + if (!metadata) { + return res.status(404).json({ + success: false, + error: 'File not found', + }); + } + + // Delete from MinIO + await deleteObject(metadata.bucket, metadata.objectName); + + // Remove metadata + fileMetadataStore.delete(fileId); + + log.info(`[Upload] File deleted: ${fileId}`); + + res.json({ + success: true, + message: 'File deleted successfully', + }); + } catch (error: any) { + log.error('[Upload] Delete error:', error); + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * GET / + * List all uploaded files (with pagination) + */ +router.get('/', async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const type = req.query.type as string; // Filter by content type prefix + + let files = Array.from(fileMetadataStore.values()); + + // Filter by type if specified + if (type) { + files = files.filter((f) => f.mimeType.startsWith(type)); + } + + // Sort by upload date (newest first) + files.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()); + + // Paginate + const total = files.length; + const start = (page - 1) * limit; + const paginatedFiles = files.slice(start, start + limit); + + res.json({ + success: true, + data: { + files: paginatedFiles, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }, + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * GET /buckets/stats + * Get statistics about buckets + */ +router.get('/buckets/stats', async (req: Request, res: Response) => { + try { + const client = getMinioClient(); + const buckets = await client.listBuckets(); + + const stats = await Promise.all( + buckets.map(async (bucket) => { + try { + const objects = await listObjects(bucket.name, undefined, 1000); + const totalSize = objects.reduce((sum, obj) => sum + (obj.size || 0), 0); + return { + name: bucket.name, + createdAt: bucket.creationDate, + objectCount: objects.length, + totalSize, + totalSizeMB: Math.round(totalSize / 1024 / 1024 * 100) / 100, + }; + } catch { + return { + name: bucket.name, + createdAt: bucket.creationDate, + objectCount: 0, + totalSize: 0, + totalSizeMB: 0, + }; + } + }) + ); + + res.json({ + success: true, + data: { + buckets: stats, + total: stats.length, + }, + }); + } catch (error: any) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/validation-rules.ts b/backend/services/orchestration-layer/didiFramework/src/routes/validation-rules.ts new file mode 100644 index 0000000..c49b862 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/validation-rules.ts @@ -0,0 +1,359 @@ +/** + * Validation Rules Routes - FULL CRUD + * + * Validation rules are leaf nodes - no children, can be deleted directly + * technique -> technique_validation_rule + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { TechniqueValidationRule, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Parameter type for validation rules +const PARAMETER_TYPE_VALIDATION_RULE = 5; + +// Helper: Create parameter entry +const createParameter = async (client: PoolClient): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, PARAMETER_TYPE_VALIDATION_RULE]); + + return nextParamId; +}; + +// Helper: Get next rule_id +const getNextRuleId = async (client: PoolClient): Promise => { + const result = await client.query('SELECT COALESCE(MAX(technique_valid_rule_id), 0) + 1 as next_id FROM technique_validation_rule'); + return result.rows[0].next_id; +}; + +// GET all validation rules +router.get('/', async (req: Request, res: Response) => { + try { + const rules = await query( + 'SELECT * FROM technique_validation_rule ORDER BY technique_id, technique_valid_rule_id' + ); + res.json({ + success: true, + data: rules, + count: rules.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET validation rules with technique info +router.get('/with-techniques', async (req: Request, res: Response) => { + try { + const rules = await query(` + SELECT r.*, t.technique_name + FROM technique_validation_rule r + JOIN technique t ON r.technique_id = t.technique_id + ORDER BY r.technique_id, r.technique_valid_rule_id + `); + res.json({ + success: true, + data: rules, + count: rules.length + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET validation rules by technique_id +router.get('/by-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const rules = await query( + 'SELECT * FROM technique_validation_rule WHERE technique_id = $1 ORDER BY technique_valid_rule_id', + [req.params.techniqueId] + ); + res.json({ + success: true, + data: rules, + count: rules.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } as ApiResponse); + } +}); + +// GET stats — declarat înainte de '/:id', altfel 'stats' e capturat ca :id +router.get('/stats', async (req: Request, res: Response) => { + try { + const stats = await query(` + SELECT + (SELECT COUNT(*) FROM technique) as total_techniques, + (SELECT COUNT(DISTINCT technique_id) FROM technique_validation_rule) as techniques_with_rules, + (SELECT COUNT(*) FROM technique_validation_rule) as total_rules, + (SELECT COUNT(*) FROM technique WHERE technique_id NOT IN (SELECT DISTINCT technique_id FROM technique_validation_rule)) as techniques_missing_rules + `); + res.json({ + success: true, + data: stats[0] + }); + } catch (error) { + internalError(res, error); + } +}); + +// GET single validation rule by ID +router.get('/:id', async (req: Request, res: Response) => { + try { + const rule = await queryOne( + 'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1', + [req.params.id] + ); + + if (!rule) { + return res.status(404).json({ + success: false, + error: 'Regula de validare nu a fost găsită' + }); + } + + res.json({ + success: true, + data: rule + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST - Create single validation rule +router.post('/', async (req: Request, res: Response) => { + try { + const { technique_id, rule_name, rule_value, description } = req.body; + + // Validation + if (!technique_id || !rule_name || !rule_value) { + return res.status(400).json({ + success: false, + error: 'Câmpuri obligatorii: technique_id, rule_name, rule_value' + }); + } + + // Verify technique exists + const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [technique_id]); + if (!technique) { + return res.status(400).json({ + success: false, + error: 'Tehnica specificată nu există' + }); + } + + const result = await transaction(async (client) => { + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next rule ID + const ruleId = await getNextRuleId(client); + + // Insert rule + const insertResult = await client.query(` + INSERT INTO technique_validation_rule (technique_valid_rule_id, technique_id, rule_name, rule_value, description, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [ruleId, technique_id, rule_name, rule_value, description || '', parameterId]); + + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result, + message: 'Regula de validare a fost creată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// POST /bulk-for-technique - Create multiple rules for a single technique +router.post('/bulk-for-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const techniqueId = parseInt(req.params.techniqueId); + const { rules } = req.body; + + if (!rules || !Array.isArray(rules) || rules.length === 0) { + return res.status(400).json({ + success: false, + error: 'Câmp obligatoriu: rules (array de {rule_name, rule_value, description})' + }); + } + + // Verify technique exists + const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [techniqueId]); + if (!technique) { + return res.status(400).json({ + success: false, + error: 'Tehnica specificată nu există' + }); + } + + const result = await transaction(async (client) => { + const created: any[] = []; + + for (const rule of rules) { + if (!rule.rule_name || !rule.rule_value) { + throw new Error(`Regulă invalidă: ${JSON.stringify(rule)}. Obligatoriu: rule_name, rule_value`); + } + + // Create parameter entry + const parameterId = await createParameter(client); + + // Get next rule ID + const ruleId = await getNextRuleId(client); + + // Insert rule + const insertResult = await client.query(` + INSERT INTO technique_validation_rule (technique_valid_rule_id, technique_id, rule_name, rule_value, description, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [ruleId, techniqueId, rule.rule_name, rule.rule_value, rule.description || '', parameterId]); + + created.push(insertResult.rows[0]); + } + + return created; + }); + + res.status(201).json({ + success: true, + data: result, + count: result.length, + message: `${result.length} reguli de validare au fost create cu succes` + }); + } catch (error) { + internalError(res, error); + } +}); + +// PUT - Update validation rule +router.put('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const { technique_id, rule_name, rule_value, description } = req.body; + + // Check if rule exists + const existing = await queryOne( + 'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1', + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Regula de validare nu a fost găsită' + }); + } + + // If changing technique_id, verify it exists + if (technique_id) { + const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [technique_id]); + if (!technique) { + return res.status(400).json({ + success: false, + error: 'Tehnica specificată nu există' + }); + } + } + + const result = await queryOne(` + UPDATE technique_validation_rule + SET technique_id = COALESCE($1, technique_id), + rule_name = COALESCE($2, rule_name), + rule_value = COALESCE($3, rule_value), + description = COALESCE($4, description) + WHERE technique_valid_rule_id = $5 + RETURNING * + `, [technique_id, rule_name, rule_value, description, id]); + + res.json({ + success: true, + data: result, + message: 'Regula de validare a fost actualizată cu succes' + }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE - Delete single validation rule +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // Check if rule exists + const existing = await queryOne( + 'SELECT * FROM technique_validation_rule WHERE technique_valid_rule_id = $1', + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: 'Regula de validare nu a fost găsită' + }); + } + + // Delete (leaf node - no children to check) + await query('DELETE FROM technique_validation_rule WHERE technique_valid_rule_id = $1', [id]); + + res.json({ + success: true, + message: 'Regula de validare a fost ștearsă cu succes', + deleted: true + }); + } catch (error) { + internalError(res, error); + } +}); + +// DELETE all validation rules for a technique +router.delete('/by-technique/:techniqueId', async (req: Request, res: Response) => { + try { + const techniqueId = req.params.techniqueId; + + // Verify technique exists + const technique = await queryOne('SELECT technique_id FROM technique WHERE technique_id = $1', [techniqueId]); + if (!technique) { + return res.status(404).json({ + success: false, + error: 'Tehnica specificată nu există' + }); + } + + const result = await query( + 'DELETE FROM technique_validation_rule WHERE technique_id = $1 RETURNING *', + [techniqueId] + ); + + res.json({ + success: true, + data: result, + count: result.length, + message: `${result.length} reguli de validare au fost șterse pentru tehnica ${techniqueId}` + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/verdicts.ts b/backend/services/orchestration-layer/didiFramework/src/routes/verdicts.ts new file mode 100644 index 0000000..8919f4c --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/verdicts.ts @@ -0,0 +1,422 @@ +/** + * Verdicts Routes - FULL CRUD + * + * All verdict-related tables are leaf nodes (no children): + * - Verdict Categories + * - Risk Mappings + * - Severity Assessments + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { VerdictCategory, RiskMapping, SeverityAssessment, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Helper functions +const createParameter = async (client: PoolClient, parameterType: number): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +}; + +const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +// ============================================================================ +// VERDICT CATEGORIES +// ============================================================================ + +router.get('/categories', async (req: Request, res: Response) => { + try { + const categories = await query( + 'SELECT * FROM verdict_category ORDER BY start_range' + ); + res.json({ success: true, data: categories, count: categories.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/categories/:id', async (req: Request, res: Response) => { + try { + const category = await queryOne( + 'SELECT * FROM verdict_category WHERE verdict_category_id = $1', + [req.params.id] + ); + if (!category) { + return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' }); + } + res.json({ success: true, data: category }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/categories', async (req: Request, res: Response) => { + try { + const { verdict_category_code, description, start_range, end_range, verdict_category_color } = req.body; + if (!verdict_category_code) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: verdict_category_code' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 20); + const id = await getNextId(client, 'verdict_category', 'verdict_category_id'); + const insertResult = await client.query(` + INSERT INTO verdict_category (verdict_category_id, verdict_category_code, description, start_range, end_range, verdict_category_color, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [id, verdict_category_code, description || '', start_range || 0, end_range || 100, verdict_category_color || '#808080', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Verdict category creată cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/categories/:id', async (req: Request, res: Response) => { + try { + const { verdict_category_code, description, start_range, end_range, verdict_category_color } = req.body; + const category = await queryOne(` + UPDATE verdict_category + SET verdict_category_code = COALESCE($1, verdict_category_code), + description = COALESCE($2, description), + start_range = COALESCE($3, start_range), + end_range = COALESCE($4, end_range), + verdict_category_color = COALESCE($5, verdict_category_color) + WHERE verdict_category_id = $6 + RETURNING * + `, [verdict_category_code, description, start_range, end_range, verdict_category_color, req.params.id]); + + if (!category) { + return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' }); + } + res.json({ success: true, data: category, message: 'Verdict category actualizată cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/categories/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM verdict_category WHERE verdict_category_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Verdict category nu a fost găsită' }); + } + res.json({ success: true, message: 'Verdict category ștearsă cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// RISK MAPPINGS +// ============================================================================ + +router.get('/risk', async (req: Request, res: Response) => { + try { + const mappings = await query( + 'SELECT * FROM risk_mapping ORDER BY start_range' + ); + res.json({ success: true, data: mappings, count: mappings.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/risk/:id', async (req: Request, res: Response) => { + try { + const mapping = await queryOne( + 'SELECT * FROM risk_mapping WHERE risk_mapping_id = $1', + [req.params.id] + ); + if (!mapping) { + return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' }); + } + res.json({ success: true, data: mapping }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/risk', async (req: Request, res: Response) => { + try { + const { risk_mapping, risk_level, start_range, end_range, risk_color } = req.body; + if (!risk_mapping) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: risk_mapping' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 21); + const id = await getNextId(client, 'risk_mapping', 'risk_mapping_id'); + const insertResult = await client.query(` + INSERT INTO risk_mapping (risk_mapping_id, risk_mapping, risk_level, start_range, end_range, risk_color, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [id, risk_mapping, risk_level || 0, start_range || 0, end_range || 100, risk_color || '#808080', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Risk mapping creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/risk/:id', async (req: Request, res: Response) => { + try { + const { risk_mapping, risk_level, start_range, end_range, risk_color } = req.body; + const mapping = await queryOne(` + UPDATE risk_mapping + SET risk_mapping = COALESCE($1, risk_mapping), + risk_level = COALESCE($2, risk_level), + start_range = COALESCE($3, start_range), + end_range = COALESCE($4, end_range), + risk_color = COALESCE($5, risk_color) + WHERE risk_mapping_id = $6 + RETURNING * + `, [risk_mapping, risk_level, start_range, end_range, risk_color, req.params.id]); + + if (!mapping) { + return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' }); + } + res.json({ success: true, data: mapping, message: 'Risk mapping actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/risk/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM risk_mapping WHERE risk_mapping_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Risk mapping nu a fost găsit' }); + } + res.json({ success: true, message: 'Risk mapping șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// SEVERITY ASSESSMENTS +// ============================================================================ + +router.get('/severity', async (req: Request, res: Response) => { + try { + const assessments = await query( + 'SELECT severity_id, severity_category, start_range, end_range, recomended_action, parameter_id FROM severity_assessment ORDER BY start_range' + ); + res.json({ success: true, data: assessments, count: assessments.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/severity/:id', async (req: Request, res: Response) => { + try { + const assessment = await queryOne( + 'SELECT * FROM severity_assessment WHERE severity_id = $1', + [req.params.id] + ); + if (!assessment) { + return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' }); + } + res.json({ success: true, data: assessment }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/severity', async (req: Request, res: Response) => { + try { + const { severity_id, severity_category, start_range, end_range, recomended_action } = req.body; + if (!severity_id || !severity_category) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: severity_id, severity_category' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 22); + const insertResult = await client.query(` + INSERT INTO severity_assessment (severity_id, severity_category, start_range, end_range, recomended_action, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [severity_id, severity_category, start_range || 0, end_range || 100, recomended_action || '', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Severity assessment creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/severity/:id', async (req: Request, res: Response) => { + try { + const { severity_category, start_range, end_range, recomended_action } = req.body; + const assessment = await queryOne(` + UPDATE severity_assessment + SET severity_category = COALESCE($1, severity_category), + start_range = COALESCE($2, start_range), + end_range = COALESCE($3, end_range), + recomended_action = COALESCE($4, recomended_action) + WHERE severity_id = $5 + RETURNING * + `, [severity_category, start_range, end_range, recomended_action, req.params.id]); + + if (!assessment) { + return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' }); + } + res.json({ success: true, data: assessment, message: 'Severity assessment actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/severity/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM severity_assessment WHERE severity_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Severity assessment nu a fost găsit' }); + } + res.json({ success: true, message: 'Severity assessment șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// RUNTIME CONFIG: synergy + overrides + confidence + confidence_levels +// Stored in component_config (component_code='pipeline', config_key='verdict_config') +// Same key that is synced to Redis at didi:config:pipeline:v1:verdict_config +// ============================================================================ + +router.get('/runtime-config', async (_req: Request, res: Response) => { + try { + const rows = await query<{ config_value: unknown }>( + `SELECT config_value FROM component_config + WHERE component_code = 'pipeline' AND config_key = 'verdict_config'` + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' }); + } + const value = typeof rows[0].config_value === 'string' + ? JSON.parse(rows[0].config_value as string) + : rows[0].config_value; + res.json({ success: true, data: value }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/runtime-config', async (req: Request, res: Response) => { + try { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return res.status(400).json({ success: false, error: 'Body must be a JSON object' }); + } + const required = ['synergy', 'overrides', 'confidence', 'confidence_levels']; + const missing = required.filter(k => !(k in body)); + if (missing.length > 0) { + return res.status(400).json({ success: false, error: `Missing required keys: ${missing.join(', ')}` }); + } + + const result = await query( + `UPDATE component_config SET config_value = $1 + WHERE component_code = 'pipeline' AND config_key = 'verdict_config' + RETURNING component_code, config_key`, + [JSON.stringify(body)] + ); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' }); + } + res.json({ success: true, message: 'verdict_config updated. Sync to Redis to apply.' }); + } catch (error) { + internalError(res, error); + } +}); + +// PATCH partial update — accept a single section (synergy, overrides, confidence, confidence_levels, or a nested override key) +router.patch('/runtime-config/:section', async (req: Request, res: Response) => { + try { + const { section } = req.params; + const allowedTop = ['synergy', 'overrides', 'confidence', 'confidence_levels']; + const allowedOverride = ['false_claims', 'severe_techniques', 'undisclosed_ai', 'untrusted_domain', 'domain_red_flags']; + + const rows = await query<{ config_value: unknown }>( + `SELECT config_value FROM component_config + WHERE component_code = 'pipeline' AND config_key = 'verdict_config'` + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'verdict_config row missing in component_config' }); + } + const current = (typeof rows[0].config_value === 'string' + ? JSON.parse(rows[0].config_value as string) + : rows[0].config_value) as Record; + + if (allowedTop.includes(section)) { + current[section] = req.body; + } else if (allowedOverride.includes(section)) { + const overrides = (current.overrides as Record) || {}; + overrides[section] = req.body; + current.overrides = overrides; + } else { + return res.status(400).json({ success: false, error: `Unknown section: ${section}` }); + } + + await query( + `UPDATE component_config SET config_value = $1 + WHERE component_code = 'pipeline' AND config_key = 'verdict_config'`, + [JSON.stringify(current)] + ); + res.json({ success: true, data: current, message: `Section '${section}' updated. Sync to Redis to apply.` }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// COMBINED: GET ALL VERDICT DATA +// ============================================================================ + +router.get('/all', async (req: Request, res: Response) => { + try { + const [categories, riskMappings, severity] = await Promise.all([ + query('SELECT * FROM verdict_category ORDER BY start_range'), + query('SELECT * FROM risk_mapping ORDER BY start_range'), + query('SELECT severity_id, severity_category, start_range, end_range, recomended_action, parameter_id FROM severity_assessment ORDER BY start_range'), + ]); + + res.json({ + success: true, + data: { + verdictCategories: categories, + riskMappings: riskMappings, + severityAssessments: severity, + }, + counts: { + verdictCategories: categories.length, + riskMappings: riskMappings.length, + severityAssessments: severity.length, + total: categories.length + riskMappings.length + severity.length, + } + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/waitlist.ts b/backend/services/orchestration-layer/didiFramework/src/routes/waitlist.ts new file mode 100644 index 0000000..5e5e1d4 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/waitlist.ts @@ -0,0 +1,155 @@ +import { Router, Request, Response } from 'express'; +import { Pool } from 'pg'; +import { requireEnv, optionalEnv } from '../config/env'; +import { log } from '../config/logger'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Staging database connection (local waitlist-only DB, separate from main cluster). +// Lazy-init so the server can boot even when waitlist env vars aren't configured — +// the failure surfaces only on /api/waitlist requests. +let _stagingPool: Pool | null = null; +function getStagingPool(): Pool { + if (!_stagingPool) { + _stagingPool = new Pool({ + host: requireEnv('STAGING_DB_HOST'), + port: parseInt(optionalEnv('STAGING_DB_PORT', '5432'), 10), + database: requireEnv('STAGING_DB_NAME'), + user: requireEnv('STAGING_DB_USER'), + password: requireEnv('STAGING_DB_PASSWORD'), + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + } + return _stagingPool; +} + +// POST /api/waitlist - Add to waitlist (public endpoint) +router.post('/', async (req: Request, res: Response) => { + try { + const { email, name } = req.body; + + if (!email) { + res.status(400).json({ + success: false, + error: 'Email is required' + }); + return; + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + res.status(400).json({ + success: false, + error: 'Invalid email format' + }); + return; + } + + // Insert into waitlist + const result = await getStagingPool().query( + `INSERT INTO public.waitlist (email, name) + VALUES ($1, $2) + ON CONFLICT (email) DO NOTHING + RETURNING id, email, name, created_at`, + [email.toLowerCase().trim(), name?.trim() || null] + ); + + if (result.rows.length === 0) { + // Email already exists + res.status(200).json({ + success: true, + message: 'Already on the waitlist!', + alreadyExists: true + }); + return; + } + + res.status(201).json({ + success: true, + message: 'Successfully added to waitlist!', + data: result.rows[0] + }); + } catch (error: any) { + log.error('Error adding to waitlist:', error); + internalError(res, error); + } +}); + +// GET /api/waitlist - List all waitlist entries (for admin) +router.get('/', async (req: Request, res: Response) => { + try { + const result = await getStagingPool().query( + `SELECT id, email, name, created_at + FROM public.waitlist + ORDER BY created_at DESC` + ); + + res.json({ + success: true, + data: result.rows, + count: result.rows.length + }); + } catch (error: any) { + log.error('Error fetching waitlist:', error); + internalError(res, error); + } +}); + +// GET /api/waitlist/count - Get waitlist count (public) +router.get('/count', async (req: Request, res: Response) => { + try { + const result = await getStagingPool().query( + `SELECT COUNT(*) as count FROM public.waitlist` + ); + + res.json({ + success: true, + count: parseInt(result.rows[0].count, 10) + }); + } catch (error: any) { + log.error('Error fetching waitlist count:', error); + internalError(res, error); + } +}); + +// DELETE /api/waitlist/:id - Remove from waitlist (admin) +router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ + success: false, + error: 'Invalid ID' + }); + return; + } + + const result = await getStagingPool().query( + `DELETE FROM public.waitlist WHERE id = $1 RETURNING *`, + [id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ + success: false, + error: 'Entry not found' + }); + return; + } + + res.json({ + success: true, + message: 'Removed from waitlist' + }); + } catch (error: any) { + log.error('Error removing from waitlist:', error); + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/webhooks/stripe.ts b/backend/services/orchestration-layer/didiFramework/src/routes/webhooks/stripe.ts new file mode 100644 index 0000000..133bcc1 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/webhooks/stripe.ts @@ -0,0 +1,457 @@ +/** + * POST /webhooks/stripe + * + * Receives Stripe events. Verifies signature, dispatches per event type, + * and persists state changes to bos_sysadmin. + * + * Critical contract: + * - Body MUST be raw (Buffer) for signature verification. + * - We respond 200 fast even on processing errors (Stripe retries + * based on HTTP status; we log + retry internally if needed). + * - All handlers must be idempotent — Stripe may deliver same event + * multiple times (network retry, manual replay). + */ +import { Router, Request, Response } from 'express'; +import type Stripe from 'stripe'; +import pool from '../../config/database'; +import { log } from '../../config/logger'; +import { getStripe, getWebhookSecret, isStripeEnabled } from '../../config/stripe'; +import { sendEmail } from '../../config/email'; +import { + subscriptionCreatedEmail, + subscriptionCancelledEmail, + paymentFailedEmail, + trialEndingEmail, +} from '../../services/email-templates'; + +const router = Router(); + +router.post('/', async (req: Request, res: Response) => { + if (!isStripeEnabled()) { + log.warn('[stripe-webhook] received event but STRIPE_* env not configured — 503'); + return res.status(503).json({ error: 'Stripe not configured' }); + } + + const sig = req.headers['stripe-signature']; + if (!sig || typeof sig !== 'string') { + log.warn('[stripe-webhook] missing Stripe-Signature header'); + return res.status(400).send('Missing signature'); + } + + // req.body is Buffer here (raw body parser configured in server.ts for this route) + const stripe = getStripe(); + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(req.body, sig, getWebhookSecret()); + } catch (err: any) { + log.warn(`[stripe-webhook] signature verification failed: ${err.message}`); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + // Idempotency: dedupe via event.id (Stripe guarantees unique IDs) + const seen = await alreadyProcessed(event.id); + if (seen) { + log.info(`[stripe-webhook] duplicate event ${event.id} (${event.type}) — ack`); + return res.json({ received: true, duplicate: true }); + } + + // Acknowledge fast; process async if needed. + // (For now, we process inline — events are small and DB calls are fast.) + try { + await dispatch(event); + await markProcessed(event); + log.info(`[stripe-webhook] handled ${event.type} (${event.id})`); + } catch (err: any) { + log.error(`[stripe-webhook] handler failed for ${event.type} (${event.id}): ${err.message}`); + await markFailed(event, err.message); + // Still 200 — Stripe would retry on 5xx, but we want to investigate via logs first. + // Switch to res.status(500) once handlers are stable to enable Stripe auto-retry. + } + + res.json({ received: true }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Event dispatcher +// ────────────────────────────────────────────────────────────────────────────── + +async function dispatch(event: Stripe.Event): Promise { + switch (event.type) { + case 'customer.created': + return handleCustomerCreated(event.data.object as Stripe.Customer); + + case 'checkout.session.completed': + return handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + + case 'customer.subscription.created': + case 'customer.subscription.updated': + return handleSubscriptionUpsert(event.data.object as Stripe.Subscription); + + case 'customer.subscription.deleted': + return handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + + case 'customer.subscription.trial_will_end': + return handleTrialWillEnd(event.data.object as Stripe.Subscription); + + case 'invoice.payment_succeeded': + return handlePaymentSucceeded(event.data.object as Stripe.Invoice); + + case 'invoice.payment_failed': + return handlePaymentFailed(event.data.object as Stripe.Invoice); + + case 'invoice.upcoming': + return handleInvoiceUpcoming(event.data.object as Stripe.Invoice); + + default: + log.info(`[stripe-webhook] unhandled event type ${event.type} — ignoring`); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Handlers (each is idempotent — UPSERT or no-op pattern) +// ────────────────────────────────────────────────────────────────────────────── + +async function handleCustomerCreated(customer: Stripe.Customer): Promise { + // Map Stripe customer back to internet_user via metadata.keycloak_id (set at customer create) + // or via email lookup as fallback. + const keycloakId = customer.metadata?.keycloak_id; + const email = customer.email; + + let userId: number | null = null; + if (keycloakId) { + const r = await pool.query( + `SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE keycloak_id = $1`, + [keycloakId] + ); + userId = r.rows[0]?.internet_user_id ?? null; + } + if (!userId && email) { + const r = await pool.query( + `SELECT internet_user_id FROM bos_sysadmin.user_credential WHERE email = $1 AND "next$internet_user_id" IS NULL`, + [email] + ); + userId = r.rows[0]?.internet_user_id ?? null; + } + if (!userId) { + log.warn(`[stripe-webhook] customer.created — no DIDI user found (kc=${keycloakId}, email=${email})`); + return; + } + + await pool.query( + `UPDATE bos_sysadmin.internet_user SET stripe_customer_id = $1 WHERE internet_user_id = $2`, + [customer.id, userId] + ); + log.info(`[stripe-webhook] mapped stripe_customer ${customer.id} → user ${userId}`); +} + +async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { + // Subscription mode → subscription details arrive separately via customer.subscription.created. + // We only log here for audit purposes; actual state mutation happens in the subscription handler. + log.info(`[stripe-webhook] checkout completed — customer=${session.customer}, sub=${session.subscription}, mode=${session.mode}`); +} + +async function handleSubscriptionUpsert(sub: Stripe.Subscription): Promise { + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; + const item = sub.items.data[0]; + const priceId = item?.price.id; + if (!priceId) { + log.warn(`[stripe-webhook] subscription ${sub.id} has no price — skip`); + return; + } + // API 2025-09+ moved current_period_start/end from subscription → item; fall back gracefully. + const periodStart = (item as any)?.current_period_start ?? (sub as any).current_period_start ?? (sub as any).start_date; + const periodEnd = (item as any)?.current_period_end ?? (sub as any).current_period_end; + if (!periodStart || !periodEnd) { + log.warn(`[stripe-webhook] subscription ${sub.id} missing period dates — skip`); + return; + } + + // Resolve internet_user_id from stripe_customer_id + const userR = await pool.query( + `SELECT internet_user_id FROM bos_sysadmin.internet_user WHERE stripe_customer_id = $1`, + [customerId] + ); + const userId = userR.rows[0]?.internet_user_id; + if (!userId) { + log.warn(`[stripe-webhook] subscription ${sub.id} — no DIDI user for customer ${customerId}`); + return; + } + + // Resolve subscription_plan_id from price_id (matches monthly OR yearly column) + const planR = await pool.query( + `SELECT subscription_plan_id, credits_per_cycle, storage_limit_gb + FROM bos_sysadmin.subscription_plan + WHERE stripe_price_id = $1 OR stripe_price_id_yearly = $1 + LIMIT 1`, + [priceId] + ); + const plan = planR.rows[0]; + if (!plan) { + log.warn(`[stripe-webhook] subscription ${sub.id} — no DIDI plan for price ${priceId}`); + return; + } + + const isActive = sub.status === 'active' || sub.status === 'trialing'; + const startDate = new Date(periodStart * 1000).toISOString().slice(0, 10); + const endDate = new Date(periodEnd * 1000).toISOString().slice(0, 10); + + // Deactivate prior active rows for same user (different stripe_sub_id) + await pool.query( + `UPDATE bos_sysadmin.subscription SET is_active = false, deactivation_date = CURRENT_DATE, updated_time = CURRENT_DATE + WHERE internet_user_id = $1 AND is_active = true AND stripe_subscription_id IS DISTINCT FROM $2`, + [userId, sub.id] + ); + + // SELECT-then-UPDATE-or-INSERT (avoids ON CONFLICT issues with partial unique indexes) + const existing = await pool.query( + `SELECT subscription_id FROM bos_sysadmin.subscription WHERE stripe_subscription_id = $1 LIMIT 1`, + [sub.id] + ); + + if (existing.rows.length > 0) { + await pool.query( + `UPDATE bos_sysadmin.subscription SET + subscription_plan_id = $2, + subscription_status = $3, + is_active = $4, + activation_date = $5, + deactivation_date = $6, + stripe_status = $7, + updated_time = CURRENT_DATE + WHERE subscription_id = $1`, + [existing.rows[0].subscription_id, plan.subscription_plan_id, isActive ? 1 : 4, isActive, startDate, endDate, sub.status] + ); + } else { + await pool.query( + `INSERT INTO bos_sysadmin.subscription + (subscription_id, internet_user_id, subscription_plan_id, subscription_status, is_active, + activation_date, deactivation_date, created_time, updated_time, + stripe_subscription_id, stripe_status) + VALUES ( + (SELECT COALESCE(MAX(subscription_id),0)+1 FROM bos_sysadmin.subscription), + $1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE, $7, $8 + )`, + [userId, plan.subscription_plan_id, isActive ? 1 : 4, isActive, startDate, endDate, sub.id, sub.status] + ); + } + + // Refill credits + apply storage limit on subscription create OR period renewal. + if (isActive) { + await pool.query( + `UPDATE bos_sysadmin.internet_user + SET credits_remained = $1, + storage_limit_bytes = $2 + WHERE internet_user_id = $3`, + [ + plan.credits_per_cycle, + plan.storage_limit_gb < 0 ? 1099511627776 : plan.storage_limit_gb * 1073741824, + userId, + ] + ); + log.info(`[stripe-webhook] credits refilled for user ${userId}: ${plan.credits_per_cycle}, storage=${plan.storage_limit_gb}GB`); + + // Send welcome email only on initial create (not every period rollover) + if (existing.rows.length === 0) { + void sendSubscriptionCreatedEmail(userId, plan, sub, item).catch(err => + log.error(`[stripe-webhook] failed to send welcome email: ${err.message}`) + ); + } + } + + log.info(`[stripe-webhook] subscription ${sub.id} synced: user=${userId}, plan=${plan.subscription_plan_id}, status=${sub.status}`); +} + +async function sendSubscriptionCreatedEmail( + userId: number, + plan: any, + sub: Stripe.Subscription, + item: any +): Promise { + const r = await pool.query( + `SELECT uc.email, p.prenume AS first_name + FROM bos_sysadmin.user_credential uc + JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id + WHERE uc.internet_user_id = $1`, + [userId] + ); + const u = r.rows[0]; + if (!u?.email) return; + + const price = item?.price; + const currency = (price?.currency || 'eur').toUpperCase(); + const amount = (price?.unit_amount || 0) / 100; + const symbol = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : currency + ' '; + const interval: 'month' | 'year' = price?.recurring?.interval === 'year' ? 'year' : 'month'; + const periodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000).toISOString().slice(0, 10) : undefined; + + const tpl = subscriptionCreatedEmail({ + userName: u.first_name || undefined, + planName: plan.plan_name, + billingInterval: interval, + priceFormatted: `${symbol}${amount.toFixed(2)}`, + nextRenewalDate: periodEnd, + creditsPerCycle: plan.credits_per_cycle, + storageGb: plan.storage_limit_gb, + }); + await sendEmail({ to: u.email, ...tpl }); +} + +async function handleSubscriptionDeleted(sub: Stripe.Subscription): Promise { + await pool.query( + `UPDATE bos_sysadmin.subscription + SET is_active = false, deactivation_date = CURRENT_DATE, stripe_status = $2, updated_time = CURRENT_DATE + WHERE stripe_subscription_id = $1`, + [sub.id, sub.status] + ); + + // Notify user + const r = await pool.query( + `SELECT uc.email, p.prenume AS first_name, sp.plan_name + FROM bos_sysadmin.subscription s + JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = s.internet_user_id + JOIN bos_sysadmin.internet_user iu ON iu.internet_user_id = uc.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id + LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id + WHERE s.stripe_subscription_id = $1 + LIMIT 1`, + [sub.id] + ); + const u = r.rows[0]; + if (u?.email) { + const cancelAt = (sub as any).cancel_at; + const tpl = subscriptionCancelledEmail({ + userName: u.first_name, + planName: u.plan_name || 'Premium', + activeUntil: cancelAt ? new Date(cancelAt * 1000).toISOString().slice(0, 10) : undefined, + }); + void sendEmail({ to: u.email, ...tpl }).catch(err => + log.error(`[stripe-webhook] failed to send cancel email: ${err.message}`) + ); + } + + log.info(`[stripe-webhook] subscription ${sub.id} cancelled`); +} + +async function handleTrialWillEnd(sub: Stripe.Subscription): Promise { + const trialEnd = (sub as any).trial_end; + if (!trialEnd) return; + const daysLeft = Math.max(0, Math.round((trialEnd * 1000 - Date.now()) / 86400000)); + + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; + const r = await pool.query( + `SELECT uc.email, p.prenume AS first_name, sp.plan_name + FROM bos_sysadmin.internet_user iu + JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id + LEFT JOIN bos_sysadmin.subscription s ON s.internet_user_id = iu.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id + WHERE iu.stripe_customer_id = $1 + LIMIT 1`, + [customerId] + ); + const u = r.rows[0]; + if (u?.email) { + const tpl = trialEndingEmail({ + userName: u.first_name, + planName: u.plan_name || 'Premium', + daysLeft, + }); + void sendEmail({ to: u.email, ...tpl }).catch(err => + log.error(`[stripe-webhook] failed to send trial-ending email: ${err.message}`) + ); + } + log.info(`[stripe-webhook] trial ending in ${daysLeft}d for ${sub.id}`); +} + +async function handlePaymentSucceeded(invoice: Stripe.Invoice): Promise { + // Audit-only: refill happens in handleSubscriptionUpsert which is the + // single source of truth for plan/credit state. This avoids race conditions + // where invoice.payment_succeeded arrives before customer.subscription.created + // (Stripe doesn't guarantee event order). + const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; + const reason = (invoice as any).billing_reason; + log.info(`[stripe-webhook] invoice ${invoice.id} paid (customer=${customerId}, reason=${reason}, amount=${invoice.amount_paid / 100} ${invoice.currency})`); +} + +async function handlePaymentFailed(invoice: Stripe.Invoice): Promise { + log.warn(`[stripe-webhook] invoice ${invoice.id} payment failed (customer=${invoice.customer})`); + + const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; + if (!customerId) return; + + const r = await pool.query( + `SELECT uc.email, p.prenume AS first_name, sp.plan_name + FROM bos_sysadmin.internet_user iu + JOIN bos_sysadmin.user_credential uc ON uc.internet_user_id = iu.internet_user_id + LEFT JOIN bos_subscriber.persoana_fizica p ON p.individual_id = iu.person_id + LEFT JOIN bos_sysadmin.subscription s ON s.internet_user_id = iu.internet_user_id AND s.is_active = true + LEFT JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id + WHERE iu.stripe_customer_id = $1 + LIMIT 1`, + [customerId] + ); + const u = r.rows[0]; + if (!u?.email) return; + + // Build a portal session for the user to update their card + let portalUrl = `${process.env.PUBLIC_APP_URL || 'https://didi365.eu'}/dashboard?section=settings`; + try { + const portal = await getStripe().billingPortal.sessions.create({ + customer: customerId, + return_url: `${process.env.PUBLIC_APP_URL || 'https://didi365.eu'}/dashboard`, + }); + portalUrl = portal.url; + } catch (err: any) { + log.warn(`[stripe-webhook] portal session create failed (portal not activated yet?): ${err.message}`); + } + + const currency = (invoice.currency || 'eur').toUpperCase(); + const symbol = currency === 'EUR' ? '€' : currency === 'USD' ? '$' : currency + ' '; + const tpl = paymentFailedEmail({ + userName: u.first_name, + planName: u.plan_name || 'Premium', + amountFormatted: `${symbol}${(invoice.amount_due / 100).toFixed(2)}`, + portalUrl, + }); + void sendEmail({ to: u.email, ...tpl }).catch(err => + log.error(`[stripe-webhook] failed to send payment-failed email: ${err.message}`) + ); +} + +async function handleInvoiceUpcoming(invoice: Stripe.Invoice): Promise { + // Hook for "your subscription renews in 7 days" email notification. + log.info(`[stripe-webhook] upcoming invoice for ${invoice.customer}: ${invoice.amount_due / 100} ${invoice.currency}`); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Idempotency persistence +// ────────────────────────────────────────────────────────────────────────────── + +async function alreadyProcessed(eventId: string): Promise { + const r = await pool.query( + `SELECT 1 FROM bos_sysadmin.stripe_event_log WHERE event_id = $1 AND status = 'processed' LIMIT 1`, + [eventId] + ); + return r.rowCount! > 0; +} + +async function markProcessed(event: Stripe.Event): Promise { + await pool.query( + `INSERT INTO bos_sysadmin.stripe_event_log (event_id, event_type, status, payload_excerpt, processed_at) + VALUES ($1, $2, 'processed', $3, NOW()) + ON CONFLICT (event_id) DO UPDATE SET status = 'processed', processed_at = NOW()`, + [event.id, event.type, JSON.stringify(event.data.object).slice(0, 2000)] + ); +} + +async function markFailed(event: Stripe.Event, errorMsg: string): Promise { + await pool.query( + `INSERT INTO bos_sysadmin.stripe_event_log (event_id, event_type, status, error_message, payload_excerpt, processed_at) + VALUES ($1, $2, 'failed', $3, $4, NOW()) + ON CONFLICT (event_id) DO UPDATE SET status = 'failed', error_message = $3, processed_at = NOW()`, + [event.id, event.type, errorMsg.slice(0, 500), JSON.stringify(event.data.object).slice(0, 2000)] + ); +} + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/routes/weights.ts b/backend/services/orchestration-layer/didiFramework/src/routes/weights.ts new file mode 100644 index 0000000..edb0f95 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/routes/weights.ts @@ -0,0 +1,352 @@ +/** + * Weights Routes - FULL CRUD + * + * All weight-related tables are leaf nodes (no children): + * - Component Weights + * - Weight Scenarios + * - Multipliers (topic, temporal, reach) + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { ComponentWeight, WeightScenario, Multiplier, ApiResponse } from '../types'; +import { PoolClient } from 'pg'; +import { internalError } from '../config/error-response'; + +const router = Router(); + +// Helper functions +const createParameter = async (client: PoolClient, parameterType: number): Promise => { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +}; + +const getNextId = async (client: PoolClient, table: string, idColumn: string): Promise => { + const result = await client.query(`SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${table}`); + return result.rows[0].next_id; +}; + +// ============================================================================ +// COMPONENT WEIGHTS +// ============================================================================ + +router.get('/components', async (req: Request, res: Response) => { + try { + const weights = await query( + 'SELECT * FROM component_weight ORDER BY component_weight_id' + ); + res.json({ success: true, data: weights, count: weights.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/components/:id', async (req: Request, res: Response) => { + try { + const weight = await queryOne( + 'SELECT * FROM component_weight WHERE component_weight_id = $1', + [req.params.id] + ); + if (!weight) { + return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' }); + } + res.json({ success: true, data: weight }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/components', async (req: Request, res: Response) => { + try { + const { component_name, component_weight, description } = req.body; + if (!component_name) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: component_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 30); + const id = await getNextId(client, 'component_weight', 'component_weight_id'); + const insertResult = await client.query(` + INSERT INTO component_weight (component_weight_id, component_name, component_weight, description, parameter_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [id, component_name, component_weight || 0, description || '', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Component weight creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/components/:id', async (req: Request, res: Response) => { + try { + const { component_name, component_weight, description } = req.body; + const weight = await queryOne(` + UPDATE component_weight + SET component_name = COALESCE($1, component_name), + component_weight = COALESCE($2, component_weight), + description = COALESCE($3, description) + WHERE component_weight_id = $4 + RETURNING * + `, [component_name, component_weight, description, req.params.id]); + + if (!weight) { + return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' }); + } + res.json({ success: true, data: weight, message: 'Component weight actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/components/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM component_weight WHERE component_weight_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Component weight nu a fost găsit' }); + } + res.json({ success: true, message: 'Component weight șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// WEIGHT SCENARIOS +// ============================================================================ + +router.get('/scenarios', async (req: Request, res: Response) => { + try { + const scenarios = await query( + 'SELECT * FROM weight_scenario ORDER BY scenario_id' + ); + res.json({ success: true, data: scenarios, count: scenarios.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/scenarios/:id', async (req: Request, res: Response) => { + try { + const scenario = await queryOne( + 'SELECT * FROM weight_scenario WHERE scenario_id = $1', + [req.params.id] + ); + if (!scenario) { + return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' }); + } + res.json({ success: true, data: scenario }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/scenarios', async (req: Request, res: Response) => { + try { + const { scenario_name, manipulation, claims, source, ai, context, notes } = req.body; + if (!scenario_name) { + return res.status(400).json({ success: false, error: 'Câmp obligatoriu: scenario_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 31); + const id = await getNextId(client, 'weight_scenario', 'scenario_id'); + const insertResult = await client.query(` + INSERT INTO weight_scenario (scenario_id, scenario_name, manipulation, claims, source, ai, context, notes, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `, [id, scenario_name, manipulation || 0, claims || 0, source || 0, ai || 0, context || 0, notes || '', parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Weight scenario creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/scenarios/:id', async (req: Request, res: Response) => { + try { + const { scenario_name, manipulation, claims, source, ai, context, notes } = req.body; + const scenario = await queryOne(` + UPDATE weight_scenario + SET scenario_name = COALESCE($1, scenario_name), + manipulation = COALESCE($2, manipulation), + claims = COALESCE($3, claims), + source = COALESCE($4, source), + ai = COALESCE($5, ai), + context = COALESCE($6, context), + notes = COALESCE($7, notes) + WHERE scenario_id = $8 + RETURNING * + `, [scenario_name, manipulation, claims, source, ai, context, notes, req.params.id]); + + if (!scenario) { + return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' }); + } + res.json({ success: true, data: scenario, message: 'Weight scenario actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/scenarios/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM weight_scenario WHERE scenario_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Weight scenario nu a fost găsit' }); + } + res.json({ success: true, message: 'Weight scenario șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// MULTIPLIERS +// ============================================================================ + +router.get('/multipliers', async (req: Request, res: Response) => { + try { + const multipliers = await query( + 'SELECT * FROM multiplier ORDER BY multiplier_type, multiplier_id' + ); + res.json({ success: true, data: multipliers, count: multipliers.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/multipliers/type/:type', async (req: Request, res: Response) => { + try { + // multiplier_type e integer în DB — validăm ca să răspundem 400, nu 500 + if (!/^\d+$/.test(req.params.type)) { + return res.status(400).json({ + success: false, + error: 'Parametrul type trebuie să fie numeric (multiplier_type: 1, 2, 3)' + }); + } + const multipliers = await query( + 'SELECT * FROM multiplier WHERE multiplier_type = $1 ORDER BY multiplier_id', + [req.params.type] + ); + res.json({ success: true, data: multipliers, count: multipliers.length }); + } catch (error) { + internalError(res, error); + } +}); + +router.get('/multipliers/:id', async (req: Request, res: Response) => { + try { + const multiplier = await queryOne( + 'SELECT * FROM multiplier WHERE multiplier_id = $1', + [req.params.id] + ); + if (!multiplier) { + return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' }); + } + res.json({ success: true, data: multiplier }); + } catch (error) { + internalError(res, error); + } +}); + +router.post('/multipliers', async (req: Request, res: Response) => { + try { + const { multiplier_type, multiplier_name, description, multiplier } = req.body; + if (!multiplier_name || multiplier_type === undefined) { + return res.status(400).json({ success: false, error: 'Câmpuri obligatorii: multiplier_type, multiplier_name' }); + } + + const result = await transaction(async (client) => { + const parameterId = await createParameter(client, 32); + const id = await getNextId(client, 'multiplier', 'multiplier_id'); + const insertResult = await client.query(` + INSERT INTO multiplier (multiplier_id, multiplier_type, multiplier_name, description, multiplier, parameter_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [id, multiplier_type, multiplier_name, description || '', multiplier || 1, parameterId]); + return insertResult.rows[0]; + }); + + res.status(201).json({ success: true, data: result, message: 'Multiplier creat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.put('/multipliers/:id', async (req: Request, res: Response) => { + try { + const { multiplier_type, multiplier_name, description, multiplier } = req.body; + const result = await queryOne(` + UPDATE multiplier + SET multiplier_type = COALESCE($1, multiplier_type), + multiplier_name = COALESCE($2, multiplier_name), + description = COALESCE($3, description), + multiplier = COALESCE($4, multiplier) + WHERE multiplier_id = $5 + RETURNING * + `, [multiplier_type, multiplier_name, description, multiplier, req.params.id]); + + if (!result) { + return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' }); + } + res.json({ success: true, data: result, message: 'Multiplier actualizat cu succes' }); + } catch (error) { + internalError(res, error); + } +}); + +router.delete('/multipliers/:id', async (req: Request, res: Response) => { + try { + const result = await query('DELETE FROM multiplier WHERE multiplier_id = $1 RETURNING *', [req.params.id]); + if (result.length === 0) { + return res.status(404).json({ success: false, error: 'Multiplier nu a fost găsit' }); + } + res.json({ success: true, message: 'Multiplier șters cu succes', deleted: true }); + } catch (error) { + internalError(res, error); + } +}); + +// ============================================================================ +// COMBINED: GET ALL WEIGHTS DATA +// ============================================================================ + +router.get('/all', async (req: Request, res: Response) => { + try { + const [components, scenarios, multipliers] = await Promise.all([ + query('SELECT * FROM component_weight ORDER BY component_weight_id'), + query('SELECT * FROM weight_scenario ORDER BY scenario_id'), + query('SELECT * FROM multiplier ORDER BY multiplier_type, multiplier_id'), + ]); + + res.json({ + success: true, + data: { + componentWeights: components, + weightScenarios: scenarios, + multipliers: multipliers, + }, + counts: { + componentWeights: components.length, + weightScenarios: scenarios.length, + multipliers: multipliers.length, + total: components.length + scenarios.length + multipliers.length, + } + }); + } catch (error) { + internalError(res, error); + } +}); + +export default router; diff --git a/backend/services/orchestration-layer/didiFramework/src/server.ts b/backend/services/orchestration-layer/didiFramework/src/server.ts new file mode 100644 index 0000000..65d2557 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/server.ts @@ -0,0 +1,446 @@ +// Init OTel BEFORE other imports to register instrumentations (no-op if OTEL_ENABLED!=true) +import { startOtel } from './config/otel'; +startOtel(); + +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import { log } from './config/logger'; +import { requestLogger } from './config/request-logger'; +import { register as metricsRegister, metricsMiddleware } from './config/metrics'; +import { jwtVerifyGate } from './config/jwt-verify'; + +import techniquesRouter from './routes/techniques'; +import dimensionsRouter from './routes/dimensions'; +import verdictsRouter from './routes/verdicts'; +import overviewRouter from './routes/overview'; +import platformsRouter from './routes/platforms'; +import sourcesRouter from './routes/sources'; +import subdimensionsRouter from './routes/subdimensions'; +import indicatorsRouter from './routes/indicators'; +import validationRulesRouter from './routes/validation-rules'; +import weightsRouter from './routes/weights'; +import promptsRouter from './routes/prompts'; +import sourceAssessmentRouter from './routes/source-assessment'; +import claimsRouter from './routes/claims'; +import syncRedisRouter from './routes/sync-redis'; +import syncAnalysisRouter from './routes/sync-analysis'; +import uploadsRouter from './routes/uploads'; +import authRouter from './routes/auth'; +import providersRouter from './routes/providers'; +import adminRouter from './routes/admin'; +import waitlistRouter from './routes/waitlist'; +import historyRouter from './routes/history'; +import subscriptionsRouter from './routes/subscriptions'; +import extensionKeysRouter from './routes/extension-keys'; +import inputProfilesRouter from './routes/input-profiles'; +import skillsRouter from './routes/skills'; +import moderationConfigRouter from './routes/moderation-config'; +import sensitiveTopicsRouter from './routes/sensitive-topics'; +import moderationRolesRouter from './routes/moderation-roles'; +import stripeWebhookRouter from './routes/webhooks/stripe'; +import notificationsRouter from './routes/notifications'; +import { startCreditResetCron } from './services/credit-reset-cron'; + +dotenv.config(); + +const app = express(); +const PORT = parseInt(process.env.PORT || '3005', 10); +const HOST = process.env.HOST || '0.0.0.0'; +const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; + +// Middleware +app.use(cors({ + origin: CORS_ORIGIN, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// Stripe webhooks need RAW body for signature verification — mount BEFORE json parser +app.use('/webhooks/stripe', express.raw({ type: 'application/json' }), stripeWebhookRouter); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(requestLogger()); +app.use(metricsMiddleware); + +// JWT signature gate — any Bearer JWT must verify against Keycloak JWKS +// (RS256) or the request is rejected. Route-level decode helpers run only on +// tokens that passed this gate. See config/jwt-verify.ts. +app.use(jwtVerifyGate()); + +// Prometheus metrics endpoint (scraped by Prometheus) +app.get('/metrics', async (_req, res) => { + res.set('Content-Type', metricsRegister.contentType); + res.end(await metricsRegister.metrics()); +}); + +// ============================================================================ +// ROUTES - Organized by Category +// ============================================================================ + +// Overview & Health +app.use('/api/overview', overviewRouter); + +// Category 1: Analysis Core +app.use('/api/dimensions', dimensionsRouter); +app.use('/api/subdimensions', subdimensionsRouter); + +// Category 2: Techniques +app.use('/api/techniques', techniquesRouter); +app.use('/api/indicators', indicatorsRouter); +app.use('/api/validation-rules', validationRulesRouter); + +// Category 3: Source Assessment +// Individual endpoints at /api level +app.use('/api', sourceAssessmentRouter); +// Also mount at /api/platforms for direct access +app.use('/api/platforms', platformsRouter); +// Source types +app.use('/api/sources', sourcesRouter); + +// Category 4: Claims Analysis +app.use('/api/claims', claimsRouter); + +// Category 5: Verdicts & Scoring +app.use('/api/verdicts', verdictsRouter); + +// Category 6: Weights & Config +app.use('/api/weights', weightsRouter); + +// Prompts (MD files) +app.use('/api/prompts', promptsRouter); + +// Redis Sync (for DIDI Agent) +app.use('/api/sync-redis', syncRedisRouter); + +// Analysis Results Sync (Redis → PostgreSQL) +app.use('/api/sync-analysis', syncAnalysisRouter); + +// File Uploads (MinIO Storage) +app.use('/api/uploads', uploadsRouter); + +// Auth & User Management (Keycloak integration) +app.use('/api/auth', authRouter); + +// Category 7: LLM Providers +app.use('/api/providers', providersRouter); + +// Category 8: Admin User Management (requires admin role) +app.use('/api/admin', adminRouter); + +// Category 9: Waitlist (public signup) +app.use('/api/waitlist', waitlistRouter); + +// Category 10: Analysis History (for frontend) +app.use('/api/history', historyRouter); + +// Category 11: Subscriptions & Usage +app.use('/api/subscriptions', subscriptionsRouter); + +// Category 12: Extension API Keys +app.use('/api/extension-keys', extensionKeysRouter); + +// Category 13: Verdict Input Profiles +app.use('/api/input-profiles', inputProfilesRouter); +// Alias: un input profile ESTE definiția de pipeline (componente + ponderi + +// praguri per tip de input) — expus și sub /api/pipelines pentru Modul 1 +// (CRUD/clonare/versionare/publicare/activare pipeline). +app.use('/api/pipelines', inputProfilesRouter); +// Catalog resurse AI: componente + skills (module platformă) + code-jobs +app.use('/api/skills', skillsRouter); + +// Category 14: HIL Moderation Config (triage rules + brain client settings) +app.use('/api/moderation-config', moderationConfigRouter); +app.use('/api/sensitive-topics', sensitiveTopicsRouter); +app.use('/api/moderation-roles', moderationRolesRouter); + +// Category 15: Notifications (transactional email) +app.use('/api/notifications', notificationsRouter); + +// ============================================================================ +// SPECIAL ENDPOINTS +// ============================================================================ + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'didiFramework', timestamp: new Date().toISOString() }); +}); + +// Health check for all dependencies +app.get('/health/all', async (req, res) => { + const { checkHealth: checkDb } = await import('./config/database'); + const { checkMinioHealth } = await import('./config/minio'); + + const [dbOk, minioOk] = await Promise.all([ + checkDb().catch(() => false), + checkMinioHealth().catch(() => false), + ]); + + res.json({ + status: dbOk && minioOk ? 'ok' : 'degraded', + services: { + postgres: dbOk ? 'healthy' : 'unhealthy', + minio: minioOk ? 'healthy' : 'unhealthy', + }, + timestamp: new Date().toISOString() + }); +}); + +// Root endpoint - API documentation +app.get('/', (req, res) => { + res.json({ + service: 'didiFramework', + description: 'DIDI Framework Management Service - CRUD API', + version: '2.0.0', + categories: { + analysisCore: { + description: 'Dimensiuni și subdimensiuni', + endpoints: { + dimensions: '/api/dimensions', + subdimensions: '/api/subdimensions' + } + }, + techniques: { + description: 'Tehnici de manipulare cu indicatori și reguli', + endpoints: { + techniques: '/api/techniques', + indicators: '/api/indicators', + validationRules: '/api/validation-rules' + } + }, + sourceAssessment: { + description: 'Evaluarea surselor', + endpoints: { + platforms: '/api/platforms', + platformModifiers: '/api/platform-modifiers', + sourceCredibility: '/api/source-credibility', + domainAgeScores: '/api/domain-age-scores', + domainRiskLevels: '/api/domain-risk-levels', + domainRedFlags: '/api/domain-red-flags', + authorClassifications: '/api/author-classifications', + authorCredibility: '/api/author-credibility', + all: '/api/source-assessment/all' + } + }, + claims: { + description: 'Analiza afirmațiilor', + endpoints: { + status: '/api/claims/status', + types: '/api/claims/types', + confidence: '/api/claims/confidence', + interpretation: '/api/claims/interpretation', + all: '/api/claims/all' + } + }, + verdicts: { + description: 'Verdicte și scoruri de risc', + endpoints: { + categories: '/api/verdicts/categories', + risk: '/api/verdicts/risk', + severity: '/api/verdicts/severity', + all: '/api/verdicts/all' + } + }, + weights: { + description: 'Ponderi și multiplicatori', + endpoints: { + components: '/api/weights/components', + scenarios: '/api/weights/scenarios', + multipliers: '/api/weights/multipliers', + all: '/api/weights/all' + } + }, + syncRedis: { + description: 'Sincronizare date în Redis pentru Agent DIDI', + endpoints: { + sync: 'POST /api/sync-redis', + status: 'GET /api/sync-redis/status', + data: 'GET /api/sync-redis/data/:category' + } + }, + syncAnalysis: { + description: 'Sincronizare rezultate analize Redis → PostgreSQL (legacy)', + endpoints: { + syncSession: 'POST /api/sync-analysis/:sessionId', + syncBatch: 'POST /api/sync-analysis/batch', + pending: 'GET /api/sync-analysis/pending', + stats: 'GET /api/sync-analysis/stats' + } + }, + uploads: { + description: 'File uploads cu MinIO storage', + endpoints: { + upload: 'POST /api/uploads (multipart/form-data)', + uploadMultiple: 'POST /api/uploads/multipart', + getFile: 'GET /api/uploads/:fileId', + getUrl: 'GET /api/uploads/:fileId/url', + deleteFile: 'DELETE /api/uploads/:fileId', + listFiles: 'GET /api/uploads', + bucketStats: 'GET /api/uploads/buckets/stats', + health: 'GET /api/uploads/health' + } + }, + auth: { + description: 'Autentificare și gestiune utilizatori (Keycloak)', + endpoints: { + me: 'GET /api/auth/me', + register: 'POST /api/auth/register', + credits: 'GET /api/auth/credits', + useCredit: 'POST /api/auth/use-credit', + profile: 'PUT /api/auth/profile' + } + }, + providers: { + description: 'LLM Providers - configurare provideri și modele per componentă', + endpoints: { + configs: '/api/providers/configs', + models: '/api/providers/models', + assignments: '/api/providers/assignments', + keys: '/api/providers/keys', + all: '/api/providers/all', + test: 'POST /api/providers/test/:providerId' + } + }, + admin: { + description: 'Admin User Management (requires admin role)', + endpoints: { + users: 'GET /api/admin/users (paginated, searchable)', + userById: 'GET /api/admin/users/:id', + updateUser: 'PUT /api/admin/users/:id', + deleteUser: 'DELETE /api/admin/users/:id', + subscription: 'PUT /api/admin/users/:id/subscription', + plans: 'GET /api/admin/plans' + } + } + }, + crudOperations: { + GET: 'Listare toate / Citire individual', + POST: 'Creare nouă', + PUT: 'Actualizare', + DELETE: 'Ștergere (cu verificare dependențe)' + }, + safetyFeatures: [ + 'Verificare dependențe înainte de ștergere', + 'GET /:id/dependencies - verifică dacă poate fi șters', + 'Eroare 409 cu detalii dacă are copii', + 'Parametru ?force=true pentru forțare (cu risc)' + ] + }); +}); + +// ============================================================================ +// ERROR HANDLING +// ============================================================================ + +// Error handling +app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + log.error('Error:', err); + res.status(500).json({ + success: false, + error: err.message || 'Internal server error' + }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + success: false, + error: 'Endpoint not found', + availableCategories: [ + '/api/dimensions', + '/api/subdimensions', + '/api/techniques', + '/api/indicators', + '/api/validation-rules', + '/api/platforms', + '/api/claims', + '/api/verdicts', + '/api/weights', + '/api/providers', + '/api/admin' + ] + }); +}); + +// Start server +app.listen(PORT, HOST, () => { + log.info('╔══════════════════════════════════════════════════════════════╗'); + log.info('║ DIDI Framework Management Service v2.0 ║'); + log.info('║ FULL CRUD + SAFETY ║'); + log.info('╠══════════════════════════════════════════════════════════════╣'); + log.info(`║ Server running on http://${HOST}:${PORT} ║`); + log.info('║ ║'); + log.info('║ Categories: ║'); + log.info('║ 1. Analysis Core - /api/dimensions, /api/subdimensions ║'); + log.info('║ 2. Techniques - /api/techniques, indicators, rules ║'); + log.info('║ 3. Source Assess. - /api/platforms, source-*, domain-* ║'); + log.info('║ 4. Claims - /api/claims/* ║'); + log.info('║ 5. Verdicts - /api/verdicts/* ║'); + log.info('║ 6. Weights - /api/weights/* ║'); + log.info('║ 7. Redis Sync - /api/sync-redis (POST/GET) ║'); + log.info('║ 8. File Uploads - /api/uploads (MinIO) ║'); + log.info('║ ║'); + log.info('║ All endpoints support: GET, POST, PUT, DELETE ║'); + log.info('║ Safe delete: GET /:id/dependencies before DELETE ║'); + log.info('╚══════════════════════════════════════════════════════════════╝'); + + // Auto-bootstrap: populate Redis cluster if framework keys are missing. + // Guarantees a fresh deployment works end-to-end without manual intervention. + // Set DIDI_SKIP_BOOTSTRAP=1 to disable (e.g. for local dev against a shared Redis). + if (process.env.DIDI_SKIP_BOOTSTRAP !== '1') { + bootstrapRedis().catch(err => { + log.error('[Bootstrap] Redis auto-populate failed:', err.message); + log.error('[Bootstrap] Manual fix: POST http://didi-framework:3005/api/sync-redis'); + }); + } + + // Schedule monthly Free-tier credit reset (paid users handled via Stripe webhook) + startCreditResetCron(); +}); + +async function bootstrapRedis(): Promise { + const { createRedisConnection } = await import('./config/redis'); + const redis = createRedisConnection({ label: 'bootstrap', overrides: { maxRetriesPerRequest: 3 } }); + + try { + // Give Redis a second to settle (container cold start) + await new Promise(resolve => setTimeout(resolve, 1000)); + + const manifestExists = await redis.exists('didi:framework:manifest'); + const host = process.env.REDIS_HOST || 'didi-cache'; + + if (manifestExists) { + log.info(`[Bootstrap] Redis at ${host} already populated — skip sync`); + return; + } + + log.info(`[Bootstrap] Redis at ${host} is empty — auto-populating from PostgreSQL...`); + const http = await import('http'); + const syncResponse = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: PORT, + path: '/api/sync-redis', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': 0 }, + }, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => resolve({ status: res.statusCode || 0, body })); + }); + req.on('error', reject); + req.setTimeout(60000, () => { req.destroy(new Error('sync-redis timed out after 60s')); }); + req.end(); + }); + + if (syncResponse.status === 200) { + const data = JSON.parse(syncResponse.body); + log.info(`[Bootstrap] ✓ Populated ${data.data?.keys_written || 0} keys in ${data.data?.duration_ms || 0}ms`); + } else { + log.error(`[Bootstrap] sync-redis returned ${syncResponse.status}: ${syncResponse.body.slice(0, 500)}`); + } + } finally { + redis.disconnect(); + } +} diff --git a/backend/services/orchestration-layer/didiFramework/src/services/credit-reset-cron.ts b/backend/services/orchestration-layer/didiFramework/src/services/credit-reset-cron.ts new file mode 100644 index 0000000..69fe9c6 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/services/credit-reset-cron.ts @@ -0,0 +1,56 @@ +/** + * Monthly credit reset cron for Free-tier users. + * + * Paid users get their credits refilled by the Stripe webhook + * (`customer.subscription.updated` → handleSubscriptionUpsert). + * Free users have no Stripe subscription, so we reset them via cron + * on the 1st of every month at 00:05 UTC. + * + * Idempotent: re-running on the same day won't double-refill, since we + * only touch users whose credits_remained < credits_per_cycle (i.e. they + * actually consumed something this cycle). + */ +import cron from 'node-cron'; +import pool from '../config/database'; +import { log } from '../config/logger'; + +const CRON_PATTERN = '5 0 1 * *'; // 00:05 UTC on day-of-month 1 + +export async function resetFreeUserCredits(): Promise<{ updated: number; sample: any[] }> { + // Reset all Free-tier users to plan.credits_per_cycle. + // We DON'T overwrite users who paid — those have stripe_subscription_id set on + // their subscription row and are handled by the Stripe webhook on renewal. + const r = await pool.query(` + UPDATE bos_sysadmin.internet_user iu + SET credits_remained = sp.credits_per_cycle, + credits_spent = 0 + FROM bos_sysadmin.subscription s + JOIN bos_sysadmin.subscription_plan sp ON sp.subscription_plan_id = s.subscription_plan_id + WHERE s.internet_user_id = iu.internet_user_id + AND s.is_active = true + AND s.stripe_subscription_id IS NULL -- Free only (paid via Stripe) + AND sp.plan_type = 1 -- Free plan + AND iu.credits_remained < sp.credits_per_cycle + RETURNING iu.internet_user_id, sp.credits_per_cycle + `); + const updated = r.rowCount ?? 0; + log.info(`[credit-reset] Reset ${updated} Free users to monthly allowance`); + return { updated, sample: r.rows.slice(0, 5) }; +} + +export function startCreditResetCron(): void { + if (!cron.validate(CRON_PATTERN)) { + log.error(`[credit-reset] invalid cron pattern: ${CRON_PATTERN}`); + return; + } + cron.schedule(CRON_PATTERN, async () => { + log.info('[credit-reset] Starting monthly Free credit reset...'); + try { + const result = await resetFreeUserCredits(); + log.info(`[credit-reset] Completed — ${result.updated} users refilled`); + } catch (err: any) { + log.error(`[credit-reset] Failed: ${err.message}`); + } + }, { timezone: 'UTC' }); + log.info(`[credit-reset] Cron scheduled — pattern="${CRON_PATTERN}" (UTC, monthly)`); +} diff --git a/backend/services/orchestration-layer/didiFramework/src/services/email-templates.ts b/backend/services/orchestration-layer/didiFramework/src/services/email-templates.ts new file mode 100644 index 0000000..fb8d2db --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/services/email-templates.ts @@ -0,0 +1,143 @@ +/** + * Email templates for DIDI transactional notifications. + * + * Each template returns { subject, html, text }. + * Branding: DIDI violet (#7c3aed). Plain HTML — no inlining/preprocessor needed. + */ + +const BRAND = '#7c3aed'; +const APP_URL = process.env.PUBLIC_APP_URL || 'https://didi365.eu'; + +function wrap(title: string, body: string, ctaLabel?: string, ctaUrl?: string): string { + const cta = ctaLabel && ctaUrl + ? `

${ctaLabel}

` + : ''; + return ` +
+
+

DIDI

+
+
+

${title}

+
${body}
+ ${cta} +
+
+ DIDI · misinformation detection
+ ${APP_URL} +
+
+`; +} + +function plain(title: string, body: string, ctaLabel?: string, ctaUrl?: string): string { + const cta = ctaLabel && ctaUrl ? `\n\n${ctaLabel}: ${ctaUrl}` : ''; + return `${title}\n\n${body}${cta}\n\n—\nDIDI · ${APP_URL}`; +} + +export interface SubscriptionEmailVars { + userName?: string; + planName: string; + billingInterval: 'month' | 'year'; + priceFormatted: string; // e.g. "€50.00" + nextRenewalDate?: string; // YYYY-MM-DD + creditsPerCycle: number; + storageGb: number; +} + +export function subscriptionCreatedEmail(v: SubscriptionEmailVars) { + const greeting = v.userName ? `Hi ${v.userName},` : 'Hi,'; + const body = `

${greeting}

+

Your ${v.planName} subscription is now active. Welcome to DIDI Premium!

+ + + + + + ${v.nextRenewalDate ? `` : ''} +
Plan${v.planName}
Price${v.priceFormatted} / ${v.billingInterval}
Credits${v.creditsPerCycle} / ${v.billingInterval}
Storage${v.storageGb < 0 ? 'Unlimited' : v.storageGb + ' GB'}
Next renewal${v.nextRenewalDate}
+

A receipt was sent separately by Stripe.

`; + return { + subject: `Welcome to DIDI ${v.planName}`, + html: wrap(`Subscription active: ${v.planName}`, body, 'Open dashboard', `${APP_URL}/dashboard`), + text: plain( + `Subscription active: ${v.planName}`, + `${greeting}\n\nYour ${v.planName} subscription is now active.\n\nPlan: ${v.planName}\nPrice: ${v.priceFormatted}/${v.billingInterval}\nCredits: ${v.creditsPerCycle}/${v.billingInterval}\nStorage: ${v.storageGb < 0 ? 'Unlimited' : v.storageGb + ' GB'}${v.nextRenewalDate ? `\nNext renewal: ${v.nextRenewalDate}` : ''}`, + 'Open dashboard', + `${APP_URL}/dashboard` + ), + }; +} + +export function subscriptionCancelledEmail(v: { userName?: string; planName: string; activeUntil?: string }) { + const greeting = v.userName ? `Hi ${v.userName},` : 'Hi,'; + const body = `

${greeting}

+

Your ${v.planName} subscription has been cancelled${v.activeUntil ? ` and will remain active until ${v.activeUntil}` : ''}. After that, your account will revert to the Free tier.

+

If this was a mistake, you can reactivate any time from the dashboard.

`; + return { + subject: `Subscription cancelled: ${v.planName}`, + html: wrap('Your subscription has been cancelled', body, 'Reactivate', `${APP_URL}/dashboard`), + text: plain( + 'Your subscription has been cancelled', + `${greeting}\n\nYour ${v.planName} subscription has been cancelled${v.activeUntil ? ` and will remain active until ${v.activeUntil}` : ''}. After that, your account reverts to Free tier.`, + 'Reactivate', + `${APP_URL}/dashboard` + ), + }; +} + +export function paymentFailedEmail(v: { userName?: string; planName: string; amountFormatted: string; portalUrl: string }) { + const greeting = v.userName ? `Hi ${v.userName},` : 'Hi,'; + const body = `

${greeting}

+

We couldn't charge your card for the ${v.planName} subscription (${v.amountFormatted}).

+

Please update your payment method to avoid losing access. Stripe will retry the payment automatically over the next few days.

`; + return { + subject: `Action needed: payment failed for ${v.planName}`, + html: wrap('Payment failed', body, 'Update payment method', v.portalUrl), + text: plain( + 'Payment failed', + `${greeting}\n\nWe couldn't charge your card for ${v.planName} (${v.amountFormatted}). Please update your payment method.`, + 'Update payment method', + v.portalUrl + ), + }; +} + +export function storageAlertEmail(v: { userName?: string; usedPercent: number; usedGb: number; limitGb: number; planName: string }) { + const greeting = v.userName ? `Hi ${v.userName},` : 'Hi,'; + const isHard = v.usedPercent >= 100; + const subject = isHard + ? `Storage limit reached` + : `Storage at ${v.usedPercent}%`; + const body = `

${greeting}

+

You're using ${v.usedGb.toFixed(2)} GB / ${v.limitGb} GB (${v.usedPercent}%) of your DIDI storage on the ${v.planName} plan.

+${isHard + ? `

Uploads are now blocked. Free up space or upgrade to a higher tier to continue.

` + : `

You'll start losing the ability to upload new content once you hit 100%. Consider upgrading or cleaning up old analyses.

`}`; + return { + subject, + html: wrap(subject, body, isHard ? 'Upgrade plan' : 'Manage storage', `${APP_URL}/dashboard?section=settings`), + text: plain( + subject, + `${greeting}\n\nYou're using ${v.usedGb.toFixed(2)} GB / ${v.limitGb} GB (${v.usedPercent}%) on ${v.planName}.${isHard ? '\n\nUploads are blocked. Free up space or upgrade.' : ''}`, + isHard ? 'Upgrade plan' : 'Manage storage', + `${APP_URL}/dashboard?section=settings` + ), + }; +} + +export function trialEndingEmail(v: { userName?: string; planName: string; daysLeft: number }) { + const greeting = v.userName ? `Hi ${v.userName},` : 'Hi,'; + const body = `

${greeting}

+

Your ${v.planName} trial ends in ${v.daysLeft} day${v.daysLeft === 1 ? '' : 's'}. After that, you'll be charged automatically unless you cancel.

`; + return { + subject: `Trial ending in ${v.daysLeft} day${v.daysLeft === 1 ? '' : 's'}`, + html: wrap('Your trial is ending soon', body, 'Manage subscription', `${APP_URL}/dashboard?section=settings`), + text: plain( + 'Your trial is ending soon', + `${greeting}\n\nYour ${v.planName} trial ends in ${v.daysLeft} days.`, + 'Manage subscription', + `${APP_URL}/dashboard?section=settings` + ), + }; +} diff --git a/backend/services/orchestration-layer/didiFramework/src/services/facebook.ts b/backend/services/orchestration-layer/didiFramework/src/services/facebook.ts new file mode 100644 index 0000000..778d628 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/services/facebook.ts @@ -0,0 +1,275 @@ +/** + * Facebook Graph API client — postare automată din admin-dashboard pe pagina FB. + * + * Config (ENV): + * FACEBOOK_PAGE_ID — Page ID (numeric) + * FACEBOOK_PAGE_ACCESS_TOKEN — Page Access Token PERMANENT (din /me/accounts cu long-lived user token) + * FACEBOOK_APP_ID — pentru debug/refresh (opțional) + * FACEBOOK_APP_SECRET — pentru debug (opțional) + * FACEBOOK_API_VERSION — default v21.0 + * + * Folosit de: src/routes/admin/social.ts + * Audit: orice apel logat în tabela social_post (response_json, error_message). + * + * DESI 6 (rețea socială) — această integrare permite postare regulată pentru a + * dovedi indicator activ la audit PNRR. + */ + +export interface FacebookPostOptions { + message: string; + link?: string; + imageUrl?: string; // dacă setat → folosim /photos endpoint pentru cu imagine + scheduledPublishTime?: number; // Unix timestamp (min 10 min în viitor; max 6 luni) +} + +export interface FacebookPostResult { + id: string; // ex: "{page_id}_{post_id}" + post_id?: string; // alias + external_url?: string; // URL public al postului +} + +export interface FacebookEngagement { + likes: number; + comments: number; + shares: number; + reactions_total: number; + reach?: number; +} + +const API_VERSION = process.env.FACEBOOK_API_VERSION || 'v21.0'; +const BASE_URL = `https://graph.facebook.com/${API_VERSION}`; + +function getPageId(): string { + const id = process.env.FACEBOOK_PAGE_ID; + if (!id) throw new Error('FACEBOOK_PAGE_ID env var not set'); + return id; +} + +function getPageToken(): string { + const t = process.env.FACEBOOK_PAGE_ACCESS_TOKEN; + if (!t) throw new Error('FACEBOOK_PAGE_ACCESS_TOKEN env var not set'); + return t; +} + +/** + * Publică un post pe pagina FB. + * + * - Text-only: POST /{page_id}/feed + * - Cu imagine: POST /{page_id}/photos (upload imagine + caption) + * - Scheduled: adăugare scheduled_publish_time în payload (published=false) + */ +export async function postToFacebookPage( + options: FacebookPostOptions, +): Promise { + const pageId = getPageId(); + const token = getPageToken(); + + const isScheduled = options.scheduledPublishTime + && options.scheduledPublishTime * 1000 > Date.now() + 9 * 60 * 1000; // min 10 min în viitor + + let endpoint: string; + const params = new URLSearchParams(); + params.set('access_token', token); + + if (options.imageUrl) { + // Photo post — include imagine + caption + endpoint = `${BASE_URL}/${pageId}/photos`; + params.set('url', options.imageUrl); + params.set('caption', options.message); + if (options.link) { + params.set('link', options.link); + } + } else { + // Text/link post + endpoint = `${BASE_URL}/${pageId}/feed`; + params.set('message', options.message); + if (options.link) { + params.set('link', options.link); + } + } + + if (isScheduled) { + params.set('published', 'false'); + params.set('scheduled_publish_time', String(options.scheduledPublishTime)); + } + + const res = await fetch(endpoint, { + method: 'POST', + body: params, + signal: AbortSignal.timeout(30_000), + }); + + const text = await res.text(); + let data: { id?: string; post_id?: string; error?: { message: string; code: number } }; + try { + data = JSON.parse(text); + } catch { + throw new Error(`Facebook API: invalid JSON response (HTTP ${res.status}): ${text.slice(0, 500)}`); + } + + if (!res.ok || data.error) { + const msg = data.error?.message || `HTTP ${res.status}`; + throw new Error(`Facebook API error: ${msg}`); + } + + const id = data.id || data.post_id; + if (!id) { + throw new Error('Facebook API: missing id in response'); + } + + // Pentru photo upload, FB returnează {id, post_id} unde id = photo_id, post_id = page_post_id + // Pentru feed normal: doar id = page_post_id + const finalId = data.post_id || id; + const externalUrl = `https://www.facebook.com/${finalId.replace('_', '/posts/')}`; + + return { + id: finalId, + post_id: finalId, + external_url: externalUrl, + }; +} + +/** + * Șterge un post de pe pagina FB. + */ +export async function deleteFacebookPost(postId: string): Promise { + const token = getPageToken(); + const res = await fetch( + `${BASE_URL}/${postId}?access_token=${encodeURIComponent(token)}`, + { + method: 'DELETE', + signal: AbortSignal.timeout(15_000), + }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok || !(data as { success?: boolean }).success) { + throw new Error(`Facebook delete failed: ${JSON.stringify(data)}`); + } + return true; +} + +/** + * Fetch engagement metrics pentru un post (likes/comments/shares). + * Folosit periodic de scheduler ca să actualizăm tabela cu numere live. + */ +export async function getFacebookPostEngagement( + postId: string, +): Promise { + const token = getPageToken(); + const fields = 'likes.summary(true),comments.summary(true),shares,reactions.summary(true)'; + const res = await fetch( + `${BASE_URL}/${postId}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`, + { + method: 'GET', + signal: AbortSignal.timeout(15_000), + }, + ); + const data = await res.json() as { + likes?: { summary?: { total_count?: number } }; + comments?: { summary?: { total_count?: number } }; + shares?: { count?: number }; + reactions?: { summary?: { total_count?: number } }; + error?: { message: string }; + }; + if (data.error) throw new Error(data.error.message); + return { + likes: data.likes?.summary?.total_count || 0, + comments: data.comments?.summary?.total_count || 0, + shares: data.shares?.count || 0, + reactions_total: data.reactions?.summary?.total_count || 0, + }; +} + +/** + * Verifică validitate Page Token (debug — folosit la /health/all). + */ +export async function debugFacebookToken(): Promise<{ + is_valid: boolean; + expires_at: number; + scopes: string[]; + profile_id?: string; + error?: string; +}> { + try { + const token = getPageToken(); + const appId = process.env.FACEBOOK_APP_ID; + const appSecret = process.env.FACEBOOK_APP_SECRET; + if (!appId || !appSecret) { + return { is_valid: false, expires_at: 0, scopes: [], error: 'APP_ID or APP_SECRET not set' }; + } + const accessToken = `${appId}|${appSecret}`; + const res = await fetch( + `${BASE_URL}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(accessToken)}`, + { signal: AbortSignal.timeout(5_000) }, + ); + const json = await res.json() as { data?: { + is_valid: boolean; + expires_at: number; + scopes: string[]; + profile_id?: string; + }}; + const d = json.data; + return { + is_valid: d?.is_valid || false, + expires_at: d?.expires_at || 0, + scopes: d?.scopes || [], + profile_id: d?.profile_id, + }; + } catch (e) { + return { is_valid: false, expires_at: 0, scopes: [], error: (e as Error).message }; + } +} + +/** + * Auto-generate draft message dintr-o sesiune de analiză. + * Folosit la endpoint /api/admin/social/generate-from-session/:session_id. + */ +export function generateDraftFromAnalysisSession(session: { + input_text?: string | null; + input_url?: string | null; + input_type?: string; + risk_score?: number | null; + risk_category?: string | null; + verdict?: { explanation_ro?: string; explanation_en?: string } | null; +}): string { + const score = session.risk_score ?? 0; + const category = session.risk_category || 'NECUNOSCUT'; + + const emoji = + score >= 80 ? '🚨' : + score >= 60 ? '⚠️' : + score >= 40 ? '🔍' : + '✅'; + + const verdictRo = session.verdict?.explanation_ro + || (category === 'DISINFORMATION' ? 'Conținut clasificat ca dezinformare.' + : category === 'QUESTIONABLE' ? 'Conținut chestionabil identificat.' + : category === 'UNCERTAIN' ? 'Conținut cu credibilitate incertă.' + : 'Conținut analizat.'); + + // Trim explanation to ~400 chars for FB readability + const explanation = verdictRo.length > 400 + ? verdictRo.slice(0, 400).trim() + '...' + : verdictRo; + + // Snippet din input + const inputPreview = session.input_text + ? `"${session.input_text.slice(0, 150).trim()}${session.input_text.length > 150 ? '...' : ''}"` + : session.input_url + ? `🔗 ${session.input_url}` + : ''; + + return [ + `${emoji} Alertă dezinformare — DiDi`, + '', + inputPreview ? `Conținut analizat:\n${inputPreview}` : '', + '', + `📊 Scor risc: ${score}/100 (${category})`, + '', + explanation, + '', + '🔍 Analiza completă prin platforma DiDi — detecție automată tehnici de manipulare, AI-generated content, verificare claims și evaluare surse.', + '', + '#DiDi #Dezinformare #FactChecking #AI #Clossers', + ].filter(Boolean).join('\n'); +} diff --git a/backend/services/orchestration-layer/didiFramework/src/types/index.ts b/backend/services/orchestration-layer/didiFramework/src/types/index.ts new file mode 100644 index 0000000..8e53267 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/types/index.ts @@ -0,0 +1,135 @@ +export interface Dimension { + dimension_id: number; + dimension_code: string; + dimension_name: string; + description: string; + weight: number; + created_date: string; + updated_date: string; +} + +export interface Technique { + technique_id: number; + subdimension_id: number; + technique_key: string; + technique_name: string; + severity: number; + confidence: number; + detectability: number; + created_date: string; + updated_date: string; +} + +export interface TechniqueIndicator { + technique_id: number; + indicator_id: number; + indicator_name: string; + description: string; + max_intensity: number; +} + +export interface VerdictCategory { + verdict_category_id: number; + verdict_category_code: string; + description: string; + start_range: number; + end_range: number; + verdict_category_color: string; +} + +export interface RiskMapping { + risk_mapping_id: number; + risk_mapping: string; + risk_level: number; + start_range: number; + end_range: number; + risk_color: string; +} + +export interface SourceType { + source_type_id: number; + source_type: string; + description: string; + default_credibility_score: number; +} + +export interface Platform { + platform_id: number; + platform_code: string; + platform_name: string; + description: string; + default_trust_score: number; +} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + count?: number; +} + +// NEW TYPES FOR EXTENDED API + +export interface Subdimension { + subdimension_id: number; + dimension_id: number; + subdimension_name: string; + subdimension_code: string; + description: string; +} + +export interface TechniqueIndicator { + technique_id: number; + indicator_id: number; + indicator_name: string; + description: string; + max_intensity: number; + parameter_id: number; +} + +export interface TechniqueValidationRule { + technique_valid_rule_id: number; + technique_id: number; + rule_name: string; + rule_value: string; + description: string; + parameter_id: number; +} + +export interface ComponentWeight { + component_weight_id: number; + component_name: string; + component_weight: number; + description: string; + parameter_id: number; +} + +export interface WeightScenario { + scenario_id: number; + scenario_name: string; + manipulation: number; + claims: number; + source: number; + ai: number; + context: number; + notes: string; + parameter_id: number; +} + +export interface Multiplier { + multiplier_id: number; + multiplier_type: number; + multiplier_name: string; + description: string; + multiplier: number; + parameter_id: number; +} + +export interface SeverityAssessment { + severity_id: string; + severity_category: string; + start_range: number; + end_range: number; + recomended_action: string; + parameter_id: number; +} \ No newline at end of file diff --git a/backend/services/orchestration-layer/didiFramework/src/utils/crud-factory.ts b/backend/services/orchestration-layer/didiFramework/src/utils/crud-factory.ts new file mode 100644 index 0000000..8c29d0f --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/utils/crud-factory.ts @@ -0,0 +1,458 @@ +/** + * CRUD Factory Utility + * + * Creates standardized CRUD endpoints for any table with: + * - Safe delete with dependency checking + * - Parameter table integration + * - Consistent response format + * - Proper error handling + */ + +import { Router, Request, Response } from 'express'; +import { query, queryOne, transaction } from '../config/database'; +import { checkDependencies, safeDelete, getDependencySummary } from './dependency-checker'; +import { PoolClient } from 'pg'; +import { ApiResponse } from '../types'; + +// Configuration for a CRUD endpoint +export interface CrudConfig { + tableName: string; + primaryKey: string; + displayName: string; + parameterType?: number; // If set, creates parameter entries + columns: { + name: string; + required: boolean; + type: 'string' | 'number' | 'boolean' | 'date'; + }[]; + orderBy?: string; + selectColumns?: string; // Custom SELECT columns (for column name fixes like subdmiension_name) +} + +/** + * Helper to get next ID for a table + */ +async function getNextId(client: PoolClient, tableName: string, idColumn: string): Promise { + const result = await client.query( + `SELECT COALESCE(MAX(${idColumn}), 0) + 1 as next_id FROM ${tableName}` + ); + return result.rows[0].next_id; +} + +/** + * Helper to create a parameter entry + */ +async function createParameter(client: PoolClient, parameterType: number): Promise { + const maxResult = await client.query('SELECT COALESCE(MAX(parameter_id), 0) + 1 as next_id FROM parameter'); + const nextParamId = maxResult.rows[0].next_id; + + await client.query(` + INSERT INTO parameter (parameter_id, parameter_type, valid_from, valid_to, created_date, updated_date) + VALUES ($1, $2, '2026-01-01', '2999-01-01', CURRENT_DATE, CURRENT_DATE) + `, [nextParamId, parameterType]); + + return nextParamId; +} + +/** + * Build SELECT query + */ +function buildSelectQuery(config: CrudConfig, whereClause?: string): string { + const columns = config.selectColumns || '*'; + let sql = `SELECT ${columns} FROM ${config.tableName}`; + if (whereClause) { + sql += ` WHERE ${whereClause}`; + } + if (config.orderBy) { + sql += ` ORDER BY ${config.orderBy}`; + } + return sql; +} + +/** + * Build INSERT query + */ +function buildInsertQuery(config: CrudConfig, hasParameterId: boolean): { + columns: string[]; + placeholders: string[]; +} { + const columns = config.columns.map(c => c.name); + if (hasParameterId) { + columns.push('parameter_id'); + } + + const placeholders = columns.map((_, i) => `$${i + 1}`); + + return { columns, placeholders }; +} + +/** + * Build UPDATE query + */ +function buildUpdateQuery(config: CrudConfig): string { + const setClauses = config.columns.map((col, i) => `${col.name} = $${i + 1}`); + // Add updated_date if table has it + setClauses.push('updated_date = CURRENT_DATE'); + return setClauses.join(', '); +} + +/** + * Extract values from request body based on config + */ +function extractValues(body: any, config: CrudConfig): any[] { + return config.columns.map(col => { + const value = body[col.name]; + if (col.required && (value === undefined || value === null)) { + throw new Error(`Câmpul '${col.name}' este obligatoriu`); + } + return value; + }); +} + +/** + * Validate required fields + */ +function validateRequiredFields(body: any, config: CrudConfig): string | null { + const missing = config.columns + .filter(col => col.required && (body[col.name] === undefined || body[col.name] === null)) + .map(col => col.name); + + if (missing.length > 0) { + return `Câmpuri obligatorii lipsă: ${missing.join(', ')}`; + } + return null; +} + +/** + * Create CRUD router for a table + */ +export function createCrudRouter(config: CrudConfig): Router { + const router = Router(); + const { tableName, primaryKey, displayName, parameterType, columns, orderBy } = config; + + // GET all + router.get('/', async (req: Request, res: Response) => { + try { + const sql = buildSelectQuery(config); + const data = await query(sql); + + res.json({ + success: true, + data, + count: data.length + } as ApiResponse); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + } as ApiResponse); + } + }); + + // GET by ID + router.get('/:id', async (req: Request, res: Response) => { + try { + const sql = buildSelectQuery(config, `${primaryKey} = $1`); + const record = await queryOne(sql, [req.params.id]); + + if (!record) { + return res.status(404).json({ + success: false, + error: `${displayName} nu a fost găsit(ă)` + } as ApiResponse); + } + + res.json({ + success: true, + data: record + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + } as ApiResponse); + } + }); + + // GET dependency check for a record + router.get('/:id/dependencies', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // First check if record exists + const record = await queryOne( + `SELECT ${primaryKey} FROM ${tableName} WHERE ${primaryKey} = $1`, + [id] + ); + + if (!record) { + return res.status(404).json({ + success: false, + error: `${displayName} nu a fost găsit(ă)` + }); + } + + const depCheck = await checkDependencies(tableName, primaryKey, id); + + res.json({ + success: true, + data: { + id, + ...depCheck, + tableSummary: getDependencySummary(tableName) + } + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + // POST create + router.post('/', async (req: Request, res: Response) => { + try { + // Validate required fields + const validationError = validateRequiredFields(req.body, config); + if (validationError) { + return res.status(400).json({ + success: false, + error: validationError + }); + } + + const result = await transaction(async (client) => { + // Get next ID + const nextId = await getNextId(client, tableName, primaryKey); + + // Create parameter if needed + let parameterId: number | null = null; + if (parameterType) { + parameterId = await createParameter(client, parameterType); + } + + // Build insert + const values = extractValues(req.body, config); + const allValues = [nextId, ...values]; + if (parameterId) { + allValues.push(parameterId); + } + + const columnNames = [primaryKey, ...columns.map(c => c.name)]; + if (parameterId) { + columnNames.push('parameter_id'); + } + + const placeholders = columnNames.map((_, i) => `$${i + 1}`); + + const insertSql = ` + INSERT INTO ${tableName} (${columnNames.join(', ')}) + VALUES (${placeholders.join(', ')}) + RETURNING * + `; + + const insertResult = await client.query(insertSql, allValues); + return insertResult.rows[0]; + }); + + res.status(201).json({ + success: true, + data: result, + message: `${displayName} a fost creat(ă) cu succes` + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + // PUT update + router.put('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + + // Check if record exists + const existing = await queryOne( + `SELECT ${primaryKey} FROM ${tableName} WHERE ${primaryKey} = $1`, + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: `${displayName} nu a fost găsit(ă)` + }); + } + + // Build dynamic update (only update provided fields) + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + for (const col of columns) { + if (req.body[col.name] !== undefined) { + updates.push(`${col.name} = $${paramIndex++}`); + values.push(req.body[col.name]); + } + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: 'Nu există câmpuri de actualizat' + }); + } + + // Add updated_date + updates.push(`updated_date = CURRENT_DATE`); + + // Add ID for WHERE clause + values.push(id); + + const updateSql = ` + UPDATE ${tableName} + SET ${updates.join(', ')} + WHERE ${primaryKey} = $${paramIndex} + RETURNING * + `; + + const result = await queryOne(updateSql, values); + + res.json({ + success: true, + data: result, + message: `${displayName} a fost actualizat(ă) cu succes` + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + // DELETE with safety check + router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params.id; + const force = req.query.force === 'true'; + + // Check if record exists + const existing = await queryOne( + `SELECT ${primaryKey} FROM ${tableName} WHERE ${primaryKey} = $1`, + [id] + ); + + if (!existing) { + return res.status(404).json({ + success: false, + error: `${displayName} nu a fost găsit(ă)` + }); + } + + // Use safe delete + const deleteResult = await safeDelete(tableName, primaryKey, id, force); + + if (!deleteResult.success) { + return res.status(409).json({ + success: false, + error: deleteResult.message, + canDelete: false, + dependencies: deleteResult.dependencyDetails?.dependencies || [], + hint: 'Folosiți ?force=true pentru a forța ștergerea (va eșua dacă există constrângeri FK)' + }); + } + + res.json({ + success: true, + message: `${displayName} a fost șters(ă) cu succes`, + deleted: true + }); + } catch (error: any) { + // Handle FK constraint violation + if (error.code === '23503') { + return res.status(409).json({ + success: false, + error: 'Nu se poate șterge: există înregistrări dependente', + canDelete: false + }); + } + + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + return router; +} + +/** + * Create a simple read-only router (for lookup tables that shouldn't be modified via API) + */ +export function createReadOnlyRouter(config: Pick): Router { + const router = Router(); + const { tableName, primaryKey, displayName, orderBy, selectColumns } = config; + + // GET all + router.get('/', async (req: Request, res: Response) => { + try { + const columns = selectColumns || '*'; + let sql = `SELECT ${columns} FROM ${tableName}`; + if (orderBy) { + sql += ` ORDER BY ${orderBy}`; + } + + const data = await query(sql); + res.json({ + success: true, + data, + count: data.length + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + // GET by ID + router.get('/:id', async (req: Request, res: Response) => { + try { + const columns = selectColumns || '*'; + const record = await queryOne( + `SELECT ${columns} FROM ${tableName} WHERE ${primaryKey} = $1`, + [req.params.id] + ); + + if (!record) { + return res.status(404).json({ + success: false, + error: `${displayName} nu a fost găsit(ă)` + }); + } + + res.json({ + success: true, + data: record + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Eroare necunoscută' + }); + } + }); + + return router; +} + +export default { + createCrudRouter, + createReadOnlyRouter +}; diff --git a/backend/services/orchestration-layer/didiFramework/src/utils/dependency-checker.ts b/backend/services/orchestration-layer/didiFramework/src/utils/dependency-checker.ts new file mode 100644 index 0000000..f50a121 --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/src/utils/dependency-checker.ts @@ -0,0 +1,377 @@ +/** + * Dependency Checker Utility + * + * Verifies if a record can be safely deleted by checking for child references. + * Provides warnings and prevents accidental deletion of records with dependencies. + */ + +import { query } from '../config/database'; + +// Types +export interface DependencyRule { + childTable: string; + childColumn: string; + displayName: string; +} + +export interface DependencyCheckResult { + canDelete: boolean; + hasChildren: boolean; + dependencies: { + table: string; + displayName: string; + count: number; + }[]; + totalChildren: number; + warning?: string; +} + +export interface DeleteResult { + success: boolean; + deleted: boolean; + message: string; + hadDependencies?: boolean; + dependencyDetails?: DependencyCheckResult; +} + +/** + * Dependency map for all tables that have child references + * Key: parent table name + * Value: array of child table references + */ +const DEPENDENCY_MAP: Record = { + // Analysis Core + 'dimension': [{ + childTable: 'subdimension', + childColumn: 'dimension_id', + displayName: 'Subdimensiuni' + }], + + 'subdimension': [{ + childTable: 'technique', + childColumn: 'subdimension_id', + displayName: 'Tehnici' + }], + + 'technique': [ + { + childTable: 'technique_indicator', + childColumn: 'technique_id', + displayName: 'Indicatori' + }, + { + childTable: 'technique_validation_rule', + childColumn: 'technique_id', + displayName: 'Reguli de validare' + } + ], + + // Source Assessment + 'platform_modifier': [{ + childTable: 'platform', + childColumn: 'platform_modifier_id', + displayName: 'Platforme' + }], + + 'source_type': [{ + childTable: 'domain_attribute', + childColumn: 'source_type_id', + displayName: 'Atribute domeniu' + }], + + 'source_credibility': [{ + childTable: 'domain_attribute', + childColumn: 'source_credibility_id', + displayName: 'Atribute domeniu' + }], + + 'domain_age_score': [{ + childTable: 'domain_attribute', + childColumn: 'domain_age_score', + displayName: 'Atribute domeniu' + }], + + 'domain_risk_level': [{ + childTable: 'domain_attribute', + childColumn: 'domain_risk_level_id', + displayName: 'Atribute domeniu' + }], + + 'domain_red_flag': [{ + childTable: 'domain_attribute', + childColumn: 'domain_red_flag_id', + displayName: 'Atribute domeniu' + }], + + 'author_classification': [{ + childTable: 'author', + childColumn: 'author_classification_id', + displayName: 'Autori' + }], + + 'author_credibility': [{ + childTable: 'author', + childColumn: 'author_credibility_id', + displayName: 'Autori' + }], + + // Source hierarchy + 'source': [ + { + childTable: 'platform', + childColumn: 'platform_id', + displayName: 'Platforme' + }, + { + childTable: 'domain', + childColumn: 'domain_id', + displayName: 'Domenii' + }, + { + childTable: 'author', + childColumn: 'author_id', + displayName: 'Autori' + } + ], + + 'domain': [{ + childTable: 'domain_attribute', + childColumn: 'domain_id', + displayName: 'Atribute domeniu' + }] +}; + +/** + * Tables that can be deleted without any child checks + */ +const LEAF_TABLES = new Set([ + 'technique_indicator', + 'technique_validation_rule', + 'platform', + 'domain_attribute', + 'author', + 'claim', + 'claim_type', + 'confidence', + 'interpretation', + 'verdict_category', + 'risk_mapping', + 'severity_assessment', + 'component_weight', + 'weight_scenario', + 'multiplier', + 'source_assessment', + 'notification', + 'email_server' +]); + +/** + * Check if a table has dependency rules defined + */ +export function hasDependencyRules(tableName: string): boolean { + return tableName in DEPENDENCY_MAP; +} + +/** + * Check if a table is a leaf table (no children possible) + */ +export function isLeafTable(tableName: string): boolean { + return LEAF_TABLES.has(tableName); +} + +/** + * Get dependency rules for a table + */ +export function getDependencyRules(tableName: string): DependencyRule[] { + return DEPENDENCY_MAP[tableName] || []; +} + +/** + * Check dependencies for a specific record before deletion + * + * @param tableName - The table name to check + * @param idColumn - The primary key column name + * @param idValue - The primary key value + * @returns DependencyCheckResult with details about children + */ +export async function checkDependencies( + tableName: string, + idColumn: string, + idValue: number | string +): Promise { + // If it's a leaf table, it can always be deleted + if (isLeafTable(tableName)) { + return { + canDelete: true, + hasChildren: false, + dependencies: [], + totalChildren: 0 + }; + } + + const rules = getDependencyRules(tableName); + + // If no rules defined, assume it can be deleted + if (rules.length === 0) { + return { + canDelete: true, + hasChildren: false, + dependencies: [], + totalChildren: 0 + }; + } + + const dependencies: { table: string; displayName: string; count: number }[] = []; + let totalChildren = 0; + + // Check each dependency rule + for (const rule of rules) { + const countQuery = `SELECT COUNT(*) as count FROM ${rule.childTable} WHERE ${rule.childColumn} = $1`; + const result = await query<{ count: string }>(countQuery, [idValue]); + const count = parseInt(result[0]?.count || '0', 10); + + if (count > 0) { + dependencies.push({ + table: rule.childTable, + displayName: rule.displayName, + count + }); + totalChildren += count; + } + } + + const hasChildren = totalChildren > 0; + + return { + canDelete: !hasChildren, + hasChildren, + dependencies, + totalChildren, + warning: hasChildren + ? `Nu se poate șterge: există ${totalChildren} înregistrări dependente (${dependencies.map(d => `${d.count} ${d.displayName}`).join(', ')})` + : undefined + }; +} + +/** + * Safe delete function - checks dependencies before deleting + * + * @param tableName - Table to delete from + * @param idColumn - Primary key column name + * @param idValue - Primary key value + * @param force - If true, attempts to delete even with children (will fail at DB level) + * @returns DeleteResult with success status and details + */ +export async function safeDelete( + tableName: string, + idColumn: string, + idValue: number | string, + force: boolean = false +): Promise { + // First check dependencies + const depCheck = await checkDependencies(tableName, idColumn, idValue); + + if (depCheck.hasChildren && !force) { + return { + success: false, + deleted: false, + message: depCheck.warning || 'Record has dependencies and cannot be deleted', + hadDependencies: true, + dependencyDetails: depCheck + }; + } + + // Attempt to delete + try { + const deleteQuery = `DELETE FROM ${tableName} WHERE ${idColumn} = $1 RETURNING *`; + const result = await query(deleteQuery, [idValue]); + + if (result.length === 0) { + return { + success: false, + deleted: false, + message: 'Înregistrarea nu a fost găsită' + }; + } + + return { + success: true, + deleted: true, + message: 'Înregistrarea a fost ștearsă cu succes' + }; + } catch (error: any) { + // Handle FK violation errors from PostgreSQL + if (error.code === '23503') { // foreign_key_violation + return { + success: false, + deleted: false, + message: 'Nu se poate șterge: există înregistrări dependente în baza de date', + hadDependencies: true + }; + } + + throw error; + } +} + +/** + * Get a summary of all dependencies for a table (useful for UI display) + */ +export function getDependencySummary(tableName: string): { + hasRules: boolean; + isLeaf: boolean; + rules: DependencyRule[]; + canHaveChildren: boolean; +} { + const isLeaf = isLeafTable(tableName); + const rules = getDependencyRules(tableName); + + return { + hasRules: rules.length > 0, + isLeaf, + rules, + canHaveChildren: !isLeaf && rules.length > 0 + }; +} + +/** + * Batch check dependencies for multiple records + */ +export async function batchCheckDependencies( + tableName: string, + idColumn: string, + idValues: (number | string)[] +): Promise> { + const results = new Map(); + + // For leaf tables, all can be deleted + if (isLeafTable(tableName)) { + for (const id of idValues) { + results.set(id, { + canDelete: true, + hasChildren: false, + dependencies: [], + totalChildren: 0 + }); + } + return results; + } + + // Check each record + for (const id of idValues) { + const check = await checkDependencies(tableName, idColumn, id); + results.set(id, check); + } + + return results; +} + +export default { + checkDependencies, + safeDelete, + hasDependencyRules, + isLeafTable, + getDependencyRules, + getDependencySummary, + batchCheckDependencies +}; diff --git a/backend/services/orchestration-layer/didiFramework/tsconfig.json b/backend/services/orchestration-layer/didiFramework/tsconfig.json new file mode 100644 index 0000000..2a13d3a --- /dev/null +++ b/backend/services/orchestration-layer/didiFramework/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/backend/services/orchestration-layer/scripts/.cluster-credentials.env.example b/backend/services/orchestration-layer/scripts/.cluster-credentials.env.example new file mode 100644 index 0000000..8aeb1a6 --- /dev/null +++ b/backend/services/orchestration-layer/scripts/.cluster-credentials.env.example @@ -0,0 +1,12 @@ +# Cluster credentials for redis-switch.sh +# +# Copy this to `.cluster-credentials.env` (gitignored) and fill in real values: +# cp .cluster-credentials.env.example .cluster-credentials.env +# $EDITOR .cluster-credentials.env +# +# The switch script sources this file automatically when running +# `./redis-switch.sh cluster`. Shell env vars (DIDI_CLUSTER_*) override +# this file. + +# Shared across Redis + RabbitMQ on the HA cluster +DIDI_CLUSTER_PASSWORD=replace-me diff --git a/backend/services/orchestration-layer/scripts/minio-switch.sh b/backend/services/orchestration-layer/scripts/minio-switch.sh new file mode 100644 index 0000000..3e3c2f7 --- /dev/null +++ b/backend/services/orchestration-layer/scripts/minio-switch.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# minio-switch.sh — swap MinIO target between local container and CAI cluster +# +# Usage: +# ./minio-switch.sh cluster # → cluster (didi-prod bucket) +# ./minio-switch.sh local # → staging-dataLayer-minio (multi-bucket) +# ./minio-switch.sh status # show current +# +# Like redis-switch.sh, this rewrites the MINIO_* block in agent-v3 + framework +# .env files and restarts both stacks. Cluster credentials live in +# .cluster-credentials.env (gitignored) — same file used by redis-switch.sh. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MONOREPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +AGENT_DIR="${MONOREPO_ROOT}/backend/services/orchestration-layer/agent-v3" +FW_DIR="${MONOREPO_ROOT}/backend/services/orchestration-layer/didiFramework" + +# Load shared cluster credentials (DIDI_MINIO_ACCESS_KEY etc) if present +if [ -f "${SCRIPT_DIR}/.cluster-credentials.env" ]; then + set -a; source "${SCRIPT_DIR}/.cluster-credentials.env"; set +a +fi + +# ------------------------------------------------------------------------- +# Cluster (external S3 cluster live since 2026-04-23) +# ------------------------------------------------------------------------- +# NOTE: Using VIP IP directly (10.11.10.128) instead of DNS — +# Docker containers don't resolve internal pfSense DNS (they use systemd-resolved +# at 127.0.0.53 which doesn't see minio-host zone). VIP IP is stable. +CLUSTER_ENDPOINT="10.11.10.128" +CLUSTER_PORT="9000" +CLUSTER_USE_SSL="false" +CLUSTER_BUCKET="didi-prod" +# Credentials sourced from .cluster-credentials.env or shell env +CLUSTER_ACCESS_KEY="${DIDI_MINIO_ACCESS_KEY:-didi-prod}" +CLUSTER_SECRET_KEY="${DIDI_MINIO_SECRET_KEY:-}" + +# ------------------------------------------------------------------------- +# Local (legacy multi-bucket) +# ------------------------------------------------------------------------- +LOCAL_ENDPOINT="staging-dataLayer-minio" +LOCAL_PORT="9000" +LOCAL_USE_SSL="false" +LOCAL_BUCKET="" # empty = legacy multi-bucket mode +LOCAL_ACCESS_KEY="minioadmin" +LOCAL_SECRET_KEY="minio123" + +MODE="${1:-status}" + +write_minio_block() { + local env_file="$1" endpoint="$2" port="$3" ssl="$4" bucket="$5" ak="$6" sk="$7" + [ ! -f "$env_file" ] && touch "$env_file" + local tmp="${env_file}.tmp" + grep -vE '^(# MinIO|MINIO_ENDPOINT=|MINIO_PORT=|MINIO_USE_SSL=|MINIO_BUCKET=|MINIO_ACCESS_KEY=|MINIO_SECRET_KEY=|MINIO_PUBLIC_ENDPOINT=)' "$env_file" > "$tmp" || true + { + echo "" + echo "# MinIO (set by minio-switch.sh $(date -Iseconds))" + echo "MINIO_ENDPOINT=${endpoint}" + echo "MINIO_PORT=${port}" + echo "MINIO_USE_SSL=${ssl}" + echo "MINIO_BUCKET=${bucket}" + echo "MINIO_ACCESS_KEY=${ak}" + echo "MINIO_SECRET_KEY=${sk}" + } >> "$tmp" + mv "$tmp" "$env_file" + echo " ✓ MinIO env → ${env_file#${MONOREPO_ROOT}/}" +} + +read_env() { + local env="$1" key="$2" + [ -f "$env" ] || { echo ""; return; } + grep -E "^${key}=" "$env" 2>/dev/null | tail -1 | cut -d= -f2- || true +} + +restart_services() { + echo "→ Restarting didi-framework..." + (cd "$FW_DIR" && docker compose up -d --force-recreate 2>&1 | tail -3) + echo "→ Restarting agent-v3 stack..." + (cd "$AGENT_DIR" && docker compose up -d --force-recreate 2>&1 | tail -3) + echo "→ Waiting for services..." + for i in $(seq 1 30); do + if docker exec didi-framework wget -qO- http://127.0.0.1:3005/health > /dev/null 2>&1 \ + && curl -sf http://localhost:24803/api/v3/health > /dev/null 2>&1; then + echo " ✓ Both ready (${i}s)" + return + fi + sleep 1 + done + echo " ⚠ Timeout — check logs" +} + +case "$MODE" in + status) + env_file="${AGENT_DIR}/.env" + ep=$(read_env "$env_file" MINIO_ENDPOINT) + port=$(read_env "$env_file" MINIO_PORT) + bk=$(read_env "$env_file" MINIO_BUCKET) + ak=$(read_env "$env_file" MINIO_ACCESS_KEY) + echo "Current MinIO target (from agent-v3 .env):" + echo " endpoint: ${ep:-staging-dataLayer-minio}:${port:-9000}" + echo " bucket: ${bk:-(legacy multi-bucket)}" + echo " user: ${ak:-minioadmin}" + echo "" + echo "Local container:" + docker ps -a --filter "name=staging-dataLayer-minio" --format " {{.Names}} | {{.Status}}" | head -1 + ;; + + cluster) + if [ -z "$CLUSTER_SECRET_KEY" ]; then + echo "✘ ERROR: cluster secret key not set." >&2 + echo " Set DIDI_MINIO_ACCESS_KEY + DIDI_MINIO_SECRET_KEY in" >&2 + echo " ${SCRIPT_DIR}/.cluster-credentials.env or shell env" >&2 + exit 1 + fi + echo "═══ Switching to CAI cluster ${CLUSTER_ENDPOINT} (bucket=${CLUSTER_BUCKET}) ═══" + write_minio_block "${AGENT_DIR}/.env" "$CLUSTER_ENDPOINT" "$CLUSTER_PORT" "$CLUSTER_USE_SSL" "$CLUSTER_BUCKET" "$CLUSTER_ACCESS_KEY" "$CLUSTER_SECRET_KEY" + write_minio_block "${FW_DIR}/.env" "$CLUSTER_ENDPOINT" "$CLUSTER_PORT" "$CLUSTER_USE_SSL" "$CLUSTER_BUCKET" "$CLUSTER_ACCESS_KEY" "$CLUSTER_SECRET_KEY" + restart_services + echo "✓ Cluster active. Single-bucket mode (MINIO_BUCKET=${CLUSTER_BUCKET})." + ;; + + local) + echo "═══ Switching to LOCAL staging-dataLayer-minio (legacy multi-bucket) ═══" + if ! docker ps --filter "name=staging-dataLayer-minio" --filter "status=running" --format "{{.Names}}" | grep -q .; then + echo "→ Starting local minio..." + docker start staging-dataLayer-minio 2>&1 | tail -2 + sleep 3 + fi + write_minio_block "${AGENT_DIR}/.env" "$LOCAL_ENDPOINT" "$LOCAL_PORT" "$LOCAL_USE_SSL" "$LOCAL_BUCKET" "$LOCAL_ACCESS_KEY" "$LOCAL_SECRET_KEY" + write_minio_block "${FW_DIR}/.env" "$LOCAL_ENDPOINT" "$LOCAL_PORT" "$LOCAL_USE_SSL" "$LOCAL_BUCKET" "$LOCAL_ACCESS_KEY" "$LOCAL_SECRET_KEY" + restart_services + echo "✓ Local active (multi-bucket mode)." + ;; + + *) + echo "Usage: $0 {cluster|local|status}" + exit 1 + ;; +esac diff --git a/backend/services/orchestration-layer/scripts/redis-switch.sh b/backend/services/orchestration-layer/scripts/redis-switch.sh new file mode 100644 index 0000000..de4e018 --- /dev/null +++ b/backend/services/orchestration-layer/scripts/redis-switch.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# redis-switch.sh — swap between local containers and HA cluster +# (for Redis and RabbitMQ) +# +# Usage: +# ./redis-switch.sh cluster # both to cluster (production) +# ./redis-switch.sh local # both to local containers (fallback/dev) +# ./redis-switch.sh status # show current active targets +# ./redis-switch.sh cluster redis # only Redis to cluster +# ./redis-switch.sh local rabbit # only RabbitMQ to local +# +# What it does: +# 1. Writes *_HOST/PORT/USER/PASS/etc env vars to .env files +# 2. For 'local': also starts the corresponding local container if stopped +# 3. Restarts didi-framework + agent-v3 stack (both need env vars) +# 4. Auto-bootstrap will populate Redis; RabbitMQ topology creates itself +# +# Safe to flip back and forth — data stays on both instances. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MONOREPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +AGENT_DIR="${MONOREPO_ROOT}/backend/services/orchestration-layer/agent-v3" +FW_DIR="${MONOREPO_ROOT}/backend/services/orchestration-layer/didiFramework" +PROD_DIR="${MONOREPO_ROOT}/backend/production" +DATA_DIR="${MONOREPO_ROOT}/backend/services/data-layer" + +# ------------------------------------------------------------------------- +# Cluster credentials — loaded from sidecar file or shell env +# ------------------------------------------------------------------------- +# Shell env (DIDI_CLUSTER_PASSWORD) takes precedence. +# Otherwise, load from `.cluster-credentials.env` in this directory. +if [ -z "${DIDI_CLUSTER_PASSWORD:-}" ] && [ -f "${SCRIPT_DIR}/.cluster-credentials.env" ]; then + # shellcheck disable=SC1090 + set -a; source "${SCRIPT_DIR}/.cluster-credentials.env"; set +a +fi + +CLUSTER_SHARED_PASSWORD="${DIDI_CLUSTER_PASSWORD:-}" + +# Redis cluster +CLUSTER_REDIS_HOST="10.11.50.100" +CLUSTER_REDIS_PORT="16379" +CLUSTER_REDIS_USER="didi" +CLUSTER_REDIS_PASSWORD="${CLUSTER_SHARED_PASSWORD}" +CLUSTER_REDIS_DB="0" + +# RabbitMQ cluster +CLUSTER_RABBIT_HOST="10.11.50.100" +CLUSTER_RABBIT_PORT="16672" +CLUSTER_RABBIT_USER="didi" +CLUSTER_RABBIT_PASS="${CLUSTER_SHARED_PASSWORD}" +CLUSTER_RABBIT_VHOST="/didi" + +# ------------------------------------------------------------------------- +# Local config (fallback) +# ------------------------------------------------------------------------- +LOCAL_REDIS_HOST="didi-cache" +LOCAL_REDIS_PORT="6379" +LOCAL_REDIS_USER="" +LOCAL_REDIS_PASSWORD="redis123" +LOCAL_REDIS_DB="0" + +LOCAL_RABBIT_HOST="staging-dataLayer-rabbitmq" +LOCAL_RABBIT_PORT="5672" +LOCAL_RABBIT_USER="admin" +LOCAL_RABBIT_PASS="rabbitmq123" +LOCAL_RABBIT_VHOST="/" + +MODE="${1:-status}" +TARGET="${2:-both}" # both | redis | rabbit + +# ------------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------------- +write_redis_block() { + local env_file="$1" host="$2" port="$3" user="$4" pass="$5" db="$6" + [ ! -f "$env_file" ] && touch "$env_file" + local tmp="${env_file}.tmp" + grep -vE '^(# Redis|REDIS_HOST=|REDIS_PORT=|REDIS_USERNAME=|REDIS_PASSWORD=|REDIS_DB=)' "$env_file" > "$tmp" || true + { + echo "" + echo "# Redis (set by redis-switch.sh $(date -Iseconds))" + echo "REDIS_HOST=${host}" + echo "REDIS_PORT=${port}" + echo "REDIS_USERNAME=${user}" + echo "REDIS_PASSWORD=${pass}" + echo "REDIS_DB=${db}" + } >> "$tmp" + mv "$tmp" "$env_file" + echo " ✓ Redis env → ${env_file#${MONOREPO_ROOT}/}" +} + +write_rabbit_block() { + local env_file="$1" host="$2" port="$3" user="$4" pass="$5" vhost="$6" + [ ! -f "$env_file" ] && touch "$env_file" + local tmp="${env_file}.tmp" + grep -vE '^(# RabbitMQ|RABBITMQ_HOST=|RABBITMQ_PORT=|RABBITMQ_USER=|RABBITMQ_PASS=|RABBITMQ_VHOST=)' "$env_file" > "$tmp" || true + { + echo "" + echo "# RabbitMQ (set by redis-switch.sh $(date -Iseconds))" + echo "RABBITMQ_HOST=${host}" + echo "RABBITMQ_PORT=${port}" + echo "RABBITMQ_USER=${user}" + echo "RABBITMQ_PASS=${pass}" + echo "RABBITMQ_VHOST=${vhost}" + } >> "$tmp" + mv "$tmp" "$env_file" + echo " ✓ RabbitMQ env → ${env_file#${MONOREPO_ROOT}/}" +} + +read_env() { + local env="$1" key="$2" + [ -f "$env" ] || { echo ""; return; } + grep -E "^${key}=" "$env" | tail -1 | cut -d= -f2- +} + +current_targets() { + local env="${AGENT_DIR}/.env" + local rh=$(read_env "$env" REDIS_HOST) + local rp=$(read_env "$env" REDIS_PORT) + local ru=$(read_env "$env" REDIS_USERNAME) + local qh=$(read_env "$env" RABBITMQ_HOST) + local qp=$(read_env "$env" RABBITMQ_PORT) + local qv=$(read_env "$env" RABBITMQ_VHOST) + echo " Redis → ${rh:-didi-cache}:${rp:-6379} (user: ${ru:-legacy})" + echo " RabbitMQ → ${qh:-staging-dataLayer-rabbitmq}:${qp:-5672}${qv:-/}" +} + +ensure_container_running() { + local name="$1" compose_dir="$2" service="$3" + if docker ps --filter "name=${name}" --filter "status=running" --format "{{.Names}}" | grep -q "${name}"; then + echo " → ${name} already running" + return + fi + if docker ps -a --filter "name=${name}" --format "{{.Names}}" | grep -q "${name}"; then + echo " → Starting stopped ${name}..." + docker start "${name}" > /dev/null + else + echo " → ${name} missing, recreating via compose..." + (cd "${compose_dir}" && docker compose up -d "${service}" 2>&1 | tail -3) + fi +} + +restart_services() { + echo "" + echo "→ Restarting didi-framework..." + (cd "$FW_DIR" && docker compose up -d --force-recreate 2>&1 | tail -3) + echo "→ Restarting agent-v3 stack (API + 12 workers)..." + (cd "$AGENT_DIR" && docker compose up -d --force-recreate 2>&1 | tail -3) + + echo "→ Waiting for services..." + for i in $(seq 1 30); do + if docker exec didi-framework wget -qO- http://127.0.0.1:3005/health > /dev/null 2>&1 \ + && curl -sf http://localhost:24803/api/v3/health > /dev/null 2>&1; then + echo " ✓ Both ready (${i}s)" + return + fi + sleep 1 + done + echo " ⚠ Timeout waiting for health — check logs" +} + +apply_redis_cluster() { + write_redis_block "${AGENT_DIR}/.env" "$CLUSTER_REDIS_HOST" "$CLUSTER_REDIS_PORT" "$CLUSTER_REDIS_USER" "$CLUSTER_REDIS_PASSWORD" "$CLUSTER_REDIS_DB" + write_redis_block "${FW_DIR}/.env" "$CLUSTER_REDIS_HOST" "$CLUSTER_REDIS_PORT" "$CLUSTER_REDIS_USER" "$CLUSTER_REDIS_PASSWORD" "$CLUSTER_REDIS_DB" +} + +apply_redis_local() { + ensure_container_running "didi-cache" "$PROD_DIR" "didi-cache" + write_redis_block "${AGENT_DIR}/.env" "$LOCAL_REDIS_HOST" "$LOCAL_REDIS_PORT" "$LOCAL_REDIS_USER" "$LOCAL_REDIS_PASSWORD" "$LOCAL_REDIS_DB" + write_redis_block "${FW_DIR}/.env" "$LOCAL_REDIS_HOST" "$LOCAL_REDIS_PORT" "$LOCAL_REDIS_USER" "$LOCAL_REDIS_PASSWORD" "$LOCAL_REDIS_DB" +} + +apply_rabbit_cluster() { + write_rabbit_block "${AGENT_DIR}/.env" "$CLUSTER_RABBIT_HOST" "$CLUSTER_RABBIT_PORT" "$CLUSTER_RABBIT_USER" "$CLUSTER_RABBIT_PASS" "$CLUSTER_RABBIT_VHOST" +} + +apply_rabbit_local() { + ensure_container_running "staging-dataLayer-rabbitmq" "$DATA_DIR" "staging-dataLayer-rabbitmq" + write_rabbit_block "${AGENT_DIR}/.env" "$LOCAL_RABBIT_HOST" "$LOCAL_RABBIT_PORT" "$LOCAL_RABBIT_USER" "$LOCAL_RABBIT_PASS" "$LOCAL_RABBIT_VHOST" +} + +# ------------------------------------------------------------------------- +# Commands +# ------------------------------------------------------------------------- +case "$MODE" in + status) + echo "Current active targets (from agent-v3 .env):" + current_targets + echo "" + echo "Local containers:" + docker ps -a --filter "name=didi-cache" --format " didi-cache | {{.Status}}" | head -1 + docker ps -a --filter "name=staging-dataLayer-rabbitmq" --format " staging-dataLayer-rabbitmq | {{.Status}}" | head -1 + echo "" + echo "Usage: $0 {cluster|local|status} [redis|rabbit|both]" + ;; + + cluster) + if [ -z "$CLUSTER_SHARED_PASSWORD" ]; then + echo "✘ ERROR: cluster password not set." >&2 + echo " Option A: export DIDI_CLUSTER_PASSWORD=..." >&2 + echo " Option B: cp ${SCRIPT_DIR}/.cluster-credentials.env.example ${SCRIPT_DIR}/.cluster-credentials.env && edit it" >&2 + exit 1 + fi + echo "═══ Switching to HA cluster (target: ${TARGET}) ═══" + case "$TARGET" in + both) apply_redis_cluster; apply_rabbit_cluster ;; + redis) apply_redis_cluster ;; + rabbit) apply_rabbit_cluster ;; + *) echo "Unknown target: $TARGET (use: redis, rabbit, both)"; exit 1 ;; + esac + restart_services + echo "" + echo "✓ Cluster active. Bootstrap will auto-populate Redis on startup." + ;; + + local) + echo "═══ Switching to LOCAL containers (target: ${TARGET}) ═══" + case "$TARGET" in + both) apply_redis_local; apply_rabbit_local ;; + redis) apply_redis_local ;; + rabbit) apply_rabbit_local ;; + *) echo "Unknown target: $TARGET (use: redis, rabbit, both)"; exit 1 ;; + esac + restart_services + echo "" + echo "✓ Local fallback active." + ;; + + *) + echo "Usage: $0 {cluster|local|status} [redis|rabbit|both]" + exit 1 + ;; +esac diff --git a/backend/teste livrare/ai tamper asignation.png b/backend/teste livrare/ai tamper asignation.png new file mode 100644 index 0000000..9cfe522 Binary files /dev/null and b/backend/teste livrare/ai tamper asignation.png differ diff --git a/backend/teste livrare/ai tamper parameters.png b/backend/teste livrare/ai tamper parameters.png new file mode 100644 index 0000000..1e01e68 Binary files /dev/null and b/backend/teste livrare/ai tamper parameters.png differ diff --git a/backend/teste livrare/analysis history.png b/backend/teste livrare/analysis history.png new file mode 100644 index 0000000..6e56266 Binary files /dev/null and b/backend/teste livrare/analysis history.png differ diff --git a/backend/teste livrare/analysis history2.png b/backend/teste livrare/analysis history2.png new file mode 100644 index 0000000..60e5db6 Binary files /dev/null and b/backend/teste livrare/analysis history2.png differ diff --git a/backend/teste livrare/analysis history3.png b/backend/teste livrare/analysis history3.png new file mode 100644 index 0000000..9d59068 Binary files /dev/null and b/backend/teste livrare/analysis history3.png differ diff --git a/backend/teste livrare/analysis history4.png b/backend/teste livrare/analysis history4.png new file mode 100644 index 0000000..b455064 Binary files /dev/null and b/backend/teste livrare/analysis history4.png differ diff --git a/backend/teste livrare/analysis history5.png b/backend/teste livrare/analysis history5.png new file mode 100644 index 0000000..8d50176 Binary files /dev/null and b/backend/teste livrare/analysis history5.png differ diff --git a/backend/teste livrare/analysis history6.png b/backend/teste livrare/analysis history6.png new file mode 100644 index 0000000..c8caa46 Binary files /dev/null and b/backend/teste livrare/analysis history6.png differ diff --git a/backend/teste livrare/analysis history7.png b/backend/teste livrare/analysis history7.png new file mode 100644 index 0000000..ce846b2 Binary files /dev/null and b/backend/teste livrare/analysis history7.png differ diff --git a/backend/teste livrare/categorii verdict.png b/backend/teste livrare/categorii verdict.png new file mode 100644 index 0000000..833de43 Binary files /dev/null and b/backend/teste livrare/categorii verdict.png differ diff --git a/backend/teste livrare/claim types.png b/backend/teste livrare/claim types.png new file mode 100644 index 0000000..038ab74 Binary files /dev/null and b/backend/teste livrare/claim types.png differ diff --git a/backend/teste livrare/claims asignation.png b/backend/teste livrare/claims asignation.png new file mode 100644 index 0000000..64c19ea Binary files /dev/null and b/backend/teste livrare/claims asignation.png differ diff --git a/backend/teste livrare/claims parameters.png b/backend/teste livrare/claims parameters.png new file mode 100644 index 0000000..0602045 Binary files /dev/null and b/backend/teste livrare/claims parameters.png differ diff --git a/backend/teste livrare/clasificari autor.png b/backend/teste livrare/clasificari autor.png new file mode 100644 index 0000000..64c5120 Binary files /dev/null and b/backend/teste livrare/clasificari autor.png differ diff --git a/backend/teste livrare/credibilitate autor.png b/backend/teste livrare/credibilitate autor.png new file mode 100644 index 0000000..3ecd346 Binary files /dev/null and b/backend/teste livrare/credibilitate autor.png differ diff --git a/backend/teste livrare/dashboard_1.png b/backend/teste livrare/dashboard_1.png new file mode 100644 index 0000000..a63d246 Binary files /dev/null and b/backend/teste livrare/dashboard_1.png differ diff --git a/backend/teste livrare/dashboard_2.png b/backend/teste livrare/dashboard_2.png new file mode 100644 index 0000000..5fd859d Binary files /dev/null and b/backend/teste livrare/dashboard_2.png differ diff --git a/backend/teste livrare/dashboard_3.png b/backend/teste livrare/dashboard_3.png new file mode 100644 index 0000000..4573f42 Binary files /dev/null and b/backend/teste livrare/dashboard_3.png differ diff --git a/backend/teste livrare/domain risk.png b/backend/teste livrare/domain risk.png new file mode 100644 index 0000000..a8c3847 Binary files /dev/null and b/backend/teste livrare/domain risk.png differ diff --git a/backend/teste livrare/eval severitate.png b/backend/teste livrare/eval severitate.png new file mode 100644 index 0000000..0d08f17 Binary files /dev/null and b/backend/teste livrare/eval severitate.png differ diff --git a/backend/teste livrare/final verdict input types profiles.png b/backend/teste livrare/final verdict input types profiles.png new file mode 100644 index 0000000..0fa1f35 Binary files /dev/null and b/backend/teste livrare/final verdict input types profiles.png differ diff --git a/backend/teste livrare/framework_!.png b/backend/teste livrare/framework_!.png new file mode 100644 index 0000000..7620def Binary files /dev/null and b/backend/teste livrare/framework_!.png differ diff --git a/backend/teste livrare/llm providers.png b/backend/teste livrare/llm providers.png new file mode 100644 index 0000000..c8e4c59 Binary files /dev/null and b/backend/teste livrare/llm providers.png differ diff --git a/backend/teste livrare/login keyckloak backend.png b/backend/teste livrare/login keyckloak backend.png new file mode 100644 index 0000000..2b9cc69 Binary files /dev/null and b/backend/teste livrare/login keyckloak backend.png differ diff --git a/backend/teste livrare/manipulation asignation.png b/backend/teste livrare/manipulation asignation.png new file mode 100644 index 0000000..f968190 Binary files /dev/null and b/backend/teste livrare/manipulation asignation.png differ diff --git a/backend/teste livrare/manipulation parameters.png b/backend/teste livrare/manipulation parameters.png new file mode 100644 index 0000000..1cda351 Binary files /dev/null and b/backend/teste livrare/manipulation parameters.png differ diff --git a/backend/teste livrare/manipulation_techniques.png b/backend/teste livrare/manipulation_techniques.png new file mode 100644 index 0000000..464adbf Binary files /dev/null and b/backend/teste livrare/manipulation_techniques.png differ diff --git a/backend/teste livrare/manipulation_validation.png b/backend/teste livrare/manipulation_validation.png new file mode 100644 index 0000000..511cd93 Binary files /dev/null and b/backend/teste livrare/manipulation_validation.png differ diff --git a/backend/teste livrare/manipulationtechniques indicators.png b/backend/teste livrare/manipulationtechniques indicators.png new file mode 100644 index 0000000..40fd13d Binary files /dev/null and b/backend/teste livrare/manipulationtechniques indicators.png differ diff --git a/backend/teste livrare/mapari risk.png b/backend/teste livrare/mapari risk.png new file mode 100644 index 0000000..9bc6f58 Binary files /dev/null and b/backend/teste livrare/mapari risk.png differ diff --git a/backend/teste livrare/mode3ration1.png b/backend/teste livrare/mode3ration1.png new file mode 100644 index 0000000..4406bc1 Binary files /dev/null and b/backend/teste livrare/mode3ration1.png differ diff --git a/backend/teste livrare/moderation2.png b/backend/teste livrare/moderation2.png new file mode 100644 index 0000000..bf80026 Binary files /dev/null and b/backend/teste livrare/moderation2.png differ diff --git a/backend/teste livrare/multiplicatori.png b/backend/teste livrare/multiplicatori.png new file mode 100644 index 0000000..a87c21f Binary files /dev/null and b/backend/teste livrare/multiplicatori.png differ diff --git a/backend/teste livrare/niveluri confidence claims.png b/backend/teste livrare/niveluri confidence claims.png new file mode 100644 index 0000000..d4fc8ac Binary files /dev/null and b/backend/teste livrare/niveluri confidence claims.png differ diff --git a/backend/teste livrare/niveluri interpretari.png b/backend/teste livrare/niveluri interpretari.png new file mode 100644 index 0000000..f23da21 Binary files /dev/null and b/backend/teste livrare/niveluri interpretari.png differ diff --git a/backend/teste livrare/pipelies2.png b/backend/teste livrare/pipelies2.png new file mode 100644 index 0000000..02c7ed3 Binary files /dev/null and b/backend/teste livrare/pipelies2.png differ diff --git a/backend/teste livrare/pipeline3.png b/backend/teste livrare/pipeline3.png new file mode 100644 index 0000000..92a81c2 Binary files /dev/null and b/backend/teste livrare/pipeline3.png differ diff --git a/backend/teste livrare/pipelines1.png b/backend/teste livrare/pipelines1.png new file mode 100644 index 0000000..9a887e9 Binary files /dev/null and b/backend/teste livrare/pipelines1.png differ diff --git a/backend/teste livrare/ponderi componente.png b/backend/teste livrare/ponderi componente.png new file mode 100644 index 0000000..83cee2b Binary files /dev/null and b/backend/teste livrare/ponderi componente.png differ diff --git a/backend/teste livrare/providers1.png b/backend/teste livrare/providers1.png new file mode 100644 index 0000000..c916e22 Binary files /dev/null and b/backend/teste livrare/providers1.png differ diff --git a/backend/teste livrare/providers2.png b/backend/teste livrare/providers2.png new file mode 100644 index 0000000..8f29f02 Binary files /dev/null and b/backend/teste livrare/providers2.png differ diff --git a/backend/teste livrare/queue1.png b/backend/teste livrare/queue1.png new file mode 100644 index 0000000..1fdc0e9 Binary files /dev/null and b/backend/teste livrare/queue1.png differ diff --git a/backend/teste livrare/queue2.png b/backend/teste livrare/queue2.png new file mode 100644 index 0000000..8764476 Binary files /dev/null and b/backend/teste livrare/queue2.png differ diff --git a/backend/teste livrare/queue3.png b/backend/teste livrare/queue3.png new file mode 100644 index 0000000..1806b6b Binary files /dev/null and b/backend/teste livrare/queue3.png differ diff --git a/backend/teste livrare/red flags domeniu.png b/backend/teste livrare/red flags domeniu.png new file mode 100644 index 0000000..175fc36 Binary files /dev/null and b/backend/teste livrare/red flags domeniu.png differ diff --git a/backend/teste livrare/scenarii pondere.png b/backend/teste livrare/scenarii pondere.png new file mode 100644 index 0000000..92f5cac Binary files /dev/null and b/backend/teste livrare/scenarii pondere.png differ diff --git a/backend/teste livrare/source assesment asignation.png b/backend/teste livrare/source assesment asignation.png new file mode 100644 index 0000000..a952174 Binary files /dev/null and b/backend/teste livrare/source assesment asignation.png differ diff --git a/backend/teste livrare/source assesment parameters.png b/backend/teste livrare/source assesment parameters.png new file mode 100644 index 0000000..3a42595 Binary files /dev/null and b/backend/teste livrare/source assesment parameters.png differ diff --git a/backend/teste livrare/source aval_.png b/backend/teste livrare/source aval_.png new file mode 100644 index 0000000..d35785c Binary files /dev/null and b/backend/teste livrare/source aval_.png differ diff --git a/backend/teste livrare/source credibility.png b/backend/teste livrare/source credibility.png new file mode 100644 index 0000000..38dc332 Binary files /dev/null and b/backend/teste livrare/source credibility.png differ diff --git a/backend/teste livrare/source domain age.png b/backend/teste livrare/source domain age.png new file mode 100644 index 0000000..74cd6eb Binary files /dev/null and b/backend/teste livrare/source domain age.png differ diff --git a/backend/teste livrare/subscription plans.png b/backend/teste livrare/subscription plans.png new file mode 100644 index 0000000..77d808e Binary files /dev/null and b/backend/teste livrare/subscription plans.png differ diff --git a/backend/teste livrare/surce modificatos platform.png b/backend/teste livrare/surce modificatos platform.png new file mode 100644 index 0000000..f01e333 Binary files /dev/null and b/backend/teste livrare/surce modificatos platform.png differ diff --git a/backend/teste livrare/textpipeline1.png b/backend/teste livrare/textpipeline1.png new file mode 100644 index 0000000..f5eeeb1 Binary files /dev/null and b/backend/teste livrare/textpipeline1.png differ diff --git a/backend/teste livrare/tipuri claims.png b/backend/teste livrare/tipuri claims.png new file mode 100644 index 0000000..e84f352 Binary files /dev/null and b/backend/teste livrare/tipuri claims.png differ diff --git a/backend/teste livrare/user management .png b/backend/teste livrare/user management .png new file mode 100644 index 0000000..3863018 Binary files /dev/null and b/backend/teste livrare/user management .png differ diff --git a/backend/teste livrare/verdict categories.png b/backend/teste livrare/verdict categories.png new file mode 100644 index 0000000..d90d4df Binary files /dev/null and b/backend/teste livrare/verdict categories.png differ diff --git a/backend/teste livrare/verdict confidence.png b/backend/teste livrare/verdict confidence.png new file mode 100644 index 0000000..9c46b7c Binary files /dev/null and b/backend/teste livrare/verdict confidence.png differ diff --git a/backend/teste livrare/verdict multipliers.png b/backend/teste livrare/verdict multipliers.png new file mode 100644 index 0000000..40398cb Binary files /dev/null and b/backend/teste livrare/verdict multipliers.png differ diff --git a/backend/teste livrare/verdict risk levels.png b/backend/teste livrare/verdict risk levels.png new file mode 100644 index 0000000..6b04f1d Binary files /dev/null and b/backend/teste livrare/verdict risk levels.png differ diff --git a/backend/teste livrare/verdict severity.png b/backend/teste livrare/verdict severity.png new file mode 100644 index 0000000..1e91245 Binary files /dev/null and b/backend/teste livrare/verdict severity.png differ diff --git a/backend/teste livrare/verdict weighs.png b/backend/teste livrare/verdict weighs.png new file mode 100644 index 0000000..5ff2125 Binary files /dev/null and b/backend/teste livrare/verdict weighs.png differ diff --git a/backend/teste livrare/verdifcts overrides & sineryg.png b/backend/teste livrare/verdifcts overrides & sineryg.png new file mode 100644 index 0000000..1b2b68f Binary files /dev/null and b/backend/teste livrare/verdifcts overrides & sineryg.png differ diff --git a/documente/00_INDEX_Dosar_Livrare_Lot2.docx b/documente/00_INDEX_Dosar_Livrare_Lot2.docx new file mode 100644 index 0000000..1e2650a Binary files /dev/null and b/documente/00_INDEX_Dosar_Livrare_Lot2.docx differ diff --git a/documente/01_Arhitectura_Lot2.docx b/documente/01_Arhitectura_Lot2.docx new file mode 100644 index 0000000..8353405 Binary files /dev/null and b/documente/01_Arhitectura_Lot2.docx differ diff --git a/documente/02_Ghid_Instalare_Operare_Lot2.docx b/documente/02_Ghid_Instalare_Operare_Lot2.docx new file mode 100644 index 0000000..b7bcad6 Binary files /dev/null and b/documente/02_Ghid_Instalare_Operare_Lot2.docx differ diff --git a/documente/03_Raport_Testare_API_Lot2.docx b/documente/03_Raport_Testare_API_Lot2.docx new file mode 100644 index 0000000..1a86d97 Binary files /dev/null and b/documente/03_Raport_Testare_API_Lot2.docx differ diff --git a/documente/04_Raport_Testare_Integrare_Lot1-Lot2.docx b/documente/04_Raport_Testare_Integrare_Lot1-Lot2.docx new file mode 100644 index 0000000..a5752fb Binary files /dev/null and b/documente/04_Raport_Testare_Integrare_Lot1-Lot2.docx differ diff --git a/documente/05_Ghid_Utilizare_Lot2.docx b/documente/05_Ghid_Utilizare_Lot2.docx new file mode 100644 index 0000000..9247da0 Binary files /dev/null and b/documente/05_Ghid_Utilizare_Lot2.docx differ diff --git a/documente/06_Matrice_Trasabilitate_Cerinte.docx b/documente/06_Matrice_Trasabilitate_Cerinte.docx new file mode 100644 index 0000000..2afbdc5 Binary files /dev/null and b/documente/06_Matrice_Trasabilitate_Cerinte.docx differ diff --git a/documente/07_Specificatii_API_Lot2.docx b/documente/07_Specificatii_API_Lot2.docx new file mode 100644 index 0000000..33849f4 Binary files /dev/null and b/documente/07_Specificatii_API_Lot2.docx differ diff --git a/imagini-docker/agent-v3.tar.gz b/imagini-docker/agent-v3.tar.gz new file mode 100644 index 0000000..4c7602e Binary files /dev/null and b/imagini-docker/agent-v3.tar.gz differ diff --git a/imagini-docker/didi-admin.tar.gz b/imagini-docker/didi-admin.tar.gz new file mode 100644 index 0000000..116b335 Binary files /dev/null and b/imagini-docker/didi-admin.tar.gz differ diff --git a/imagini-docker/didi-framework.tar.gz b/imagini-docker/didi-framework.tar.gz new file mode 100644 index 0000000..6ee1c74 Binary files /dev/null and b/imagini-docker/didi-framework.tar.gz differ diff --git a/imagini-docker/didi-kong.tar.gz b/imagini-docker/didi-kong.tar.gz new file mode 100644 index 0000000..2d64e8d Binary files /dev/null and b/imagini-docker/didi-kong.tar.gz differ diff --git a/imagini-docker/incarca-imagini.sh b/imagini-docker/incarca-imagini.sh new file mode 100644 index 0000000..e79a1b6 --- /dev/null +++ b/imagini-docker/incarca-imagini.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Incarca imaginile Docker Lot 2 in daemon-ul local (docker load). +set -e +cd "$(dirname "$0")" +for f in agent-v3.tar.gz didi-framework.tar.gz didi-admin.tar.gz didi-kong.tar.gz; do + echo "Incarc $f ..." + gunzip -c "$f" | docker load +done +echo "Gata. Verifica: docker images | grep -E 'agent-v3|didi-framework|didi-admin|didi-kong'"