Skip to content

COA Storage Plan: Medusa Admin File Upload & Management

This document defines the plan for implementing Certificate of Analysis (COA) file upload, storage, and management within the Medusa admin dashboard. It builds on the existing Product Compliance Module's COA data model and adds the missing file upload infrastructure.


1. Current State

What Exists

The Product Compliance Module already provides a complete data model and API layer for COAs:

Component Status Location
COA data model (MikroORM) Done app/src/modules/product-compliance/models/coa.ts
Lot data model Done app/src/modules/product-compliance/models/lot.ts
Purity Record data model Done app/src/modules/product-compliance/models/purity-record.ts
Database migration Done app/src/modules/product-compliance/migrations/
Admin API: create COA Done POST /admin/compliance/lots/[id]/coa
Admin API: get COA Done GET /admin/compliance/coa/[id]
Store API: public COA Done GET /store/compliance/coa/[id]
Store API: lot lookup Done GET /store/compliance/lots/[lot_number]
Validators (Zod) Done app/src/api/admin/compliance/lots/validators.ts
Admin lot list page Done app/src/admin/routes/compliance/lots/page.tsx
Admin lot detail page Done app/src/admin/routes/compliance/lots/[id]/page.tsx
Storefront COA card Done storefront/src/components/compliance/coa-card.tsx
Event subscriber Done app/src/subscribers/coa-verified.ts
Seed data Done app/src/scripts/seed-compliance.ts

What's Missing

The current implementation expects COA files to be pre-uploaded with their URLs provided at COA creation time. There is no mechanism to:

  1. Upload COA PDF files through the admin dashboard
  2. Store files in cloud object storage (S3/R2)
  3. Delete or replace COA files
  4. Validate uploaded file types and sizes
  5. Generate pre-signed/temporary URLs for secure file access

The existing COA model fields file_url and file_key are in place and ready to receive values from an upload pipeline.


2. Architecture Decision: Storage Backend

Given the existing Cloudflare infrastructure (DNS, CDN, Pages), Cloudflare R2 is the natural choice for file storage:

Factor Cloudflare R2 AWS S3 MinIO (self-hosted)
S3-compatible API Yes Yes (native) Yes
Egress fees $0 $0.09/GB N/A (self-hosted)
Storage cost $0.015/GB/mo $0.023/GB/mo Disk cost only
CDN integration Native (via custom domain) Requires CloudFront Manual
Existing relationship Already using Cloudflare New vendor Requires server resources
Medusa S3 plugin Compatible Native support Compatible
Ops complexity Low (managed) Low (managed) Medium (self-managed)

Decision: Use Cloudflare R2 with the Medusa S3 file provider plugin, which supports any S3-compatible API.

Storage Layout

rr-bizops-files/                    # R2 bucket name
├── coa/                            # Certificate of Analysis PDFs
│   ├── {lot_number}/               # Organized by lot
│   │   ├── {lot_number}-coa.pdf    # Primary COA document
│   │   └── {lot_number}-coa-v2.pdf # Revision (if re-analyzed)
│   └── ...
├── sds/                            # Safety Data Sheets (future)
│   └── {product-handle}.pdf
└── products/                       # Product images (future)
    └── {product-handle}/
        ├── thumb.jpg
        └── ...

Key naming convention: coa/{lot_number}/{lot_number}-coa.pdf

  • Example: coa/BPC157-2026-001/BPC157-2026-001-coa.pdf
  • The file_key field on the COA record stores this path
  • The file_url field stores the full public URL: https://cdn.research-relay.com/coa/BPC157-2026-001/BPC157-2026-001-coa.pdf

3. Implementation Plan

Phase 1: S3-Compatible File Provider Setup

Goal: Configure Medusa's file storage to use Cloudflare R2.

3.1 Install the S3 file provider

cd app
npm install @medusajs/file-s3

3.2 Configure R2 credentials

Add to app/.env (and .env.template):

