Files
KanzlAI-mGMT/backend/internal/services/document_service.go
m 9bd8cc9e07 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
2026-03-25 13:40:19 +01:00

164 lines
4.6 KiB
Go

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
}