Ports KanzlAI document upload + AI extraction into paliad. PDFs are stored
in Supabase Storage (bucket paliad-documents); Claude Sonnet extracts
deadlines with tool-forced structured output; the user reviews candidates
and picks which to persist as Fristen.
Backend
- internal/services/ai_service.go — Anthropic SDK wrapper. Uses native PDF
content blocks, forced tool_use for structured output, ephemeral prompt
caching on the system prompt. Sonnet 4.6.
- internal/services/storage.go — Supabase Storage REST client (upload,
download, delete). Nil when SUPABASE_SERVICE_KEY is unset.
- internal/services/dokument_service.go — upload (PDF magic-number check,
20 MB cap), list, download, extract, persist-confirmed-as-Fristen. All
visibility-checked through AkteService.GetByID.
- internal/handlers/dokumente.go — five endpoints plus /api/config/features
so the UI can hide disabled buttons.
- internal/handlers/ratelimit.go — in-memory per-user cap of 20 extractions
per UTC day (design §9.7).
- Both optional services (storage, AI) degrade to 501 with friendly German
messages when their env vars are unset.
Schema
- migration 013 adds fristen.source_document_id (FK to dokumente) and
dokumente.ai_extraction_count + ai_extracted_at for the UI badge.
Frontend
- Dokumente tab in /akten/{id}/dokumente replaces the Phase D placeholder:
drag-drop upload zone with live progress bar (XHR), document table with
download + extract actions, extraction-review modal with per-row
checkboxes, confidence chips, expandable source-quote, editable title +
due date + rule code, POST to the from-extraction endpoint.
- Upload + extract buttons hide automatically when the server reports the
feature is disabled.
- Full DE/EN i18n. CSS for the upload zone, extraction modal, and
confidence chips.
Env vars (not set here — flag to head):
- ANTHROPIC_API_KEY (enables extraction)
- SUPABASE_SERVICE_KEY (enables upload/download)
Branch: mai/ritchie/phase-h-ai-deadline
247 lines
6.1 KiB
Go
247 lines
6.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/auth"
|
|
"mgit.msbls.de/m/patholo/internal/services"
|
|
)
|
|
|
|
// dbServices bundles the Phase B services so handlers can stay thin.
|
|
// Nil if DATABASE_URL was unset at startup.
|
|
type dbServices struct {
|
|
akte *services.AkteService
|
|
parteien *services.ParteienService
|
|
frist *services.FristService
|
|
rules *services.DeadlineRuleService
|
|
calc *services.DeadlineCalculator
|
|
users *services.UserService
|
|
fristenrechner *services.FristenrechnerService
|
|
dashboard *services.DashboardService
|
|
dokument *services.DokumentService
|
|
}
|
|
|
|
var dbSvc *dbServices
|
|
|
|
// requireDB returns true if the DB-backed services are wired; otherwise
|
|
// writes a 503 response and returns false.
|
|
func requireDB(w http.ResponseWriter) bool {
|
|
if dbSvc == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "database not configured — set DATABASE_URL on the server",
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// requireUser pulls the authenticated user UUID from the request context.
|
|
// Returns (uuid.Nil, false) and writes 401 if missing.
|
|
func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
|
uid, ok := auth.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
|
"error": "authentication required",
|
|
})
|
|
return uuid.Nil, false
|
|
}
|
|
return uid, true
|
|
}
|
|
|
|
// writeServiceError maps a services error to an HTTP status.
|
|
func writeServiceError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, services.ErrNotVisible):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
case errors.Is(err, services.ErrForbidden):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
}
|
|
}
|
|
|
|
// GET /api/akten
|
|
func handleListAkten(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akten, err := dbSvc.akte.ListVisibleForUser(r.Context(), uid)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, akten)
|
|
}
|
|
|
|
// POST /api/akten
|
|
func handleCreateAkte(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input services.CreateAkteInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
a, err := dbSvc.akte.Create(r.Context(), uid, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, a)
|
|
}
|
|
|
|
// GET /api/akten/{id}
|
|
func handleGetAkte(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akteID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
a, err := dbSvc.akte.GetByID(r.Context(), uid, akteID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, a)
|
|
}
|
|
|
|
// PATCH /api/akten/{id}
|
|
func handleUpdateAkte(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akteID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.UpdateAkteInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
a, err := dbSvc.akte.Update(r.Context(), uid, akteID, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, a)
|
|
}
|
|
|
|
// DELETE /api/akten/{id}
|
|
func handleDeleteAkte(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akteID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.akte.Delete(r.Context(), uid, akteID); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/akten/{id}/parteien
|
|
func handleListParteien(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akteID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
parteien, err := dbSvc.parteien.ListForAkte(r.Context(), uid, akteID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, parteien)
|
|
}
|
|
|
|
// POST /api/akten/{id}/parteien
|
|
func handleCreatePartei(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
akteID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.CreateParteiInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
p, err := dbSvc.parteien.Create(r.Context(), uid, akteID, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, p)
|
|
}
|
|
|
|
// DELETE /api/parteien/{id}
|
|
func handleDeletePartei(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
parteiID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.parteien.Delete(r.Context(), uid, parteiID); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|