# Cloudflare R2 (S3-compatible file storage)
S3_FILE_URL=https://cdn.research-relay.com
S3_ACCESS_KEY_ID=<r2-access-key>
S3_SECRET_ACCESS_KEY=<r2-secret-key>
S3_REGION=auto
S3_BUCKET=rr-bizops-files
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com

3.3 Register the file provider in Medusa config

Update app/medusa-config.ts to add the S3 file provider module:

{
  resolve: "@medusajs/medusa/file",
  options: {
    providers: [
      {
        resolve: "@medusajs/file-s3",
        id: "s3",
        options: {
          file_url: process.env.S3_FILE_URL,
          access_key_id: process.env.S3_ACCESS_KEY_ID,
          secret_access_key: process.env.S3_SECRET_ACCESS_KEY,
          region: process.env.S3_REGION,
          bucket: process.env.S3_BUCKET,
          endpoint: process.env.S3_ENDPOINT,
          additional_client_config: {
            forcePathStyle: true,
          },
        },
      },
    ],
  },
},

3.4 Set up Cloudflare R2

  1. Create R2 bucket rr-bizops-files in Cloudflare dashboard
  2. Create R2 API token with read/write permissions scoped to the bucket
  3. Configure custom domain cdn.research-relay.com pointing to the R2 bucket (Cloudflare dashboard > R2 > bucket settings > custom domains)
  4. Set CORS policy on the bucket to allow uploads from the admin domain

R2 CORS configuration:

[
  {
    "AllowedOrigins": [
      "https://api.research-relay.com",
      "http://localhost:9000"
    ],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]

Phase 2: COA Upload API Endpoint

Goal: Add an admin API endpoint for uploading COA PDF files.

3.5 Create upload route

Create app/src/api/admin/compliance/coa/upload/route.ts:

// POST /admin/compliance/coa/upload
// Accepts multipart/form-data with a PDF file
// Returns { file_url, file_key } for use when creating a COA record

import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"

export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
  const fileService = req.scope.resolve("file")

  const files = req.files as Express.Multer.File[]

  if (!files || files.length === 0) {
    return res.status(400).json({ message: "No file provided" })
  }

  const file = files[0]

  // Validate file type
  if (file.mimetype !== "application/pdf") {
    return res.status(400).json({ message: "Only PDF files are accepted" })
  }

  // Validate file size (max 25MB)
  const MAX_SIZE = 25 * 1024 * 1024
  if (file.size > MAX_SIZE) {
    return res.status(400).json({ message: "File must be under 25MB" })
  }

  // Get the lot_number from the request body for file naming
  const lotNumber = req.body?.lot_number as string
  if (!lotNumber) {
    return res.status(400).json({ message: "lot_number is required" })
  }

  // Construct the file key
  const fileKey = `coa/${lotNumber}/${lotNumber}-coa.pdf`

  // Upload to R2 via Medusa's file service
  const uploaded = await fileService.uploadProtected({
    filename: fileKey,
    mimeType: file.mimetype,
    content: file.buffer,
  })

  res.json({
    file_url: uploaded.url,
    file_key: uploaded.key || fileKey,
  })
}

3.6 Add file upload middleware

Update app/src/api/middlewares.ts to configure multer for the upload endpoint:

import { defineMiddlewares } from "@medusajs/medusa"
import multer from "multer"

const upload = multer({ storage: multer.memoryStorage() })

export default defineMiddlewares({
  routes: [
    {
      matcher: "/admin/compliance/coa/upload",
      method: "POST",
      middlewares: [upload.array("files")],
    },
    // ... existing middleware entries
  ],
})

3.7 Create delete route

Create app/src/api/admin/compliance/coa/[id]/file/route.ts:

// DELETE /admin/compliance/coa/:id/file
// Deletes the COA file from storage and clears file_url/file_key

import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { PRODUCT_COMPLIANCE_MODULE } from "../../../../../modules/product-compliance"
import type ProductComplianceModuleService from "../../../../../modules/product-compliance/service"

