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 };