diff --git a/packages/web/src/components/admin/StorageManagement.jsx b/packages/web/src/components/admin/StorageManagement.jsx
index c6361a67a..eaea99edb 100644
--- a/packages/web/src/components/admin/StorageManagement.jsx
+++ b/packages/web/src/components/admin/StorageManagement.jsx
@@ -205,9 +205,8 @@ export default function StorageManagement() {
Note: This dashboard shows all PDFs in R2 storage. PDFs marked as
- "Orphaned" have a project ID that no longer exists in the database (e.g., from failed
- cleanup when projects were deleted). You can safely delete orphaned PDFs to free up
- storage space.
+ "Orphaned" are files in R2 that are not tracked in the mediaFiles database table (e.g.,
+ from failed cleanup). You can safely delete orphaned PDFs to free up storage space.
@@ -351,7 +350,7 @@ export default function StorageManagement() {
Orphaned
diff --git a/packages/web/src/components/billing/BillingPage.jsx b/packages/web/src/components/billing/BillingPage.jsx
index c24277969..7e2f287f1 100644
--- a/packages/web/src/components/billing/BillingPage.jsx
+++ b/packages/web/src/components/billing/BillingPage.jsx
@@ -161,9 +161,7 @@ export default function BillingPage() {
Payment successful!
-
- Your subscription has been activated. Welcome aboard!
-
+
Your subscription has been activated!
diff --git a/packages/workers/migrations/0000_good_violations.sql b/packages/workers/migrations/0000_amusing_black_panther.sql
similarity index 96%
rename from packages/workers/migrations/0000_good_violations.sql
rename to packages/workers/migrations/0000_amusing_black_panther.sql
index cdcf26e3a..a09dde097 100644
--- a/packages/workers/migrations/0000_good_violations.sql
+++ b/packages/workers/migrations/0000_amusing_black_panther.sql
@@ -36,8 +36,13 @@ CREATE TABLE `mediaFiles` (
`fileSize` integer,
`uploadedBy` text,
`bucketKey` text NOT NULL,
+ `orgId` text NOT NULL,
+ `projectId` text NOT NULL,
+ `studyId` text,
`createdAt` integer DEFAULT (unixepoch()),
- FOREIGN KEY (`uploadedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
+ FOREIGN KEY (`uploadedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
+ FOREIGN KEY (`orgId`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `member` (
diff --git a/packages/workers/migrations/meta/0000_snapshot.json b/packages/workers/migrations/meta/0000_snapshot.json
index b63cc3844..e36a19168 100644
--- a/packages/workers/migrations/meta/0000_snapshot.json
+++ b/packages/workers/migrations/meta/0000_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
- "id": "4d58f8a3-08c6-4560-880b-a1eb4bfde9f5",
+ "id": "3a9d2b37-4834-467a-ac0f-ec8ae72a22ce",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -257,6 +257,27 @@
"notNull": true,
"autoincrement": false
},
+ "orgId": {
+ "name": "orgId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "projectId": {
+ "name": "projectId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "studyId": {
+ "name": "studyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
"createdAt": {
"name": "createdAt",
"type": "integer",
@@ -276,6 +297,24 @@
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
+ },
+ "mediaFiles_orgId_organization_id_fk": {
+ "name": "mediaFiles_orgId_organization_id_fk",
+ "tableFrom": "mediaFiles",
+ "tableTo": "organization",
+ "columnsFrom": ["orgId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mediaFiles_projectId_projects_id_fk": {
+ "name": "mediaFiles_projectId_projects_id_fk",
+ "tableFrom": "mediaFiles",
+ "tableTo": "projects",
+ "columnsFrom": ["projectId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json
index 64f0ab8a1..7fbdd85b5 100644
--- a/packages/workers/migrations/meta/_journal.json
+++ b/packages/workers/migrations/meta/_journal.json
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
- "when": 1767574603970,
- "tag": "0000_good_violations",
+ "when": 1767649024745,
+ "tag": "0000_amusing_black_panther",
"breakpoints": true
}
]
diff --git a/packages/workers/scripts/generate-openapi.mjs b/packages/workers/scripts/generate-openapi.mjs
index dc35b84d8..7f97f806f 100644
--- a/packages/workers/scripts/generate-openapi.mjs
+++ b/packages/workers/scripts/generate-openapi.mjs
@@ -267,7 +267,7 @@ async function generate() {
// Legacy routes (deprecated, kept for backward compatibility detection)
{ file: 'src/routes/projects.js', basePath: '/api/projects' },
{ file: 'src/routes/members.js', basePath: '/api/projects/{projectId}/members' },
- { file: 'src/routes/pdfs.js', basePath: '/api/projects/{projectId}/studies/{studyId}/pdfs' },
+ // Note: src/routes/pdfs.js removed - replaced by org-scoped routes in src/routes/orgs/pdfs.js
{ file: 'src/routes/invitations.js', basePath: '/api/invitations' },
// Other routes
{ file: 'src/routes/users.js', basePath: '/api/users' },
diff --git a/packages/workers/src/__tests__/helpers.js b/packages/workers/src/__tests__/helpers.js
index 35a474417..5b0693f1c 100644
--- a/packages/workers/src/__tests__/helpers.js
+++ b/packages/workers/src/__tests__/helpers.js
@@ -348,6 +348,9 @@ export async function seedMediaFile(params) {
fileSize: validated.fileSize,
uploadedBy: validated.uploadedBy,
bucketKey: validated.bucketKey,
+ orgId: validated.orgId,
+ projectId: validated.projectId,
+ studyId: validated.studyId,
createdAt: new Date(validated.createdAt * 1000),
});
}
diff --git a/packages/workers/src/__tests__/seed-schemas.js b/packages/workers/src/__tests__/seed-schemas.js
index b2eac76e8..70e977164 100644
--- a/packages/workers/src/__tests__/seed-schemas.js
+++ b/packages/workers/src/__tests__/seed-schemas.js
@@ -207,6 +207,9 @@ export const seedMediaFileSchema = z.object({
fileSize: z.number().int().nullable().optional().default(null),
uploadedBy: z.string().nullable().optional().default(null),
bucketKey: z.string().min(1, 'Bucket key is required'),
+ orgId: z.string().min(1, 'Organization ID is required'),
+ projectId: z.string().min(1, 'Project ID is required'),
+ studyId: z.string().nullable().optional().default(null),
createdAt: dateOrTimestampToNumber,
});
diff --git a/packages/workers/src/db/schema.js b/packages/workers/src/db/schema.js
index acf6485b4..d76b1b027 100644
--- a/packages/workers/src/db/schema.js
+++ b/packages/workers/src/db/schema.js
@@ -151,6 +151,13 @@ export const mediaFiles = sqliteTable('mediaFiles', {
fileSize: integer('fileSize'),
uploadedBy: text('uploadedBy').references(() => user.id, { onDelete: 'set null' }),
bucketKey: text('bucketKey').notNull(),
+ orgId: text('orgId')
+ .notNull()
+ .references(() => organization.id, { onDelete: 'cascade' }),
+ projectId: text('projectId')
+ .notNull()
+ .references(() => projects.id, { onDelete: 'cascade' }),
+ studyId: text('studyId'),
createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});
diff --git a/packages/workers/src/routes/__tests__/pdfs.test.js b/packages/workers/src/routes/__tests__/pdfs.test.js
index b8831eac2..4625d1f22 100644
--- a/packages/workers/src/routes/__tests__/pdfs.test.js
+++ b/packages/workers/src/routes/__tests__/pdfs.test.js
@@ -13,8 +13,12 @@ import {
seedProjectMember,
seedOrganization,
seedOrgMember,
+ seedMediaFile,
json,
} from '../../__tests__/helpers.js';
+import { createDb } from '../../db/client.js';
+import { mediaFiles } from '../../db/schema.js';
+import { eq, and } from 'drizzle-orm';
import { FILE_SIZE_LIMITS } from '../../config/constants.js';
// Mock postmark
@@ -197,7 +201,22 @@ describe('Org-Scoped PDF Routes - GET /api/orgs/:orgId/projects/:projectId/studi
joinedAt: nowSec,
});
- // Upload a PDF
+ // Seed a PDF in mediaFiles table
+ await seedMediaFile({
+ id: 'media-1',
+ filename: 'document.pdf',
+ originalName: 'document.pdf',
+ fileType: 'application/pdf',
+ fileSize: 1024,
+ uploadedBy: 'user-1',
+ bucketKey: 'projects/project-1/studies/study-1/document.pdf',
+ orgId: 'org-1',
+ projectId: 'project-1',
+ studyId: 'study-1',
+ createdAt: nowSec,
+ });
+
+ // Also add to R2 mock for download compatibility
const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
await mockR2Bucket.put('projects/project-1/studies/study-1/document.pdf', pdfData, {
httpMetadata: { contentType: 'application/pdf' },
@@ -210,6 +229,9 @@ describe('Org-Scoped PDF Routes - GET /api/orgs/:orgId/projects/:projectId/studi
expect(body.pdfs).toBeDefined();
expect(body.pdfs.length).toBe(1);
expect(body.pdfs[0].fileName).toBe('document.pdf');
+ expect(body.pdfs[0].id).toBe('media-1');
+ expect(body.pdfs[0].uploadedBy).toBeDefined();
+ expect(body.pdfs[0].uploadedBy.id).toBe('user-1');
});
it('should require project membership', async () => {
@@ -339,6 +361,27 @@ describe('Org-Scoped PDF Routes - POST /api/orgs/:orgId/projects/:projectId/stud
expect(body.success).toBe(true);
expect(body.fileName).toBe('document.pdf');
expect(body.key).toBe('projects/project-1/studies/study-1/document.pdf');
+ expect(body.id).toBeDefined();
+
+ // Verify mediaFiles record was created
+ const db = createDb(env.DB);
+ const mediaFile = await db
+ .select()
+ .from(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, 'project-1'),
+ eq(mediaFiles.studyId, 'study-1'),
+ eq(mediaFiles.filename, 'document.pdf'),
+ ),
+ )
+ .get();
+ expect(mediaFile).toBeDefined();
+ expect(mediaFile.id).toBe(body.id);
+ expect(mediaFile.orgId).toBe('org-1');
+ expect(mediaFile.projectId).toBe('project-1');
+ expect(mediaFile.studyId).toBe('study-1');
+ expect(mediaFile.uploadedBy).toBe('user-1');
});
it('should reject files that are too large', async () => {
@@ -458,7 +501,7 @@ describe('Org-Scoped PDF Routes - POST /api/orgs/:orgId/projects/:projectId/stud
expect(body.code).toBe('FILE_INVALID_TYPE');
});
- it('should reject duplicate file names', async () => {
+ it('should auto-rename duplicate file names', async () => {
const nowSec = Math.floor(Date.now() / 1000);
await seedUser({
id: 'user-1',
@@ -500,13 +543,28 @@ describe('Org-Scoped PDF Routes - POST /api/orgs/:orgId/projects/:projectId/stud
joinedAt: nowSec,
});
- // Upload first PDF
+ // Seed first PDF in database
+ await seedMediaFile({
+ id: 'media-1',
+ filename: 'document.pdf',
+ originalName: 'document.pdf',
+ fileType: 'application/pdf',
+ fileSize: 1024,
+ uploadedBy: 'user-1',
+ bucketKey: 'projects/project-1/studies/study-1/document.pdf',
+ orgId: 'org-1',
+ projectId: 'project-1',
+ studyId: 'study-1',
+ createdAt: nowSec,
+ });
+
+ // Also add to R2 mock
const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
await mockR2Bucket.put('projects/project-1/studies/study-1/document.pdf', pdfData, {
httpMetadata: { contentType: 'application/pdf' },
});
- // Try to upload duplicate
+ // Try to upload duplicate - should auto-rename
const file = new File([pdfData], 'document.pdf', { type: 'application/pdf' });
const formData = new FormData();
formData.append('file', file);
@@ -516,9 +574,12 @@ describe('Org-Scoped PDF Routes - POST /api/orgs/:orgId/projects/:projectId/stud
body: formData,
});
- expect(res.status).toBe(409);
+ expect(res.status).toBe(200);
const body = await json(res);
- expect(body.code).toBe('FILE_ALREADY_EXISTS');
+ expect(body.success).toBe(true);
+ expect(body.fileName).toBe('document (1).pdf');
+ expect(body.originalFileName).toBe('document.pdf');
+ expect(body.key).toBe('projects/project-1/studies/study-1/document (1).pdf');
});
it('should accept raw PDF upload with X-File-Name header', async () => {
@@ -731,6 +792,21 @@ describe('Org-Scoped PDF Routes - DELETE /api/orgs/:orgId/projects/:projectId/st
joinedAt: nowSec,
});
+ // Seed a PDF in mediaFiles table
+ await seedMediaFile({
+ id: 'media-1',
+ filename: 'document.pdf',
+ originalName: 'document.pdf',
+ fileType: 'application/pdf',
+ fileSize: 1024,
+ uploadedBy: 'user-1',
+ bucketKey: 'projects/project-1/studies/study-1/document.pdf',
+ orgId: 'org-1',
+ projectId: 'project-1',
+ studyId: 'study-1',
+ createdAt: nowSec,
+ });
+
const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
await mockR2Bucket.put('projects/project-1/studies/study-1/document.pdf', pdfData, {
httpMetadata: { contentType: 'application/pdf' },
@@ -744,6 +820,21 @@ describe('Org-Scoped PDF Routes - DELETE /api/orgs/:orgId/projects/:projectId/st
const body = await json(res);
expect(body.success).toBe(true);
+ // Verify mediaFiles record was deleted
+ const db = createDb(env.DB);
+ const mediaFile = await db
+ .select()
+ .from(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, 'project-1'),
+ eq(mediaFiles.studyId, 'study-1'),
+ eq(mediaFiles.filename, 'document.pdf'),
+ ),
+ )
+ .get();
+ expect(mediaFile).toBeUndefined();
+
// Verify PDF is deleted
const pdf = await mockR2Bucket.get('projects/project-1/studies/study-1/document.pdf');
expect(pdf).toBeNull();
diff --git a/packages/workers/src/routes/admin/database.js b/packages/workers/src/routes/admin/database.js
index 127a81538..f23467e0f 100644
--- a/packages/workers/src/routes/admin/database.js
+++ b/packages/workers/src/routes/admin/database.js
@@ -6,8 +6,8 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { createDb } from '../../db/client.js';
-import { dbSchema } from '../../db/schema.js';
-import { count, desc, asc } from 'drizzle-orm';
+import { dbSchema, mediaFiles, organization, projects, user } from '../../db/schema.js';
+import { count, desc, asc, eq, and, sum } from 'drizzle-orm';
import { createDomainError, VALIDATION_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
import { validateQueryParams } from '../../config/validation.js';
@@ -61,6 +61,14 @@ const databaseSchemas = {
.default('')
.transform(val => val.trim()),
order: z.enum(['asc', 'desc']).optional().default('desc'),
+ filterBy: z
+ .string()
+ .optional()
+ .transform(val => val?.trim()),
+ filterValue: z
+ .string()
+ .optional()
+ .transform(val => val?.trim()),
}),
};
@@ -144,19 +152,28 @@ databaseRoutes.get('/database/tables/:tableName/schema', async c => {
/**
* GET /api/admin/database/tables/:tableName/rows
- * Get rows from a table with pagination
+ * Get rows from a table with pagination and filtering
* Query params:
* - page: page number (default 1)
* - limit: rows per page (default 50, max 100)
* - orderBy: column to sort by (default: id or first column)
* - order: 'asc' or 'desc' (default: desc)
+ * - filterBy: column name to filter by (optional)
+ * - filterValue: value to match (optional, required if filterBy is provided)
*/
databaseRoutes.get(
'/database/tables/:tableName/rows',
validateQueryParams(databaseSchemas.tableRows),
async c => {
const { tableName } = c.req.param();
- const { page, limit, orderBy: orderByParam, order } = c.get('validatedQuery');
+ const {
+ page,
+ limit,
+ orderBy: orderByParam,
+ order,
+ filterBy,
+ filterValue,
+ } = c.get('validatedQuery');
if (!ALLOWED_TABLES.includes(tableName)) {
const error = createDomainError(VALIDATION_ERRORS.FIELD_INVALID_FORMAT, {
@@ -177,12 +194,34 @@ databaseRoutes.get(
return c.json(error, error.statusCode);
}
+ // Special handling for mediaFiles with joins
+ if (tableName === 'mediaFiles') {
+ return handleMediaFilesQuery(c, {
+ page,
+ limit,
+ orderBy: orderByParam,
+ order,
+ filterBy,
+ filterValue,
+ });
+ }
+
try {
const db = createDb(c.env.DB);
const offset = (page - 1) * limit;
- // Get total count
- const countResult = await db.select({ count: count() }).from(table);
+ // Build where clause for filtering
+ let whereConditions = [];
+ if (filterBy && filterValue && table[filterBy]) {
+ whereConditions.push(eq(table[filterBy], filterValue));
+ }
+
+ // Get total count with filtering
+ let countQuery = db.select({ count: count() }).from(table);
+ if (whereConditions.length > 0) {
+ countQuery = countQuery.where(and(...whereConditions));
+ }
+ const countResult = await countQuery;
const totalRows = countResult[0]?.count ?? 0;
// Determine order column (use provided column, fall back to 'id', then first column)
@@ -194,13 +233,17 @@ databaseRoutes.get(
const orderColumn = table[orderColumnName];
const orderFn = order === 'asc' ? asc : desc;
- // Get rows with ordering
- const rows = await db
+ // Get rows with ordering and filtering
+ let rowsQuery = db
.select()
.from(table)
.orderBy(orderFn(orderColumn))
.limit(limit)
.offset(offset);
+ if (whereConditions.length > 0) {
+ rowsQuery = rowsQuery.where(and(...whereConditions));
+ }
+ const rows = await rowsQuery;
return c.json({
tableName,
@@ -216,8 +259,365 @@ databaseRoutes.get(
});
} catch (error) {
console.error('Database rows fetch error:', error);
- const domainError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
- message: `Failed to fetch rows from table '${tableName}'`,
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_table_rows',
+ tableName,
+ originalError: error.message,
+ });
+ return c.json(domainError, domainError.statusCode);
+ }
+ },
+);
+
+/**
+ * Handle mediaFiles query with joins for better readability
+ */
+async function handleMediaFilesQuery(
+ c,
+ { page, limit, orderBy: orderByParam, order, filterBy, filterValue },
+) {
+ try {
+ const db = createDb(c.env.DB);
+ const offset = (page - 1) * limit;
+
+ // Build where conditions
+ let whereConditions = [];
+
+ // Handle filtering
+ if (filterBy && filterValue) {
+ if (filterBy === 'orgId' || filterBy === 'orgSlug') {
+ if (filterBy === 'orgSlug') {
+ // Look up org by slug first
+ const org = await db
+ .select({ id: organization.id })
+ .from(organization)
+ .where(eq(organization.slug, filterValue))
+ .get();
+ if (org) {
+ whereConditions.push(eq(mediaFiles.orgId, org.id));
+ } else {
+ // Org not found, return empty results
+ return c.json({
+ tableName: 'mediaFiles',
+ rows: [],
+ pagination: {
+ page,
+ limit,
+ totalRows: 0,
+ totalPages: 0,
+ orderBy: orderByParam || 'createdAt',
+ order,
+ },
+ });
+ }
+ } else {
+ whereConditions.push(eq(mediaFiles.orgId, filterValue));
+ }
+ } else if (filterBy === 'projectId') {
+ whereConditions.push(eq(mediaFiles.projectId, filterValue));
+ } else if (filterBy === 'uploadedBy') {
+ whereConditions.push(eq(mediaFiles.uploadedBy, filterValue));
+ } else if (filterBy === 'studyId') {
+ whereConditions.push(eq(mediaFiles.studyId, filterValue));
+ }
+ }
+
+ // Get total count with filtering
+ let countQuery = db.select({ count: count() }).from(mediaFiles);
+ if (whereConditions.length > 0) {
+ countQuery = countQuery.where(and(...whereConditions));
+ }
+ const countResult = await countQuery;
+ const totalRows = countResult[0]?.count ?? 0;
+
+ // Determine order column
+ let orderColumnName = orderByParam || 'createdAt';
+ let orderColumn = mediaFiles[orderColumnName];
+ if (!orderColumn) {
+ orderColumnName = 'createdAt';
+ orderColumn = mediaFiles.createdAt;
+ }
+ const orderFn = order === 'asc' ? asc : desc;
+
+ // Get rows with joins
+ let rowsQuery = db
+ .select({
+ // mediaFiles fields
+ id: mediaFiles.id,
+ filename: mediaFiles.filename,
+ originalName: mediaFiles.originalName,
+ fileType: mediaFiles.fileType,
+ fileSize: mediaFiles.fileSize,
+ bucketKey: mediaFiles.bucketKey,
+ createdAt: mediaFiles.createdAt,
+ studyId: mediaFiles.studyId,
+ // Joined fields
+ orgId: mediaFiles.orgId,
+ orgName: organization.name,
+ orgSlug: organization.slug,
+ projectId: mediaFiles.projectId,
+ projectName: projects.name,
+ uploadedBy: mediaFiles.uploadedBy,
+ uploadedByName: user.name,
+ uploadedByEmail: user.email,
+ uploadedByDisplayName: user.displayName,
+ })
+ .from(mediaFiles)
+ .leftJoin(organization, eq(mediaFiles.orgId, organization.id))
+ .leftJoin(projects, eq(mediaFiles.projectId, projects.id))
+ .leftJoin(user, eq(mediaFiles.uploadedBy, user.id))
+ .orderBy(orderFn(orderColumn))
+ .limit(limit)
+ .offset(offset);
+
+ if (whereConditions.length > 0) {
+ rowsQuery = rowsQuery.where(and(...whereConditions));
+ }
+
+ const rows = await rowsQuery;
+
+ return c.json({
+ tableName: 'mediaFiles',
+ rows,
+ pagination: {
+ page,
+ limit,
+ totalRows,
+ totalPages: Math.ceil(totalRows / limit),
+ orderBy: orderColumnName,
+ order,
+ },
+ });
+ } catch (error) {
+ console.error('MediaFiles query error:', error);
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_mediafiles_rows',
+ originalError: error.message,
+ });
+ return c.json(domainError, domainError.statusCode);
+ }
+}
+
+/**
+ * GET /api/admin/database/analytics/pdfs-by-org
+ * Get PDF count and total storage per organization
+ */
+databaseRoutes.get('/database/analytics/pdfs-by-org', async c => {
+ try {
+ const db = createDb(c.env.DB);
+
+ const results = await db
+ .select({
+ orgId: mediaFiles.orgId,
+ orgName: organization.name,
+ orgSlug: organization.slug,
+ pdfCount: count(mediaFiles.id),
+ totalStorage: sum(mediaFiles.fileSize),
+ })
+ .from(mediaFiles)
+ .leftJoin(organization, eq(mediaFiles.orgId, organization.id))
+ .groupBy(mediaFiles.orgId, organization.name, organization.slug)
+ .orderBy(desc(count(mediaFiles.id)));
+
+ const analytics = results.map(row => ({
+ orgId: row.orgId,
+ orgName: row.orgName,
+ orgSlug: row.orgSlug,
+ pdfCount: Number(row.pdfCount || 0),
+ totalStorage: Number(row.totalStorage || 0),
+ }));
+
+ return c.json({ analytics });
+ } catch (error) {
+ console.error('PDFs by org analytics error:', error);
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'analytics_pdfs_by_org',
+ originalError: error.message,
+ });
+ return c.json(domainError, domainError.statusCode);
+ }
+});
+
+/**
+ * GET /api/admin/database/analytics/pdfs-by-user
+ * Get uploads by user
+ */
+databaseRoutes.get('/database/analytics/pdfs-by-user', async c => {
+ try {
+ const db = createDb(c.env.DB);
+
+ const results = await db
+ .select({
+ userId: mediaFiles.uploadedBy,
+ userName: user.name,
+ userEmail: user.email,
+ userDisplayName: user.displayName,
+ pdfCount: count(mediaFiles.id),
+ totalStorage: sum(mediaFiles.fileSize),
+ })
+ .from(mediaFiles)
+ .leftJoin(user, eq(mediaFiles.uploadedBy, user.id))
+ .groupBy(mediaFiles.uploadedBy, user.name, user.email, user.displayName)
+ .orderBy(desc(count(mediaFiles.id)));
+
+ const analytics = results
+ .filter(row => row.userId) // Only include rows with a user
+ .map(row => ({
+ userId: row.userId,
+ userName: row.userName,
+ userEmail: row.userEmail,
+ userDisplayName: row.userDisplayName,
+ pdfCount: Number(row.pdfCount || 0),
+ totalStorage: Number(row.totalStorage || 0),
+ }));
+
+ return c.json({ analytics });
+ } catch (error) {
+ console.error('PDFs by user analytics error:', error);
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'analytics_pdfs_by_user',
+ originalError: error.message,
+ });
+ return c.json(domainError, domainError.statusCode);
+ }
+});
+
+/**
+ * GET /api/admin/database/analytics/pdfs-by-project
+ * Get PDFs per project
+ */
+databaseRoutes.get('/database/analytics/pdfs-by-project', async c => {
+ try {
+ const db = createDb(c.env.DB);
+
+ const results = await db
+ .select({
+ projectId: mediaFiles.projectId,
+ projectName: projects.name,
+ orgId: mediaFiles.orgId,
+ orgName: organization.name,
+ orgSlug: organization.slug,
+ pdfCount: count(mediaFiles.id),
+ totalStorage: sum(mediaFiles.fileSize),
+ })
+ .from(mediaFiles)
+ .leftJoin(projects, eq(mediaFiles.projectId, projects.id))
+ .leftJoin(organization, eq(mediaFiles.orgId, organization.id))
+ .groupBy(
+ mediaFiles.projectId,
+ projects.name,
+ mediaFiles.orgId,
+ organization.name,
+ organization.slug,
+ )
+ .orderBy(desc(count(mediaFiles.id)));
+
+ const analytics = results.map(row => ({
+ projectId: row.projectId,
+ projectName: row.projectName,
+ orgId: row.orgId,
+ orgName: row.orgName,
+ orgSlug: row.orgSlug,
+ pdfCount: Number(row.pdfCount || 0),
+ totalStorage: Number(row.totalStorage || 0),
+ }));
+
+ return c.json({ analytics });
+ } catch (error) {
+ console.error('PDFs by project analytics error:', error);
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'analytics_pdfs_by_project',
+ originalError: error.message,
+ });
+ return c.json(domainError, domainError.statusCode);
+ }
+});
+
+/**
+ * GET /api/admin/database/analytics/recent-uploads
+ * Get recent PDF uploads with user/org context
+ * Query params:
+ * - limit: number of recent uploads (default 50, max 100)
+ */
+databaseRoutes.get(
+ '/database/analytics/recent-uploads',
+ validateQueryParams(
+ z.object({
+ limit: z
+ .string()
+ .optional()
+ .default('50')
+ .transform(val => parseInt(val, 10))
+ .pipe(
+ z
+ .number()
+ .int('Limit must be an integer')
+ .min(1, 'Limit must be at least 1')
+ .max(100, 'Limit must be at most 100'),
+ ),
+ }),
+ ),
+ async c => {
+ try {
+ const { limit } = c.get('validatedQuery');
+ const db = createDb(c.env.DB);
+
+ const results = await db
+ .select({
+ id: mediaFiles.id,
+ filename: mediaFiles.filename,
+ originalName: mediaFiles.originalName,
+ fileSize: mediaFiles.fileSize,
+ createdAt: mediaFiles.createdAt,
+ orgId: mediaFiles.orgId,
+ orgName: organization.name,
+ orgSlug: organization.slug,
+ projectId: mediaFiles.projectId,
+ projectName: projects.name,
+ uploadedBy: mediaFiles.uploadedBy,
+ uploadedByName: user.name,
+ uploadedByEmail: user.email,
+ uploadedByDisplayName: user.displayName,
+ })
+ .from(mediaFiles)
+ .leftJoin(organization, eq(mediaFiles.orgId, organization.id))
+ .leftJoin(projects, eq(mediaFiles.projectId, projects.id))
+ .leftJoin(user, eq(mediaFiles.uploadedBy, user.id))
+ .orderBy(desc(mediaFiles.createdAt))
+ .limit(limit);
+
+ const uploads = results.map(row => ({
+ id: row.id,
+ filename: row.filename,
+ originalName: row.originalName,
+ fileSize: row.fileSize,
+ createdAt: row.createdAt,
+ org: {
+ id: row.orgId,
+ name: row.orgName,
+ slug: row.orgSlug,
+ },
+ project: {
+ id: row.projectId,
+ name: row.projectName,
+ },
+ uploadedBy:
+ row.uploadedBy ?
+ {
+ id: row.uploadedBy,
+ name: row.uploadedByName,
+ email: row.uploadedByEmail,
+ displayName: row.uploadedByDisplayName,
+ }
+ : null,
+ }));
+
+ return c.json({ uploads });
+ } catch (error) {
+ console.error('Recent uploads analytics error:', error);
+ const domainError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'analytics_recent_uploads',
+ originalError: error.message,
});
return c.json(domainError, domainError.statusCode);
}
diff --git a/packages/workers/src/routes/admin/storage.js b/packages/workers/src/routes/admin/storage.js
index 55ff92bd9..c15514920 100644
--- a/packages/workers/src/routes/admin/storage.js
+++ b/packages/workers/src/routes/admin/storage.js
@@ -5,8 +5,7 @@
import { Hono } from 'hono';
import { createDb } from '../../db/client.js';
-import { projects } from '../../db/schema.js';
-import { inArray } from 'drizzle-orm';
+import { mediaFiles } from '../../db/schema.js';
import { createDomainError, SYSTEM_ERRORS } from '@corates/shared';
import { storageSchemas, validateQueryParams, validateRequest } from '../../config/validation.js';
@@ -138,26 +137,17 @@ storageRoutes.get(
// Slice with offset to get the correct page
const paginatedObjects = matchingObjects.slice(skipCount, skipCount + limit);
- // Check which projects exist in the database to identify orphaned PDFs
- const uniqueProjectIds = [...new Set(paginatedObjects.map(doc => doc.projectId))];
- let existingProjectIds = new Set();
+ // Check which PDFs are tracked in mediaFiles table to identify orphaned PDFs
+ // Orphans are R2 objects whose keys are NOT in mediaFiles.bucketKey
+ const db = createDb(c.env.DB);
+ const trackedKeys = await db.select({ bucketKey: mediaFiles.bucketKey }).from(mediaFiles);
- // Only query database if there are project IDs to check
- // inArray with empty array generates invalid SQL (WHERE id IN ())
- if (uniqueProjectIds.length > 0) {
- const db = createDb(c.env.DB);
- const existingProjects = await db
- .select({ id: projects.id })
- .from(projects)
- .where(inArray(projects.id, uniqueProjectIds));
+ const trackedKeysSet = new Set(trackedKeys.map(row => row.bucketKey));
- existingProjectIds = new Set(existingProjects.map(p => p.id));
- }
-
- // Mark documents as orphaned if their project doesn't exist
+ // Mark documents as orphaned if their key is not in mediaFiles table
const documentsWithOrphanStatus = paginatedObjects.map(doc => ({
...doc,
- orphaned: !existingProjectIds.has(doc.projectId),
+ orphaned: !trackedKeysSet.has(doc.key),
}));
const response = {
diff --git a/packages/workers/src/routes/google-drive.js b/packages/workers/src/routes/google-drive.js
index 8c119055c..e7a3cc32d 100644
--- a/packages/workers/src/routes/google-drive.js
+++ b/packages/workers/src/routes/google-drive.js
@@ -6,8 +6,9 @@
import { Hono } from 'hono';
import { requireAuth, getAuth } from '../middleware/auth.js';
import { createDb } from '../db/client.js';
-import { account } from '../db/schema.js';
+import { account, projects, mediaFiles } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
+import { generateUniqueFileName } from './orgs/pdfs.js';
import {
createDomainError,
createValidationError,
@@ -337,15 +338,36 @@ googleDriveRoutes.post('/import', async c => {
// This is necessary because R2 requires a stream with a known length
const fileContent = await downloadResponse.arrayBuffer();
+ // Get project to retrieve orgId
+ const project = await db
+ .select({ orgId: projects.orgId })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .get();
+
+ if (!project) {
+ const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_project_for_import',
+ projectId,
+ message: 'Project not found',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Generate unique filename (auto-rename if duplicate exists)
+ const originalFileName = fileMeta.name;
+ const uniqueFileName = await generateUniqueFileName(fileMeta.name, projectId, studyId, db);
+ const r2Key = `projects/${projectId}/studies/${studyId}/${uniqueFileName}`;
+
// Upload to R2 bucket
- const r2Key = `projects/${projectId}/studies/${studyId}/${fileMeta.name}`;
+ const fileSize = fileContent.byteLength;
await c.env.PDF_BUCKET.put(r2Key, fileContent, {
httpMetadata: {
contentType: 'application/pdf',
},
customMetadata: {
- originalName: fileMeta.name,
+ originalName: originalFileName,
importedFrom: 'google-drive',
googleDriveFileId: fileId,
uploadedBy: user.id,
@@ -353,12 +375,35 @@ googleDriveRoutes.post('/import', async c => {
},
});
+ // Insert into mediaFiles table
+ const mediaFileId = crypto.randomUUID();
+ try {
+ await db.insert(mediaFiles).values({
+ id: mediaFileId,
+ filename: uniqueFileName,
+ originalName: originalFileName,
+ fileType: 'application/pdf',
+ fileSize: fileSize,
+ uploadedBy: user.id,
+ bucketKey: r2Key,
+ orgId: project.orgId,
+ projectId,
+ studyId,
+ createdAt: new Date(),
+ });
+ } catch (dbError) {
+ // Log error but don't fail the request (R2 object exists, can be cleaned up later)
+ console.error('Failed to insert mediaFiles record after Google Drive import:', dbError);
+ }
+
return c.json({
success: true,
+ id: mediaFileId,
file: {
key: r2Key,
- fileName: fileMeta.name,
- size: fileMeta.size,
+ fileName: uniqueFileName,
+ originalFileName: originalFileName !== uniqueFileName ? originalFileName : undefined,
+ size: fileSize,
source: 'google-drive',
},
});
diff --git a/packages/workers/src/routes/orgs/pdfs.js b/packages/workers/src/routes/orgs/pdfs.js
index a9c7831e4..55bab1dd0 100644
--- a/packages/workers/src/routes/orgs/pdfs.js
+++ b/packages/workers/src/routes/orgs/pdfs.js
@@ -15,6 +15,9 @@ import {
import { requireOrgWriteAccess } from '../../middleware/requireOrgWriteAccess.js';
import { FILE_SIZE_LIMITS } from '../../config/constants.js';
import { createDomainError, FILE_ERRORS, VALIDATION_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
+import { createDb } from '../../db/client.js';
+import { mediaFiles, user } from '../../db/schema.js';
+import { eq, and } from 'drizzle-orm';
const orgPdfRoutes = new Hono();
@@ -47,6 +50,72 @@ function isValidFileName(fileName) {
return true;
}
+/**
+ * Generate a unique filename by auto-renaming if duplicate exists
+ * @param {string} fileName - Original filename
+ * @param {string} projectId - Project ID
+ * @param {string} studyId - Study ID
+ * @param {DrizzleD1Database} db - Database connection
+ * @returns {Promise} Unique filename
+ */
+export async function generateUniqueFileName(fileName, projectId, studyId, db) {
+ // Check if original filename is available
+ const existing = await db
+ .select({ id: mediaFiles.id })
+ .from(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, projectId),
+ eq(mediaFiles.studyId, studyId),
+ eq(mediaFiles.filename, fileName),
+ ),
+ )
+ .get();
+
+ if (!existing) {
+ return fileName;
+ }
+
+ // Extract name and extension
+ const lastDot = fileName.lastIndexOf('.');
+ const nameWithoutExt = lastDot > 0 ? fileName.slice(0, lastDot) : fileName;
+ const ext = lastDot > 0 ? fileName.slice(lastDot) : '';
+
+ // Try numbered versions: "file (1).pdf", "file (2).pdf", etc.
+ let counter = 1;
+ let uniqueFileName;
+ let found = true;
+
+ while (found && counter < 1000) {
+ uniqueFileName = `${nameWithoutExt} (${counter})${ext}`;
+ const duplicate = await db
+ .select({ id: mediaFiles.id })
+ .from(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, projectId),
+ eq(mediaFiles.studyId, studyId),
+ eq(mediaFiles.filename, uniqueFileName),
+ ),
+ )
+ .get();
+
+ if (!duplicate) {
+ found = false;
+ } else {
+ counter++;
+ }
+ }
+
+ if (found) {
+ // Fallback: use timestamp if we hit the limit
+ const timestamp = Date.now();
+ uniqueFileName = `${nameWithoutExt}_${timestamp}${ext}`;
+ }
+
+ return uniqueFileName;
+}
+
/**
* GET /api/orgs/:orgId/projects/:projectId/studies/:studyId/pdfs
* List PDFs for a study
@@ -55,26 +124,55 @@ orgPdfRoutes.get('/', requireOrgMembership(), requireProjectAccess(), extractStu
const { projectId } = getProjectContext(c);
const studyId = c.get('studyId');
- const prefix = `projects/${projectId}/studies/${studyId}/`;
-
try {
- const listed = await c.env.PDF_BUCKET.list({ prefix });
-
- const pdfs = listed.objects.map(obj => ({
- key: obj.key,
- fileName: obj.key.replace(prefix, ''),
- size: obj.size,
- uploaded: obj.uploaded,
+ const db = createDb(c.env.DB);
+
+ const results = await db
+ .select({
+ id: mediaFiles.id,
+ filename: mediaFiles.filename,
+ originalName: mediaFiles.originalName,
+ fileType: mediaFiles.fileType,
+ fileSize: mediaFiles.fileSize,
+ bucketKey: mediaFiles.bucketKey,
+ createdAt: mediaFiles.createdAt,
+ uploadedBy: mediaFiles.uploadedBy,
+ uploadedByName: user.name,
+ uploadedByEmail: user.email,
+ uploadedByDisplayName: user.displayName,
+ })
+ .from(mediaFiles)
+ .leftJoin(user, eq(mediaFiles.uploadedBy, user.id))
+ .where(and(eq(mediaFiles.projectId, projectId), eq(mediaFiles.studyId, studyId)))
+ .orderBy(mediaFiles.createdAt);
+
+ const pdfs = results.map(row => ({
+ id: row.id,
+ key: row.bucketKey,
+ fileName: row.filename,
+ originalName: row.originalName,
+ size: row.fileSize,
+ fileType: row.fileType,
+ createdAt: row.createdAt,
+ uploadedBy:
+ row.uploadedBy ?
+ {
+ id: row.uploadedBy,
+ name: row.uploadedByName,
+ email: row.uploadedByEmail,
+ displayName: row.uploadedByDisplayName,
+ }
+ : null,
}));
return c.json({ pdfs });
} catch (error) {
console.error('PDF list error:', error);
- const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
operation: 'list_pdfs',
originalError: error.message,
});
- return c.json(systemError, systemError.statusCode);
+ return c.json(dbError, dbError.statusCode);
}
});
@@ -182,16 +280,13 @@ orgPdfRoutes.post(
return c.json(error, error.statusCode);
}
- // Check for duplicate file name
- const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
- const existingFile = await c.env.PDF_BUCKET.head(key);
- if (existingFile) {
- const error = createDomainError(FILE_ERRORS.ALREADY_EXISTS, {
- fileName,
- key,
- });
- return c.json(error, error.statusCode);
- }
+ const orgId = c.get('orgId');
+ const originalFileName = fileName;
+ const db = createDb(c.env.DB);
+
+ // Generate unique filename (auto-rename if duplicate exists)
+ const uniqueFileName = await generateUniqueFileName(fileName, projectId, studyId, db);
+ const key = `projects/${projectId}/studies/${studyId}/${uniqueFileName}`;
// Store in R2
await c.env.PDF_BUCKET.put(key, pdfData, {
@@ -201,16 +296,40 @@ orgPdfRoutes.post(
customMetadata: {
projectId,
studyId,
- fileName,
+ fileName: uniqueFileName,
+ originalFileName: originalFileName !== uniqueFileName ? originalFileName : undefined,
uploadedBy: user.id,
uploadedAt: new Date().toISOString(),
},
});
+ // Insert into mediaFiles table
+ const mediaFileId = crypto.randomUUID();
+ try {
+ await db.insert(mediaFiles).values({
+ id: mediaFileId,
+ filename: uniqueFileName,
+ originalName: originalFileName,
+ fileType: 'application/pdf',
+ fileSize: pdfData.byteLength,
+ uploadedBy: user.id,
+ bucketKey: key,
+ orgId,
+ projectId,
+ studyId,
+ createdAt: new Date(),
+ });
+ } catch (dbError) {
+ // Log error but don't fail the request (R2 object exists, can be cleaned up later)
+ console.error('Failed to insert mediaFiles record after R2 upload:', dbError);
+ }
+
return c.json({
success: true,
+ id: mediaFileId,
key,
- fileName,
+ fileName: uniqueFileName,
+ originalFileName: originalFileName !== uniqueFileName ? originalFileName : undefined,
size: pdfData.byteLength,
});
} catch (error) {
@@ -324,16 +443,59 @@ orgPdfRoutes.delete(
const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
try {
- await c.env.PDF_BUCKET.delete(key);
+ const db = createDb(c.env.DB);
+
+ // Check if record exists in database first
+ const existingRecord = await db
+ .select({ id: mediaFiles.id })
+ .from(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, projectId),
+ eq(mediaFiles.studyId, studyId),
+ eq(mediaFiles.filename, fileName),
+ ),
+ )
+ .get();
+
+ if (!existingRecord) {
+ // Record doesn't exist in database, but try to delete from R2 anyway
+ try {
+ await c.env.PDF_BUCKET.delete(key);
+ } catch (r2Error) {
+ // R2 delete failed, but that's okay - return success since DB record doesn't exist
+ console.warn('PDF not found in database, R2 delete also failed:', r2Error);
+ }
+ return c.json({ success: true });
+ }
+
+ // Delete from mediaFiles table (database is source of truth)
+ await db
+ .delete(mediaFiles)
+ .where(
+ and(
+ eq(mediaFiles.projectId, projectId),
+ eq(mediaFiles.studyId, studyId),
+ eq(mediaFiles.filename, fileName),
+ ),
+ );
+
+ // Delete from R2 (if this fails, log but don't fail - database is source of truth)
+ try {
+ await c.env.PDF_BUCKET.delete(key);
+ } catch (r2Error) {
+ console.error('Failed to delete PDF from R2 after database delete:', r2Error);
+ // Continue - database is source of truth
+ }
+
return c.json({ success: true });
} catch (error) {
console.error('PDF delete error:', error);
- const internalError = createDomainError(
- SYSTEM_ERRORS.INTERNAL_ERROR,
- { operation: 'delete_pdf', originalError: error.message },
- error.message,
- );
- return c.json(internalError, internalError.statusCode);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'delete_pdf',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
}
},
);
diff --git a/packages/workers/src/routes/pdfs.js b/packages/workers/src/routes/pdfs.js
deleted file mode 100644
index 33c28b178..000000000
--- a/packages/workers/src/routes/pdfs.js
+++ /dev/null
@@ -1,370 +0,0 @@
-/**
- * PDF routes for Hono
- * Handle PDF upload/download via R2 storage
- *
- * PDFs are stored with keys: projects/{projectId}/studies/{studyId}/{filename}
- */
-
-import { Hono } from 'hono';
-import { requireAuth, getAuth } from '../middleware/auth.js';
-import { createDb } from '../db/client.js';
-import { projectMembers } from '../db/schema.js';
-import { eq, and } from 'drizzle-orm';
-import { FILE_SIZE_LIMITS } from '../config/constants.js';
-import {
- createDomainError,
- FILE_ERRORS,
- VALIDATION_ERRORS,
- AUTH_ERRORS,
- SYSTEM_ERRORS,
- PROJECT_ERRORS,
-} from '@corates/shared';
-
-const pdfRoutes = new Hono();
-
-// Apply auth middleware to all routes
-pdfRoutes.use('*', requireAuth);
-
-/**
- * Middleware to verify project membership
- */
-async function verifyProjectMembership(c, next) {
- const { user } = getAuth(c);
- const projectId = c.req.param('projectId');
- const studyId = c.req.param('studyId');
-
- if (!projectId || !studyId) {
- const error = createDomainError(VALIDATION_ERRORS.FIELD_REQUIRED, {
- field: 'projectId or studyId',
- });
- return c.json(error, error.statusCode);
- }
-
- // Verify user is a member of this project using Drizzle ORM
- const db = createDb(c.env.DB);
- const membership = await db
- .select({ role: projectMembers.role })
- .from(projectMembers)
- .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, user.id)))
- .get();
-
- if (!membership) {
- const error = createDomainError(PROJECT_ERRORS.ACCESS_DENIED, { projectId });
- return c.json(error, error.statusCode);
- }
-
- c.set('projectId', projectId);
- c.set('studyId', studyId);
- c.set('memberRole', membership.role);
-
- await next();
-}
-
-// Apply membership middleware to all routes
-pdfRoutes.use('*', verifyProjectMembership);
-
-function isValidFileName(fileName) {
- if (!fileName) return false;
- if (fileName.length > 200) return false;
- if (/[\\/]/.test(fileName)) return false;
- if (/\p{C}/u.test(fileName)) return false;
- if (fileName.includes('"')) return false;
- return true;
-}
-
-/**
- * GET /api/projects/:projectId/studies/:studyId/pdfs
- * List PDFs for a study
- */
-pdfRoutes.get('/', async c => {
- const projectId = c.get('projectId');
- const studyId = c.get('studyId');
-
- const prefix = `projects/${projectId}/studies/${studyId}/`;
-
- try {
- const listed = await c.env.PDF_BUCKET.list({ prefix });
-
- const pdfs = listed.objects.map(obj => ({
- key: obj.key,
- fileName: obj.key.replace(prefix, ''),
- size: obj.size,
- uploaded: obj.uploaded,
- }));
-
- return c.json({ pdfs });
- } catch (error) {
- console.error('PDF list error:', error);
- const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
- operation: 'list_pdfs',
- originalError: error.message,
- });
- return c.json(systemError, systemError.statusCode);
- }
-});
-
-/**
- * POST /api/projects/:projectId/studies/:studyId/pdfs
- * Upload a PDF for a study
- */
-pdfRoutes.post('/', async c => {
- const { user } = getAuth(c);
- const projectId = c.get('projectId');
- const studyId = c.get('studyId');
- const memberRole = c.get('memberRole');
-
- // Enforce read-only access for viewers
- if (memberRole === 'viewer') {
- const error = createDomainError(
- AUTH_ERRORS.FORBIDDEN,
- { reason: 'upload_pdf' },
- 'Insufficient permissions',
- );
- return c.json(error, error.statusCode);
- }
-
- // Check Content-Length header first for early rejection
- const contentLength = parseInt(c.req.header('Content-Length') || '0', 10);
- if (contentLength > FILE_SIZE_LIMITS.PDF) {
- const error = createDomainError(
- FILE_ERRORS.TOO_LARGE,
- { fileSize: contentLength, maxSize: FILE_SIZE_LIMITS.PDF },
- `File size exceeds limit of ${FILE_SIZE_LIMITS.PDF / (1024 * 1024)}MB`,
- );
- return c.json(error, error.statusCode);
- }
-
- const contentType = c.req.header('Content-Type') || '';
-
- let pdfData;
- let fileName;
-
- try {
- if (contentType.includes('multipart/form-data')) {
- // Handle multipart form data
- const formData = await c.req.formData();
- const file = formData.get('file');
-
- if (!file || !(file instanceof File)) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_REQUIRED,
- { field: 'file' },
- 'No file provided',
- );
- return c.json(error, error.statusCode);
- }
-
- // Check file size
- if (file.size > FILE_SIZE_LIMITS.PDF) {
- const error = createDomainError(
- FILE_ERRORS.TOO_LARGE,
- { fileSize: file.size, maxSize: FILE_SIZE_LIMITS.PDF },
- `File size (${(file.size / (1024 * 1024)).toFixed(2)}MB) exceeds limit of ${FILE_SIZE_LIMITS.PDF / (1024 * 1024)}MB`,
- );
- return c.json(error, error.statusCode);
- }
-
- fileName = file.name || 'document.pdf';
- pdfData = await file.arrayBuffer();
- } else if (contentType === 'application/pdf') {
- // Handle raw PDF upload
- fileName = c.req.header('X-File-Name') || 'document.pdf';
- pdfData = await c.req.arrayBuffer();
-
- // Check size after reading for raw uploads
- if (pdfData.byteLength > FILE_SIZE_LIMITS.PDF) {
- const error = createDomainError(
- FILE_ERRORS.TOO_LARGE,
- { fileSize: pdfData.byteLength, maxSize: FILE_SIZE_LIMITS.PDF },
- `File size (${(pdfData.byteLength / (1024 * 1024)).toFixed(2)}MB) exceeds limit of ${FILE_SIZE_LIMITS.PDF / (1024 * 1024)}MB`,
- );
- return c.json(error, error.statusCode);
- }
- } else {
- const error = createDomainError(
- FILE_ERRORS.INVALID_TYPE,
- { contentType },
- 'Invalid content type. Expected multipart/form-data or application/pdf',
- );
- return c.json(error, error.statusCode);
- }
-
- if (!isValidFileName(fileName)) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_INVALID_FORMAT,
- { field: 'fileName', value: fileName },
- 'Invalid file name. Avoid quotes, slashes, control characters, and very long names.',
- );
- return c.json(error, error.statusCode);
- }
-
- // Validate it's a PDF (check magic bytes)
- const header = new Uint8Array(pdfData.slice(0, 5));
- const pdfMagic = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
- const isPdf = pdfMagic.every((byte, i) => header[i] === byte);
-
- if (!isPdf) {
- const error = createDomainError(
- FILE_ERRORS.INVALID_TYPE,
- { fileType: 'unknown', expectedType: 'application/pdf' },
- 'File is not a valid PDF',
- );
- return c.json(error, error.statusCode);
- }
-
- // Check for duplicate file name
- const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
- const existingFile = await c.env.PDF_BUCKET.head(key);
- if (existingFile) {
- const error = createDomainError(FILE_ERRORS.ALREADY_EXISTS, {
- fileName,
- key,
- });
- return c.json(error, error.statusCode);
- }
-
- // Store in R2
-
- await c.env.PDF_BUCKET.put(key, pdfData, {
- httpMetadata: {
- contentType: 'application/pdf',
- },
- customMetadata: {
- projectId,
- studyId,
- fileName,
- uploadedBy: user.id,
- uploadedAt: new Date().toISOString(),
- },
- });
-
- return c.json({
- success: true,
- key,
- fileName,
- size: pdfData.byteLength,
- });
- } catch (error) {
- console.error('PDF upload error:', error);
- const uploadError = createDomainError(
- FILE_ERRORS.UPLOAD_FAILED,
- { operation: 'upload_pdf', originalError: error.message },
- error.message,
- );
- return c.json(uploadError, uploadError.statusCode);
- }
-});
-
-/**
- * GET /api/projects/:projectId/studies/:studyId/pdfs/:fileName
- * Download a PDF for a study
- */
-pdfRoutes.get('/:fileName', async c => {
- const projectId = c.get('projectId');
- const studyId = c.get('studyId');
- const fileName = decodeURIComponent(c.req.param('fileName'));
-
- if (!fileName) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_REQUIRED,
- { field: 'fileName' },
- 'Missing file name',
- );
- return c.json(error, error.statusCode);
- }
-
- if (!isValidFileName(fileName)) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_INVALID_FORMAT,
- { field: 'fileName', value: fileName },
- 'Invalid file name',
- );
- return c.json(error, error.statusCode);
- }
-
- const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
-
- try {
- const object = await c.env.PDF_BUCKET.get(key);
-
- if (!object) {
- const error = createDomainError(FILE_ERRORS.NOT_FOUND, { fileName, key });
- return c.json(error, error.statusCode);
- }
-
- return new Response(object.body, {
- headers: {
- 'Content-Type': 'application/pdf',
- 'Content-Disposition': `inline; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(
- fileName,
- )}`,
- 'Cache-Control': 'private, max-age=3600',
- },
- });
- } catch (error) {
- console.error('PDF download error:', error);
- const internalError = createDomainError(
- SYSTEM_ERRORS.INTERNAL_ERROR,
- { operation: 'download_pdf', originalError: error.message },
- error.message,
- );
- return c.json(internalError, internalError.statusCode);
- }
-});
-
-/**
- * DELETE /api/projects/:projectId/studies/:studyId/pdfs/:fileName
- * Delete a PDF for a study
- */
-pdfRoutes.delete('/:fileName', async c => {
- const memberRole = c.get('memberRole');
-
- // Verify user has edit permissions
- if (memberRole === 'viewer') {
- const error = createDomainError(
- AUTH_ERRORS.FORBIDDEN,
- { reason: 'delete_pdf' },
- 'Insufficient permissions',
- );
- return c.json(error, error.statusCode);
- }
-
- const projectId = c.get('projectId');
- const studyId = c.get('studyId');
- const fileName = decodeURIComponent(c.req.param('fileName'));
-
- if (!fileName) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_REQUIRED,
- { field: 'fileName' },
- 'Missing file name',
- );
- return c.json(error, error.statusCode);
- }
-
- if (!isValidFileName(fileName)) {
- const error = createDomainError(
- VALIDATION_ERRORS.FIELD_INVALID_FORMAT,
- { field: 'fileName', value: fileName },
- 'Invalid file name',
- );
- return c.json(error, error.statusCode);
- }
-
- const key = `projects/${projectId}/studies/${studyId}/${fileName}`;
-
- try {
- await c.env.PDF_BUCKET.delete(key);
- return c.json({ success: true });
- } catch (error) {
- console.error('PDF delete error:', error);
- const internalError = createDomainError(
- SYSTEM_ERRORS.INTERNAL_ERROR,
- { operation: 'delete_pdf', originalError: error.message },
- error.message,
- );
- return c.json(internalError, internalError.statusCode);
- }
-});
-
-export { pdfRoutes };