package documentupload import ( "context" "errors" "fmt" "io" "mime" "path/filepath" "strings" "time" "queryorchestration/internal/database/repository" "queryorchestration/internal/serviceconfig/objectstore" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) type File struct { ClientID string Content io.Reader BatchID *uuid.UUID // Optional batch ID for batch processing Filename string // Original filename (just the file name, not path) for MIME type detection Path string // Full path including folders (e.g., "contracts/2025/Q1/agreement.pdf"). Optional - if empty, uses Filename only. } // UploadResult contains all information from an upload operation type UploadResult struct { Key objectstore.BucketKey OriginalPath string // The original path provided (or filename if no path) FolderID uuid.UUID // The folder ID where the document was placed (root folder if no path specified) } // RootFolderPath is the path used for the auto-created root folder per client. const RootFolderPath = "/" // detectContentType determines the MIME type based on file extension func detectContentType(filename string) string { // Use Go's standard library to detect MIME type from extension contentType := mime.TypeByExtension(filepath.Ext(filename)) if contentType == "" { // Default to binary if extension is unknown return "application/octet-stream" } return contentType } // parsePath extracts the folder path and filename from a full path. // Returns (folderPath, filename). If no folder path, folderPath is empty. // Examples: // - "contracts/2025/Q1/agreement.pdf" -> ("contracts/2025/Q1", "agreement.pdf") // - "agreement.pdf" -> ("", "agreement.pdf") // - "/contracts/agreement.pdf" -> ("contracts", "agreement.pdf") (leading slash stripped) func parsePath(path string) (folderPath string, filename string) { // Normalize: strip leading/trailing slashes path = strings.TrimPrefix(path, "/") path = strings.TrimSuffix(path, "/") if path == "" { return "", "" } // Split by last separator lastSlash := strings.LastIndex(path, "/") if lastSlash == -1 { // No folder path, just filename return "", path } return path[:lastSlash], path[lastSlash+1:] } // createFolderHierarchy creates all folders in the given path and returns the leaf folder ID. // For path "contracts/2025/Q1", it creates: // - /contracts (parent: root folder "/") // - /contracts/2025 (parent: /contracts) // - /contracts/2025/Q1 (parent: /contracts/2025) // // Returns the UUID of the leaf folder (/contracts/2025/Q1 in this example). // If folderPath is empty, returns the root folder ID (documents without paths go to root). // The root folder "/" must exist (created when the client was created). func (s *Service) createFolderHierarchy(ctx context.Context, q *repository.Queries, clientID string, folderPath string, createdBy string, batchID *uuid.UUID) (uuid.UUID, error) { // Get the root folder for this client (must exist - created when client was created) rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{ Clientid: clientID, Path: RootFolderPath, }) if err != nil { return uuid.Nil, fmt.Errorf("root folder not found for client %s: %w", clientID, err) } // Normalize path folderPath = strings.TrimPrefix(folderPath, "/") folderPath = strings.TrimSuffix(folderPath, "/") // If no folder path provided, use root folder if folderPath == "" { return rootFolder.ID, nil } // Split path into parts parts := strings.Split(folderPath, "/") // Start with root as the parent parentID := &rootFolder.ID var currentPath string var lastFolderID uuid.UUID for _, part := range parts { if part == "" { continue } // Build the full path for this folder if currentPath == "" { currentPath = "/" + part } else { currentPath = currentPath + "/" + part } folder, existedBefore, err := s.createOrGetFolder(ctx, q, clientID, currentPath, parentID, createdBy, batchID) if err != nil { return uuid.Nil, err } if batchID != nil { if err := q.RecordBatchFolderCandidate(ctx, &repository.RecordBatchFolderCandidateParams{ BatchID: *batchID, FolderID: folder.ID, Path: currentPath, ExistedBefore: existedBefore, }); err != nil { return uuid.Nil, err } } // This folder becomes the parent for the next level lastFolderID = folder.ID parentID = &folder.ID } return lastFolderID, nil } func (s *Service) createOrGetFolder( ctx context.Context, q *repository.Queries, clientID string, currentPath string, parentID *uuid.UUID, createdBy string, batchID *uuid.UUID, ) (*repository.Folder, bool, error) { if batchID == nil { folder, err := q.UpsertFolder(ctx, &repository.UpsertFolderParams{ Path: currentPath, Parentid: parentID, Clientid: clientID, Createdby: createdBy, }) return folder, true, err } folder, err := q.CreateFolderIfAbsent(ctx, &repository.CreateFolderIfAbsentParams{ Path: currentPath, ParentID: parentID, ClientID: clientID, CreatedBy: createdBy, }) if err == nil { return folder, false, nil } if !errors.Is(err, pgx.ErrNoRows) { return nil, false, err } folder, err = q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{ Clientid: clientID, Path: currentPath, }) if err != nil { return nil, false, err } return folder, true, nil } func (s *Service) Upload(ctx context.Context, file File) error { _, err := s.UploadWithResult(ctx, file) return err } // UploadAndReturnKey uploads a file and returns the BucketKey used (for backward compatibility) func (s *Service) UploadAndReturnKey(ctx context.Context, file File) (objectstore.BucketKey, error) { result, err := s.UploadWithResult(ctx, file) if err != nil { return objectstore.BucketKey{}, err } return result.Key, nil } // UploadWithResult uploads a file and returns the full upload result including folder information. // If file.Path is provided, it will: // 1. Extract the folder path and filename from the path // 2. Create the folder hierarchy in the database // 3. Return the folder ID in the result for use when creating the document func (s *Service) UploadWithResult(ctx context.Context, file File) (UploadResult, error) { var result UploadResult // Determine the original path and folder path var originalPath string var folderPath string var filename string if file.Path != "" { // Use the provided path originalPath = file.Path folderPath, filename = parsePath(file.Path) // If filename from path is empty but Filename is set, use Filename if filename == "" && file.Filename != "" { filename = file.Filename } } else { // Fall back to just the filename (no folder) originalPath = file.Filename filename = file.Filename folderPath = "" } result.OriginalPath = originalPath err := s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error { now := time.Now().UTC() part, err := s.cfg.GetDBQueries().GetDocumentUploadCurrentPart(ctx, &repository.GetDocumentUploadCurrentPartParams{ Clientid: &file.ClientID, Querydate: pgtype.Timestamptz{ Valid: true, Time: now, }, }) if err != nil { return err } // Create folder hierarchy if folder path is provided // Use system email as createdBy since this is an automated upload process folderID, err := s.createFolderHierarchy(ctx, q, file.ClientID, folderPath, "system@doczy.local", file.BatchID) if err != nil { return err } result.FolderID = folderID uploadId := uuid.New() newPart := s.cfg.GetDirectoryPart(part.Part, part.Count) result.Key = objectstore.BucketKey{ ClientID: file.ClientID, Location: objectstore.Import, CreatedAt: now, EntityID: uploadId, Part: &newPart, BatchID: file.BatchID, } if ext := strings.TrimPrefix(filepath.Ext(filename), "."); ext != "" { result.Key.FileType = &ext } keyStr := result.Key.String() bucket := s.cfg.GetBucket() // Store the full original path in Filename to preserve the complete path from ZIP uploads // The folder hierarchy is separately maintained via FolderID filenameToStore := originalPath if filenameToStore == "" { filenameToStore = filename } err = q.AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{ ID: uploadId, Clientid: file.ClientID, Bucket: bucket, Key: keyStr, Part: newPart, Createdat: pgtype.Timestamp{ Valid: true, Time: now, }, Filename: &filenameToStore, BatchID: file.BatchID, FolderID: &folderID, }) if err != nil { return err } // Detect content type from filename contentType := detectContentType(filename) // Prepare metadata for S3 object - store original path for debugging metadata := make(map[string]string) if originalPath != "" { metadata["original-path"] = originalPath } if filename != "" { metadata["original-filename"] = filename } _, err = s.cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{ Bucket: &bucket, Key: &keyStr, Body: file.Content, ContentType: &contentType, Metadata: metadata, }) if err != nil { return err } return nil }) return result, err }