From 7f7cf6e9e4299eae1f4861c32758bd1743996b48 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Mon, 5 Jan 2026 15:42:48 -0600 Subject: [PATCH 1/4] use mediafile table for file uploads --- .../src/components/billing/BillingPage.jsx | 2 +- ...ons.sql => 0000_amusing_black_panther.sql} | 7 +- .../migrations/meta/0000_snapshot.json | 227 ++++++++++++++---- .../workers/migrations/meta/_journal.json | 6 +- packages/workers/src/__tests__/helpers.js | 3 + .../workers/src/__tests__/seed-schemas.js | 3 + packages/workers/src/db/schema.js | 7 + .../workers/src/routes/__tests__/pdfs.test.js | 103 +++++++- packages/workers/src/routes/google-drive.js | 55 ++++- packages/workers/src/routes/orgs/pdfs.js | 222 ++++++++++++++--- 10 files changed, 543 insertions(+), 92 deletions(-) rename packages/workers/migrations/{0000_good_violations.sql => 0000_amusing_black_panther.sql} (96%) diff --git a/packages/web/src/components/billing/BillingPage.jsx b/packages/web/src/components/billing/BillingPage.jsx index c24277969..27b2dbef0 100644 --- a/packages/web/src/components/billing/BillingPage.jsx +++ b/packages/web/src/components/billing/BillingPage.jsx @@ -162,7 +162,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..436f428a2 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": { @@ -107,8 +107,12 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": ["userId"], - "columnsTo": ["id"], + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -186,8 +190,12 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": ["inviterId"], - "columnsTo": ["id"], + "columnsFrom": [ + "inviterId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -195,8 +203,12 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": ["organizationId"], - "columnsTo": ["id"], + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -257,6 +269,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", @@ -272,10 +305,40 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": ["uploadedBy"], - "columnsTo": ["id"], + "columnsFrom": [ + "uploadedBy" + ], + "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": {}, @@ -329,8 +392,12 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": ["userId"], - "columnsTo": ["id"], + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -338,8 +405,12 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": ["organizationId"], - "columnsTo": ["id"], + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -419,7 +490,9 @@ "indexes": { "org_access_grants_stripeCheckoutSessionId_unique": { "name": "org_access_grants_stripeCheckoutSessionId_unique", - "columns": ["stripeCheckoutSessionId"], + "columns": [ + "stripeCheckoutSessionId" + ], "isUnique": true } }, @@ -428,8 +501,12 @@ "name": "org_access_grants_orgId_organization_id_fk", "tableFrom": "org_access_grants", "tableTo": "organization", - "columnsFrom": ["orgId"], - "columnsTo": ["id"], + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -488,7 +565,9 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": ["slug"], + "columns": [ + "slug" + ], "isUnique": true } }, @@ -592,7 +671,9 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": ["token"], + "columns": [ + "token" + ], "isUnique": true } }, @@ -601,8 +682,12 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": ["orgId"], - "columnsTo": ["id"], + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -610,8 +695,12 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": ["projectId"], - "columnsTo": ["id"], + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -619,8 +708,12 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": ["invitedBy"], - "columnsTo": ["id"], + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -676,8 +769,12 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": ["projectId"], - "columnsTo": ["id"], + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -685,8 +782,12 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": ["userId"], - "columnsTo": ["id"], + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -756,8 +857,12 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": ["orgId"], - "columnsTo": ["id"], + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -765,8 +870,12 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": ["createdBy"], - "columnsTo": ["id"], + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -854,7 +963,9 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": ["token"], + "columns": [ + "token" + ], "isUnique": true } }, @@ -863,8 +974,12 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": ["userId"], - "columnsTo": ["id"], + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -872,8 +987,12 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": ["impersonatedBy"], - "columnsTo": ["id"], + "columnsFrom": [ + "impersonatedBy" + ], + "columnsTo": [ + "id" + ], "onDelete": "set null", "onUpdate": "no action" }, @@ -881,8 +1000,12 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": ["activeOrganizationId"], - "columnsTo": ["id"], + "columnsFrom": [ + "activeOrganizationId" + ], + "columnsTo": [ + "id" + ], "onDelete": "set null", "onUpdate": "no action" } @@ -1032,12 +1155,16 @@ "indexes": { "stripe_event_ledger_payloadHash_unique": { "name": "stripe_event_ledger_payloadHash_unique", - "columns": ["payloadHash"], + "columns": [ + "payloadHash" + ], "isUnique": true }, "stripe_event_ledger_stripeEventId_unique": { "name": "stripe_event_ledger_stripeEventId_unique", - "columns": ["stripeEventId"], + "columns": [ + "stripeEventId" + ], "isUnique": true } }, @@ -1233,8 +1360,12 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": ["userId"], - "columnsTo": ["id"], + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -1381,12 +1512,16 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": ["email"], + "columns": [ + "email" + ], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": ["username"], + "columns": [ + "username" + ], "isUnique": true } }, @@ -1460,4 +1595,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json index 64f0ab8a1..2a7d86755 100644 --- a/packages/workers/migrations/meta/_journal.json +++ b/packages/workers/migrations/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "6", - "when": 1767574603970, - "tag": "0000_good_violations", + "when": 1767649024745, + "tag": "0000_amusing_black_panther", "breakpoints": true } ] -} +} \ No newline at end of file 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/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); } }, ); From 74aa45ff55f42f6053b6090355f9cb150034211d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 5 Jan 2026 21:43:29 +0000 Subject: [PATCH 2/4] Apply Prettier formatting --- .../src/components/billing/BillingPage.jsx | 4 +- .../migrations/meta/0000_snapshot.json | 194 +++++------------- .../workers/migrations/meta/_journal.json | 2 +- 3 files changed, 51 insertions(+), 149 deletions(-) diff --git a/packages/web/src/components/billing/BillingPage.jsx b/packages/web/src/components/billing/BillingPage.jsx index 27b2dbef0..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! -

