No description
  • TypeScript 99.3%
  • Dockerfile 0.3%
  • JavaScript 0.2%
  • CSS 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-02 00:01:39 +01:00
data/samples whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
drizzle whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
scripts follow up fix or something idk 2026-10-01 23:41:17 +01:00
src forgot some files 2026-10-02 00:01:39 +01:00
tests forgot some files 2026-10-02 00:01:39 +01:00
.dockerignore whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
.env.example Add development bypass for authentication and improve error handling for database connectivity 2026-10-01 23:58:49 +01:00
.gitignore whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
docker-compose.yml whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
Dockerfile whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
drizzle.config.ts whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
eslint.config.mjs whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
next.config.ts whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
package-lock.json whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
package.json whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
playwright.config.ts whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
postcss.config.mjs whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
README.md Add development bypass for authentication and improve error handling for database connectivity 2026-10-01 23:58:49 +01:00
tsconfig.json whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00
vitest.config.ts whatever tf gemini 4 did as a start 2026-10-01 23:30:44 +01:00

quackAnal

Private, selbst gehostete Webanwendung, mit der ein privater Discord-Channel mit hunderttausenden Nachrichten durchsucht und ausgewertet werden kann.

Die Anwendung ist ab Werk gesperrt: Ohne konfigurierte OIDC-Anmeldung und ohne Allowlist gibt es keinen Zugriff. Alle angemeldeten und freigeschalteten Personen sehen denselben importierten Bestand.

Keine Rechtsberatung. Dieses Projekt stellt Werkzeuge bereit, keine Rechtskonformität. Ob die Verarbeitung von Chatnachrichten zulässig ist, hängt von Rechtsgrundlage, Verhältnissen, Löschfristen und Informationspflichten ab und ist vom Betreiber zu bewerten. Siehe Datenschutz und Rechtsfragen.


Inhalt

  1. Schnellstart
  2. Umgebungsvariablen
  3. Authentifizierung mit Pocket ID (OIDC)
  4. Ohne OIDC ansehen (Entwicklungs-Bypass)
  5. Discord-Daten importieren
  6. Embeddings und semantische Suche
  7. Hintergrundverarbeitung
  8. Funktionsumfang der Oberfläche
  9. Architektur und Datenmodell
  10. Skalierung auf 500.000 Nachrichten
  11. Sicherheit
  12. Datenschutz und Rechtsfragen
  13. Tests, Linting, Typechecking
  14. Bekannte Einschränkungen
  15. Weiterführend

Schnellstart

Voraussetzungen: Node.js ≥ 20.11 und Docker (für PostgreSQL).

git clone <repository> quackanal
cd quackanal

# 1. Abhängigkeiten installieren
npm install

# 2. Konfiguration anlegen (enthält keine echten Geheimnisse)
cp .env.example .env.local
$EDITOR .env.local        # OIDC_* und Allowlist setzen, mindestens:
                          #   OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
                          #   OIDC_ALLOWED_DOMAINS oder OIDC_ALLOWED_EMAILS

# 3. Datenbank starten (PostgreSQL 17 + pgvector)
docker compose up -d db

# 4. Erweiterungen aktivieren und Migrationen ausführen
npm run db:migrate

# 5. Web-Oberfläche starten
npm run dev                # http://localhost:3000

# 6. Hintergrundprozesse starten (zweites Terminal)
npm run worker

Hinweis: npm run db:migrate, npm run worker und npm run sample:import laden .env.local selbst ein. tsx liest .env-Dateien nicht von allein – ohne diesen Schritt würde der Migrationslauf mit „Fehlende Umgebungsvariable DATABASE_URL“ abbrechen. Die Web-Oberfläche (next dev) lädt sie wie gewohnt.

Optional: Beispieldaten erzeugen und importieren, ohne Discord zu bemühen.

npm run sample:generate                              # Beispieldatei (erfundene Inhalte)
npm run sample:import -- data/samples/discord-export-sample.json
npm run worker                                        # Abschnitte + Embeddings erzeugen

Der komplette Stack (Datenbank + Web + Worker) läuft auch in Containern:

docker compose --profile app up -d --build

Zur Konfiguration des IdP muss Pocket ID erreichbar sein. Für einen ersten Test genügt OIDC_ALLOW_ALL=true zusammen mit einem beliebigen OIDC-Provider (z. B. Pocket ID). Diese Option ist für den Normalbetrieb nicht vorgesehen und wird zusammen mit Mock-KI-Providern in NODE_ENV=production abgelehnt.


Umgebungsvariablen

Vollständige Liste mit Erklärungen: .env.example.

Basis

