Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/web/src/components/admin/StorageManagement.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,8 @@ export default function StorageManagement() {
<div class='mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4'>
<p class='text-sm text-blue-800'>
<strong>Note:</strong> 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.
</p>
</div>

Expand Down Expand Up @@ -351,7 +350,7 @@ export default function StorageManagement() {
<Show when={doc.orphaned}>
<span
class='inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800'
title='Orphaned: Project no longer exists in database'
title='Orphaned: File exists in R2 but is not tracked in mediaFiles database table'
>
Orphaned
</span>
Expand Down
4 changes: 1 addition & 3 deletions packages/web/src/components/billing/BillingPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,7 @@ export default function BillingPage() {
</div>
<div>
<p class='font-semibold text-green-800'>Payment successful!</p>
<p class='text-sm text-green-600'>
Your subscription has been activated. Welcome aboard!
</p>
<p class='text-sm text-green-600'>Your subscription has been activated!</p>
</div>
</div>
</Show>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` (
Expand Down
41 changes: 40 additions & 1 deletion packages/workers/migrations/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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": {},
Expand Down
4 changes: 2 additions & 2 deletions packages/workers/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1767574603970,
"tag": "0000_good_violations",
"when": 1767649024745,
"tag": "0000_amusing_black_panther",
"breakpoints": true
}
]
Expand Down
2 changes: 1 addition & 1 deletion packages/workers/scripts/generate-openapi.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
3 changes: 3 additions & 0 deletions packages/workers/src/__tests__/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down
3 changes: 3 additions & 0 deletions packages/workers/src/__tests__/seed-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
7 changes: 7 additions & 0 deletions packages/workers/src/db/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())`),
});

Expand Down
103 changes: 97 additions & 6 deletions packages/workers/src/routes/__tests__/pdfs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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();
Expand Down
Loading