- 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
164 lines
4.6 KiB
Go
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
|
|
}
|