ebf47c6013
track file sizes for all documents in system * feature complete needs dev testing
410 lines
12 KiB
Go
410 lines
12 KiB
Go
package folder
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"queryorchestration/internal/database/repository"
|
|
"queryorchestration/internal/serviceconfig"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Service provides folder management operations
|
|
type Service struct {
|
|
cfg serviceconfig.ConfigProvider
|
|
}
|
|
|
|
// New creates a new folder service instance
|
|
func New(cfg serviceconfig.ConfigProvider) *Service {
|
|
return &Service{
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// CreateFolder creates a new folder with validation.
|
|
// Validates folder path constraints and that parent folder exists if parentId is provided.
|
|
//
|
|
// Parameters:
|
|
// - ctx: request context
|
|
// - path: folder path (must pass all path constraints)
|
|
// - parentID: optional parent folder UUID
|
|
// - clientID: client identifier
|
|
// - createdBy: email of user creating the folder
|
|
//
|
|
// Returns:
|
|
// - *repository.Folder: the created folder
|
|
// - error: validation error (PathValidationError) or database error
|
|
func (s *Service) CreateFolder(ctx context.Context, path string, parentID *uuid.UUID, clientID string, createdBy string) (*repository.Folder, error) {
|
|
if clientID == "" {
|
|
return nil, fmt.Errorf("clientID cannot be empty")
|
|
}
|
|
|
|
if createdBy == "" {
|
|
return nil, fmt.Errorf("createdBy cannot be empty")
|
|
}
|
|
|
|
// Check if root folder already exists for this client
|
|
rootExists := false
|
|
if path == "/" {
|
|
_, err := s.cfg.GetDBQueries().GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
|
Clientid: clientID,
|
|
Path: "/",
|
|
})
|
|
if err == nil {
|
|
rootExists = true
|
|
}
|
|
}
|
|
|
|
// Validate folder path constraints
|
|
if err := ValidateFolderPathForCreate(path, rootExists); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate parent folder exists if parentID is provided
|
|
if parentID != nil {
|
|
parent, err := s.cfg.GetDBQueries().GetFolderByID(ctx, *parentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parent folder not found: %w", err)
|
|
}
|
|
|
|
// Ensure parent belongs to same client
|
|
if parent.Clientid != clientID {
|
|
return nil, fmt.Errorf("parent folder belongs to different client")
|
|
}
|
|
}
|
|
|
|
folder, err := s.cfg.GetDBQueries().CreateFolder(ctx, &repository.CreateFolderParams{
|
|
Path: path,
|
|
Parentid: parentID,
|
|
Clientid: clientID,
|
|
Createdby: createdBy,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create folder: %w", err)
|
|
}
|
|
|
|
return folder, nil
|
|
}
|
|
|
|
// GetFolder retrieves a folder by ID
|
|
func (s *Service) GetFolder(ctx context.Context, folderID uuid.UUID) (*repository.Folder, error) {
|
|
folder, err := s.cfg.GetDBQueries().GetFolderByID(ctx, folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("folder not found: %w", err)
|
|
}
|
|
|
|
return folder, nil
|
|
}
|
|
|
|
// GetFolderByPath retrieves a folder by client and path
|
|
func (s *Service) GetFolderByPath(ctx context.Context, clientID string, path string) (*repository.Folder, error) {
|
|
folder, err := s.cfg.GetDBQueries().GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
|
Clientid: clientID,
|
|
Path: path,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("folder not found: %w", err)
|
|
}
|
|
|
|
return folder, nil
|
|
}
|
|
|
|
// RenameFolder renames a folder.
|
|
// Validates folder path constraints and prevents renaming the root folder.
|
|
//
|
|
// Parameters:
|
|
// - ctx: request context
|
|
// - folderID: UUID of the folder to rename
|
|
// - newPath: new folder path (must pass all path constraints)
|
|
//
|
|
// Returns:
|
|
// - error: validation error (PathValidationError) or database error
|
|
func (s *Service) RenameFolder(ctx context.Context, folderID uuid.UUID, newPath string) error {
|
|
// Verify folder exists and get current path
|
|
folder, err := s.cfg.GetDBQueries().GetFolderByID(ctx, folderID)
|
|
if err != nil {
|
|
return fmt.Errorf("folder not found: %w", err)
|
|
}
|
|
|
|
// Check if this is the root folder
|
|
isRootFolder := folder.Path == "/"
|
|
|
|
// Validate folder path constraints for rename
|
|
if err := ValidateFolderPathForRename(newPath, isRootFolder); err != nil {
|
|
return err
|
|
}
|
|
|
|
err = s.cfg.GetDBQueries().RenameFolder(ctx, &repository.RenameFolderParams{
|
|
ID: folderID,
|
|
Path: newPath,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to rename folder: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetFolderTree retrieves a folder and all its descendants (recursive)
|
|
func (s *Service) GetFolderTree(ctx context.Context, folderID uuid.UUID) ([]*repository.GetFolderTreeRow, error) {
|
|
folders, err := s.cfg.GetDBQueries().GetFolderTree(ctx, &folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get folder tree: %w", err)
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// GetFoldersByClient retrieves all folders for a client
|
|
func (s *Service) GetFoldersByClient(ctx context.Context, clientID string) ([]*repository.Folder, error) {
|
|
folders, err := s.cfg.GetDBQueries().GetFoldersByClientID(ctx, clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get folders: %w", err)
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// GetRootFolders retrieves all root folders (parentId IS NULL) for a client
|
|
func (s *Service) GetRootFolders(ctx context.Context, clientID string) ([]*repository.Folder, error) {
|
|
folders, err := s.cfg.GetDBQueries().GetRootFolders(ctx, clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get root folders: %w", err)
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// GetSubfolders retrieves direct child folders of a parent folder
|
|
func (s *Service) GetSubfolders(ctx context.Context, parentID uuid.UUID) ([]*repository.Folder, error) {
|
|
folders, err := s.cfg.GetDBQueries().GetFoldersByParentID(ctx, &parentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get subfolders: %w", err)
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
// GetDocumentsByFolder retrieves all documents in a specific folder
|
|
func (s *Service) GetDocumentsByFolder(ctx context.Context, folderID uuid.UUID) ([]*repository.Document, error) {
|
|
documents, err := s.cfg.GetDBQueries().GetDocumentsByFolder(ctx, &folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get documents by folder: %w", err)
|
|
}
|
|
|
|
return documents, nil
|
|
}
|
|
|
|
// FolderMetrics contains aggregated metrics for a folder
|
|
type FolderMetrics struct {
|
|
TotalDocuments int32
|
|
ByLabel map[string]int32
|
|
}
|
|
|
|
// GetFolderMetrics retrieves processing metrics for a folder including document counts by label
|
|
func (s *Service) GetFolderMetrics(ctx context.Context, folderID uuid.UUID) (*FolderMetrics, error) {
|
|
// Get total document count
|
|
total, err := s.cfg.GetDBQueries().CountDocumentsByFolder(ctx, &folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count documents: %w", err)
|
|
}
|
|
|
|
// Get label counts
|
|
labelCounts, err := s.cfg.GetDBQueries().GetFolderLabelCounts(ctx, &folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get label counts: %w", err)
|
|
}
|
|
|
|
byLabel := make(map[string]int32)
|
|
for _, lc := range labelCounts {
|
|
byLabel[lc.Label] = safeInt64ToInt32(lc.Count)
|
|
}
|
|
|
|
return &FolderMetrics{
|
|
TotalDocuments: safeInt64ToInt32(total),
|
|
ByLabel: byLabel,
|
|
}, nil
|
|
}
|
|
|
|
// safeInt64ToInt32 safely converts int64 to int32, clamping at max int32 if necessary
|
|
func safeInt64ToInt32(n int64) int32 {
|
|
const maxInt32 = 2147483647
|
|
if n > maxInt32 {
|
|
return maxInt32
|
|
}
|
|
return int32(n) //nolint:gosec // bounds check performed above
|
|
}
|
|
|
|
// DocumentInFolder represents a document in a folder with enriched metadata.
|
|
// This is a lighter-weight format than DocumentEnriched, excluding client_id and computed fields.
|
|
type DocumentInFolder struct {
|
|
ID uuid.UUID
|
|
Hash string
|
|
HasTextRecord bool
|
|
FolderID *uuid.UUID
|
|
Filename *string
|
|
OriginalPath *string
|
|
Labels []LabelRecord
|
|
TextRecord *TextRecord // Only populated when includeTextRecord=true
|
|
FileSizeBytes *int64 // File size in bytes (nil for legacy documents)
|
|
}
|
|
|
|
// LabelRecord represents a label applied to a document.
|
|
type LabelRecord struct {
|
|
ID uuid.UUID
|
|
DocumentID uuid.UUID
|
|
Label string
|
|
AppliedBy string
|
|
AppliedAt time.Time
|
|
}
|
|
|
|
// TextRecord represents the field extraction response for a document.
|
|
type TextRecord struct {
|
|
ID uuid.UUID
|
|
DocumentID uuid.UUID
|
|
Version int32
|
|
SingleFields interface{}
|
|
ArrayFields interface{}
|
|
CreatedAt time.Time
|
|
CreatedBy string
|
|
}
|
|
|
|
// GetDocumentsEnriched retrieves all documents in a folder with enriched metadata.
|
|
// This returns a lighter-weight format than GET /document/{id}, excluding client_id and computed fields.
|
|
//
|
|
// Parameters:
|
|
// - ctx: request context
|
|
// - folderID: UUID of the folder
|
|
// - includeTextRecord: if true, fetches and includes the full field extraction response for each document
|
|
//
|
|
// Returns:
|
|
// - []DocumentInFolder: documents with enriched metadata
|
|
// - error: database error or nil on success
|
|
func (s *Service) GetDocumentsEnriched(ctx context.Context, folderID uuid.UUID, includeTextRecord bool) ([]DocumentInFolder, error) {
|
|
queries := s.cfg.GetDBQueries()
|
|
|
|
// 1. Get documents with enriched metadata
|
|
docs, err := queries.GetDocumentsByFolderEnriched(ctx, &folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get documents by folder: %w", err)
|
|
}
|
|
|
|
if len(docs) == 0 {
|
|
return []DocumentInFolder{}, nil
|
|
}
|
|
|
|
// 2. Collect document IDs for batch operations
|
|
docIDs := make([]uuid.UUID, len(docs))
|
|
for i, doc := range docs {
|
|
docIDs[i] = doc.ID
|
|
}
|
|
|
|
// 3. Batch check for text record existence
|
|
textRecordExists, err := queries.HasFieldExtractionBatch(ctx, docIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check field extractions: %w", err)
|
|
}
|
|
|
|
// Build map for quick lookup
|
|
hasTextRecordMap := make(map[uuid.UUID]bool)
|
|
for _, tr := range textRecordExists {
|
|
if tr.Documentid != nil {
|
|
hasTextRecordMap[*tr.Documentid] = tr.HasExtraction
|
|
}
|
|
}
|
|
|
|
// 4. Get labels for all documents
|
|
// We need to fetch labels per document since there's no batch query
|
|
labelsMap := make(map[uuid.UUID][]LabelRecord)
|
|
for _, docID := range docIDs {
|
|
dbLabels, err := queries.GetDocumentLabels(ctx, docID)
|
|
if err != nil {
|
|
// Log but don't fail - labels are optional enhancement
|
|
continue
|
|
}
|
|
labels := make([]LabelRecord, len(dbLabels))
|
|
for i, lbl := range dbLabels {
|
|
labels[i] = LabelRecord{
|
|
ID: lbl.ID,
|
|
DocumentID: lbl.Documentid,
|
|
Label: lbl.Label,
|
|
AppliedBy: lbl.Appliedby,
|
|
AppliedAt: lbl.Appliedat.Time,
|
|
}
|
|
}
|
|
labelsMap[docID] = labels
|
|
}
|
|
|
|
// 5. Optionally fetch text records
|
|
textRecordsMap := make(map[uuid.UUID]*TextRecord)
|
|
if includeTextRecord {
|
|
for _, docID := range docIDs {
|
|
if hasTextRecordMap[docID] {
|
|
tr, err := s.getTextRecord(ctx, docID)
|
|
if err != nil {
|
|
// Log but don't fail - text record is optional
|
|
continue
|
|
}
|
|
textRecordsMap[docID] = tr
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Build result
|
|
result := make([]DocumentInFolder, len(docs))
|
|
for i, doc := range docs {
|
|
labels := labelsMap[doc.ID]
|
|
if labels == nil {
|
|
labels = []LabelRecord{}
|
|
}
|
|
|
|
result[i] = DocumentInFolder{
|
|
ID: doc.ID,
|
|
Hash: doc.Hash,
|
|
HasTextRecord: hasTextRecordMap[doc.ID],
|
|
FolderID: doc.Folderid,
|
|
Filename: doc.Filename,
|
|
OriginalPath: doc.Originalpath,
|
|
Labels: labels,
|
|
TextRecord: textRecordsMap[doc.ID],
|
|
FileSizeBytes: doc.FileSizeBytes,
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// getTextRecord fetches the current field extraction for a document.
|
|
// Returns nil if no extraction exists.
|
|
func (s *Service) getTextRecord(ctx context.Context, documentID uuid.UUID) (*TextRecord, error) {
|
|
queries := s.cfg.GetDBQueries()
|
|
|
|
current, err := queries.GetCurrentFieldExtraction(ctx, documentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if current == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Get array fields
|
|
arrayFields, err := queries.GetFieldExtractionArrayFields(ctx, current.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TextRecord{
|
|
ID: current.ID,
|
|
DocumentID: current.Documentid,
|
|
//nolint:gosec // Version is a small number, int32 is sufficient
|
|
Version: int32(current.Version),
|
|
SingleFields: current, // Pass the whole struct - handler will convert
|
|
ArrayFields: arrayFields, // Pass array fields - handler will convert
|
|
CreatedAt: current.Createdat.Time,
|
|
CreatedBy: current.Createdby,
|
|
}, nil
|
|
}
|