Files
paliad/internal/services/storage.go
m f539102937 feat(dokumente): Phase H — AI deadline extraction from documents
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
2026-04-16 17:43:42 +02:00

132 lines
4.3 KiB
Go

// Package services — Supabase Storage client for Paliad document uploads.
//
// Paliad uses Supabase Storage (youpc instance) for the PDF blobs backing
// paliad.dokumente rows. Writes require the service-role key; the anon key
// used for auth is not enough. If SUPABASE_SERVICE_KEY is unset at startup,
// NewStorageClient returns nil and handlers respond with 501.
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// ErrStorageDisabled is returned when handlers invoke a storage-backed
// operation but SUPABASE_SERVICE_KEY was unset at startup.
var ErrStorageDisabled = errors.New("document storage not configured")
// StorageClient is a thin wrapper around the Supabase Storage REST API.
// A nil pointer is a valid "disabled" state; callers check for it.
type StorageClient struct {
baseURL string
serviceKey string
httpClient *http.Client
}
// NewStorageClient returns a ready-to-use client, or nil if either baseURL
// or serviceKey is empty. Handlers treat nil as "storage not configured".
func NewStorageClient(baseURL, serviceKey string) *StorageClient {
if baseURL == "" || serviceKey == "" {
return nil
}
return &StorageClient{
baseURL: baseURL,
serviceKey: serviceKey,
httpClient: &http.Client{Timeout: 60 * time.Second},
}
}
// Upload writes a single object to the given bucket/path. Existing objects
// at the path are replaced (x-upsert: true). contentType is required.
func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error {
if s == nil {
return ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, data)
if err != nil {
return fmt.Errorf("creating upload request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", contentType)
req.Header.Set("x-upsert", "true")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("uploading to storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// Download fetches an object. The caller must close the returned ReadCloser.
func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) {
if s == nil {
return nil, "", ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("creating download request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("downloading from storage: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", fmt.Errorf("storage object not found")
}
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body))
}
return resp.Body, resp.Header.Get("Content-Type"), nil
}
// Delete removes the given paths from a bucket (Supabase accepts an array).
func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error {
if s == nil {
return ErrStorageDisabled
}
url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket)
body, err := json.Marshal(map[string][]string{"prefixes": paths})
if err != nil {
return fmt.Errorf("marshaling delete request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("deleting from storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}