# Appendix ## Table of Contents 1. [S3 Storage Guide](#s3-storage-guide) - [Overview](#overview) - [Bucket Configuration](#bucket-configuration) - [BucketKey Path Format](#bucketkey-path-format) - [Path Segment Reference](#path-segment-reference) - [Location Types](#location-types) - [Filename Segment Reference](#filename-segment-reference) - [Client ID Constraints](#client-id-constraints) - [Directory Partitioning](#directory-partitioning) - [S3 Object Metadata](#s3-object-metadata) - [Batch Upload Paths](#batch-upload-paths) - [Concrete Examples](#concrete-examples) - [Parsing a Key Back Into Its Components](#parsing-a-key-back-into-its-components) - [Guide: Watching an S3 Bucket for Client Document Imports](#guide-watching-an-s3-bucket-for-client-document-imports) --- ## S3 Storage Guide ### Overview Every document uploaded through the platform is stored in AWS S3 using a structured key (path) that encodes enough metadata to identify and recover the document without a database lookup. The key contains the client ID, processing stage, date, partition number, timestamp, entity ID, and optionally a batch ID and file extension. The implementation lives in: | File | Purpose | |------|---------| | `internal/serviceconfig/objectstore/bucketkey.go` | `BucketKey` struct, `String()`, `ParseBucketKey()` | | `internal/serviceconfig/objectstore/bucketkey_test.go` | Unit tests with concrete path examples | | `internal/serviceconfig/objectstore/config.go` | Bucket config, partition logic (`GetDirectoryPart`) | | `internal/document/upload/get.go` | Upload flow that constructs keys | | `internal/document/batch/storage/service.go` | Batch (ZIP) upload storage | ### Bucket Configuration The bucket name is configured via environment variable: | Variable | Required | Description | |----------|----------|-------------| | `BUCKET` | Yes | S3 bucket name | | `AWS_ENDPOINT_URL_S3` | No | Override for local dev (default: `http://localhost:4566` via localstack) | | `AWS_S3_USE_PATH_STYLE` | No | Path-style addressing (default: `false`) | | `AWS_REGION` | No | AWS region (default: `us-east-1`) | ### BucketKey Path Format A full S3 object key follows this structure: ``` {ClientID}/{Location}/{Date}/{Part}/{Timestamp}~{ClientID}~{Location}~{EntityID}[~{BatchID}][.FileType] |_______________ prefix _________________| |______________________ filename __________________________| ``` For the `export` location, the `Part` segment is omitted: ``` {ClientID}/export/{Date}/{Timestamp}~{ClientID}~export~{EntityID}[~{BatchID}][.FileType] ``` ### Path Segment Reference The **prefix** portion determines the directory structure in S3: | Segment | Format | Example | Description | |---------|--------|---------|-------------| | ClientID | `[a-zA-Z0-9_#-]+` | `acme_corp` | Client identifier. Same value repeated in the filename. | | Location | `import`, `text`, or `export` | `import` | Processing stage (see Location Types below). | | Date | `YYYYMMDD` | `20250331` | Date the document was uploaded, in UTC. | | Part | Integer (0-based) | `0` | Partition shard within a date directory. Omitted for `export`. | ### Location Types | Location | Meaning | Has Part? | |----------|---------|-----------| | `import` | Original uploaded document | Yes | | `text` | Text extraction output | Yes | | `export` | Exported results (CSV, etc.) | No | ### Filename Segment Reference The **filename** portion is delimited by the tilde character (`~`). It comes in two variants depending on how the document was uploaded: **4-segment filename (single upload via `POST /clients/{id}/documents`):** ``` {Timestamp}~{ClientID}~{Location}~{EntityID}[.FileType] ``` **5-segment filename (batch upload via `POST /clients/{id}/documents/batches`):** ``` {Timestamp}~{ClientID}~{Location}~{EntityID}~{BatchID}[.FileType] ``` The only difference is the 5th `~`-delimited segment containing the BatchID. Both formats may optionally end with a `.{FileType}` extension, though in practice the current upload code does not set FileType for `import` documents -- the file extension is typically absent. | Segment | Position | Format | Example | Description | |---------|----------|--------|---------|-------------| | Timestamp | 1 (always) | `YYYY-MM-DDTHHMMSSZ` (ISO 8601 UTC) | `2025-03-31T040816Z` | Exact creation time. Uses Go format `2006-01-02T150405Z`. | | ClientID | 2 (always) | `[a-zA-Z0-9_#-]+` | `acme_corp` | Repeated from prefix for self-describing keys. | | Location | 3 (always) | `import`, `text`, or `export` | `import` | Repeated from prefix for self-describing keys. | | EntityID | 4 (always) | UUID | `b745910f-f529-43c5-87e1-25629d46ef40` | The upload ID (for `import`) or document/entity ID. | | BatchID | 5 (optional) | UUID | `12345678-1234-5678-9012-123456789012` | Present only for documents uploaded as part of a batch ZIP. | | FileType | suffix | Extension without dot | `pdf` | Appended after a `.` separator on the last segment. Often absent for imports. | **Real-world examples:** Single upload (4 segments, no extension): ``` 2026-02-07T002850Z~demo-data-loader-1770424122~import~438b605f-1b80-4c9f-bfb9-3f6bfbf853d8 ``` Batch upload (5 segments, no extension): ``` 2026-02-06T183937Z~int-test-1770403169~import~13234476-2881-4b22-924d-e3ebaf63e7c0~019c3440-a4c1-7da0-a3da-3c97f6287a24 ``` ### Client ID Constraints Client IDs must match the regex pattern: ``` [a-zA-Z0-9_#-]+ ``` Valid characters: letters, numbers, underscore (`_`), hash (`#`), hyphen (`-`). The tilde (`~`) character is explicitly banned from client IDs because it serves as the delimiter between segments in the filename portion of the key. See `internal/client/create.go`: ``` // NOTE: Make sure the char ~ never makes way in as a valid client id character // Reference bucket key filename const CLIENT_ID_REGEX = `[a-zA-Z0-9\_#-]+` ``` ### Directory Partitioning Within each `{ClientID}/{Location}/{Date}/` prefix, documents are distributed across numbered partitions to avoid placing too many objects in a single virtual directory. - The default partition limit is **1000 objects** per partition. - Partition numbering starts at `0`. - When partition `N` reaches 1000 objects for a given date, new uploads go to partition `N+1`. - The current part and count are tracked in the database via `GetDocumentUploadCurrentPart`. Logic from `ObjectStoreConfig.GetDirectoryPart()`: ``` MaxDirSize = 1000 (default) if count >= MaxDirSize: return part + 1 else: return part ``` Example progression for a busy client on a single day: ``` acme_corp/import/20250331/0/... (objects 1-1000) acme_corp/import/20250331/1/... (objects 1001-2000) acme_corp/import/20250331/2/... (objects 2001-3000) ``` The `export` location does not use partitions. Export keys skip the part segment entirely. ### S3 Object Metadata In addition to the structured key, each uploaded object carries S3 metadata headers: | Metadata Key | Value | Example | |-------------|-------|---------| | `original-path` | Full path from the upload request (includes folders) | `contracts/2025/Q1/agreement.pdf` | | `original-filename` | Just the filename | `agreement.pdf` | | `Content-Type` | MIME type detected from file extension | `application/pdf` | These metadata values preserve the user's original file naming, which is not encoded in the S3 key itself. ### Batch Upload Paths ZIP files uploaded via the batch endpoint (`POST /client/{id}/document/batch`) use a separate, simpler path scheme: ``` batches/{ClientID}/{YYYY}/{MM}/{DD}/{HH}/{BatchID}-{OriginalFilename} ``` Example: ``` batches/acme_corp/2025/03/15/14/550e8400-e29b-41d4-a716-446655440000-uploads.zip ``` | Segment | Description | |---------|-------------| | `batches/` | Fixed prefix for all batch uploads | | ClientID | Client identifier | | `YYYY/MM/DD/HH` | Timestamp directory (year/month/day/hour) | | BatchID | UUID assigned to the batch | | OriginalFilename | The user's original ZIP filename | Once the batch ZIP is extracted, individual documents from the ZIP are stored using the standard `BucketKey` format with the `import` location and the `BatchID` field populated. ### Concrete Examples **1. Single PDF upload for client `acme_corp`:** ``` s3://my-bucket/acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40.pdf ``` Decoded: - Client: `acme_corp` - Stage: `import` (original upload) - Date: March 31, 2025 - Partition: `0` - Created at: 2025-03-31 04:08:16 UTC - Upload/Entity ID: `b745910f-f529-43c5-87e1-25629d46ef40` - File type: `pdf` **2. Document from a batch upload:** ``` s3://my-bucket/acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40~12345678-1234-5678-9012-123456789012.pdf ``` Same as above, plus: - Batch ID: `12345678-1234-5678-9012-123456789012` **3. Text extraction output:** ``` s3://my-bucket/acme_corp/text/20250331/3/2025-03-31T040816Z~acme_corp~text~b745910f-f529-43c5-87e1-25629d46ef40 ``` - Stage: `text` (extraction result) - Partition: `3` (high volume, rolled past partitions 0-2) - No file extension **4. Export result:** ``` s3://my-bucket/acme_corp/export/20250331/2025-03-31T040816Z~acme_corp~export~b745910f-f529-43c5-87e1-25629d46ef40.csv ``` - Stage: `export` - No partition number (exports never have one) - File type: `csv` **5. Client IDs with special characters:** ``` s3://my-bucket/A_A/import/20250331/0/2025-03-31T040816Z~A_A~import~b745910f-f529-43c5-87e1-25629d46ef40 s3://my-bucket/A-A/import/20250331/0/2025-03-31T040816Z~A-A~import~b745910f-f529-43c5-87e1-25629d46ef40 s3://my-bucket/A#A/import/20250331/0/2025-03-31T040816Z~A#A~import~b745910f-f529-43c5-87e1-25629d46ef40 ``` ### Parsing a Key Back Into Its Components The `ParseBucketKey()` function in `bucketkey.go` reverses the process. It uses regex to extract the prefix components and then parses the tilde-delimited filename. **Step 1: Split the prefix from the filename.** Parse regex for the prefix: ``` ^([a-zA-Z0-9_#-]+)/([a-z]+/?[a-z]+?)/([0-9]{8})/(.+)$ |___ ClientID ___| |__ Location __| |_ Date _| |_ rest (part/filename or just filename) _| ``` The "rest" portion is then checked for a leading integer partition number. If present, it splits into `{Part}/{Filename}`. If not, the entire rest is the filename. **Step 2: Parse the filename.** The filename is a `~`-delimited string with either 4 or 5 segments, plus an optional `.{extension}` suffix on the last segment. The parsing algorithm is: 1. Split on `.` first. If there are exactly 2 parts, the second part is the FileType. If there is 1 part, there is no FileType. More than 2 `.`-separated parts is invalid. 2. Split the base (part before `.`) on `~`. This yields either 4 or 5 segments: - **4 segments:** `[Timestamp, ClientID, Location, EntityID]` -- single upload, no batch - **5 segments:** `[Timestamp, ClientID, Location, EntityID, BatchID]` -- batch upload 3. The segment count is the only way to determine whether a BatchID is present. Count the `~` delimiters: 3 tildes = 4 segments (no batch), 4 tildes = 5 segments (has batch). **Decision table for segment count:** | Tilde count | Segment count | Has BatchID? | Upload source | |-------------|---------------|--------------|---------------| | 3 | 4 | No | Single document upload API | | 4 | 5 | Yes | Batch (ZIP) upload API | | Other | N/A | N/A | Invalid -- reject | ### Guide: Watching an S3 Bucket for Client Document Imports This section shows how to monitor an S3 bucket for newly imported PDF documents belonging to a specific client. #### Prerequisites - AWS CLI configured with credentials that have `s3:ListBucket` and `s3:GetObject` permissions on the target bucket. - The bucket name (from the `BUCKET` environment variable). - The client ID you want to monitor. #### Step 1: List All Imports for a Client To see all imported documents for client `acme_corp`: ```bash aws s3 ls s3://my-bucket/acme_corp/import/ --recursive ``` This lists every object under the client's `import` location across all dates and partitions. #### Step 2: Filter by Date To narrow down to a specific date (e.g., March 31, 2025): ```bash aws s3 ls s3://my-bucket/acme_corp/import/20250331/ ``` This shows the partition directories for that date: ``` PRE 0/ PRE 1/ ``` Then list a specific partition: ```bash aws s3 ls s3://my-bucket/acme_corp/import/20250331/0/ ``` #### Step 3: Filter for PDFs Only ```bash aws s3 ls s3://my-bucket/acme_corp/import/ --recursive | grep '\.pdf$' ``` #### Step 4: Watch for New Imports in Real Time Use a polling loop to detect new documents as they arrive: ```bash BUCKET="my-bucket" CLIENT="acme_corp" INTERVAL=30 # seconds between checks LAST_CHECK=$(date -u +"%Y-%m-%dT%H:%M:%SZ") while true; do echo "Checking for new imports since $LAST_CHECK ..." # List objects and filter by those newer than last check aws s3api list-objects-v2 \ --bucket "$BUCKET" \ --prefix "$CLIENT/import/" \ --query "Contents[?LastModified>=\`$LAST_CHECK\`].[Key,Size,LastModified]" \ --output table LAST_CHECK=$(date -u +"%Y-%m-%dT%H:%M:%SZ") sleep "$INTERVAL" done ``` #### Step 5: Extract Document Identity from a Key Given a key, you can extract the client ID, document/upload ID, batch ID, and timestamp without a database query. The filename portion has either 4 or 5 `~`-delimited segments (see [Filename Segment Reference](#filename-segment-reference)). **Example 1 -- single upload (4 segments, no batch, no extension):** ``` acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40 ``` **Example 2 -- batch upload (5 segments, has batch ID, no extension):** ``` acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40~12345678-1234-5678-9012-123456789012 ``` Using bash to parse either format: ```bash KEY="$1" # pass the S3 key as argument # Extract the filename (everything after the last /) FILENAME="${KEY##*/}" # Strip file extension if present (e.g. ".pdf") # Note: import documents typically have NO extension BASE="${FILENAME%%.*}" if [[ "$FILENAME" == *"."* ]]; then FILE_EXT="${FILENAME##*.}" else FILE_EXT="" fi # Split on ~ into an array IFS='~' read -ra SEGMENTS <<< "$BASE" SEGMENT_COUNT="${#SEGMENTS[@]}" # First 4 segments are always present TIMESTAMP="${SEGMENTS[0]}" CLIENT_ID="${SEGMENTS[1]}" LOCATION="${SEGMENTS[2]}" ENTITY_ID="${SEGMENTS[3]}" # 5th segment is the BatchID (only present for batch uploads) if [[ "$SEGMENT_COUNT" -eq 5 ]]; then BATCH_ID="${SEGMENTS[4]}" else BATCH_ID="" fi echo "Segments: $SEGMENT_COUNT" echo "Client ID: $CLIENT_ID" echo "Location: $LOCATION" echo "Timestamp: $TIMESTAMP" echo "Entity ID: $ENTITY_ID" echo "Batch ID: ${BATCH_ID:-none}" echo "File Type: ${FILE_EXT:-none}" ``` Output for example 1 (single upload): ``` Segments: 4 Client ID: acme_corp Location: import Timestamp: 2025-03-31T040816Z Entity ID: b745910f-f529-43c5-87e1-25629d46ef40 Batch ID: none File Type: none ``` Output for example 2 (batch upload): ``` Segments: 5 Client ID: acme_corp Location: import Timestamp: 2025-03-31T040816Z Entity ID: b745910f-f529-43c5-87e1-25629d46ef40 Batch ID: 12345678-1234-5678-9012-123456789012 File Type: none ``` #### Step 6: Download a Document by Key ```bash aws s3 cp \ "s3://my-bucket/acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40.pdf" \ ./recovered-document.pdf ``` #### Step 7: Retrieve the Original Filename The original filename from the user's upload is stored in S3 object metadata, not in the key: ```bash aws s3api head-object \ --bucket my-bucket \ --key "acme_corp/import/20250331/0/2025-03-31T040816Z~acme_corp~import~b745910f-f529-43c5-87e1-25629d46ef40.pdf" \ --query "Metadata" ``` Output: ```json { "original-path": "contracts/2025/Q1/agreement.pdf", "original-filename": "agreement.pdf" } ``` #### Step 8: Bulk Recovery Script To recover all PDFs for a client on a given date and rename them using their original filenames: ```bash BUCKET="my-bucket" CLIENT="acme_corp" DATE="20250331" OUTPUT_DIR="./recovered" mkdir -p "$OUTPUT_DIR" aws s3api list-objects-v2 \ --bucket "$BUCKET" \ --prefix "$CLIENT/import/$DATE/" \ --query "Contents[].Key" \ --output text | tr '\t' '\n' | grep '\.pdf$' | while read -r KEY; do # Get original filename from metadata ORIGINAL=$(aws s3api head-object \ --bucket "$BUCKET" \ --key "$KEY" \ --query "Metadata.\"original-filename\"" \ --output text) # Extract entity ID for uniqueness (4th segment, works for both 4 and 5 segment filenames) FILENAME="${KEY##*/}" BASE="${FILENAME%%.*}" IFS='~' read -ra SEGS <<< "$BASE" ENTITY_ID="${SEGS[3]}" # Use original filename if available, otherwise use entity ID if [ "$ORIGINAL" != "None" ] && [ -n "$ORIGINAL" ]; then DEST="$OUTPUT_DIR/${ENTITY_ID}_${ORIGINAL}" else DEST="$OUTPUT_DIR/${ENTITY_ID}.pdf" fi echo "Downloading: $KEY -> $DEST" aws s3 cp "s3://$BUCKET/$KEY" "$DEST" done ``` --- *Source code reference: `internal/serviceconfig/objectstore/bucketkey.go`*