export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
  const fileService = req.scope.resolve("file")
  const complianceService: ProductComplianceModuleService = req.scope.resolve(
    PRODUCT_COMPLIANCE_MODULE
  )

  const { id } = req.params
  const coa = await complianceService.retrieveCoa(id)

  if (coa.file_key) {
    await fileService.delete({ fileKey: coa.file_key })
  }

  await complianceService.updateCoas(id, {
    file_url: "",
    file_key: "",
  })

  res.json({ success: true })
}

Phase 3: Admin Dashboard UI — COA Upload Widget

Goal: Add file upload capability to the lot detail page in the Medusa admin.

3.8 Add upload form to lot detail page

Enhance app/src/admin/routes/compliance/lots/[id]/page.tsx with:

  1. Upload button in the COA section header
  2. File input that accepts .pdf files only
  3. Upload progress indicator
  4. COA creation form that appears after upload, pre-filled with file_url and file_key

The form flow:

1. Admin clicks "Add COA" on lot detail page
2. Modal/drawer opens with:
   a. File upload zone (drag & drop or click to browse)
   b. Fields: purity_percentage, analysis_date, lab_name, lab_reference
   c. Optional purity records table (add rows)
3. Admin selects a PDF file
   → File uploads to /admin/compliance/coa/upload
   → file_url and file_key are stored in form state
4. Admin fills remaining fields
5. Admin clicks "Save"
   → POST /admin/compliance/lots/{id}/coa with file_url, file_key, and other fields
6. COA appears in the lot detail page

3.9 Add COA verification action

Add a "Verify" button on each COA card in the admin that:

  1. Sets verified: true
  2. Sets verified_by to the current admin user ID
  3. Sets verified_at to the current timestamp
  4. Calls POST /admin/compliance/coa/{id} (update endpoint — needs to be created)

3.10 Create COA update endpoint

Create app/src/api/admin/compliance/coa/[id]/route.ts:

// POST /admin/compliance/coa/:id — Update COA fields (including verification)
// GET /admin/compliance/coa/:id — Get COA details (already exists)

Phase 4: Public COA Access

Goal: Ensure storefront customers can view and download COA PDFs.

3.11 Public access strategy

COA PDFs should be publicly accessible for transparency. The cdn.research-relay.com custom domain on R2 serves files publicly. The existing store endpoints already return file_url which points to the CDN.

No additional work needed if R2 is configured with a public custom domain. If private access is needed in the future:

  • Use Medusa's file service getPresignedDownloadUrl() to generate time-limited signed URLs
  • Update the store COA endpoint to return a signed URL instead of the direct file_url

3.12 Storefront COA card updates

The existing storefront/src/components/compliance/coa-card.tsx already renders a download link using file_url. No changes needed — it will work once real files are uploaded.


4. Data Model Summary

No schema changes are required. The existing COA model already has the necessary fields:

Coa
├── id (PK)
├── lot_id (FK → Lot)
├── file_url (TEXT)         ← populated by upload endpoint
├── file_key (TEXT)         ← populated by upload endpoint
├── purity_percentage (FLOAT, nullable)
├── analysis_date (DATETIME)
├── lab_name (TEXT, nullable)
├── lab_reference (TEXT, nullable)
├── verified (BOOLEAN, default false)
├── verified_by (TEXT, nullable)
├── verified_at (DATETIME, nullable)
├── metadata (JSONB, nullable)
└── purity_records (HasMany → PurityRecord)

5. Admin Workflow: Adding a COA

Complete workflow for an admin user adding a COA to a lot:

1. Navigate to Compliance > Lots in admin sidebar
2. Click on a lot (e.g., BPC157-2026-001)
3. In the "Certificates of Analysis" section, click "Add COA"
4. Upload the COA PDF from the supplier/lab
   → System uploads to R2: coa/BPC157-2026-001/BPC157-2026-001-coa.pdf
   → System returns file_url and file_key
