Compare commits

..

30 Commits

Author SHA1 Message Date
noroot
b4d4ee8d3c feat!: Produktwechsel zu KI-Antragsprüfung — Phase 1 (Datenmodell, Regelwerk, Katalog)
Deklarix war eine Pre-Publish-Kennzeichnungsprüfung für Werbe-Content
(UWG/MStV). Dieser Scope wird komplett verworfen und durch eine
KI-Antragsprüfung ersetzt: Mitarbeitende beschreiben ein KI-Vorhaben,
das System leitet Datenklasse und KI-VO-Einstufung ab, gleicht sie
gegen einen Werkzeugkatalog ab und erzeugt einen Entscheidungsvorschlag
mit Herleitung — ein Mensch entscheidet, das System bereitet nur vor.

BREAKING CHANGE: Migration 0008 droppt alle werberechtsspezifischen
Tabellen (submission, finding, extraction, evidence_package,
participant, platform_connection, asset). account/app_user/session/
audit_log bleiben (Mandantentrennung, Login, Protokollierung sind
produktunabhängig) — app_user.role wechselt von
creator/agentur/marke/kanzlei/admin zu den fünf neuen Rollen
mitarbeiter/verantwortlicher/pruefer/admin/betreiber (vier
Mandanten-Rollen + eine plattformweite, siehe CLAUDE.md).

