1. Overview
1.1 Purpose
Media module manages photo and video metadata with URL resolution to Cloudflare R2 storage. Provides a centralized service for uploading, organizing, and serving media assets across all modules.
1.2 Scope
In Scope:
- Media metadata storage (photos, videos)
- R2 URL generation and resolution
- Upload handling with validation
- Cloudflare Images integration for on-the-fly transformations
- Media organization via path prefixes
Out of Scope:
- Actual file storage (handled by R2 directly)
- Video transcoding (future consideration)
- CDN configuration (infrastructure concern)
2. Dependencies
2.1 Upstream (What This Module Needs)
| Module/Service |
What We Need |
Interface |
| Cloudflare R2 |
Object storage |
S3 API |
Note: R2 bucket configuration via environment variables (infrastructure level)
2.2 Downstream (What Needs This Module)
| Module/Service |
What They Need |
Interface |
| Inventory |
Puppy/breed images and videos |
Server Actions |
| CMS |
Blog/page images |
Server Actions |
| Customer Website |
Resolved media URLs for display |
Server Actions |
| Social Media |
Media for content pipeline |
Server Actions |
3. Data Ownership
3.1 Entities This Module Owns
| Entity |
Description |
Key Fields |
| Media |
Photo/video metadata |
id, type, filename, mimeType, size, r2Key, url |
Note: Folder organization handled via structured r2Key path prefixes (e.g., puppies/{id}/, breeds/{slug}/, blog/{postId}/)
3.2 Entities This Module Uses (Read-Only)
| Entity |
Owner |
How We Use It |
| User |
Auth |
Track who uploaded (uploadedBy) |
3.3 Data Schema
import type { User } from '@/modules/auth';
type MediaType = 'image' | 'video';
interface Media {
id: string;
type: MediaType;
filename: string;
originalFilename: string;
mimeType: string;
size: number; // bytes
r2Key: string; // path in R2 bucket (e.g., puppies/123/photo1.jpg)
url: string; // base Cloudflare Images URL
// Video metadata (videos only)
duration?: number; // seconds
width?: number;
height?: number;
alt?: string; // accessibility text
uploadedBy: User['id'];
createdAt: Date;
updatedAt: Date;
}
// Pure utility function for URL transformations (no DB call)
function transformMediaUrl(baseUrl: string, options?: ImageTransformOptions): string;
interface ImageTransformOptions {
width?: number;
height?: number;
fit?: 'contain' | 'cover' | 'crop' | 'scale-down';
format?: 'webp' | 'avif' | 'auto';
quality?: number; // 1-100
}
4. Service Level Objectives
| Objective |
Target |
Measurement |
| Upload Latency |
< 2s for images < 5MB |
Application monitoring |
| URL Resolution |
< 50ms |
Application monitoring |
| Availability |
99.9% |
Health checks |
Note: Image variants and format conversion handled on-the-fly by Cloudflare Images. No pre-generation needed.
5. Interface Contract
5.1 Internal Service Interface
interface MediaService {
// Media operations
getMedia(id: string): Promise<Media>;
getMediaByIds(ids: string[]): Promise<Media[]>;
listMedia(filters: MediaFilters, pagination: Pagination): Promise<PaginatedResult<Media>>;
// Upload
uploadMedia(file: File, options?: UploadOptions): Promise<Media>;
uploadMediaFromUrl(url: string, options?: UploadOptions): Promise<Media>;
// Update/Delete
updateMedia(id: string, data: UpdateMediaDTO): Promise<Media>;
deleteMedia(id: string): Promise<void>;
bulkDeleteMedia(ids: string[]): Promise<void>;
}
interface MediaFilters {
type?: MediaType;
mimeType?: string;
uploadedBy?: string;
r2KeyPrefix?: string; // Filter by path prefix
search?: string; // Search filename/alt
}
interface UploadOptions {
r2KeyPrefix?: string; // e.g., 'puppies/123/'
alt?: string;
}
interface UpdateMediaDTO {
alt?: string;
filename?: string;
}
// Pure utility function (no DB call) - can be used anywhere
function transformMediaUrl(baseUrl: string, options?: ImageTransformOptions): string;
interface ImageTransformOptions {
width?: number;
height?: number;
fit?: 'contain' | 'cover' | 'crop' | 'scale-down';
format?: 'webp' | 'avif' | 'auto';
quality?: number; // 1-100
}
5.2 Remote Procedure Interface
Communication Pattern: Next.js Server Actions
| Procedure |
Input |
Output |
Auth |
Description |
| getMedia |
{ id } |
Media |
Staff+ |
Get media details |
| getMediaByIds |
{ ids } |
Media[] |
Staff+ |
Get multiple media by IDs |
| listMedia |
MediaFilters |
PaginatedResult |
Staff+ |
List media with filters |
| uploadMedia |
File, UploadOptions |
Media |
Photographer+ |
Upload new media |
| updateMedia |
{ id, data } |
Media |
Photographer+ |
Update media metadata |
| deleteMedia |
{ id } |
void |
Admin |
Delete media |
URL Transform: Use transformMediaUrl() utility with base media.url - no service call needed.
6. Error Handling Strategy
| Error Code |
Condition |
User Message |
| MEDIA_NOT_FOUND |
Media doesn't exist |
"Media not found" |
| MEDIA_UPLOAD_FAILED |
R2 upload error |
"Upload failed, please try again" |
| MEDIA_INVALID_TYPE |
Unsupported file type |
"File type not supported" |
| MEDIA_TOO_LARGE |
File exceeds size limit |
"File is too large (max 50MB)" |
| MEDIA_INVALID_URL |
Invalid URL for URL upload |
"Invalid URL provided" |
| MEDIA_UNAUTHORIZED |
Insufficient permissions |
"You don't have permission to perform this action" |
interface ErrorResponse {
code: string;
message: string;
details?: Record<string, unknown>;
}
7. Observability
7.1 Logging
| Event |
Level |
Fields to Include |
| Media uploaded |
INFO |
media_id, type, size, r2_key, user_id |
| Media deleted |
INFO |
media_id, user_id |
| Upload failed |
ERROR |
error_code, filename, size, user_id |
| Bulk delete completed |
INFO |
count, user_id |
7.2 Metrics
| Metric |
Type |
Description |
| media_uploads_total |
Counter |
Total uploads by type |
| media_upload_size_bytes |
Histogram |
Upload size distribution |
| media_upload_duration_ms |
Histogram |
Upload latency |
| media_deletes_total |
Counter |
Total deletions |
8. Testing Strategy
| Test Type |
Coverage Target |
Tools |
Focus Areas |
| Unit |
80%+ |
Vitest |
URL transformation, validation, filters |
| Integration |
Critical paths |
Vitest |
R2 upload/delete (mocked), metadata CRUD |
| E2E |
Happy paths |
Playwright |
Upload flow, media picker UI |
9. Feature Inventory
| Feature ID |
Name |
Status |
Priority |
| FEATURE-042 |
Media Upload |
Draft |
Must Have |
| FEATURE-043 |
Media CRUD |
Draft |
Must Have |
| FEATURE-044 |
URL Transform Utility |
Draft |
Must Have |
| FEATURE-045 |
Bulk Media Operations |
Draft |
Should Have |
| FEATURE-046 |
Media Picker Component |
Draft |
Must Have |
10. Access Control
Operation Permissions
| Operation |
Sales |
Kennel Staff |
Photographer |
Admin |
| View media |
Yes |
Yes |
Yes |
Yes |
| Upload media |
No |
No |
Yes |
Yes |
| Update metadata |
No |
No |
Yes |
Yes |
| Delete media |
No |
No |
No |
Yes |
| Bulk delete |
No |
No |
No |
Yes |
Note: Photographer role is specific to media management.
11. Decisions
Status: Accepted
Context: Need responsive images with multiple sizes and formats
Decision: Use Cloudflare Images for on-the-fly transformations instead of pre-generating variants
Consequences: Simpler storage (one file), flexible sizing, automatic format optimization (webp/avif)
D2: Path-Based Organization (2026-01-20)
Status: Accepted
Context: Need to organize media by usage context
Decision: Use structured r2Key path prefixes instead of folder entities (e.g., puppies/{id}/, breeds/{slug}/)
Consequences: Simpler schema, organization handled in code, easy to query by prefix
Status: Accepted
Context: Need flexible URL generation for different image sizes
Decision: Store base URL in Media entity, use pure utility function for transformations
Consequences: Simple service layer, utility usable anywhere (server/client), no extra DB calls for URL generation
12. References
Change Log
| Version |
Date |
Author |
Changes |
| 1.0 |
2026-01-20 |
Claude |
Initial module spec - Draft |
| 1.1 |
2026-01-20 |
Claude |
Updated relationship types with explicit module imports (User['id']) |