2025-05-27 15:28:46 +00:00
|
|
|
package documentupload
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-05-08 21:42:46 +00:00
|
|
|
"errors"
|
2025-11-26 19:23:42 +00:00
|
|
|
"fmt"
|
2025-05-27 15:28:46 +00:00
|
|
|
"io"
|
2025-08-20 19:01:13 +00:00
|
|
|
"mime"
|
|
|
|
|
"path/filepath"
|
2025-11-26 19:23:42 +00:00
|
|
|
"strings"
|
2025-05-27 15:28:46 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"queryorchestration/internal/database/repository"
|
|
|
|
|
"queryorchestration/internal/serviceconfig/objectstore"
|
|
|
|
|
|
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
|
|
|
"github.com/google/uuid"
|
2026-05-08 21:42:46 +00:00
|
|
|
"github.com/jackc/pgx/v5"
|
2025-05-27 15:28:46 +00:00
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type File struct {
|
|
|
|
|
ClientID string
|
|
|
|
|
Content io.Reader
|
2025-08-20 19:01:13 +00:00
|
|
|
BatchID *uuid.UUID // Optional batch ID for batch processing
|
2025-11-26 19:23:42 +00:00
|
|
|
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.
|
2025-08-20 19:01:13 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// 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 = "/"
|
|
|
|
|
|
2025-08-20 19:01:13 +00:00
|
|
|
// 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
|
2025-05-27 15:28:46 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// 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).
|
2026-05-08 21:42:46 +00:00
|
|
|
func (s *Service) createFolderHierarchy(ctx context.Context, q *repository.Queries, clientID string, folderPath string, createdBy string, batchID *uuid.UUID) (uuid.UUID, error) {
|
2025-11-26 19:23:42 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 21:42:46 +00:00
|
|
|
folder, existedBefore, err := s.createOrGetFolder(ctx, q, clientID, currentPath, parentID, createdBy, batchID)
|
2025-11-26 19:23:42 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return uuid.Nil, err
|
|
|
|
|
}
|
2026-05-08 21:42:46 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-26 19:23:42 +00:00
|
|
|
|
|
|
|
|
// This folder becomes the parent for the next level
|
|
|
|
|
lastFolderID = folder.ID
|
|
|
|
|
parentID = &folder.ID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lastFolderID, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 21:42:46 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-27 15:28:46 +00:00
|
|
|
func (s *Service) Upload(ctx context.Context, file File) error {
|
2025-11-26 19:23:42 +00:00
|
|
|
_, err := s.UploadWithResult(ctx, file)
|
2025-09-05 18:25:31 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// UploadAndReturnKey uploads a file and returns the BucketKey used (for backward compatibility)
|
2025-09-05 18:25:31 +00:00
|
|
|
func (s *Service) UploadAndReturnKey(ctx context.Context, file File) (objectstore.BucketKey, error) {
|
2025-11-26 19:23:42 +00:00
|
|
|
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
|
|
|
|
|
|
2025-09-05 18:25:31 +00:00
|
|
|
err := s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error {
|
2025-05-27 15:28:46 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// Create folder hierarchy if folder path is provided
|
|
|
|
|
// Use system email as createdBy since this is an automated upload process
|
2026-05-08 21:42:46 +00:00
|
|
|
folderID, err := s.createFolderHierarchy(ctx, q, file.ClientID, folderPath, "system@doczy.local", file.BatchID)
|
2025-11-26 19:23:42 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
result.FolderID = folderID
|
|
|
|
|
|
2025-05-27 15:28:46 +00:00
|
|
|
uploadId := uuid.New()
|
|
|
|
|
newPart := s.cfg.GetDirectoryPart(part.Part, part.Count)
|
2025-11-26 19:23:42 +00:00
|
|
|
result.Key = objectstore.BucketKey{
|
2025-05-27 15:28:46 +00:00
|
|
|
ClientID: file.ClientID,
|
|
|
|
|
Location: objectstore.Import,
|
|
|
|
|
CreatedAt: now,
|
|
|
|
|
EntityID: uploadId,
|
|
|
|
|
Part: &newPart,
|
2025-08-20 19:01:13 +00:00
|
|
|
BatchID: file.BatchID,
|
2025-05-27 15:28:46 +00:00
|
|
|
}
|
2026-03-31 17:40:42 +00:00
|
|
|
if ext := strings.TrimPrefix(filepath.Ext(filename), "."); ext != "" {
|
|
|
|
|
result.Key.FileType = &ext
|
|
|
|
|
}
|
2025-11-26 19:23:42 +00:00
|
|
|
keyStr := result.Key.String()
|
2025-05-27 15:28:46 +00:00
|
|
|
bucket := s.cfg.GetBucket()
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
2025-05-27 15:28:46 +00:00
|
|
|
err = q.AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
|
|
|
|
ID: uploadId,
|
|
|
|
|
Clientid: file.ClientID,
|
|
|
|
|
Bucket: bucket,
|
|
|
|
|
Key: keyStr,
|
|
|
|
|
Part: newPart,
|
|
|
|
|
Createdat: pgtype.Timestamp{
|
|
|
|
|
Valid: true,
|
|
|
|
|
Time: now,
|
|
|
|
|
},
|
2025-11-26 19:23:42 +00:00
|
|
|
Filename: &filenameToStore,
|
2025-09-05 18:25:31 +00:00
|
|
|
BatchID: file.BatchID,
|
2025-11-26 19:23:42 +00:00
|
|
|
FolderID: &folderID,
|
2025-05-27 15:28:46 +00:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// Detect content type from filename
|
|
|
|
|
contentType := detectContentType(filename)
|
2025-08-20 19:01:13 +00:00
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
// Prepare metadata for S3 object - store original path for debugging
|
2025-09-05 18:25:31 +00:00
|
|
|
metadata := make(map[string]string)
|
2025-11-26 19:23:42 +00:00
|
|
|
if originalPath != "" {
|
|
|
|
|
metadata["original-path"] = originalPath
|
|
|
|
|
}
|
|
|
|
|
if filename != "" {
|
|
|
|
|
metadata["original-filename"] = filename
|
2025-09-05 18:25:31 +00:00
|
|
|
}
|
|
|
|
|
|
2025-05-27 15:28:46 +00:00
|
|
|
_, err = s.cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
2025-08-20 19:01:13 +00:00
|
|
|
Bucket: &bucket,
|
|
|
|
|
Key: &keyStr,
|
|
|
|
|
Body: file.Content,
|
|
|
|
|
ContentType: &contentType,
|
2025-09-05 18:25:31 +00:00
|
|
|
Metadata: metadata,
|
2025-05-27 15:28:46 +00:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
})
|
2025-09-05 18:25:31 +00:00
|
|
|
|
2025-11-26 19:23:42 +00:00
|
|
|
return result, err
|
2025-05-27 15:28:46 +00:00
|
|
|
}
|