Variable Pflicht Bedeutung
DATABASE_URL ja PostgreSQL-Verbindung
APP_URL ja (Default http://localhost:3000) Öffentliche URL; bestimmt OIDC-Redirect und CSRF-Origin-Prüfung
LOG_LEVEL nein debug | info | warn | error
TRUST_PROXY_HEADERS nein true hinter Reverse Proxy, damit X-Forwarded-For für das Rate-Limit genutzt wird
IMPORT_MAX_UPLOAD_BYTES nein Maximale Dateigröße beim Import (Default 512 MB)

Authentifizierung

Variable Pflicht Bedeutung
OIDC_ISSUER ja Issuer bzw. Discovery-URL des Providers
OIDC_CLIENT_ID ja Client-ID
OIDC_CLIENT_SECRET ja Client-Secret (nur serverseitig)
OIDC_REDIRECT_URI nein Default ${APP_URL}/api/auth/callback
OIDC_SCOPES nein Default openid profile email
OIDC_ALLOWED_DOMAINS eine von Erlaubte E-Mail-Domains, kommagetrennt
OIDC_ALLOWED_EMAILS eine von Erlaubte Einzeladressen, kommagetrennt
OIDC_ALLOWED_GROUP_CLAIM / OIDC_ALLOWED_GROUP_VALUE optional Zugang über eine Pocket-ID-Gruppe
OIDC_ALLOW_ALL nein true = jeder angemeldete Account; nur für Entwicklung
SESSION_TTL_HOURS nein Session-Lebensdauer (Default 24)
AUTH_BYPASS_ENABLED nein true = Anmeldung ohne OIDC (nur Entwicklung, in Produktion ignoriert)
AUTH_BYPASS_EMAIL / AUTH_BYPASS_NAME / AUTH_BYPASS_ADMIN nein Identität des Entwicklungsnutzers

Fehlt jede Allowlist, startet die App nicht mit einer erklärenden Meldung.

KI-Provider

Variable Default Bedeutung
EMBEDDING_PROVIDER mock openai | ollama | mock
EMBEDDING_MODEL text-embedding-3-small Modellname (frei wählbar)
EMBEDDING_DIMENSIONS 1536 Muss zur DB-Spalte passen
EMBEDDING_DAILY_TOKEN_BUDGET 2000000 Kostengrenze pro Tag
CHAT_PROVIDER mock openai | ollama | mock
CHAT_MODEL gpt-4o-mini Modellname
OPENAI_API_KEY – nur serverseitig; wird nie an den Browser ausgeliefert
OPENAI_BASE_URL – kompatible Gateways (LiteLLM, vLLM, …)
OLLAMA_BASE_URL http://localhost:11434 lokale Alternative

Ohne Konfiguration bleiben Stichwortsuche und Statistiken vollständig funktionsfähig. Externe Anbieter werden nie unbeabsichtigt verwendet: Fehlt der Key, meldet die Oberfläche einen erklärenden Hinweis, statt stillschweigend zurückzufallen.

Discord-Bot (optional)

Siehe Import. Nur für neue Nachrichten.


Authentifizierung mit Pocket ID (OIDC)

1. Client in Pocket ID anlegen

In Pocket ID: OIDC Clients → Create Client

Feld Wert
Name quackanal
Client type Public oder Confidential (beides unterstützt)
Redirect URIs https://<deine-domain>/api/auth/callback
Post logout redirect URIs https://<deine-domain>/login
Scopes openid, profile, email
PKCE enabled (die Anwendung verwendet PKCE verpflichtend)

Notizen:

  • Für den lokalen Test mit http://localhost:3000 genügt http://localhost:3000/api/auth/callback.
  • OIDC_CLIENT_SECRET wird nur benötigt, wenn der Client als Confidential angelegt wurde. Es verlässt den Server nie.
  • In den ID-Token-Claims müssen enthalten sein:
    • sub (Pflicht)
    • email und email_verified (Pflicht für die Allowlist-Prüfung)
    • name oder preferred_username (optional, Anzeigename)
    • Gruppen: konfigurierbar über OIDC_ALLOWED_GROUP_CLAIM, Standard groups

2. Gruppe anlegen (empfohlen)

Lege eine Gruppe an (z. B. quackanal-leser), weise die berechtigten Personen zu und setze:

OIDC_ALLOWED_GROUP_CLAIM=groups
OIDC_ALLOWED_GROUP_VALUE=quackanal-leser

Alternativ pro Domain: OIDC_ALLOWED_DOMAINS=example.com.

3. Ersten Administrator einrichten

Beim ersten Login wird automatisch ein Konto mit Rolle viewer angelegt. Für den Import wird admin benötigt. Es gibt keinen selbstregistrierenden Admin:

  1. Einmalig in der Datenbank ausführen:

    UPDATE users SET role = 'admin' WHERE email = 'du@example.com';
    
  2. Ab sofort kann das Konto unter Administration → Angemeldete Personen die Rolle viewer/admin vergeben, Konten deaktivieren und die Allowlist pflegen.

4. Ablauf des Logins

  1. /api/auth/login erzeugt state, nonce und einen PKCE-Verifier, legt diese kurzlebig in der Datenbank ab und leitet zum Provider weiter.
  2. /api/auth/callback tauscht den Code ein, prüft state/nonce und entscheidet anhand der Allowlist über den Zugang.
  3. Der Browser erhält ein HTTP-only, SameSite=Lax Cookie (in Produktion zusätzlich Secure). In der Datenbank liegt nur sha256(Geheimnis) – ein Datenbank-Dump allein reicht nicht für eine Anmeldung.
  4. Alle Seiten, Server Actions und API-Routen prüfen die Session serverseitig. Ohne Session gibt es keinen Inhalt.

Wenn OIDC fehlt

Die Anwendung zeigt eine Konfigurationsanleitung und bleibt gesperrt. Es gibt bewusst keinen development-only Bypass. Für automatisierte Tests wird eine Session direkt in der Testdatenbank erzeugt (tests/e2e/fixtures.ts).


Ohne OIDC ansehen (Entwicklungs-Bypass)

Wenn du dir nur die Oberfläche ansehen willst, ohne Pocket ID einzurichten:

# in .env.local (oder .env) einfügen:
AUTH_BYPASS_ENABLED=true

npm run dev

Danach ist /dashboard direkt erreichbar – ohne Login, ohne OIDC-Client, ohne Allowlist. Im Terminal genügt dafür die Datenbank (docker compose up -d db, npm run db:migrate); OIDC wird nicht mehr benötigt.

Was dabei passiert

Aspekt Verhalten
Nutzer Fester Entwicklungsnutzer dev@localhost mit Rolle admin (konfigurierbar)
Anmeldung Vollständig übersprungen; es wird keine Session und kein Cookie erzeugt
Banner Dauerhaft sichtbare Warnung oben in jeder Seite
Log Beim ersten Zugriff erscheint auth.bypass.enabled im Log
Rechte Bei AUTH_BYPASS_ADMIN=false nur Lese- und Suchfunktionen

Sicherheitsregeln

Der Bypass ist so gebaut, dass er nicht versehentlich in einen Produktivbetrieb gerät:

  1. In NODE_ENV=production wird die Variable ignoriert. Die Anmeldung bleibt aktiv – auch wenn jemand AUTH_BYPASS_ENABLED=true mitnimmt. Das ist mit Tests abgesichert (tests/auth/bypass.test.ts).
  2. getAuthEnv() weicht in Produktion nicht auf die Bypass-Konfiguration aus: Issuer, Client und Allowlist werden weiterhin zwingend verlangt.
  3. Der Bypass kann sich nicht in eine dauerhafte Sitzung umwandeln – es gibt bewusst kein Cookie.
  4. Es wird trotzdem eine echte Zeile in users angelegt, damit Fremdschlüssel (created_by, updated_by, topic_summaries) gültig bleiben.
  5. Ist der Bypass inaktiv, wird die Datenbank nicht angefasst (tests/auth/bypass-db-isolation.test.ts).

Wenn die Datenbank fehlt

Beim Start ohne laufendes PostgreSQL landest du nicht auf einer kryptischen Fehlerseite, sondern auf /nicht-verfuegbar mit einer Checkliste. Alle geschützten Seiten leiten dorthin um – der Zugriff bleibt gesperrt, es werden keine Daten ausgegeben.

Ausschalten

AUTH_BYPASS_ENABLED=false

Bei aktivem Bypass ist /login weiterhin erreichbar und erklärt, dass keine Anmeldung nötig ist.

Der Bypass ersetzt nur die Anmeldung. Für die Oberfläche mit Daten (Dashboard, Suche, Import) wird weiterhin PostgreSQL benötigt.


Discord-Daten importieren

Dateien erzeugen

In Discord: Channel öffnen → Nachrichten teilen → Nachrichtenhistorie exportieren → JSON oder CSV. Alternativ mit DiscordChatExporter:

DiscordChatExporter.exe -f Json  -o export.json  "<Channel-ID>"
DiscordChatExporter.exe -f Jsonl -o export.jsonl "<Channel-ID>"
DiscordChatExporter.exe -f Csv   -o export.csv   "<Channel-ID>"

Discord erlaubt das Exportieren nur für Channels, in denen die ausführende Person Zugriff hat. Für Kanäle ohne Leseberechtigung ist der Export nicht möglich.

Unterstützte Formate

Die Format-Erkennung (src/import/adapters.ts) unterscheidet drei Adapter:

Format Erkennung Channel-Angabe
dce-json JSON-Objekt mit "messages": [ in der Datei (guild/channel)
dce-ndjson eine Nachricht pro Zeile pro Zeile oder aus dem Formular
dce-csv Kopfzeile Author,AuthorID,Timestamp,Date,Content muss im Formular angegeben werden

Beispieldateien liegen in data/samples/ (frei erfundene Inhalte) und lassen sich neu erzeugen:

npm run sample:generate
npm run sample:generate -- --format ndjson
npm run sample:generate -- --format csv

Bei unbekannten Formaten bricht der Import mit einer Meldung ab, die die drei unterstützten Formate und ihre Erkennungsmerkmale nennt – es wird nicht stillschweigend eine leere Datei importiert.

Import über die Oberfläche

  1. Anmelden, Konto muss admin sein.
  2. Import & Verarbeitung → Datei auswählen. Für CSV/JSONL zusätzlich Channel-ID und -Name angeben.
  3. Optionen: gelöschte Nachrichten überspringen, Nachrichten ohne Text überspringen, Nur Vorschau.
  4. Die Vorschau zeigt Format, gelesene Datensätze und die ersten Nachrichten.
  5. Der eigentliche Import läuft als Hintergrundauftrag; Fortschritt, Fehler und Wiederholbarkeit sind unter Import & Verarbeitung sichtbar.

Alternativ auf der Kommandozeile:

npm run sample:import -- data/samples/discord-export-sample.json
npm run sample:import -- --channel-id 900000000000000000 --channel-name allgemein export.csv

Speicherverhalten und Deduplizierung

  • Streaming: Der JSON-Scanner (src/import/json-stream.ts) liest die Datei in Chunks und gibt messages-Elemente einzeln aus. Ein 300-MB-Export belegt dadurch nur wenige MB zusätzlichen Speicher. CSV wird genauso zeilenweise gelesen (inkl. mehrzeiliger, kommahaltiger Felder).
  • Deduplizierung über messages.discord_id (UNIQUE):
    • Nachricht existiert nicht → eingefügt (neu)
    • Inhalt identisch → nichts geschrieben, gezählt als übersprungen
    • Inhalt geändert → aktualisiert, is_edited = true, Embeddings werden als stale markiert
  • Wiederaufnehmbarkeit: Ein erneut gestarteter Import ist sicher, weil alle Datensätze idempotent verarbeitet werden. import_runs protokolliert jeden Lauf; import_errors hält Zeilennummer/Datensatz-ID und Grund fest – ohne Nachrichtentexte.
  • Ohne Discord-ID (manche CSV-Exporte) wird eine deterministische synthetische ID aus Kanal, Autor, Zeitstempel und Inhalts-Hash erzeugt. Damit bleibt auch dort die Deduplizierung erhalten; id_source = 'synthetic' macht das transparent.

Discord-Bot (optional)

Zweck: neue Nachrichten in Echtzeit übernehmen.

DISCORD_BOT_TOKEN=<Bot-Token>
DISCORD_GUILD_ID=<Server-ID>
DISCORD_CHANNEL_IDS=1111111111111111111,2222222222222222222
DISCORD_SYNC_INTERVAL_MINUTES=60

Benötigte Discord-Berechtigungen:

Berechtigung Zweck erforderlich
View Channel Channel sehen ja
Read Message History Nachrichten lesen ja
Send Messages nicht benötigt nein
Manage Messages / Administrator nicht benötigt nein
Gateway-Intent Message Content nicht nötig (HTTP-API, kein Gateway) nein

Der Bot braucht keine Schreibrechte und keine Administratorrolle. Es werden ausschließlich GET-Aufrufe abgesetzt (/channels/{id}/messages), mit Backoff/Retry und der Auswertung von Retry-After bei HTTP 429.

Wichtige Einschränkung: Discord gibt einem Bot nur Nachrichten, die er selbst sehen darf – in der Regel erst ab dem Zeitpunkt, zu dem die Berechtigung erteilt wurde. Ein vollständiger historischer Backfill per Bot ist nicht garantiert. Für historische Daten ist der Dateiimport der vorgesehene Weg. Das gilt auch dann, wenn Discord die API-Zugriffe nicht ablehnt.


Embeddings und semantische Suche

Die Stichwortsuche funktioniert immer und benötigt keine KI. Für die semantische Suche wird ein konfigurierbarer Provider benötigt.

Provider

Provider Einrichtung Datenschutz
openai OPENAI_API_KEY, EMBEDDING_MODEL Nachrichtentexte verlassen die eigene Infrastruktur
ollama OLLAMA_BASE_URL, Modell vorher ollama pull Verarbeitung bleibt lokal
mock keine Nur Entwicklungs-/Testzwecke; deterministische Platzhaltervektoren, in Produktion abgelehnt

OPENAI_BASE_URL erlaubt kompatible Endpunkte (LiteLLM, vLLM, Azure-OpenAI über Proxy) ohne Codeänderung.

Pipeline

  1. Abschnittbildung (chunks.build): zusammenhängende Nachrichten desselben Channels/Threads werden zu Abschnitten gruppiert. Konfigurierbar über CHUNK_MAX_CHARS (Standard 1600), CHUNK_MAX_MESSAGES (40) und CHUNK_MAX_GAP_SECONDS (900). Eine zu lange Einzelnachricht wird nie mitten drin abgeschnitten.
  2. Embedding-Erzeugung (embeddings.backfill): Batches (Standard 64), Status je Abschnitt (pending, done, failed, stale), exponentielles Backoff bei 429/5xx, Tagesbudget.
  3. Modellwechsel: Vektoren werden mit Modellname und Dimension gespeichert. Die semantische Suche filtert auf das aktuell konfigurierte Modell – alte Vektoren liefern kein irreführendes Ergebnis, sondern gar keins.

Embedding-Modell wechseln

Bei anderer Dimension ist eine Migration nötig (die Dimension ist Teil des Schemas):

# 1. Dimension in src/db/schema.ts anpassen (EMBEDDING_DIMENSIONS)
# 2. Section nach Anleitung umbenennen bzw. Migration schreiben:
ALTER TABLE message_chunks ALTER COLUMN embedding TYPE vector(768);
# 3. Alte Vektoren verwerfen und neu berechnen:
UPDATE message_chunks SET embedding = NULL, embedding_status = 'pending',
                         embedding_model = NULL, embedded_content_hash = NULL;

Anschließend npm run worker (der Backfill läuft beim Start erneut).


Hintergrundverarbeitung

Aufgaben laufen über eine pg-boss-Queue in derselben PostgreSQL-Datenbank. Kein Redis erforderlich.

npm run worker            # dauerhaft laufen lassen
npm run worker:watch      # mit Watch-Modus
Aufgabe Auslöser
import.run Upload im Admin-Bereich
chunks.build nach Import, manuell im Admin-Bereich
embeddings.backfill nach Import, manuell, Zeitplan beim Worker-Start
topics.extract beim Öffnen einer Themen-/Personenseite
discord.sync Zeitplan (DISCORD_SYNC_INTERVAL_MINUTES) und manuell
maintenance.cleanup täglich 04:17

Zustand, Fortschritt und Fehler landen in job_runs und sind unter Import & Verarbeitung sichtbar. Aufgaben sind wiederholbar: Fehler werden mit Backoff erneut eingereiht (Standard 3 Versuche).


Funktionsumfang der Oberfläche

Seite Inhalt
Login OIDC-Anmeldung; bei fehlender Konfiguration eine Anleitung statt eines Bypass
Dashboard Gesamtzahl Nachrichten, eindeutige Personen, Nachrichten pro Tag/Woche/Monat, Aktivität nach Wochentag und Tageszeit, aktivste Channels, Nachrichten pro Person (paginiert), Import- und Embedding-Fortschritt
Suche Stichwort / Hybrid / semantisch, Filter für Zeitraum, Channel, Autor, Seitenzahl; Treffergrund, Kontextnachrichten, Sprung zum Original
Nachrichtendetail Originaltext, Diskussionsabschnitt, Antwortbezug, Zeitliches Umfeld, Import-Metadaten
Personen Häufige Themen je Person mit Belegen, Datenbasis und Zeitraum, Ausschluss-Funktion für Admins
Themen Häufige Begriffe je Channel und serverweit, Belege, Verlauf
Import & Verarbeitung Upload mit Vorschau, Fortschritt, Fehlerprotokoll, Job-Übersicht, KI-Verbrauch
Administration Allowlist, Nutzerrollen, Analyse-Einstellungen, Einzellöschung, Komplettlöschung, Konfigübersicht

Suche

  • Hybrid kombiniert Volltextsuche (websearch_to_tsquery, deutsche Postgres-Textkonfiguration) und Vektorsuche (pgvector, Cosinus) über Reciprocal Rank Fusion. RRF wird verwendet, weil BM25-Ränge und Vektorähnlichkeiten nicht vergleichbar skaliert sind; der Wert ist eine Rangfolge, keine Wahrscheinlichkeit.
  • Jeder Treffer zeigt worauf er zurückgeht: Stichworttreffer (mit gefundenen Begriffen), semantische Ähnlichkeit und den RRF-Wert.
  • Ähnliche Vektoren werden nie als gesicherte Tatsachen dargestellt.
  • Filterzustände liegen in den URL-Query-Parametern und sind damit teilbar.

Frage-Antwort-Funktion (optional)

Das Modell erhält ausschließlich die für die Frage relevanten Treffer (Standard: bis zu 12 Nachrichten) – nie den ganzen Channel. Regeln:

  • Jede Aussage muss mit [Q1], [Q2], … belegt werden.
  • Ohne Beleg wird exakt „Die verfügbaren Quellen erlauben keine belastbare Antwort.“ ausgegeben.
  • Die Quellen erscheinen unter jeder Antwort mit Autor, Kanal, Zeitpunkt und klickbarem Discord-Permalink; zitierte und nicht zitierte sind gekennzeichnet.
  • Antworten sind dauerhaft als KI-Auswertung gekennzeichnet.

Architektur und Datenmodell

Stack (bewusste Entscheidungen)

Entscheidung Begründung
Next.js App Router Alle Seiten serverseitig gerendert → keine Inhalte im Browser-Bundle, keine Client-seitigen Datenabfragen.
PostgreSQL + pgvector Volltextsuche, JSONB, Transaktionen und Vektorsuche in einem System. Kein zweiter Dienst.
Drizzle ORM Typisierte Queries ohne Runtime-Magie; Migrationen sind nachvollziehbares SQL.
pg-boss statt Redis Eine Queue weniger im Betrieb. Jobs sind transaktional abgesichert und überstehen Neustarts.
Eigener JSON-Stream-Scanner Ein 300-MB-DiscordChatExporter-Export ist ein einzelnes JSON-Objekt. JSON.parse würde GB RAM benötigen.
RRF statt Score-Mixing Vergleicht nur Ränge statt inkompatibler Scores; nachvollziehbar und stabil.
Statistische Themen statt KI-Themen Themen lassen sich exakt nachrechnen und funktionieren ohne KI.

Übersicht der Komponenten

src/
  ai/            Provider-Abstraktion (openai | ollama | mock), Retry/Backoff
  analysis/      Themenextraktion, quellenbasierte QA, Datenschutz-Leitplanken
  app/           Next.js App Router: Seiten, Server Actions, Auth-Routen
  auth/          OIDC (PKCE), Sessions, Allowlist, Zugriffs-Guards
  db/            Drizzle-Schema, Client, Migrationen
  discord/       Optionale Bot-Synchronisierung
  import/        Adapter (JSON/JSONL/CSV), Streaming-Scanner, Upsert-Pipeline
  jobs/          pg-boss-Queue, Handler, Worker
  search/        Filter, Volltext, Vektor, RRF, Chunking, Embedding-Backfill
  stats/         SQL-Aggregationen für das Dashboard

Tabellen und Beziehungen

guilds 1───* channels 1───* messages 1───1 authors
                        │            │
                        │            ├──* chunk_messages *──1 message_chunks
                        │            │                    (embedding, embedding_status)
                        └── import_runs ──* import_errors

users 1───* sessions          auth_allowlist (E-Mail/Domain)
users 1───* llm_answers
topic_scopes (scope_type, scope_id) 1───* topic_terms
                              1───* topic_summaries
person_analysis_exclusions ──1 authors
job_runs · settings · rate_limit_counters · ai_usage_daily · discord_sync_state
Tabelle Zweck Löschregel
users angemeldete Identitäten (nur E-Mail, Name, Rollen) manuell
sessions serverseitige Sitzungen (nur Token-Hash) CASCADE bei Nutzerlöschung, Ablauf wird automatisch entfernt
auth_allowlist statische Freischaltungen manuell
guilds / channels Server und Channels CASCADE zu Nachrichten
authors Autoren:innen mit Nachrichtenzähler CASCADE zu Ausschlüssen
messages Nachrichten inkl. search_vector (generiert) import_run_id → SET NULL
message_chunks Suchabschnitte mit Embedding (pgvector, HNSW) CASCADE über chunk_messages
chunk_messages Abschnitt → Originalnachricht (Reihenfolge erhalten) CASCADE
import_runs / import_errors Fortschritt und Fehler je Lauf CASCADE zu Fehlern
topic_terms / topic_scopes / topic_summaries Themen und deren Datenbasis manuell
llm_answers protokollierte KI-Antworten manuell
job_runs Fortschritt der Hintergrundaufgaben CASCADE bei Importlöschung
ai_usage_daily Token-Verbrauch je Tag und Provider manuell

Nachrichtenmodell

Gespeichert werden: Discord-ID, Guild-ID/-Name, Channel-ID/-Name, Autor-ID und Anzeigename, Zeitstempel, Text, Thread-/Parent-Bezug, Antwortbezug, Edit-/ Delete-Status, Permalink, Importquelle und Importzeitpunkt.

Nicht gespeichert werden: Anhänge, Bilder, Sticker, Embeds, Nur Zahlen angefügt (has_attachment, attachment_count) – das reduziert personenbezogene Daten erheblich.


Skalierung auf 500.000 Nachrichten

Gemessene Größen

Größenordnung Umsetzung
Import Streaming-Scanner + Massen-Upsert über unnest in Batches von 500 → konstanter Speicherbedarf, kein Volumen im RAM
Indexierung messages_search_vector_idx (GIN, generierte Spalte), messages_channel_timestamp_idx, messages_author_timestamp_idx, messages_discord_id_uidx
Vektorsuche HNSW-Index message_chunks_embedding_hnsw_idx (vector_cosine_ops, m=16, ef_construction=64), nur für Zeilen mit Embedding
Abschnitte Aufbau über Server-Cursor in Seiten von 2000 Nachrichten
Themen Cursor-Streaming; im RAM nur die Wortliste
Statistiken Alle Kennzahlen als SQL-Aggregation, Zeitraumfilter über Indizes
Seiten ausschließlich serverseitige Pagination, keine unvirtualisierten Langlisten

Empfohlene Postgres-Einstellungen

docker-compose.yml setzt bereits shared_buffers, effective_cache_size, work_mem und maintenance_work_mem. Für eine dedizierte Maschine:

shared_buffers = 2GB              # ~25 % des RAM
effective_cache_size = 6GB
work_mem = 32MB
maintenance_work_mem = 1GB       # beschleunigt den HNSW-Aufbau deutlich
max_wal_size = 4GB
random_page_cost = 1.1           # SSD
effective_io_concurrency = 200
timezone = 'Europe/Berlin'

HNSW-Index und Wartung

Der Aufbau eines HNSW-Index über 100.000+ Vektoren dauert einige Minuten und braucht maintenance_work_mem. Falls pgvector meldet memory needed for index build …, maintenance_work_mem erhöhen.

Nach einem großen Import empfiehlt sich:

VACUUM ANALYZE messages;
VACUUM ANALYZE message_chunks;
REINDEX INDEX CONCURRENTLY messages_search_vector_idx;   -- nur wenn nötig

Nachgewiesene Testabdeckung

tests/db/ enthält Integrationstests gegen eine echte Datenbank, unter anderem einen Massenimport von 20.000 Nachrichten mit Messung der Importdauer und einer Plausibilitätsgrenze (< 60 s), damit eine Regression (z. B. Batch-Größe

  1. sofort auffällt. Diese Tests laufen mit npm run test:db und sind ohne Datenbank automatisch übersprungen – siehe Tests.

Reproduzieren

docker compose up -d db
npm run db:migrate
npm run sample:generate -- --count 500000 --out data/samples/big.json
npm run sample:import -- data/samples/big.json
npm run worker        # Abschnitte + Embeddings

Sicherheit

Maßnahme Umsetzung
Zugriff Serverseitiger Guard auf jeder Seite, Server Action und API-Route (src/auth/guard.ts)
Bypass-Sperre AUTH_BYPASS_ENABLED wirkt ausschließlich außerhalb von NODE_ENV=production (testgesichert)
Datenbankausfall Führt zu /nicht-verfuegbar mit Checkliste statt zu einem 500er; der Zugriff bleibt gesperrt
Sessions HTTP-only, SameSite=Lax, Secure in Produktion; DB speichert nur sha256
CSRF Origin-/Sec-Fetch-Site-Prüfung für Route Handler; Next.js prüft Server Actions selbst
SQL-Injection Ausschließlich gebundene Parameter; Filterwerte werden zusätzlich per Whitelist validiert
XSS Chattexte werden nie per dangerouslySetInnerHTML gerendert; Highlights als Segmente
Uploads Endungs-Whitelist, Größengrenze während des Schreibens, nicht-öffentliches Verzeichnis, Path-Traverse-Schutz, Dateiname nur Basisname
Missbrauch Rate-Limit (DB-gestützt, wirkt über mehrere Instanzen) für Suche, KI und Login-nahe Aufgaben
Header Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
Externe Links rel="noopener noreferrer nofollow"
Löschfunktionen Einzelne Nachricht und kompletter Importlauf, jeweils mit Bestätigungstext und Admin-Recht
Logs Niemals vollständige Nachrichtentexte; nur IDs, Zähler, Fehlermeldungen

Zusätzlich empfohlen, nicht von der Anwendung übernommen:

  • Reverse Proxy mit TLS, Ratenbegrenzung und max_request_body_size
  • TRUST_PROXY_HEADERS=true nur, wenn ein vertrauenswürdiger Proxy vorangeschaltet ist
  • Zugriff auf die Anwendung zusätzlich per VPN oder IP-Beschränkung

Datenschutz und Rechtsfragen

Diese Anwendung garantiert keine Rechtskonformität. Die folgenden Hinweise sind keine Rechtsberatung, sondern eine technische Einordnung.

Welche Daten verarbeitet werden

Nachrichtentexte, Anzeigenamen, Discord-IDs, Zeitstempel, Antwortbezüge. Nicht gespeichert: Anhänge, Bilder, Sticker, Embed-Inhalte. IP-Adressen werden nur als Hash in Sessions gespeichert.

Zu bewerten durch den Betreiber

  • Rechtsgrundlage für die Verarbeitung (z. B. Art. 6 Abs. 1 lit. f DSGVO bei berechtigtem Interesse, oder Einwilligung)
  • Informationspflichten gegenüber den betroffenen Personen
  • Löschfristen und Aufbewahrung
  • Auftragsverarbeitungsverträge, wenn externe KI-Anbieter oder externe Hosting genutzt werden (Art. 28 DSGVO)
  • Zugriffsrechte – einzelne Nachrichten können über Administration gelöscht werden; für die Bearbeitung von Auskunftsersuchen ist das der vorgesehene Weg

Personenanalysen: bewusste Grenzen

Die Anwendung erstellt keine Persönlichkeitsprofile und keine Ranglisten nach Wert oder Einfluss. Technisch umgesetzt:

  • Formulierung als Frequenzangabe („in 25 von 200 Nachrichten“), nie als Eigenschaft einer Person
  • Verboten und gefiltert: psychologische Diagnosen, Aussagen zu Religion, Gesundheit, Politik, Sexualität, Herkunft, Vertrauenswürdigkeit (src/analysis/privacy.ts)
  • Fragen nach solchen Eigenschaften werden abgelehnt, ohne das Modell zu befragen
  • Jede Aussage verweist auf konkrete Originalnachrichten
  • Datenbasis und Zeitraum werden immer angezeigt; bei zu wenig Material wird gar keine Aussage getroffen
  • Personen können über Administration bzw. die Personenübersicht ausgeschlossen werden; PERSON_ANALYSIS_ENABLED=false schaltet die Analyse global ab

Externe KI-Anbieter

Wird ein externer Embedding- oder Chat-Provider verwendet, werden die jeweils benötigten Nachrichtentexte an diesen übertragen:

  • Embeddings: vollständige Abschnittstexte (bis zu ~1600 Zeichen)
  • Chat/Antwort: die 10–12 zur Frage passenden Nachrichtenauszüge

Die Oberfläche weist darauf hin, wenn ein externer Provider aktiv ist. Für eine Verarbeitung ohne Weitergabe EMBEDDING_PROVIDER=ollama und CHAT_PROVIDER=ollama setzen und die Modelle lokal bereitstellen.

Backups

Die Anwendung führt keine Backups automatisch aus. Empfohlenes Vorgehen:

# täglich per cronjob
docker compose exec -T db pg_dump -U quackanal -Fc quackanal > "backup-$(date +%F).dump"

# Wiederherstellung (überschreibt den aktuellen Stand!)
docker compose exec -T db pg_restore -U quackanal -d quackanal --clean --if-exists < backup-2025-01-01.dump

# Importdateien liegen getrennt unter data/uploads und sollten mitgesichert werden
tar czf uploads-$(date +%F).tar.gz data/uploads

Aufbewahrung und Verschlüsselung der Backups sind zu regeln; sie enthalten denselben Datenbestand wie die Datenbank.


Tests, Linting, Typechecking

npm run verify          # lint + typecheck + Unit- und Integrationstests
Befehl Inhalt
npm run lint ESLint 9 (Flat Config, eslint-config-next)
npm run typecheck tsc --noEmit, strict + noUncheckedIndexedAccess
npm test Vitest: ohne Datenbank lauffähig
npm run test:db zusätzlich Integrationstests gegen PostgreSQL

Abgedeckt

Bereich Datei
Streaming-JSON-Scanner (Chunk-Grenzen, Escapes, Fehler) tests/import/json-stream.test.ts
CSV-Parser (Quotes, Mehrzeiler, Chunk-Grenzen) tests/import/csv.test.ts
Normalisierung, synthetische IDs, Zeitstempel tests/import/normalize.test.ts
Format-Erkennung und alle drei Adapter tests/import/adapters.test.ts
Deduplizierung und Edit-Erkennung tests/db/import.test.ts
Massenimport (20.000 Nachrichten) tests/db/import.test.ts
Zugriffsschutz (Allowlist, Admin, gesperrte Konten, Session-Token) tests/auth/access.test.ts
Eingabevalidierung der Suche (Injektion, Grenzwerte) tests/search/query.test.ts
Suche, Filter, Kontext, Fusion, Modellwechsel tests/db/search.test.ts
RRF, SQL-Parameter, Filterbildung tests/search/rrf-sql.test.ts
Chunking tests/search/chunking.test.ts
KI-Provider mit gemockten HTTP-Antworten tests/ai/providers.test.ts
Quellenzuordnung von KI-Antworten tests/security/search-highlight.test.ts
Datenschutz-Leitplanken tests/analysis/privacy.test.ts
Statistiken und Themen tests/db/stats-topics.test.ts
CSRF/Origin, XSS-sicheres Rendering tests/security/…, tests/ui/rendering.test.tsx
Bypass-Sperre in Produktion, keine DB-Anlage bei inaktivem Bypass tests/auth/bypass.test.ts, tests/auth/bypass-db-isolation.test.ts

Integrationstests

docker compose up -d db
npm run db:migrate
npm run test:db         # RUN_DB_TESTS=1

Ohne RUN_DB_TESTS=1 werden sie übersprungen (describe.skip), damit npm test auch ohne Docker funktioniert.

Ende-zu-Ende

npx playwright install chromium
npm run sample:generate -- --count 400
npm run sample:import -- data/samples/discord-export-sample.json
npm run worker &
npm run dev &
TEST_DATABASE_URL=postgresql://quackanal:quackanal@localhost:5432/quackanal npm run e2e

Die E2E-Tests legen eine Session direkt in der Testdatenbank an (kein OIDC nötig) und prüfen: Zugriffsschutz ohne Session, Dashboard, Suche inkl. Sprung zum Original, Datenschutz-Hinweise, Themen, Status und Administration.


Bekannte Einschränkungen

  1. Historischer Backfill per Bot ist nicht garantiert. Discord zeigt einem Bot nur Nachrichten ab dem Zeitpunkt der Berechtigung. Historische Daten gehören über Dateiimport.
  2. Kein Attachmentspeicher. Anhänge werden nicht importiert; die Aussage „hat 3 Anhänge“ bleibt bestehen, der Inhalt fehlt.
  3. Embeds und Sticker fehlen. Nachrichten, deren Inhalt nur in einem Embed oder Sticker steckt, gelten als „ohne Text“ und werden standardmäßig übersprungen.
  4. DiscordChatExporter-CSV ohne Zeitzone wird als UTC interpretiert. Für deutsche Sommerzeit verschieben sich solche Nachrichten dadurch um bis zu zwei Stunden.
  5. Embeddings sind asynchron. Bis der Backfill durch ist, arbeitet die Suche nur mit Stichworten. Das ist beabsichtigt und wird in der Oberfläche angezeigt.
  6. Themenlisten sind Momentaufnahmen. Sie werden beim Öffnen der Seite erzeugt, nicht laufend nachgeführt.
  7. Keine Mehrmandantenfähigkeit. Ein AUTH_ALLOW_ALL=true gibt allen angemeldeten Personen Zugriff auf denselben Bestand.
  8. RRF-Werte sind keine Wahrscheinlichkeiten. Sie sind nur zur Reihenfolge innerhalb einer Suche geeignet.
  9. Keine Volltextsuche in Anhängen, reactionen oder Embeds, weil diese Daten nicht gespeichert werden.
  10. docker compose ist für die Entwicklung gedacht, nicht für den Produktivbetrieb. Für Produktion: TLS, eigene Secrets, getrennte Datenbankinstanz, Monitoring.
  11. E2E-Tests wurden in dieser Umgebung nicht ausgeführt, weil kein Docker verfügbar war. Sie sind enthalten und lauffähig (siehe oben).

Weiterführend