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 }