FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI - FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs - FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig - FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
This commit is contained in:
@@ -20,10 +20,67 @@ Konzeption basiert auf Recherche zu Paperless-ngx und ecoDMS (siehe `dms-feature
|
||||
|
||||
Läuft bei jedem Push/PR; ein fehlschlagender Schritt bricht den jeweiligen Job ab (kein `continue-on-error`), das blockiert den Merge sobald in Gitea die Branch-Protection-Regel "Require status checks to pass" auf diese Jobs gesetzt ist. **Wichtig:** archivdms hat aktuell noch keinen Gitea-Remote (nur lokales `git init`), die Pipeline greift daher erst, sobald das Repo zu einer Gitea-Instanz mit aktivierten Actions und registriertem Runner gepusht wird.
|
||||
|
||||
## Betrieb: Logging, Metriken & Fehler-Tracking (FDN-08)
|
||||
|
||||
Jede HTTP-Anfrage läuft durch eine Basis-Middleware-Kette in `internal/api/observability.go`
|
||||
(`requestID -> metrics -> recover -> ServeMux`, gebaut in `api.New`):
|
||||
|
||||
- **Korrelations-ID:** pro Anfrage wird eine Request-ID erzeugt oder ein mitgelieferter
|
||||
`X-Request-ID`-Header übernommen (nur alphanumerisch/`-_.`, max. 64 Zeichen — sonst verworfen,
|
||||
Schutz gegen Log-Injection). Die ID steht im `context.Context` und im Response-Header
|
||||
`X-Request-ID`. Handler loggen über `s.reqLog(r.Context())` bzw. `s.reqLog(ctx)`; jede Zeile
|
||||
bekommt dadurch automatisch `request_id=...` — auch in der asynchronen Verarbeitung
|
||||
(`ProcessDocumentJob`, `ReprocessDocument`), soweit der Request-Context durchgereicht wird.
|
||||
- **Panic-Recovery:** `net/http` hat kein zentrales Recovery. Die `recoverMiddleware` fängt jedes
|
||||
Panic ab, loggt es mit Korrelations-ID und gekürztem Stacktrace, zählt `archivdms_panics_total`
|
||||
hoch und antwortet mit einem sauberen HTTP 500 statt einer abgebrochenen Verbindung.
|
||||
- **Keine Geheimnisse in Logs:** geloggt werden nur Methode, normalisierter Pfad, Status, Dauer und
|
||||
Client-IP. Query-Strings, Header und Bodies werden nie ausgegeben (dort stehen Signed-URL-Signaturen,
|
||||
Share-Tokens, Bearer-Keys, Passwörter). `normalizeRoute` ersetzt IDs durch `{id}` und das Segment
|
||||
hinter `/share/` immer durch `{token}`.
|
||||
|
||||
### `GET /metrics`
|
||||
|
||||
Prometheus-Textformat, ohne Fremdabhängigkeit (`internal/api/metrics_handlers.go`, reines `fmt.Fprintf`).
|
||||
Bewusst **ohne Login** (ein Scraper hat keine Session), dafür **IP-beschränkt**: Loopback ist immer
|
||||
erlaubt, weitere Scraper werden über den Config-Key `api.metrics_allowed_ips` (Liste aus IPs oder
|
||||
CIDR-Bereichen, Default leer = nur localhost) freigeschaltet; alles andere bekommt 403. Der Endpunkt
|
||||
gehört nicht ins öffentliche Reverse-Proxy-Mapping.
|
||||
|
||||
Ausgegeben werden: `archivdms_http_requests_total{method,route,status}` (Fehlerrate = Anteil
|
||||
`status=~"5.."`), `archivdms_http_request_duration_seconds_{bucket,sum,count}` (Latenz-Histogramm),
|
||||
`archivdms_http_requests_in_flight`, `archivdms_goroutines`, `archivdms_uptime_seconds`,
|
||||
`archivdms_panics_total`, `archivdms_build_info{version}` sowie die Queue-Länge
|
||||
`archivdms_processing_jobs{status}` (aus `processing_jobs`, aggregiert über alle Mandanten — es
|
||||
werden keine mandantenbezogenen Daten ausgegeben). Die Label-Kardinalität ist auf 500 Serien
|
||||
gedeckelt, danach landet alles unter `route="/other"`.
|
||||
|
||||
## Frontend: Login-Pflicht & UI-Struktur
|
||||
|
||||
Alle Seiten außer `/login` sind hinter einer Session-Cookie-Prüfung (`middleware.ts`, Cookie `archivdms_session`). Ohne gültiges Cookie wird sofort (ohne sichtbares Flackern) zu `/login` umgeleitet; die vollständige JWT-Prüfung bleibt Aufgabe des Go-Backends bei jedem echten API-Call. Eingeloggte Bereiche laufen unter der Routegruppe `src/app/(app)/` mit gemeinsamer App-Shell (Sidebar, TopBar, Cmd+K-Befehlspalette, Dark Mode). `/documents` und `/reminders` sind Server Components mit serverseitigem Datenfetch (`src/lib/session.ts` reicht das Session-Cookie manuell an die Go-API weiter) statt der bisherigen "use client" + `useEffect`-Ladeschleife. Die Dokumenten-Detail-/Vorschauseite `/documents/{id}` (Server Component `src/app/(app)/documents/[id]/page.tsx` + Client-Island `DocumentPreview`) zeigt die Datei-Vorschau in einem `<iframe src="/api/documents/{id}/file">` (same-origin, Cookie-Auth greift automatisch, Browser-natives PDF/Bild-Rendering). Die Metadaten-Seitenleiste (Reiter Details/Inhalt/Verlauf) ist in die Unterkomponente `DocumentDetailsTab` ausgelagert und in einem kompakten 2-Spalten-Raster angeordnet (kurze Felder Belegdatum/Dokumenttyp/Korrespondent/Erstellt nebeneinander, Titel/Akte/Tags in voller Breite). Editierbar: Inline-Titel, Belegdatum (`<input type="date">`, `PUT /api/documents/{id}/document-date`), Dokumenttyp/Korrespondent/Akte (Command-Popover, Auswahl wird direkt beim Klick gespeichert) sowie Tags hinzufügen (`POST /api/documents/{id}/tags/{tagId}`) / entfernen (X am Badge, `DELETE ...`). Heuristische Vorschlags-Chips (inkl. erkanntem Belegdatum aus dem OCR-Text) lassen sich per Klick übernehmen. In der Kopfzeile der Detailseite liegen die Aktionen "Export" (Download-Icon, `<a href="/api/documents/{id}/export" download>` — ZIP mit Originaldatei, `metadata.json` und `ocr_text.txt`, same-origin per Session-Cookie, kein Blob-Umweg) und "Neu verarbeiten". Erreichbar über den "Vorschau"-Button bzw. das Auge-Icon in der Dokumentenliste.
|
||||
|
||||
### Basis-Komponenten & Design-Tokens (FDN-06)
|
||||
|
||||
Alle wiederverwendbaren UI-Bausteine liegen ausschließlich unter `src/components/ui/` (shadcn/ui-Stand, Radix-basiert). Neue Features benutzen diese Komponenten, statt eigene Varianten zu bauen; Anpassungen erfolgen in der Datei selbst (nicht per Kopie), Dateinamen werden nie umbenannt.
|
||||
|
||||
| Komponente | Datei | Einsatzzweck |
|
||||
|---|---|---|
|
||||
| Table | `ui/table.tsx` | Standard-Listenansicht (Dokumente, Wiedervorlage, Admin-Tabellen). Wrapper hat `overflow-auto` → horizontal scrollbar auf schmalen Viewports |
|
||||
| Dialog / Sheet | `ui/dialog.tsx`, `ui/sheet.tsx` | Modale Formulare bzw. mobile Drawer (Sidebar auf < md) |
|
||||
| Formularelemente | `ui/input.tsx`, `ui/textarea.tsx`, `ui/label.tsx`, `ui/calendar.tsx` | Eingaben; Datumsauswahl über Calendar + Popover |
|
||||
| Button | `ui/button.tsx` | Varianten (`default`/`ghost`/`outline`/`destructive`) + Größen über `cva` |
|
||||
| Badge | `ui/badge.tsx` | Status-Anzeigen (Wiedervorlage, Verarbeitungsstatus, Share-Status) |
|
||||
| Card | `ui/card.tsx` | Dashboard-Kacheln, Sektionen in Einstellungen |
|
||||
| Tabs | `ui/tabs.tsx` | Reiter in der Dokument-Detailansicht (Details/Inhalt/Verlauf) |
|
||||
| DropdownMenu / Popover / Command | `ui/dropdown-menu.tsx`, `ui/popover.tsx`, `ui/command.tsx` | Benutzermenü, Auswahl-Popover, Cmd+K-Befehlspalette |
|
||||
| Toast | `ui/sonner.tsx` | Rückmeldungen nach Mutationen (`toast()` aus `sonner`); `<Toaster />` global in `src/app/layout.tsx` |
|
||||
| Sidebar | `ui/sidebar.tsx` | Shell-Navigation inkl. Collapse-State und Mobile-Drawer |
|
||||
| Weitere | `ui/avatar.tsx`, `ui/progress.tsx`, `ui/skeleton.tsx` | Benutzer-Avatar, Upload-Fortschritt, Skeleton-Loading in `loading.tsx` |
|
||||
|
||||
Die App-Shell selbst liegt in `src/components/shell/` (`AppSidebar`, `TopBar`, `SearchBar`, `CommandPalette`) und wird von `src/app/(app)/layout.tsx` zusammengesetzt (`SidebarProvider` → `AppSidebar` + `SidebarInset` → `TopBar` + Inhaltsbereich).
|
||||
|
||||
**Design-Tokens** sind zentral gepflegt: `src/app/globals.css` definiert die HSL-Werte je Theme (`:root` = hell, `.dark` = dunkel) für `background/foreground`, `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`, die `sidebar-*`-Familie sowie `--radius`. `tailwind.config.ts` mappt sie auf Tailwind-Utilities (`bg-background`, `text-muted-foreground`, `border-border`, `rounded-lg` …). Abstände und Typografie kommen unverändert aus der Tailwind-Standardskala — bewusst kein eigener Satz, um Sonderwege zu vermeiden. Regel: keine Hex-/RGB-Literale und keine Inline-Styles für Farben in Komponenten. Einzige Ausnahme sind semantische Statusfarben (emerald/amber/red) an Badges und Farbbalken; Inline-`style` ist nur für berechnete Geometrie erlaubt (Progress-Balken, Crop-Rechtecke, OCR-Overlay-Boxen).
|
||||
|
||||
## Projektstatus
|
||||
|
||||
Scaffold + erstes fachliches Feature (Wiedervorlage/Reminder, PROJ-1) als Code geschrieben, **noch nicht gebaut/getestet** (kein Go/Node-Toolchain in dieser Umgebung ausgeführt). Vor Inbetriebnahme: `go build ./...`, `npm install && npm run build`, Migrationen gegen echte PostgreSQL-Instanz prüfen.
|
||||
@@ -73,6 +130,12 @@ Ablauf beim Upload (`POST /api/documents/upload`, multipart, Feld `file` + Pflic
|
||||
|
||||
Weil OCR erst nachgelagert läuft, richtet sich der Archivordner `<yyyy>/<mm>` nach dem Upload-Zeitpunkt; das erkannte Belegdatum landet danach in `documents.document_date`, die archivierte Datei wird dabei nie verschoben (WORM).
|
||||
|
||||
### Storage-Abstraktion & signierte Download-Links (FDN-03)
|
||||
|
||||
Der Dateizugriff liegt hinter dem Interface `objectstore.Store` (`internal/objectstore`): `Archive` (Scratch-Datei → WORM-Ablage inkl. `chmod 0440`), `Open`, `Stat`, `Delete`, `SignedURL`, `VerifySignedURL`. Einzige Implementierung ist `LocalStore` (lokales Dateisystem) — **bewusst kein S3-Treiber**, weil die WORM-/GoBD-Garantie an POSIX-Rechten (0440) hängt. Das oben beschriebene Pfadschema bleibt unverändert; zusätzlich prüfen `Open`/`Stat`/`Delete` jetzt, dass der übergebene `storage_path` tatsächlich unter `store/<tenant_id>/` liegt (Mandanten-Containment gegen Pfad-Traversal/IDOR). Verdrahtet wird der Treiber in `Server.SetStorageConfig` (Signierschlüssel per HKDF-SHA256 aus `api.secret`, Link-Basis aus `server.fqdn`).
|
||||
|
||||
**Signierte, zeitlich begrenzte Download-URLs:** `POST /api/documents/{id}/signed-url` (authentifiziert, mandantengeprüft, Body optional `{"ttl_minutes": 15}`) liefert `{url, expires_at}`. Eingelöst wird der Link unter `GET /public/files?t=&d=&exp=&sig=` — ohne Session, die HMAC-SHA256-Signatur über `tenant|dokument|ablauf` ist das Credential. Gleiche Schutzmechanik wie die Share-Links (harter Pflicht-Ablauf, per-IP-Rate-Limit, Audit-Trail `signed_url_created`/`signed_url_accessed` inkl. Fehlschlägen), aber zustandslos, ohne DB-Zeile und ohne Widerruf — für kurzlebigen Maschinenzugriff. Für Weitergabe an Dritte mit eigenem Lebenszyklus (Widerruf, Passwort, Zugriffslimit) bleiben die Share-Links das Mittel der Wahl. Abgelaufener Link → HTTP 410, gefälschter → 403, fehlende Datei → 404. Gültigkeit: `storage.signed_url_ttl_minutes` (Default 15), pro Anfrage überschreibbar, hart gedeckelt auf 24 h.
|
||||
|
||||
### Trennseiten-Split (Barcode-Trennblätter)
|
||||
|
||||
`internal/pagesplit` zerlegt mehrseitige **PDF**-Scans beim Ingest an Barcode-Trennblättern in Einzeldokumente (Vorbild: Paperless-ngx, an die archivdms-Pipeline angepasst). Ablauf im synchronen Staging-Schritt, **bevor** irgendetwas archiviert wird (Schritt 1a in `storeUploadedFile`): `pdfinfo` liefert die Seitenzahl, `pdftoppm -r 150` rastert jede Seite einmal, `zbarimg` (via `internal/barcode`) dekodiert die Barcodes; Seiten mit dem konfigurierten Marker (`pagesplit.marker`, Default `ARCHIVDMS-SPLIT`, Vergleich case-insensitiv, optional Präfix-Match) gelten als Trennblatt. Die Segmente dazwischen werden mit `pdfseparate` + `pdfunite` (poppler-utils, kein qpdf/pdftk) zu je einem Teil-PDF zusammengesetzt; **das Trennblatt selbst wird verworfen**. Jedes Teildokument durchläuft danach exakt denselben Pfad wie ein normaler Einzel-Upload (eigener Hash + Duplikatprüfung, eigene WORM-Ablage mit `chmod 0440`, eigener Verarbeitungsjob mit OCR/Taxonomie/Workflows).
|
||||
@@ -96,6 +159,7 @@ storage:
|
||||
base_path: "/var/lib/archivdms" # enthält inbox/, store/, ocr-tmp/
|
||||
retention_days: 3650
|
||||
max_upload_size_mb: 50
|
||||
signed_url_ttl_minutes: 15 # Default-Gültigkeit signierter Download-Links
|
||||
|
||||
ocr:
|
||||
tesseract_path: "tesseract"
|
||||
@@ -124,11 +188,31 @@ index:
|
||||
manticore_dsn: "" # z.B. "archivdms@tcp(127.0.0.1:9306)/?charset=utf8mb4"
|
||||
```
|
||||
|
||||
### Volltext-Index (Manticore, Phase 1)
|
||||
### Volltext-Index (Manticore)
|
||||
|
||||
Optionaler sekundärer Volltext-Index (`internal/index`) über Manticore Search (MySQL-Protokoll, Port 9306, `github.com/go-sql-driver/mysql`, CGO-frei). **PostgreSQL bleibt Source of Truth** — der Index wird nur best-effort synchron gehalten: jeder Sync-Fehler wird geloggt, blockiert aber nie den auslösenden Request. Ist `index.manticore_dsn` leer, ist der Indexer `nil` und alle Sync-Aufrufe sind No-ops.
|
||||
Sekundärer Volltext-Index (`internal/index`) über Manticore Search (MySQL-Protokoll, Port 9306, nur `127.0.0.1` gebunden, `github.com/go-sql-driver/mysql`, CGO-frei). **PostgreSQL bleibt Source of Truth** — der Index wird nur best-effort synchron gehalten: jeder Sync-Fehler wird geloggt, blockiert aber nie den auslösenden Request. Ist `index.manticore_dsn` leer, ist der Indexer `nil` und alle Sync-/Suchaufrufe sind No-ops (reproduzierbarer Start ohne manuelle Zusatzschritte: DSN in `/etc/archivdms/config.yml` setzen, Backend/Systemd-Dienst startet den Rest selbst — RT-Tabellen werden lazy beim ersten Tenant-Zugriff angelegt).
|
||||
|
||||
Pro Mandant existiert eine RT-Tabelle `documents_tenant_<tenant_id>` (Tabellenname gegen Injection validiert). Synchronisiert wird nach Upload/Create, ACL-/Tag-/Dokumenttyp-Änderung (RecomputeVisibility), Korrespondent- und Custom-Field-Änderung; beim Verschieben in den Papierkorb sowie bei finaler Löschung (Vier-Augen-bestätigt) wird der Eintrag aus dem Index entfernt (GoBD: endgültig gelöschte Dokumente dürfen nicht mehr auffindbar sein), beim Wiederherstellen neu indexiert. Der Such-Endpunkt und ein Reindex-CLI folgen in Phase 2/3.
|
||||
Pro Mandant existiert eine eigene RT-Tabelle `documents_tenant_<tenant_id>` (Tabellenname gegen Injection validiert, Mandantentrennung über getrennte Tabellen statt Row-Filter). Synchronisiert wird nach Upload/Create, ACL-/Tag-/Dokumenttyp-Änderung (RecomputeVisibility), Korrespondent- und Custom-Field-Änderung; beim Verschieben in den Papierkorb sowie bei finaler Löschung (Vier-Augen-bestätigt) wird der Eintrag aus dem Index entfernt (GoBD: endgültig gelöschte Dokumente dürfen nicht mehr auffindbar sein), beim Wiederherstellen neu indexiert. Ein manueller Neuaufbau des kompletten Index aus dem Postgres-Bestand ist jederzeit über `archivdms reindex [-tenant N]` möglich (streamt in Batches von 500 Dokumenten, bricht laut ohne exit(1) ab statt einen fehlenden `manticore_dsn` als stillen No-op zu behandeln). Such-Endpunkt: `GET /api/documents/search` (ACL-gefiltert; Manticore liefert nur IDs+Score, die vollständigen Dokumentzeilen kommen aus Postgres).
|
||||
|
||||
**Index-Schema** (`internal/index/manticore.go`, `ensureTable`):
|
||||
|
||||
| Feld | Typ | Zweck |
|
||||
|---|---|---|
|
||||
| `doc_id` | string | Anzeige-ID (String-Form von `id`) |
|
||||
| `title` | text | Volltext-durchsucht |
|
||||
| `doc_type` | text | Volltext-durchsucht (Legacy-Freitext) |
|
||||
| `correspondent` | text | Volltext-durchsucht (Legacy-Freitext) |
|
||||
| `ocr_text` | text | Volltext-durchsucht, OCR-Ergebnis |
|
||||
| `tags` | text | Volltext-durchsucht, Tag-Namen als Leerzeichen-separierter String |
|
||||
| `tag_ids` | multi (MVA) | Attribut-Filter (Tag-Facette) |
|
||||
| `doc_type_id` / `correspondent_id` | bigint | Attribut-Filter (Taxonomie-IDs) |
|
||||
| `acl_group_ids` | multi (MVA) | Attribut-Filter für ACL (Sichtbarkeitsgruppen) |
|
||||
| `retain_until_ts` / `created_ts` / `updated_ts` | bigint (Unix) | Zeitstempel-Attribute |
|
||||
| `deleted` | uint | Soft-Delete-Flag, `Search` filtert immer `deleted = 0` |
|
||||
|
||||
**Gewichtung**: Die Volltext-Suche matcht gleichgewichtet über `@(title,ocr_text,tags,correspondent,doc_type)` (kein `field_weights`-Boost) — Ranking erfolgt rein über Manticores BM25 (`WEIGHT()`), sortiert nach `WEIGHT() DESC, created_ts DESC`. Bewusst MVP: keine Feldgewichtung, um keine Suchsyntax-Erwartungshaltung/Tuning-Aufwand vor dem ersten echten Nutzungs-Feedback aufzubauen. Eine spätere Gewichtung (z. B. `title` stärker als `ocr_text`) ist eine reine Query-Änderung in `Search`, kein Schema-Umbau.
|
||||
|
||||
**Deutsche Sprachbehandlung**: Die Tabelle wird mit `morphology='lemmatize_de_all,stem_en'` angelegt — deutsche Lemmatisierung (u. a. Kompositazerlegung, Umlaut-Normalisierung über Manticores eingebautes de-Wörterbuch) plus englisches Stemming für Mischtexte. Keine explizite Stoppwortliste konfiguriert (Manticore filtert ohne `stopwords=`-Option keine Füllwörter heraus) — bei BM25-Ranking wirkt sich das nur moderat auf die Relevanz aus, da seltene Terme ohnehin höher gewichtet werden; bei Bedarf lässt sich eine deutsche Stoppwortliste (`stopwords = de`) nachrüsten, das erfordert danach einen vollen Reindex (`archivdms reindex`).
|
||||
|
||||
### OCR-Wortkoordinaten & Vorschau-Overlay
|
||||
|
||||
@@ -211,10 +295,29 @@ bash update.sh
|
||||
|
||||
Route `/scan` (in der Sidebar als "Beleg erfassen") öffnet auf mobilen Browsern direkt die Rückkamera (`<input type="file" capture="environment">`), kein natives App nötig. Ein Foto genügt, Titel wird automatisch aus Datum/Uhrzeit gesetzt, Upload läuft über dieselbe Pipeline wie `/documents` (Hash, WORM-Ablage, OCR, automatisches Tag-/Korrespondent-Matching). Dokumenttyp/Korrespondent lassen sich danach unter `/documents` nachträglich ergänzen.
|
||||
|
||||
## Migrationen & Entwicklungs-Seed (FDN-02)
|
||||
|
||||
Schemaänderungen laufen weiterhin über die idempotenten `initSchema()`-Funktionen
|
||||
der Store-Pakete (kein Migrationstool). Ergänzend gilt: zu jeder neuen
|
||||
`internal/storage/migrations/NNN_name.sql` gehört eine `NNN_name.down.sql` mit
|
||||
reviewtem Rückbau-SQL (manuell per `psql -f` auszuführen, nie automatisch).
|
||||
Details und Anforderungen: `internal/storage/migrations/README.md`.
|
||||
|
||||
Für eine frische lokale Datenbank legt
|
||||
|
||||
```
|
||||
archivdms seed dev [-config PATH] [-name NAME] [-slug SLUG] [-email EMAIL] [-role ROLE] [-reset-password]
|
||||
```
|
||||
|
||||
einen Test-Mandanten ("Testfirma") und einen tenant-gebundenen Test-Benutzer
|
||||
(`testuser@testfirma.local`, Rolle `domain_admin`) an. Der Befehl ist idempotent
|
||||
(Mandant über Slug, Benutzer über E-Mail); das Passwort wird zufällig erzeugt und
|
||||
einmalig auf der Konsole ausgegeben — es steht nirgends im Code oder in der Config.
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
cmd/archivdms/ CLI-Einstiegspunkt (serve, reminders notify)
|
||||
cmd/archivdms/ CLI-Einstiegspunkt (serve, reminders notify, seed dev)
|
||||
config/ YAML-Konfiguration
|
||||
internal/api/ HTTP-Handler (auth, user, audit, document, reminder, sftp-credentials)
|
||||
internal/audit/ Append-only Audit-Log (GoBD-Nachvollziehbarkeit)
|
||||
|
||||
Reference in New Issue
Block a user