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 }