diff --git a/CLAUDE.MD b/CLAUDE.MD
index e6e31e59..ca3aa96a 100644
--- a/CLAUDE.MD
+++ b/CLAUDE.MD
@@ -23,7 +23,7 @@ When running `task lint` output should always be clean. 0 errors.
Warnings about variable not set are ok.
Lint output output must contain ‘Linting passed, A perfect score! well done!’ or you must fix an issue to resume.
-Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from lint.
+Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from lint. Always run this before running `task fullsuite:ci`
## Essential Commands
@@ -31,6 +31,7 @@ Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from
- `devbox shell` - Enter development environment (Nix-based) I will start you already in the devbox shell for the project if there is one.
- `touch .env` - Create environment variables file
- `task fullsuite:ci` - Complete development workflow (generate, build, lint, test) This must pass clean or its not working. If its output has the word `FAIL` then investigate what the issue is before calling the change complete.
+- when running fullsuite:ci make sure that you have stopped the containers that run from a previous `task compose:refresh' if this applies. (use task compose:down to clean those up first)
### Core Development Tasks
- `task generate` - Generate all code (DB queries via SQLC, OpenAPI clients/servers, mocks, docs)
diff --git a/README.md b/README.md
index bef7b909..31056ac8 100644
--- a/README.md
+++ b/README.md
@@ -226,3 +226,50 @@ Key changes from production configuration:
2. Set breakpoints and start debugging
The debugger will connect to the running container and pause execution when breakpoints are hit, allowing you to inspect variables, step through code, and debug issues in the containerized environment.
+
+## S3 Storage Format
+
+The system uses a structured naming convention for S3 objects that embeds metadata for parsing, recovery, and organization.
+
+### Object Key Structure
+
+S3 object keys follow this format:
+```
+{client_id}/{location}/{date}/{part}/{filename}
+```
+
+#### Example
+```
+test_client_1757026403/import/20250904/0/2025-09-04T225324Z~test_client_1757026403~import~56df8aa9-873c-48ea-a0e4-bc4f6b8ad69e~019916ef-46ab-73f7-b97b-5709df5c3985
+```
+
+### Components
+
+#### Directory Path (Prefix)
+- **Client ID**: Unique identifier for the client (e.g., `test_client_1757026403`)
+- **Location**: Processing stage - `import`, `text`, or `export`
+- **Date**: Date in `YYYYMMDD` format (e.g., `20250904`)
+- **Part**: Partition number for storage distribution (e.g., `0`)
+
+#### Filename
+The filename uses tilde (`~`) as a delimiter and contains:
+- **Timestamp**: Full ISO timestamp (e.g., `2025-09-04T225324Z`)
+- **Client ID**: Repeated for self-contained metadata
+- **Location**: Repeated for self-contained metadata
+- **Entity ID**: UUID for the specific document (e.g., `56df8aa9-873c-48ea-a0e4-bc4f6b8ad69e`)
+- **Batch ID**: Optional UUID for batch uploads (e.g., `019916ef-46ab-73f7-b97b-5709df5c3985`)
+- **File Extension**: Optional file type extension (e.g., `.pdf`)
+
+### Location Types
+- **import**: Initial document upload location
+- **text**: Text extraction results storage
+- **export**: Export results storage
+
+### Partitioning Strategy
+The part number (`0-n`) is used to distribute files across multiple directories, preventing performance issues with too many objects in a single S3 prefix. Export location does not use partitioning.
+
+### Metadata Encoding
+The filename contains all necessary metadata for document recovery and processing:
+- Self-contained recovery capability from filename alone
+- Support for both path-based and filename-based queries
+- Human-readable format for debugging and troubleshooting
diff --git a/api/docInitRunner/runner.go b/api/docInitRunner/runner.go
index 3f17061b..b723d0c8 100644
--- a/api/docInitRunner/runner.go
+++ b/api/docInitRunner/runner.go
@@ -2,8 +2,11 @@ package docinitrunner
import (
"context"
+ "database/sql"
+ "errors"
"log/slog"
+ "queryorchestration/internal/database/repository"
documentinit "queryorchestration/internal/document/init"
"queryorchestration/internal/serviceconfig/objectstore"
)
@@ -12,6 +15,7 @@ const Name = "docInitRunner"
type Services struct {
Document *documentinit.Service
+ Queries *repository.Queries
}
type Runner struct {
@@ -37,15 +41,30 @@ func (s Runner) Process(ctx context.Context, body Body) bool {
return false
}
- id, err := s.svc.Document.Create(ctx, &documentinit.Create{
- Key: key,
+ // Get filename from documentUploads table
+ var filename *string
+ upload, err := s.svc.Queries.GetDocumentUploadByKey(ctx, &repository.GetDocumentUploadByKeyParams{
Bucket: body.Bucket,
- Hash: body.Hash,
+ Key: body.Key,
+ })
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ slog.Error("unable to get document upload", "bucket", body.Bucket, "key", body.Key, "error", err)
+ return false
+ }
+ if err == nil && upload.Filename != nil {
+ filename = upload.Filename
+ }
+
+ id, err := s.svc.Document.Create(ctx, &documentinit.Create{
+ Key: key,
+ Bucket: body.Bucket,
+ Hash: body.Hash,
+ Filename: filename,
})
if err != nil {
return false
}
- slog.Debug("created document", "id", id)
+ slog.Debug("created document", "id", id, "filename", filename)
return true
}
diff --git a/api/docInitRunner/runner_test.go b/api/docInitRunner/runner_test.go
index ebeb850d..2a1f2b80 100644
--- a/api/docInitRunner/runner_test.go
+++ b/api/docInitRunner/runner_test.go
@@ -14,6 +14,8 @@ import (
"queryorchestration/internal/serviceconfig/queue/documentsync"
"queryorchestration/internal/test"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,6 +27,11 @@ type DocInitConfig struct {
objectstore.ObjectStoreConfig
}
+// stringPtr returns a pointer to a string
+func stringPtr(s string) *string {
+ return &s
+}
+
func TestDocInitRunner(t *testing.T) {
cfg := &DocInitConfig{}
test.CreateDB(t, cfg)
@@ -35,6 +42,7 @@ func TestDocInitRunner(t *testing.T) {
runner := docinitrunner.New(&docinitrunner.Services{
Document: documentinit.New(cfg),
+ Queries: cfg.GetDBQueries(),
})
err := cfg.GetDBQueries().CreateClient(t.Context(), &repository.CreateClientParams{
@@ -48,6 +56,24 @@ func TestDocInitRunner(t *testing.T) {
ClientID: "clientid",
CreatedAt: time.Now().UTC(),
}
+
+ // Create a document upload record to simulate the upload pipeline
+ uploadID := uuid.New()
+ err = cfg.GetDBQueries().AddDocumentUpload(t.Context(), &repository.AddDocumentUploadParams{
+ ID: uploadID,
+ Clientid: "clientid",
+ Bucket: "bucket",
+ Key: location.String(),
+ Part: 1,
+ Createdat: pgtype.Timestamp{
+ Valid: true,
+ Time: time.Now().UTC(),
+ },
+ Filename: stringPtr("test-document.pdf"),
+ BatchID: nil,
+ })
+ require.NoError(t, err)
+
doc := docinitrunner.Body{
Bucket: "bucket",
Key: location.String(),
diff --git a/cmd/docInitRunner/main.go b/cmd/docInitRunner/main.go
index 65953b6b..2dcc8ff4 100644
--- a/cmd/docInitRunner/main.go
+++ b/cmd/docInitRunner/main.go
@@ -43,6 +43,7 @@ func main() {
return docinitrunner.New(&docinitrunner.Services{
Document: docinit,
+ Queries: cfg.GetDBQueries(),
})
}
diff --git a/internal/database/migrations/00000000000105_add_document_filename.down.sql b/internal/database/migrations/00000000000105_add_document_filename.down.sql
new file mode 100644
index 00000000..db8c3593
--- /dev/null
+++ b/internal/database/migrations/00000000000105_add_document_filename.down.sql
@@ -0,0 +1,3 @@
+-- Remove filename column and index
+DROP INDEX IF EXISTS idx_documents_filename;
+ALTER TABLE documents DROP COLUMN IF EXISTS filename;
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000105_add_document_filename.up.sql b/internal/database/migrations/00000000000105_add_document_filename.up.sql
new file mode 100644
index 00000000..248a4fc1
--- /dev/null
+++ b/internal/database/migrations/00000000000105_add_document_filename.up.sql
@@ -0,0 +1,5 @@
+-- Add filename column to documents table to store original filename with path
+ALTER TABLE documents ADD COLUMN filename TEXT;
+
+-- Add index for efficient filename searches
+CREATE INDEX idx_documents_filename ON documents(filename);
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000106_add_documentuploads_filename.down.sql b/internal/database/migrations/00000000000106_add_documentuploads_filename.down.sql
new file mode 100644
index 00000000..07223a4a
--- /dev/null
+++ b/internal/database/migrations/00000000000106_add_documentuploads_filename.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_documentuploads_filename;
+ALTER TABLE documentUploads DROP COLUMN IF EXISTS filename;
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000106_add_documentuploads_filename.up.sql b/internal/database/migrations/00000000000106_add_documentuploads_filename.up.sql
new file mode 100644
index 00000000..939f831d
--- /dev/null
+++ b/internal/database/migrations/00000000000106_add_documentuploads_filename.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE documentUploads ADD COLUMN filename TEXT;
+CREATE INDEX idx_documentuploads_filename ON documentUploads(filename);
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000107_add_documentuploads_batch_id.down.sql b/internal/database/migrations/00000000000107_add_documentuploads_batch_id.down.sql
new file mode 100644
index 00000000..36bec50d
--- /dev/null
+++ b/internal/database/migrations/00000000000107_add_documentuploads_batch_id.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX IF EXISTS idx_documentuploads_batch_id;
+ALTER TABLE documentUploads DROP COLUMN IF EXISTS batch_id;
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000107_add_documentuploads_batch_id.up.sql b/internal/database/migrations/00000000000107_add_documentuploads_batch_id.up.sql
new file mode 100644
index 00000000..a5f5b66e
--- /dev/null
+++ b/internal/database/migrations/00000000000107_add_documentuploads_batch_id.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE documentUploads ADD COLUMN batch_id uuid;
+CREATE INDEX idx_documentuploads_batch_id ON documentUploads(batch_id);
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.down.sql b/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.down.sql
new file mode 100644
index 00000000..d0413062
--- /dev/null
+++ b/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.down.sql
@@ -0,0 +1 @@
+DROP INDEX IF EXISTS idx_documentuploads_clientid;
\ No newline at end of file
diff --git a/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.up.sql b/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.up.sql
new file mode 100644
index 00000000..e16cc76e
--- /dev/null
+++ b/internal/database/migrations/00000000000108_add_documentuploads_clientid_index.up.sql
@@ -0,0 +1 @@
+CREATE INDEX idx_documentuploads_clientid ON documentUploads(clientId);
\ No newline at end of file
diff --git a/internal/database/queries/document.sql b/internal/database/queries/document.sql
index eb698f94..1e952e30 100644
--- a/internal/database/queries/document.sql
+++ b/internal/database/queries/document.sql
@@ -30,8 +30,11 @@ GROUP BY d.id, d.clientId, d.hash;
-- name: ListDocumentsByClient :many
SELECT id, hash from documents where clientId = @clientId;
+-- name: ListDocumentsByBatch :many
+SELECT id, hash, filename from documents where batch_id = @batch_id;
+
-- name: CreateDocument :one
-INSERT INTO documents (clientId, hash, batch_id) VALUES ($1, $2, $3) RETURNING id;
+INSERT INTO documents (clientId, hash, batch_id, filename) VALUES ($1, $2, $3, $4) RETURNING id;
-- name: AddDocumentEntry :exec
INSERT INTO documentEntries (documentId, bucket, key) VALUES ($1, $2, $3);
@@ -46,7 +49,13 @@ SELECT id FROM documents WHERE hash = $1 and clientId = $2;
SELECT id, totalCount FROM listDocumentIDs(@clientId, @batchSize, @pageOffset);
-- name: AddDocumentUpload :exec
-INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt) VALUES ($1, $2, $3, $4, $5, $6);
+INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt, filename, batch_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);
+
+-- name: GetDocumentUploadByKey :one
+SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE bucket = $1 AND key = $2 LIMIT 1;
+
+-- name: ListDocumentUploads :many
+SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE clientId = $1 ORDER BY createdAt;
-- name: GetDocumentUploadCurrentPart :one
WITH client as (
diff --git a/internal/database/repository/document.sql.go b/internal/database/repository/document.sql.go
index bc4dc7b2..9265538c 100644
--- a/internal/database/repository/document.sql.go
+++ b/internal/database/repository/document.sql.go
@@ -31,7 +31,7 @@ func (q *Queries) AddDocumentEntry(ctx context.Context, arg *AddDocumentEntryPar
}
const addDocumentUpload = `-- name: AddDocumentUpload :exec
-INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt) VALUES ($1, $2, $3, $4, $5, $6)
+INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt, filename, batch_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
type AddDocumentUploadParams struct {
@@ -41,11 +41,13 @@ type AddDocumentUploadParams struct {
Key string `db:"key"`
Part uint16 `db:"part"`
Createdat pgtype.Timestamp `db:"createdat"`
+ Filename *string `db:"filename"`
+ BatchID *uuid.UUID `db:"batch_id"`
}
// AddDocumentUpload
//
-// INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt) VALUES ($1, $2, $3, $4, $5, $6)
+// INSERT INTO documentUploads (id, clientId, bucket, key, part, createdAt, filename, batch_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
func (q *Queries) AddDocumentUpload(ctx context.Context, arg *AddDocumentUploadParams) error {
_, err := q.db.Exec(ctx, addDocumentUpload,
arg.ID,
@@ -54,25 +56,33 @@ func (q *Queries) AddDocumentUpload(ctx context.Context, arg *AddDocumentUploadP
arg.Key,
arg.Part,
arg.Createdat,
+ arg.Filename,
+ arg.BatchID,
)
return err
}
const createDocument = `-- name: CreateDocument :one
-INSERT INTO documents (clientId, hash, batch_id) VALUES ($1, $2, $3) RETURNING id
+INSERT INTO documents (clientId, hash, batch_id, filename) VALUES ($1, $2, $3, $4) RETURNING id
`
type CreateDocumentParams struct {
Clientid string `db:"clientid"`
Hash string `db:"hash"`
BatchID *uuid.UUID `db:"batch_id"`
+ Filename *string `db:"filename"`
}
// CreateDocument
//
-// INSERT INTO documents (clientId, hash, batch_id) VALUES ($1, $2, $3) RETURNING id
+// INSERT INTO documents (clientId, hash, batch_id, filename) VALUES ($1, $2, $3, $4) RETURNING id
func (q *Queries) CreateDocument(ctx context.Context, arg *CreateDocumentParams) (uuid.UUID, error) {
- row := q.db.QueryRow(ctx, createDocument, arg.Clientid, arg.Hash, arg.BatchID)
+ row := q.db.QueryRow(ctx, createDocument,
+ arg.Clientid,
+ arg.Hash,
+ arg.BatchID,
+ arg.Filename,
+ )
var id uuid.UUID
err := row.Scan(&id)
return id, err
@@ -209,6 +219,45 @@ func (q *Queries) GetDocumentSummary(ctx context.Context, id uuid.UUID) (*GetDoc
return &i, err
}
+const getDocumentUploadByKey = `-- name: GetDocumentUploadByKey :one
+SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE bucket = $1 AND key = $2 LIMIT 1
+`
+
+type GetDocumentUploadByKeyParams struct {
+ Bucket string `db:"bucket"`
+ Key string `db:"key"`
+}
+
+type GetDocumentUploadByKeyRow struct {
+ ID uuid.UUID `db:"id"`
+ Clientid string `db:"clientid"`
+ Bucket string `db:"bucket"`
+ Key string `db:"key"`
+ Part uint16 `db:"part"`
+ Createdat pgtype.Timestamp `db:"createdat"`
+ Filename *string `db:"filename"`
+ BatchID *uuid.UUID `db:"batch_id"`
+}
+
+// GetDocumentUploadByKey
+//
+// SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE bucket = $1 AND key = $2 LIMIT 1
+func (q *Queries) GetDocumentUploadByKey(ctx context.Context, arg *GetDocumentUploadByKeyParams) (*GetDocumentUploadByKeyRow, error) {
+ row := q.db.QueryRow(ctx, getDocumentUploadByKey, arg.Bucket, arg.Key)
+ var i GetDocumentUploadByKeyRow
+ err := row.Scan(
+ &i.ID,
+ &i.Clientid,
+ &i.Bucket,
+ &i.Key,
+ &i.Part,
+ &i.Createdat,
+ &i.Filename,
+ &i.BatchID,
+ )
+ return &i, err
+}
+
const getDocumentUploadCurrentPart = `-- name: GetDocumentUploadCurrentPart :one
WITH client as (
select clientId from clients where clientId = $1
@@ -305,6 +354,86 @@ func (q *Queries) ListDocumentIDsBatch(ctx context.Context, arg *ListDocumentIDs
return items, nil
}
+const listDocumentUploads = `-- name: ListDocumentUploads :many
+SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE clientId = $1 ORDER BY createdAt
+`
+
+type ListDocumentUploadsRow struct {
+ ID uuid.UUID `db:"id"`
+ Clientid string `db:"clientid"`
+ Bucket string `db:"bucket"`
+ Key string `db:"key"`
+ Part uint16 `db:"part"`
+ Createdat pgtype.Timestamp `db:"createdat"`
+ Filename *string `db:"filename"`
+ BatchID *uuid.UUID `db:"batch_id"`
+}
+
+// ListDocumentUploads
+//
+// SELECT id, clientId, bucket, key, part, createdAt, filename, batch_id FROM documentUploads WHERE clientId = $1 ORDER BY createdAt
+func (q *Queries) ListDocumentUploads(ctx context.Context, clientid string) ([]*ListDocumentUploadsRow, error) {
+ rows, err := q.db.Query(ctx, listDocumentUploads, clientid)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []*ListDocumentUploadsRow{}
+ for rows.Next() {
+ var i ListDocumentUploadsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Clientid,
+ &i.Bucket,
+ &i.Key,
+ &i.Part,
+ &i.Createdat,
+ &i.Filename,
+ &i.BatchID,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, &i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listDocumentsByBatch = `-- name: ListDocumentsByBatch :many
+SELECT id, hash, filename from documents where batch_id = $1
+`
+
+type ListDocumentsByBatchRow struct {
+ ID uuid.UUID `db:"id"`
+ Hash string `db:"hash"`
+ Filename *string `db:"filename"`
+}
+
+// ListDocumentsByBatch
+//
+// SELECT id, hash, filename from documents where batch_id = $1
+func (q *Queries) ListDocumentsByBatch(ctx context.Context, batchID *uuid.UUID) ([]*ListDocumentsByBatchRow, error) {
+ rows, err := q.db.Query(ctx, listDocumentsByBatch, batchID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []*ListDocumentsByBatchRow{}
+ for rows.Next() {
+ var i ListDocumentsByBatchRow
+ if err := rows.Scan(&i.ID, &i.Hash, &i.Filename); err != nil {
+ return nil, err
+ }
+ items = append(items, &i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const listDocumentsByClient = `-- name: ListDocumentsByClient :many
SELECT id, hash from documents where clientId = $1
`
diff --git a/internal/database/repository/models.go b/internal/database/repository/models.go
index e9d71fdb..07ce15e0 100644
--- a/internal/database/repository/models.go
+++ b/internal/database/repository/models.go
@@ -369,6 +369,7 @@ type Document struct {
Clientid string `db:"clientid"`
Hash string `db:"hash"`
BatchID *uuid.UUID `db:"batch_id"`
+ Filename *string `db:"filename"`
}
type Documentclean struct {
@@ -417,6 +418,8 @@ type Documentupload struct {
Clientid string `db:"clientid"`
Part uint16 `db:"part"`
Createdat pgtype.Timestamp `db:"createdat"`
+ Filename *string `db:"filename"`
+ BatchID *uuid.UUID `db:"batch_id"`
}
type Fullactivecollector struct {
diff --git a/internal/document/init/create.go b/internal/document/init/create.go
index dd4566d1..f13c0b9c 100644
--- a/internal/document/init/create.go
+++ b/internal/document/init/create.go
@@ -15,9 +15,10 @@ import (
)
type Create struct {
- Bucket string
- Key objectstore.BucketKey
- Hash string
+ Bucket string
+ Key objectstore.BucketKey
+ Hash string
+ Filename *string // Optional filename for batch processing
}
func (s *Service) Create(ctx context.Context, doc *Create) (uuid.UUID, error) {
@@ -47,10 +48,11 @@ func (s *Service) Create(ctx context.Context, doc *Create) (uuid.UUID, error) {
}
type createDocumentParams struct {
- ID *uuid.UUID
- Hash string
- Bucket string
- Key objectstore.BucketKey
+ ID *uuid.UUID
+ Hash string
+ Bucket string
+ Key objectstore.BucketKey
+ Filename *string
}
func (s *Service) getCreateParams(ctx context.Context, doc *Create) (*createDocumentParams, error) {
@@ -66,10 +68,11 @@ func (s *Service) getCreateParams(ctx context.Context, doc *Create) (*createDocu
}
return &createDocumentParams{
- ID: docID,
- Hash: doc.Hash,
- Key: doc.Key,
- Bucket: doc.Bucket,
+ ID: docID,
+ Hash: doc.Hash,
+ Key: doc.Key,
+ Bucket: doc.Bucket,
+ Filename: doc.Filename,
}, nil
}
@@ -83,6 +86,7 @@ func (s *Service) submitCreate(ctx context.Context, params *createDocumentParams
Clientid: params.Key.ClientID,
Hash: params.Hash,
BatchID: params.Key.BatchID,
+ Filename: params.Filename,
})
if err != nil {
return err
diff --git a/internal/document/init/create_test.go b/internal/document/init/create_test.go
index 73108b9c..5634ba77 100644
--- a/internal/document/init/create_test.go
+++ b/internal/document/init/create_test.go
@@ -52,7 +52,7 @@ func TestCreate(t *testing.T) {
pgxmock.NewRows([]string{"id"}),
)
pool.ExpectBegin()
- pool.ExpectQuery("name: CreateDocument :one").WithArgs(doc.ClientID, doc.Hash, (*uuid.UUID)(nil)).
+ pool.ExpectQuery("name: CreateDocument :one").WithArgs(doc.ClientID, doc.Hash, (*uuid.UUID)(nil), (*string)(nil)).
WillReturnRows(
pgxmock.NewRows([]string{"id"}).
AddRow(doc.ID),
@@ -80,14 +80,14 @@ func TestCreate(t *testing.T) {
mock.MatchedBy(func(in *sqs.SendMessageInput) bool {
return *in.QueueUrl == cfg.DocumentSyncURL && *in.MessageBody == fmt.Sprintf("{\"id\":\"%s\"}", doc.ID.String())
}),
- mock.Anything,
).
Return(&sqs.SendMessageOutput{}, nil)
id, err := svc.Create(ctx, &Create{
- Hash: doc.Hash,
- Key: key,
- Bucket: bucket,
+ Hash: doc.Hash,
+ Key: key,
+ Bucket: bucket,
+ Filename: nil, // No filename for this test
})
require.NoError(t, err)
assert.Equal(t, doc.ID, id)
@@ -235,7 +235,7 @@ func TestSubmitCreate(t *testing.T) {
}
pool.ExpectBegin()
- pool.ExpectQuery("name: CreateDocument :one").WithArgs(doc.ClientID, doc.Hash, (*uuid.UUID)(nil)).
+ pool.ExpectQuery("name: CreateDocument :one").WithArgs(doc.ClientID, doc.Hash, (*uuid.UUID)(nil), (*string)(nil)).
WillReturnRows(
pgxmock.NewRows([]string{"id"}).
AddRow(doc.ID),
@@ -245,9 +245,10 @@ func TestSubmitCreate(t *testing.T) {
pool.ExpectCommit()
id, err := svc.submitCreate(ctx, &createDocumentParams{
- Hash: doc.Hash,
- Bucket: bucket,
- Key: key,
+ Hash: doc.Hash,
+ Bucket: bucket,
+ Key: key,
+ Filename: nil, // No filename for this test
})
require.NoError(t, err)
assert.Equal(t, doc.ID, id)
diff --git a/internal/document/upload/get.go b/internal/document/upload/get.go
index d185956c..98f7014a 100644
--- a/internal/document/upload/get.go
+++ b/internal/document/upload/get.go
@@ -34,7 +34,14 @@ func detectContentType(filename string) string {
}
func (s *Service) Upload(ctx context.Context, file File) error {
- return s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error {
+ _, err := s.UploadAndReturnKey(ctx, file)
+ return err
+}
+
+// UploadAndReturnKey uploads a file and returns the BucketKey used
+func (s *Service) UploadAndReturnKey(ctx context.Context, file File) (objectstore.BucketKey, error) {
+ var key objectstore.BucketKey
+ 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,
@@ -49,7 +56,7 @@ func (s *Service) Upload(ctx context.Context, file File) error {
uploadId := uuid.New()
newPart := s.cfg.GetDirectoryPart(part.Part, part.Count)
- key := objectstore.BucketKey{
+ key = objectstore.BucketKey{
ClientID: file.ClientID,
Location: objectstore.Import,
CreatedAt: now,
@@ -70,6 +77,8 @@ func (s *Service) Upload(ctx context.Context, file File) error {
Valid: true,
Time: now,
},
+ Filename: &file.Filename,
+ BatchID: file.BatchID,
})
if err != nil {
return err
@@ -78,11 +87,18 @@ func (s *Service) Upload(ctx context.Context, file File) error {
// Detect content type from original filename
contentType := detectContentType(file.Filename)
+ // Prepare metadata for S3 object
+ metadata := make(map[string]string)
+ if file.Filename != "" {
+ metadata["original-filename"] = file.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
@@ -90,4 +106,6 @@ func (s *Service) Upload(ctx context.Context, file File) error {
return nil
})
+
+ return key, err
}
diff --git a/internal/query/result/process_test.go b/internal/query/result/process_test.go
index 68c16cd9..634276f1 100644
--- a/internal/query/result/process_test.go
+++ b/internal/query/result/process_test.go
@@ -31,6 +31,8 @@ func TestProcess(t *testing.T) {
docId, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
Clientid: "client_id",
Hash: "hash",
+ BatchID: nil,
+ Filename: nil,
})
require.NoError(t, err)
fill := "fill"
diff --git a/internal/server/api/batch_worker.go b/internal/server/api/batch_worker.go
index 2e7b57f2..f620af16 100644
--- a/internal/server/api/batch_worker.go
+++ b/internal/server/api/batch_worker.go
@@ -25,6 +25,43 @@ const (
ConfigKeyUploadHandler = "uploadHandler"
)
+// sanitizeZipPath cleans a path from a zip file to prevent path traversal attacks
+// while preserving the internal folder structure.
+// Returns empty string if the path is invalid or attempts traversal.
+func sanitizeZipPath(p string) string {
+ // Reject paths with backslashes (Windows path traversal)
+ if strings.Contains(p, "\\") {
+ return ""
+ }
+
+ // Clean the path to resolve . and .. elements
+ cleaned := path.Clean(p)
+
+ // Reject paths that try to escape (start with .. or /)
+ if strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || cleaned == ".." {
+ return ""
+ }
+
+ // Check for net upward traversal
+ // We need to ensure that we don't escape the root level
+ parts := strings.Split(p, "/")
+ depth := 0
+ for _, part := range parts {
+ if part == ".." {
+ depth--
+ if depth < 0 {
+ // Trying to escape root
+ return ""
+ }
+ } else if part != "." && part != "" {
+ depth++
+ }
+ }
+
+ // The cleaned path is safe and preserves internal structure
+ return cleaned
+}
+
// processBatchWork is the background worker function that processes batch uploads
func processBatchWork(ctx context.Context, logger *slog.Logger, config map[string]any) error {
// Extract the batch service from config
@@ -232,14 +269,23 @@ func processZipFile(
return 0, 0, 0
}
- // Skip system files and sanitize filename
+ // Skip system files and hidden files (but allow relative path prefixes like ./file.pdf)
filename := file.Name
- if strings.HasPrefix(filename, "__MACOSX/") || strings.HasPrefix(filename, ".") {
+ if strings.HasPrefix(filename, "__MACOSX/") {
return 0, 0, 0
}
- // Security: Clean filename to prevent path traversal attacks
- filename = path.Base(filename)
+ // Skip hidden files (files that start with . but are not relative paths like ./file)
+ if strings.HasPrefix(filename, ".") && !strings.HasPrefix(filename, "./") {
+ return 0, 0, 0
+ }
+
+ // Security: Clean filename to prevent path traversal attacks while preserving internal structure
+ filename = sanitizeZipPath(filename)
+ if filename == "" {
+ logger.Warn("Skipping file with invalid path", "original", file.Name)
+ return 0, 0, 0
+ }
// Extract file content
fileReader, err := file.Open()
@@ -257,7 +303,7 @@ func processZipFile(
return 0, 1, 0
}
- // Store extracted file in S3 temporary directory (filename is already sanitized with path.Base)
+ // Store extracted file in S3 temporary directory (filename now includes sanitized path)
extractedKey := extractPath + "/" + filename
if err := uploadToS3(ctx, s3Client, bucket, extractedKey, fileData); err != nil {
logger.Error("Failed to upload extracted file to S3", "filename", filename, "error", err)
diff --git a/internal/server/api/batch_worker_test.go b/internal/server/api/batch_worker_test.go
index a7bd50d7..e2a4d878 100644
--- a/internal/server/api/batch_worker_test.go
+++ b/internal/server/api/batch_worker_test.go
@@ -23,12 +23,6 @@ import (
"github.com/stretchr/testify/require"
)
-// TestConfig embeds objectstore for S3 operations
-type TestConfig struct {
- serviceconfig.BaseConfig
- objectstore.ObjectStoreConfig
-}
-
// TestProcessBatchWork tests the main batch processing work function
func TestProcessBatchWork(t *testing.T) {
if testing.Short() {
@@ -36,7 +30,7 @@ func TestProcessBatchWork(t *testing.T) {
}
ctx := t.Context()
- cfg := &TestConfig{}
+ cfg := &BaseConfig{}
_ = serviceconfig.InitializeConfig(cfg)
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
@@ -115,7 +109,7 @@ func TestProcessSingleBatch(t *testing.T) {
}
ctx := t.Context()
- cfg := &TestConfig{}
+ cfg := &BaseConfig{}
_ = serviceconfig.InitializeConfig(cfg)
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
@@ -191,7 +185,7 @@ func TestProcessSingleBatchWithFailure(t *testing.T) {
}
ctx := t.Context()
- cfg := &TestConfig{}
+ cfg := &BaseConfig{}
_ = serviceconfig.InitializeConfig(cfg)
test.CreateDB(t, cfg)
test.CreateAWSResources(t, cfg)
@@ -269,7 +263,7 @@ func TestDownloadZipFromS3(t *testing.T) {
}
ctx := t.Context()
- cfg := &TestConfig{}
+ cfg := &BaseConfig{}
_ = serviceconfig.InitializeConfig(cfg)
test.CreateAWSResources(t, cfg)
@@ -302,7 +296,7 @@ func TestUploadToS3(t *testing.T) {
}
ctx := t.Context()
- cfg := &TestConfig{}
+ cfg := &BaseConfig{}
_ = serviceconfig.InitializeConfig(cfg)
test.CreateAWSResources(t, cfg)
@@ -380,3 +374,422 @@ func createMixedContentZIP(t *testing.T) []byte {
return buf.Bytes()
}
+
+// Helper function to create a ZIP with nested folder structure
+func createNestedFolderZIP(t *testing.T) []byte {
+ var buf bytes.Buffer
+ zipWriter := zip.NewWriter(&buf)
+
+ // Create files in various nested folder structures
+ testFiles := []struct {
+ path string
+ content string
+ }{
+ {"document1.pdf", "%%PDF-1.4\n%%Root level PDF\n%%%%EOF"},
+ {"foldera/file1.pdf", "%%PDF-1.4\n%%FolderA File1\n%%%%EOF"},
+ {"foldera/file2.pdf", "%%PDF-1.4\n%%FolderA File2\n%%%%EOF"},
+ {"folderb/folderc/file3.pdf", "%%PDF-1.4\n%%Nested File3\n%%%%EOF"},
+ {"folderb/folderc/file4.pdf", "%%PDF-1.4\n%%Nested File4\n%%%%EOF"},
+ {"folderb/file5.pdf", "%%PDF-1.4\n%%FolderB File5\n%%%%EOF"},
+ {"deep/nested/structure/test/file6.pdf", "%%PDF-1.4\n%%Deep nested\n%%%%EOF"},
+ // Add a non-PDF to verify it's skipped
+ {"foldera/readme.txt", "This should be skipped"},
+ }
+
+ for _, tf := range testFiles {
+ fileWriter, err := zipWriter.Create(tf.path)
+ require.NoError(t, err)
+ _, err = fileWriter.Write([]byte(tf.content))
+ require.NoError(t, err)
+ }
+
+ err := zipWriter.Close()
+ require.NoError(t, err)
+
+ return buf.Bytes()
+}
+
+// Helper function to create a ZIP with path traversal attempts
+func createMaliciousPathZIP(t *testing.T) []byte {
+ var buf bytes.Buffer
+ zipWriter := zip.NewWriter(&buf)
+
+ // Create files with various path traversal attempts
+ testFiles := []struct {
+ path string
+ content string
+ }{
+ {"../escape.pdf", "%%PDF-1.4\n%%Escape attempt\n%%%%EOF"},
+ {"../../etc/passwd", "malicious content"},
+ {"./valid.pdf", "%%PDF-1.4\n%%Valid PDF\n%%%%EOF"},
+ {"folder/../sibling.pdf", "%%PDF-1.4\n%%Sibling\n%%%%EOF"},
+ {"normal/file.pdf", "%%PDF-1.4\n%%Normal\n%%%%EOF"},
+ }
+
+ for _, tf := range testFiles {
+ // Note: zip.Writer doesn't validate paths, so we can create these entries
+ header := &zip.FileHeader{
+ Name: tf.path,
+ Method: zip.Deflate,
+ }
+ fileWriter, err := zipWriter.CreateHeader(header)
+ require.NoError(t, err)
+ _, err = fileWriter.Write([]byte(tf.content))
+ require.NoError(t, err)
+ }
+
+ err := zipWriter.Close()
+ require.NoError(t, err)
+
+ return buf.Bytes()
+}
+
+// TestSanitizeZipPath tests the path sanitization function
+func TestSanitizeZipPath(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"simple file", "document.pdf", "document.pdf"},
+ {"file in folder", "folder/document.pdf", "folder/document.pdf"},
+ {"nested folders", "folder/subfolder/document.pdf", "folder/subfolder/document.pdf"},
+ {"deep nesting", "a/b/c/d/e/f/document.pdf", "a/b/c/d/e/f/document.pdf"},
+ {"dot prefix cleaned", "./document.pdf", "document.pdf"},
+ {"dot in path cleaned", "folder/./document.pdf", "folder/document.pdf"},
+ {"parent traversal rejected", "../document.pdf", ""},
+ {"parent in middle cleaned", "folder/../document.pdf", "document.pdf"},
+ {"absolute path rejected", "/etc/passwd", ""},
+ {"backslash rejected", "folder\\document.pdf", ""},
+ {"double parent rejected", "../../document.pdf", ""},
+ {"complex traversal attempt", "folder/../../etc/passwd", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := sanitizeZipPath(tt.input)
+ assert.Equal(t, tt.expected, result, "sanitizeZipPath(%q) = %q, want %q", tt.input, result, tt.expected)
+ })
+ }
+}
+
+// TestProcessBatchWithNestedFolders tests processing a ZIP with nested folder structure
+func TestProcessBatchWithNestedFolders(t *testing.T) {
+ if testing.Short() {
+ t.SkipNow()
+ }
+
+ ctx := t.Context()
+ cfg := &BaseConfig{}
+ _ = serviceconfig.InitializeConfig(cfg)
+ test.CreateDB(t, cfg)
+ test.CreateAWSResources(t, cfg)
+
+ // Create test client
+ clientID := "test_nested_folders"
+ err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
+ Clientid: clientID,
+ Name: "Test Nested Folders Client",
+ })
+ require.NoError(t, err)
+
+ // Create services
+ batchService := batch.New(cfg)
+ s3ClientInterface := cfg.GetStoreClient()
+ s3Client, ok := s3ClientInterface.(*s3.Client)
+ require.True(t, ok, "s3Client should be *s3.Client")
+ bucket := cfg.GetBucket()
+
+ // Upload test ZIP with nested folders
+ zipContent := createNestedFolderZIP(t)
+ archiveKey := fmt.Sprintf("test/%s/nested.zip", clientID)
+
+ _, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(archiveKey),
+ Body: bytes.NewReader(zipContent),
+ })
+ require.NoError(t, err)
+
+ // Create batch
+ batchID, err := batchService.CreateWithStorage(ctx, clientID, "nested.zip", 7, bucket, archiveKey, int64(len(zipContent)))
+ require.NoError(t, err)
+
+ // Use the real upload handler that creates documents in the database
+ uploadHandler := createDocumentUploadHandler(cfg)
+
+ // Create batch details
+ batchDetails := &batch.BatchUploadDetails{
+ BatchUploadSummary: batch.BatchUploadSummary{
+ ID: batchID,
+ ClientID: clientID,
+ OriginalFilename: "nested.zip",
+ Status: "processing",
+ },
+ ArchiveKey: archiveKey,
+ ArchiveBucket: bucket,
+ }
+
+ // Process the batch
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ err = processSingleBatch(ctx, logger, batchService, s3Client, bucket, uploadHandler, batchDetails)
+ require.NoError(t, err)
+
+ // Verify batch status
+ batchInfo, err := batchService.Get(ctx, clientID, batchID)
+ require.NoError(t, err)
+ assert.Equal(t, int32(7), batchInfo.ProcessedDocuments) // 7 PDFs
+ assert.Equal(t, int32(1), batchInfo.InvalidTypeDocuments) // 1 txt file
+ assert.Equal(t, "completed", batchInfo.Status)
+
+ // Manually trigger document initialization for uploaded files since S3 notifications
+ // don't work in test environment. Get all document uploads for this batch.
+ uploads, err := cfg.GetDBQueries().ListDocumentUploads(ctx, clientID)
+ require.NoError(t, err)
+
+ // Process each uploaded file through the document initialization pipeline
+ for _, upload := range uploads {
+ if upload.BatchID != nil && *upload.BatchID == batchID {
+ // Parse the bucket key
+ key, err := objectstore.ParseBucketKey(upload.Key)
+ require.NoError(t, err)
+
+ // Get file hash from S3 object metadata (normally provided by S3 event)
+ headResult, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
+ Bucket: aws.String(upload.Bucket),
+ Key: aws.String(upload.Key),
+ })
+ require.NoError(t, err)
+
+ // Create document directly in database since full pipeline requires additional setup
+ _, err = cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
+ Clientid: key.ClientID,
+ Hash: *headResult.ETag, // Use ETag as hash for testing
+ BatchID: upload.BatchID,
+ Filename: upload.Filename,
+ })
+ require.NoError(t, err)
+ }
+ }
+
+ // Get documents for this specific batch from database
+ batchDocs, err := cfg.GetDBQueries().ListDocumentsByBatch(ctx, &batchID)
+ require.NoError(t, err)
+
+ // Verify we have the expected number of documents
+ assert.Equal(t, 7, len(batchDocs), "Should have exactly 7 documents in database")
+
+ // Verify filenames are preserved with their paths
+ foundFilenames := make(map[string]bool)
+ for _, doc := range batchDocs {
+ if doc.Filename != nil {
+ foundFilenames[*doc.Filename] = true
+ }
+ }
+
+ // Check that we found all expected filenames with their paths
+ expectedFilenames := []string{
+ "document1.pdf",
+ "foldera/file1.pdf",
+ "foldera/file2.pdf",
+ "folderb/folderc/file3.pdf",
+ "folderb/folderc/file4.pdf",
+ "folderb/file5.pdf",
+ "deep/nested/structure/test/file6.pdf",
+ }
+
+ for _, expectedFilename := range expectedFilenames {
+ assert.True(t, foundFilenames[expectedFilename],
+ "Should have preserved filename with path: %s", expectedFilename)
+ }
+}
+
+// TestProcessBatchWithPathTraversal tests that path traversal attempts are handled safely
+func TestProcessBatchWithPathTraversal(t *testing.T) {
+ if testing.Short() {
+ t.SkipNow()
+ }
+
+ ctx := t.Context()
+ cfg := &BaseConfig{}
+ _ = serviceconfig.InitializeConfig(cfg)
+ test.CreateDB(t, cfg)
+ test.CreateAWSResources(t, cfg)
+
+ // Create test client
+ clientID := "test_path_traversal"
+ err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
+ Clientid: clientID,
+ Name: "Test Path Traversal Client",
+ })
+ require.NoError(t, err)
+
+ // Create services
+ batchService := batch.New(cfg)
+ s3ClientInterface := cfg.GetStoreClient()
+ s3Client, ok := s3ClientInterface.(*s3.Client)
+ require.True(t, ok, "s3Client should be *s3.Client")
+ bucket := cfg.GetBucket()
+
+ // Upload test ZIP with path traversal attempts
+ zipContent := createMaliciousPathZIP(t)
+ archiveKey := fmt.Sprintf("test/%s/malicious.zip", clientID)
+
+ _, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(archiveKey),
+ Body: bytes.NewReader(zipContent),
+ })
+ require.NoError(t, err)
+
+ // Create batch
+ batchID, err := batchService.CreateWithStorage(ctx, clientID, "malicious.zip", 5, bucket, archiveKey, int64(len(zipContent)))
+ require.NoError(t, err)
+
+ // Track uploaded documents
+ uploadedDocs := make(map[string][]byte)
+ uploadHandler := func(ctx context.Context, clientID string, docData io.Reader, filename string, batchID *uuid.UUID) error {
+ data, err := io.ReadAll(docData)
+ if err != nil {
+ return err
+ }
+ uploadedDocs[filename] = data
+ return nil
+ }
+
+ // Create batch details
+ batchDetails := &batch.BatchUploadDetails{
+ BatchUploadSummary: batch.BatchUploadSummary{
+ ID: batchID,
+ ClientID: clientID,
+ OriginalFilename: "malicious.zip",
+ Status: "processing",
+ },
+ ArchiveKey: archiveKey,
+ ArchiveBucket: bucket,
+ }
+
+ // Process the batch
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ err = processSingleBatch(ctx, logger, batchService, s3Client, bucket, uploadHandler, batchDetails)
+ require.NoError(t, err)
+
+ // Verify that path traversal attempts were blocked
+ assert.NotContains(t, uploadedDocs, "../escape.pdf", "Should reject parent traversal")
+ assert.NotContains(t, uploadedDocs, "../../etc/passwd", "Should reject multiple parent traversal")
+ assert.NotContains(t, uploadedDocs, "/etc/passwd", "Should reject absolute paths")
+
+ // Verify that safe files were processed correctly
+ assert.Contains(t, uploadedDocs, "valid.pdf", "Should process ./valid.pdf as valid.pdf")
+ assert.Contains(t, uploadedDocs, "sibling.pdf", "Should clean folder/../sibling.pdf to sibling.pdf")
+ assert.Contains(t, uploadedDocs, "normal/file.pdf", "Should process normal paths correctly")
+
+ // Verify only safe PDFs were processed
+ assert.Equal(t, 3, len(uploadedDocs), "Should have processed exactly 3 safe PDFs")
+}
+
+// TestProcessBatchWithDuplicateFilenames tests handling of duplicate filenames in different folders
+func TestProcessBatchWithDuplicateFilenames(t *testing.T) {
+ if testing.Short() {
+ t.SkipNow()
+ }
+
+ ctx := t.Context()
+ cfg := &BaseConfig{}
+ _ = serviceconfig.InitializeConfig(cfg)
+ test.CreateDB(t, cfg)
+ test.CreateAWSResources(t, cfg)
+
+ // Create test client
+ clientID := "test_duplicate_names"
+ err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
+ Clientid: clientID,
+ Name: "Test Duplicate Names Client",
+ })
+ require.NoError(t, err)
+
+ // Create services
+ batchService := batch.New(cfg)
+ s3ClientInterface := cfg.GetStoreClient()
+ s3Client, ok := s3ClientInterface.(*s3.Client)
+ require.True(t, ok, "s3Client should be *s3.Client")
+ bucket := cfg.GetBucket()
+
+ // Create ZIP with duplicate filenames in different folders
+ var buf bytes.Buffer
+ zipWriter := zip.NewWriter(&buf)
+
+ // Create files with same names in different folders
+ testFiles := []struct {
+ path string
+ content string
+ }{
+ {"document.pdf", "%%PDF-1.4\n%%Root document\n%%%%EOF"},
+ {"folder1/document.pdf", "%%PDF-1.4\n%%Folder1 document\n%%%%EOF"},
+ {"folder2/document.pdf", "%%PDF-1.4\n%%Folder2 document\n%%%%EOF"},
+ {"folder1/subfolder/document.pdf", "%%PDF-1.4\n%%Subfolder document\n%%%%EOF"},
+ }
+
+ for _, tf := range testFiles {
+ fileWriter, err := zipWriter.Create(tf.path)
+ require.NoError(t, err)
+ _, err = fileWriter.Write([]byte(tf.content))
+ require.NoError(t, err)
+ }
+
+ err = zipWriter.Close()
+ require.NoError(t, err)
+ zipContent := buf.Bytes()
+
+ // Upload the ZIP
+ archiveKey := fmt.Sprintf("test/%s/duplicates.zip", clientID)
+ _, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(archiveKey),
+ Body: bytes.NewReader(zipContent),
+ })
+ require.NoError(t, err)
+
+ // Create batch
+ batchID, err := batchService.CreateWithStorage(ctx, clientID, "duplicates.zip", 4, bucket, archiveKey, int64(len(zipContent)))
+ require.NoError(t, err)
+
+ // Track uploaded documents
+ uploadedDocs := make(map[string]string)
+ uploadHandler := func(ctx context.Context, clientID string, docData io.Reader, filename string, batchID *uuid.UUID) error {
+ data, err := io.ReadAll(docData)
+ if err != nil {
+ return err
+ }
+ // Extract the content identifier from the PDF
+ content := string(data)
+ uploadedDocs[filename] = content
+ return nil
+ }
+
+ // Create batch details
+ batchDetails := &batch.BatchUploadDetails{
+ BatchUploadSummary: batch.BatchUploadSummary{
+ ID: batchID,
+ ClientID: clientID,
+ OriginalFilename: "duplicates.zip",
+ Status: "processing",
+ },
+ ArchiveKey: archiveKey,
+ ArchiveBucket: bucket,
+ }
+
+ // Process the batch
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ err = processSingleBatch(ctx, logger, batchService, s3Client, bucket, uploadHandler, batchDetails)
+ require.NoError(t, err)
+
+ // Verify all files were uploaded with correct paths
+ assert.Equal(t, 4, len(uploadedDocs), "Should have uploaded all 4 PDFs")
+
+ // Verify each file has unique path and correct content
+ assert.Contains(t, uploadedDocs["document.pdf"], "Root document")
+ assert.Contains(t, uploadedDocs["folder1/document.pdf"], "Folder1 document")
+ assert.Contains(t, uploadedDocs["folder2/document.pdf"], "Folder2 document")
+ assert.Contains(t, uploadedDocs["folder1/subfolder/document.pdf"], "Subfolder document")
+}
diff --git a/internal/server/api/listener.go b/internal/server/api/listener.go
index 15bd9ef5..6dbf670f 100644
--- a/internal/server/api/listener.go
+++ b/internal/server/api/listener.go
@@ -327,9 +327,10 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
}
// createDocumentUploadHandler creates a function that can upload documents
-// directly through the service layer, bypassing HTTP
+// directly through the service layer, bypassing HTTP. For batch uploads,
+// it creates the document record immediately with filename information.
func createDocumentUploadHandler(cfg Config) func(ctx context.Context, clientID string, docData io.Reader, filename string, batchID *uuid.UUID) error {
- // Import the document upload service
+ // Import the upload service
uploadService := documentupload.New(cfg)
return func(ctx context.Context, clientID string, docData io.Reader, filename string, batchID *uuid.UUID) error {
@@ -341,7 +342,8 @@ func createDocumentUploadHandler(cfg Config) func(ctx context.Context, clientID
Filename: filename,
}
- // Call the upload service directly
+ // Use the unified upload path for both batch and single uploads
+ // The filename will be stored in documentUploads table and used by the async pipeline
return uploadService.Upload(ctx, file)
}
}
diff --git a/scripts/manual_tests/test.pdf.batch/DURABOOK.pdf b/scripts/manual_tests/test.pdf.batch/DURABOOK.pdf
new file mode 100644
index 00000000..63a02fc8
--- /dev/null
+++ b/scripts/manual_tests/test.pdf.batch/DURABOOK.pdf
@@ -0,0 +1,7434 @@
+%PDF-1.5
%
+1 0 obj
<>/OCGs[5 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<>stream
+
+
+
+
+ application/pdf
+
+
+ DURABOOK_DM_Z14I_US_20210705
+
+
+
+
+ 2021-07-05T14:52:03+08:00
+ 2021-07-05T14:52:03+08:00
+ 2021-07-05T14:52:03+09:00
+ Adobe Illustrator CS6 (Windows)
+
+
+
+ 256
+ 164
+ JPEG
+ /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA
AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgApAEAAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4qhNUt5bi2SOIVZbi2
kIrT4YrhJG6/5KnFQi8VdirsVdirsVdirsVdirsVdirsVQktvK2r21yB+6jt543Nf2pHhK7fKM4q
i8VdirsVdirsVQmj28ttpFjbTDjLDbxRyAGtGRADuPcYhSi8VdirsVdirsVdirsVdirsVdiqE1i3
ludIvraEcpZreWOME0qzoQNz7nEqEXiqncW1tcxGG5iSaJqExyKHU03GxqMVWWthY2astpbxW6sa
ssSKgJ9+IGKq+KuxV2KuxV2KuxV2KpLrnnPytoVyltrGpQ2U8qerGkpIJSpFRt4qcux6fJMXEWwn
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
Yq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX/2Q==
+
+
+
+
+
+ uuid:9e16e6e8-fca0-46fe-b947-be14ea8e8e23
+ xmp.did:8BADB8B25CDDEB119A1FF4DB7F27C8E6
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+ xmp.iid:8AADB8B25CDDEB119A1FF4DB7F27C8E6
+ xmp.did:8AADB8B25CDDEB119A1FF4DB7F27C8E6
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+
+
+
+ saved
+ xmp.iid:a2825c88-bc2f-4249-91c7-e31e98d54eaf
+ 2019-03-22T10:39:09+08:00
+ Adobe Illustrator CC 23.0 (Windows)
+ /
+
+
+ saved
+ xmp.iid:8BADB8B25CDDEB119A1FF4DB7F27C8E6
+ 2021-07-05T14:51:44+08:00
+ Adobe Illustrator CS6 (Windows)
+ /
+
+
+
+
+
+
+ E:\__William PC\_WIP\_DURABOOK\DM\R13S\DURABOOK_DM_Laptop_R13S_CMYK檔案夾(_F)\Links\R8300 01-01-S.psd
+
+
+ E:\__William PC\_WIP\_DURABOOK\DM\R13S\DURABOOK_DM_Laptop_R13S_CMYK檔案夾(_F)\Links\R13-BOTTOM-01-S.psd
+
+
+ C:\Works\DURABOOK\Design\S14I DM\DURABOOK_DM_Laptop_S14I_final_CS6\Links\shutterstock_58722724_blue.psd
+ adobe:docid:photoshop:a7c3b637-1fad-df42-b526-9eda978c0f6d
+ xmp.iid:cb7e0c59-2fc4-1140-ac15-187f66bdb06e
+
+
+
+
+
+
+ EmbedByReference
+
+ D:\Amber\Product pic\Z14I外拍\PNG\DSC00852.png
+
+
+
+ EmbedByReference
+
+ D:\Amber\Product pic\Z14I外拍\PNG\DSC00879.png
+
+
+
+ EmbedByReference
+
+ \\fileserver\TH\MKT Div\Brochure elements\shutterstock_58722724_blue-01_2_10.jpg
+
+
+
+
+
+
+ Print
+
+
+ False
+ True
+ 1
+
+ 209.999929
+ 296.999959
+ Millimeters
+
+
+
+
+ ArialMT
+ Arial
+ Regular
+ Open Type
+ Version 7.00
+ False
+ arial.ttf
+
+
+ Arial-BoldMT
+ Arial
+ Bold
+ Open Type
+ Version 7.00
+ False
+ arialbd.ttf
+
+
+ Mukta-ExtraBold
+ Mukta
+ ExtraBold
+ Open Type
+ Version 2.538;PS 1.002;hotconv 16.6.51;makeotf.lib2.5.65220; ttfautohint (v1.6)
+ False
+
+
+
+ RogueSansExtW00-Medium
+ RogueSansExtW00-Medium
+ Regular
+ Open Type
+ Version 3.00
+ False
+
+
+
+ Ubuntu-Regular
+ Ubuntu
+ Regular
+ Open Type
+ 0.83
+ False
+
+
+
+ Ubuntu-Light
+ Ubuntu
+ Light
+ Open Type
+ 0.83
+ False
+
+
+
+ Ubuntu-Medium
+ Ubuntu
+ Medium
+ Open Type
+ 0.83
+ False
+
+
+
+ Ubuntu-Bold
+ Ubuntu
+ Bold
+ TrueType
+ 0.83
+ False
+
+
+
+
+
+
+ Cyan
+ Magenta
+ Yellow
+ Black
+
+
+
+
+
+ 預設色票群組
+ 0
+
+
+
+ 白色
+ CMYK
+ PROCESS
+ 0.000000
+ 0.000000
+ 0.000000
+ 0.000000
+
+
+ 黑色
+ CMYK
+ PROCESS
+ 0.000000
+ 0.000000
+ 0.000000
+ 100.000000
+
+
+ DURABLUE
+ PROCESS
+ 100.000000
+ CMYK
+ 90.000000
+ 68.000000
+ 0.000000
+ 0.000000
+
+
+
+
+
+ 灰階
+ 0
+
+
+
+ C=0 M=0 Y=0 K=100
+ CMYK
+ PROCESS
+ 0.000000
+ 0.000000
+ 0.000000
+ 100.000000
+
+
+
+
+
+
+
+
+ Adobe PDF library 10.01
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+endstream
endobj
3 0 obj
<>
endobj
7 0 obj
<>/ExtGState<>/Font<>/ProcSet[/PDF/Text/ImageC]/Properties<>/Shading<>/XObject<>>>/TrimBox[0.0 0.0 595.275 841.89]/Type/Page>>
endobj
8 0 obj
<>/Font<>/ProcSet[/PDF/Text]/Properties<>/XObject<>>>/TrimBox[0.0 0.0 595.275 841.89]/Type/Page>>
endobj
39 0 obj
<>stream
+HlWٮI|ﯨp9O)!$ ,FW61X^[#ϭ\'?!?q-HgGç/)t[tMşr1V9KJJOR-qϷt\=hǻ~-pײ3s`-F?^8Y*I'ny3Ywy#LZr{օҬ!9o:s/rÏV:S&,,S?tf9븂Sg
+N)
+VKeֆw V\y}˙V1α!
=rxGv$x65
+h2#9-e zek vJ'
HYz(CS#
+U1N~qNykfxc*t?^Xػmk mCJ
+r@QV<Uh:aB"+')ړ~B꫱i*)