Entfernt: internal/extract, internal/dossier, internal/evidence,
internal/socialconnect, alte rules/*.yaml (UWG-Regeln), testdata/golden
— alles ausschließlich für das alte Produkt.

Neu, Phase 1 der Baureihenfolge ("Datenmodell, Regelwerk als YAML,
Katalogstruktur"):
- Store: abteilung (Stammdaten), werkzeug + werkzeug_sperre (der
  eigentliche Wert des Produkts — zentral gepflegter Katalog mit
  mandantenspezifischen Ergänzungen/Sperrungen, Pflichtfelder
  letzte_pruefung/quelle für jede Zusicherung), antrag (Fragebogen-
  Grundgerüst, Antworten als JSONB für den adaptiven Fragebogen aus
  Phase 2).
- internal/rules komplett neu: lädt und validiert drei YAML-
  Regelwerke (Datenklasse-Ableitung, KI-VO-Einstufung, Anforderungs-
  profil) aus rules/*.yaml — noch ohne Auswertungslogik gegen echte
  Fragebogen-Antworten (das ist Phase 3, bewusst erst nach dem
  Fragebogen aus Phase 2, der die exakten Fakten-Feldnamen festlegt).
  Offene fachliche Annahmen (Rangfolge der Datenklassen, Fragebogen-
  Lücke für die "verboten"-Varianten) explizit in rules/OPEN.md
  dokumentiert statt geraten.
- Web-Layer auf Minimalgerüst reduziert, das kompiliert und die neue
  Rollenwelt trägt: Firma-Registrierung (Ebene 1, erster Nutzer wird
  admin), Login/Logout, Plattform-Bereich (Ebene 5, nur betreiber:
  Dashboard, Accounts-Übersicht, Audit-Log) — Fragebogen (Ebene 2) und
  Fachebene (Ebene 3) folgen in den nächsten Phasen.
- CLAUDE.md komplett neu geschrieben: Produktbeschreibung, Fünf-Ebenen-
  Rollenmodell, Fragebogen-Spezifikation, Ableitungstabellen,
  Werkzeugkatalog, Bewertungslogik (geplant), Onboarding, offene
  Punkte (u. a. Postgres-RLS-Frage aus der Frontend-Spezifikation
  noch nicht entschieden, "Admin und Verantwortlicher gleichzeitig"
  beim Onboarding noch nicht datenmodelliert).

Volle Testsuite inkl. echter Postgres-Tests grün. End-to-End gegen
einen laufenden Server verifiziert: Firma-Registrierung legt Account +
admin-Nutzer an, Betreiber-Login leitet zu /betreiber, mandanten-
übergreifende Accounts-Liste sichtbar für betreiber, 404 für
mitarbeiter auf /betreiber, 303 zu /login ohne Sitzung.
2026-08-28 21:39:04 +02:00
noroot
6fd7831784 feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.

- Migration 0007: asset.purpose ('initial' | 'insights', Default
  'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
  kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
  GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
  damit ein späterer Insights-Upload nie den beim Archivieren
  referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
  ungetestete gegen echte Instagram-Daten, aber isoliert testbare
  Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
  daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
  "story"-Beiträgen ohne existierendes insights-Asset; POST
  /beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
  Validierung wie das initiale Standbild, wiederverwendet über
  readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
  Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
  CLAUDE.md-Hinweis dazu.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
2026-08-28 10:10:44 +02:00
noroot
5813e6209c feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.

- internal/socialconnect: Connector-Interface + InstagramConnector/
  TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
  Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
  TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
  gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
  API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
  Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
  finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
  Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
  nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
  (State-Cookie gegen CSRF, Redirect zum Consent-Screen),
  GET /oauth/{platform}/callback (State prüfen, Code tauschen,
  Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
  Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
  "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.

Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
2026-08-28 09:32:43 +02:00
noroot
790ab20651 feat: Standbild-Upload bei der Pre-Publish-Prüfung
CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption,
Standbild und Vertragslage rein" — bisher wurde nur die Caption
verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt.

- internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission.
  Migration 0005 macht asset append-only (Trigger fehlte seit 0001,
  weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück
  wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei
  extraction/finding/evidence_package.
- handleCheck liest ein optionales "standbild"-Formularfeld (Bild-
  Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es
  VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so
  keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und
  legt die Asset-Zeile an.
- handleArchive bindet den Asset-Hash (falls vorhanden) in den
  Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war
  bereits vorbereitet, wurde aber nie befüllt).
- index.html: Formular auf multipart/form-data umgestellt
  (hx-encoding + enctype), neues optionales Dateifeld. handleCheck
  bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm
  liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen"
  behandelt, nicht wie ein Fehler).
- ASSET_DIR neue Konfigurationsvariable (Default "assets", wie
  DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein
  postinst-Healing nötig, anders als bei RULES_DIR, dessen Default
  nicht zum installierten Pfad passt).

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Upload, Hash in DB, Hash im
erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
2026-08-27 21:45:03 +02:00
noroot
ae59e745c0 fix: Admin nach Login direkt zum Admin-Bereich leiten
Ein Admin-Login hat kein eigenes Produkt zu prüfen — die Pre-Publish-
Prüfung ("/") ist die Startseite für Mandanten (Creator/Agentur/Marke/
Kanzlei). Nutzer hat nach dem Login zu Recht gefragt, wieso ein Admin
dieses Formular sieht. Login leitet role=admin jetzt direkt zu /admin,
alle anderen Rollen weiterhin zu "/".
2026-08-27 19:06:05 +02:00
noroot
8e5d06aafb fix: Admin-Link in der Navigation zeigen
Nach dem Login landet jeder Nutzer (auch ein Admin, jeder Login gehört
zu einem Account) auf der normalen Startseite — ohne einen Link zu
/admin in der Navigation war der Admin-Bereich für einen Admin ohne
die URL im Kopf praktisch unerreichbar (genau das hat der Nutzer nach
dem Login gemeldet).

navData{IsAdmin} wird jetzt von jeder angemeldeten Seite (Start,
Beiträge, Beitrag-Detail, alle Admin-Seiten) an den gemeinsamen
"nav"-Template-Block durchgereicht; der Link erscheint nur für
role=admin. Test deckt beide Fälle ab (Admin sieht den Link, Mandant
nicht).
2026-08-27 19:04:04 +02:00
noroot
835ad9f0a7 feat: Admin-Bereich (Accounts, Kanzlei-Verzeichnis-Freigabe, Audit-Log)
Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.

Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
  dorthin — /register bietet die Rolle nicht an, erster Admin wird
  einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
  (§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
  wie finding/extraction/evidence_package).

Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
  und -Detail (Logins je Account), requireAdmin (404 statt 403 für
  angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
  schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
  Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
  "kanzlei" haben.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
2026-08-27 18:17:45 +02:00
noroot
c9d71b0d54 feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.

Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
  (Plattform, Status, höchste Finding-Schwere) via
  ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
  Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
  .../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
  htmx ohne Seiten-Reload.

Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.

Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
2026-08-27 17:54:33 +02:00
noroot
6df1d2961b feat: add participant CRUD and per-account submission listing
Participant (the Verantwortungsmatrix — who briefed, who approved) is
not append-only like finding/extraction/evidence_package; getting a
role wrong and correcting it isn't rewriting evidence, so full CRUD is
legitimate here: Create/List/Get/Update/Delete. UpdateParticipant sets
approved_at the first time freigegeben flips to true and never moves it
again on subsequent no-op updates — it marks when approval first
happened, not "last touched".

ListSubmissionsForAccount is the query the upcoming archive overview
needs: every submission for a tenant plus a findings count and highest
severity, computed with the same anti-join ListCurrentFindings already
uses for "currently valid" findings.

Also fixed a real bug this surfaced: CreateFinding let a nil Sources
slice reach a NOT NULL TEXT[] column, which Postgres rejects with an
unhelpful constraint error instead of a clear message. It now normalizes
nil to an empty slice before inserting — matters for any future rule
that ships without a fundstelle entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:46:31 +02:00
noroot
77de1627c5 feat: add mobile-first styling matching enconf's CI
Pages were unstyled HTML until now. design/enterprise.css (enconf's
4400-line stylesheet) is tightly coupled to Ant Design/React class
names and a fixed desktop sidebar layout — not usable as-is for
Deklarix's plain server-rendered forms. Instead, internal/web/static/
app.css is a small, purpose-built mobile-first stylesheet that reuses
enconf's actual design tokens (primary blue #1677ff, radius scale,
shadows, Inter) for brand consistency without dragging in the
unrelated layout/framework rules.

Inter is self-hosted (copied from enconf's font files) rather than
pulled from Google Fonts, keeping the "no runtime internet dependency"
property. Only the "latin" subset is included — German umlauts and ß
all live in U+0000-00FF, so the cyrillic/greek/vietnamese subsets
enconf ships aren't needed here.

Inputs/buttons are sized for touch (44px min-height) and use 16px font
size to avoid iOS's auto-zoom-on-focus. Findings are color-coded by
severity (red/amber/green backgrounds with a matching left border).

Verified visually with chromium --headless --screenshot at both mobile
(390px) and desktop (1280px) viewports, including a real WK-001 finding
fetched from the running server and rendered through the actual
stylesheet — not just asserted via HTTP status codes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:41:57 +02:00
noroot
374f4ade31 fix: heal missing RULES_DIR in postinst on upgrade
Discovered live: upgrading the test server to v0.2.0 crash-looped with
"rules: read dir: open .: no such file or directory". Its
/etc/deklarix/deklarix.env predates RULES_DIR entirely (created on
first install, before that variable existed) — postinst never
overwrites an existing env file, by design, so the variable was simply
missing rather than set. main.go's relative default "rules" then
resolved against WorkingDirectory=/var/lib/deklarix instead of the
actual install path (/usr/share/deklarix/rules).

postinst now appends RULES_DIR with its packaged default whenever it's
absent, on both fresh installs and upgrades — never overwriting an
existing value. Same healing pattern enconf uses for DB_SSLMODE.
Verified: reinstalling over the broken config fixed it and the service
came back up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:24:41 +02:00
noroot
34b1d8d2a6 feat: replace Claude-based extraction with a rule-based engine
Deklarix itself no longer depends on the Anthropic API — that was a
separate API key/billing relationship from Claude Code (used to develop
Deklarix), which the user did not intend to take on for the product
itself.

Consideration (Gegenleistung) is no longer guessed from text — it's a
required form field now, since only the submitter actually knows
whether a business relationship existed. A keyword-only system can't
tell a covertly-paid post from a genuinely organic one; they read
identically. What internal/extract *can* still determine reliably and
deterministically from the caption: whether a disclosure keyword is
present (werbung, anzeige, bezahlte partnerschaft, paid partnership,
#ad, #werbung, #anzeige, #sponsored, #sponsoredby, #sponsoredpost —
case-insensitive), its exact original-case wording, and whether it sits
before the platform's "mehr anzeigen" truncation point (~125 chars
Instagram, ~150 TikTok — rough estimates, platforms change these without
notice, verify before real customer use).

internal/extract's Anthropic HTTP client and tool-use schema are gone
(client.go/api.go deleted), replaced by engine.go — a stateless Engine
with no network calls. extract.Result/ParsePayload keep the exact same
JSON shape as before (gegenleistung/kennzeichnung_vorhanden/
kennzeichnung_wortlaut/kennzeichnung_vor_kuerzung), so internal/store and
internal/dossier needed no changes at all — only extract itself, the web
form/handler (new consideration field), and main.go (no more
ANTHROPIC_API_KEY requirement) changed.

Trade-off the user was told and accepted: without an LLM, the system can
no longer independently catch undisclosed paid content that carries no
recognizable keyword at all — that now rests on the submitter's honesty.
Creative or implicit disclosure phrasing outside the keyword list also
won't be recognized.

Verified against a real running instance with zero API keys configured:
register -> check (real rule engine, correctly triggered WK-004 for a
disclosure placed 130 characters in, past the Instagram threshold) ->
archive -> PDF dossier download, all against real Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:15:39 +02:00
noroot
8f638df387 docs: document app.deklarix.de and the reverse proxy setup
app.deklarix.de is the canonical app URL (DNS now points at the test
server, 89.163.205.4); no separate app.deklarix.com since the root
.com already redirects to .de. nginx + certbot now terminate TLS there
and proxy to the local deklarix service, verified end-to-end over real
HTTPS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:50:52 +02:00
noroot
5156fe62da feat: wire auth into the web layer (Schritt 2, part 2/2)
Registration creates a new account plus its first user; login
authenticates an existing one; both set a deklarix_session cookie
(HttpOnly, SameSite=Strict, Secure only when the request itself came
over TLS — hardcoding Secure=true would break local http://localhost
development, since browsers won't store a Secure cookie over plaintext).

requirePage protects full-page GETs (redirects to /login); requireAPI
protects the htmx/download endpoints (401, since those are only ever
called from an already-authenticated page — an unauthenticated hit
there is the exception, e.g. a session expiring mid-use).

handleCheck now creates submissions under the current account.
handleArchive and handleDossierDownload compare the submission's
account against the caller's and return 404 on mismatch — not 403,
which would confirm the ID exists to a different tenant. Login failure
uses the same message for "no such email" and "wrong password" to avoid
account enumeration.

Restructured templates along the way: layout.html now only holds
reusable fragments ("head", "nav"); each full page (index/login/register)
is its own top-level named template. The previous layout+content nesting
would have broken the moment a second page defined "content" — Go's
html/template keys blocks by name across the whole parsed set, not per
file, so two pages both defining "content" would silently overwrite each
other.

Verified against a real running instance (not just Go's test recorder):
started the compiled binary against a fresh Postgres and drove the whole
flow with curl — anonymous redirect, registration setting a real cookie,
authenticated page load, logout clearing both the cookie and the
server-side session row, and being locked out again afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 15:56:11 +02:00
noroot
2a16dc2200 feat: add auth and multi-tenancy (Schritt 2, part 1/2)
internal/auth is pure logic (bcrypt hashing, session token generation)
with no DB access — persistence for account/app_user/session lives in
internal/store like everything else, via migration 0003.

account is the tenant (Mandant); app_user is a login inside one account;
session is a real server-side row (not a signed stateless token) so
logout can actually end a session rather than the client just
forgetting a JWT. submission.account_id is NOT NULL — added directly
rather than the nullable-then-backfill dance, since no submission rows
exist anywhere yet (verified empty on the test server before writing
the migration). Added as migration 0003 (new file), not folded into an
earlier one, since 0001/0002 are already applied on the test server.

store.ErrNotFound lets callers distinguish "wrong email" / "unknown
session" from a genuine DB error — matters for login, where those two
cases should both fail closed but for different reasons.

Not yet wired into internal/web — that's the next commit. All of this
is tested against real Postgres (14 store tests green) but isn't
reachable from any HTTP handler yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 15:48:40 +02:00
noroot
f5ad08cebd feat: wire persistence into the web layer (check → archive → dossier)
This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.

extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.

internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.

internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:

- POST /pruefen persists submission + extraction + findings and marks
  the submission "checked". A needsClarification result persists the
  extraction (there's something worth keeping) but no findings and no
  status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
  the canonical submission+facts+findings metadata, gets an RFC-3161
  timestamp, generates the PDF dossier to disk, and persists the
  evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.

Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.

cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:39:38 +02:00
noroot
72005fc326 feat: store finding as structured title/fix/sources, not flat message
Migration 0002 replaces finding.message with title/fix/sources (a
Postgres text[]). A finding needs to render into the dossier the way it
looked at the moment it was raised — referencing the current rules/*.yaml
by rule_id+version isn't safe once that file is edited for a later
version, since old wording isn't kept around as a separate live file.
Added as a new migration rather than editing 0001, since that's already
applied on the test server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:39:14 +02:00
noroot
e0218337e7 feat: add store CRUD methods for submission/extraction/finding/evidence
Create/Get for submission (including the one legitimate status
transition — submission is not append-only, unlike the other three),
Create/GetLatest for extraction and evidence_package, Create for
finding plus ListCurrentFindings which applies the anti-join documented
in the migration (a finding referenced by another row's `supersedes`
is not "current").

Tested against real Postgres, including that the append-only trigger
still rejects UPDATE on evidence_package via this new code path, and
that ListCurrentFindings actually hides a finding once a correction
supersedes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:30:08 +02:00
noroot
31caa8f66a feat: add web layer (internal/web) and wire it into main.go
Routing, html/template layout/content pattern, and the Pre-Publish
check flow: POST /pruefen runs extraction (Stufe 1) then rules.Evaluate
(Stufe 2) and renders the result as an htmx fragment. Nothing is
persisted yet — that's the next step (wiring internal/store in).

The needsClarification case is rendered explicitly as a request for
more information rather than "no findings", matching the core
principle. Every result carries the legal-advice disclaimer required by
CLAUDE.md's guardrails.

Server depends on a narrow Extractor interface rather than *extract.
Client directly, so tests inject a fake instead of calling the real API
— internal/web's test suite never touches the network. htmx is vendored
locally (internal/web/static/htmx.min.js) instead of loaded from a CDN,
keeping the UI usable without runtime internet access.

cmd/deklarix/main.go now wires all of this together: reads
ANTHROPIC_API_KEY (required) and RULES_DIR (default "rules"), builds
the extract client and loads the rule set, and serves web.Server instead
of the old inline health-only mux.

This exposed the same crash-loop risk fixed earlier for DATABASE_URL:
postinst's start guard only checked DATABASE_URL, so a fresh install
would now crash-loop on a missing ANTHROPIC_API_KEY instead. The guard
checks both. scripts/build.sh also now ships rules/*.yaml into the .deb
under /usr/share/deklarix/rules (not a conffile — rules are updated via
the release pipeline, never hand-edited on a server), and
deklarix.env.example points RULES_DIR there by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:23:45 +02:00
noroot
db373e51ce feat: add PDF dossier generation (internal/dossier)
Content assembly (BuildContent) is separate from PDF drawing (Render),
so the actual business logic — what goes into the evidence dossier, in
what form, with which mandatory fields — is unit-testable without
parsing PDF bytes. BuildContent refuses to produce a dossier missing
its evidentiary fields (timestamp token, asset/metadata hash, platform)
rather than emitting one with silently empty proof sections. Every
dossier carries the "this is not legal advice" disclaimer required by
CLAUDE.md's guardrails.

Uses github.com/go-pdf/fpdf (actively maintained fork of jung-kurt/
gofpdf, no dependencies beyond the Go stdlib) for rendering. Its core
fonts use cp1252 internally, so a small cp1252.map (copied from the
fpdf module, embedded via go:embed) drives UnicodeTranslator — German
umlauts render correctly without needing an external font file at
runtime, keeping Deklarix a single binary. Verified visually with
pdftotext/pdfinfo against a generated sample.

Tests build a real, structurally valid RFC-3161 token offline (a
throwaway self-signed cert + timestamp.Timestamp.CreateResponse), so
BuildContent/Render/Generate are fully tested without hitting a real
TSA — unlike the network-gated integration test in internal/evidence.

Also adds evidence.TimestampTime(), extracted from the parsing logic
already used by the TSA client, since the dossier needs to show the
timestamped time to a human reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:18:01 +02:00
noroot
917b51299b feat: add RFC-3161 timestamping (internal/evidence)
HTTPTimestamper builds a Request directly from a precomputed SHA-256
digest (Request{HashAlgorithm, HashedMessage, Nonce}.Marshal(), not
CreateRequest(io.Reader) which would hash the input itself), POSTs it to
a TSA, and validates that the parsed response's hash algorithm, hashed
message and nonce actually match what was sent before accepting the
token — a response that doesn't match the request isn't a valid
timestamp for that hash, regardless of whether it parses.

Uses github.com/digitorus/timestamp for the RFC-3161/ASN.1 encoding
rather than hand-rolling it.

TSA choice (open point in CLAUDE.md): FreeTSA.org for now — free,
RFC-3161-compliant, verified end-to-end with a live smoke test, but
not eIDAS-qualified. Documented as needing an upgrade to a qualified
provider (D-Trust, Bundesdruckerei, ...) before real customer use, same
treatment as the open legal questions in rules/OPEN.md.

Tests cover request/response validation without network (hash size
guard, a fake server that parses and checks the incoming request,
non-200 and malformed-response handling) plus a real integration test
against FreeTSA gated behind DEKLARIX_TSA_INTEGRATION, mirroring the
DATABASE_URL-gated pattern in internal/store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:07:53 +02:00
noroot
d54636f57a feat: add evidence hashing (internal/evidence)
SHA-256 over raw asset bytes and over the canonical JSON form of
metadata (encoding/json already sorts map keys and preserves struct
field order deterministically, so no separate canonicalization library
is needed for our own fixed types). A Timestamper interface stands in
for the RFC-3161 timestamp step — no concrete implementation yet, since
which TSA to use is an open decision (CLAUDE.md "Offene Punkte") that
directly affects the archive's evidentiary weight, not a purely
technical choice to make silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:54:29 +02:00
noroot
15bf277bee feat: add jurisdiction field to rules, facts and extraction
Rule now carries a required Jurisdiction (land) field, and Facts a
matching Jurisdiction supplied by the caller (like Platform — no
legal jurisdiction can be read off a caption or image, so the model
never guesses it). Evaluate() only lets a rule fire when its
jurisdiction matches the facts' jurisdiction.

This is the structural half of "deutsche Rechtslage zuerst, Struktur
für Österreich und Schweiz vorgesehen": a future AT/CH rule set can be
added as plain new YAML files without touching existing DE rules, but
no AT/CH content is added now — that needs its own legal research
first, same as WK-001/WK-004 needed for Germany.

WK-001 and WK-004 are tagged land: DE, all golden fixtures carry
jurisdiction: DE, and extract.Input passes Jurisdiction through
unchanged into the returned Facts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:47:08 +02:00
noroot
da91c3e2c1 feat: add Claude API extraction (internal/extract)
Stufe 1 from the core principle: Client calls the Claude Messages API
directly over net/http (no SDK dependency, stays consistent with
"Go-Standard-Library wo möglich") and forces tool-use with a strict JSON
schema instead of parsing free text. Extract() returns rules.Facts
directly rather than an intermediate DTO, since producing exactly that
is the point of this stage. Platform is supplied by the caller, never
guessed by the model.

Every failure mode returns an error instead of a zero-value Facts:
network errors, non-200 API responses, a missing tool_use block, and —
critically — a gegenleistung value outside the four allowed enum
values, which would otherwise get silently coerced into a wrong fact.
Tested entirely against an httptest fake server, no real API calls.

Building this surfaced a real gap in internal/rules: Evaluate() treated
Consideration=="unklar" the same as any other value, i.e. it just
produced an empty finding list — indistinguishable from "everything's
fine". That contradicts the core principle (uncertain extraction should
trigger a user clarification, never a judgment). Evaluate() now returns
(findings, needsClarification), with a golden case covering it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:43:01 +02:00
noroot
bec34b5988 feat: add rules engine (internal/rules) with first two disclosure rules
internal/rules implements Stufe 2 from the core principle: the LLM
extracts facts, this deterministic engine judges them against versioned
YAML rules. Facts/Condition/Rule/Finding types, a loader that refuses to
load on a missing id/version or a duplicate rule id rather than silently
skipping a bad file, and Evaluate() matching facts against rules.

Two real rules grounded in verified research (see rules/OPEN.md for the
open questions that surfaced along the way):
- WK-001: no disclosure at all despite consideration (§ 5a Abs. 4 UWG,
  § 22 Abs. 1 MStV)
- WK-004: disclosure present but hidden behind a "mehr anzeigen" cut
  (§ 5a Abs. 4 UWG, Leitfaden der Medienanstalten, LG Köln 12.05.2026)

The two are deliberately disjoint (WK-004 requires disclosure_present=
true) so a post with no disclosure at all doesn't double-fire both
rules. Golden suite in testdata/golden/ covers both rules plus two clean
cases; it's this suite, not the UI, that's the actual asset per
CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:35:07 +02:00
noroot
8b08b39725 feat: apt-based deployment via Gitea Debian registry
Deklarix now ships as a .deb package instead of a raw binary, matching
the enconf-webpanel infrastructure standard: scripts/build.sh assembles
a real .deb (systemd unit, env template, postinst/prerm), scripts/release.sh
uploads it to Gitea's built-in Debian package registry after a green
test run. Target servers add one apt source and get updates via
`apt upgrade` from then on.

postinst only starts the service once DATABASE_URL is actually set in
/etc/deklarix/deklarix.env — the shipped template ships it commented out
on purpose, since an uncommented but unfilled placeholder URL is
syntactically indistinguishable from a real one and caused exactly that
crash-loop during verification.

Verified end-to-end against the real test server and Gitea registry:
upload -> apt-get update -> apt-cache policy -> apt-get install -> a
service that stays down until configured, then runs migrations and
serves /health once a real database is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:34:58 +02:00
noroot
e929e5bf26 docs: rewrite CLAUDE.md for full product spec and Postgres stack
Replaces the generic React/Vite/SQLite scaffold description with the
actual Deklarix product spec: what it does, the two-stage extract/rules
principle, the Postgres-based stack and cmd/deklarix structure, data
model, evidence chain, legal guardrails (UWG, Versicherungsaufsicht,
BRAO), QA via the golden-test suite, and the DATABASE_URL/test-system
split for scripts/test.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:51:48 +02:00
noroot
667343fa81 fix: require DATABASE_URL in test.sh instead of provisioning Postgres
Spinning up a throwaway Postgres via Docker in test.sh forced Docker
onto every local dev machine — that's the job of the dedicated test
system, not local iteration. test.sh now fails loudly if DATABASE_URL
is unset instead, so the append-only guarantee still can't be silently
skipped on the release path, without assuming Docker locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:51:31 +02:00
noroot
e9e386df85 feat: add Postgres store with append-only schema and migrations
internal/store connects via pgx and runs golang-migrate migrations
embedded in the binary (go:embed), so Deklarix stays a single binary
despite the move to Postgres. Schema covers the five MVP tables
(submission, asset, extraction, finding, evidence_package, participant).

extraction, finding and evidence_package are append-only by design: a
Postgres trigger rejects UPDATE/DELETE outright, since a corrigible
evidence archive isn't an evidence archive. Corrections to a finding are
new rows whose supersedes column points at the row they replace (set at
INSERT time on the new row, since the trigger blocks UPDATE on the old
one) — "currently valid" findings are the ones no other row supersedes.

scripts/test.sh now spins up a disposable Postgres container so the
store's integration tests (including the append-only guarantee) actually
run on every test.sh/release.sh invocation instead of silently skipping
for lack of DATABASE_URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:50:30 +02:00
noroot
bfe52c73eb refactor: rename cmd/server to cmd/deklarix
Matches the project structure and binary name defined in the Deklarix
product spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:50:22 +02:00
73 changed files with 5112 additions and 113 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build output # Build output
dist/ dist/
dossiers/
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

471
CLAUDE.md
View File

@@ -1,18 +1,242 @@
# Deklarix # Deklarix
> Projekt für deklarix.de und deklarix.com > KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein
> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse,
> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen
> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten
> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet
> nur vor.
--- ---
## Stack ## Produktwechsel (2026-08-28)
| Backend | Frontend (geplant) | Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
|---------|---------------------| Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
| Go 1.26 | React 19, TypeScript 5.9 | Dieser Scope wurde **komplett verworfen** und durch das oben
| net/http (Standard-Library-first) | Vite 8, Tailwind CSS | beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
| SQLite (geplant) | enterprise.css Design-System (enconf-Pattern) | Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt.
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main` ---
## Grundregel
**Das System entscheidet nicht. Es bereitet vor.** Der/die
Verantwortliche bestätigt, ändert oder überschreibt einen
Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede
Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird
**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort
welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit
wertlos.
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
---
## Fünf Ebenen, fünf Rollen
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
| Ebene | Zugang | Rolle(n) | Zweck |
|---|---|---|---|
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4,
Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche.
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
`AccountID` gegen den angemeldeten Account, wie bisher) — die
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
siehe Offene Punkte unten.
---
## Fragebogen (Ebene 2, "Antrag stellen")
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst.
Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI
tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit`
(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus
Stammdaten).
**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/
Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1),
Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/
Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/
Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5),
Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein
zugängliche/erfundene Inhalte (B7).
**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung):
geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine
Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/
Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang
zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es
Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne
menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI
stammt (C5)?
**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext),
Firmen- oder privater Zugang, Firmen- oder Privatgerät.
---
## Ableitungen (`rules/*.yaml`, `internal/rules`)
**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe
gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) <
`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) <
`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine
Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe
`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz-
beauftragten bestätigen.
**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge,
erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/
Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja
und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder
für die drei "verboten"-Varianten sind noch nicht abschließend
spezifiziert — siehe `rules/OPEN.md`.
**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
`kein_training_auf_eingabe`, `loeschfrist_max_tage`,
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser).
**Die Auswertung gegen echte Fragebogen-Antworten ist noch nicht
gebaut** — das ist Schritt 3 der Baureihenfolge (siehe unten), bewusst
erst, wenn der Fragebogen (Schritt 2) die exakten Fakten-Feldnamen
festlegt.
---
## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`)
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder
Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und
Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
nicht in der UI umgesetzt, siehe Offene Punkte).
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
---
## Bewertungslogik (geplant, Schritt 3+)
1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5
KI-VO), keine Werkzeugsuche.
2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
und gegen `geeignete_zwecke` filtern.
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
Prüfung.
5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen
(Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung,
Kennzeichnung, Aufbewahrung begrenzen).
6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen,
vollständige Herleitung.
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
mehrfacher Neubewertung eines Antrags erhalten bleibt.
---
## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7)
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag
Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog-
Version und der vollständige Werkzeugdatensatz werden eingefroren — ein
späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine
Entscheidung beruhte.
Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei
`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine
aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt
(Wiedervorlage).
Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck,
Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/
r, Datum, Gültigkeit. Export als PDF und CSV.
---
## Onboarding
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
Registrierung.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
--- ---
@@ -21,21 +245,23 @@
``` ```
/var/www/deklarix/ /var/www/deklarix/
├── cmd/ ├── cmd/
│ └── server/ │ └── deklarix/
│ └── main.go # Entry Point │ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
├── internal/ ├── internal/
│ ├── config/ # Konfiguration (env-basiert) │ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
│ ├── handler/ # HTTP Handler │ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
── middleware/ # Auth, Logging, CORS ── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/ ├── design/
│ └── enterprise.css # Gemeinsames Design-System (enconf-Basis) │ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
├── packaging/ ├── packaging/
│ └── DEBIAN/ │ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template │ └── control.tmpl # .deb Package-Control-Template
├── scripts/ ├── scripts/
│ ├── build.sh # Cross-Compile amd64 + arm64 │ ├── build.sh # Cross-Compile amd64 + arm64
│ ├── test.sh # Tests + vet + build-check │ ├── test.sh # Tests + vet + build-check
│ └── release.sh # Vollständiger Release-Prozess │ └── release.sh # Vollständiger Release-Prozess
├── go.mod ├── go.mod
├── go.sum ├── go.sum
└── CLAUDE.md └── CLAUDE.md
@@ -43,13 +269,70 @@
--- ---
## Datenmodell
- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau
einem Account.
- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von
`mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier
Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5).
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte
Tabelle statt zustandsloser signierter Tokens, damit Logout eine
Sitzung wirklich beendet.
- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede
Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel
und Akteur.
- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.
- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral,
gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der
Katalog wird gepflegt, eine Entscheidung friert den zu ihrem
Zeitpunkt gültigen Datensatz separat ein (geplant).
- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags
durch einen einzelnen Mandanten.
- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die
vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein
starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden.
NICHT append-only (normale Zustandsänderung `entwurf`
`eingereicht``entschieden`, wie `submission` es im alten Produkt
war).
- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht
gebaut**, geplant für Schritt 5/6 der Baureihenfolge.
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
`registereintrag` werden bei ihrer Einführung ebenfalls append-only.
---
## Baureihenfolge
1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~**erledigt**
(dieser Stand: `account`/`app_user`/`session`/`audit_log`/
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
2. Fragebogen mit adaptiver Logik
3. Ableitungen und harte Filter (Auswertung gegen echte Antworten)
4. Ergebnisdarstellung mit Herleitung
5. Entscheidung, Snapshot, Audit-Log
6. Registereintrag und Export
7. Wiedervorlage und Katalog-Benachrichtigung
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
---
## Go Commands ## Go Commands
```bash ```bash
export PATH=$PATH:/usr/local/go/bin # Immer setzen! export PATH=$PATH:/usr/local/go/bin # Immer setzen!
# Entwickeln # Entwickeln
go run ./cmd/server/ go run ./cmd/deklarix/
# Tests # Tests
./scripts/test.sh ./scripts/test.sh
@@ -78,28 +361,56 @@ go vet ./...
# 1. Alle Änderungen committen # 1. Alle Änderungen committen
git add -p && git commit -m "feat: ..." git add -p && git commit -m "feat: ..."
# 2. Release-Skript (macht Tests → Build → Tag → Push) # 2. Release-Skript (macht Tests → .deb-Build → Gitea-Upload → Tag → Push)
./scripts/release.sh 1.2.0 ./scripts/release.sh 1.2.0
# Danach liegt in dist/: # Danach liegt in dist/:
# deklarix_1.2.0_amd64 # deklarix_1.2.0_amd64.deb
# deklarix_1.2.0_arm64 # deklarix_1.2.0_arm64.deb
``` ```
### Was das Release-Skript tut ### Was das Release-Skript tut
1. Prüft: sauberer Git-Status (keine uncommitted changes) 1. Prüft: sauberer Git-Status (keine uncommitted changes)
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check) 2. Führt `./scripts/test.sh` aus (vet + race tests + build-check, braucht `DATABASE_URL`)
3. Kompiliert für `linux/amd64` und `linux/arm64` 3. Baut `.deb`-Pakete für `linux/amd64` und `linux/arm64` (`scripts/build.sh`)
4. Setzt Git-Tag `v<version>` mit Annotierung 4. Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal `testing`,
5. Pusht `main` + Tag nach `origin` Suites `bookworm` + `trixie`) — siehe „Deployment“ unten
5. Setzt Git-Tag `v<version>` mit Annotierung
6. Pusht `main` + Tag nach `origin`
### Deployment (apt, Infrastruktur-Standard wie bei enconf)
Updates laufen ausschließlich über ein apt-Paket, nie über manuelles
Kopieren eines Binaries. `.deb`-Pakete landen in Giteas eingebauter
Debian-Paketregistrierung unter der Gitea-Org `projekte` (gleicher
Mechanismus wie beim Schwesterprojekt enconf-webpanel). Zielserver
richten sich einmalig ein:
```bash
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL https://git.netcell-it.de/api/packages/projekte/debian/repository.key \
-o /etc/apt/keyrings/deklarix-gitea.asc
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $(lsb_release -cs) testing" \
| sudo tee /etc/apt/sources.list.d/deklarix.list
sudo apt-get update && sudo apt-get install deklarix
```
Danach: `apt upgrade` für Updates, `systemctl {start,stop,status} deklarix`
für den Dienst. Konfiguration liegt in `/etc/deklarix/deklarix.env`
(aus `deklarix.env.example` beim Erstinstall kopiert, `DATABASE_URL` ist
dort absichtlich auskommentiert — der Dienst startet erst, wenn sie
gesetzt ist; `postinst` startet ihn sonst bewusst nicht, um keine
Crash-Restart-Schleife gegen einen Platzhalter-Host zu erzeugen).
Aktuell gibt es nur den Kanal `testing` (kein `stable` — es gibt noch
keine Kunden, für die eine Trennung nötig wäre).
--- ---
## Testing-Pattern ## Testing-Pattern & Qualitätssicherung
```go ```go
// Datei: internal/handler/health_test.go // Datei: internal/web/health_test.go
package handler_test package web_test
import ( import (
"net/http" "net/http"
@@ -117,39 +428,38 @@ func TestHealth(t *testing.T) {
} }
``` ```
- Tests liegen neben dem Code: `handler/foo_test.go` - Tests liegen neben dem Code: `paket/foo_test.go`
- Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box) - Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box)
- Race-Detector immer an: `go test -race ./...` - Race-Detector immer an: `go test -race ./...`
- Tabellenbasierte Tests für mehrere Inputs - Tabellenbasierte Tests für mehrere Inputs
--- **DATABASE_URL und Testsystem.** `internal/store` hat Integrationstests
gegen echtes Postgres (u. a. die Append-only-Garantie). Ohne
## Design-System `DATABASE_URL` überspringt `go test` diese Tests einfach — praktisch für
schnelle lokale Iteration ohne lokales Postgres. `./scripts/test.sh`
Das Frontend folgt dem **Enterprise Light Theme** aus enconf (`design/enterprise.css`). (und damit `release.sh`) ist strenger: es bricht ohne `DATABASE_URL` mit
Fehler ab, statt die Append-only-Garantie still zu überspringen. Es
- **Primärfarbe:** `#1677ff` (Blau) zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
- **Sidebar:** Dunkel (`#0B1426``#101D33`) mit weißen Icons dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
- **Body:** `#F8FAFC` Hintergrund, `#334155` Text dort muss `DATABASE_URL` vorkonfiguriert sein.
- **Font:** Inter (von `/fonts/inter.css` oder Google Fonts)
- **Radius:** 6px / 8px / 10px
Für neue Frontend-Projekte unter `/var/www/deklarix`:
```bash
# Frontend-Scaffold (wenn benötigt)
npm create vite@latest frontend -- --template react-ts
cd frontend && npm install
# enterprise.css aus design/ einbinden
```
--- ---
## Domains ## Leitplanken, die dauerhaft gelten
| Domain | Verwendung | - **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor,
|--------|-----------| entscheidet nicht. Keine Formulierung darf eine individuelle
| deklarix.de | Primär | rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
| deklarix.com | Redirect / International | - **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
stehen in `rules/OPEN.md`, werden nicht geraten.
- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und
`letzte_pruefung` sind Pflichtfelder, keine Ausnahme.
- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten,
welche einzelne Person welches Werkzeug wie oft nutzt — das
widerspricht dem Vertrauensmodell, auf dem die freiwillige
Antragstellung beruht.
- **DSGVO und Hosting in der EU.**
--- ---
@@ -169,15 +479,35 @@ git push origin main
### Server-Prozess ### Server-Prozess
```bash ```bash
# Start (manuell) # Dienst läuft über systemd (installiert per apt, siehe Deployment oben)
PORT=8080 ./dist/deklarix_latest_amd64 & sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
# Logs prüfen # Logs prüfen
journalctl -u deklarix -f journalctl -u deklarix -f
``` ```
### Bekannte Server
| Rolle | Host | Zugang |
|---|---|---|
| Testsystem + `app.deklarix.de` (DB-Integrationstests, apt-Verifikation, nginx-Reverse-Proxy + Let's-Encrypt-TLS auf den lokalen Dienst) | 89.163.205.4 | SSH `noroot`, sudo passwortlos |
| Installer-Endpoint (`get.deklarix.com`) | 89.163.205.110 | noch nicht eingerichtet |
| Marketing-Site (`deklarix.de`/`.com`) | 89.163.205.80 | noch nicht eingerichtet |
--- ---
## Arbeitsweise für Claude Code
- Kleine Commits, ein Thema pro Commit
- Erst Test, dann Implementierung
- Keine neue Abhängigkeit ohne Rückfrage
- Fehler nicht verschlucken, keine stillen Fallbacks
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
`rules/OPEN.md` notieren
## Vor Änderungen ## Vor Änderungen
1. `go vet ./...` — keine Fehler 1. `go vet ./...` — keine Fehler
@@ -190,3 +520,32 @@ journalctl -u deklarix -f
2. `./scripts/build.sh <version>` → erfolgreich 2. `./scripts/build.sh <version>` → erfolgreich
3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:` 3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:`
4. Bei Release: `./scripts/release.sh <version>` 4. Bei Release: `./scripts/release.sh <version>`
---
## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
(siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen.
- **Genaue Fragebogen-Felder für die drei "verboten"-Varianten** (KI-VO
Art. 5) — Fragebogen-Spezifikation nennt nur eine C3-Frage ohne die
nötige Unterscheidung, siehe `rules/OPEN.md`.
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
— die Frontend-Spezifikation fordert das explizit ("nicht in der
Anwendungslogik"), aktuell ist Isolation ausschließlich
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten.
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
zwei `app_user`-Zeilen für dieselbe Person?
- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der
Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt
noch keine Ebene-5/Betreiber-UI zur Katalogpflege.
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.

69
cmd/deklarix/main.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
func main() {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
log.Fatal("DATABASE_URL is required")
}
if err := store.Migrate(databaseURL); err != nil {
log.Fatalf("migrate: %v", err)
}
db, err := store.Open(context.Background(), databaseURL)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer db.Close()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {
rulesDir = "rules"
}
rulesFS := os.DirFS(rulesDir)
// Beim Start laden und validieren, auch wenn die Auswertungslogik
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
// zum Absturz bringen, sondern gar nicht erst starten lassen.
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
if err != nil {
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
}
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
if err != nil {
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
}
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
if err != nil {
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
}
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
server, err := web.NewServer(db)
if err != nil {
log.Fatalf("web server: %v", err)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
log.Printf("Deklarix server starting on %s", addr)
if err := http.ListenAndServe(addr, server); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,27 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true}`)
})
log.Printf("Deklarix server starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}

21
go.mod
View File

@@ -1,3 +1,24 @@
module github.com/netcell-it/deklarix module github.com/netcell-it/deklarix
go 1.26.6 go 1.26.6
require (
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
github.com/go-pdf/fpdf v0.9.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.55.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)

100
go.sum Normal file
View File

@@ -0,0 +1,100 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

56
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,56 @@
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
// internal/store (account, app_user, session), damit Persistenz an
// einer Stelle im Projekt gebündelt bleibt.
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
// vorhersehbaren Mustern verleiten.
const MinPasswordLength = 8
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
const SessionDuration = 30 * 24 * time.Hour
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("auth: passwort hashen: %w", err)
}
return string(hash), nil
}
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
// ein Angreifer für User-Enumeration nutzen könnte.
func VerifyPassword(hash, password string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return fmt.Errorf("auth: passwort ungültig: %w", err)
}
return nil
}
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
// (32 Byte, hex-kodiert).
func NewSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: token generieren: %w", err)
}
return hex.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,50 @@
package auth_test
import (
"testing"
"github.com/netcell-it/deklarix/internal/auth"
)
func TestHashPasswordRejectsShortPassword(t *testing.T) {
if _, err := auth.HashPassword("kurz"); err == nil {
t.Fatal("expected error for a too-short password, got nil")
}
}
func TestHashAndVerifyRoundTrip(t *testing.T) {
hash, err := auth.HashPassword("ein-langes-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
}
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
hash, err := auth.HashPassword("ein-langes-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
t.Fatal("expected error for a wrong password, got nil")
}
}
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
a, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
b, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if a == b {
t.Fatal("expected two distinct tokens, got the same value twice")
}
if len(a) != 64 {
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
}
}

111
internal/rules/loader.go Normal file
View File

@@ -0,0 +1,111 @@
package rules
import (
"fmt"
"io/fs"
"gopkg.in/yaml.v3"
)
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
var rw DatenklasseRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
}
if rw.Version < 1 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
}
if len(rw.Stufen) == 0 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
}
seenIDs := map[string]bool{}
seenRaenge := map[int]string{}
for _, st := range rw.Stufen {
if st.ID == "" {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
}
if seenIDs[st.ID] {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
}
seenIDs[st.ID] = true
if other, ok := seenRaenge[st.Rang]; ok {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
}
seenRaenge[st.Rang] = st.ID
if len(st.Ausloeser) == 0 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
}
}
return rw, nil
}
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
var rw EinstufungRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
}
if rw.Version < 1 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
}
if len(rw.Stufen) == 0 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
}
seenIDs := map[string]bool{}
for i, st := range rw.Stufen {
if st.ID == "" {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
}
if seenIDs[st.ID] {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
}
seenIDs[st.ID] = true
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
}
}
return rw, nil
}
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
var rw AnforderungsRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
}
if rw.Version < 1 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
}
if len(rw.Anforderungen) == 0 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
}
seenIDs := map[string]bool{}
for _, a := range rw.Anforderungen {
if a.ID == "" {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
}
if seenIDs[a.ID] {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
}
seenIDs[a.ID] = true
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
}
}
return rw, nil
}

View File

@@ -0,0 +1,132 @@
package rules_test
import (
"io/fs"
"os"
"testing"
"testing/fstest"
"github.com/netcell-it/deklarix/internal/rules"
)
func realRulesFS(t *testing.T) fs.FS {
t.Helper()
return os.DirFS("../../rules")
}
func TestLoadDatenklasseRealFile(t *testing.T) {
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
if err != nil {
t.Fatalf("LoadDatenklasse: %v", err)
}
if rw.Version != 1 {
t.Fatalf("Version = %d, want 1", rw.Version)
}
if len(rw.Stufen) != 6 {
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
}
byID := map[string]rules.DatenklasseStufe{}
for _, st := range rw.Stufen {
byID[st.ID] = st
}
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
if _, ok := byID[id]; !ok {
t.Errorf("expected Stufe %q to be defined", id)
}
}
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
t.Error("expected besondere_kategorie to outrank personenbezogen")
}
}
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
fsys := fstest.MapFS{
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
version: 1
stufen:
- {id: a, rang: 1, ausloeser: [b1]}
- {id: b, rang: 1, ausloeser: [b2]}
`)},
}
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
t.Fatal("expected an error for duplicate rang values")
}
}
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
fsys := fstest.MapFS{
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
stufen:
- {id: a, rang: 1, ausloeser: [b1]}
`)},
}
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
t.Fatal("expected an error for a missing version")
}
}
func TestLoadEinstufungRealFile(t *testing.T) {
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
if err != nil {
t.Fatalf("LoadEinstufung: %v", err)
}
if len(rw.Stufen) != 4 {
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
}
if rw.Stufen[0].ID != "verboten" {
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
}
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
}
}
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
fsys := fstest.MapFS{
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
version: 1
stufen:
- {id: verboten, varianten: []}
- {id: minimal, varianten: []}
`)},
}
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
t.Fatal("expected an error when a non-last Stufe has no Varianten")
}
}
func TestLoadAnforderungenRealFile(t *testing.T) {
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
if err != nil {
t.Fatalf("LoadAnforderungen: %v", err)
}
wantIDs := []string{
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
}
if len(rw.Anforderungen) != len(wantIDs) {
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
}
byID := map[string]bool{}
for _, a := range rw.Anforderungen {
byID[a.ID] = true
}
for _, id := range wantIDs {
if !byID[id] {
t.Errorf("expected Anforderung %q to be defined", id)
}
}
}
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
fsys := fstest.MapFS{
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
version: 1
anforderungen:
- {id: x}
`)},
}
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
t.Fatal("expected an error for an Anforderung without any trigger")
}
}

View File

@@ -0,0 +1,68 @@
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
// exakten Fakten-Feldnamen festlegt).
package rules
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
// gewinnt") — höherer Rang gewinnt.
type DatenklasseStufe struct {
ID string `yaml:"id"`
Rang int `yaml:"rang"`
Ausloeser []string `yaml:"ausloeser"`
Folge string `yaml:"folge"`
}
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
// Fragebogen-Abschnitt B.
type DatenklasseRegelwerk struct {
Version int `yaml:"version"`
Stufen []DatenklasseStufe `yaml:"stufen"`
}
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
// dieses Paket.
type EinstufungVariante map[string]any
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
type EinstufungStufe struct {
ID string `yaml:"id"`
Quelle string `yaml:"quelle,omitempty"`
Varianten []EinstufungVariante `yaml:"varianten"`
}
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
type EinstufungRegelwerk struct {
Version int `yaml:"version"`
Stufen []EinstufungStufe `yaml:"stufen"`
}
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
type AnforderungsRegel struct {
ID string `yaml:"id"`
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
Beschreibung string `yaml:"beschreibung,omitempty"`
}
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
// Datenklasse/Einstufung -> Anforderungsprofil.
type AnforderungsRegelwerk struct {
Version int `yaml:"version"`
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
}

View File

@@ -0,0 +1,85 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
type Abteilung struct {
ID string
AccountID string
Name string
CreatedAt time.Time
}
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
var a Abteilung
err := s.Pool.QueryRow(ctx, `
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
RETURNING id, account_id, name, created_at
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
if err != nil {
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
}
return a, nil
}
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
// alphabetisch — als Auswahlliste für den Fragebogen.
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, name, created_at FROM abteilung
WHERE account_id = $1 ORDER BY name
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list abteilungen: %w", err)
}
defer rows.Close()
var out []Abteilung
for rows.Next() {
var a Abteilung
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan abteilung: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list abteilungen: %w", err)
}
return out, nil
}
// GetAbteilung liest eine Abteilung anhand ihrer ID.
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
var a Abteilung
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Abteilung{}, ErrNotFound
}
if err != nil {
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
}
return a, nil
}
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
// angelegt).
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete abteilung: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,62 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAbteilungCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
a, err := s.CreateAbteilung(ctx, accID, "IT")
if err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
got, err := s.GetAbteilung(ctx, a.ID)
if err != nil {
t.Fatalf("GetAbteilung: %v", err)
}
if got.Name != "IT" || got.AccountID != accID {
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
}
list, err := s.ListAbteilungenForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListAbteilungenForAccount: %v", err)
}
if len(list) != 1 || list[0].ID != a.ID {
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
}
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
t.Fatalf("DeleteAbteilung: %v", err)
}
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
}
}
func TestAbteilungIsolatedPerAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
list, err := s.ListAbteilungenForAccount(ctx, accB)
if err != nil {
t.Fatalf("ListAbteilungenForAccount: %v", err)
}
if len(list) != 0 {
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
}
}

71
internal/store/account.go Normal file
View File

@@ -0,0 +1,71 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account.
type Account struct {
ID string
Name string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
return a, nil
}
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,138 @@
package store_test
import (
"context"
"testing"
)
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser mit role=betreiber: %v", err)
}
if u.Role != "betreiber" {
t.Fatalf("Role = %q, want betreiber", u.Role)
}
}
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
}
}
func TestListAccounts(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
before, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
after, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if len(after) != len(before)+1 {
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
}
found := false
for _, a := range after {
if a.ID == acc.ID {
found = true
}
}
if !found {
t.Fatal("expected the newly created account in ListAccounts")
}
}
func TestListUsersForAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
otherAccID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
list, err := s.ListUsersForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListUsersForAccount: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
}
}
func TestAuditLogCreateAndList(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
if entry.ActorUserID != admin.ID {
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
}
list, err := s.ListAuditLog(ctx, 10)
if err != nil {
t.Fatalf("ListAuditLog: %v", err)
}
if len(list) == 0 {
t.Fatal("expected at least one audit entry")
}
if list[0].ID != entry.ID {
t.Fatalf("expected the newest entry first, got %+v", list[0])
}
}
func TestAuditLogIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
}
}

135
internal/store/antrag.go Normal file
View File

@@ -0,0 +1,135 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
type Antrag struct {
ID string
AccountID string
ErstellerUserID string
AbteilungID *string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Antworten []byte
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
func scanAntrag(row interface {
Scan(dest ...any) error
}) (Antrag, error) {
var a Antrag
err := row.Scan(
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
VALUES ($1, $2, $3, $4)
RETURNING `+antragColumns,
accountID, erstellerUserID, abteilungID, titel,
)
a, err := scanAntrag(row)
if err != nil {
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
}
return a, nil
}
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
a, err := scanAntrag(row)
if errors.Is(err, pgx.ErrNoRows) {
return Antrag{}, ErrNotFound
}
if err != nil {
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
}
return a, nil
}
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
// Status hier nicht).
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
UPDATE antrag SET
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
WHERE id = $1
RETURNING `+antragColumns,
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
)
a, err := scanAntrag(row)
if errors.Is(err, pgx.ErrNoRows) {
return Antrag{}, ErrNotFound
}
if err != nil {
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
}
return a, nil
}
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
if err != nil {
return fmt.Errorf("store: set antrag status: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
// zuerst.
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list antraege for account: %w", err)
}
defer rows.Close()
var out []Antrag
for rows.Next() {
a, err := scanAntrag(rows)
if err != nil {
return nil, fmt.Errorf("store: scan antrag: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list antraege for account: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,107 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func testUserID(t *testing.T, s *store.Store, accountID string) string {
t.Helper()
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u.ID
}
func TestAntragCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
if err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
if a.Status != "entwurf" {
t.Fatalf("Status = %q, want entwurf", a.Status)
}
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
}
got, err := s.GetAntrag(ctx, a.ID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if got.Titel != "Angebotstexte generieren" {
t.Fatalf("Titel = %q, unerwartet", got.Titel)
}
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
if err != nil {
t.Fatalf("UpdateAntragFelder: %v", err)
}
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
}
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
t.Fatalf("SetAntragStatus: %v", err)
}
got, err = s.GetAntrag(ctx, a.ID)
if err != nil {
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
}
if got.Status != "eingereicht" {
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
}
}
func TestGetAntragNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestSetAntragStatusNotFound(t *testing.T) {
s := openTestStore(t)
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
userA := testUserID(t, s, accA)
userB := testUserID(t, s, accB)
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
t.Fatalf("CreateAntrag (A): %v", err)
}
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
t.Fatalf("CreateAntrag (B): %v", err)
}
listA, err := s.ListAntraegeForAccount(ctx, accA)
if err != nil {
t.Fatalf("ListAntraegeForAccount (A): %v", err)
}
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
}
}

View File

@@ -0,0 +1,61 @@
package store
import (
"context"
"fmt"
"time"
)
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
type AuditEntry struct {
ID string
ActorUserID string
Action string
TargetType string
TargetID string
Details string
CreatedAt time.Time
}
// CreateAuditEntry protokolliert eine Admin-Aktion.
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
var e AuditEntry
err := s.Pool.QueryRow(ctx, `
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
`, actorUserID, action, targetType, targetID, details).Scan(
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
)
if err != nil {
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
}
return e, nil
}
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
FROM audit_log ORDER BY created_at DESC LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan audit entry: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
return out, nil
}

116
internal/store/auth_test.go Normal file
View File

@@ -0,0 +1,116 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAccountCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
}
func TestUserCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if byEmail.ID != user.ID {
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
}
byID, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if byID.Email != "team@example.com" || byID.AccountID != accID {
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
}
}
func TestGetUserByEmailNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestUserEmailIsUnique(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser (1): %v", err)
}
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
t.Fatal("expected error for a duplicate email, got nil")
}
}
func TestSessionCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
got, err := s.GetSession(ctx, sess.Token)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.UserID != user.ID {
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
}
if err := s.DeleteSession(ctx, sess.Token); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
}
}
func TestGetSessionNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetSession(context.Background(), "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}

48
internal/store/migrate.go Normal file
View File

@@ -0,0 +1,48 @@
package store
import (
"database/sql"
"embed"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
// weiterhin als einzelnes Binary lauffähig bleibt.
func Migrate(databaseURL string) error {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return fmt.Errorf("store: open db for migration: %w", err)
}
defer db.Close()
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
if err != nil {
return fmt.Errorf("store: migration driver: %w", err)
}
source, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("store: migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
if err != nil {
return fmt.Errorf("store: migrate init: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("store: migrate up: %w", err)
}
return nil
}

View File

@@ -0,0 +1,11 @@
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
DROP TRIGGER IF EXISTS finding_append_only ON finding;
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
DROP FUNCTION IF EXISTS forbid_update_delete();
DROP TABLE IF EXISTS participant;
DROP TABLE IF EXISTS evidence_package;
DROP TABLE IF EXISTS finding;
DROP TABLE IF EXISTS extraction;
DROP TABLE IF EXISTS asset;
DROP TABLE IF EXISTS submission;

View File

@@ -0,0 +1,89 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE submission (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
post_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
caption TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE asset (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
path TEXT NOT NULL,
sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
CREATE TABLE extraction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
payload JSONB NOT NULL,
model_version TEXT NOT NULL,
prompt_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
-- siehe Index unten für den Anti-Join.
CREATE TABLE finding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
extraction_id UUID REFERENCES extraction (id),
rule_id TEXT NOT NULL,
rule_version INTEGER NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
message TEXT NOT NULL,
supersedes UUID REFERENCES finding (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
CREATE TABLE evidence_package (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
dossier_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
timestamp_token BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE participant (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
name TEXT NOT NULL,
vorgegeben BOOLEAN NOT NULL DEFAULT false,
freigegeben BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER extraction_append_only
BEFORE UPDATE OR DELETE ON extraction
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TRIGGER finding_append_only
BEFORE UPDATE OR DELETE ON finding
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TRIGGER evidence_package_append_only
BEFORE UPDATE OR DELETE ON evidence_package
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1,5 @@
ALTER TABLE finding DROP COLUMN title;
ALTER TABLE finding DROP COLUMN fix;
ALTER TABLE finding DROP COLUMN sources;
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;

View File

@@ -0,0 +1,11 @@
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
ALTER TABLE finding DROP COLUMN message;
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;

View File

@@ -0,0 +1,4 @@
ALTER TABLE submission DROP COLUMN account_id;
DROP TABLE session;
DROP TABLE app_user;
DROP TABLE account;

View File

@@ -0,0 +1,33 @@
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
-- werden.
CREATE TABLE account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
CREATE TABLE app_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE session (
token TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app_user (id),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
-- Zeit vor Auth existieren (kein Kunde live).
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);

View File

@@ -0,0 +1,6 @@
DROP TRIGGER audit_log_append_only ON audit_log;
DROP TABLE audit_log;
ALTER TABLE account DROP COLUMN verified;
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));

View File

@@ -0,0 +1,36 @@
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
-- verified markiert eine Kanzlei-Account als für das öffentliche,
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
-- Anwendungsschicht, nicht die Datenbank.
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
-- kann, ist kein Nachweis mehr.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_user_id UUID NOT NULL REFERENCES app_user (id),
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1 @@
DROP TRIGGER asset_append_only ON asset;

View File

@@ -0,0 +1,9 @@
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
-- finding/evidence_package — es nachträglich austauschen zu können,
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
-- sind.
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1 @@
DROP TABLE platform_connection;

View File

@@ -0,0 +1,21 @@
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
-- OAuth verbinden (siehe internal/socialconnect), damit die
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
-- abrufen kann statt ihn manuell hochzuladen. Anders als
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
-- und werden erneuert, eine Verbindung kann getrennt und neu
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
-- ON CONFLICT in UpsertPlatformConnection).
CREATE TABLE platform_connection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
platform_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, platform)
);

View File

@@ -0,0 +1 @@
ALTER TABLE asset DROP COLUMN purpose;

View File

@@ -0,0 +1,9 @@
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
-- angelegten Zeilen unverändert.
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
CHECK (purpose IN ('initial', 'insights'));

View File

@@ -0,0 +1,104 @@
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
DROP TABLE IF EXISTS antrag;
DROP TABLE IF EXISTS werkzeug_sperre;
DROP TABLE IF EXISTS werkzeug;
DROP TABLE IF EXISTS abteilung;
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE submission (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
post_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
caption TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
account_id UUID NOT NULL REFERENCES account (id)
);
CREATE TABLE asset (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
path TEXT NOT NULL,
sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
);
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE extraction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
payload JSONB NOT NULL,
model_version TEXT NOT NULL,
prompt_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER extraction_append_only
BEFORE UPDATE OR DELETE ON extraction
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE finding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
extraction_id UUID REFERENCES extraction (id),
rule_id TEXT NOT NULL,
rule_version INTEGER NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
supersedes UUID REFERENCES finding (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
fix TEXT NOT NULL,
sources TEXT[] NOT NULL DEFAULT '{}'
);
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
CREATE TRIGGER finding_append_only
BEFORE UPDATE OR DELETE ON finding
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE evidence_package (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
dossier_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
timestamp_token BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER evidence_package_append_only
BEFORE UPDATE OR DELETE ON evidence_package
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE participant (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
name TEXT NOT NULL,
vorgegeben BOOLEAN NOT NULL DEFAULT false,
freigegeben BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE platform_connection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
platform_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, platform)
);

View File

@@ -0,0 +1,104 @@
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
-- Produkt existierte, wird entfernt. account/app_user/session/
-- audit_log bleiben — Mandantentrennung, Login und
-- Protokollierungsprinzip sind produktunabhängig.
DROP TABLE IF EXISTS platform_connection;
DROP TABLE IF EXISTS asset;
DROP TABLE IF EXISTS participant;
DROP TABLE IF EXISTS evidence_package;
DROP TABLE IF EXISTS finding;
DROP TABLE IF EXISTS extraction;
DROP TABLE IF EXISTS submission;
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
-- Sonderrolle "kanzlei".
ALTER TABLE account DROP COLUMN IF EXISTS verified;
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
-- ohne Entscheidungsrecht (reine Prüfsicht)
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
-- auf GENAU EINEN Mandanten, nicht plattformweit
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
-- entspricht der alten "admin"-Rolle vor dieser Migration
-- Die alten Rollen (creator/agentur/marke/kanzlei) haben im neuen
-- Produkt keine Bedeutung mehr.
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
CREATE TABLE abteilung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, name)
);
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
-- markiert einen zentral gepflegten, für alle Mandanten identischen
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
CREATE TABLE werkzeug (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account (id),
name TEXT NOT NULL,
anbieter TEXT NOT NULL,
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
avv_url TEXT NOT NULL DEFAULT '',
training_opt_out BOOLEAN NOT NULL DEFAULT false,
training_standard BOOLEAN NOT NULL DEFAULT false,
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
letzte_pruefung TIMESTAMPTZ NOT NULL,
quelle TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
-- sperren, ohne den zentralen Katalog selbst zu verändern.
CREATE TABLE werkzeug_sperre (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
grund TEXT NOT NULL,
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, werkzeug_id)
);
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
-- Spaltenschema würde das nicht abbilden). status ist eine normale
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
CREATE TABLE antrag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
abteilung_id UUID REFERENCES abteilung (id),
titel TEXT NOT NULL,
beschreibung TEXT NOT NULL DEFAULT '',
ergebnis TEXT NOT NULL DEFAULT '',
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
antworten JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

62
internal/store/session.go Normal file
View File

@@ -0,0 +1,62 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
// eine Session wird immer über ihren Token nachgeschlagen.
type Session struct {
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
}
// CreateSession speichert eine neue Sitzung. token muss bereits ein
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
INSERT INTO session (token, user_id, expires_at)
VALUES ($1, $2, $3)
RETURNING token, user_id, expires_at, created_at
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
if err != nil {
return Session{}, fmt.Errorf("store: create session: %w", err)
}
return sess, nil
}
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
if err != nil {
return Session{}, fmt.Errorf("store: get session: %w", err)
}
return sess, nil
}
// DeleteSession beendet eine Sitzung (Logout).
func (s *Store) DeleteSession(ctx context.Context, token string) error {
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
return fmt.Errorf("store: delete session: %w", err)
}
return nil
}

31
internal/store/store.go Normal file
View File

@@ -0,0 +1,31 @@
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store hält den Verbindungspool zur Postgres-Datenbank.
type Store struct {
Pool *pgxpool.Pool
}
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
func Open(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("store: open pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("store: ping: %w", err)
}
return &Store{Pool: pool}, nil
}
// Close gibt den Verbindungspool frei.
func (s *Store) Close() {
s.Pool.Close()
}

View File

@@ -0,0 +1,70 @@
package store_test
import (
"context"
"os"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
func testDatabaseURL(t *testing.T) string {
t.Helper()
url := os.Getenv("DATABASE_URL")
if url == "" {
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
}
return url
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
url := testDatabaseURL(t)
if err := store.Migrate(url); err != nil {
t.Fatalf("Migrate: %v", err)
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(s.Close)
return s
}
// testAccountID legt einen Mandanten an und liefert dessen ID.
func testAccountID(t *testing.T, s *store.Store) string {
t.Helper()
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
return acc.ID
}
func TestMigrateAndOpen(t *testing.T) {
url := testDatabaseURL(t)
if err := store.Migrate(url); err != nil {
t.Fatalf("Migrate: %v", err)
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
var tableCount int
err = s.Pool.QueryRow(context.Background(), `
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = ANY($1)
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
if err != nil {
t.Fatalf("query tables: %v", err)
}
if tableCount != 8 {
t.Fatalf("expected 8 tables, got %d", tableCount)
}
}

103
internal/store/user.go Normal file
View File

@@ -0,0 +1,103 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
// Login: falsche E-Mail vs. Datenbankfehler).
var ErrNotFound = errors.New("store: nicht gefunden")
// User ist ein Login innerhalb eines Account (Mandanten).
type User struct {
ID string
AccountID string
Email string
PasswordHash string
Role string
CreatedAt time.Time
}
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
// speichert nur, es hasht nicht selbst.
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, email, password_hash, role, created_at
`, accountID, email, passwordHash, role).Scan(
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
)
if err != nil {
return User{}, fmt.Errorf("store: create user: %w", err)
}
return u, nil
}
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE email = $1
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("store: get user by email: %w", err)
}
return u, nil
}
// GetUser liest einen Nutzer anhand seiner ID.
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE id = $1
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("store: get user: %w", err)
}
return u, nil
}
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
// Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan user: %w", err)
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
return out, nil
}

301
internal/store/werkzeug.go Normal file
View File

@@ -0,0 +1,301 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert
// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral
// gepflegten, für alle Mandanten identischen Katalogeintrag; ein
// gesetzter AccountID ist eine mandantenspezifische Ergänzung.
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
// muss belegbar sein.
type Werkzeug struct {
ID string
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
Zertifizierungen []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
CreatedAt time.Time
UpdatedAt time.Time
}
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
// Create/Update — bei 14 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
Zertifizierungen []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface {
Scan(dest ...any) error
}) (Werkzeug, error) {
var w Werkzeug
err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
)
return w, err
}
// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die
// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine
// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
// Fall wie früher bei finding.sources).
func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Zertifizierungen == nil {
in.Zertifizierungen = []string{}
}
if in.GeeigneteZwecke == nil {
in.GeeigneteZwecke = []string{}
}
if in.Einschraenkungen == nil {
in.Einschraenkungen = []string{}
}
}
// CreateWerkzeug legt einen Katalogeintrag an.
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, `
INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
if err != nil {
return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err)
}
return w, nil
}
// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags
// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist
// bewusst nicht append-only — anders als ein Beweisstück ist der
// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, `
UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
letzte_pruefung = $13, quelle = $14, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
if errors.Is(err, pgx.ErrNoRows) {
return Werkzeug{}, ErrNotFound
}
if err != nil {
return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err)
}
return w, nil
}
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
w, err := scanWerkzeug(row)
if errors.Is(err, pgx.ErrNoRows) {
return Werkzeug{}, ErrNotFound
}
if err != nil {
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
}
return w, nil
}
// DeleteWerkzeug entfernt einen Katalogeintrag.
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete werkzeug: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+werkzeugColumns+` FROM werkzeug w
WHERE (w.account_id IS NULL AND NOT EXISTS (
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
)) OR w.account_id = $1
ORDER BY w.name
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
}
defer rows.Close()
var out []Werkzeug
for rows.Next() {
w, err := scanWerkzeug(rows)
if err != nil {
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
}
return out, nil
}
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
// Mandanten-Sperrungen.
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
`)
if err != nil {
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
}
defer rows.Close()
var out []Werkzeug
for rows.Next() {
w, err := scanWerkzeug(rows)
if err != nil {
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
}
return out, nil
}
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
// erzeugt wurde.
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
var count int
var lastUpdate time.Time
err := s.Pool.QueryRow(ctx, `
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
`).Scan(&count, &lastUpdate)
if err != nil {
return "", fmt.Errorf("store: current katalog version: %w", err)
}
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
}
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
// dabei unverändert.
type WerkzeugSperre struct {
ID string
AccountID string
WerkzeugID string
Grund string
GesperrtAm time.Time
}
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
var sp WerkzeugSperre
err := s.Pool.QueryRow(ctx, `
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
VALUES ($1, $2, $3)
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
if err != nil {
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
}
return sp, nil
}
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
tag, err := s.Pool.Exec(ctx, `
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
`, accountID, werkzeugID)
if err != nil {
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
}
defer rows.Close()
var out []WerkzeugSperre
for rows.Next() {
var sp WerkzeugSperre
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
}
out = append(out, sp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,219 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{
AccountID: accountID,
Name: name,
Anbieter: "Beispiel-Anbieter GmbH",
Verarbeitungsort: "EU",
AVVVerfuegbar: true,
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: 30,
Zertifizierungen: []string{"ISO 27001"},
GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
Quelle: "https://beispiel.example/beleg",
}
}
func TestWerkzeugCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug"))
if err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
if w.AccountID != nil {
t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID)
}
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
}
got, err := s.GetWerkzeug(ctx, w.ID)
if err != nil {
t.Fatalf("GetWerkzeug: %v", err)
}
if got.Name != "Zentrales Werkzeug" {
t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name)
}
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
updateInput.AufbewahrungTage = 14
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
if err != nil {
t.Fatalf("UpdateWerkzeug: %v", err)
}
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
}
if err := s.DeleteWerkzeug(ctx, w.ID); err != nil {
t.Fatalf("DeleteWerkzeug: %v", err)
}
if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
}
}
func TestUpdateWerkzeugNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x"))
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A"))
if err != nil {
t.Fatalf("CreateWerkzeug (zentral): %v", err)
}
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug"))
if err != nil {
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
}
// Anderer Mandant darf das eigene Werkzeug nicht sehen.
otherAcc := testAccountID(t, s)
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
if err != nil {
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
}
for _, w := range otherList {
if w.ID == eigenes.ID {
t.Fatal("expected the other account's own werkzeug to stay isolated")
}
}
list, err := s.ListWerkzeugeForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListWerkzeugeForAccount: %v", err)
}
byID := map[string]bool{}
for _, w := range list {
byID[w.ID] = true
}
if !byID[zentral.ID] || !byID[eigenes.ID] {
t.Fatalf("expected both the central and the own werkzeug, got %+v", list)
}
}
func TestWerkzeugSperreHidesCentralEntry(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug"))
if err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil {
t.Fatalf("CreateWerkzeugSperre: %v", err)
}
list, err := s.ListWerkzeugeForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListWerkzeugeForAccount: %v", err)
}
for _, w := range list {
if w.ID == zentral.ID {
t.Fatal("expected the sperred central werkzeug to be hidden for this account")
}
}
// Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar.
otherAcc := testAccountID(t, s)
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
if err != nil {
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
}
found := false
for _, w := range otherList {
if w.ID == zentral.ID {
found = true
}
}
if !found {
t.Fatal("expected the central werkzeug to remain visible for a different account")
}
if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil {
t.Fatalf("DeleteWerkzeugSperre: %v", err)
}
listAfter, err := s.ListWerkzeugeForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err)
}
found = false
for _, w := range listAfter {
if w.ID == zentral.ID {
found = true
}
}
if !found {
t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre")
}
}
func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
before, err := s.CurrentKatalogVersion(ctx)
if err != nil {
t.Fatalf("CurrentKatalogVersion: %v", err)
}
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
after, err := s.CurrentKatalogVersion(ctx)
if err != nil {
t.Fatalf("CurrentKatalogVersion (2): %v", err)
}
if before == after {
t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before)
}
}
func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil {
t.Fatalf("CreateWerkzeug (zentral): %v", err)
}
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B"))
if err != nil {
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
}
list, err := s.ListZentraleWerkzeuge(ctx)
if err != nil {
t.Fatalf("ListZentraleWerkzeuge: %v", err)
}
for _, w := range list {
if w.ID == eigenes.ID {
t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege")
}
}
}

View File

@@ -0,0 +1,145 @@
package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
)
type authPageData struct {
Title string
Error string
}
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
}
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
}
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
return
}
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
return
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
return
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
return
}
email := r.FormValue("email")
password := r.FormValue("password")
ctx := r.Context()
user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
return
}
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
return
}
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
// getrennt vom Mandantenbereich).
if user.Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = s.store.DeleteSession(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
// HTTP schlicht nicht speichern.
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
token, err := auth.NewSessionToken()
if err != nil {
return err
}
expiresAt := time.Now().Add(auth.SessionDuration)
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
Expires: expiresAt,
})
return nil
}

View File

@@ -0,0 +1,139 @@
package web
import (
"net/http"
)
type betreiberDashboardData struct {
Title string
Nav navData
AccountCount int
RecentAuditCount int
}
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
// vom Mandantenbereich).
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
accounts, err := s.store.ListAccounts(ctx)
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
auditLog, err := s.store.ListAuditLog(ctx, 5)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberDashboardData{
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
}
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberAccountListItem struct {
ID string
Name string
CreatedAt string
}
type betreiberAccountListData struct {
Title string
Nav navData
Accounts []betreiberAccountListItem
}
// handleBetreiberAccountList listet alle Mandanten der Plattform.
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListAccounts(r.Context())
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
for _, a := range accounts {
data.Accounts = append(data.Accounts, betreiberAccountListItem{
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberUserView struct {
Email string
Role string
}
type betreiberAccountDetailData struct {
Title string
Nav navData
AccountID string
Name string
Users []betreiberUserView
CreatedAt string
}
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAccountDetailData{
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberAuditEntryView struct {
CreatedAt string
Action string
TargetType string
TargetID string
Details string
}
type betreiberAuditLogData struct {
Title string
Nav navData
Entries []betreiberAuditEntryView
}
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
for _, e := range entries {
data.Entries = append(data.Entries, betreiberAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

26
internal/web/handlers.go Normal file
View File

@@ -0,0 +1,26 @@
package web
import (
"fmt"
"net/http"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}
type indexData struct {
Title string
Nav navData
}
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
data := indexData{Title: "Start", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

117
internal/web/middleware.go Normal file
View File

@@ -0,0 +1,117 @@
package web
import (
"context"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
const sessionCookieName = "deklarix_session"
type contextKey int
const userContextKey contextKey = iota
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return store.User{}, false
}
sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil {
return store.User{}, false
}
if time.Now().After(sess.ExpiresAt) {
return store.User{}, false
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil {
return store.User{}, false
}
return user, true
}
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
// werden nur aus einer bereits authentifizierten Seite heraus
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "betreiber" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll.
type navData struct {
IsBetreiber bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
return navData{IsBetreiber: currentUser(r).Role == "betreiber"}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
// kein Laufzeitfall, den man abfangen sollte.
func currentUser(r *http.Request) store.User {
user, ok := r.Context().Value(userContextKey).(store.User)
if !ok {
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
}
return user
}

84
internal/web/server.go Normal file
View File

@@ -0,0 +1,84 @@
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
store Store
templates *template.Template
}
// NewServer erstellt den Server.
func NewServer(st Store) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{
store: st,
templates: tmpl,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}

546
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,546 @@
package web_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
const testSessionCookie = "deklarix_session"
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
// Handler-Tests keine echte Postgres-Instanz brauchen.
type fakeStore struct {
mu sync.Mutex
nextID int
accounts map[string]store.Account
users map[string]store.User
usersByEmail map[string]string // email -> user id
sessions map[string]store.Session
auditLog []store.AuditEntry
}
func newFakeStore() *fakeStore {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
}
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
}
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc
return acc, nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
return acc, nil
}
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
out = append(out, acc)
}
return out, nil
}
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.usersByEmail[email]; exists {
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
}
u := store.User{
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
Role: role, CreatedAt: time.Now(),
}
f.users[u.ID] = u
f.usersByEmail[email] = u.ID
return u, nil
}
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.usersByEmail[email]
if !ok {
return store.User{}, store.ErrNotFound
}
return f.users[id], nil
}
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[id]
if !ok {
return store.User{}, store.ErrNotFound
}
return u, nil
}
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.User
for _, u := range f.users {
if u.AccountID == accountID {
out = append(out, u)
}
}
return out, nil
}
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
f.sessions[token] = sess
return sess, nil
}
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess, ok := f.sessions[token]
if !ok {
return store.Session{}, store.ErrNotFound
}
return sess, nil
}
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.sessions, token)
return nil
}
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.AuditEntry{
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
TargetID: targetID, Details: details, CreatedAt: time.Now(),
}
f.auditLog = append(f.auditLog, e)
return e, nil
}
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]store.AuditEntry, len(f.auditLog))
for i, e := range f.auditLog {
out[len(f.auditLog)-1-i] = e
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
func newServer(t *testing.T, fs *fakeStore) *web.Server {
t.Helper()
s, err := web.NewServer(fs)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return s
}
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
// Account, einen Nutzer und eine gültige Sitzung an und liefert das
// Session-Cookie.
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper()
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
}
func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
t.Helper()
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
return s, fs, cookie
}
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
func TestHandleHealth(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
}
}
func TestHandleStaticServesHTMX(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
"password": {"ein-sicheres-passwort"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
cookies := regResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("expected a session cookie to be set after registration")
}
indexResp := getWithCookie(t, s, cookies[0], "/")
if indexResp.Code != http.StatusOK {
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
}
var created store.User
for _, u := range fs.users {
created = u
}
if created.Role != "admin" {
t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role)
}
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
form := url.Values{
"account_name": {"A"}, "email": {"doppelt@example.com"},
"password": {"ein-sicheres-passwort"},
}
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
t.Fatalf("first register status = %d, want 303", resp.Code)
}
resp := postForm(t, s, nil, "/register", form)
if resp.Code != http.StatusOK {
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "schon vergeben") {
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
}
}
func TestLoginWithCorrectPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie after successful login")
}
}
func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("admin-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if loc := resp.Header().Get("Location"); loc != "/betreiber" {
t.Fatalf("Location = %q, want /betreiber", loc)
}
}
func TestLoginRejectsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, _ := fs.CreateAccount(context.Background(), "X")
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
}
if len(resp.Result().Cookies()) != 0 {
t.Fatal("expected no session cookie for a failed login")
}
}
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
}
}
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestLogoutClearsSession(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t)
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
t.Fatalf("index before logout = %d, want 200", resp.Code)
}
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
if logoutResp.Code != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
}
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
t.Fatal("expected the session to be deleted from the store after logout")
}
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
}
}
func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
}
// ─── Tests: Plattform-Bereich (nur Betreiber) ─────────────────────────
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
resp := getWithCookie(t, s, cookie, path)
if resp.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
}
}
}
func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
resp := getWithCookie(t, s, nil, "/betreiber")
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
}
}
func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := getWithCookie(t, s, betreiberCookie, "/betreiber")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
}
func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Mandant A", "a@example.com")
seedAccount(t, fs, "Mandant B", "b@example.com")
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the betreiber account list, got: %s", want, body)
}
}
}
func TestBetreiberAccountDetailShowsUsers(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com")
var accID string
for id, acc := range fs.accounts {
if acc.Name == "Mandant A" {
accID = id
}
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") {
t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String())
}
}
func TestBetreiberAuditLogShowsEntries(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") {
t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String())
}
}

397
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,397 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
@import url('/static/inter.css');
:root {
--branding-primary: #1677ff;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--color-bg: #f8fafc;
--color-text: #334155;
--color-heading: #0f172a;
--color-border: #e2e8f0;
--color-muted: #64748b;
--color-hoch: #b91c1c;
--color-hoch-bg: #fef2f2;
--color-mittel: #b45309;
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
}
h1 {
font-size: 1.5rem;
}
p {
margin: 0 0 1em;
}
a {
color: var(--branding-primary);
}
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
fester Breite ab Tablet-Größe aufwärts. */
.page {
padding: 16px;
max-width: 640px;
margin: 0 auto;
}
nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 12px 16px;
}
nav a {
color: var(--color-muted);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 24px;
}
label {
font-weight: 500;
font-size: 0.875rem;
color: var(--color-heading);
margin-top: 12px;
}
input, select, textarea, button {
font: inherit;
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
}
input, select, textarea {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
border-color: var(--branding-primary);
}
button {
margin-top: 16px;
padding: 12px 20px;
min-height: 44px;
border: none;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
button:hover {
filter: brightness(0.94);
}
button:active {
filter: brightness(0.88);
}
nav button {
margin-top: 0;
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.hinweis {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: 4px;
}
.disclaimer {
font-size: 0.8125rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
padding-top: 12px;
margin-top: 24px;
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
border-radius: var(--radius);
padding: 12px;
font-size: 0.9375rem;
}
.rueckfrage {
background: var(--color-mittel-bg);
color: var(--color-mittel);
border-radius: var(--radius);
padding: 12px;
}
.keine-findings {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.archiviert {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.findings {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.finding {
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.finding p {
margin: 6px 0 0;
font-size: 0.9375rem;
}
.finding-hoch {
background: var(--color-hoch-bg);
border-left: 4px solid var(--color-hoch);
}
.finding-mittel {
background: var(--color-mittel-bg);
border-left: 4px solid var(--color-mittel);
}
.finding-niedrig {
background: var(--color-niedrig-bg);
border-left: 4px solid var(--color-niedrig);
}
.status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius);
background: var(--color-border);
color: var(--color-muted);
}
.status-published {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beitraege-liste li a {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beteiligter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.beteiligter-kopf {
flex: 1 1 100%;
}
.beteiligter .rolle {
color: var(--color-muted);
font-weight: 400;
}
.beteiligter-form {
flex-direction: row;
align-items: center;
gap: 12px;
margin: 0;
flex: 1 1 auto;
}
.beteiligter-form label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 0;
}
.beteiligter-form input[type="checkbox"] {
width: auto;
min-height: 0;
}
.beteiligter form:last-child {
margin: 0;
}
button.entfernen {
margin: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.admin-kacheln {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-kacheln a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.kanzlei-eintrag {
display: block;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
padding: 32px 24px;
}
h1 {
font-size: 1.75rem;
}
}

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
/* Inter, selbst gehostet (aus enconf übernommen) — nur der "latin"-Subset
(U+0000-00FF u.a.), das deckt deutsche Umlaute und ß bereits ab, ohne
Zeichensätze für Kyrillisch/Griechisch/Vietnamesisch mitzuladen, die
Deklarix nicht braucht. Eine Datei für alle Schriftschnitte (variable
Font), daher font-weight als Bereich. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/static/fonts/inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -0,0 +1,22 @@
{{define "betreiber-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "betreiber-accounts"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Accounts</h1>
{{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Accounts}}
<li>
<a href="/betreiber/accounts/{{.ID}}">
<strong>{{.Name}}</strong> · {{.CreatedAt}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "betreiber-audit-log"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Audit-Log</h1>
{{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p>
{{else}}
<ul class="beteiligte">
{{range .Entries}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
{{if .Details}}<p>{{.Details}}</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "betreiber-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Plattform</h1>
<ul class="admin-kacheln">
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "index"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Willkommen bei Deklarix</h1>
<p class="hinweis">
Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten
Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits.
</p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Deklarix</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
{{end}}
{{define "nav"}}
<nav>
<a href="/">Start</a>
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>
</nav>
{{end}}

View File

@@ -0,0 +1,21 @@
{{define "login"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<button type="submit">Anmelden</button>
</form>
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "register"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Firma registrieren</h1>
<p class="hinweis">
Testzugang ist sofort aktiv. Der Bezahlbetrieb wird nach Prüfung durch
den Betreiber freigeschaltet.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1 @@
/etc/deklarix/deklarix.env.example

View File

@@ -2,6 +2,10 @@ Package: deklarix
Version: VERSION Version: VERSION
Architecture: ARCH Architecture: ARCH
Maintainer: NetCell IT <team@netcell-it.de> Maintainer: NetCell IT <team@netcell-it.de>
Description: Deklarix — deklarix.de / deklarix.com Homepage: https://deklarix.de
Deklarix Software Package. Section: web
Depends: Priority: optional
Depends: ca-certificates
Description: Deklarix — Prüfung und Nachweis für Werbe-Content
Pre-Publish-Kennzeichnungsprüfung und revisionssichere Archivierung
veröffentlichter Beiträge für Creator, Agenturen, Marken und Kanzleien.

79
packaging/DEBIAN/postinst Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Deklarix — postinst
set -e
SERVICE_USER="deklarix"
CONFIG_DIR="/etc/deklarix"
DATA_DIR="/var/lib/deklarix"
LOG_DIR="/var/log/deklarix"
case "$1" in
configure)
# ─── apt-Quelle einrichten/aktualisieren ────────────────────
# Gleiches Muster wie enconf: Gitea-Debian-Registry, Suite =
# OS-Codename, Komponente = Kanal (aktuell nur "testing"). Ein
# bereits gewählter Kanal bleibt über Upgrades hinweg erhalten.
mkdir -p /etc/apt/keyrings
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
-o /etc/apt/keyrings/deklarix-gitea.asc 2>/dev/null || true
if [ -f /etc/apt/keyrings/deklarix-gitea.asc ]; then
DX_CODENAME="$(. /etc/os-release 2>/dev/null; echo "${VERSION_CODENAME:-}")"
case "$DX_CODENAME" in bookworm|trixie) ;; *) DX_CODENAME="trixie" ;; esac
DX_CHANNEL="$(grep -oE 'projekte/debian[[:space:]]+[a-z]+[[:space:]]+(stable|testing)' \
/etc/apt/sources.list.d/deklarix.list 2>/dev/null | awk '{print $NF}' | head -1)"
case "$DX_CHANNEL" in stable|testing) ;; *) DX_CHANNEL="testing" ;; esac
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $DX_CODENAME $DX_CHANNEL" \
> /etc/apt/sources.list.d/deklarix.list
fi
# ─── System-User + Verzeichnisse ────────────────────────────
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd -r -s /usr/sbin/nologin -d "$DATA_DIR" "$SERVICE_USER"
fi
mkdir -p "$DATA_DIR" "$LOG_DIR" "$CONFIG_DIR"
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
# ─── Konfiguration ───────────────────────────────────────────
# Erstinstall: Vorlage kopieren, nicht scharf schalten (Secrets
# fehlen noch). Upgrade: bestehende deklarix.env bleibt unberührt.
if [ ! -f "$CONFIG_DIR/deklarix.env" ]; then
cp "$CONFIG_DIR/deklarix.env.example" "$CONFIG_DIR/deklarix.env"
chmod 640 "$CONFIG_DIR/deklarix.env"
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
fi
# ─── Healing: fehlende Variablen aus älteren Installationen ───
# deklarix.env wird bei Upgrades nie überschrieben (Secrets/
# Anpassungen bleiben erhalten) — Variablen, die erst nach einer
# Erstinstallation dazugekommen sind, fehlen dort sonst schlicht
# und lassen den Dienst mit einem für den Admin überraschenden
# Fehler abbrechen (z. B. RULES_DIR fehlt -> main.go sucht Regeln
# im falschen Verzeichnis, obwohl DATABASE_URL längst gesetzt
# war). Bekannte Variablen mit unkritischem Default werden hier
# ergänzt, falls sie fehlen — nie überschrieben, nur angehängt.
if [ -f "$CONFIG_DIR/deklarix.env" ]; then
grep -q '^RULES_DIR=' "$CONFIG_DIR/deklarix.env" || \
echo "RULES_DIR=/usr/share/deklarix/rules" >> "$CONFIG_DIR/deklarix.env"
fi
systemctl daemon-reload 2>/dev/null || true
systemctl enable deklarix.service >/dev/null 2>&1 || true
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
systemctl restart deklarix.service
else
echo ""
echo " → Deklarix installiert, aber noch nicht gestartet."
echo " DATABASE_URL in $CONFIG_DIR/deklarix.env setzen, dann:"
echo " systemctl start deklarix"
echo ""
fi
;;
esac
exit 0

11
packaging/DEBIAN/prerm Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Deklarix — prerm
set -e
case "$1" in
remove|deconfigure)
systemctl stop deklarix.service 2>/dev/null || true
;;
esac
exit 0

View File

@@ -0,0 +1,17 @@
# Deklarix — Konfiguration
# Wird bei der Erstinstallation nach /etc/deklarix/deklarix.env kopiert.
# Diese Datei selbst wird von apt-Updates verwaltet — Anpassungen gehören
# in deklarix.env, nicht hierhin.
PORT=8080
# Wo internal/rules das Bewertungsregelwerk (Datenklasse, KI-VO-
# Einstufung, Anforderungsprofil) findet — vom .deb-Paket nach
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
RULES_DIR=/usr/share/deklarix/rules
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
# Schleife gegen einen Platzhalter laufen zu lassen.
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Deklarix
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deklarix
Group=deklarix
EnvironmentFile=/etc/deklarix/deklarix.env
ExecStart=/usr/bin/deklarix
WorkingDirectory=/var/lib/deklarix
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/deklarix /var/log/deklarix
[Install]
WantedBy=multi-user.target

64
rules/OPEN.md Normal file
View File

@@ -0,0 +1,64 @@
# Offene fachliche Fragen zum Regelwerk
Diese Punkte wurden beim Anlegen der Regelwerk-YAML-Dateien (Phase 1)
bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
betrieblichen Datenschutzbeauftragten) klären.
## 1. Rangfolge der Datenklassen
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
diese Reihenfolge an (niedrigster zu höchstem Rang):
1. `oeffentlich`
2. `intern`
3. `auftragsdaten`
4. `personenbezogen`
5. `berufsgeheimnis`
6. `besondere_kategorie`
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
bestätigt.**
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5)
Fragebogen-Abschnitt C stellt nur eine einzelne Frage (C3: "Erkennt
oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?")
ohne Folgefragen, die zwischen den drei in der Bewertungslogik
genannten Auslösern für "verboten" unterscheiden:
- Emotionserkennung am Arbeitsplatz
- Social Scoring
- Biometrische Kategorisierung
`rules/kivo_einstufung.yaml` geht davon aus, dass Phase 2 (Fragebogen)
für C3 eine Folgefrage ergänzt, die zwischen diesen dreien (und "keins
davon") unterscheidet — analog zur bereits spezifizierten Folgefrage
bei C2. Die Feldnamen `c3_art` und dessen Werte
(`emotionserkennung_arbeitsplatz`, `social_scoring`,
`biometrische_kategorisierung`) sind Platzhalter, bis Phase 2 das
festlegt.
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
Tage") müssen vor Phase 3 fachlich festgelegt werden.

32
rules/anforderungen.yaml Normal file
View File

@@ -0,0 +1,32 @@
# Anforderungsprofil: aus Datenklasse und/oder KI-VO-Einstufung
# abgeleitete Menge von Anforderungen, die ein Werkzeug erfüllen muss.
#
# ACHTUNG: "kein_training_auf_eingabe aus intern, personenbezogen und
# höher" in der Produktspezifikation lässt offen, ob auftragsdaten
# (rang 3, zwischen intern und personenbezogen) eingeschlossen sein
# soll — hier bewusst explizit aufgeführt (siehe rules/OPEN.md), damit
# nichts über eine implizite Rangfolge erraten wird.
version: 1
anforderungen:
- id: avv_erforderlich
aus_datenklassen: [personenbezogen, besondere_kategorie, auftragsdaten]
beschreibung: "Auftragsverarbeitungsvertrag mit dem Werkzeug-Anbieter erforderlich"
- id: eu_verarbeitung
aus_datenklassen: [berufsgeheimnis, besondere_kategorie]
beschreibung: "Verarbeitung muss innerhalb der EU stattfinden"
- id: kein_training_auf_eingabe
aus_datenklassen: [intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
beschreibung: "Eingaben dürfen nicht zum Training des Werkzeugs verwendet werden"
- id: loeschfrist_max_tage
aus_datenklassen: [oeffentlich, intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
beschreibung: "maximale Aufbewahrungsdauer richtet sich nach der Datenklasse"
- id: menschliche_aufsicht
aus_einstufungen: [hochrisiko]
beschreibung: "ein Mensch muss das Ergebnis vor Wirksamkeit prüfen"
- id: kennzeichnungspflicht
aus_einstufungen: [transparenzpflicht]
beschreibung: "das Ergebnis muss als KI-generiert gekennzeichnet werden"
- id: dsfa_erforderlich
aus_datenklassen: [besondere_kategorie]
aus_einstufungen: [hochrisiko]
beschreibung: "Datenschutz-Folgenabschätzung erforderlich"

35
rules/datenklasse.yaml Normal file
View File

@@ -0,0 +1,35 @@
# Datenklasse-Ableitung aus Fragebogen-Abschnitt B. "Höchste zutreffende
# Stufe gewinnt" — rang entscheidet, welche Stufe gewinnt, wenn mehrere
# Auslöser gleichzeitig zutreffen. "Unsicher" zählt wie "Ja" (siehe
# Fragebogen-Spezifikation) — das wird in der Auswertung (Phase 3)
# umgesetzt, nicht hier.
#
# ACHTUNG: Die Rangfolge unten ist eine Annahme (nicht explizit in der
# Produktspezifikation vorgegeben), siehe rules/OPEN.md — vor Phase 3
# mit dem fachlich Verantwortlichen bestätigen.
version: 1
stufen:
- id: oeffentlich
rang: 1
ausloeser: [b7]
folge: "keine besonderen Anforderungen"
- id: intern
rang: 2
ausloeser: [b6]
folge: "Geschäftsgeheimnis-Schutz"
- id: auftragsdaten
rang: 3
ausloeser: [b5]
folge: "Weitergabe nur wenn Haupt-AVV es zulässt"
- id: personenbezogen
rang: 4
ausloeser: [b1, b4]
folge: "AVV, EU-Verarbeitung, Löschfrist"
- id: berufsgeheimnis
rang: 5
ausloeser: [b3]
folge: "§ 203 StGB — externe Verarbeitung nur mit Verpflichtung"
- id: besondere_kategorie
rang: 6
ausloeser: [b2]
folge: "zusätzlich DSFA prüfen, Art. 9 DSGVO"

View File

@@ -0,0 +1,30 @@
# KI-VO-Einstufung aus Fragebogen-Abschnitt C. Reihenfolge ist
# Prüfreihenfolge (erste zutreffende Stufe gewinnt) — "verboten" muss
# zuerst geprüft werden (K.-o.-Prüfung, siehe Bewertungslogik). Jede
# Variante ist eine UND-Verknüpfung von Fakten; mehrere Varianten pro
# Stufe sind ODER-verknüpft.
#
# ACHTUNG: c3_art (social_scoring / biometrische_kategorisierung /
# emotionserkennung_arbeitsplatz) und c2_folge sind Platzhalter-
# Feldnamen, bis Phase 2 (Fragebogen) die genauen Fakten-Schlüssel
# festlegt — siehe rules/OPEN.md.
version: 1
stufen:
- id: verboten
quelle: "Art. 5 KI-VO"
varianten:
- {c3: true, c3_art: emotionserkennung_arbeitsplatz}
- {c3: true, c3_art: social_scoring}
- {c3: true, c3_art: biometrische_kategorisierung}
- id: hochrisiko
varianten:
- {c2: true, c2_folge: beschaeftigung}
- {c2: true, c2_folge: kreditwuerdigkeit}
- {c2: true, c2_folge: leistungsbewilligung}
- {c2: true, c2_folge: bildung}
- {c2: true, c2_folge: gesundheit}
- id: transparenzpflicht
varianten:
- {c1: true, c5: false}
- id: minimal
varianten: []

View File

@@ -1,23 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deklarix — Build-Skript (amd64 + arm64) # Deklarix — .deb-Pakete bauen (amd64 + arm64)
# #
# Verwendung: # Verwendung:
# ./scripts/build.sh [version] # Beide Architekturen # ./scripts/build.sh [version] # Beide Architekturen
# ./scripts/build.sh [version] amd64 # Nur amd64 # ./scripts/build.sh [version] amd64 # Nur amd64
# ./scripts/build.sh [version] arm64 # Nur arm64 # ./scripts/build.sh [version] arm64 # Nur arm64
# #
# Ausgabe: dist/deklarix_<version>_<arch> # Ausgabe: dist/deklarix_<version>_<arch>.deb
#
# Release-Prozess:
# 1. VERSION erhöhen (Semantic Versioning: MAJOR.MINOR.PATCH)
# 2. ./scripts/build.sh <version>
# 3. ./scripts/test.sh
# 4. git tag v<version> && git push origin v<version>
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PACKAGING_DIR="$REPO_DIR/packaging"
BUILD_DIR="$REPO_DIR/dist/build"
VERSION="${1:-0.0.1}" VERSION="${1:-0.0.1}"
ARCH_FILTER="${2:-both}" ARCH_FILTER="${2:-both}"
@@ -26,9 +22,8 @@ log() { echo -e "${GRN}[build]${NC} $*"; }
export PATH=$PATH:/usr/local/go/bin export PATH=$PATH:/usr/local/go/bin
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; } command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
command -v dpkg-deb >/dev/null || { echo "dpkg-deb nicht gefunden"; exit 1; }
mkdir -p "$REPO_DIR/dist"
log "Version: $VERSION | Go: $(go version)" log "Version: $VERSION | Go: $(go version)"
@@ -39,14 +34,42 @@ case "$ARCH_FILTER" in
*) ARCHS=("amd64" "arm64") ;; *) ARCHS=("amd64" "arm64") ;;
esac esac
rm -rf "$BUILD_DIR"
mkdir -p "$REPO_DIR/dist"
for ARCH in "${ARCHS[@]}"; do for ARCH in "${ARCHS[@]}"; do
OUT="$REPO_DIR/dist/deklarix_${VERSION}_${ARCH}" DEB_NAME="deklarix_${VERSION}_${ARCH}"
log "Baue $ARCH$OUT" DEB_DIR="$BUILD_DIR/$DEB_NAME"
GOARCH=$ARCH GOOS=linux go build \
-ldflags="-X main.Version=${VERSION} -s -w" \ log "Baue Binary ($ARCH)..."
-o "$OUT" \ mkdir -p "$DEB_DIR/DEBIAN" \
./cmd/server/ "$DEB_DIR/usr/bin" \
log "$ARCH fertig: $(du -sh "$OUT" | cut -f1)" "$DEB_DIR/usr/share/deklarix/rules" \
"$DEB_DIR/etc/systemd/system" \
"$DEB_DIR/etc/deklarix"
CGO_ENABLED=0 GOARCH=$ARCH GOOS=linux go build \
-trimpath -ldflags="-X main.Version=${VERSION} -s -w" \
-o "$DEB_DIR/usr/bin/deklarix" \
./cmd/deklarix/
chmod 755 "$DEB_DIR/usr/bin/deklarix"
log "Stelle Paket zusammen ($ARCH)..."
sed "s/^Version: VERSION/Version: $VERSION/; s/^Architecture: ARCH/Architecture: $ARCH/" \
"$PACKAGING_DIR/DEBIAN/control.tmpl" > "$DEB_DIR/DEBIAN/control"
cp "$PACKAGING_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/postinst"
cp "$PACKAGING_DIR/DEBIAN/prerm" "$DEB_DIR/DEBIAN/prerm"
cp "$PACKAGING_DIR/DEBIAN/conffiles" "$DEB_DIR/DEBIAN/conffiles"
chmod 755 "$DEB_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/prerm"
cp "$PACKAGING_DIR/etc/systemd/system/deklarix.service" "$DEB_DIR/etc/systemd/system/"
cp "$PACKAGING_DIR/etc/deklarix/deklarix.env.example" "$DEB_DIR/etc/deklarix/"
cp "$REPO_DIR"/rules/*.yaml "$DEB_DIR/usr/share/deklarix/rules/"
dpkg-deb --build --root-owner-group "$DEB_DIR" "$REPO_DIR/dist/$DEB_NAME.deb"
log "$ARCH fertig: dist/$DEB_NAME.deb ($(du -sh "$REPO_DIR/dist/$DEB_NAME.deb" | cut -f1))"
done done
rm -rf "$BUILD_DIR"
log "Build abgeschlossen ✓" log "Build abgeschlossen ✓"

View File

@@ -6,13 +6,14 @@
# #
# Was passiert: # Was passiert:
# 1. Tests laufen # 1. Tests laufen
# 2. Build für amd64 + arm64 # 2. .deb-Pakete bauen (amd64 + arm64)
# 3. git tag v<version> wird gesetzt # 3. Pakete in Giteas Debian-Paketregistrierung hochladen (Kanal "testing")
# 4. git push origin main + git push origin v<version> # 4. git tag v<version> wird gesetzt
# 5. git push origin main + git push origin v<version>
# #
# Voraussetzungen: # Voraussetzungen:
# - Sauberer Git-Status (keine uncommitted changes) # - Sauberer Git-Status (keine uncommitted changes)
# - Alle Tests grün # - Alle Tests grün (inkl. DATABASE_URL für die Store-Integrationstests)
set -euo pipefail set -euo pipefail
@@ -24,8 +25,9 @@ if [[ -z "$VERSION" ]]; then
exit 1 exit 1
fi fi
GRN='\033[0;32m'; BLD='\033[1m'; NC='\033[0m' GRN='\033[0;32m'; YLW='\033[0;33m'; BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}[release]${NC} $*"; } log() { echo -e "${GRN}[release]${NC} $*"; }
warn() { echo -e "${YLW}[release]${NC} $*"; }
cd "$REPO_DIR" cd "$REPO_DIR"
@@ -43,9 +45,46 @@ log "Tests laufen..."
bash "$REPO_DIR/scripts/test.sh" bash "$REPO_DIR/scripts/test.sh"
# Build # Build
log "Build für alle Architekturen..." log "Pakete bauen..."
bash "$REPO_DIR/scripts/build.sh" "$VERSION" bash "$REPO_DIR/scripts/build.sh" "$VERSION"
# ─── apt-Kanal-Modell ────────────────────────────────────────────────────────
# Suite = OS-Codename, Komponente = Kanal. Deklarix hat aktuell nur einen
# Kanal (testing) — es gibt noch keine Kunden, für die "stable" vom
# aktuellen Entwicklungsstand getrennt werden müsste. Sobald das relevant
# wird: gleiches Promote-Modell wie enconf-webpanel (release.sh stable).
GITEA_URL="https://git.netcell-it.de"
GITEA_ORG="projekte"
GITEA_CHANNEL="testing"
APT_SUITES=(bookworm trixie)
GITEA_TOKEN="$(git -C "$REPO_DIR" remote get-url origin | grep -oP '://[^:]+:\K[^@]+' 2>/dev/null || echo '')"
if [ -z "$GITEA_TOKEN" ]; then
echo "FEHLER: Gitea-Token nicht im Git-Remote gefunden."
exit 1
fi
upload_deb() {
curl -sk -w "%{http_code}" -o /dev/null -X PUT \
"$GITEA_URL/api/packages/$GITEA_ORG/debian/pool/$2/$3/upload" \
-H "Authorization: token $GITEA_TOKEN" --upload-file "$1" 2>/dev/null
}
log "Pakete → Gitea-Debian-Registry (${APT_SUITES[*]} / $GITEA_CHANNEL)..."
for deb in "$REPO_DIR"/dist/deklarix_"${VERSION}"_*.deb; do
[ -f "$deb" ] || { echo "FEHLER: kein .deb für Version $VERSION in dist/ gefunden"; exit 1; }
b="$(basename "$deb")"
for suite in "${APT_SUITES[@]}"; do
code=$(upload_deb "$deb" "$suite" "$GITEA_CHANNEL")
if [ "$code" = "201" ] || [ "$code" = "409" ]; then
log "$b$suite/$GITEA_CHANNEL"
else
echo "FEHLER: Upload $b$suite/$GITEA_CHANNEL fehlgeschlagen (HTTP $code)"
exit 1
fi
done
done
# Git Tag # Git Tag
log "Tag v${VERSION} setzen..." log "Tag v${VERSION} setzen..."
git tag -a "v${VERSION}" -m "Release v${VERSION}" git tag -a "v${VERSION}" -m "Release v${VERSION}"
@@ -57,4 +96,5 @@ git push origin "v${VERSION}"
log "" log ""
log "Release v${VERSION} fertig ✓" log "Release v${VERSION} fertig ✓"
log "Binaries: dist/deklarix_${VERSION}_amd64 + _arm64" log "Pakete: dist/deklarix_${VERSION}_amd64.deb + _arm64.deb"
log "Installation/Update auf Zielservern: apt install/upgrade deklarix"

View File

@@ -15,6 +15,17 @@ fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
cd "$REPO_DIR" cd "$REPO_DIR"
# internal/store hat Integrationstests gegen echtes Postgres (u. a. die
# Append-only-Garantie auf finding/extraction/evidence_package). Ohne
# DATABASE_URL überspringt Go diese Tests still — das darf im
# Release-Pfad (release.sh ruft dieses Skript auf) nicht passieren.
# Postgres wird hier bewusst NICHT selbst hochgezogen — das ist Aufgabe
# des Testsystems, nicht des lokalen Dev-Rechners. DATABASE_URL muss dort
# vorkonfiguriert sein.
if [[ -z "${DATABASE_URL:-}" ]]; then
fail "DATABASE_URL ist nicht gesetzt — Store-Integrationstests (Append-only-Garantie) würden sonst still übersprungen. Auf dem Testsystem konfigurieren; für schnelle lokale Iteration ohne Postgres stattdessen gezielt 'go test ./internal/...' o. Ä. nutzen."
fi
log "go vet ..." log "go vet ..."
go vet ./... || fail "go vet fehlgeschlagen" go vet ./... || fail "go vet fehlgeschlagen"
@@ -22,6 +33,6 @@ log "go test ./... "
go test -race -count=1 ./... || fail "Tests fehlgeschlagen" go test -race -count=1 ./... || fail "Tests fehlgeschlagen"
log "Build-Check (amd64) ..." log "Build-Check (amd64) ..."
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/server/ || fail "Build fehlgeschlagen" GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/deklarix/ || fail "Build fehlgeschlagen"
log "Alle Tests bestanden ✓" log "Alle Tests bestanden ✓"