From 9bd8cc9e07ea197a8596c4e874470fc7abf70d89 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 13:40:19 +0100 Subject: [PATCH] feat: add document upload/download backend (Phase 2K) - StorageClient for Supabase Storage REST API (upload, download, delete) - DocumentService with CRUD operations + storage integration - DocumentHandler with multipart form upload support (50MB limit) - Routes: GET/POST /api/cases/{id}/documents, GET/DELETE /api/documents/{docId} - file_path format: {tenant_id}/{case_id}/{uuid}_{filename} - Case events logged on upload/delete - Added SUPABASE_SERVICE_KEY to config for server-side storage access - Fixed pre-existing duplicate writeJSON/writeError in appointments.go --- .env.example | 1 + backend/cmd/server/main.go | 2 +- backend/internal/config/config.go | 14 +- backend/internal/handlers/appointments.go | 11 -- backend/internal/handlers/documents.go | 183 ++++++++++++++++++ backend/internal/router/router.go | 23 ++- backend/internal/services/document_service.go | 163 ++++++++++++++++ backend/internal/services/storage.go | 112 +++++++++++ 8 files changed, 479 insertions(+), 30 deletions(-) create mode 100644 backend/internal/handlers/documents.go create mode 100644 backend/internal/services/document_service.go create mode 100644 backend/internal/services/storage.go diff --git a/.env.example b/.env.example index ed1fe37..1c7f6c0 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ PORT=8080 # Supabase (required for database access) SUPABASE_URL= SUPABASE_ANON_KEY= +SUPABASE_SERVICE_KEY= # Claude API (required for AI features) ANTHROPIC_API_KEY= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7bffddc..1dc5528 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -23,7 +23,7 @@ func main() { defer database.Close() authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database) - handler := router.New(database, authMW) + handler := router.New(database, authMW, cfg) log.Printf("Starting KanzlAI API server on :%s", cfg.Port) if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1569aa2..3b78b3a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,12 +6,13 @@ import ( ) type Config struct { - Port string - DatabaseURL string - SupabaseURL string - SupabaseAnonKey string + Port string + DatabaseURL string + SupabaseURL string + SupabaseAnonKey string + SupabaseServiceKey string SupabaseJWTSecret string - AnthropicAPIKey string + AnthropicAPIKey string } func Load() (*Config, error) { @@ -19,7 +20,8 @@ func Load() (*Config, error) { Port: getEnv("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"), SupabaseURL: os.Getenv("SUPABASE_URL"), - SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"), + SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"), + SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), } diff --git a/backend/internal/handlers/appointments.go b/backend/internal/handlers/appointments.go index 49d8e16..16d1111 100644 --- a/backend/internal/handlers/appointments.go +++ b/backend/internal/handlers/appointments.go @@ -203,14 +203,3 @@ func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) -} diff --git a/backend/internal/handlers/documents.go b/backend/internal/handlers/documents.go new file mode 100644 index 0000000..c15c0cb --- /dev/null +++ b/backend/internal/handlers/documents.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" + + "github.com/google/uuid" +) + +const maxUploadSize = 50 << 20 // 50 MB + +type DocumentHandler struct { + svc *services.DocumentService +} + +func NewDocumentHandler(svc *services.DocumentService) *DocumentHandler { + return &DocumentHandler{svc: svc} +} + +func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "documents": docs, + "total": len(docs), + }) +} + +func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + userID, _ := auth.UserFromContext(r.Context()) + + caseID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + writeError(w, http.StatusBadRequest, "file too large or invalid multipart form") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing file field") + return + } + defer file.Close() + + title := r.FormValue("title") + if title == "" { + title = header.Filename + } + + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + input := services.CreateDocumentInput{ + Title: title, + DocType: r.FormValue("doc_type"), + Filename: header.Filename, + ContentType: contentType, + Size: int(header.Size), + Data: file, + } + + doc, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input) + if err != nil { + if err.Error() == "case not found" { + writeError(w, http.StatusNotFound, "case not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, doc) +} + +func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + docID, err := uuid.Parse(r.PathValue("docId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid document ID") + return + } + + body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID) + if err != nil { + if err.Error() == "document not found" || err.Error() == "document has no file" { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer body.Close() + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title)) + io.Copy(w, body) +} + +func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + docID, err := uuid.Parse(r.PathValue("docId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid document ID") + return + } + + doc, err := h.svc.GetByID(r.Context(), tenantID, docID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if doc == nil { + writeError(w, http.StatusNotFound, "document not found") + return + } + + writeJSON(w, http.StatusOK, doc) +} + +func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + userID, _ := auth.UserFromContext(r.Context()) + + docID, err := uuid.Parse(r.PathValue("docId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid document ID") + return + } + + if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil { + writeError(w, http.StatusNotFound, "document not found") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4b21e0d..d45446d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -7,11 +7,12 @@ import ( "github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/config" "mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers" "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" ) -func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { +func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) http.Handler { mux := http.NewServeMux() // Services @@ -23,6 +24,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { deadlineSvc := services.NewDeadlineService(db) deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) + storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) + documentSvc := services.NewDocumentService(db, storageCli) // Middleware tenantResolver := auth.NewTenantResolver(tenantSvc) @@ -35,6 +38,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db) ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc) calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc) + docH := handlers.NewDocumentHandler(documentSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -86,8 +90,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler { scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update) scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete) - // Placeholder routes for future phases - scoped.HandleFunc("GET /api/documents", placeholder("documents")) + // Documents + scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) + scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload) + scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) + scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) + scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // Wire: auth -> tenant routes go directly, scoped routes get tenant resolver api.Handle("/api/", tenantResolver.Resolve(scoped)) @@ -109,12 +117,3 @@ func handleHealth(db *sqlx.DB) http.HandlerFunc { } } -func placeholder(resource string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "not_implemented", - "resource": resource, - }) - } -} diff --git a/backend/internal/services/document_service.go b/backend/internal/services/document_service.go new file mode 100644 index 0000000..3d60af8 --- /dev/null +++ b/backend/internal/services/document_service.go @@ -0,0 +1,163 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + "io" + "time" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +const documentBucket = "kanzlai-documents" + +type DocumentService struct { + db *sqlx.DB + storage *StorageClient +} + +func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService { + return &DocumentService{db: db, storage: storage} +} + +type CreateDocumentInput struct { + Title string `json:"title"` + DocType string `json:"doc_type"` + Filename string + ContentType string + Size int + Data io.Reader +} + +func (s *DocumentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Document, error) { + var docs []models.Document + err := s.db.SelectContext(ctx, &docs, + "SELECT * FROM documents WHERE tenant_id = $1 AND case_id = $2 ORDER BY created_at DESC", + tenantID, caseID) + if err != nil { + return nil, fmt.Errorf("listing documents: %w", err) + } + return docs, nil +} + +func (s *DocumentService) GetByID(ctx context.Context, tenantID, docID uuid.UUID) (*models.Document, error) { + var doc models.Document + err := s.db.GetContext(ctx, &doc, + "SELECT * FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("getting document: %w", err) + } + return &doc, nil +} + +func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID uuid.UUID, input CreateDocumentInput) (*models.Document, error) { + // Verify case belongs to tenant + var caseExists int + if err := s.db.GetContext(ctx, &caseExists, + "SELECT COUNT(*) FROM cases WHERE id = $1 AND tenant_id = $2", + caseID, tenantID); err != nil { + return nil, fmt.Errorf("verifying case: %w", err) + } + if caseExists == 0 { + return nil, fmt.Errorf("case not found") + } + + id := uuid.New() + storagePath := fmt.Sprintf("%s/%s/%s_%s", tenantID, caseID, id, input.Filename) + + // Upload to Supabase Storage + if err := s.storage.Upload(ctx, documentBucket, storagePath, input.ContentType, input.Data); err != nil { + return nil, fmt.Errorf("uploading file: %w", err) + } + + // Insert metadata record + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO documents (id, tenant_id, case_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`, + id, tenantID, caseID, input.Title, nilIfEmpty(input.DocType), storagePath, input.Size, input.ContentType, userID, now) + if err != nil { + // Best effort: clean up uploaded file + _ = s.storage.Delete(ctx, documentBucket, []string{storagePath}) + return nil, fmt.Errorf("inserting document record: %w", err) + } + + // Log case event + createEvent(ctx, s.db, tenantID, caseID, userID, "document_uploaded", + fmt.Sprintf("Document uploaded: %s", input.Title), nil) + + var doc models.Document + if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil { + return nil, fmt.Errorf("fetching created document: %w", err) + } + return &doc, nil +} + +func (s *DocumentService) Download(ctx context.Context, tenantID, docID uuid.UUID) (io.ReadCloser, string, string, error) { + doc, err := s.GetByID(ctx, tenantID, docID) + if err != nil { + return nil, "", "", err + } + if doc == nil { + return nil, "", "", fmt.Errorf("document not found") + } + if doc.FilePath == nil { + return nil, "", "", fmt.Errorf("document has no file") + } + + body, contentType, err := s.storage.Download(ctx, documentBucket, *doc.FilePath) + if err != nil { + return nil, "", "", fmt.Errorf("downloading file: %w", err) + } + + // Use stored mime_type if available, fall back to storage response + if doc.MimeType != nil && *doc.MimeType != "" { + contentType = *doc.MimeType + } + + return body, contentType, doc.Title, nil +} + +func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uuid.UUID) error { + doc, err := s.GetByID(ctx, tenantID, docID) + if err != nil { + return err + } + if doc == nil { + return sql.ErrNoRows + } + + // Delete from storage + if doc.FilePath != nil { + if err := s.storage.Delete(ctx, documentBucket, []string{*doc.FilePath}); err != nil { + return fmt.Errorf("deleting file from storage: %w", err) + } + } + + // Delete database record + _, err = s.db.ExecContext(ctx, + "DELETE FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID) + if err != nil { + return fmt.Errorf("deleting document record: %w", err) + } + + // Log case event + createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted", + fmt.Sprintf("Document deleted: %s", doc.Title), nil) + + return nil +} + +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/backend/internal/services/storage.go b/backend/internal/services/storage.go new file mode 100644 index 0000000..b7a1075 --- /dev/null +++ b/backend/internal/services/storage.go @@ -0,0 +1,112 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// StorageClient interacts with Supabase Storage via REST API. +type StorageClient struct { + baseURL string + serviceKey string + httpClient *http.Client +} + +func NewStorageClient(supabaseURL, serviceKey string) *StorageClient { + return &StorageClient{ + baseURL: supabaseURL, + serviceKey: serviceKey, + httpClient: &http.Client{}, + } +} + +// Upload stores a file in the given bucket at the specified path. +func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error { + url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path) + + req, err := http.NewRequestWithContext(ctx, "POST", 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 retrieves a file from storage. Caller must close the returned ReadCloser. +func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) { + url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path) + + req, err := http.NewRequestWithContext(ctx, "GET", 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("file not found in storage") + } + body, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body)) + } + + ct := resp.Header.Get("Content-Type") + return resp.Body, ct, nil +} + +// Delete removes files from storage by their paths. +func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error { + 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, "DELETE", 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 +}