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
2 changes: 1 addition & 1 deletion .cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"mcpServers": {}
}
}
1 change: 1 addition & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ We will acknowledge receipt and work to address the issue as quickly as possible
## Scope

This policy applies to:

- The CoRATES web application
- Cloudflare Workers and Durable Objects
- Client-side synchronization and storage logic
Expand Down
16 changes: 8 additions & 8 deletions docs/architecture/diagrams/01-package-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ graph TB

## Package Details

| Package | Purpose | Tech |
| --------- | ----------------------------------- | ------------------------ |
| `web` | Main SolidJS application | SolidJS, Vite, Tailwind |
| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers |
| `landing` | Marketing site (includes web app) | SolidStart |
| `ui` | Shared component library | SolidJS, Zag.js |
| `shared` | Shared error definitions and utilities | TypeScript |
| `mcp` | Development tooling (docs, linting) | Node.js |
| Package | Purpose | Tech |
| --------- | -------------------------------------- | ------------------------ |
| `web` | Main SolidJS application | SolidJS, Vite, Tailwind |
| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers |
| `landing` | Marketing site (includes web app) | SolidStart |
| `ui` | Shared component library | SolidJS, Zag.js |
| `shared` | Shared error definitions and utilities | TypeScript |
| `mcp` | Development tooling (docs, linting) | Node.js |
19 changes: 10 additions & 9 deletions docs/architecture/diagrams/04-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,18 @@ Individual response to a checklist question. Stored entirely in Durable Objects

## Storage Split

| Entity | Storage | Reason |
| ---------------------------- | -------------------------- | ----------------------------------- |
| Users | D1 (SQLite) | User accounts, authentication |
| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control |
| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) |
| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration |
| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
| PDFs | R2 | Large binary files |
| Entity | Storage | Reason |
| ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| Users | D1 (SQLite) | User accounts, authentication |
| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control |
| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) |
| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration |
| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access |
| PDFs | R2 | Large binary files |

**Architecture Notes**:

- **D1** stores project metadata (name, description, etc.) and membership relationships. This is the source of truth for authorization and access control.
- **Durable Objects** store the actual project content (studies, checklists, answers) in a Yjs Document, plus synced copies of metadata and members for real-time collaborative access.
- When a project is created, it's written to D1 first, then metadata is synced to the Durable Object.
Expand Down
28 changes: 14 additions & 14 deletions docs/architecture/diagrams/05-frontend-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ flowchart TD

No authentication required. Redirects to dashboard if already logged in.

| Route | Component | Purpose |
| ----------------- | -------------- | ------------------------- |
| `/signin` | SignIn | Email/password login |
| `/signup` | SignUp | New account creation |
| `/check-email` | CheckEmail | Email verification prompt |
| `/complete-profile` | CompleteProfile | Initial profile setup |
| `/reset-password` | ResetPassword | Password recovery |
| Route | Component | Purpose |
| ------------------- | --------------- | ------------------------- |
| `/signin` | SignIn | Email/password login |
| `/signup` | SignUp | New account creation |
| `/check-email` | CheckEmail | Email verification prompt |
| `/complete-profile` | CompleteProfile | Initial profile setup |
| `/reset-password` | ResetPassword | Password recovery |

### Protected Routes (ProtectedGuard)

Expand All @@ -64,16 +64,16 @@ Requires authentication. Redirects to signin if not logged in.

### Project Routes

| Route | Component | Purpose |
| ------------------------------ | --------------------- | ------------------------------ |
| `/projects/:projectId` | ProjectView | Project overview, studies list |
| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment |
| Route | Component | Purpose |
| -------------------------------------------- | --------------------- | ------------------------------ |
| `/projects/:projectId` | ProjectView | Project overview, studies list |
| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment |
| `/.../reconcile/:checklist1Id/:checklist2Id` | ReconciliationWrapper | Compare two checklists |

### Local Routes

Routes for local-only checklists (no authentication required, stored in IndexedDB).

| Route | Component | Purpose |
| ------------------------ | ------------------ | ---------------------------- |
| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor |
| Route | Component | Purpose |
| ------------------------- | ------------------ | --------------------------- |
| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor |
2 changes: 2 additions & 0 deletions docs/architecture/diagrams/06-api-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Handled by BetterAuth. Includes signin, signup, session management.
### Billing (`/api/billing/*`)

Stripe integration for subscriptions and payments:

- `GET /api/billing/subscription` - Get user subscription
- `POST /api/billing/checkout` - Create Stripe checkout session
- `POST /api/billing/portal` - Create Stripe customer portal session
Expand All @@ -113,6 +114,7 @@ Google Drive integration endpoints for importing documents.
### Database (`/api/db/*`)

Development/diagnostic endpoints:

- `GET /api/db/users` - List users (development only)

### Durable Object Routes
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default [

globals: {
// Browser globals
performance: 'readonly',
ReadableStream: 'readonly',
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"deps:update:latest": "pnpm -r up --latest",
"user:make-admin:prod": "pnpm --filter @corates/workers run user:make-admin:prod",
"user:make-admin:local": "pnpm --filter @corates/workers run user:make-admin:local",
"loc": "bash ./scripts/loc-report.sh",
"loc": "node ./scripts/loc-report.mjs",
"docs": "npx serve ./docs/architecture -l 3020",
"openapi": "pnpm --filter @corates/workers run openapi:generate"
},
Expand Down
17 changes: 17 additions & 0 deletions packages/ui/src/zag/Tooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ export function Tooltip(props) {
placement: props.placement || 'top',
gutter: 8,
strategy: 'fixed', // Use fixed positioning to avoid stacking context issues
flip: true,
// shift: true,
boundary: () => {
// Get the main navbar element to use as a boundary
const navbar = document.querySelector('nav[class*="sticky"]');
if (navbar) {
const navbarRect = navbar.getBoundingClientRect();
const navbarBottom = navbarRect.bottom;
return {
x: 0,
y: navbarBottom,
width: window.innerWidth,
height: window.innerHeight - navbarBottom,
};
}
return 'viewport';
},
},
openDelay: props.openDelay ?? 100,
closeDelay: props.closeDelay ?? 100,
Expand Down
1 change: 1 addition & 0 deletions packages/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

# testing
/coverage
bundle-analysis.html

# production
/build
Expand Down
6 changes: 5 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"deploy": "pnpm run build",
"test:ui": "vitest --ui --ui.port=51234",
"test": "vitest run --reporter=verbose",
"format": "prettier --write ."
"format": "prettier --write .",
"analyze": "vite build --mode analyze && source-map-explorer dist/assets/index-*.js --no-border-checks"
},
"dependencies": {
"@corates/shared": "workspace:*",
Expand All @@ -36,7 +37,10 @@
"jsdom": "^27.3.0",
"prettier": "^3.7.4",
"puppeteer": "^24.34.0",
"rollup-plugin-visualizer": "^6.0.5",
"source-map-explorer": "^2.5.3",
"tailwindcss": "^4.1.18",
"terser": "^5.44.1",
"vite": "^7.3.0",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.16"
Expand Down
20 changes: 10 additions & 10 deletions packages/web/src/api/pdf-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,25 @@ export async function fetchPdfViaProxy(url) {
export async function uploadPdf(projectId, studyId, file, fileName = null) {
const url = `${API_BASE}/api/projects/${projectId}/studies/${studyId}/pdfs`;

let body;
let headers = {};
// Always use FormData for consistency and better browser streaming support
// This works for both File objects and ArrayBuffers (by converting ArrayBuffer to Blob)
const formData = new FormData();

if (file instanceof File) {
const formData = new FormData();
formData.append('file', file);
body = formData;
} else {
// ArrayBuffer
body = file;
headers['Content-Type'] = 'application/pdf';
headers['X-File-Name'] = fileName || 'document.pdf';
// Convert ArrayBuffer to Blob so we can use FormData
const blob = new Blob([file], { type: 'application/pdf' });
const fileObj = new File([blob], fileName || 'document.pdf', { type: 'application/pdf' });
formData.append('file', fileObj);
}

// Don't set Content-Type - browser will set it automatically with boundary for multipart/form-data
// Don't set Content-Length - browser will calculate it automatically for FormData
const response = await fetch(url, {
method: 'POST',
credentials: 'include',
headers,
body,
body: formData,
});

if (!response.ok) {
Expand Down
38 changes: 4 additions & 34 deletions packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js';
import { AMSTAR_CHECKLIST } from '@/AMSTAR2/checklist-map.js';
import { createChecklist as createAMSTAR2Checklist } from '@/AMSTAR2/checklist.js';
import { FaSolidCircleInfo } from 'solid-icons/fa';
import { Tooltip, FloatingPanel } from '@corates/ui';
import { Tooltip } from '@corates/ui';
import NoteEditor from '@checklist-ui/common/NoteEditor.jsx';

export function Question1(props) {
Expand Down Expand Up @@ -695,43 +695,13 @@ function StandardQuestion(props) {
}

function QuestionInfo(props) {
const [showInfo, setShowInfo] = createSignal(false);
const [panelPos, setPanelPos] = createSignal({ x: 0, y: 0 });
let containerRef = () => props.containerRef;

function openInfoPanel() {
if (containerRef()) {
const btnRect = containerRef().getBoundingClientRect();
setPanelPos({
x: btnRect.left + 20,
y: btnRect.top - 20,
});
}
setShowInfo(true);
}

let question = () => props.question.text.split('.')[0] + '. Learn more';

return (
<>
<FloatingPanel
title={question()}
open={showInfo()}
onOpenChange={details => setShowInfo(details.open)}
position={panelPos()}
onPositionChange={pos => setPanelPos(pos.position)}
showMaximize={false}
>
{props.question.info}
</FloatingPanel>
<div class='absolute top-1.5 right-1.5'>
<Tooltip content={question()} placement='top' openDelay={200}>
<button
class='inline-flex cursor-pointer items-center justify-center rounded-full p-1.5 opacity-70 hover:opacity-100 focus:opacity-100 focus:ring-2 focus:ring-blue-500 focus:outline-none'
onClick={openInfoPanel}
>
<Tooltip content={props.question.info} placement='top' openDelay={200}>
<div class='inline-flex items-center justify-center rounded-full p-1.5 opacity-70 hover:opacity-100 focus:opacity-100 focus:ring-2 focus:ring-blue-500 focus:outline-none'>
<FaSolidCircleInfo size={12} />
</button>
</div>
</Tooltip>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@/AMSTAR2/checklist-compare.js';
import ReconciliationQuestionPage from './ReconciliationQuestionPage.jsx';
import SummaryView from './SummaryView.jsx';
import { createChecklist } from '@/AMSTAR2/checklist.js';

export default function ChecklistReconciliation(props) {
// props.checklist1 - First reviewer's checklist data
Expand Down Expand Up @@ -285,7 +286,6 @@ export default function ChecklistReconciliation(props) {
if (!props.updateChecklistAnswer) return;

// Get default empty structure from createChecklist
const { createChecklist } = await import('@/AMSTAR2/checklist.js');
const defaultChecklist = createChecklist({
name: 'temp',
id: 'temp',
Expand Down
42 changes: 37 additions & 5 deletions packages/web/src/stores/projectActionsStore/pdfs.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur

/**
* Delete a PDF from a study (uses active project)
* Ensures cleanup from R2, IndexedDB, and Y.js with proper error handling
*/
async function deletePdfFromStudy(studyId, pdf) {
const projectId = getActiveProjectId();
Expand All @@ -198,12 +199,43 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur
throw new Error('Not connected to project');
}

let r2Deleted = false;

try {
await deletePdf(projectId, studyId, pdf.fileName);
ops.removePdfFromStudy(studyId, pdf.id);
removeCachedPdf(projectId, studyId, pdf.fileName).catch(err =>
console.warn('Failed to remove PDF from cache:', err),
);
// Step 1: Delete from R2 storage first
try {
await deletePdf(projectId, studyId, pdf.fileName);
r2Deleted = true;
} catch (r2Err) {
console.error('Failed to delete PDF from R2:', r2Err);
// Still attempt IndexedDB cleanup even if R2 deletion fails
}

// Step 2: Always attempt IndexedDB cleanup, even if previous step failed
try {
await removeCachedPdf(projectId, studyId, pdf.fileName);
} catch (cacheErr) {
console.warn('Failed to remove PDF from IndexedDB cache:', cacheErr);
// Don't throw - cache cleanup failure shouldn't block the operation
}

// Step 3: Remove from Y.js only if R2 deletion succeeded
// This prevents inconsistencies where PDF exists in R2 but not in Y.js
if (r2Deleted) {
try {
ops.removePdfFromStudy(studyId, pdf.id);
} catch (yjsErr) {
console.error('Failed to remove PDF from Y.js:', yjsErr);
// R2 deletion succeeded but Y.js removal failed - log warning
// The PDF will remain in Y.js but is deleted from R2
throw new Error('PDF deleted from R2 but failed to remove from study');
}
}

// If R2 deletion failed, throw to indicate the operation didn't fully succeed
if (!r2Deleted) {
throw new Error('Failed to delete PDF from R2 storage');
}
} catch (err) {
console.error('Error deleting PDF:', err);
throw err;
Expand Down
Loading