feat: add document upload/download backend (Phase 2K)
This commit is contained in:
163
backend/internal/services/document_service.go
Normal file
163
backend/internal/services/document_service.go
Normal 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
|
||||
}
|
||||
112
backend/internal/services/storage.go
Normal file
112
backend/internal/services/storage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user