feat: add document upload/download backend (Phase 2K)

This commit is contained in:
m
2026-03-25 13:44:01 +01:00
7 changed files with 479 additions and 19 deletions

View File

@@ -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
}

View File

@@ -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
}