Support image storage, retrieval, and management for the journal feature. Users can reference images from chat conversations and upload new images directly. The backend handles storage (Cloudflare R2), generates thumbnails, and serves image metadata to the frontend.
User Device → Backend API → Cloudflare R2 (via S3 SDK)
↓
Cloudflare Worker (public serving)
https://talkamore-media-worker.team-e29.workers.dev/
- Upload path: Device → Backend → R2
- Serving path: Frontend → Cloudflare Worker → R2 (direct, no backend involved)
- Storage: Cloudflare R2 bucket (already set up with Worker)
talkamore-media/
├── chat/
│ └── {userId}/
│ ├── {imageId}.jpg # original chat image
│ └── thumb_{imageId}.jpg # thumbnail (300px wide)
├── journal/
│ └── {userId}/
│ ├── {imageId}.jpg # original uploaded image
│ └── thumb_{imageId}.jpg # thumbnail
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source VARCHAR(10) NOT NULL CHECK (source IN ('chat', 'upload')),
persona_id VARCHAR(20), -- nullable, only for chat images
original_url TEXT NOT NULL, -- full R2 URL
thumbnail_url TEXT NOT NULL, -- thumbnail R2 URL
file_name VARCHAR(255),
mime_type VARCHAR(50) NOT NULL,
file_size INTEGER NOT NULL, -- bytes
width INTEGER, -- original width in px
height INTEGER, -- original height in px
created_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP -- soft delete
);
CREATE INDEX idx_images_user_id ON images(user_id);
CREATE INDEX idx_images_user_source ON images(user_id, source);Add a column or use a join table for image placements:
CREATE TABLE journal_entry_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
journal_entry_id UUID NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
image_id UUID NOT NULL REFERENCES images(id),
x FLOAT NOT NULL DEFAULT 0, -- x position
y FLOAT NOT NULL DEFAULT 0, -- y position
width FLOAT NOT NULL DEFAULT 200, -- display width
height FLOAT NOT NULL DEFAULT 200, -- display height
rotation FLOAT NOT NULL DEFAULT 0, -- degrees
z_index INTEGER NOT NULL DEFAULT 0, -- layer order
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_jei_entry ON journal_entry_images(journal_entry_id);model Image {
id String @id @default(uuid())
userId String @map("user_id")
source String // 'chat' | 'upload'
personaId String? @map("persona_id")
originalUrl String @map("original_url")
thumbnailUrl String @map("thumbnail_url")
fileName String? @map("file_name")
mimeType String @map("mime_type")
fileSize Int @map("file_size")
width Int?
height Int?
createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
journalEntryImages JournalEntryImage[]
@@index([userId])
@@index([userId, source])
@@map("images")
}
model JournalEntryImage {
id String @id @default(uuid())
journalEntryId String @map("journal_entry_id")
imageId String @map("image_id")
x Float @default(0)
y Float @default(0)
width Float @default(200)
height Float @default(200)
rotation Float @default(0)
zIndex Int @default(0) @map("z_index")
createdAt DateTime @default(now()) @map("created_at")
journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade)
image Image @relation(fields: [imageId], references: [id])
@@index([journalEntryId])
@@map("journal_entry_images")
}Fetch user's images from chat conversations.
Auth: Required (JWT)
Query params:
cursor(optional) — pagination cursorlimit(optional, default 20, max 50)personaId(optional) — filter by persona
Response:
{
"images": [
{
"imageId": "uuid",
"url": "https://talkamore-media-worker.team-e29.workers.dev/chat/{userId}/{imageId}.jpg",
"thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/chat/{userId}/thumb_{imageId}.jpg",
"sentAt": "2026-05-10T14:30:00Z",
"personaId": "maya",
"personaName": "Maya"
}
],
"nextCursor": "string | null",
"hasMore": true
}Upload a new image from device.
Auth: Required (JWT)
Request: multipart/form-data
image— file field (JPG, PNG, WEBP, GIF)
Validation:
- Max file size: 10MB
- Allowed MIME types:
image/jpeg,image/png,image/webp,image/gif - Max dimensions: 4096x4096 (resize if larger)
Process:
- Validate file type and size
- Generate UUID for
imageId - Process image: extract dimensions, generate thumbnail (300px wide, maintain aspect ratio)
- Upload original to R2:
journal/{userId}/{imageId}.{ext} - Upload thumbnail to R2:
journal/{userId}/thumb_{imageId}.{ext} - Insert record into
imagestable - Return response
Response:
{
"imageId": "uuid",
"url": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/{imageId}.jpg",
"thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/thumb_{imageId}.jpg",
"width": 1200,
"height": 800
}Libraries needed:
sharp— for image resizing / thumbnail generation@aws-sdk/client-s3— for R2 upload (S3-compatible API)multerorbusboy— for multipart form parsing
Update journal entry to include image placements.
Auth: Required (JWT, must own the entry)
Request body addition:
{
"content": "...",
"images": [
{
"imageId": "uuid",
"x": 120,
"y": 45,
"width": 250,
"height": 180,
"rotation": 0,
"zIndex": 1
}
]
}Process:
- Validate all
imageIds belong to the authenticated user - Delete existing
journal_entry_imagesfor this entry - Bulk insert new
journal_entry_imagesrecords - Update journal entry content as usual
Load journal entry with image placements.
Response addition:
{
"id": "uuid",
"content": "...",
"images": [
{
"imageId": "uuid",
"url": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/{imageId}.jpg",
"thumbnailUrl": "https://talkamore-media-worker.team-e29.workers.dev/journal/{userId}/thumb_{imageId}.jpg",
"x": 120,
"y": 45,
"width": 250,
"height": 180,
"rotation": 0,
"zIndex": 1
}
]
}Soft-delete an uploaded image.
Auth: Required (JWT, must own the image)
Process:
- Set
deleted_at = NOW()on the image record - Remove any
journal_entry_imagesreferencing this image - Optionally: queue a background job to delete from R2 after 30 days (grace period)
Response: 204 No Content
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
const r2Client = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const BUCKET = process.env.R2_BUCKET_NAME!; // 'talkamore-media'
const WORKER_URL = process.env.R2_WORKER_URL!; // 'https://talkamore-media-worker.team-e29.workers.dev'
export async function uploadToR2(key: string, body: Buffer, contentType: string) {
await r2Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
}));
return `${WORKER_URL}/${key}`;
}
export async function deleteFromR2(key: string) {
await r2Client.send(new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
}));
}import sharp from 'sharp';
import { v4 as uuid } from 'uuid';
import { uploadToR2 } from '../lib/r2';
const THUMBNAIL_WIDTH = 300;
const MAX_DIMENSION = 4096;
export async function processAndUploadImage(
fileBuffer: Buffer,
mimeType: string,
userId: string,
source: 'chat' | 'upload',
personaId?: string
) {
const imageId = uuid();
const ext = mimeType.split('/')[1] === 'jpeg' ? 'jpg' : mimeType.split('/')[1];
// Get metadata and resize if needed
const metadata = await sharp(fileBuffer).metadata();
let processed = sharp(fileBuffer);
if ((metadata.width && metadata.width > MAX_DIMENSION) ||
(metadata.height && metadata.height > MAX_DIMENSION)) {
processed = processed.resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside' });
}
const originalBuffer = await processed.toBuffer();
const thumbnailBuffer = await sharp(fileBuffer)
.resize(THUMBNAIL_WIDTH, null, { fit: 'inside' })
.toBuffer();
const folder = source === 'chat' ? 'chat' : 'journal';
const originalKey = `${folder}/${userId}/${imageId}.${ext}`;
const thumbnailKey = `${folder}/${userId}/thumb_${imageId}.${ext}`;
const [originalUrl, thumbnailUrl] = await Promise.all([
uploadToR2(originalKey, originalBuffer, mimeType),
uploadToR2(thumbnailKey, thumbnailBuffer, mimeType),
]);
return {
imageId,
originalUrl,
thumbnailUrl,
width: metadata.width,
height: metadata.height,
fileSize: originalBuffer.length,
};
}Add to .env:
R2_ACCOUNT_ID=your_cloudflare_account_id
R2_ACCESS_KEY_ID=your_r2_access_key
R2_SECRET_ACCESS_KEY=your_r2_secret_key
R2_BUCKET_NAME=talkamore-media
R2_WORKER_URL=https://talkamore-media-worker.team-e29.workers.devWhen a user sends an image in a chat conversation (to any persona), the existing chat image handling should ALSO create a record in the images table:
// In your existing chat message handler, after storing the chat message:
if (message.hasImage) {
await processAndUploadImage(
imageBuffer,
mimeType,
userId,
'chat', // source
personaId // which persona they sent it to
);
}This ensures chat images automatically appear in the journal image picker without any additional user action.
- Auth: All endpoints require valid JWT. Users can only access their own images.
- Validation: Strict MIME type checking (don't trust Content-Type alone — use
sharporfile-typeto verify actual image bytes) - Rate limiting: Max 20 uploads per minute per user
- Size limits: 10MB per file, max 10 images per journal entry
- CORS: R2 Worker already has
Access-Control-Allow-Origin: *— consider restricting to your domain in production - Soft delete: Always soft-delete images (set
deleted_at). Hard-delete from R2 via background job after 30-day grace period.