Files
query-orchestration/internal/document/upload/get.go
T

322 lines
9.2 KiB
Go
Raw Normal View History

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
}