+

Your subscription has been activated!

diff --git a/packages/workers/migrations/meta/0000_snapshot.json b/packages/workers/migrations/meta/0000_snapshot.json index 436f428a2..e36a19168 100644 --- a/packages/workers/migrations/meta/0000_snapshot.json +++ b/packages/workers/migrations/meta/0000_snapshot.json @@ -107,12 +107,8 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -190,12 +186,8 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviterId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -203,12 +195,8 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -305,12 +293,8 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": [ - "uploadedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploadedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -318,12 +302,8 @@ "name": "mediaFiles_orgId_organization_id_fk", "tableFrom": "mediaFiles", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -331,12 +311,8 @@ "name": "mediaFiles_projectId_projects_id_fk", "tableFrom": "mediaFiles", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -392,12 +368,8 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -405,12 +377,8 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -490,9 +458,7 @@ "indexes": { "org_access_grants_stripeCheckoutSessionId_unique": { "name": "org_access_grants_stripeCheckoutSessionId_unique", - "columns": [ - "stripeCheckoutSessionId" - ], + "columns": ["stripeCheckoutSessionId"], "isUnique": true } }, @@ -501,12 +467,8 @@ "name": "org_access_grants_orgId_organization_id_fk", "tableFrom": "org_access_grants", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -565,9 +527,7 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -671,9 +631,7 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -682,12 +640,8 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -695,12 +649,8 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -708,12 +658,8 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": [ - "invitedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitedBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -769,12 +715,8 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -782,12 +724,8 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -857,12 +795,8 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -870,12 +804,8 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": [ - "createdBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -963,9 +893,7 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -974,12 +902,8 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -987,12 +911,8 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "impersonatedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["impersonatedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1000,12 +920,8 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "activeOrganizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["activeOrganizationId"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1155,16 +1071,12 @@ "indexes": { "stripe_event_ledger_payloadHash_unique": { "name": "stripe_event_ledger_payloadHash_unique", - "columns": [ - "payloadHash" - ], + "columns": ["payloadHash"], "isUnique": true }, "stripe_event_ledger_stripeEventId_unique": { "name": "stripe_event_ledger_stripeEventId_unique", - "columns": [ - "stripeEventId" - ], + "columns": ["stripeEventId"], "isUnique": true } }, @@ -1360,12 +1272,8 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1512,16 +1420,12 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": [ - "username" - ], + "columns": ["username"], "isUnique": true } }, @@ -1595,4 +1499,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json index 2a7d86755..7fbdd85b5 100644 --- a/packages/workers/migrations/meta/_journal.json +++ b/packages/workers/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From 8639d05304cd4495ebdda9516a6c4919446abd29 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Mon, 5 Jan 2026 16:11:33 -0600 Subject: [PATCH 3/4] update frontend storage manager --- .../components/admin/StorageManagement.jsx | 7 +- packages/workers/scripts/generate-openapi.mjs | 2 +- packages/workers/src/routes/admin/database.js | 398 +++++++++++++++++- packages/workers/src/routes/admin/storage.js | 28 +- packages/workers/src/routes/pdfs.js | 370 ---------------- 5 files changed, 402 insertions(+), 403 deletions(-) delete mode 100644 packages/workers/src/routes/pdfs.js 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/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/routes/admin/database.js b/packages/workers/src/routes/admin/database.js index 127a81538..572bcdfd2 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,8 @@ 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 +146,21 @@ 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 +181,27 @@ 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 +213,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 +239,363 @@ 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..eb210407b 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,19 @@ 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/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 }; From cc7df8d5af57c8ef5dfbb5f8df0eee7181a9f1e3 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 5 Jan 2026 22:12:13 +0000 Subject: [PATCH 4/4] Apply Prettier formatting --- packages/workers/src/routes/admin/database.js | 42 ++++++++++++++----- packages/workers/src/routes/admin/storage.js | 4 +- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/workers/src/routes/admin/database.js b/packages/workers/src/routes/admin/database.js index 572bcdfd2..f23467e0f 100644 --- a/packages/workers/src/routes/admin/database.js +++ b/packages/workers/src/routes/admin/database.js @@ -61,8 +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()), + filterBy: z + .string() + .optional() + .transform(val => val?.trim()), + filterValue: z + .string() + .optional() + .transform(val => val?.trim()), }), }; @@ -160,7 +166,14 @@ databaseRoutes.get( validateQueryParams(databaseSchemas.tableRows), async c => { const { tableName } = c.req.param(); - const { page, limit, orderBy: orderByParam, order, filterBy, filterValue } = 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, { @@ -183,7 +196,14 @@ databaseRoutes.get( // Special handling for mediaFiles with joins if (tableName === 'mediaFiles') { - return handleMediaFilesQuery(c, { page, limit, orderBy: orderByParam, order, filterBy, filterValue }); + return handleMediaFilesQuery(c, { + page, + limit, + orderBy: orderByParam, + order, + filterBy, + filterValue, + }); } try { @@ -252,7 +272,10 @@ databaseRoutes.get( /** * Handle mediaFiles query with joins for better readability */ -async function handleMediaFilesQuery(c, { page, limit, orderBy: orderByParam, order, filterBy, filterValue }) { +async function handleMediaFilesQuery( + c, + { page, limit, orderBy: orderByParam, order, filterBy, filterValue }, +) { try { const db = createDb(c.env.DB); const offset = (page - 1) * limit; @@ -300,9 +323,7 @@ async function handleMediaFilesQuery(c, { page, limit, orderBy: orderByParam, or } // Get total count with filtering - let countQuery = db - .select({ count: count() }) - .from(mediaFiles); + let countQuery = db.select({ count: count() }).from(mediaFiles); if (whereConditions.length > 0) { countQuery = countQuery.where(and(...whereConditions)); } @@ -580,8 +601,9 @@ databaseRoutes.get( id: row.projectId, name: row.projectName, }, - uploadedBy: row.uploadedBy - ? { + uploadedBy: + row.uploadedBy ? + { id: row.uploadedBy, name: row.uploadedByName, email: row.uploadedByEmail, diff --git a/packages/workers/src/routes/admin/storage.js b/packages/workers/src/routes/admin/storage.js index eb210407b..c15514920 100644 --- a/packages/workers/src/routes/admin/storage.js +++ b/packages/workers/src/routes/admin/storage.js @@ -140,9 +140,7 @@ storageRoutes.get( // 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); + const trackedKeys = await db.select({ bucketKey: mediaFiles.bucketKey }).from(mediaFiles); const trackedKeysSet = new Set(trackedKeys.map(row => row.bucketKey));