5. Fill in COA metadata:
   - Purity %: 98.7
   - Analysis date: 2026-01-18
   - Lab name: Analytical Testing Services Inc.
   - Lab reference: ATS-2026-00142
6. (Optional) Add purity records:
   | Parameter | Method | Result | Unit | Spec | Pass? |
   |-----------|--------|--------|------|------|-------|
   | Purity    | HPLC   | 98.7   | %    | >98% | Yes   |
   | Identity  | MS     | 1419.5 | m/z  | ±0.5 | Yes   |
   | Endotoxin | LAL    | 0.05   | EU/mg| <0.1 | Yes   |
7. Click "Save" → COA record created
8. Review the COA details, then click "Verify"
   → Sets verified=true, verified_by=current admin, verified_at=now
9. COA is now visible on the storefront product page

6. File Validation Rules

Rule Value Rationale
Allowed MIME types application/pdf COAs are always PDF documents
Max file size 25 MB Typical COAs are 100KB-2MB; 25MB allows for high-res scans
File naming {lot_number}-coa.pdf Consistent, human-readable naming
Storage path coa/{lot_number}/ Organized by lot for easy browsing
Duplicate handling Overwrite with timestamp suffix BPC157-2026-001-coa-v2.pdf for revisions

7. Security Considerations

Concern Mitigation
Unauthorized uploads Upload endpoint requires admin authentication (Medusa's built-in auth middleware)
File type spoofing Validate MIME type server-side; consider PDF magic byte validation
Large file DoS Enforce 25MB limit in multer middleware
Public file access COAs are intentionally public (transparency for research customers)
R2 credentials Stored in server env vars and 1Password; scoped to single bucket
File deletion Soft-delete in database; hard-delete from R2 only on explicit admin action

8. Environment Variables

New variables required for the file storage integration:

# Add to app/.env.template
S3_FILE_URL=https://cdn.research-relay.com
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=auto
S3_BUCKET=rr-bizops-files
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com

Add to the production environment file at /etc/rr-bizops/.env on the server.


9. Implementation Checklist

  • Infrastructure

    • Create R2 bucket rr-bizops-files in Cloudflare dashboard
    • Create R2 API token (read/write, scoped to bucket)
    • Configure custom domain cdn.research-relay.com → R2 bucket
    • Set CORS policy on R2 bucket
    • Add R2 credentials to production .env
    • Add R2 credentials to development .env
  • Backend

    • Install @medusajs/file-s3 package
    • Add S3 file provider to medusa-config.ts
    • Add S3 env vars to .env.template
    • Create POST /admin/compliance/coa/upload endpoint
    • Add multer middleware for upload route
    • Create POST /admin/compliance/coa/:id update endpoint
    • Create DELETE /admin/compliance/coa/:id/file endpoint
    • Add integration tests for upload, create, update, delete
  • Admin UI

    • Add "Add COA" button to lot detail page
    • Build COA upload form (file input + metadata fields)
    • Build purity records inline editor
    • Add "Verify" action on COA cards
    • Add "Delete" action on COA cards (with confirmation)
    • Add upload progress indicator
    • Add error handling and validation feedback
  • Testing

    • Unit tests for file validation logic
    • Integration tests for upload → create → verify flow
    • Integration tests for file deletion
    • Manual test: upload, view on storefront, download PDF

10. Dependencies & Ordering

This plan can be implemented independently and slots into the existing project phases:

Phase B (Product Compliance Module) ← already done
  └── COA Storage Plan (this document) ← implement next
        ├── Phase 1: R2 + S3 provider setup
        ├── Phase 2: Upload API endpoints
        ├── Phase 3: Admin UI upload widget
        └── Phase 4: Storefront integration (already works)

No blocking dependencies. The S3 file provider setup (Phase 1) is a prerequisite for everything else. Phases 2-4 can be built incrementally.