diff --git a/.clauderc b/.clauderc index c3c27c39b..8c70447fa 100644 --- a/.clauderc +++ b/.clauderc @@ -33,27 +33,38 @@ Do not worry about migrations either client side or backend unless specifically ## Libraries -Use Zod for schema and input validation. +Use Zod for schema and input validation (backend). Use Drizzle ORM for database interactions and migrations. Use Better-Auth for authentication and user management. -Use Zag.js for UI components and design system. +Use Ark UI components from `@corates/ui` package (built with Ark UI and some remaining Zag.js components). ## Documentation Tool -PLEASE USE THE CORATES MCP tools to explore local documentation sources. Use this MCP for all Better-Auth, Drizzle, Icons, and Zag documentation. +PLEASE USE THE CORATES MCP tools to explore local documentation sources. Use this MCP for all Better-Auth, Drizzle, Icons, and Ark UI documentation. For comprehensive development guides, see the docs site (`packages/docs/`) - run `pnpm docs` to view. -## Zag.js +## UI Components -Zag component exist in `packages/web/src/components/zag/*` and should be reused, see the README.md in that folder for a list of existing components. BE SURE TO CHECK THAT LIST AND REFERENCE EXISTING COMPONENTS AS WELL AS THE DOCS MCP BEFORE ADDING NEW COMPONENTS AND WHEN DEBUGGING. +UI components are in `@corates/ui` package, built with Ark UI (and some remaining Zag.js components). NOT in local components. + +```js +// CORRECT - Import from @corates/ui +import { Dialog, Select, Toast, showToast, Avatar } from '@corates/ui'; + +// WRONG - Don't import from local components +import { Dialog } from '@/components/zag/Dialog.jsx'; +``` + +See `packages/ui/src/components/index.ts` for all available components. Most components have been migrated to Ark UI. Common ones include Dialog, Select, Toast, Avatar, Tabs, Checkbox, Switch, RadioGroup, Tooltip, Popover, Menu, FileUpload, PasswordInput, and more. ## Additional References -- The style-guide.md file contains additional style and formatting guidelines. +- The [Style Guide](/guides/style-guide) in the docs site contains additional style and formatting guidelines. - See vite.config.js or jsconfig.json for path aliases and build configurations. -- See TESTING.md for testing guidelines and best practices, do NOT add tests unless asked. +- See the [Testing Guide](/guides/testing) in the docs site for testing guidelines and best practices, do NOT add tests unless asked. - Cloudflare Pages is not used in this project; only Cloudflare Workers is used for backend services and frontend deployments. - This project is split into multiple packages under the `packages/` directory. Each package may have its own dependencies and configurations. The landing/marketing site, the main app, and the backend services. - Use the Corates MCP for documentation. +- For comprehensive documentation, see the docs site (`packages/docs/`) - run `pnpm docs` to view guides. ## Database Migrations diff --git a/.cursor/rules/api-routes.mdc b/.cursor/rules/api-routes.mdc index 607b49fa1..635d9c3dd 100644 --- a/.cursor/rules/api-routes.mdc +++ b/.cursor/rules/api-routes.mdc @@ -6,6 +6,9 @@ globs: packages/workers/** # API Route Patterns +> **For comprehensive API development patterns**, see the [API Development Guide](/guides/api-development) in the docs site. +> This file provides quick reference patterns for AI agents. + ## Request Validation **ALWAYS use `validateRequest` middleware** for request body validation: @@ -212,3 +215,9 @@ See these files for reference: - `packages/workers/src/routes/members.js` - Validation, error handling - `packages/workers/src/routes/account-merge.js` - Complex batch operations - `packages/workers/src/config/validation.js` - Schema definitions + +## Related Documentation + +- [API Development Guide](/guides/api-development) - Comprehensive API development patterns +- [Database Guide](/guides/database) - Drizzle ORM patterns and batch operations +- [Error Handling Guide](/guides/error-handling) - Error handling patterns diff --git a/.cursor/rules/checklist-operations.mdc b/.cursor/rules/checklist-operations.mdc new file mode 100644 index 000000000..07a30fe71 --- /dev/null +++ b/.cursor/rules/checklist-operations.mdc @@ -0,0 +1,275 @@ +--- +alwaysApply: false +description: "Checklist-specific patterns for AMSTAR2, ROBINS-I, and checklist operations" +globs: packages/web/src/components/checklist-ui/**, packages/web/src/AMSTAR2/**, packages/web/src/ROBINS-I/**, packages/web/src/lib/checklist-domain.js +--- + +# Checklist Operations Patterns + +> This file provides quick reference patterns for AI agents working with checklist operations. + +## Checklist Types + +### Supported Checklists + +- **AMSTAR2**: 16-question quality assessment +- **ROBINS-I**: Risk of bias assessment +- **Generic**: Framework for custom checklists + +### Checklist Registry + +Checklists are registered in `checklist-registry`: + +```js +import { getChecklistType, getChecklistComponent } from '@/checklist-registry'; + +const type = getChecklistType(checklistId); +const Component = getChecklistComponent(type); +``` + +## Checklist Structure + +### Checklist Data Model + +```js +{ + id: 'uuid', + type: 'AMSTAR2' | 'ROBINS-I' | 'Generic', + title: 'Checklist Title', + assignedTo: userId, + status: 'in_progress' | 'completed' | 'reconciled', + createdAt: timestamp, + updatedAt: timestamp, + answers: { + // Question key => answer data + q1: { answers: [[...]], critical: boolean }, + q2: { answers: [[...]], critical: boolean }, + // ... + } +} +``` + +### Answer Structure + +Answers vary by checklist type: + +```js +// AMSTAR2 answer structure +{ + answers: [ + [true, false, false], // Column 1 options + [false, true, false], // Column 2 options + // ... + ], + critical: boolean // Whether question is critical +} + +// ROBINS-I answer structure +{ + answer: 'Y' | 'PY' | 'PN' | 'N' | 'NI' | 'NA', + comment: string // Optional comment +} +``` + +## Checklist Operations + +### Creating Checklists + +Use `createChecklist` operation: + +```js +const { createChecklist } = useProject(projectId); + +const checklist = await createChecklist(studyId, { + type: 'AMSTAR2', + title: 'Reviewer 1 Assessment', + assignedTo: userId, +}); +``` + +### Updating Checklist Answers + +Use `updateChecklistAnswer` operation: + +```js +const { updateChecklistAnswer } = useProject(projectId); + +await updateChecklistAnswer(studyId, checklistId, 'q1', { + answers: [[true, false], [false, true]], + critical: false, +}); +``` + +### Getting Checklist Data + +Use `getChecklistData` operation: + +```js +const { getChecklistData } = useProject(projectId); + +const checklist = getChecklistData(studyId, checklistId); +// Returns full checklist with answers +``` + +## AMSTAR2 Specific + +### Question Structure + +AMSTAR2 has 16 questions, some with parts: + +- q1-q8: Single-part questions +- q9: Multi-part (q9a, q9b) +- q10: Single-part +- q11: Multi-part (q11a, q11b) +- q12-q16: Single-part questions + +### Answer Matrix + +AMSTAR2 uses answer matrices: + +```js +// Each question has columns (e.g., "Yes", "Partial Yes", "No", "No Meta-analysis") +// Each column has options (checkboxes) +answers: [ + [true, false, false], // Column 1: Yes selected + [false, true, false], // Column 2: Partial Yes selected + [false, false, true], // Column 3: No selected +] +``` + +### Scoring + +Use `scoreChecklist` utility: + +```js +import { scoreChecklist } from '@/AMSTAR2/checklist.js'; + +const score = scoreChecklist(checklist.answers); +// Returns: 'High' | 'Moderate' | 'Low' | 'Critically Low' +``` + +## ROBINS-I Specific + +### Section Structure + +ROBINS-I has sections: + +- **Section B**: Pre-assessment (decide whether to proceed) +- **Domains**: D1-D7 (risk of bias domains) + +### Section B + +Section B determines if assessment should stop: + +```js +import { shouldStopAssessment } from '@/ROBINS-I/checklist.js'; + +const stop = shouldStopAssessment(sectionBState); +// If true, assessment should stop (critical risk of bias) +``` + +### Domain Judgements + +Each domain has a judgement: + +```js +// Domain judgement options +'Low' | 'Moderate' | 'Serious' | 'Critical' | 'No Information' + +// Get domain judgement +const judgement = domain.judgement; +``` + +### Scoring + +Use `scoreChecklist` utility: + +```js +import { scoreChecklist } from '@/ROBINS-I/checklist.js'; + +const score = scoreChecklist(checklistState); +// Returns: 'Low' | 'Moderate' | 'Serious' | 'Critical' +``` + +## Checklist Status + +### Status Values + +```js +const CHECKLIST_STATUS = { + IN_PROGRESS: 'in_progress', + COMPLETED: 'completed', + RECONCILED: 'reconciled', +}; +``` + +### Status Transitions + +- `in_progress` → `completed`: When all questions answered +- `completed` → `reconciled`: When reconciliation is complete + +### Checking Status + +Use `getChecklistStatus` utility: + +```js +import { getChecklistStatus } from '@/lib/checklist-domain.js'; + +const status = getChecklistStatus(checklist); +``` + +## Checklist Notes + +### Question Notes + +Each question can have notes (Y.Text for collaborative editing): + +```js +const { getQuestionNote } = useProject(projectId); + +const note = getQuestionNote(studyId, checklistId, questionKey); +// Returns: Y.Text object +``` + +### Note Editor Component + +Use `NoteEditor` component: + +```jsx +import NoteEditor from '@/components/checklist-ui/common/NoteEditor.jsx'; + + +``` + +## Best Practices + +### DO + +- Use checklist operations from useProject +- Handle multi-part questions correctly (q9, q11) +- Use scoring utilities for AMSTAR2/ROBINS-I +- Check checklist status before operations +- Use NoteEditor for question notes +- Store answers in correct format for checklist type + +### DON'T + +- Don't manually update checklist structure +- Don't mix answer formats between checklist types +- Don't forget to handle multi-part questions +- Don't bypass checklist operations +- Don't store notes outside of Y.Text + +## Examples from Codebase + +- `packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx` - AMSTAR2 checklist component +- `packages/web/src/components/checklist-ui/ROBINSIChecklist/ROBINSIChecklist.jsx` - ROBINS-I checklist component +- `packages/web/src/lib/checklist-domain.js` - Checklist utilities +- `packages/web/src/AMSTAR2/checklist.js` - AMSTAR2 scoring logic +- `packages/web/src/ROBINS-I/checklist.js` - ROBINS-I scoring logic +- `packages/web/src/primitives/useProject/checklists.js` - Checklist operations diff --git a/.cursor/rules/corates.mdc b/.cursor/rules/corates.mdc index 07bdaa2e9..9b8f09a33 100644 --- a/.cursor/rules/corates.mdc +++ b/.cursor/rules/corates.mdc @@ -18,6 +18,7 @@ The project is split into multiple packages under the `packages/` directory: - `/ui`: Shared UI component library built with Ark UI - `/shared`: Shared TypeScript utilities and error definitions - `/mcp`: MCP server for development tools and documentation +- `/docs`: Vitepress docs site containing internal documentation The web package is copied into the landing package during build and deployed as a single site on one worker. @@ -112,10 +113,20 @@ See `solidjs.mdc` for detailed reactivity patterns and examples. ## Documentation +- **Primary source**: Comprehensive guides are in the docs site (`packages/docs/`) - run `pnpm docs` to view - **ALWAYS use Corates MCP tools or other MCP** for Better-Auth, Drizzle, Icons, linting, and Ark UI documentation -- Reference `style-guide.md` for UI styling guidelines -- Reference `docs/error-handling-guide.md` for error handling patterns -- See `TESTING.md` for testing guidelines (do NOT add tests unless asked) +- **For comprehensive documentation**, see the docs site guides: + - [Testing Guide](/guides/testing) - Frontend and backend testing patterns, setup, and best practices + - [Authentication Guide](/guides/authentication) - Setup, configuration, API endpoints, and usage patterns + - [Database Guide](/guides/database) - Schema management, Drizzle ORM patterns, migrations, and test helpers + - [State Management](/guides/state-management) - SolidJS store patterns + - [Primitives](/guides/primitives) - Reusable hooks and primitives + - [Components](/guides/components) - Component development patterns + - [API Development](/guides/api-development) - Backend API route patterns + - [Error Handling](/guides/error-handling) - Error handling patterns + - [Style Guide](/guides/style-guide) - UI styling guidelines + - [Configuration](/guides/configuration) - Configuration files and environment variables + - [Development Workflow](/guides/development-workflow) - Getting started and common tasks ## Specialized Rule Files @@ -126,6 +137,16 @@ For detailed patterns, see: - `ui-components.mdc` - UI component imports and usage - `workers.mdc` - Workers package specific patterns +### Complex Area Rules + +For specific complex areas, see: +- `yjs-sync.mdc` - Yjs synchronization, connection management, sync operations +- `reconciliation.mdc` - Checklist reconciliation, multi-part questions, comparison logic +- `pdf-handling.mdc` - PDF upload, caching, Google Drive integration +- `form-state.mdc` - Form state persistence across OAuth redirects +- `durable-objects.mdc` - Durable Objects patterns for Yjs and WebSocket handling +- `checklist-operations.mdc` - Checklist-specific patterns (AMSTAR2, ROBINS-I) + ## Additional Notes - Cloudflare Pages is NOT used; only Cloudflare Workers diff --git a/.cursor/rules/durable-objects.mdc b/.cursor/rules/durable-objects.mdc new file mode 100644 index 000000000..83b8543c1 --- /dev/null +++ b/.cursor/rules/durable-objects.mdc @@ -0,0 +1,214 @@ +--- +alwaysApply: false +description: "Durable Objects patterns for Yjs synchronization and WebSocket handling" +globs: packages/workers/src/durable-objects/** +--- + +# Durable Objects Patterns + +> This file provides quick reference patterns for AI agents working with Durable Objects in the Workers package. + +## Durable Objects Overview + +Durable Objects provide stateful, single-instance objects for each project. They hold the authoritative Y.Doc and manage WebSocket connections. + +### Available Durable Objects + +- **ProjectDoc**: Manages project Y.Doc and WebSocket connections +- **UserSession**: Manages user session state +- **EmailQueue**: Manages email sending queue + +## ProjectDoc Pattern + +### Y.Doc Structure + +ProjectDoc holds the authoritative Y.Doc: + +```js +// Y.Doc structure in ProjectDoc +{ + meta: Y.Map, // Project metadata + members: Y.Map, // userId => { role, joinedAt } + reviews: Y.Map, // studyId => { + // study data + checklists: Y.Map, // checklistId => checklist data + pdfs: Y.Array, // PDF metadata + reconciliation: Y.Map // reconciliation progress + } +} +``` + +### WebSocket Handling + +ProjectDoc handles WebSocket connections: + +```js +// WebSocket upgrade in fetch() +const upgradeHeader = request.headers.get('Upgrade'); +if (upgradeHeader === 'websocket') { + return await this.handleWebSocket(request); +} +``` + +### Authentication + +WebSocket connections require authentication: + +```js +// Auth is verified before WebSocket upgrade +const { user } = await verifyAuth(request, this.env); +if (!user) { + return new Response('Unauthorized', { status: 401 }); +} +``` + +## Internal Sync Endpoints + +### Internal Requests + +Internal requests (from worker routes) use `X-Internal-Request` header: + +```js +const isInternalRequest = request.headers.get('X-Internal-Request') === 'true'; + +if (isInternalRequest) { + // Handle internal sync endpoints + if (url.pathname === '/sync') { + return await this.handleSync(request); + } +} +``` + +### Sync Endpoints + +- `/sync` - Sync study/checklist data from D1 +- `/sync-member` - Sync member data from D1 +- `/sync-pdf` - Sync PDF metadata from D1 +- `/disconnect-all` - Disconnect all WebSocket clients + +## WebSocket Protocol + +### Message Types + +WebSocket uses y-websocket protocol: + +```js +const messageSync = 0; // Yjs sync messages +const messageAwareness = 1; // Awareness (presence) messages +``` + +### Message Handling + +```js +// Handle sync messages +if (messageType === messageSync) { + // Forward to Y.Doc + syncProtocol.readSyncMessage(message, ws, this.doc, ws); +} + +// Handle awareness messages +if (messageType === messageAwareness) { + // Forward to awareness + awarenessProtocol.applyAwarenessUpdate( + this.awareness, + message, + ws + ); +} +``` + +## Access Control + +### Member Verification + +Verify user is a project member before allowing connection: + +```js +// Check membership +const member = await db + .select() + .from(projectMembers) + .where( + and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.userId, user.id) + ) + ) + .get(); + +if (!member) { + // Access denied - close connection + ws.close(1008, 'Access denied'); + return; +} +``` + +### Access Denied Handling + +When access is denied: + +1. Close WebSocket connection +2. Client receives access denied error +3. Client cleans up local data +4. Client redirects to dashboard + +## Session Management + +### Session Tracking + +Track active WebSocket sessions: + +```js +// Map +this.sessions = new Map(); + +// Add session on connect +this.sessions.set(ws, { user, awarenessClientId }); + +// Remove session on disconnect +this.sessions.delete(ws); +``` + +## Y.Doc Persistence + +### Durable Object Storage + +Y.Doc is stored in Durable Object's persistent storage: + +```js +// Load Y.Doc from storage +const stored = await this.state.storage.get('ydoc'); +if (stored) { + this.doc = Y.decodeStateAsUpdate(stored); +} + +// Save Y.Doc to storage on updates +this.doc.on('update', async (update) => { + await this.state.storage.put('ydoc', Y.encodeStateAsUpdate(this.doc)); +}); +``` + +## Best Practices + +### DO + +- Verify authentication before WebSocket upgrade +- Check membership before allowing connection +- Use internal request header for sync endpoints +- Track sessions for cleanup +- Persist Y.Doc state to storage +- Handle errors gracefully (close connections) + +### DON'T + +- Don't allow unauthenticated WebSocket connections +- Don't bypass membership checks +- Don't forget to clean up sessions on disconnect +- Don't store large binary data in Y.Doc +- Don't expose internal sync endpoints publicly + +## Examples from Codebase + +- `packages/workers/src/durable-objects/ProjectDoc.js` - Main ProjectDoc implementation +- `packages/workers/src/durable-objects/UserSession.js` - User session management +- `packages/workers/src/durable-objects/EmailQueue.js` - Email queue management diff --git a/.cursor/rules/error-handling.mdc b/.cursor/rules/error-handling.mdc index d399a2ab7..011afb1fc 100644 --- a/.cursor/rules/error-handling.mdc +++ b/.cursor/rules/error-handling.mdc @@ -6,7 +6,8 @@ globs: packages/web/**, packages/workers/** # Error Handling Patterns -See `docs/error-handling-guide.md` for comprehensive error handling documentation. +> **For comprehensive error handling documentation**, see the [Error Handling Guide](/guides/error-handling) in the docs site. +> This file provides quick reference patterns for AI agents. ## Backend (Workers) Error Handling @@ -178,3 +179,7 @@ if (isErrorCode(error, 'PROJECT_NOT_FOUND')) { - `packages/web/src/lib/error-utils.js` - Frontend error utilities - `packages/web/src/lib/form-errors.js` - Form error handling - `packages/web/src/components/project-ui/CreateProjectForm.jsx` - Form error usage + +## Related Documentation + +- [Error Handling Guide](/guides/error-handling) - Comprehensive error handling patterns and examples diff --git a/.cursor/rules/form-state.mdc b/.cursor/rules/form-state.mdc new file mode 100644 index 000000000..9f5e67127 --- /dev/null +++ b/.cursor/rules/form-state.mdc @@ -0,0 +1,214 @@ +--- +alwaysApply: false +description: "Form state persistence across OAuth redirects and page navigation" +globs: packages/web/src/lib/formStatePersistence.js, packages/web/src/components/project-ui/AddStudiesForm.jsx, packages/web/src/components/project-ui/CreateProjectForm.jsx +--- + +# Form State Persistence Patterns + +> This file provides quick reference patterns for AI agents working with form state persistence across OAuth redirects. + +## Overview + +Form state persistence saves form data to IndexedDB before OAuth redirects (Google Drive, ORCID) and restores it after the redirect completes. + +## When to Use + +### OAuth Redirects + +Save state before OAuth redirects: + +- Google Drive file picker (requires OAuth) +- ORCID authentication +- Any external authentication flow + +### Form Types + +Supported form types: + +- `'createProject'` - Project creation form +- `'addStudies'` - Add studies form (requires projectId) + +## Saving State + +### Save Before OAuth + +Save state before initiating OAuth flow: + +```js +import { saveFormState } from '@/lib/formStatePersistence.js'; + +// Before OAuth redirect +await saveFormState('addStudies', { + studiesState: studies.getSerializableState(), + activeTab: 'drive', + // ... other serializable state +}, projectId); +``` + +### Serializable State Only + +Only save serializable data (no File objects, ArrayBuffers, functions): + +```js +// CORRECT - Serializable state +await saveFormState('addStudies', { + studiesState: { + pdfs: pdfs.map(p => ({ + name: p.name, + size: p.size, + // Don't include File object or ArrayBuffer + })), + references: references.map(r => ({ text: r.text })), + } +}, projectId); + +// WRONG - Non-serializable data +await saveFormState('addStudies', { + pdfFiles: [file1, file2], // File objects can't be serialized + pdfData: arrayBuffer, // ArrayBuffer can't be serialized +}, projectId); +``` + +## Restoring State + +### Restore After Redirect + +Restore state after OAuth redirect completes: + +```js +import { getFormState, clearFormState, getRestoreParamsFromUrl, clearRestoreParamsFromUrl } from '@/lib/formStatePersistence.js'; + +// Check for restore params in URL +onMount(async () => { + const restoreParams = getRestoreParamsFromUrl(); + if (restoreParams?.type === 'addStudies' && restoreParams.projectId === projectId) { + try { + const savedState = await getFormState('addStudies', projectId); + if (savedState) { + // Restore state + studies.restoreState(savedState.studiesState); + setActiveTab(savedState.activeTab || 'drive'); + + // Clear saved state + await clearFormState('addStudies', projectId); + } + } catch (err) { + console.error('Failed to restore form state:', err); + } + clearRestoreParamsFromUrl(); + } +}); +``` + +### Restore Pattern + +1. Check URL for restore params +2. Get saved state from IndexedDB +3. Restore state to form +4. Clear saved state +5. Clear URL params + +## URL Parameters + +### Restore Params + +Restore params are added to URL after OAuth: + +``` +/project/123/add-studies?restore=addStudies&projectId=123 +``` + +### Reading Params + +Use `getRestoreParamsFromUrl`: + +```js +const params = getRestoreParamsFromUrl(); +// Returns: { type: 'addStudies', projectId: '123' } or null +``` + +### Clearing Params + +Clear params after restore: + +```js +clearRestoreParamsFromUrl(); +// Removes ?restore=... from URL +``` + +## State Serialization + +### Handling Files + +Files must be converted to metadata before saving: + +```js +// Before save - convert Files to metadata +const pdfsMetadata = pdfs.map(pdf => ({ + name: pdf.name, + size: pdf.size, + type: pdf.type, + // Don't include File object +})); + +// After restore - Files are lost, user must re-upload +// Or use pendingPdfs in projectStore for temporary storage +``` + +### Pending PDFs Pattern + +For PDFs during project creation, use `projectStore.setPendingProjectData`: + +```js +// Before OAuth redirect +projectStore.setPendingProjectData(projectId, { + pendingPdfs: pdfFiles, // Store File objects temporarily + pendingRefs: references, +}); + +// After redirect - retrieve from store +const pendingData = projectStore.getPendingProjectData(projectId); +if (pendingData?.pendingPdfs) { + // Process pending PDFs +} +``` + +## Form State Lifecycle + +### Complete Flow + +1. User fills form +2. User clicks "Import from Google Drive" +3. Form state saved to IndexedDB +4. OAuth redirect occurs +5. User authenticates +6. Redirect back to app +7. Form state restored from IndexedDB +8. Form continues from where user left off + +## Best Practices + +### DO + +- Save state before OAuth redirects +- Only save serializable data +- Restore state on mount after redirect +- Clear saved state after restore +- Use pendingPdfs for temporary File storage +- Clear URL params after restore + +### DON'T + +- Don't save File objects or ArrayBuffers +- Don't forget to restore state after redirect +- Don't leave stale state in IndexedDB +- Don't forget to clear URL params +- Don't save functions or non-serializable objects + +## Examples from Codebase + +- `packages/web/src/lib/formStatePersistence.js` - State persistence utilities +- `packages/web/src/components/project-ui/AddStudiesForm.jsx` - Add studies form with state persistence +- `packages/web/src/components/project-ui/CreateProjectForm.jsx` - Project creation with state persistence +- `packages/web/src/components/project-ui/all-studies-tab/AllStudiesTab.jsx` - State restoration example diff --git a/.cursor/rules/pdf-handling.mdc b/.cursor/rules/pdf-handling.mdc new file mode 100644 index 000000000..bb7aca256 --- /dev/null +++ b/.cursor/rules/pdf-handling.mdc @@ -0,0 +1,268 @@ +--- +alwaysApply: false +description: "PDF upload, caching, Google Drive integration, and PDF operations" +globs: packages/web/src/api/pdf-api.js, packages/web/src/primitives/pdfCache.js, packages/web/src/api/google-drive.js, packages/web/src/components/**/pdf/** +--- + +# PDF Handling Patterns + +> This file provides quick reference patterns for AI agents working with PDF operations. + +## PDF Upload + +### Upload Pattern + +Always use `uploadPdf` from `@api/pdf-api.js`: + +```js +import { uploadPdf } from '@api/pdf-api.js'; + +// CORRECT - Use uploadPdf utility +const result = await uploadPdf(file, { + projectId, + studyId, + tag: 'primary', // or 'supplementary' + name: fileName, +}); + +// Returns: { id, url, name, size, tag, uploadedAt } +``` + +### File Validation + +PDF files are validated on the backend. Frontend should check: + +```js +// Basic validation (backend does full validation) +if (!file.type.includes('pdf')) { + throw new Error('File must be a PDF'); +} +if (file.size > MAX_PDF_SIZE) { + throw new Error('File too large'); +} +``` + +## PDF Caching + +### Cache Pattern + +Use `pdfCache` primitive for caching PDFs: + +```js +import { cachePdf, getCachedPdf } from '@primitives/pdfCache.js'; + +// Cache a PDF +await cachePdf(projectId, studyId, pdfId, arrayBuffer); + +// Get cached PDF (returns ArrayBuffer or null) +const cached = await getCachedPdf(projectId, studyId, pdfId); +if (cached) { + // Use cached PDF +} else { + // Download from server +} +``` + +### Cache Strategy + +1. Check cache first +2. If not cached, download from server +3. Cache after download +4. Cache persists in IndexedDB + +```js +// CORRECT - Check cache first +const cached = await getCachedPdf(projectId, studyId, pdfId); +if (cached) { + setPdfData(cached); +} else { + const pdfData = await downloadPdf(projectId, studyId, pdfId); + await cachePdf(projectId, studyId, pdfId, pdfData); + setPdfData(pdfData); +} +``` + +## PDF Storage in Yjs + +### PDF Metadata + +PDF metadata is stored in study's Y.Map: + +```js +// PDF metadata structure in Yjs +study.pdfs = Y.Array([ + { + id: 'uuid', + name: 'filename.pdf', + size: 1234567, + tag: 'primary', // or 'supplementary' + uploadedAt: timestamp, + uploadedBy: userId, + } +]); +``` + +### PDF Binary Data + +PDF binary data is NOT stored in Yjs (too large). Only metadata is in Yjs: + +```js +// CORRECT - Only metadata in Yjs +study.pdfs.push({ + id, name, size, tag, uploadedAt, uploadedBy +}); + +// WRONG - Don't store binary in Yjs +study.pdfs.push({ + id, name, data: arrayBuffer // Too large! +}); +``` + +## Google Drive Integration + +### Importing from Google Drive + +Use `importFromGoogleDrive` utility: + +```js +import { importFromGoogleDrive } from '@api/google-drive.js'; + +// CORRECT - Import from Google Drive +const result = await importFromGoogleDrive({ + fileId: googleDriveFileId, + projectId, + studyId, + tag: 'primary', +}); + +// Returns: { id, name, size, tag, uploadedAt } +``` + +### Google Drive OAuth Flow + +1. User clicks "Import from Google Drive" +2. Opens Google Picker (if not authenticated) +3. User selects file +4. OAuth redirect may occur +5. File is imported after redirect + +### State Persistence During OAuth + +Form state must be saved before OAuth redirect: + +```js +import { saveFormState } from '@/lib/formStatePersistence.js'; + +// Before OAuth redirect +await saveFormState('addStudies', { + studiesState: studies.getSerializableState(), + // ... other state +}, projectId); +``` + +## PDF Operations via Yjs + +### Adding PDF to Study + +Use `addPdfToStudy` operation: + +```js +const { addPdfToStudy } = useProject(projectId); + +await addPdfToStudy(studyId, { + id: pdfId, + name: fileName, + size: fileSize, + tag: 'primary', + uploadedAt: Date.now(), + uploadedBy: userId, +}); +``` + +### Removing PDF from Study + +Use `removePdfFromStudy` operation: + +```js +const { removePdfFromStudy } = useProject(projectId); + +await removePdfFromStudy(studyId, pdfId); +``` + +## PDF Preview + +### PDF Preview Store + +Use `pdfPreviewStore` for preview state: + +```js +import pdfPreviewStore from '@/stores/pdfPreviewStore.js'; + +// Open preview +pdfPreviewStore.openPreview(projectId, studyId, pdfInfo); + +// Close preview +pdfPreviewStore.closePreview(); + +// Get preview state +const preview = pdfPreviewStore.getPreview(); +``` + +### PDF Viewer Component + +Use `PdfViewer` component: + +```jsx +import PdfViewer from '@/components/checklist-ui/pdf/PdfViewer.jsx'; + + {}} +/> +``` + +## PDF Download + +### Download Pattern + +Use `downloadPdf` utility: + +```js +import { downloadPdf } from '@api/pdf-api.js'; + +// CORRECT - Download PDF +const arrayBuffer = await downloadPdf(projectId, studyId, pdfId); + +// Cache after download +await cachePdf(projectId, studyId, pdfId, arrayBuffer); +``` + +## Best Practices + +### DO + +- Cache PDFs after download +- Check cache before downloading +- Store only metadata in Yjs (not binary) +- Use PDF operations from useProject +- Save form state before OAuth redirects +- Use pdfPreviewStore for preview state + +### DON'T + +- Don't store PDF binary data in Yjs +- Don't download PDFs without checking cache +- Don't forget to cache after download +- Don't bypass PDF operations (use useProject) +- Don't lose form state during OAuth redirects + +## Examples from Codebase + +- `packages/web/src/api/pdf-api.js` - PDF API utilities +- `packages/web/src/primitives/pdfCache.js` - PDF caching +- `packages/web/src/api/google-drive.js` - Google Drive integration +- `packages/web/src/components/checklist-ui/pdf/PdfViewer.jsx` - PDF viewer +- `packages/web/src/stores/pdfPreviewStore.js` - Preview state +- `packages/web/src/primitives/useProject/pdfs.js` - PDF operations diff --git a/.cursor/rules/reconciliation.mdc b/.cursor/rules/reconciliation.mdc new file mode 100644 index 000000000..3175b8731 --- /dev/null +++ b/.cursor/rules/reconciliation.mdc @@ -0,0 +1,272 @@ +--- +alwaysApply: false +description: "Checklist reconciliation patterns, multi-part questions, and comparison logic" +globs: packages/web/src/components/checklist-ui/compare/**, packages/web/src/lib/checklist-domain.js, packages/web/src/AMSTAR2/checklist-compare.js +--- + +# Checklist Reconciliation Patterns + +> **For comprehensive reconciliation documentation**, see the reconciliation components and checklist-domain utilities. +> This file provides quick reference patterns for AI agents working with checklist reconciliation. + +## Reconciliation Overview + +Reconciliation allows two reviewers to compare their independent checklist assessments and create a final reconciled version. + +### Reconciliation Structure + +- **checklist1**: First reviewer's checklist +- **checklist2**: Second reviewer's checklist +- **reconciledChecklist**: Third checklist that both reviewers edit (final answer) +- **reconciliation progress**: Metadata stored in study's reconciliation Y.Map + +## Reconciliation Progress Storage + +### Progress Metadata + +Reconciliation progress is stored in the study's Y.Map: + +```js +study.reconciliation = { + checklist1Id: 'uuid-1', + checklist2Id: 'uuid-2', + reconciledChecklistId: 'uuid-3', + currentPage: 0, // Optional: last viewed page +} +``` + +### Final Answers Storage + +**Final answers are stored in the reconciled checklist itself**, not in progress metadata: + +```js +// CORRECT - Final answers are in the reconciled checklist +const reconciledChecklist = getChecklistData(studyId, reconciledChecklistId); +const finalAnswer = reconciledChecklist.answers[questionKey]; + +// WRONG - Don't look for final answers in reconciliation progress +const progress = getReconciliationProgress(studyId); +// progress doesn't contain final answers +``` + +## Multi-Part Questions + +### Questions with Parts + +Questions q9 and q11 have multiple parts (a/b): + +- **q9**: Has q9a and q9b parts +- **q11**: Has q11a and q11b parts + +### Handling Multi-Part Questions + +Use `MultiPartQuestionPage` component for q9 and q11: + +```jsx +// CORRECT - Use MultiPartQuestionPage for multi-part questions +}> + + +``` + +### Multi-Part Answer Structure + +Multi-part answers are stored as objects with part keys: + +```js +// Multi-part answer structure +{ + q9a: { + answers: [[...], [...]], // Answer matrix + critical: boolean + }, + q9b: { + answers: [[...], [...]], + critical: boolean + } +} +``` + +### Comparing Multi-Part Answers + +Use `multiPartAnswersEqual` helper: + +```js +import { multiPartAnswersEqual } from './MultiPartQuestionPage.jsx'; + +const agree = multiPartAnswersEqual(reviewer1Answers, reviewer2Answers); +``` + +## Single-Part Questions + +### Standard Questions + +All other questions (q1-q8, q10, q12-q16) are single-part: + +```jsx +// Use SingleQuestionPage for standard questions + updateFinalAnswer('q1', newAnswer)} +/> +``` + +## Answer Comparison + +### Comparing Checklists + +Use `compareChecklists` utility: + +```js +import { compareChecklists } from '@/AMSTAR2/checklist-compare.js'; + +const comparison = compareChecklists(checklist1, checklist2); +// Returns: { agreements: [...], disagreements: [...] } +``` + +### Agreement Detection + +Check if reviewers agree on a question: + +```js +// For single-part questions +const agree = singleAnswerEqual( + checklist1.answers[questionKey], + checklist2.answers[questionKey] +); + +// For multi-part questions +const agree = multiPartAnswersEqual( + checklist1.answers, // Contains q9a, q9b, etc. + checklist2.answers +); +``` + +## Reconciliation Workflow + +### Starting Reconciliation + +1. Find or create reconciled checklist +2. Load both reviewer checklists +3. Compare answers +4. Display reconciliation UI + +```js +// Find existing reconciled checklist +const reconciled = findReconciledChecklist(study, checklist1Id, checklist2Id); + +// Or create new one +if (!reconciled) { + const newReconciled = await createChecklist({ + studyId, + type: 'AMSTAR2', + title: 'Reconciled', + // ... other fields + }); + + // Save progress + saveReconciliationProgress(studyId, { + checklist1Id, + checklist2Id, + reconciledChecklistId: newReconciled.id, + }); +} +``` + +### Updating Final Answers + +Update final answers in the reconciled checklist: + +```js +// CORRECT - Update reconciled checklist directly +updateChecklistAnswer( + studyId, + reconciledChecklistId, + questionKey, + finalAnswerData +); + +// WRONG - Don't update reconciliation progress with answers +saveReconciliationProgress(studyId, { + finalAnswers: {...} // Don't do this +}); +``` + +### Auto-Fill on Agreement + +When reviewers agree, auto-fill final answer: + +```js +// Auto-fill logic (handled in MultiPartQuestionPage/SingleQuestionPage) +if (reviewersAgree && !hasFinalAnswer) { + // Use reviewer1's answer as starting point + const finalAnswer = reviewer1Answers; + onFinalChange(finalAnswer); +} +``` + +## Notes Reconciliation + +### Question Notes + +Each question can have notes from both reviewers and a final note: + +```js +// Get notes for a question +const reviewer1Note = getQuestionNote(studyId, checklist1Id, questionKey); +const reviewer2Note = getQuestionNote(studyId, checklist2Id, questionKey); +const finalNote = getQuestionNote(studyId, reconciledChecklistId, questionKey); + +// Notes are Y.Text objects for collaborative editing +// Final note is editable by both reviewers +``` + +## Navigation State + +### Page Tracking + +Track current page in reconciliation: + +```js +// Save current page in reconciliation progress +saveReconciliationProgress(studyId, { + checklist1Id, + checklist2Id, + reconciledChecklistId, + currentPage: 5, // Optional +}); + +// Or use localStorage for UI state +localStorage.setItem(`reconciliation-${studyId}`, JSON.stringify({ + currentPage: 5, + viewMode: 'questions' +})); +``` + +## Best Practices + +### DO + +- Store final answers in the reconciled checklist +- Use reconciliation progress only for metadata (IDs, page) +- Handle multi-part questions with MultiPartQuestionPage +- Auto-fill final answers when reviewers agree +- Use comparison utilities for agreement detection + +### DON'T + +- Don't store final answers in reconciliation progress +- Don't manually compare answers (use utilities) +- Don't mix single-part and multi-part question handling +- Don't forget to save reconciliation progress after creating reconciled checklist + +## Examples from Codebase + +- `packages/web/src/components/checklist-ui/compare/ReconciliationWrapper.jsx` - Main reconciliation component +- `packages/web/src/components/checklist-ui/compare/MultiPartQuestionPage.jsx` - Multi-part question handling +- `packages/web/src/components/checklist-ui/compare/ReconciliationQuestionPage.jsx` - Single question page +- `packages/web/src/lib/checklist-domain.js` - Reconciliation utilities +- `packages/web/src/AMSTAR2/checklist-compare.js` - Comparison logic diff --git a/.cursor/rules/solidjs.mdc b/.cursor/rules/solidjs.mdc index 455f4bb55..7218031e5 100644 --- a/.cursor/rules/solidjs.mdc +++ b/.cursor/rules/solidjs.mdc @@ -5,6 +5,9 @@ globs: packages/web/**, packages/landing/** --- # SolidJS Patterns +> **For comprehensive SolidJS patterns**, see the [State Management Guide](/guides/state-management), [Primitives Guide](/guides/primitives), and [Components Guide](/guides/components) in the docs site. +> This file provides quick reference patterns for AI agents. + ## Props - Critical Reactivity Rules **NEVER destructure props** - it breaks reactivity: @@ -231,3 +234,9 @@ See these files for reference: - `packages/web/src/components/checklist-ui/compare/ReconciliationWrapper.jsx` - createMemo, effects - `packages/web/src/stores/projectStore.js` - Store implementation - `packages/web/src/primitives/useProject/index.js` - Primitive pattern + +## Related Documentation + +- [State Management Guide](/guides/state-management) - Comprehensive store patterns and state architecture +- [Primitives Guide](/guides/primitives) - Reusable hooks and primitives patterns +- [Components Guide](/guides/components) - Component development patterns and best practices diff --git a/.cursor/rules/ui-components.mdc b/.cursor/rules/ui-components.mdc index daa0605af..a48955461 100644 --- a/.cursor/rules/ui-components.mdc +++ b/.cursor/rules/ui-components.mdc @@ -6,6 +6,9 @@ globs: packages/web/**, packages/ui/** # UI Components +> **For comprehensive UI component patterns**, see the [Components Guide](/guides/components) and [Style Guide](/guides/style-guide) in the docs site. +> This file provides quick reference patterns for AI agents. + ## Ark UI Components **UI components are in `@corates/ui` package, built with Ark UI (and some remaining Zag.js components). NOT in local components.** @@ -121,3 +124,8 @@ import projectStore from '../../stores/projectStore.js'; - `packages/web/src/components/admin-ui/UserTable.jsx` - Avatar, Dialog usage - `packages/web/src/components/auth-ui/SignIn.jsx` - PasswordInput usage - `packages/ui/README.md` - Component library documentation + +## Related Documentation + +- [Components Guide](/guides/components) - Comprehensive component development patterns +- [Style Guide](/guides/style-guide) - UI/UX guidelines and design system diff --git a/.cursor/rules/workers.mdc b/.cursor/rules/workers.mdc index 75725c32e..bac64727b 100644 --- a/.cursor/rules/workers.mdc +++ b/.cursor/rules/workers.mdc @@ -6,6 +6,9 @@ globs: packages/workers/src/** # Workers Package Rules +> **For comprehensive database patterns**, see the [Database Guide](/guides/database) in the docs site. +> This file provides quick reference patterns for AI agents. + ## Drizzle Transactions **ALWAYS use `db.batch()` for multiple related database operations** to ensure atomicity: @@ -50,3 +53,8 @@ projectRoutes.post('/', async c => { - See `src/routes/account-merge.js` for batch usage - See `src/routes/projects.js` for validation patterns + +## Related Documentation + +- [Database Guide](/guides/database) - Comprehensive Drizzle ORM patterns, migrations, and schema management +- [API Development Guide](/guides/api-development) - API route patterns and validation diff --git a/.cursor/rules/yjs-sync.mdc b/.cursor/rules/yjs-sync.mdc new file mode 100644 index 000000000..6f856191f --- /dev/null +++ b/.cursor/rules/yjs-sync.mdc @@ -0,0 +1,228 @@ +--- +alwaysApply: false +description: "Yjs synchronization patterns, connection management, and sync operations" +globs: packages/web/src/primitives/useProject/**, packages/web/src/stores/projectStore.js, packages/workers/src/durable-objects/ProjectDoc.js +--- + +# Yjs Synchronization Patterns + +> **For comprehensive Yjs documentation**, see the [Yjs Sync Guide](/guides/yjs-sync) in the docs site. +> This file provides quick reference patterns for AI agents working with Yjs synchronization. + +## Connection Management + +### useProject Hook Pattern + +The `useProject` hook manages Yjs connections with reference counting to prevent duplicate connections: + +```js +// CORRECT - Use useProject hook +import useProject from '@/primitives/useProject/index.js'; + +function MyComponent() { + const { studies, connect, disconnect } = useProject(projectId); + + createEffect(() => { + if (projectId) { + connect(); + } + }); + + onCleanup(() => { + disconnect(); + }); +} +``` + +### Connection Registry + +Connections are shared via a global registry with reference counting. Never create Y.Doc instances directly: + +```js +// WRONG - Don't create Y.Doc directly +const ydoc = new Y.Doc(); // This won't be synced + +// CORRECT - Use useProject which manages connections +const { ydoc } = useProject(projectId); +``` + +### Connection States + +Check connection state from the store: + +```js +import projectStore from '@/stores/projectStore.js'; + +const connectionState = () => projectStore.getConnectionState(projectId); +const connected = () => connectionState().connected; +const synced = () => connectionState().synced; +const error = () => connectionState().error; +``` + +## Y.Doc Structure + +### Project Data Hierarchy + +Y.Doc structure in ProjectDoc (Durable Object): + +``` +Project (Y.Doc) + - meta: Y.Map (project metadata) + - members: Y.Map (userId => { role, joinedAt }) + - reviews: Y.Map (studyId => { + id, name, description, createdAt, updatedAt, + checklists: Y.Map (checklistId => { + id, title, assignedTo, status, createdAt, updatedAt, + answers: Y.Map (questionKey => { value, notes, updatedAt, updatedBy }) + }), + pdfs: Y.Array (PDF metadata), + reconciliation: Y.Map (reconciliation progress) + }) +``` + +### Accessing Y.Doc Data + +Always access Y.Doc through the hook, never directly: + +```js +// CORRECT - Use hook operations +const { getChecklistData, updateChecklistAnswer } = useProject(projectId); +const checklist = getChecklistData(studyId, checklistId); + +// WRONG - Don't access Y.Doc directly +const ydoc = getYDoc(); +const studiesMap = ydoc.getMap('reviews'); // This breaks encapsulation +``` + +## Sync Operations + +### Reading from Store (After Sync) + +Yjs updates are automatically synced to `projectStore`. Read from the store, not Y.Doc: + +```js +// CORRECT - Read from store (synced from Y.Doc) +const studies = () => projectStore.getStudies(projectId); +const checklist = () => projectStore.getChecklist(projectId, studyId, checklistId); + +// WRONG - Don't read directly from Y.Doc +const ydoc = getYDoc(); +const studiesMap = ydoc.getMap('reviews'); // Use store instead +``` + +### Writing via Operations + +Use operation functions from `useProject` or `projectActionsStore`: + +```js +// CORRECT - Use operations +const { updateChecklistAnswer } = useProject(projectId); +updateChecklistAnswer(studyId, checklistId, questionKey, answerData); + +// Or via actions store +import projectActionsStore from '@/stores/projectActionsStore'; +projectActionsStore.checklist.updateAnswer(studyId, checklistId, questionKey, answerData); +``` + +### Sync Manager Pattern + +The sync manager converts Y.Doc state to store format: + +```js +// Sync happens automatically via ydoc.on('update') +// Sync manager converts Y.Map/Y.Array to plain objects for the store +// Never manually call syncFromYDoc() - it's handled internally +``` + +## Local Projects + +### Local-Only Projects + +Projects with IDs starting with `local-` skip WebSocket connection: + +```js +const isLocalProject = () => projectId && projectId.startsWith('local-'); + +// Local projects: +// - Use IndexedDB persistence only +// - No WebSocket connection +// - Still use Y.Doc for structure +// - Sync to store normally +``` + +## IndexedDB Persistence + +### Persistence Setup + +IndexedDB persistence is handled automatically by `useProject`: + +```js +// Persistence is set up automatically +// Database name: `corates-project-${projectId}` +// Don't manually create IndexedDB providers +``` + +### Persistence Lifecycle + +- Persistence is created when connection is established +- Data is loaded from IndexedDB on connection +- Changes are persisted automatically +- Cleanup happens on disconnect (when refCount reaches 0) + +## WebSocket Connection + +### Connection Manager + +WebSocket connections are managed by `createConnectionManager`: + +```js +// Connection is established automatically for non-local projects +// Handles: +// - Reconnection with exponential backoff +// - Access denied errors +// - Sync status updates +// - Online/offline detection +``` + +### Access Denied Handling + +When access is denied (user removed, project deleted): + +```js +// Access denied triggers cleanup: +// 1. All local IndexedDB data is cleared +// 2. Connection is closed +// 3. Store is cleared +// 4. User is redirected to dashboard +``` + +## Best Practices + +### DO + +- Use `useProject` hook for all Yjs operations +- Read from `projectStore` after sync +- Use operation functions for writes +- Let the connection registry manage connections +- Handle connection states reactively + +### DON'T + +- Don't create Y.Doc instances directly +- Don't access Y.Doc maps/arrays directly +- Don't manually call sync functions +- Don't bypass the connection registry +- Don't store Y.Doc references in components + +## Examples from Codebase + +- `packages/web/src/primitives/useProject/index.js` - Connection management +- `packages/web/src/primitives/useProject/sync.js` - Sync manager +- `packages/web/src/primitives/useProject/connection.js` - WebSocket connection +- `packages/workers/src/durable-objects/ProjectDoc.js` - Server-side Y.Doc +- `packages/web/src/stores/projectStore.js` - Store sync target + +## Related Documentation + +- [Yjs Sync Guide](/guides/yjs-sync) - Comprehensive Yjs synchronization patterns +- [State Management Guide](/guides/state-management) - Store patterns that receive Yjs updates diff --git a/.github/Contributing.md b/.github/Contributing.md index 41655026d..94b3f8cb1 100644 --- a/.github/Contributing.md +++ b/.github/Contributing.md @@ -156,7 +156,7 @@ See [style-guide.md](style-guide.md) for detailed conventions. pnpm run docs ``` -This serves the architecture documentation at http://localhost:3020. +This serves the architecture documentation at http://localhost:8080. Note: these docs are not the Open API docs which are served by the Workers backend. ## Code of Conduct diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b14ae3bee..1f121c163 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,24 +36,35 @@ Do not worry about migrations either client side or backend unless specifically Use Zod for schema and input validation (backend). Use Drizzle ORM for database interactions and migrations. Use Better-Auth for authentication and user management. -Use Zag.js for UI components and design system. +Use Ark UI components from `@corates/ui` package (built with Ark UI and some remaining Zag.js components). ## Documentation Tool -PLEASE USE THE CORATES MCP tools to explore local documentation sources. Use this MCP for all Better-Auth, Drizzle, Icons, and Zag documentation. For linting, prefer using the MCP. +PLEASE USE THE CORATES MCP tools to explore local documentation sources. Use this MCP for all Better-Auth, Drizzle, Icons, and Ark UI documentation. For comprehensive development guides, see the docs site (`packages/docs/`) - run `pnpm docs` to view. For linting, prefer using the MCP. -## Zag.js +## UI Components -Zag component exist in `packages/web/src/components/zag/*` and should be reused, see the README.md in that folder for a list of existing components. BE SURE TO CHECK THAT LIST AND REFERENCE EXISTING COMPONENTS AS WELL AS THE DOCS MCP BEFORE ADDING NEW COMPONENTS AND WHEN DEBUGGING. +UI components are in `@corates/ui` package, built with Ark UI (and some remaining Zag.js components). NOT in local components. + +```js +// CORRECT - Import from @corates/ui +import { Dialog, Select, Toast, showToast, Avatar } from '@corates/ui'; + +// WRONG - Don't import from local components +import { Dialog } from '@/components/zag/Dialog.jsx'; +``` + +See `packages/ui/src/components/index.ts` for all available components. Most components have been migrated to Ark UI. Common ones include Dialog, Select, Toast, Avatar, Tabs, Checkbox, Switch, RadioGroup, Tooltip, Popover, Menu, FileUpload, PasswordInput, and more. ## Additional References -- The style-guide.md file contains additional style and formatting guidelines. +- The [Style Guide](/guides/style-guide) in the docs site contains additional style and formatting guidelines. - See vite.config.js or jsconfig.json for path aliases and build configurations. -- See TESTING.md for testing guidelines and best practices, do NOT add tests unless asked. +- See the [Testing Guide](/guides/testing) in the docs site for testing guidelines and best practices, do NOT add tests unless asked. - Cloudflare Pages is not used in this project; only Cloudflare Workers is used for backend services and frontend deployments. - This project is split into multiple packages under the `packages/` directory. Each package may have its own dependencies and configurations. The landing/marketing site, the main app, and the backend services. - Use the Corates MCP for documentation. +- For comprehensive documentation, see the docs site (`packages/docs/`) - run `pnpm docs` to view guides. ## Database Migrations diff --git a/docs/architecture/diagrams.md b/docs/architecture/diagrams.md deleted file mode 100644 index a4b373d35..000000000 --- a/docs/architecture/diagrams.md +++ /dev/null @@ -1,21 +0,0 @@ -# CoRATES Architecture Diagrams - -Mermaid diagrams to help new contributors understand the CoRATES application architecture. - -## How to View - -- **VS Code**: Open any `.md` file and use Markdown preview (Cmd+Shift+V) -- **Browser**: Open [viewer.html](viewer.html) for a rendered view of all diagrams -- **GitHub**: Diagrams render automatically in the GitHub UI - -## Diagrams - -| # | Diagram | Description | -| --- | ----------------------------------------------------------- | -------------------------------------------------------------------- | -| 1 | [Package Architecture](diagrams/01-package-architecture.md) | Monorepo structure and package relationships | -| 2 | [System Architecture](diagrams/02-system-architecture.md) | Frontend, backend, and storage layers | -| 3 | [Sync Flow](diagrams/03-sync-flow.md) | Local-first CRDT sync with Yjs | -| 4 | [Data Model](diagrams/04-data-model.md) | Entity relationships and storage | -| 5 | [Frontend Routes](diagrams/05-frontend-routes.md) | Application routing structure | -| 6 | [API Routes](diagrams/06-api-routes.md) | Backend endpoints and middleware | -| 7 | [API Actions](diagrams/07-api-actions.md) | All actions and failure points for projects, studies, and checklists | diff --git a/docs/architecture/index.html b/docs/architecture/index.html deleted file mode 100644 index 8b154d823..000000000 --- a/docs/architecture/index.html +++ /dev/null @@ -1,262 +0,0 @@ - - - - - - CoRATES Architecture Diagrams - - - - - -

CoRATES Architecture Diagrams

- - - -
-
-
- - diff --git a/eslint.config.js b/eslint.config.js index fe24ab9ea..87d1decb6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,8 +1,6 @@ import js from '@eslint/js'; import solid from 'eslint-plugin-solid/configs/recommended'; import * as tsParser from '@typescript-eslint/parser'; -// import eslintPluginUnicorn from 'eslint-plugin-unicorn'; -// import sonarjs from 'eslint-plugin-sonarjs'; export default [ js.configs.recommended, @@ -176,6 +174,8 @@ export default [ '**/.output/**', '**/coverage/**', 'packages/learn/.astro/**', + '**/.vitepress/cache/**', + '**/.vitepress/dist/**', ], }, ]; diff --git a/package.json b/package.json index 62862bbd5..d4b5e8fec 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "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": "node ./scripts/loc-report.mjs", - "docs": "npx serve ./docs/architecture -l 3020", + "docs": "pnpm --filter @corates/docs dev", + "docs:build": "pnpm --filter @corates/docs build", + "docs:preview": "pnpm --filter @corates/docs preview", "openapi": "pnpm --filter @corates/workers run openapi:generate" }, "keywords": [ diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore new file mode 100644 index 000000000..cb536060e --- /dev/null +++ b/packages/docs/.gitignore @@ -0,0 +1,3 @@ +.vitepress/dist +.vitepress/cache +node_modules diff --git a/packages/docs/.vitepress/config.js b/packages/docs/.vitepress/config.js new file mode 100644 index 000000000..8c3f368db --- /dev/null +++ b/packages/docs/.vitepress/config.js @@ -0,0 +1,161 @@ +import { withMermaid } from 'vitepress-plugin-mermaid'; + +export default withMermaid({ + title: 'CoRATES Documentation', + description: 'Collaborative Research Appraisal Tool for Evidence Synthesis - Documentation', + base: '/', + + themeConfig: { + nav: [ + { text: 'Home', link: '/' }, + { text: 'Architecture', link: '/architecture/' }, + { text: 'Guides', link: '/guides/' }, + ], + + sidebar: { + '/': [ + { + text: 'Getting Started', + items: [{ text: 'Home', link: '/' }], + }, + { + text: 'Architecture', + items: [ + { text: 'Overview', link: '/architecture/' }, + { + text: 'Package Architecture', + link: '/architecture/diagrams/01-package-architecture', + }, + { text: 'System Architecture', link: '/architecture/diagrams/02-system-architecture' }, + { text: 'Sync Flow', link: '/architecture/diagrams/03-sync-flow' }, + { text: 'Data Model', link: '/architecture/diagrams/04-data-model' }, + { text: 'Frontend Routes', link: '/architecture/diagrams/05-frontend-routes' }, + { text: 'API Routes', link: '/architecture/diagrams/06-api-routes' }, + { text: 'API Actions', link: '/architecture/diagrams/07-api-actions' }, + { text: 'Yjs Sync', link: '/architecture/diagrams/08-yjs-sync' }, + ], + }, + { + text: 'Guides', + items: [ + { text: 'Overview', link: '/guides/' }, + { + text: 'Core Development', + items: [ + { text: 'State Management', link: '/guides/state-management' }, + { text: 'Primitives', link: '/guides/primitives' }, + { text: 'Components', link: '/guides/components' }, + { text: 'API Development', link: '/guides/api-development' }, + ], + }, + { + text: 'System-Specific', + items: [ + { text: 'Authentication', link: '/guides/authentication' }, + { text: 'Yjs Sync', link: '/guides/yjs-sync' }, + { text: 'Database', link: '/guides/database' }, + ], + }, + { + text: 'Supporting', + items: [ + { text: 'Configuration', link: '/guides/configuration' }, + { text: 'Testing', link: '/guides/testing' }, + { text: 'Development Workflow', link: '/guides/development-workflow' }, + { text: 'Error Handling', link: '/guides/error-handling' }, + { text: 'Style Guide', link: '/guides/style-guide' }, + ], + }, + ], + }, + ], + '/architecture/': [ + { + text: 'Architecture', + items: [ + { text: 'Overview', link: '/architecture/' }, + { + text: 'Package Architecture', + link: '/architecture/diagrams/01-package-architecture', + }, + { text: 'System Architecture', link: '/architecture/diagrams/02-system-architecture' }, + { text: 'Sync Flow', link: '/architecture/diagrams/03-sync-flow' }, + { text: 'Data Model', link: '/architecture/diagrams/04-data-model' }, + { text: 'Frontend Routes', link: '/architecture/diagrams/05-frontend-routes' }, + { text: 'API Routes', link: '/architecture/diagrams/06-api-routes' }, + { text: 'API Actions', link: '/architecture/diagrams/07-api-actions' }, + { text: 'Yjs Sync', link: '/architecture/diagrams/08-yjs-sync' }, + ], + }, + ], + '/guides/': [ + { + text: 'Guides', + items: [ + { text: 'Overview', link: '/guides/' }, + { + text: 'Core Development', + items: [ + { text: 'State Management', link: '/guides/state-management' }, + { text: 'Primitives', link: '/guides/primitives' }, + { text: 'Components', link: '/guides/components' }, + { text: 'API Development', link: '/guides/api-development' }, + ], + }, + { + text: 'System-Specific', + items: [ + { text: 'Authentication', link: '/guides/authentication' }, + { text: 'Yjs Sync', link: '/guides/yjs-sync' }, + { text: 'Database', link: '/guides/database' }, + ], + }, + { + text: 'Supporting', + items: [ + { text: 'Configuration', link: '/guides/configuration' }, + { text: 'Testing', link: '/guides/testing' }, + { text: 'Development Workflow', link: '/guides/development-workflow' }, + { text: 'Error Handling', link: '/guides/error-handling' }, + { text: 'Style Guide', link: '/guides/style-guide' }, + ], + }, + ], + }, + ], + }, + + search: { + provider: 'local', + }, + + // Uncomment and update when GitHub repository is available + // socialLinks: [ + // { icon: 'github', link: 'https://github.com/yourusername/corates' }, + // ], + // + // editLink: { + // pattern: 'https://github.com/yourusername/corates/edit/main/packages/docs/:path', + // text: 'Edit this page on GitHub', + // }, + }, + + markdown: { + theme: { + light: 'github-light', + dark: 'github-dark', + }, + }, + + vite: { + optimizeDeps: { + include: ['mermaid', 'dayjs'], + }, + ssr: { + noExternal: ['mermaid'], + }, + define: { + 'import.meta.vitest': 'undefined', + }, + }, +}); diff --git a/packages/docs/README.md b/packages/docs/README.md new file mode 100644 index 000000000..145f7890b --- /dev/null +++ b/packages/docs/README.md @@ -0,0 +1,39 @@ +# CoRATES Documentation + +This package contains the VitePress documentation site for CoRATES. + +## Development + +```bash +# Start dev server +pnpm dev + +# Build for production +pnpm build + +# Preview production build +pnpm preview +``` + +Or from the root: + +```bash +pnpm docs +pnpm docs:build +pnpm docs:preview +``` + +## Structure + +- `index.md` - Home page +- `architecture/` - Architecture diagrams and documentation +- `guides/` - Development guides (error handling, style guide, etc.) + +## Content + +Documentation is written in Markdown with support for: + +- Mermaid diagrams +- Code syntax highlighting +- Vue components (if needed) +- Full VitePress features diff --git a/docs/architecture/diagrams/01-package-architecture.md b/packages/docs/architecture/diagrams/01-package-architecture.md similarity index 100% rename from docs/architecture/diagrams/01-package-architecture.md rename to packages/docs/architecture/diagrams/01-package-architecture.md diff --git a/docs/architecture/diagrams/02-system-architecture.md b/packages/docs/architecture/diagrams/02-system-architecture.md similarity index 100% rename from docs/architecture/diagrams/02-system-architecture.md rename to packages/docs/architecture/diagrams/02-system-architecture.md diff --git a/docs/architecture/diagrams/03-sync-flow.md b/packages/docs/architecture/diagrams/03-sync-flow.md similarity index 100% rename from docs/architecture/diagrams/03-sync-flow.md rename to packages/docs/architecture/diagrams/03-sync-flow.md diff --git a/docs/architecture/diagrams/04-data-model.md b/packages/docs/architecture/diagrams/04-data-model.md similarity index 100% rename from docs/architecture/diagrams/04-data-model.md rename to packages/docs/architecture/diagrams/04-data-model.md diff --git a/docs/architecture/diagrams/05-frontend-routes.md b/packages/docs/architecture/diagrams/05-frontend-routes.md similarity index 100% rename from docs/architecture/diagrams/05-frontend-routes.md rename to packages/docs/architecture/diagrams/05-frontend-routes.md diff --git a/docs/architecture/diagrams/06-api-routes.md b/packages/docs/architecture/diagrams/06-api-routes.md similarity index 100% rename from docs/architecture/diagrams/06-api-routes.md rename to packages/docs/architecture/diagrams/06-api-routes.md diff --git a/docs/architecture/diagrams/07-api-actions.md b/packages/docs/architecture/diagrams/07-api-actions.md similarity index 100% rename from docs/architecture/diagrams/07-api-actions.md rename to packages/docs/architecture/diagrams/07-api-actions.md diff --git a/docs/architecture/diagrams/08-yjs-sync.md b/packages/docs/architecture/diagrams/08-yjs-sync.md similarity index 100% rename from docs/architecture/diagrams/08-yjs-sync.md rename to packages/docs/architecture/diagrams/08-yjs-sync.md diff --git a/packages/docs/architecture/index.md b/packages/docs/architecture/index.md new file mode 100644 index 000000000..e29b51e51 --- /dev/null +++ b/packages/docs/architecture/index.md @@ -0,0 +1,55 @@ +# Architecture Documentation + +This section contains architecture diagrams and documentation to help developers understand the CoRATES system structure. + +## Diagrams + +Mermaid diagrams to help new contributors understand the CoRATES application architecture. + +### Package Architecture + +Overview of the monorepo structure and how packages relate to each other. + +[View Diagram →](/architecture/diagrams/01-package-architecture) + +### System Architecture + +How the frontend, backend, and storage layers connect. + +[View Diagram →](/architecture/diagrams/02-system-architecture) + +### Sync Flow + +Local-first CRDT sync with Yjs. + +[View Diagram →](/architecture/diagrams/03-sync-flow) + +### Data Model + +Entity relationships and storage. + +[View Diagram →](/architecture/diagrams/04-data-model) + +### Frontend Routes + +Application routing structure. + +[View Diagram →](/architecture/diagrams/05-frontend-routes) + +### API Routes + +Backend endpoints and middleware. + +[View Diagram →](/architecture/diagrams/06-api-routes) + +### API Actions + +All actions and failure points for projects, studies, and checklists. + +[View Diagram →](/architecture/diagrams/07-api-actions) + +### Yjs Sync + +Yjs synchronization details. + +[View Diagram →](/architecture/diagrams/08-yjs-sync) diff --git a/packages/docs/guides/api-development.md b/packages/docs/guides/api-development.md new file mode 100644 index 000000000..0e4f970ce --- /dev/null +++ b/packages/docs/guides/api-development.md @@ -0,0 +1,596 @@ +# API Development Guide + +This guide covers how to develop API routes in the CoRATES Workers backend, including validation, database operations, error handling, and middleware patterns. + +## Overview + +API routes in CoRATES are built with Hono, use Zod for validation, Drizzle ORM for database operations, and follow consistent patterns for authentication, authorization, and error handling. + +## Route Structure + +### Basic Route Pattern + +```js +import { Hono } from 'hono'; +import { requireAuth, getAuth } from '../middleware/auth.js'; +import { validateRequest, projectSchemas } from '../config/validation.js'; +import { createDomainError, PROJECT_ERRORS, SYSTEM_ERRORS } from '@corates/shared'; +import { createDb } from '../db/client.js'; + +const routes = new Hono(); + +// Apply middleware +routes.use('*', requireAuth); + +// Route handler +routes.post('/', validateRequest(projectSchemas.create), async c => { + const { user } = getAuth(c); + const db = createDb(c.env.DB); + const data = c.get('validatedBody'); + + try { + // Database operations + const result = await db.insert(projects).values({ + id: crypto.randomUUID(), + name: data.name, + description: data.description, + createdBy: user.id, + }); + + return c.json(result); + } catch (error) { + // Error handling + console.error('Error creating project:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, { + operation: 'create_project', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); + } +}); + +export { routes as projectRoutes }; +``` + +### Middleware Chain Order + +Order matters when applying middleware: + +1. **Authentication** (`requireAuth`) - Verify user is logged in +2. **Authorization** (`requireEntitlement`, `requireQuota`) - Check permissions +3. **Validation** (`validateRequest`, `validateQueryParams`) - Validate input +4. **Route handler** - Business logic + +```js +routes.post( + '/', + requireAuth, + requireEntitlement('project.create'), + requireQuota('projects.max', getProjectCount, 1), + validateRequest(projectSchemas.create), + async c => { + // Handler + }, +); +``` + +## Request Validation + +### Using validateRequest Middleware + +**Always use `validateRequest` middleware for request body validation:** + +```js +import { validateRequest, projectSchemas } from '../config/validation.js'; + +// CORRECT +routes.post('/', validateRequest(projectSchemas.create), async c => { + const data = c.get('validatedBody'); // Already validated + // Use validated data +}); + +// WRONG - Manual validation +routes.post('/', async c => { + const body = await c.req.json(); + // Don't manually validate - use middleware +}); +``` + +### Adding New Schemas + +Add schemas to `src/config/validation.js` and reuse `commonFields`: + +```js +import { z } from 'zod/v4'; +import { commonFields } from '../config/validation.js'; + +export const mySchemas = { + create: z.object({ + name: commonFields.nonEmptyString, + email: commonFields.email, + // custom fields + age: z.number().int().positive(), + }), + + update: z.object({ + name: z.string().min(1).optional(), + email: commonFields.email.optional(), + }), +}; +``` + +### Query Parameter Validation + +Use `validateQueryParams` for query strings: + +```js +import { validateQueryParams } from '../config/validation.js'; + +const querySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), +}); + +routes.get('/', validateQueryParams(querySchema), async c => { + const { page, limit } = c.get('validatedQuery'); + // Use validated query params +}); +``` + +### Validation Error Handling + +Validation middleware automatically creates validation errors using the shared error system: + +```290:316:packages/workers/src/config/validation.js +export function validateRequest(schema) { + return async (c, next) => { + try { + const body = await c.req.json(); + const result = validateBody(schema, body); + + if (!result.success) { + // result.error is already a DomainError from createValidationError/createMultiFieldValidationError + return c.json(result.error, result.error.statusCode); + } + + // Attach validated data to context + c.set('validatedBody', result.data); + await next(); + } catch (error) { + console.warn('Body validation error:', error.message); + // Invalid JSON - create validation error + const invalidJsonError = createValidationError( + 'body', + 'VALIDATION_INVALID_INPUT', + null, + 'invalid_json', + ); + return c.json(invalidJsonError, invalidJsonError.statusCode); + } + }; +} +``` + +## Database Operations + +### Database Client + +Always create DB client from environment: + +```js +import { createDb } from '../db/client.js'; + +async c => { + const db = createDb(c.env.DB); + // Use db +}; +``` + +### Batch Operations for Atomicity + +**Use `db.batch()` for related operations that must be atomic:** + +```js +// CORRECT - Atomic operations +const batchOps = [ + db.insert(projects).values({ id, name, createdBy }), + db.insert(projectMembers).values({ projectId: id, userId, role: 'owner' }), +]; +await db.batch(batchOps); + +// WRONG - Not atomic +await db.insert(projects).values({ id, name }); +await db.insert(projectMembers).values({ projectId: id, userId }); +``` + +Use batch when operations must succeed or fail together. Single independent operations don't need batch. + +### Drizzle Query Patterns + +Always use Drizzle ORM - never raw SQL: + +```js +// CORRECT +import { eq, and, count } from 'drizzle-orm'; + +const result = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.createdBy, userId))) + .get(); + +// WRONG +const result = await db.prepare('SELECT * FROM projects WHERE id = ?').bind(projectId).first(); +``` + +### Common Query Patterns + +```js +// Get single record +const project = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + +// Get multiple records +const allProjects = await db.select().from(projects).where(eq(projects.createdBy, userId)).all(); + +// Count records +const projectCount = await db.select({ count: count() }).from(projects).where(eq(projects.createdBy, userId)).get(); + +// Update record +await db.update(projects).set({ name: newName, updatedAt: new Date() }).where(eq(projects.id, projectId)); + +// Delete record +await db.delete(projects).where(eq(projects.id, projectId)); + +// Join query +const projectWithMembers = await db + .select({ + project: projects, + member: projectMembers, + }) + .from(projects) + .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId)) + .where(eq(projects.id, projectId)) + .all(); +``` + +## Error Handling + +### Creating Domain Errors + +**Always use `createDomainError` from `@corates/shared`:** + +```js +import { createDomainError, PROJECT_ERRORS, SYSTEM_ERRORS } from '@corates/shared'; + +// CORRECT +if (!project) { + const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId }); + return c.json(error, error.statusCode); +} + +// WRONG +if (!project) { + return c.json({ error: 'Project not found' }, 404); +} +``` + +### Error Constants + +Use predefined error constants from `@corates/shared`: + +- `PROJECT_ERRORS.*` - Project-related errors +- `AUTH_ERRORS.*` - Authentication/authorization errors +- `VALIDATION_ERRORS.*` - Validation errors (automatically created by middleware) +- `SYSTEM_ERRORS.*` - System/database errors +- `USER_ERRORS.*` - User-related errors + +### Database Errors + +Wrap database operations in try-catch and return domain errors: + +```js +try { + const result = await db.select()... +} catch (error) { + console.error('Error fetching project:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, { + operation: 'fetch_project', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); +} +``` + +### Never Throw String Literals + +```js +// WRONG +throw 'Something went wrong'; + +// CORRECT +throw new Error('Something went wrong'); +// Or better: return domain error in API route +``` + +## Authentication + +### requireAuth Middleware + +Use `requireAuth` to protect routes: + +```35:56:packages/workers/src/middleware/auth.js +export async function requireAuth(c, next) { + try { + const auth = createAuth(c.env); + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session?.user) { + const error = createDomainError(AUTH_ERRORS.REQUIRED); + return c.json(error, error.statusCode); + } + + c.set('user', session.user); + c.set('session', session.session); + + await next(); + } catch (error) { + console.error('Auth verification error:', error); + const authError = createDomainError(AUTH_ERRORS.REQUIRED); + return c.json(authError, authError.statusCode); + } +} +``` + +### Getting Authenticated User + +Use `getAuth` helper after `requireAuth`: + +```63:68:packages/workers/src/middleware/auth.js +export function getAuth(c) { + return { + user: c.get('user'), + session: c.get('session'), + }; +} +``` + +```js +routes.get('/me', requireAuth, async c => { + const { user } = getAuth(c); + return c.json({ user }); +}); +``` + +### Optional Authentication + +Use `authMiddleware` (not `requireAuth`) for routes that work with or without auth: + +```13:29:packages/workers/src/middleware/auth.js +export async function authMiddleware(c, next) { + try { + const auth = createAuth(c.env); + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + c.set('user', session?.user || null); + c.set('session', session?.session || null); + } catch (error) { + console.error('Auth middleware error:', error); + c.set('user', null); + c.set('session', null); + } + + await next(); +} +``` + +## Authorization + +### Entitlements + +Check user entitlements (subscription-based permissions): + +```js +import { requireEntitlement } from '../middleware/entitlements.js'; + +routes.post('/', requireAuth, requireEntitlement('project.create'), validateRequest(projectSchemas.create), async c => { + // User has entitlement, proceed +}); +``` + +### Quotas + +Check user quotas (usage limits): + +```js +import { requireQuota } from '../middleware/entitlements.js'; + +async function getProjectCount(userId) { + const db = createDb(c.env.DB); + // Return current count +} + +routes.post( + '/', + requireAuth, + requireQuota('projects.max', getProjectCount, 1), + validateRequest(projectSchemas.create), + async c => { + // User has quota, proceed + }, +); +``` + +## Route Examples + +### GET Route + +```js +routes.get('/:id', requireAuth, async c => { + const { user } = getAuth(c); + const db = createDb(c.env.DB); + const projectId = c.req.param('id'); + + const project = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + + if (!project) { + const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId }); + return c.json(error, error.statusCode); + } + + // Check access + const member = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, user.id))) + .get(); + + if (!member) { + const error = createDomainError(PROJECT_ERRORS.ACCESS_DENIED, { projectId }); + return c.json(error, error.statusCode); + } + + return c.json(project); +}); +``` + +### POST Route with Validation + +```js +routes.post('/', requireAuth, validateRequest(projectSchemas.create), async c => { + const { user } = getAuth(c); + const db = createDb(c.env.DB); + const data = c.get('validatedBody'); + + const projectId = crypto.randomUUID(); + + try { + // Batch operations for atomicity + const batchOps = [ + db.insert(projects).values({ + id: projectId, + name: data.name, + description: data.description, + createdBy: user.id, + }), + db.insert(projectMembers).values({ + id: crypto.randomUUID(), + projectId, + userId: user.id, + role: 'owner', + }), + ]; + + await db.batch(batchOps); + + const project = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + + return c.json(project, 201); + } catch (error) { + console.error('Error creating project:', error); + const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, { + operation: 'create_project', + originalError: error.message, + }); + return c.json(dbError, dbError.statusCode); + } +}); +``` + +### PATCH Route + +```js +routes.patch('/:id', requireAuth, validateRequest(projectSchemas.update), async c => { + const { user } = getAuth(c); + const db = createDb(c.env.DB); + const projectId = c.req.param('id'); + const data = c.get('validatedBody'); + + // Check access and ownership + const member = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, user.id))) + .get(); + + if (!member || member.role !== 'owner') { + const error = createDomainError(PROJECT_ERRORS.ACCESS_DENIED, { projectId }); + return c.json(error, error.statusCode); + } + + // Build update object (only include provided fields) + const updates = {}; + if (data.name !== undefined) updates.name = data.name; + if (data.description !== undefined) updates.description = data.description; + + await db + .update(projects) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(projects.id, projectId)); + + const updated = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + + return c.json(updated); +}); +``` + +### DELETE Route + +```js +routes.delete('/:id', requireAuth, async c => { + const { user } = getAuth(c); + const db = createDb(c.env.DB); + const projectId = c.req.param('id'); + + // Check ownership + const project = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + + if (!project) { + const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId }); + return c.json(error, error.statusCode); + } + + if (project.createdBy !== user.id) { + const error = createDomainError(PROJECT_ERRORS.ACCESS_DENIED, { projectId }); + return c.json(error, error.statusCode); + } + + // Delete (cascade will handle related records) + await db.delete(projects).where(eq(projects.id, projectId)); + + return c.json({ success: true }, 204); +}); +``` + +## Best Practices + +### DO + +- Use `validateRequest` middleware for all request bodies +- Use `validateQueryParams` for query parameters +- Use `db.batch()` for atomic operations +- Use Drizzle ORM queries (never raw SQL) +- Use `createDomainError` for all errors +- Use `requireAuth` to protect routes +- Check access permissions before operations +- Use try-catch for database operations +- Return proper HTTP status codes + +### DON'T + +- Don't manually validate requests (use middleware) +- Don't use raw SQL queries +- Don't skip authentication on protected routes +- Don't throw string literals +- Don't expose internal error details +- Don't forget to check permissions/access +- Don't skip batch operations for related database writes + +## Related Guides + +- [Error Handling Guide](/guides/error-handling) - For error handling patterns +- [Database Guide](/guides/database) - For database schema and patterns +- [Authentication Guide](/guides/authentication) - For auth setup and patterns diff --git a/packages/workers/AUTH_README.md b/packages/docs/guides/authentication.md similarity index 54% rename from packages/workers/AUTH_README.md rename to packages/docs/guides/authentication.md index 6d8090f2b..fc9f6aded 100644 --- a/packages/workers/AUTH_README.md +++ b/packages/docs/guides/authentication.md @@ -1,7 +1,11 @@ -# Authentication Setup for CoRATES +# Authentication Guide + +This guide covers authentication setup, configuration, usage patterns, and code examples with Better Auth in CoRATES. ## Overview +CoRATES uses Better Auth for authentication, providing email/password, magic links, OAuth (Google, ORCID), and two-factor authentication. Authentication state is managed on both the backend (Workers) and frontend (SolidJS). + This setup provides comprehensive user authentication using Better Auth with multiple authentication methods, storing users in Cloudflare D1 database, and protecting all Workers endpoints. ## Features @@ -18,27 +22,70 @@ This setup provides comprehensive user authentication using Better Auth with mul - Account linking and merging - WebSocket authentication support +## Better Auth Setup + +### Backend Configuration + +Better Auth is configured in `packages/workers/src/auth/config.js`: + +```11:34:packages/workers/src/auth/config.js +export function createAuth(env, ctx) { + // Initialize Drizzle with D1 + const db = drizzle(env.DB, { schema }); + + // Create email service + const emailService = createEmailService(env); + + // Build social providers config if credentials are present + const socialProviders = {}; + + if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { + socialProviders.google = { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + // Required so Google issues a refresh token (needed for Drive access when access tokens expire) + accessType: 'offline', + // Request Drive read-only access for PDF import + scope: ['openid', 'email', 'profile', 'https://www.googleapis.com/auth/drive.readonly'], + }; + } else { + console.error( + '[Auth] Google OAuth NOT configured - missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET', + ); + } +``` + +### Authentication Methods + +CoRATES supports multiple authentication methods: + +1. **Email/Password** - Traditional email and password authentication +2. **Magic Link** - Passwordless email-based authentication +3. **Google OAuth** - OAuth with Google (includes Drive access) +4. **ORCID OAuth** - OAuth with ORCID for academic researchers +5. **Two-Factor Authentication** - TOTP-based 2FA with backup codes + ## API Endpoints -### Authentication +### Authentication Endpoints - `POST /api/auth/sign-up/email` - Register new user with email/password - `POST /api/auth/sign-in/email` - Login with email/password - `POST /api/auth/sign-out` - Logout user -- `GET /api/auth/session` - Get current session info (returns `{ user, session, sessionToken }`) +- `GET /api/auth/session` - Get current session info - `GET /api/auth/verify-email` - Verify email address (from email link) - `POST /api/auth/forget-password` - Request password reset - `POST /api/auth/reset-password` - Reset password with token - `POST /api/auth/magic-link` - Send magic link for passwordless login -### OAuth Providers +### OAuth Endpoints - `GET /api/auth/sign-in/social?provider=google` - Initiate Google OAuth flow - `GET /api/auth/callback/google` - Google OAuth callback handler - `GET /api/auth/sign-in/social?provider=orcid` - Initiate ORCID OAuth flow - `GET /api/auth/callback/orcid` - ORCID OAuth callback handler -### Two-Factor Authentication +### Two-Factor Authentication Endpoints - `POST /api/auth/two-factor/enable` - Enable 2FA (returns TOTP secret and backup codes) - `POST /api/auth/two-factor/verify` - Verify 2FA code during login @@ -51,6 +98,8 @@ This setup provides comprehensive user authentication using Better Auth with mul ### Protected Resources +The following endpoints require authentication: + - `/api/projects/*` - Project management (requires auth) - `/api/projects/:projectId/members/*` - Project member management (requires auth) - `/api/projects/:projectId/studies/:studyId/pdfs/*` - PDF management (requires auth) @@ -62,6 +111,167 @@ This setup provides comprehensive user authentication using Better Auth with mul - `/api/google-drive/*` - Google Drive integration (requires auth) - `/api/accounts/merge/*` - Account merging (requires auth) +## Frontend Usage + +### Auth Client + +The frontend uses Better Auth's SolidJS client: + +```1:41:packages/web/src/api/auth-client.js +import { createAuthClient } from 'better-auth/solid'; +import { + genericOAuthClient, + magicLinkClient, + twoFactorClient, + adminClient, +} from 'better-auth/client/plugins'; +import { API_BASE } from '@config/api.js'; +import { parseError } from '@/lib/error-utils.js'; + +export const authClient = createAuthClient({ + baseURL: API_BASE, + + plugins: [genericOAuthClient(), magicLinkClient(), twoFactorClient(), adminClient()], + + fetchOptions: { + credentials: 'include', + onError(error) { + const parsedError = parseError(error); + console.error('Auth error:', parsedError.code, parsedError.message); + }, + onSuccess() { + // Auth action successful + }, + }, +}); + +// Export Better Auth methods for easy access +export const { + signIn, + signUp +``` + +### Auth Store + +The auth store wraps Better Auth with caching and offline support: + +```18:65:packages/web/src/api/better-auth-store.js +function createBetterAuthStore() { + // Track online status without reactive primitives (for singleton context) + const [isOnline, setIsOnline] = createSignal(navigator.onLine); + + // Listen for online/offline events + if (typeof window !== 'undefined') { + window.addEventListener('online', () => setIsOnline(true)); + window.addEventListener('offline', () => setIsOnline(false)); + } + + function loadCachedAuth() { + if (typeof window === 'undefined') return null; + try { + const cached = localStorage.getItem(AUTH_CACHE_KEY); + const timestamp = localStorage.getItem(AUTH_CACHE_TIMESTAMP_KEY); + if (!cached || !timestamp) return null; + + const age = Date.now() - parseInt(timestamp, 10); + if (age > AUTH_CACHE_MAX_AGE) { + localStorage.removeItem(AUTH_CACHE_KEY); + localStorage.removeItem(AUTH_CACHE_TIMESTAMP_KEY); + return null; + } + + return JSON.parse(cached); + } catch (err) { + console.error('Error loading cached auth:', err); + return null; + } + } + + // Save auth data to localStorage + function saveCachedAuth(userData) { + if (typeof window === 'undefined') return; + try { + if (userData) { + localStorage.setItem(AUTH_CACHE_KEY, JSON.stringify(userData)); + localStorage.setItem(AUTH_CACHE_TIMESTAMP_KEY, Date.now().toString()); + } else { + localStorage.removeItem(AUTH_CACHE_KEY); + localStorage.removeItem(AUTH_CACHE_TIMESTAMP_KEY); + } + } catch (err) { + console.error('Error saving cached auth:', err); + } + } +``` + +### Using Auth in Components + +```js +import { useBetterAuth } from '@/api/better-auth-store.js'; + +function MyComponent() { + const auth = useBetterAuth(); + + // Reactive values + const user = () => auth.user(); + const isLoggedIn = () => auth.isLoggedIn(); + const isLoading = () => auth.authLoading(); + + return ( + +
Welcome, {user()?.name}
+
+ ); +} +``` + +## Protected Routes + +### Backend Protection + +Use `requireAuth` middleware to protect routes: + +```35:56:packages/workers/src/middleware/auth.js +export async function requireAuth(c, next) { + try { + const auth = createAuth(c.env); + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session?.user) { + const error = createDomainError(AUTH_ERRORS.REQUIRED); + return c.json(error, error.statusCode); + } + + c.set('user', session.user); + c.set('session', session.session); + + await next(); + } catch (error) { + console.error('Auth verification error:', error); + const authError = createDomainError(AUTH_ERRORS.REQUIRED); + return c.json(authError, authError.statusCode); + } +} +``` + +### Frontend Protection + +Use `ProtectedGuard` component: + +```js +import ProtectedGuard from '@auth-ui/ProtectedGuard.jsx'; + +function App() { + return ( + + + + ); +} +``` + ## Usage Examples ### Register a new user @@ -102,6 +312,92 @@ For WebSocket connections, authenticate via URL parameter: const ws = new WebSocket('ws://localhost:8787/api/project/my-project?token=YOUR_SESSION_TOKEN'); ``` +## Session Management + +### Session Configuration + +Sessions are managed with secure cookies (7-day expiry by default). Session data includes: + +- User information (id, name, email, etc.) +- Session token +- Expiration timestamp + +### Session Access + +**Backend:** + +```js +const { user, session } = getAuth(c); +``` + +**Frontend:** + +```js +const auth = useBetterAuth(); +const user = () => auth.user(); +const session = () => auth.session(); +``` + +## Admin Features + +### User Impersonation + +Admins can impersonate users for support purposes: + +```js +// Admin only - impersonate a user +await authClient.admin.impersonate({ userId: 'user-id' }); +``` + +### Admin Routes + +Admin routes require admin role and use special middleware to check permissions. + +## Account Linking and Merging + +Users can link multiple accounts (e.g., email and OAuth) and merge accounts when needed. This is handled through Better Auth's account linking features. + +## Two-Factor Authentication + +### Enabling 2FA + +```js +// Enable 2FA +const result = await authClient.twoFactor.enable(); +// Returns: { secret, backupCodes, qrCode } + +// Display QR code to user +// User scans with authenticator app +``` + +### Verifying 2FA + +```js +// During login with 2FA enabled +await authClient.signIn.email({ + email, + password, + twoFactorCode: '123456', // From authenticator app +}); +``` + +### Backup Codes + +Users receive backup codes when enabling 2FA. These can be used if the authenticator app is lost. + +## Email Verification + +Users must verify their email address after signup. Verification links are sent via email and contain tokens that expire after a set time. + +## Password Reset + +Password reset flow: + +1. User requests password reset (`POST /api/auth/forget-password`) +2. Email sent with reset token +3. User clicks link and enters new password +4. Password is reset (`POST /api/auth/reset-password`) + ## Database Schema The authentication system uses these tables (managed by Better Auth with Drizzle ORM): @@ -247,3 +543,29 @@ Google OAuth is used to allow users to connect their Google account and access t - Development: `http://localhost:8787/api/auth/callback/google` - Production: `https://your-api-domain.com/api/auth/callback/google` 5. Copy the Client ID and Client Secret + +## Best Practices + +### DO + +- Always use `requireAuth` middleware on protected routes +- Use `useBetterAuth` hook in components for auth state +- Cache auth state in localStorage for offline support +- Handle auth errors gracefully +- Verify email addresses +- Support password reset flow +- Use secure session cookies + +### DON'T + +- Don't expose session tokens to frontend +- Don't store passwords in plain text +- Don't skip email verification +- Don't allow weak passwords (Better Auth handles this) +- Don't forget to handle expired sessions + +## Related Guides + +- [API Development Guide](/guides/api-development) - For protected route patterns +- [Error Handling Guide](/guides/error-handling) - For auth error handling +- [State Management Guide](/guides/state-management) - For auth store patterns diff --git a/packages/docs/guides/components.md b/packages/docs/guides/components.md new file mode 100644 index 000000000..52800e92f --- /dev/null +++ b/packages/docs/guides/components.md @@ -0,0 +1,638 @@ +# Component Development Guide + +This guide covers component structure, props patterns, composition, and best practices for building components in CoRATES. + +## Overview + +Components in CoRATES are built with SolidJS and follow patterns that maintain reactivity while keeping components lean and focused on rendering. + +## Component Structure + +### Basic Component Pattern + +```jsx +import { createSignal, Show, For } from 'solid-js'; +import projectStore from '@/stores/projectStore.js'; + +export default function MyComponent(props) { + // Local state (only for UI state) + const [isOpen, setIsOpen] = createSignal(false); + + // Read from stores (reactive) + const projects = () => projectStore.getProjectList(); + + // Computed values + const projectCount = () => projects().length; + + return ( +
+ + {project => } + +
+ ); +} +``` + +## Props Patterns + +### Critical: Never Destructure Props + +**Destructuring breaks reactivity in SolidJS:** + +```jsx +// WRONG - breaks reactivity +function MyComponent(props) { + const { name, age } = props; + return ( +
+ {name} is {age} +
+ ); // Won't update when props change +} + +// WRONG - another example +function MyComponent(props) { + const name = props.name; + return
{name}
; // Won't update +} + +// CORRECT - access directly +function MyComponent(props) { + return ( +
+ {props.name} is {props.age} +
+ ); // Maintains reactivity +} + +// CORRECT - wrap in function for computed access +function MyComponent(props) { + const name = () => props.name; + return
{name()}
; // Maintains reactivity +} +``` + +### Props Best Practices + +```jsx +// Good: Direct prop access +function ProjectCard(props) { + return ( +
+

{props.project.name}

+

{props.project.description}

+
+ ); +} + +// Good: Memoized prop access for derived values +function ProjectCard(props) { + const displayName = createMemo(() => { + return `${props.project.name} (${props.project.status})`; + }); + + return

{displayName()}

; +} +``` + +## Component Organization + +### File Structure + +Components are organized by feature: + +``` +src/components/ +├── project-ui/ # Project-related components +│ ├── ProjectView.jsx +│ ├── ProjectCard.jsx +│ └── CreateProjectForm.jsx +├── checklist-ui/ # Checklist-related components +│ ├── AMSTAR2Checklist.jsx +│ └── ChecklistYjsWrapper.jsx +├── auth-ui/ # Auth-related components +│ ├── SignIn.jsx +│ └── SignUp.jsx +└── common/ # Shared components + └── NoteEditor.jsx +``` + +### Component Size + +Keep components focused and small: + +```jsx +// GOOD - Focused component +function ProjectCard(props) { + const project = () => props.project; + + return ( +
+

{project().name}

+ +
+ ); +} + +// BAD - Too much logic in component +function ProjectCard(props) { + // Don't put API calls, complex logic, etc. in components + const [data, setData] = createSignal(null); + + fetch('/api/project/' + props.project.id) + .then(res => res.json()) + .then(setData); + + // Move this to a store or primitive instead +} +``` + +## State Management in Components + +### When to Use createSignal + +Use `createSignal` for **local UI state only**: + +```jsx +function MyComponent() { + // Good: UI state (modal open/closed, form field values) + const [isOpen, setIsOpen] = createSignal(false); + const [inputValue, setInputValue] = createSignal(''); + + // Bad: Application state (should be in store) + // const [projects, setProjects] = createSignal([]); +} +``` + +### When to Use Stores + +Use stores for **shared/cross-feature state**: + +```jsx +import projectStore from '@/stores/projectStore.js'; + +function MyComponent() { + // Good: Read from store + const projects = () => projectStore.getProjectList(); + + // Bad: Local state for shared data + // const [projects, setProjects] = createSignal([]); +} +``` + +### When to Use Primitives + +Use primitives for **reusable business logic**: + +```jsx +import { useProjectData } from '@/primitives/useProjectData.js'; + +function MyComponent(props) { + // Good: Use primitive for project operations + const { studies, isConnected } = useProjectData(props.projectId); + + // Bad: Duplicate logic in component + // const [studies, setStudies] = createSignal([]); + // useEffect(() => { /* fetch studies */ }); +} +``` + +## Component Composition + +### Passing Props vs Store Access + +**Prefer store access over prop drilling:** + +```jsx +// BAD - Prop drilling +function App() { + const projects = () => projectStore.getProjectList(); + return ; +} + +function ProjectList({ projects }) { + return ; +} + +function ProjectDashboard({ projects }) { + return
{projects.length} projects
; +} + +// GOOD - Direct store access +function App() { + return ; +} + +function ProjectList() { + return ; +} + +function ProjectDashboard() { + const projects = () => projectStore.getProjectList(); + return
{projects().length} projects
; +} +``` + +### Component Props Limit + +Components should receive **at most 1-5 props** (for local configuration only): + +```jsx +// GOOD - Few props for configuration +function ProjectCard(props) { + // props.projectId, props.showActions, props.onClick - all configuration + const project = () => projectStore.getProject(props.projectId); + return
...
; +} + +// BAD - Too many props (should use store or context) +function ProjectCard(props) { + // 10+ props means you're prop drilling + // Move shared state to a store instead +} +``` + +## Import Aliases + +Use import aliases from `jsconfig.json`: + +```jsx +// GOOD - Use aliases +import projectStore from '@/stores/projectStore.js'; +import { handleError } from '@/lib/error-utils.js'; +import SignIn from '@auth-ui/SignIn.jsx'; +import ChecklistWrapper from '@checklist-ui/ChecklistYjsWrapper.jsx'; + +// BAD - Relative paths when alias available +import projectStore from '../../stores/projectStore.js'; +``` + +### Available Aliases + +- `@/*` → `src/*` +- `@components/*` → `src/components/*` +- `@auth-ui/*` → `src/components/auth-ui/*` +- `@checklist-ui/*` → `src/components/checklist-ui/*` +- `@project-ui/*` → `src/components/project-ui/*` +- `@routes/*` → `src/routes/*` +- `@primitives/*` → `src/primitives/*` +- `@api/*` → `src/api/*` +- `@config/*` → `src/config/*` +- `@lib/*` → `src/lib/*` + +## UI Component Library + +### Ark UI Components + +**Always import from `@corates/ui`, not local components:** + +```jsx +// GOOD - Import from @corates/ui +import { Dialog, Select, Toast, showToast, Avatar } from '@corates/ui'; + +// BAD - Don't import from local components +import { Dialog } from '@/components/zag/Dialog.jsx'; +``` + +### Common Components + +```jsx +import { + Dialog, + ConfirmDialog, + useConfirmDialog, + Select, + Combobox, + Toast, + showToast, + Avatar, + Tabs, + Checkbox, + Switch, + RadioGroup, + Tooltip, + Popover, + Menu, + FileUpload, + PasswordInput, +} from '@corates/ui'; + +function MyComponent() { + const confirmDialog = useConfirmDialog(); + + const handleDelete = async () => { + const confirmed = await confirmDialog.confirm({ + title: 'Delete Project?', + message: 'This action cannot be undone.', + }); + + if (confirmed) { + // Delete logic + showToast.success('Project deleted'); + } + }; + + return {/* Dialog content */}; +} +``` + +## Icons + +**Always use `solid-icons` library, NEVER use emojis:** + +```jsx +// GOOD - Import from solid-icons +import { BiRegularHome, BiRegularCheck } from 'solid-icons/bi'; +import { FiUsers } from 'solid-icons/fi'; +import { AiFillCheckCircle } from 'solid-icons/ai'; + +function MyComponent() { + return ( +
+ + +
+ ); +} + +// BAD - Don't use emojis +function MyComponent() { + return 🏠 Home; // Use icon component instead +} +``` + +### Icon Packages + +- `solid-icons/bi` - BoxIcons Regular +- `solid-icons/bx` - BoxIcons +- `solid-icons/fi` - Feather Icons +- `solid-icons/ai` - Ant Design Icons + +Use the icon search MCP tool to find icons by name. + +## Conditional Rendering + +### Show Component + +```jsx +import { Show } from 'solid-js'; + +Loading...}> + + + + + + +``` + +### Switch/Match + +```jsx +import { Switch, Match } from 'solid-js'; + + + + + + + + + + + +; +``` + +## Lists + +### For Component + +```jsx +import { For } from 'solid-js'; + + + {(item, index) => ( + + )} + + +// With fallback +No items}> + {item => } + +``` + +## Effects and Lifecycle + +### createEffect + +Use effects sparingly (prefer derived values with `createMemo`): + +```jsx +import { createEffect, onCleanup } from 'solid-js'; + +function MyComponent(props) { + createEffect(() => { + const id = props.id(); + + // Setup + const subscription = subscribe(id); + + // Cleanup + onCleanup(() => { + subscription.unsubscribe(); + }); + }); +} +``` + +### onMount / onCleanup + +```jsx +import { onMount, onCleanup } from 'solid-js'; + +function MyComponent() { + onMount(() => { + // Component mounted + }); + + onCleanup(() => { + // Component will unmount - cleanup + }); +} +``` + +## Error Handling in Components + +### Error Boundaries + +Use error boundaries for rendering errors: + +```jsx +import AppErrorBoundary from '@/components/ErrorBoundary.jsx'; + +function App() { + return ( + + + + ); +} +``` + +### API Error Handling + +Use `handleFetchError` for API calls: + +```jsx +import { handleFetchError } from '@/lib/error-utils.js'; + +async function handleSubmit() { + try { + const response = await handleFetchError( + fetch('/api/projects', { + method: 'POST', + body: JSON.stringify(data), + }), + { showToast: true }, + ); + // Success + } catch (error) { + // Error already handled (toast shown) + } +} +``` + +### Form Error Handling + +Use form error signals: + +```jsx +import { createFormErrorSignals } from '@/lib/form-errors.js'; +import { createSignal } from 'solid-js'; + +function MyForm() { + const errors = createFormErrorSignals(createSignal); + + async function handleSubmit() { + try { + // Submit form + } catch (error) { + errors.handleError(error); // Handles field-level and global errors + } + } + + return ( +
+ + {errors.fieldErrors().email && {errors.fieldErrors().email}} + {errors.globalError() &&
{errors.globalError()}
} +
+ ); +} +``` + +## Styling + +Use Tailwind CSS classes (see [Style Guide](/guides/style-guide)): + +```jsx +function MyComponent() { + return ( +
+

Title

+

Description

+
+ ); +} +``` + +## Best Practices + +### DO + +- Keep components lean and focused on rendering +- Move business logic to stores, primitives, or utilities +- Access props directly (never destructure) +- Use store imports instead of prop drilling +- Use import aliases +- Use Ark UI components from `@corates/ui` +- Use `solid-icons` for icons +- Handle errors appropriately +- Use Tailwind CSS for styling + +### DON'T + +- Don't destructure props +- Don't prop-drill application state +- Don't put business logic in components +- Don't use emojis (use icons) +- Don't import UI components from local files (use `@corates/ui`) +- Don't create "God components" that do too much +- Don't use more than 5 props (consider store or context) + +## Component Examples + +### Simple Component + +```jsx +import { Show } from 'solid-js'; +import projectStore from '@/stores/projectStore.js'; + +export default function ProjectList() { + const projects = () => projectStore.getProjectList(); + const loading = () => projectStore.isProjectListLoading(); + + return ( +
+ Loading...
}> + {project => } + + + ); +} +``` + +### Form Component + +```jsx +import { createSignal } from 'solid-js'; +import { createFormErrorSignals } from '@/lib/form-errors.js'; +import { handleFetchError } from '@/lib/error-utils.js'; +import projectActionsStore from '@/stores/projectActionsStore'; + +export default function CreateProjectForm(props) { + const [name, setName] = createSignal(''); + const errors = createFormErrorSignals(createSignal); + + async function handleSubmit(e) { + e.preventDefault(); + + try { + await projectActionsStore.createProject({ + name: name(), + }); + props.onSuccess?.(); + } catch (error) { + errors.handleError(error); + } + } + + return ( +
+ setName(e.target.value)} class='rounded border px-3 py-2' /> + {errors.fieldErrors().name && {errors.fieldErrors().name}} + +
+ ); +} +``` + +## Related Guides + +- [State Management Guide](/guides/state-management) - For store patterns +- [Primitives Guide](/guides/primitives) - For reusable logic patterns +- [Style Guide](/guides/style-guide) - For UI/UX guidelines +- [Error Handling Guide](/guides/error-handling) - For error handling patterns diff --git a/packages/docs/guides/configuration.md b/packages/docs/guides/configuration.md new file mode 100644 index 000000000..08466c235 --- /dev/null +++ b/packages/docs/guides/configuration.md @@ -0,0 +1,222 @@ +# Configuration Guide + +This guide covers configuration files, environment variables, path aliases, and setup for CoRATES. + +## Overview + +CoRATES uses a monorepo structure with multiple packages, each with its own configuration. This guide covers the key configuration files and settings. + +## Package Structure + +The project is organized as a monorepo with packages under `packages/`: + +``` +packages/ +├── web/ # Frontend application (SolidJS) +├── workers/ # Backend API (Cloudflare Workers) +├── landing/ # Landing/marketing site +├── ui/ # Shared UI component library +├── shared/ # Shared TypeScript utilities +├── mcp/ # MCP server for development tools +└── docs/ # Documentation site +``` + +## Path Aliases + +### Frontend (Web Package) + +Path aliases are defined in `packages/web/jsconfig.json`: + +```1:20:packages/web/jsconfig.json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@auth-ui/*": ["src/components/auth-ui/*"], + "@checklist-ui/*": ["src/components/checklist-ui/*"], + "@project-ui/*": ["src/components/project-ui/*"], + "@routes/*": ["src/routes/*"], + "@primitives/*": ["src/primitives/*"], + "@auth/*": ["src/components/auth-ui/*"], + "@offline/*": ["src/offline/*"], + "@api/*": ["src/api/*"], + "@config/*": ["src/config/*"], + "@lib/*": ["src/lib/*"] + } + }, + "exclude": ["node_modules", "dist"] +} +``` + +**Usage:** + +```js +// Use aliases instead of relative paths +import projectStore from '@/stores/projectStore.js'; +import SignIn from '@auth-ui/SignIn.jsx'; +import { useProject } from '@primitives/useProject/index.js'; +``` + +**Available Aliases:** + +- `@/*` → `src/*` +- `@components/*` → `src/components/*` +- `@auth-ui/*` → `src/components/auth-ui/*` +- `@checklist-ui/*` → `src/components/checklist-ui/*` +- `@project-ui/*` → `src/components/project-ui/*` +- `@routes/*` → `src/routes/*` +- `@primitives/*` → `src/primitives/*` +- `@api/*` → `src/api/*` +- `@config/*` → `src/config/*` +- `@lib/*` → `src/lib/*` + +## Environment Variables + +### Backend (Workers) + +Environment variables are defined in `wrangler.jsonc` or `.dev.vars`: + +**Required:** + +- `DB` - D1 database binding +- `BETTER_AUTH_SECRET` - Secret key for Better Auth +- `BETTER_AUTH_URL` - Base URL for Better Auth callbacks + +**Optional:** + +- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` - Google OAuth +- `ORCID_CLIENT_ID` / `ORCID_CLIENT_SECRET` - ORCID OAuth +- `POSTMARK_API_KEY` - Email service +- `STRIPE_SECRET_KEY` - Stripe billing +- `R2_BUCKET` - R2 storage binding for PDFs + +### Frontend (Web) + +Environment variables are typically set at build time via Vite: + +- `VITE_API_BASE` - API base URL (defaults to `/api`) + +## Build Configuration + +### Vite Config (Frontend) + +The web package uses Vite for building. Configuration in `packages/web/vite.config.js`. + +### Wrangler Config (Backend) + +Cloudflare Workers configuration in `packages/workers/wrangler.jsonc`: + +- D1 database bindings +- Durable Object bindings +- R2 bucket bindings +- Environment variables +- Routes + +## Development Setup + +### Prerequisites + +- Node.js (v18+) +- pnpm (package manager) +- Cloudflare account (for Workers/D1) + +### Installation + +```bash +# Install dependencies +pnpm install + +# Setup local development +# See README.md for detailed setup instructions +``` + +### Development Commands + +```bash +# Start development servers +pnpm dev + +# Build all packages +pnpm build + +# Run tests +pnpm test + +# Run linting +pnpm lint +``` + +## Package-Specific Configuration + +### Web Package + +- **Build tool**: Vite +- **Framework**: SolidJS + SolidStart +- **Styling**: Tailwind CSS +- **Type checking**: TypeScript (via JSDoc comments) + +### Workers Package + +- **Runtime**: Cloudflare Workers +- **Database**: Cloudflare D1 (SQLite) +- **ORM**: Drizzle ORM +- **Auth**: Better Auth +- **Storage**: Cloudflare R2 + +### UI Package + +- **Components**: Ark UI +- **Icons**: solid-icons +- **TypeScript**: Full TypeScript support + +## Import Patterns + +### UI Components + +Always import from `@corates/ui`: + +```js +import { Dialog, Select, Toast } from '@corates/ui'; +``` + +### Icons + +Import from `solid-icons`: + +```js +import { BiRegularHome } from 'solid-icons/bi'; +import { FiUsers } from 'solid-icons/fi'; +``` + +### Internal Packages + +Import using package names: + +```js +import { createDomainError } from '@corates/shared'; +``` + +## Best Practices + +### DO + +- Use path aliases instead of relative paths +- Keep configuration files in sync across packages +- Use environment variables for secrets +- Document required environment variables +- Use package.json scripts for common tasks + +### DON'T + +- Don't use relative paths when aliases are available +- Don't hardcode API URLs or secrets +- Don't commit `.env` files +- Don't create circular dependencies between packages + +## Related Guides + +- [Development Workflow Guide](/guides/development-workflow) - For setup and common tasks +- [API Development Guide](/guides/api-development) - For backend configuration +- [Component Development Guide](/guides/components) - For frontend configuration diff --git a/packages/docs/guides/database.md b/packages/docs/guides/database.md new file mode 100644 index 000000000..ec2ce8a6e --- /dev/null +++ b/packages/docs/guides/database.md @@ -0,0 +1,613 @@ +# Database Guide + +This guide covers the database schema, Drizzle ORM patterns, migrations, and query patterns in CoRATES. + +## Overview + +CoRATES uses Cloudflare D1 (SQLite) with Drizzle ORM for type-safe database operations. All database interactions must use Drizzle - raw SQL queries are not allowed. + +## Schema Overview + +The database schema is defined in `packages/workers/src/db/schema.js` using Drizzle's SQLite schema builder. + +### Core Tables + +#### Users + +```1:24:packages/workers/src/db/schema.js +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +// Users table +export const user = sqliteTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('emailVerified', { mode: 'boolean' }).default(false), + image: text('image'), + createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), + username: text('username').unique(), + displayName: text('displayName'), + avatarUrl: text('avatarUrl'), + role: text('role'), // Better Auth admin/plugin role (e.g. 'user', 'admin') + persona: text('persona'), // optional: researcher, student, librarian, other + profileCompletedAt: integer('profileCompletedAt'), // unix timestamp (seconds) + twoFactorEnabled: integer('twoFactorEnabled', { mode: 'boolean' }).default(false), + // Admin plugin fields + banned: integer('banned', { mode: 'boolean' }).default(false), + banReason: text('banReason'), + banExpires: integer('banExpires', { mode: 'timestamp' }), +}); +``` + +#### Projects + +```72:82:packages/workers/src/db/schema.js +// Projects table (for user's research projects) +export const projects = sqliteTable('projects', { + id: text('id').primaryKey(), + name: text('name').notNull(), + description: text('description'), + createdBy: text('createdBy') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), +}); +``` + +#### Project Members + +```84:95:packages/workers/src/db/schema.js +// Project membership table (which users have access to which projects) +export const projectMembers = sqliteTable('project_members', { + id: text('id').primaryKey(), + projectId: text('projectId') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + userId: text('userId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + role: text('role').default('member'), // owner, collaborator, member, viewer + joinedAt: integer('joinedAt', { mode: 'timestamp' }).default(sql`(unixepoch())`), +}); +``` + +### Table Relationships + +- **users** ↔ **projects**: One-to-many (createdBy) +- **users** ↔ **project_members**: Many-to-many (through projectMembers table) +- **projects** ↔ **project_members**: One-to-many + +## Drizzle ORM Patterns + +### Database Client + +Always create DB client from environment: + +```js +import { createDb } from '../db/client.js'; + +async c => { + const db = createDb(c.env.DB); + // Use db +}; +``` + +### Query Patterns + +#### Select Single Record + +```js +import { eq } from 'drizzle-orm'; + +const project = await db.select().from(projects).where(eq(projects.id, projectId)).get(); +``` + +#### Select Multiple Records + +```js +const allProjects = await db.select().from(projects).where(eq(projects.createdBy, userId)).all(); +``` + +#### Select with Joins + +```js +import { eq, and } from 'drizzle-orm'; + +const projectWithMembers = await db + .select({ + project: projects, + member: projectMembers, + }) + .from(projects) + .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId)) + .where(eq(projects.id, projectId)) + .all(); +``` + +#### Count Records + +```js +import { count } from 'drizzle-orm'; + +const [result] = await db.select({ count: count() }).from(projects).where(eq(projects.createdBy, userId)); + +const projectCount = result?.count || 0; +``` + +#### Insert Records + +```js +const newProject = await db + .insert(projects) + .values({ + id: crypto.randomUUID(), + name: 'My Project', + description: 'Project description', + createdBy: userId, + }) + .returning() + .get(); +``` + +#### Update Records + +```js +await db + .update(projects) + .set({ + name: 'Updated Name', + updatedAt: new Date(), + }) + .where(eq(projects.id, projectId)); +``` + +#### Delete Records + +```js +await db.delete(projects).where(eq(projects.id, projectId)); +``` + +### Batch Operations + +**Always use `db.batch()` for related operations that must be atomic:** + +```js +// CORRECT - Atomic operations +const batchOps = [ + db.insert(projects).values({ id, name, createdBy }), + db.insert(projectMembers).values({ projectId: id, userId, role: 'owner' }), +]; +await db.batch(batchOps); + +// WRONG - Not atomic +await db.insert(projects).values({ id, name }); +await db.insert(projectMembers).values({ projectId: id, userId }); +``` + +Use batch when operations must succeed or fail together. Single independent operations don't need batch. + +### Where Conditions + +Combine conditions with `and`, `or`: + +```js +import { and, or, eq, like } from 'drizzle-orm'; + +// AND condition +const result = await db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.createdBy, userId))) + .get(); + +// OR condition +const results = await db + .select() + .from(projects) + .where( + or( + eq(projects.createdBy, userId), + // User is member via join + ), + ) + .all(); + +// LIKE (string search) +const results = await db + .select() + .from(projects) + .where(like(projects.name, `%${searchTerm}%`)) + .all(); +``` + +## Schema Management + +### Architecture: Single Source of Truth + +The database schema follows a single source of truth pattern: + +``` +Drizzle Schema (src/db/schema.js) + ↓ +Migration SQL (migrations/0001_init.sql) + ↓ +Test SQL Constant (src/__tests__/migration-sql.js) [generated] +``` + +**Key Principle:** Only the Drizzle schema (`src/db/schema.js`) needs to be maintained manually. Everything else is generated from it. + +### Files Overview + +#### Source Files (Maintained Manually) + +- **`src/db/schema.js`** - Drizzle ORM schema definitions + - This is the single source of truth + - All table definitions, columns, relationships, and constraints are defined here + +- **`migrations/0001_init.sql`** - SQL migration file + - Can be manually maintained OR generated using `drizzle-kit generate` + - Used by Wrangler to apply migrations to D1 databases + +#### Generated Files (Auto-generated) + +- **`src/__tests__/migration-sql.js`** - Test SQL constant + - Generated by `scripts/generate-test-sql.mjs` + - Exported as `MIGRATION_SQL` constant + - Used by test helpers for database reset + - **Do not edit manually** - it will be overwritten + +## Migrations + +### Migration Process + +**All migrations go in a single file: `packages/workers/migrations/0001_init.sql`** + +Do NOT create separate migration files (0002_xxx.sql, etc.). Edit the existing 0001_init.sql file directly. + +### Migration Structure + +Migrations use standard SQLite syntax: + +```sql +-- Create projects table +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + createdBy TEXT NOT NULL, + createdAt INTEGER DEFAULT (unixepoch()), + updatedAt INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (createdBy) REFERENCES user(id) ON DELETE CASCADE +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_projects_created_by ON projects(createdBy); +CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updatedAt); +``` + +### Migration Workflow + +#### Step 1: Update Drizzle Schema + +Edit `src/db/schema.js` to add/modify tables, columns, or constraints: + +```typescript +export const myTable = sqliteTable('my_table', { + id: text('id').primaryKey(), + name: text('name').notNull(), + // ... other columns +}); +``` + +#### Step 2: Update Migration SQL + +You have two options: + +**Option A: Manual Update (Current Approach)** + +- Edit `migrations/0001_init.sql` directly +- Add/update CREATE TABLE statements, indexes, etc. +- Keep it in sync with the Drizzle schema + +**Option B: Use Drizzle Kit** + +```bash +pnpm db:generate +``` + +- This generates migration files in `migrations/` directory +- Review and apply the generated SQL +- For this project, you may need to merge into `0001_init.sql` + +#### Step 3: Regenerate Test SQL Constant + +After updating the migration SQL: + +```bash +pnpm db:generate:test +``` + +This ensures test helpers use the latest schema. + +#### Step 4: Apply Migration to Database + +**Local development:** + +```bash +pnpm db:migrate +``` + +**Production:** + +```bash +pnpm db:migrate:prod +``` + +### Available Scripts + +#### Generate Migration SQL from Schema + +```bash +pnpm db:generate +``` + +This runs `drizzle-kit generate` which: + +- Reads the Drizzle schema from `src/db/schema.js` +- Compares it with existing migrations +- Generates new migration SQL files in `migrations/` + +**Note:** Currently, the project uses a single consolidated migration file (`0001_init.sql`). When making schema changes, you can either: + +1. Manually edit `migrations/0001_init.sql` to match the schema +2. Use `drizzle-kit generate` and merge the generated SQL into `0001_init.sql` + +#### Generate Test SQL Constant + +```bash +pnpm db:generate:test +``` + +This runs `scripts/generate-test-sql.mjs` which: + +- Reads `migrations/0001_init.sql` +- Generates `src/__tests__/migration-sql.js` with the SQL as an exported constant +- Used by test helpers to reset the database schema in tests + +**When to run:** After updating the migration SQL file, run this to update the test helpers. + +## Indexes + +Create indexes for frequently queried columns: + +```sql +-- Index on foreign keys +CREATE INDEX IF NOT EXISTS idx_project_members_project_id ON project_members(projectId); +CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(userId); + +-- Index on searchable fields +CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name); + +-- Unique indexes (enforced by schema) +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON user(email); +``` + +## Common Query Patterns + +### Check Existence + +```js +const exists = await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).get(); + +if (!exists) { + // Project doesn't exist +} +``` + +### Pagination + +```js +import { desc, limit, offset } from 'drizzle-orm'; + +const page = 1; +const pageSize = 20; + +const results = await db + .select() + .from(projects) + .where(eq(projects.createdBy, userId)) + .orderBy(desc(projects.createdAt)) + .limit(pageSize) + .offset((page - 1) * pageSize) + .all(); +``` + +### Update with Current Timestamp + +```js +await db + .update(projects) + .set({ + name: newName, + updatedAt: new Date(), + }) + .where(eq(projects.id, projectId)); +``` + +### Upsert Pattern + +```js +// Check if exists, then insert or update +const existing = await db.select().from(projects).where(eq(projects.id, projectId)).get(); + +if (existing) { + await db.update(projects).set({ name: newName }).where(eq(projects.id, projectId)); +} else { + await db.insert(projects).values({ id: projectId, name: newName, createdBy: userId }); +} +``` + +## Data Types + +### Timestamps + +Timestamps are stored as integers (Unix epoch in seconds): + +```js +createdAt: integer('createdAt', { mode: 'timestamp' }).default(sql`(unixepoch())`); +``` + +Convert between Date and integer: + +```js +// Store +const now = Math.floor(Date.now() / 1000); // seconds +await db.insert(projects).values({ createdAt: new Date() }); // Drizzle converts + +// Read +const project = await db.select().from(projects).get(); +const date = project.createdAt; // Date object (if mode: 'timestamp') +``` + +### Booleans + +Booleans stored as integers (0/1): + +```js +emailVerified: integer('emailVerified', { mode: 'boolean' }).default(false); +``` + +Drizzle automatically converts between boolean and integer. + +## Test Helpers + +Test seed functions use Drizzle ORM and Zod validation: + +### Seed Functions + +All seed functions (`seedUser`, `seedProject`, `seedProjectMember`, `seedSession`, `seedSubscription`) now: + +- Use Drizzle ORM `insert()` operations (no raw SQL) +- Validate inputs with Zod schemas (`src/__tests__/seed-schemas.js`) +- Automatically convert timestamps and handle type transformations + +### Example Usage + +```javascript +import { seedUser, seedProject } from './helpers.js'; + +// Timestamps can be Date objects or Unix timestamps (seconds) +await seedUser({ + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + createdAt: Math.floor(Date.now() / 1000), // Unix timestamp + updatedAt: Math.floor(Date.now() / 1000), + role: 'researcher', // optional, defaults to 'researcher' + emailVerified: true, // boolean or 0/1 +}); + +await seedProject({ + id: 'project-1', + name: 'Test Project', + description: 'A test project', + createdBy: 'user-1', + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000), +}); +``` + +### Validation + +All seed function parameters are validated using Zod schemas: + +- Required fields are enforced +- Email format is validated +- Enums (roles, tiers, statuses) are validated +- Timestamps accept both Date objects and Unix timestamps +- Boolean fields accept both boolean and number (0/1) values + +## Configuration + +### Drizzle Kit Config + +`drizzle.config.ts`: + +```typescript +export default defineConfig({ + dialect: 'sqlite', + schema: './src/db/schema.js', + out: './migrations', +}); +``` + +### Dependencies + +- **`drizzle-orm`** - ORM for database operations (runtime) +- **`drizzle-kit`** - CLI tool for migrations (dev dependency) + +## Best Practices + +### DO + +- Always use Drizzle ORM queries (never raw SQL) +- Use `db.batch()` for atomic operations +- Create indexes for frequently queried columns +- Use transactions for related operations +- Handle errors gracefully +- Use proper types (text, integer, etc.) +- Always update the schema first - Edit `src/db/schema.js` before migration SQL +- Keep migrations in sync - Ensure `migrations/0001_init.sql` matches the schema +- Regenerate test SQL - Run `pnpm db:generate:test` after migration changes +- Use Drizzle ORM in tests - Seed functions use Drizzle, not raw SQL +- Validate inputs - All seed functions validate with Zod schemas + +### DON'T + +- Don't use raw SQL queries +- Don't skip batch operations for related writes +- Don't forget indexes on foreign keys +- Don't store timestamps as strings (use integer with timestamp mode) +- Don't forget to handle null/undefined values + +## Troubleshooting + +### Test SQL is out of sync + +If tests fail with schema errors: + +```bash +pnpm db:generate:test +``` + +### Migration SQL doesn't match schema + +1. Review `src/db/schema.js` for the intended schema +2. Update `migrations/0001_init.sql` to match +3. Run `pnpm db:generate:test` to update test helpers + +### Seed function validation errors + +Check the Zod schema in `src/__tests__/seed-schemas.js`: + +- Required fields must be provided +- Enums must match valid values +- Timestamps can be Date objects or Unix timestamps (seconds) + +## Related Files + +- `src/db/schema.js` - Drizzle schema definitions +- `src/db/client.js` - Drizzle client factory +- `src/__tests__/helpers.js` - Test utilities and seed functions +- `src/__tests__/seed-schemas.js` - Zod validation schemas for seed functions +- `src/__tests__/migration-sql.js` - Generated test SQL constant (do not edit) +- `scripts/generate-test-sql.mjs` - Script to generate test SQL constant +- `drizzle.config.ts` - Drizzle Kit configuration + +## Related Guides + +- [API Development Guide](/guides/api-development) - For database usage in routes +- [Architecture Diagrams](/architecture/diagrams/04-data-model) - For entity relationships diff --git a/packages/docs/guides/development-workflow.md b/packages/docs/guides/development-workflow.md new file mode 100644 index 000000000..0a686e704 --- /dev/null +++ b/packages/docs/guides/development-workflow.md @@ -0,0 +1,270 @@ +# Development Workflow Guide + +This guide covers getting started, development commands, code organization, and common development tasks. + +## Getting Started + +### Prerequisites + +- **Node.js** v18 or higher +- **pnpm** (package manager) - Install via `npm install -g pnpm` +- **Cloudflare account** (for Workers/D1 development) +- **Git** (for version control) + +### Installation + +```bash +# Clone the repository +git clone +cd corates + +# Install dependencies +pnpm install + +# Setup local development environment +# See package.json scripts for available commands +``` + +### Initial Setup + +1. **Environment Variables**: Copy `.env.example` to `.env` and fill in required values +2. **Database**: Create local D1 database via Wrangler +3. **Run Migrations**: Apply database migrations +4. **Start Dev Server**: Run `pnpm dev` to start development servers + +## Development Commands + +### Package Scripts + +From the root directory: + +```bash +# Development +pnpm dev # Start all development servers +pnpm dev:web # Start frontend dev server +pnpm dev:workers # Start backend dev server +pnpm dev:docs # Start docs dev server + +# Building +pnpm build # Build all packages +pnpm build:web # Build frontend +pnpm build:workers # Build backend +pnpm build:docs # Build docs + +# Testing +pnpm test # Run all tests +pnpm test:watch # Run tests in watch mode +pnpm lint # Lint all packages +pnpm lint:fix # Lint and fix issues + +# Database +pnpm db:migrate # Run database migrations (local) +pnpm db:generate # Generate migrations from schema +``` + +### Package-Specific Commands + +Each package has its own scripts in `packages/*/package.json`: + +```bash +# From package directory +cd packages/web +pnpm dev # Start dev server for this package +pnpm build # Build this package +pnpm test # Run tests for this package +``` + +## Code Organization + +### File Structure + +``` +packages/ +├── web/ # Frontend application +│ └── src/ +│ ├── components/ # UI components +│ ├── stores/ # State management +│ ├── primitives/ # Reusable hooks +│ ├── routes/ # Routes +│ ├── lib/ # Utilities +│ └── config/ # Configuration +├── workers/ # Backend API +│ └── src/ +│ ├── routes/ # API routes +│ ├── db/ # Database schema +│ ├── middleware/ # Middleware +│ ├── auth/ # Authentication +│ └── config/ # Configuration +├── ui/ # Shared UI components +└── shared/ # Shared utilities +``` + +### Naming Conventions + +- **Components**: PascalCase (e.g., `ProjectCard.jsx`) +- **Stores**: camelCase with `Store` suffix (e.g., `projectStore.js`) +- **Primitives**: camelCase with `use` prefix (e.g., `useProject.js`) +- **Utilities**: camelCase (e.g., `errorUtils.js`) +- **Constants**: UPPER_SNAKE_CASE (e.g., `API_BASE`) + +### Code Comments + +Comments should explain **why**, not **what**: + +```js +// GOOD - explains why +// Some APIs occasionally return 500s on valid requests. We retry up to 3 times +// before surfacing an error. +retries += 1; + +// BAD - narrates what the code does +retries += 1; // Increment retries counter +``` + +## Git Workflow + +### Branch Naming + +- `feature/description` - New features +- `fix/description` - Bug fixes +- `docs/description` - Documentation changes +- `refactor/description` - Code refactoring + +### Commit Messages + +Follow conventional commits: + +``` +feat: add project creation form +fix: handle offline state in project store +docs: update API development guide +refactor: simplify error handling +``` + +### Pull Requests + +- Keep PRs focused and small +- Include description of changes +- Reference related issues +- Ensure tests pass +- Update documentation if needed + +## Common Development Tasks + +### Adding a New API Route + +1. Create route file in `packages/workers/src/routes/` +2. Add validation schema in `packages/workers/src/config/validation.js` +3. Add route to main app in `packages/workers/src/index.js` +4. Write tests in `packages/workers/src/routes/__tests__/` +5. Update API documentation if needed + +### Adding a New Component + +1. Create component file in appropriate directory under `packages/web/src/components/` +2. Use path aliases for imports +3. Follow component patterns (see [Component Guide](/guides/components)) +4. Add to barrel export if in a feature directory +5. Write tests if component has complex logic + +### Adding a New Store + +1. Create store file in `packages/web/src/stores/` +2. Create corresponding actions store if needed +3. Export as singleton +4. Document store structure in comments +5. Use in components via direct imports + +### Adding Database Schema Changes + +1. Update schema in `packages/workers/src/db/schema.js` +2. Update migration SQL in `packages/workers/migrations/0001_init.sql` +3. Regenerate test SQL: `pnpm db:generate:test` +4. Run migrations locally: `pnpm db:migrate` +5. Test changes with database operations + +### Debugging + +#### Frontend Debugging + +- Use browser DevTools +- Check SolidJS DevTools extension +- Inspect store state in console +- Use `console.log` for debugging (remove before commit) + +#### Backend Debugging + +- Use `console.log` in Workers (visible in Wrangler logs) +- Check D1 database via Wrangler CLI +- Use `wrangler tail` for real-time logs +- Test routes with curl or Postman + +## Common Issues and Solutions + +### Issue: Tests failing with database errors + +**Solution:** Ensure database is reset between tests: + +```js +beforeEach(async () => { + await resetTestDatabase(); +}); +``` + +### Issue: Import aliases not working + +**Solution:** Check `jsconfig.json` paths are correct, restart dev server + +### Issue: Durable Object state persisting between tests + +**Solution:** This is expected with `isolatedStorage: false`. Reset database instead. + +### Issue: CORS errors in development + +**Solution:** Check CORS middleware configuration in `packages/workers/src/index.js` + +### Issue: Build errors after dependency updates + +**Solution:** Clear node_modules and reinstall: + +```bash +rm -rf node_modules packages/*/node_modules +pnpm install +``` + +## Best Practices + +### DO + +- Run tests before committing +- Lint code before committing +- Write tests for new features +- Update documentation when adding features +- Use TypeScript types/JSDoc for better IDE support +- Follow existing code patterns +- Keep components small and focused +- Use stores for shared state + +### DON'T + +- Don't commit console.log statements +- Don't skip tests +- Don't ignore linting errors +- Don't prop-drill state (use stores) +- Don't destructure props in SolidJS components +- Don't use raw SQL (use Drizzle ORM) +- Don't create circular dependencies + +## Resources + +- [Contributing Guide](/.github/Contributing.md) - Detailed contribution guidelines +- [Architecture Diagrams](/architecture/) - System architecture +- [Error Handling Guide](/guides/error-handling) - Error handling patterns +- [Style Guide](/guides/style-guide) - UI/UX guidelines + +## Related Guides + +- [Configuration Guide](/guides/configuration) - Configuration details +- [Testing Guide](/guides/testing) - Testing patterns +- [API Development Guide](/guides/api-development) - Backend development +- [Component Development Guide](/guides/components) - Frontend development diff --git a/packages/docs/guides/error-handling.md b/packages/docs/guides/error-handling.md new file mode 100644 index 000000000..875f545a4 --- /dev/null +++ b/packages/docs/guides/error-handling.md @@ -0,0 +1,241 @@ +# Error Handling Guide + +This guide explains how to handle errors consistently across the CoRATES application using the shared error system. + +## Overview + +CoRATES uses a centralized error system defined in `@corates/shared` that provides: + +- **Type-safe error codes** - String-based error codes (e.g., `PROJECT_NOT_FOUND`) +- **Canonical error shape** - Consistent error structure across frontend and backend +- **Domain vs Transport separation** - Clear distinction between API errors and network errors +- **Validation error details** - Field-level error information for forms + +## Error Types + +### Domain Errors + +Domain errors come from the backend API and always include a `statusCode`. These represent business logic errors. + +```javascript +import { createDomainError, PROJECT_ERRORS } from '@corates/shared'; + +// Create a domain error +const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId: '123' }); +// Returns: { code: 'PROJECT_NOT_FOUND', message: 'Project not found', statusCode: 404, ... } +``` + +### Transport Errors + +Transport errors occur during network/fetch operations (frontend only). They do NOT have a `statusCode`. + +```javascript +import { createTransportError } from '@corates/shared'; + +// Create a transport error +const error = createTransportError('TRANSPORT_NETWORK_ERROR'); +// Returns: { code: 'TRANSPORT_NETWORK_ERROR', message: 'Unable to connect...', ... } +``` + +### Validation Errors + +Validation errors are a special type of domain error with field-level details. + +```javascript +import { createValidationError, createMultiFieldValidationError } from '@corates/shared'; + +// Single field error +const error = createValidationError('email', 'VALIDATION_FIELD_REQUIRED', ''); +// Returns: { code: 'VALIDATION_FIELD_REQUIRED', details: { field: 'email', value: '', ... }, ... } + +// Multi-field error +const multiError = createMultiFieldValidationError([ + { field: 'email', code: 'VALIDATION_FIELD_REQUIRED', message: 'Email is required' }, + { field: 'password', code: 'VALIDATION_FIELD_TOO_SHORT', message: 'Password too short' }, +]); +``` + +## Backend Usage + +### Creating Domain Errors + +```javascript +// packages/workers/src/routes/projects.js +import { createDomainError, PROJECT_ERRORS } from '@corates/shared'; + +export async function getProject(c) { + const project = await db.getProject(c.req.param('id')); + + if (!project) { + return c.json(createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId: c.req.param('id') }), 404); + } + + return c.json(project); +} +``` + +### Validation Middleware + +The validation middleware automatically creates validation errors: + +```javascript +// packages/workers/src/config/validation.js +// Already configured to use shared error system +// Returns DomainError objects with validation details +``` + +## Frontend Usage + +### Handling API Errors + +```javascript +// packages/web/src/lib/error-utils.js +import { handleFetchError, handleDomainError } from '@/lib/error-utils.js'; + +// Wrap fetch calls +try { + const response = await handleFetchError(fetch('/api/projects'), { showToast: true }); + const data = await response.json(); +} catch (error) { + // Error already handled (toast shown, etc.) + // error is a DomainError or TransportError +} +``` + +### Form Error Handling + +```javascript +// packages/web/src/lib/form-errors.js +import { createFormErrorSignals, handleFormError } from '@/lib/form-errors.js'; +import { createSignal } from 'solid-js'; + +function MyForm() { + const errors = createFormErrorSignals(createSignal); + + async function handleSubmit() { + try { + const response = await fetch('/api/projects', { ... }); + // ... + } catch (error) { + // Handle validation errors with field details + errors.handleError(error); + } + } + + return ( +
+ + {errors.fieldErrors().email && ( + {errors.fieldErrors().email} + )} + {errors.globalError() && ( +
{errors.globalError()}
+ )} +
+ ); +} +``` + +### Error Boundaries + +Error boundaries catch rendering errors and unknown/programmer errors: + +```javascript +// packages/web/src/components/ErrorBoundary.jsx +import AppErrorBoundary from '@/components/ErrorBoundary.jsx'; + +function App() { + return ( + + + + ); +} +``` + +## Error Code Reference + +### Authentication Errors (`AUTH_*`) + +- `AUTH_REQUIRED` - Authentication required (401) +- `AUTH_INVALID` - Invalid credentials (401) +- `AUTH_EXPIRED` - Session expired (401) +- `AUTH_FORBIDDEN` - Access denied (403) + +### Validation Errors (`VALIDATION_*`) + +- `VALIDATION_FIELD_REQUIRED` - Required field missing (400) +- `VALIDATION_FIELD_INVALID_FORMAT` - Invalid format (400) +- `VALIDATION_FIELD_TOO_LONG` - Value too long (400) +- `VALIDATION_FIELD_TOO_SHORT` - Value too short (400) +- `VALIDATION_MULTI_FIELD` - Multiple field errors (400) +- `VALIDATION_FAILED` - General validation failure (400) + +### Project Errors (`PROJECT_*`) + +- `PROJECT_NOT_FOUND` - Project not found (404) +- `PROJECT_ACCESS_DENIED` - Access denied (403) +- `PROJECT_MEMBER_EXISTS` - User already a member (409) +- `PROJECT_LAST_OWNER` - Cannot remove last owner (400) + +### File Errors (`FILE_*`) + +- `FILE_TOO_LARGE` - File exceeds size limit (413) +- `FILE_INVALID_TYPE` - Invalid file type (400) +- `FILE_NOT_FOUND` - File not found (404) +- `FILE_UPLOAD_FAILED` - Upload failed (500) + +### Transport Errors (`TRANSPORT_*`) + +- `TRANSPORT_NETWORK_ERROR` - Network connection failed +- `TRANSPORT_TIMEOUT` - Request timed out +- `TRANSPORT_CORS_ERROR` - CORS error + +### Unknown Errors (`UNKNOWN_*`) + +- `UNKNOWN_PROGRAMMER_ERROR` - Programmer error (500) +- `UNKNOWN_UNHANDLED_ERROR` - Unhandled error (500) +- `UNKNOWN_INVALID_RESPONSE` - Invalid API response (500) + +## Best Practices + +### ✅ DO + +- Use error helpers from `@corates/shared` +- Handle domain errors with `handleDomainError()` +- Handle transport errors with `handleTransportError()` +- Use form error utilities for validation errors +- Wrap fetch calls with `handleFetchError()` +- Use error boundaries for rendering errors + +### ❌ DON'T + +- Throw string literals (use `no-throw-literal` ESLint rule) +- Create raw `Error()` objects without error codes +- Use string matching for error codes +- Mix domain and transport error handling +- Ignore error boundaries + +## ESLint Rules + +The project includes ESLint rules to enforce error handling: + +- `no-throw-literal: error` - Prevents throwing string literals + +## Migration Guide + +When migrating existing code: + +1. Replace numeric error codes with string codes from `@corates/shared` +2. Replace `createErrorResponse()` with `createDomainError()` +3. Replace string-based error matching with type-safe code checks +4. Use `handleFetchError()` for all fetch calls +5. Use form error utilities for form validation + +## Examples + +See the test files for comprehensive examples: + +- `packages/shared/src/errors/__tests__/helpers.test.ts` +- `packages/web/src/lib/__tests__/error-utils.test.js` +- `packages/web/src/lib/__tests__/form-errors.test.js` diff --git a/packages/docs/guides/index.md b/packages/docs/guides/index.md new file mode 100644 index 000000000..af33467df --- /dev/null +++ b/packages/docs/guides/index.md @@ -0,0 +1,26 @@ +# Guides + +Practical guides for common development tasks and patterns in CoRATES. + +## Available Guides + +### Core Development + +- [State Management](/guides/state-management) - Managing application state with SolidJS stores +- [Primitives](/guides/primitives) - Reusable hooks and primitives for SolidJS +- [Components](/guides/components) - Component development patterns and best practices +- [API Development](/guides/api-development) - Backend API route development patterns + +### System-Specific + +- [Authentication](/guides/authentication) - Authentication setup, configuration, and usage with Better Auth +- [Yjs Sync](/guides/yjs-sync) - Collaborative editing and synchronization with Yjs +- [Database](/guides/database) - Database schema, Drizzle ORM patterns, and migration workflow + +### Supporting + +- [Configuration](/guides/configuration) - Configuration files, environment variables, and path aliases +- [Testing](/guides/testing) - Testing philosophy, patterns, and best practices for frontend and backend +- [Development Workflow](/guides/development-workflow) - Getting started, development commands, and common tasks +- [Error Handling](/guides/error-handling) - How to handle errors consistently across the application +- [Style Guide](/guides/style-guide) - UI/UX guidelines and design system diff --git a/packages/docs/guides/primitives.md b/packages/docs/guides/primitives.md new file mode 100644 index 000000000..b60df1c89 --- /dev/null +++ b/packages/docs/guides/primitives.md @@ -0,0 +1,505 @@ +# Primitives Guide + +This guide explains the primitives (hooks) pattern in CoRATES, when to create primitives, and how to structure them. + +## Overview + +Primitives are reusable SolidJS hooks that encapsulate business logic, state management, and side effects. They keep components lean by moving logic out of components into reusable functions. + +## What Are Primitives? + +Primitives are similar to React hooks - they're functions that: + +- Start with `use` (e.g., `useProject`, `useSubscription`) +- Use SolidJS reactive primitives (`createSignal`, `createStore`, `createMemo`, `createEffect`) +- Return reactive values and helper functions +- Can be composed together +- Handle their own cleanup + +## When to Create a Primitive + +### Create a Primitive When + +1. **Logic is reused** - Multiple components need the same logic +2. **Complex state/effects** - Managing connections, subscriptions, or complex state +3. **Business logic** - Domain-specific operations (e.g., project operations, auth) +4. **Side effects** - API calls, WebSocket connections, event listeners + +### Use a Component When + +1. **UI-only** - Pure rendering with no business logic +2. **Component-specific** - Logic that only applies to one component + +### Use a Utility When + +1. **Pure functions** - No state or side effects +2. **Stateless operations** - Data transformations, validations + +### Use a Store When + +1. **Global state** - Shared across many components/features +2. **Persistent state** - Needs to survive navigation + +### Decision Tree + +``` +Is this logic reusable across multiple components? +├─ YES → Does it manage state or side effects? +│ ├─ YES → Create a primitive (hook) +│ └─ NO → Create a utility function +│ +└─ NO → Is it business logic or state management? + ├─ YES → Consider if it should be a store (if global) or primitive (if scoped) + └─ NO → Keep in component +``` + +## Primitive Structure + +### Basic Primitive Pattern + +```js +import { createSignal, createEffect, onCleanup } from 'solid-js'; + +export function useMyPrimitive(options = {}) { + // Internal state + const [value, setValue] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + + // Side effects + createEffect(() => { + // React to changes + const someValue = options.someProp?.(); + if (someValue) { + // Do something + } + }); + + // Cleanup + onCleanup(() => { + // Clean up subscriptions, timers, etc. + }); + + // Helper functions + async function doSomething() { + setLoading(true); + try { + // Perform operation + setValue(result); + } catch (err) { + setError(err); + } finally { + setLoading(false); + } + } + + // Return reactive values and helpers + return { + value, + loading, + error, + doSomething, + }; +} +``` + +### Primitive with Store Integration + +Primitives often interact with stores: + +```js +import projectStore from '@/stores/projectStore.js'; +import projectActionsStore from '@/stores/projectActionsStore'; + +export function useProjectData(projectId) { + // Read from store reactively + const project = () => projectStore.getProject(projectId); + const studies = () => projectStore.getStudies(projectId); + const connectionState = () => projectStore.getConnectionState(projectId); + + // Helper to check if connected + const isConnected = () => connectionState().connected; + + return { + project, + studies, + isConnected, + connectionState, + }; +} +``` + +## Primitive Examples + +### useProject - Complex Connection Management + +The `useProject` primitive manages Yjs connections, sync, and project operations: + +```149:160:packages/web/src/primitives/useProject/index.js +export function useProject(projectId) { + const isOnline = useOnlineStatus(); + + // Get or create a shared connection for this project + const connection = getOrCreateConnection(projectId); + + // Initialize connection if not already initialized + if (!connection.initialized) { + connection.initialized = true; + + // Set up IndexedDB persistence + connection.indexeddbProvider = new IndexeddbPersistence( + `${INDEXEDDB_PREFIX}${projectId}`, + connection.ydoc, + ); +``` + +This primitive: + +- Manages WebSocket connections +- Handles IndexedDB persistence +- Coordinates sync between Yjs and the store +- Provides operations for studies, checklists, PDFs +- Handles cleanup on disconnect + +### useSubscription - Resource-Based Primitive + +The `useSubscription` primitive uses `createResource` for async data: + +```55:75:packages/web/src/primitives/useSubscription.js +export function useSubscription() { + const { isLoggedIn } = useBetterAuth(); + + // Only fetch subscription when user is logged in + // This prevents errors during signout when component is still mounted + const [subscription, { refetch, mutate }] = createResource( + () => (isLoggedIn() ? getSubscriptionSafe() : null), + { + initialValue: DEFAULT_SUBSCRIPTION, + }, + ); + + /** + * Current subscription tier + */ + const tier = createMemo(() => subscription()?.tier ?? 'free'); + + /** + * Whether the subscription is active + */ +``` + +This primitive: + +- Uses `createResource` for async data fetching +- Provides memoized computed values +- Handles loading and error states +- Returns permission helpers + +### useProjectData - Lightweight Store Wrapper + +The `useProjectData` primitive provides a lightweight way to read project data: + +```22:60:packages/web/src/primitives/useProjectData.js +export function useProjectData(projectId, options = {}) { + const { autoConnect = true } = options; + + // If autoConnect is enabled and we don't have a connection, establish one + // This ensures the store gets populated + let projectHook = null; + if (autoConnect) { + // Only create connection if we need one + const connectionState = () => projectStore.getConnectionState(projectId); + const needsConnection = () => !connectionState().connected && !connectionState().connecting; + + if (needsConnection()) { + projectHook = useProject(projectId); + } + } + + // Return reactive getters that read from the store + return { + // Data getters (reactive) + studies: () => projectStore.getStudies(projectId), + members: () => projectStore.getMembers(projectId), + meta: () => projectStore.getMeta(projectId), + + // Connection state (reactive) + connected: () => projectStore.getConnectionState(projectId).connected, + connecting: () => projectStore.getConnectionState(projectId).connecting, + synced: () => projectStore.getConnectionState(projectId).synced, + error: () => projectStore.getConnectionState(projectId).error, + + // Helpers + hasData: () => projectStore.hasProject(projectId), + getStudy: studyId => projectStore.getStudy(projectId, studyId), + getChecklist: (studyId, checklistId) => + projectStore.getChecklist(projectId, studyId, checklistId), + + // If we created a connection, expose disconnect + disconnect: projectHook?.disconnect, + }; +} +``` + +This primitive: + +- Provides reactive getters from the store +- Optionally establishes connections +- Keeps components simple when only reading data + +### useOnlineStatus - Simple Signal Primitive + +Simple primitives can just wrap browser APIs: + +```1:30:packages/web/src/primitives/useOnlineStatus.js +import { createSignal, onMount, onCleanup } from 'solid-js'; + +/** + * Hook to track online/offline status + * Returns a reactive signal that updates when network status changes + */ +export function useOnlineStatus() { + const [isOnline, setIsOnline] = createSignal( + typeof navigator !== 'undefined' ? navigator.onLine : true, + ); + + onMount(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + onCleanup(() => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }); + }); + + return isOnline; +} + +export default useOnlineStatus; +``` + +This primitive: + +- Wraps browser API in a reactive signal +- Handles event listener cleanup +- Works in SSR contexts (checks for `navigator`) + +## Primitive Composition + +Primitives can use other primitives: + +```js +import useOnlineStatus from '../useOnlineStatus.js'; +import projectStore from '@/stores/projectStore.js'; + +export function useProject(projectId) { + // Use another primitive + const isOnline = useOnlineStatus(); + + // Use store + const connectionState = () => projectStore.getConnectionState(projectId); + + // Combine primitives + const canSync = () => isOnline() && connectionState().connected; + + return { + isOnline, + connectionState, + canSync, + }; +} +``` + +## Using Primitives in Components + +Import and use primitives directly in components: + +```js +import { useProjectData } from '@/primitives/useProjectData.js'; + +function MyComponent(props) { + // Use the primitive + const { studies, isConnected } = useProjectData(props.projectId); + + return ( +
+ + {study => } + +
+ ); +} +``` + +## Primitive Lifecycle + +### Initialization + +Primitives are called during component render: + +```js +function MyComponent() { + // Primitive is initialized here + const data = useMyPrimitive(); + + // Use the primitive's return values + return
{data.value()}
; +} +``` + +### Cleanup + +Use `onCleanup` for cleanup logic: + +```js +export function useMyPrimitive() { + createEffect(() => { + const interval = setInterval(() => { + // Do something + }, 1000); + + onCleanup(() => { + clearInterval(interval); + }); + }); +} +``` + +### Multiple Instances + +Each component call creates a new primitive instance: + +```js +function Component() { + // These are separate instances + const project1 = useProject('project-1'); + const project2 = useProject('project-2'); + + // Each manages its own state +} +``` + +## Best Practices + +### DO + +- Use `use` prefix for primitives +- Return reactive values (signals, memos) +- Handle cleanup with `onCleanup` +- Compose primitives together +- Keep primitives focused on one concern +- Use stores for shared state, primitives for component-scoped state + +### DON'T + +- Don't call primitives conditionally +- Don't use primitives for pure utilities +- Don't expose raw setters from stores (use action stores) +- Don't create primitives that duplicate store functionality +- Don't forget cleanup for subscriptions/timers + +## Testing Primitives + +Test primitives by rendering them in a test component: + +```js +import { describe, it, expect } from 'vitest'; +import { createRoot } from 'solid-js'; +import useOnlineStatus from '../useOnlineStatus'; + +describe('useOnlineStatus', () => { + it('should return online status', () => { + let result; + createRoot(dispose => { + result = useOnlineStatus(); + dispose(); + }); + expect(result()).toBe(navigator.onLine); + }); +}); +``` + +## Common Patterns + +### Pattern: Resource Fetching + +```js +import { createResource } from 'solid-js'; + +export function useData(id) { + const [data, { refetch }] = createResource( + () => id(), + async id => { + const response = await fetch(`/api/data/${id}`); + return response.json(); + }, + ); + + return { + data, + loading: () => data.loading, + error: () => data.error, + refetch, + }; +} +``` + +### Pattern: Store Integration + +```js +import myStore from '@/stores/myStore.js'; + +export function useMyData(id) { + // Read from store reactively + const item = () => myStore.getItem(id()); + + // Memoized computed value + const displayName = createMemo(() => { + const i = item(); + return i ? `${i.name} (${i.status})` : 'Loading...'; + }); + + return { + item, + displayName, + }; +} +``` + +### Pattern: Connection Management + +```js +export function useConnection(url) { + const [connected, setConnected] = createSignal(false); + let ws = null; + + createEffect(() => { + const urlValue = url(); + if (!urlValue) return; + + ws = new WebSocket(urlValue); + ws.onopen = () => setConnected(true); + ws.onclose = () => setConnected(false); + + onCleanup(() => { + ws?.close(); + setConnected(false); + }); + }); + + return { + connected, + send: data => ws?.send(JSON.stringify(data)), + }; +} +``` + +## Related Guides + +- [State Management Guide](/guides/state-management) - For understanding stores vs primitives +- [Component Development Guide](/guides/components) - For using primitives in components +- [Yjs Sync Guide](/guides/yjs-sync) - For understanding `useProject` primitive diff --git a/packages/docs/guides/state-management.md b/packages/docs/guides/state-management.md new file mode 100644 index 000000000..fed219571 --- /dev/null +++ b/packages/docs/guides/state-management.md @@ -0,0 +1,539 @@ +# State Management Guide + +This guide explains how state management works in CoRATES, covering the store architecture pattern, when to use stores vs props, and implementation patterns. + +## Overview + +CoRATES uses a centralized store architecture built on SolidJS's `createStore` for managing application state. The pattern separates **read operations** (data stores) from **write operations** (action stores), providing clear boundaries and eliminating prop drilling. + +## Store Architecture Pattern + +### Read Stores vs Action Stores + +The codebase uses a separation pattern: + +- **Read Stores** (`*Store.js`) - Hold cached data and provide getters/selectors +- **Action Stores** (`*ActionsStore.js`) - Manage write operations and mutations + +```js +// Read from store +import projectStore from '@/stores/projectStore.js'; +const projects = () => projectStore.getProjectList(); + +// Write via actions store +import projectActionsStore from '@/stores/projectActionsStore'; +projectActionsStore.createProject({ name: 'New Project' }); +``` + +### Key Benefits + +- **No prop drilling** - Components import stores directly +- **Single source of truth** - Data lives in one place +- **Clear separation** - Reads vs writes are explicit +- **Reactive updates** - SolidJS store updates trigger UI re-renders +- **Offline support** - Stores handle caching and persistence + +## When to Use Stores + +### Use Stores For + +1. **Shared/cross-feature state** - Data used across multiple components/features +2. **Persistent data** - Data that should survive navigation +3. **Cached API data** - Data fetched from APIs that should be cached +4. **Connection state** - WebSocket/Yjs connection status +5. **User session** - Authentication state and user data + +### Use Props For + +1. **Local component configuration** - Settings specific to one component +2. **Parent-child communication** - Data passed directly from parent +3. **UI state only** - Modal open/close, form field values (unless shared) + +### Use Context For + +1. **Feature-scoped state** - State that only matters within a feature tree +2. **Avoid if possible** - Prefer stores for shared state + +### Decision Tree + +``` +Is this state shared across multiple components/features? +├─ YES → Use a store +│ └─ Does it need write operations? +│ ├─ YES → Create both *Store.js and *ActionsStore.js +│ └─ NO → Create just *Store.js +│ +└─ NO → Is it configuration for a single component? + ├─ YES → Use props + └─ NO → Use createSignal or createStore (local state) +``` + +## Store Implementation Patterns + +### Creating a Read Store + +Read stores use SolidJS `createStore` for reactive state management: + +```js +import { createStore, produce } from 'solid-js/store'; + +function createMyStore() { + const [store, setStore] = createStore({ + items: [], + loading: false, + error: null, + }); + + // Getters + function getItems() { + return store.items; + } + + function getItem(id) { + return store.items.find(item => item.id === id); + } + + // Setters (internal use only, prefer action stores for mutations) + function setItems(items) { + setStore('items', items); + } + + // Complex updates using produce + function updateItem(id, updates) { + setStore( + produce(s => { + const item = s.items.find(item => item.id === id); + if (item) { + Object.assign(item, updates); + } + }), + ); + } + + return { + store, // Expose raw store for reactive access + getItems, + getItem, + setItems, + updateItem, + }; +} + +// Create singleton +const myStore = createMyStore(); +export default myStore; +``` + +### Creating an Action Store + +Action stores manage write operations and coordinate with read stores: + +```js +import myStore from '@/stores/myStore.js'; +import { handleFetchError } from '@/lib/error-utils.js'; +import { showToast } from '@corates/ui'; +import { API_BASE } from '@config/api.js'; + +function createMyActionsStore() { + async function createItem(data) { + try { + const response = await handleFetchError( + fetch(`${API_BASE}/api/items`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'include', + }), + { showToast: true }, + ); + + const newItem = await response.json(); + + // Update read store + const currentItems = myStore.getItems(); + myStore.setItems([...currentItems, newItem]); + + showToast.success('Item created'); + return newItem; + } catch (error) { + // Error already handled by handleFetchError + throw error; + } + } + + async function updateItem(id, updates) { + try { + const response = await handleFetchError( + fetch(`${API_BASE}/api/items/${id}`, { + method: 'PATCH', + body: JSON.stringify(updates), + credentials: 'include', + }), + { showToast: true }, + ); + + const updatedItem = await response.json(); + + // Update read store + myStore.updateItem(id, updatedItem); + + showToast.success('Item updated'); + return updatedItem; + } catch (error) { + throw error; + } + } + + return { + createItem, + updateItem, + }; +} + +const myActionsStore = createMyActionsStore(); +export default myActionsStore; +``` + +### Store with localStorage Caching + +Stores can cache data in localStorage for offline support: + +```88:97:packages/web/src/stores/projectStore.js + const [store, setStore] = createStore({ + // Cached project data by projectId (Y.js data: studies, members, meta) + projects: {}, + // Currently active project + activeProjectId: null, + // Connection states by projectId + connections: {}, + // Project list from API (for dashboard) + projectList: initialProjectList, + }); +``` + +Example caching pattern: + +```26:49:packages/web/src/stores/projectStore.js + function loadCachedProjectList() { + if (typeof window === 'undefined') return null; + try { + const cached = localStorage.getItem(PROJECT_LIST_CACHE_KEY); + const timestamp = localStorage.getItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); + const cachedUserId = localStorage.getItem(PROJECT_LIST_CACHE_USER_ID_KEY); + if (!cached || !timestamp) return null; + + const age = Date.now() - parseInt(timestamp, 10); + if (age > PROJECT_LIST_CACHE_MAX_AGE) { + // Cache expired, clear it + localStorage.removeItem(PROJECT_LIST_CACHE_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_USER_ID_KEY); + return null; + } + + return { projects: JSON.parse(cached), userId: cachedUserId }; + } catch (err) { + console.error('Error loading cached project list:', err); + return null; + } + } +``` + +## Using Stores in Components + +### Reading from Stores + +Import the store directly and use getters: + +```js +import projectStore from '@/stores/projectStore.js'; + +function MyComponent() { + // Reactive getter - updates when store changes + const projects = () => projectStore.getProjectList(); + + // Direct access to store (if needed) + const activeProjectId = () => projectStore.store.activeProjectId; + + return ( +
+ {project => } +
+ ); +} +``` + +### Writing via Action Stores + +Import the action store and call mutation methods: + +```js +import projectActionsStore from '@/stores/projectActionsStore'; + +function CreateProjectForm() { + async function handleSubmit(e) { + e.preventDefault(); + const formData = new FormData(e.target); + + await projectActionsStore.createProject({ + name: formData.get('name'), + description: formData.get('description'), + }); + // Store updates automatically, UI re-renders + } + + return
{/* form fields */}
; +} +``` + +### Never Prop-Drill Store Data + +```js +// WRONG - prop drilling +function App() { + const projects = () => projectStore.getProjectList(); + return ; +} + +function ProjectList({ projects }) { + return ; +} + +// CORRECT - import store directly +function ProjectList() { + const projects = () => projectStore.getProjectList(); + return ; +} + +function ProjectDashboard() { + // Import store directly, no props needed + const projects = () => projectStore.getProjectList(); + // ... +} +``` + +## Store Examples + +### Project Store + +The project store manages project data, connection states, and caching: + +```99:112:packages/web/src/stores/projectStore.js + function getProject(projectId) { + return store.projects[projectId]; + } + + /** + * Get active project data + */ + function getActiveProject() { + if (!store.activeProjectId) return null; + return store.projects[store.activeProjectId] || null; + } + + /** + * Set the active project + */ + function setActiveProject(projectId) { + setStore('activeProjectId', projectId); + } +``` + +### Project Actions Store + +The actions store manages all write operations: + +```26:67:packages/web/src/stores/projectActionsStore/index.js +function createProjectActionsStore() { + /** + * Map of projectId -> Y.js connection operations + * Set by useProject hook when connecting + * @type {Map} + */ + const connections = new Map(); + + /** + * The currently active project ID. + * Set by ProjectView when a project is opened. + * Most methods use this automatically so components don't need to pass it. + */ + let activeProjectId = null; + + // ============================================================================ + // Internal: Active Project & User Access + // ============================================================================ + + /** + * Set the active project (called by ProjectView on mount) + */ + function _setActiveProject(projectId) { + activeProjectId = projectId; + } + + /** + * Clear the active project (called by ProjectView on unmount) + */ + function _clearActiveProject() { + activeProjectId = null; + } + + /** + * Get the active project ID, throws if none set + */ + function getActiveProjectId() { + if (!activeProjectId) { + throw new Error('No active project - are you inside a ProjectView?'); + } + return activeProjectId; + } + + /** + * Get active project ID or null (for components that just need to check) + */ + function getActiveProjectIdOrNull() { + return activeProjectId; + } + + /** + * Get current user ID from auth store + */ + function getCurrentUserId() { + const auth = useBetterAuth(); + return auth.user()?.id || null; + } +``` + +### Better Auth Store + +The auth store wraps Better Auth with caching and offline support: + +```18:65:packages/web/src/api/better-auth-store.js +function createBetterAuthStore() { + // Track online status without reactive primitives (for singleton context) + const [isOnline, setIsOnline] = createSignal(navigator.onLine); + + // Listen for online/offline events + if (typeof window !== 'undefined') { + window.addEventListener('online', () => setIsOnline(true)); + window.addEventListener('offline', () => setIsOnline(false)); + } + + function loadCachedAuth() { + if (typeof window === 'undefined') return null; + try { + const cached = localStorage.getItem(AUTH_CACHE_KEY); + const timestamp = localStorage.getItem(AUTH_CACHE_TIMESTAMP_KEY); + if (!cached || !timestamp) return null; + + const age = Date.now() - parseInt(timestamp, 10); + if (age > AUTH_CACHE_MAX_AGE) { + localStorage.removeItem(AUTH_CACHE_KEY); + localStorage.removeItem(AUTH_CACHE_TIMESTAMP_KEY); + return null; + } + + return JSON.parse(cached); + } catch (err) { + console.error('Error loading cached auth:', err); + return null; + } + } + + // Save auth data to localStorage + function saveCachedAuth(userData) { + if (typeof window === 'undefined') return; + try { + if (userData) { + localStorage.setItem(AUTH_CACHE_KEY, JSON.stringify(userData)); + localStorage.setItem(AUTH_CACHE_TIMESTAMP_KEY, Date.now().toString()); + } else { + localStorage.removeItem(AUTH_CACHE_KEY); + localStorage.removeItem(AUTH_CACHE_TIMESTAMP_KEY); + } + } catch (err) { + console.error('Error saving cached auth:', err); + } + } +``` + +## Store Lifecycle and Cleanup + +### Initialization + +Stores are singletons created at module load time: + +```604:607:packages/web/src/stores/projectStore.js +// Create singleton store without createRoot +// createStore doesn't need a reactive owner/root context +const projectStore = createProjectStore(); + +export default projectStore; +``` + +### Cache Validation + +Stores should validate cached data when appropriate: + +```498:534:packages/web/src/stores/projectStore.js + function validateProjectListCache(currentUserId) { + if (!currentUserId) { + // No user ID, clear the cache + setStore('projectList', { + items: [], + loaded: false, + loading: false, + error: null, + cachedUserId: null, + }); + // Clear localStorage cache + localStorage.removeItem(PROJECT_LIST_CACHE_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_USER_ID_KEY); + return; + } + + const cachedUserId = store.projectList.cachedUserId; + + // If cached user ID doesn't match current user, clear the cache + if (cachedUserId && cachedUserId !== currentUserId) { + console.log( + '[projectStore] Cached project list belongs to different user, clearing cache', + ); + setStore('projectList', { + items: [], + loaded: false, + loading: false, + error: null, + cachedUserId: null, + }); + // Clear localStorage cache + localStorage.removeItem(PROJECT_LIST_CACHE_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); + localStorage.removeItem(PROJECT_LIST_CACHE_USER_ID_KEY); + } + } +``` + +## Best Practices + +### DO + +- Separate read stores from action stores +- Use `produce` for complex nested updates +- Cache data in localStorage for offline support +- Validate cached data (expiry, user matching, etc.) +- Use getters/selectors instead of exposing raw store +- Import stores directly in components (no prop drilling) + +### DON'T + +- Don't prop-drill store data +- Don't mutate store directly (use setters or action stores) +- Don't expose raw `setStore` from read stores +- Don't create stores for local component state +- Don't forget to handle cache invalidation + +## Related Guides + +- [Primitives Guide](/guides/primitives) - For store-like patterns that are component-scoped +- [Component Development Guide](/guides/components) - For component state patterns +- [Yjs Sync Guide](/guides/yjs-sync) - For understanding how Yjs updates stores diff --git a/packages/docs/guides/style-guide.md b/packages/docs/guides/style-guide.md new file mode 100644 index 000000000..0ba4682d8 --- /dev/null +++ b/packages/docs/guides/style-guide.md @@ -0,0 +1,402 @@ +# CoRATES UI Style Guide + +## Brand Identity + +### Application Name + +**CoRATES** - Collaborative Research Appraisal Tool for Evidence Synthesis + +### Logo/Brand Icon + +- Circular checkmark icon +- Primary color: `blue-600` +- White checkmark symbol inside + +## Color Palette + +### Primary Colors + +- **Blue Primary**: `blue-600` (#2563eb) +- **Blue Dark**: `blue-700` (#1d4ed8) +- **Blue Light**: `blue-500` (#3b82f6) +- **Blue Hover**: `blue-800` (#1e40af) + +### Accent Colors + +- **Red**: `red-600` (#dc2626) for destructive actions +- **Red Hover**: `red-700` (#b91c1c) +- **Green**: For success states (implied from traffic light patterns) + +### Neutral Colors + +- **Gray Scale**: + - `gray-50` (#f9fafb) - lightest backgrounds + - `gray-100` (#f3f4f6) - subtle backgrounds + - `gray-200` (#e5e7eb) - borders, dividers + - `gray-300` (#d1d5db) - disabled states + - `gray-400` (#9ca3af) - placeholder text, icons + - `gray-500` (#6b7280) - secondary text + - `gray-600` (#4b5563) - body text + - `gray-700` (#374151) - headings + - `gray-800` (#1f2937) - dark text + - `gray-900` (#111827) - darkest text + +### Background Colors + +- **Main Background**: `bg-blue-50` +- **Card Background**: `bg-white` +- **Feature Backgrounds**: `bg-blue-50`, `bg-blue-100` +- **Sidebar Background**: Light gray tones + +## Typography + +### Font Family + +Primary: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` + +### Font Sizes + +- **4XS**: `0.375rem` (6px) +- **3XS**: `0.5rem` (8px) +- **2XS**: `0.625rem` (10px) +- **XS**: `text-xs` - 12px +- **SM**: `text-sm` - 14px +- **Base**: `text-base` - 16px +- **LG**: `text-lg` - 18px +- **XL**: `text-xl` - 20px +- **2XL**: `text-2xl` - 24px +- **3XL**: `text-3xl` - 30px +- **4XL**: `text-4xl` - 36px +- **6XL**: `text-6xl` - 60px +- **7XL**: `text-7xl` - 72px + +### Font Weights + +- **Medium**: `font-medium` +- **Semibold**: `font-semibold` +- **Bold**: `font-bold` +- **Extrabold**: `font-extrabold` (for brand name) + +### Text Colors + +- **Primary**: `text-gray-900` +- **Secondary**: `text-gray-600` +- **Muted**: `text-gray-500` +- **Placeholder**: `text-gray-400` +- **Brand**: `text-blue-600` +- **Links**: `text-blue-600` + +## Layout & Spacing + +### Container Patterns + +- **Max Width**: `max-w-4xl mx-auto` for main content +- **Page Padding**: `p-6` for main content areas +- **Card Padding**: `p-4` to `p-8` depending on component + +### Spacing Scale + +- **Gap/Margin**: `gap-2`, `gap-4`, `gap-6`, `gap-8` +- **Padding**: `p-2`, `p-4`, `p-6`, `p-8`, `p-12` + +### Breakpoints + +- **XS**: `30rem` (480px) - Custom breakpoint +- **SM**: Standard Tailwind breakpoints apply + +## Components + +### Buttons + +#### Primary Button + +```jsx +); + + fireEvent.click(screen.getByText('Click me')); + expect(handleClick).toHaveBeenCalledOnce(); +}); +``` + +## Flags for Potential Bugs + +When writing tests, if you discover behavior that conflicts with the expected/intended behavior: + +1. Write the test for the **intended** behavior +2. Add a comment like `// BUG: Current implementation does X, but should do Y` +3. The test will fail, highlighting the bug for fixing + +## Best Practices + +### DO + +- Write tests for intended behavior, not implementation +- Use AAA pattern (Arrange, Act, Assert) +- Test edge cases and error conditions +- Mock external dependencies +- Keep tests isolated and independent +- Use descriptive test names +- Reset database state between tests (backend) +- Always reset the database in `beforeEach` hooks (backend) +- Use unique test data (unique IDs, emails, etc.) to avoid conflicts (backend) +- Mock external dependencies (Postmark, Stripe, etc.) at the test file level or globally (backend) +- Await all async operations to ensure proper cleanup (backend) +- Use Drizzle queries in tests to match production code behavior (backend) +- Test in isolation - each test should be independent and not rely on other tests (backend) + +### DON'T + +- Don't test implementation details +- Don't create tests that depend on other tests +- Don't forget to clean up (database, mocks) +- Don't use real external services in tests +- Don't skip error handling tests + +## Resources + +### Official Documentation + +- [Vitest Documentation](https://vitest.dev/guide/) +- [SolidJS Testing Library](https://github.com/solidjs/solid-testing-library) +- [Testing Library Queries](https://testing-library.com/docs/queries/about) +- [Vitest Mocking](https://vitest.dev/guide/mocking.html) + +### SolidJS-Specific Testing + +- [SolidJS Testing Best Practices](https://www.solidjs.com/guides/testing) +- [Testing Reactive Primitives](https://github.com/solidjs/solid-testing-library#primitives) + +### AMSTAR-2 Domain Knowledge + +- [AMSTAR 2 Official Website](https://amstar.ca/Amstar-2.php) +- [AMSTAR 2 Checklist PDF](https://amstar.ca/Amstar_Checklist.php) + +## Related Guides + +- [API Development Guide](/guides/api-development) - For backend patterns +- [Component Development Guide](/guides/components) - For frontend patterns diff --git a/packages/docs/guides/yjs-sync.md b/packages/docs/guides/yjs-sync.md new file mode 100644 index 000000000..bece80e28 --- /dev/null +++ b/packages/docs/guides/yjs-sync.md @@ -0,0 +1,388 @@ +# Yjs Sync Guide + +This guide explains how collaborative editing works in CoRATES using Yjs CRDTs, Durable Objects, and WebSocket connections. + +## Overview + +CoRATES uses Yjs (Yet another CRDT) for real-time collaborative editing. The architecture consists of: + +- **Frontend**: Yjs documents synced via WebSocket +- **Backend**: Durable Object holding authoritative Yjs document +- **Persistence**: IndexedDB on client, Durable Object storage on server +- **Sync Protocol**: y-protocols/sync for document updates, y-protocols/awareness for presence + +## Architecture + +### Yjs Document Structure + +The Yjs document is organized hierarchically: + +``` +Project (Y.Doc) +├── meta (Y.Map) - Project metadata (name, description, etc.) +├── members (Y.Map) - Project members (userId => { role, joinedAt }) +└── reviews (Y.Map) - Reviews/Studies + └── reviewId (Y.Map) + ├── id, name, description, createdAt, updatedAt + ├── checklists (Y.Map) + │ └── checklistId (Y.Map) + │ ├── id, title, assignedTo, status + │ └── answers (Y.Map) - questionKey => { value, notes, updatedAt, updatedBy } + └── pdfs (Y.Array) - PDF metadata +``` + +### Durable Object Implementation + +The ProjectDoc Durable Object holds the authoritative Yjs document: + +```1:39:packages/workers/src/durable-objects/ProjectDoc.js +import * as Y from 'yjs'; +import * as syncProtocol from 'y-protocols/sync'; +import * as awarenessProtocol from 'y-protocols/awareness'; +import * as encoding from 'lib0/encoding'; +import * as decoding from 'lib0/decoding'; +import { verifyAuth } from '../auth/config.js'; +import { createDb } from '../db/client.js'; +import { projectMembers } from '../db/schema.js'; +import { eq, and } from 'drizzle-orm'; + +// y-websocket message types +const messageSync = 0; +const messageAwareness = 1; + +/** + * ProjectDoc Durable Object + * + * Holds the authoritative Y.Doc for a project with hierarchical structure: + * + * Project (this DO) + * - meta: Y.Map (project metadata: name, description, createdAt, etc.) + * - members: Y.Map (userId => { role, joinedAt }) + * - reviews: Y.Map (reviewId => { + * id, name, description, createdAt, updatedAt, + * checklists: Y.Map (checklistId => { + * id, title, assignedTo (userId), status, createdAt, updatedAt, + * answers: Y.Map (questionKey => { value, notes, updatedAt, updatedBy }) + * }) + * }) + */ +export class ProjectDoc { + constructor(state, env) { + this.state = state; + this.env = env; + // Map + this.sessions = new Map(); + this.doc = null; + this.awareness = null; + } +``` + +### Document Initialization and Persistence + +```286:327:packages/workers/src/durable-objects/ProjectDoc.js + async initializeDoc() { + if (!this.doc) { + this.doc = new Y.Doc(); + this.awareness = new awarenessProtocol.Awareness(this.doc); + + // Load persisted state if exists + const persistedState = await this.state.storage.get('yjs-state'); + if (persistedState) { + Y.applyUpdate(this.doc, new Uint8Array(persistedState)); + } + + // Persist the FULL document state on every update + // This ensures we don't lose data when the DO restarts + this.doc.on('update', async (update, origin) => { + // Encode the full document state, not just the incremental update + const fullState = Y.encodeStateAsUpdate(this.doc); + await this.state.storage.put('yjs-state', Array.from(fullState)); + + // Broadcast update to all connected clients (except origin) + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + this.broadcastBinary(message, origin); + }); + + // Broadcast awareness updates to all clients + this.awareness.on('update', ({ added, updated, removed }, origin) => { + const changedClients = added.concat(updated, removed); + if (changedClients.length > 0) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageAwareness); + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients), + ); + const message = encoding.toUint8Array(encoder); + this.broadcastBinary(message, origin); + } + }); + } + } +``` + +## Client-Side Sync + +### useProject Hook + +The `useProject` primitive manages client-side Yjs connection: + +```149:190:packages/web/src/primitives/useProject/index.js +export function useProject(projectId) { + // Check if this is a local-only project + const isLocalProject = () => projectId && projectId.startsWith('local-'); + + // Get shared connection from registry + const connectionEntry = getOrCreateConnection(projectId); + + const isOnline = useOnlineStatus(); + + // Reactive getters from store + const connectionState = createMemo(() => projectStore.getConnectionState(projectId)); + const connected = () => connectionState().connected; + const connecting = () => connectionState().connecting; + const synced = () => connectionState().synced; + const error = () => connectionState().error; + + const projectData = createMemo(() => projectStore.getProject(projectId)); + const studies = () => projectData()?.studies || []; + const meta = () => projectData()?.meta || {}; + const members = () => projectData()?.members || []; + + // Helper to get the current Y.Doc from the shared connection + const getYDoc = () => connectionEntry?.ydoc || null; + + // Connect to the project's WebSocket (or just IndexedDB for local projects) + function connect() { + if (!projectId || !connectionEntry) return; + + // If already initialized, just return - connection is shared + if (connectionEntry.initialized) return; + + // Mark as initializing to prevent race conditions + connectionEntry.initialized = true; + + // Set this as the active project + projectStore.setActiveProject(projectId); + projectStore.setConnectionState(projectId, { connecting: true, error: null }); + + const { ydoc } = connectionEntry; + + // Initialize sync manager + connectionEntry.syncManager = createSyncManager(projectId, getYDoc); + + // Initialize domain operation modules + connectionEntry.studyOps = createStudyOperations(projectId, getYDoc, synced); + connectionEntry.checklistOps = createChecklistOperations(projectId, getYDoc, synced); + connectionEntry.pdfOps = createPdfOperations(projectId, getYDoc, synced); + connectionEntry.reconciliationOps = createReconciliationOperations(projectId, getYDoc, synced); +``` + +### Sync Manager + +The sync manager handles bidirectional sync between Yjs and the store: + +```14:53:packages/web/src/primitives/useProject/sync.js +export function createSyncManager(projectId, getYDoc) { + /** + * Sync from Y.Doc to store + * Called when Y.Doc changes (from local edits or remote sync) + */ + function syncFromYDoc() { + const ydoc = getYDoc(); + if (!ydoc) return; + + try { + const metaYMap = ydoc.getMap('meta'); + const membersYMap = ydoc.getMap('members'); + const reviewsYMap = ydoc.getMap('reviews'); + + // Convert Yjs structures to plain objects + const meta = yMapToPlain(metaYMap); + const members = Array.from(membersYMap.entries()).map(([userId, memberData]) => ({ + userId, + ...yMapToPlain(memberData), + })); + + const studies = Array.from(reviewsYMap.entries()) + .map(([studyId, studyYMap]) => { + const studyData = yMapToPlain(studyYMap); + return buildStudyFromYMap(studyId, studyData, studyYMap); + }) + .filter(Boolean); + + // Update store + projectStore.setProjectData(projectId, { meta, members, studies }); + projectStore.setConnectionState(projectId, { synced: true }); + } catch (error) { + console.error('Error syncing from Y.Doc:', error); + projectStore.setConnectionState(projectId, { error: error.message }); + } + } + + return { syncFromYDoc }; +} +``` + +### IndexedDB Persistence + +Client-side persistence uses y-indexeddb: + +```js +import { IndexeddbPersistence } from 'y-indexeddb'; + +// Set up IndexedDB persistence for offline support +connectionEntry.indexeddbProvider = new IndexeddbPersistence(`corates-project-${projectId}`, connectionEntry.ydoc); +``` + +## WebSocket Connection + +### Connection Lifecycle + +1. **Connect**: Client opens WebSocket to Durable Object +2. **Authenticate**: Session token sent in query parameter or header +3. **Sync**: Initial document state sent from server +4. **Subscribe**: Client subscribes to updates +5. **Update**: Changes broadcast to all connected clients + +### WebSocket Message Types + +- `messageSync` (0): Document update messages +- `messageAwareness` (1): Presence/awareness updates + +### Handling Updates + +```js +// Client receives update +ws.onmessage = event => { + const data = JSON.parse(event.data); + if (data.type === 'sync' || data.type === 'update') { + const update = new Uint8Array(data.update); + Y.applyUpdate(ydoc, update); + // Sync manager will update the store + } +}; +``` + +## Offline Support + +### Client-Side + +- **IndexedDB**: All Yjs updates persisted locally +- **Queue**: Changes queued when offline +- **Sync**: Automatic sync when connection restored + +### Server-Side + +- **Durable Object Storage**: Full document state persisted +- **State Vector**: Efficient diff-based sync on reconnect + +## Data Operations + +### Reading Data + +Read from the store (which is kept in sync with Yjs): + +```js +import projectStore from '@/stores/projectStore.js'; + +const studies = () => projectStore.getStudies(projectId); +const meta = () => projectStore.getMeta(projectId); +``` + +### Writing Data + +Write via action store, which updates Yjs: + +```js +import projectActionsStore from '@/stores/projectActionsStore'; + +// Create study +await projectActionsStore.createStudy({ + id: studyId, + name: 'Study Name', +}); + +// Update checklist answer +await projectActionsStore.updateChecklistAnswer(studyId, checklistId, 'q1', { + value: 'yes', + notes: 'Based on section 2.1', +}); +``` + +### Yjs Data Structures + +Use appropriate Yjs types: + +- **Y.Map**: For key-value objects (meta, members, answers) +- **Y.Array**: For ordered lists (PDFs) +- **Y.Text**: For collaborative text (notes) + +```js +// Update Y.Map +const metaYMap = ydoc.getMap('meta'); +metaYMap.set('name', 'New Project Name'); + +// Update Y.Array +const pdfsYArray = studyYMap.get('pdfs'); +pdfsYArray.push([{ id: pdfId, name: 'document.pdf' }]); + +// Update nested Y.Map +const answersYMap = checklistYMap.get('answers'); +const answerData = answersYMap.get('q1') || new Y.Map(); +answerData.set('value', 'yes'); +answersYMap.set('q1', answerData); +``` + +## Awareness (Presence) + +Awareness protocol tracks user presence: + +- Current cursor position +- User information +- Custom presence data + +```js +import * as awarenessProtocol from 'y-protocols/awareness'; + +const awareness = new awarenessProtocol.Awareness(ydoc); +awareness.setLocalStateField('user', { + name: 'John Doe', + color: '#ff0000', +}); +``` + +## Conflict Resolution + +Yjs uses CRDTs (Conflict-free Replicated Data Types) for automatic conflict resolution: + +- **No conflicts**: All changes merge automatically +- **Last write wins**: For scalar values (strings, numbers) +- **Ordered merging**: For arrays (based on logical timestamps) + +## Best Practices + +### DO + +- Read from store, not directly from Yjs +- Write via action stores, not directly to Yjs +- Use Y.Map for objects, Y.Array for lists +- Handle offline scenarios gracefully +- Clean up connections when components unmount + +### DON'T + +- Don't modify Yjs structures directly from components +- Don't bypass the store when reading data +- Don't create new Y.Doc instances for the same project +- Don't forget to handle connection errors +- Don't store large binary data in Yjs (use R2/storage instead) + +## Related Guides + +- [State Management Guide](/guides/state-management) - For store patterns +- [Primitives Guide](/guides/primitives) - For useProject hook +- [Architecture Diagrams](/architecture/diagrams/08-yjs-sync) - For visual architecture diff --git a/packages/docs/index.md b/packages/docs/index.md new file mode 100644 index 000000000..9b42799be --- /dev/null +++ b/packages/docs/index.md @@ -0,0 +1,47 @@ +# CoRATES Documentation + +**Collaborative Research Appraisal Tool for Evidence Synthesis** + +Welcome to the CoRATES documentation. This site provides comprehensive guides and architecture documentation for developers working on CoRATES. + +## What is CoRATES? + +CoRATES is a web application designed to streamline the entire quality and risk-of-bias appraisal process with intuitive workflows, real-time collaboration, and automation, creating greater transparency and efficiency at every step. Built for researchers conducting evidence synthesis, it enables real-time collaboration, offline support, and PDF annotation. + +## Tech Stack + +- **Frontend**: SolidJS, SolidStart, Tailwind CSS, Vite, Ark UI +- **Backend**: Cloudflare Workers, Durable Objects +- **Database**: Cloudflare D1 (SQLite) +- **Storage**: Cloudflare R2 (PDF documents) +- **Sync**: Yjs (CRDT), y-indexeddb for local persistence +- **Auth**: BetterAuth + +## Documentation Sections + +### [Architecture](/architecture/) + +Learn about the system architecture, package structure, data models, and how different components interact. + +- Package Architecture +- System Architecture +- Sync Flow +- Data Model +- Routes (Frontend & API) +- API Actions +- Yjs Sync + +### [Guides](/guides/) + +Practical guides for common development tasks and patterns. + +- [Error Handling](/guides/error-handling) - How to handle errors consistently across the application +- [Style Guide](/guides/style-guide) - UI/UX guidelines and design system + +## Getting Started + +For setup instructions and contributing guidelines, see the main repository README in the project root. + +## License + +PolyForm Noncommercial License 1.0.0 diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 000000000..716825623 --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,17 @@ +{ + "name": "@corates/docs", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "PolyForm-Noncommercial-1.0.0", + "scripts": { + "dev": "vitepress dev --port 8080", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3", + "vitepress-plugin-mermaid": "^2.0.17", + "mermaid": "^11.12.2" + } +} diff --git a/packages/web/TESTING.md b/packages/web/TESTING.md deleted file mode 100644 index 2f8b2b632..000000000 --- a/packages/web/TESTING.md +++ /dev/null @@ -1,235 +0,0 @@ -# Testing Guide for CoRATES Web - -This document provides guidelines and resources for testing the CoRATES frontend application. - -## Testing Stack - -- **Test Runner**: [Vitest](https://vitest.dev/) - Fast, Vite-native testing framework -- **Component Testing**: [@solidjs/testing-library](https://github.com/solidjs/solid-testing-library) - Testing utilities for SolidJS -- **DOM Environment**: [jsdom](https://github.com/jsdom/jsdom) - JavaScript implementation of web standards for Node.js - -## Running Tests - -```bash -# Run tests with UI -pnpm test - -# Run tests in watch mode (headless) -pnpm vitest - -# Run tests once (CI mode) -pnpm vitest run - -# Run tests with coverage -pnpm vitest run --coverage -``` - -## Testing Philosophy - -### Behavior-Driven Testing - -Tests should validate **intended behavior**, not implementation details. The current implementation may contain bugs, so tests should be written based on: - -1. Function/component names and their semantic meaning -2. JSDoc comments and inline documentation -3. Domain conventions (AMSTAR-2 methodology, systematic review practices) -4. Expected UX patterns - -### Test Structure - -Follow the AAA pattern: - -- **Arrange**: Set up test data and conditions -- **Act**: Execute the code being tested -- **Assert**: Verify the expected outcomes - -## Directory Structure - -``` -src/ - __tests__/ # Global test utilities and setup - setup.js # Test setup file - config/ - __tests__/ # Tests for config modules - primitives/ - __tests__/ # Tests for hooks/primitives - lib/ - __tests__/ # Tests for utility functions - AMSTAR2/ - __tests__/ # Tests for AMSTAR2 logic - components/ - __tests__/ # Tests for shared components - auth-ui/ - __tests__/ # Tests for auth components - project-ui/ - __tests__/ # Tests for project components - checklist-ui/ - __tests__/ # Tests for checklist components -``` - -## Writing Tests - -### Pure Functions - -For pure utility functions, test input/output relationships: - -```javascript -import { describe, it, expect } from 'vitest'; -import { formatDate } from '../dateUtils'; - -describe('formatDate', () => { - it('should format ISO date strings correctly', () => { - expect(formatDate('2025-01-15T10:30:00Z')).toBe('1/15/2025'); - }); - - it('should handle Unix timestamps in seconds', () => { - expect(formatDate(1705312200)).toBe('1/15/2024'); - }); - - it('should return empty string for invalid input', () => { - expect(formatDate(null)).toBe(''); - expect(formatDate(undefined)).toBe(''); - }); -}); -``` - -### SolidJS Components - -Use `@solidjs/testing-library` for component testing: - -```javascript -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@solidjs/testing-library'; -import MyComponent from '../MyComponent'; - -describe('MyComponent', () => { - it('should render the title', () => { - render(() => ); - expect(screen.getByText('Hello')).toBeInTheDocument(); - }); -}); -``` - -### SolidJS Primitives (Hooks) - -Test primitives by rendering them in a test component: - -```javascript -import { describe, it, expect } from 'vitest'; -import { createRoot } from 'solid-js'; -import useOnlineStatus from '../useOnlineStatus'; - -describe('useOnlineStatus', () => { - it('should return true when browser is online', () => { - let result; - createRoot(dispose => { - result = useOnlineStatus(); - dispose(); - }); - expect(result()).toBe(navigator.onLine); - }); -}); -``` - -### Mocking - -#### Mocking Browser APIs - -```javascript -import { vi } from 'vitest'; - -// Mock navigator.onLine -Object.defineProperty(navigator, 'onLine', { - value: true, - writable: true, -}); - -// Mock IndexedDB -const mockIndexedDB = { - open: vi.fn(), -}; -global.indexedDB = mockIndexedDB; -``` - -#### Mocking Modules - -```javascript -import { vi } from 'vitest'; - -vi.mock('@api/better-auth-store', () => ({ - useBetterAuth: () => ({ - user: () => ({ id: 'test-user', name: 'Test' }), - isLoggedIn: () => true, - }), -})); -``` - -## Resources - -### Official Documentation - -- [Vitest Documentation](https://vitest.dev/guide/) -- [SolidJS Testing Library](https://github.com/solidjs/solid-testing-library) -- [Testing Library Queries](https://testing-library.com/docs/queries/about) -- [Vitest Mocking](https://vitest.dev/guide/mocking.html) - -### SolidJS-Specific Testing - -- [SolidJS Testing Best Practices](https://www.solidjs.com/guides/testing) -- [Testing Reactive Primitives](https://github.com/solidjs/solid-testing-library#primitives) - -### AMSTAR-2 Domain Knowledge - -- [AMSTAR 2 Official Website](https://amstar.ca/Amstar-2.php) -- [AMSTAR 2 Checklist PDF](https://amstar.ca/Amstar_Checklist.php) - -## Common Patterns - -### Testing Async Operations - -```javascript -import { describe, it, expect, vi } from 'vitest'; - -describe('async function', () => { - it('should handle async operations', async () => { - const result = await someAsyncFunction(); - expect(result).toBeDefined(); - }); -}); -``` - -### Testing Error States - -```javascript -describe('error handling', () => { - it('should throw on invalid input', () => { - expect(() => validateInput(null)).toThrow('Input required'); - }); - - it('should handle rejected promises', async () => { - await expect(asyncFn()).rejects.toThrow('Expected error'); - }); -}); -``` - -### Testing DOM Events - -```javascript -import { fireEvent } from '@solidjs/testing-library'; - -it('should handle click events', async () => { - const handleClick = vi.fn(); - render(() => ); - - fireEvent.click(screen.getByText('Click me')); - expect(handleClick).toHaveBeenCalledOnce(); -}); -``` - -## Flags for Potential Bugs - -When writing tests, if you discover behavior that conflicts with the expected/intended behavior: - -1. Write the test for the **intended** behavior -2. Add a comment like `// BUG: Current implementation does X, but should do Y` -3. The test will fail, highlighting the bug for fixing diff --git a/packages/web/src/components/ErrorBoundary.jsx b/packages/web/src/components/ErrorBoundary.jsx index b702480bc..5586f2789 100644 --- a/packages/web/src/components/ErrorBoundary.jsx +++ b/packages/web/src/components/ErrorBoundary.jsx @@ -149,7 +149,7 @@ export function SectionErrorBoundary(props) { {reset && ( diff --git a/packages/web/src/components/Navbar.jsx b/packages/web/src/components/Navbar.jsx index d7663ab83..f1782f464 100644 --- a/packages/web/src/components/Navbar.jsx +++ b/packages/web/src/components/Navbar.jsx @@ -56,7 +56,7 @@ export default function Navbar(props) {
{/* Sidebar toggle button */} @@ -485,7 +485,7 @@ export default function StorageManagement() { diff --git a/packages/web/src/components/admin-ui/UserTable.jsx b/packages/web/src/components/admin-ui/UserTable.jsx index b142d50ea..9c4fb384c 100644 --- a/packages/web/src/components/admin-ui/UserTable.jsx +++ b/packages/web/src/components/admin-ui/UserTable.jsx @@ -232,7 +232,10 @@ export default function UserTable(props) {
{error()} -
@@ -487,7 +490,7 @@ export default function UserTable(props) { @@ -602,7 +605,7 @@ export default function UserTable(props) {
diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionA.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionA.jsx index d9fe20a6f..9d131d8db 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionA.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionA.jsx @@ -48,7 +48,7 @@ export function SectionA(props) { placeholder={field.placeholder} onInput={e => handleFieldChange(field.stateKey, e.currentTarget.value)} rows={3} - class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} + class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} /> diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionB.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionB.jsx index f71ea2a35..4bcc809e5 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionB.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionB.jsx @@ -102,7 +102,7 @@ export function SectionB(props) { value={props.sectionBState?.[key]?.comment || ''} onInput={e => handleCommentChange(key, e.target.value)} disabled={props.disabled} - class='w-full rounded-lg border border-gray-300 py-2 pr-3 pl-3 text-xs transition focus:ring-2 focus:ring-blue-400 focus:outline-none disabled:bg-gray-50 disabled:text-gray-500 sm:text-sm' + class='w-full rounded-lg border border-gray-300 py-2 pr-3 pl-3 text-xs transition focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-500 sm:text-sm' /> diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionC.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionC.jsx index 6abf2e22c..1ffb9aed8 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionC.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionC.jsx @@ -57,7 +57,7 @@ export function SectionC(props) { placeholder={field.placeholder} onInput={e => handleFieldChange(field.stateKey, e.currentTarget.value)} rows={3} - class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} + class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} /> diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionD.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionD.jsx index 0673c3e59..4685147a3 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionD.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SectionD.jsx @@ -67,7 +67,7 @@ export function SectionD(props) { placeholder={SECTION_D.otherField.placeholder} onInput={e => handleOtherChange(e.currentTarget.value)} rows={2} - class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} + class={`mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none ${props.disabled ? 'cursor-not-allowed bg-gray-100 opacity-60' : 'bg-white'} `} /> diff --git a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SignallingQuestion.jsx b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SignallingQuestion.jsx index 78f265b47..a9ea86658 100644 --- a/packages/web/src/components/checklist-ui/ROBINSIChecklist/SignallingQuestion.jsx +++ b/packages/web/src/components/checklist-ui/ROBINSIChecklist/SignallingQuestion.jsx @@ -76,7 +76,7 @@ export function SignallingQuestion(props) { value={props.answer?.comment || ''} onInput={handleCommentChange} disabled={props.disabled} - class='w-full rounded border border-gray-200 px-2 py-1 text-xs focus:ring-1 focus:ring-blue-400 focus:outline-none' + class='w-full rounded border border-gray-200 px-2 py-1 text-xs focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none' /> )} diff --git a/packages/web/src/components/checklist-ui/compare/Footer.jsx b/packages/web/src/components/checklist-ui/compare/Footer.jsx index fd0a52b7e..51862ad0f 100644 --- a/packages/web/src/components/checklist-ui/compare/Footer.jsx +++ b/packages/web/src/components/checklist-ui/compare/Footer.jsx @@ -12,7 +12,7 @@ export default function Footer(props) {
diff --git a/packages/web/src/components/checklist-ui/pdf/PdfListItem.jsx b/packages/web/src/components/checklist-ui/pdf/PdfListItem.jsx index 655211164..05330cb82 100644 --- a/packages/web/src/components/checklist-ui/pdf/PdfListItem.jsx +++ b/packages/web/src/components/checklist-ui/pdf/PdfListItem.jsx @@ -159,7 +159,7 @@ export default function PdfListItem(props) { diff --git a/packages/web/src/components/checklist-ui/pdf/PdfToolbar.jsx b/packages/web/src/components/checklist-ui/pdf/PdfToolbar.jsx index 74b8d03b7..ac5222405 100644 --- a/packages/web/src/components/checklist-ui/pdf/PdfToolbar.jsx +++ b/packages/web/src/components/checklist-ui/pdf/PdfToolbar.jsx @@ -82,7 +82,7 @@ export default function PdfToolbar(props) { diff --git a/packages/web/src/components/profile-ui/MergeAccountsDialog.jsx b/packages/web/src/components/profile-ui/MergeAccountsDialog.jsx index 4dd0f13a2..912eb06d0 100644 --- a/packages/web/src/components/profile-ui/MergeAccountsDialog.jsx +++ b/packages/web/src/components/profile-ui/MergeAccountsDialog.jsx @@ -426,7 +426,7 @@ export default function MergeAccountsDialog(props) {
@@ -351,7 +351,7 @@ export default function ProfilePage() { type='text' value={editLastName()} onInput={e => setEditLastName(e.target.value)} - class='block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-400 focus:outline-none' + class='block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none' placeholder='Last name' /> @@ -373,7 +373,7 @@ export default function ProfilePage() { @@ -425,7 +425,7 @@ export default function ProfilePage() { @@ -484,7 +484,7 @@ export default function ProfilePage() { @@ -512,7 +512,7 @@ export default function ProfilePage() { type='text' value={deleteConfirmText()} onInput={e => setDeleteConfirmText(e.target.value)} - class='block w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-red-400 focus:outline-none' + class='block w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none' placeholder='DELETE' /> @@ -521,7 +521,7 @@ export default function ProfilePage() { diff --git a/packages/web/src/components/profile-ui/SettingsPage.jsx b/packages/web/src/components/profile-ui/SettingsPage.jsx index a139a8e37..6d4fe68db 100644 --- a/packages/web/src/components/profile-ui/SettingsPage.jsx +++ b/packages/web/src/components/profile-ui/SettingsPage.jsx @@ -227,7 +227,7 @@ export default function SettingsPage() { } }} disabled={addPasswordLoading()} - class='flex items-center space-x-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:opacity-50' + class='flex items-center space-x-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50' > {addPasswordLoading() ? 'Sending...' : 'Send Setup Email'} @@ -325,7 +325,7 @@ export default function SettingsPage() { diff --git a/packages/web/src/components/profile-ui/TwoFactorSetup.jsx b/packages/web/src/components/profile-ui/TwoFactorSetup.jsx index 716c3b967..73a06bd25 100644 --- a/packages/web/src/components/profile-ui/TwoFactorSetup.jsx +++ b/packages/web/src/components/profile-ui/TwoFactorSetup.jsx @@ -522,7 +522,7 @@ export default function TwoFactorSetup() { diff --git a/packages/web/src/components/project-ui/AddStudiesForm.jsx b/packages/web/src/components/project-ui/AddStudiesForm.jsx index 8c56aa1f5..360361df6 100644 --- a/packages/web/src/components/project-ui/AddStudiesForm.jsx +++ b/packages/web/src/components/project-ui/AddStudiesForm.jsx @@ -310,7 +310,7 @@ export default function AddStudiesForm(props) { type='button' onClick={handleSubmit} disabled={isSubmitting() || studies.totalStudyCount() === 0} - class='inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400' + class='inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50' > {props.loading ? 'Adding...' : 'Add Checklist'} diff --git a/packages/web/src/components/project-ui/ChecklistRow.jsx b/packages/web/src/components/project-ui/ChecklistRow.jsx index ea97df46e..100d2f7a1 100644 --- a/packages/web/src/components/project-ui/ChecklistRow.jsx +++ b/packages/web/src/components/project-ui/ChecklistRow.jsx @@ -28,7 +28,7 @@ export default function ChecklistRow(props) { e.stopPropagation(); props.onOpen(); }} - class='rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700' + class='rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none' > Open @@ -37,7 +37,7 @@ export default function ChecklistRow(props) { e.stopPropagation(); props.onDelete?.(); }} - class='rounded-lg p-2 text-gray-400 opacity-0 transition-colors group-hover:opacity-100 hover:bg-red-50 hover:text-red-600' + class='rounded-lg p-2 text-gray-400 opacity-0 transition-colors group-hover:opacity-100 hover:bg-red-50 hover:text-red-600 focus:opacity-100 focus:ring-2 focus:ring-blue-500 focus:outline-none' title='Delete Checklist' > diff --git a/packages/web/src/components/project-ui/ContactPrompt.jsx b/packages/web/src/components/project-ui/ContactPrompt.jsx index cd5f4b401..d88cc58ed 100644 --- a/packages/web/src/components/project-ui/ContactPrompt.jsx +++ b/packages/web/src/components/project-ui/ContactPrompt.jsx @@ -44,7 +44,7 @@ export default function ContactPrompt(props) { href={contactUrl()} target='_blank' rel='noopener noreferrer' - class='mt-3 inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700' + class='mt-3 inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none' > Request Early Access diff --git a/packages/web/src/components/project-ui/CreateProjectForm.jsx b/packages/web/src/components/project-ui/CreateProjectForm.jsx index 4cc3e70b9..d3cc4c69d 100644 --- a/packages/web/src/components/project-ui/CreateProjectForm.jsx +++ b/packages/web/src/components/project-ui/CreateProjectForm.jsx @@ -217,13 +217,13 @@ export default function CreateProjectForm(props) { diff --git a/packages/web/src/components/project-ui/ProjectCard.jsx b/packages/web/src/components/project-ui/ProjectCard.jsx index b02965b3d..176ac17ca 100644 --- a/packages/web/src/components/project-ui/ProjectCard.jsx +++ b/packages/web/src/components/project-ui/ProjectCard.jsx @@ -56,7 +56,7 @@ export default function ProjectCard(props) {
@@ -66,7 +66,7 @@ export default function ProjectCard(props) { e.stopPropagation(); props.onDelete?.(project().id); }} - class='rounded-lg p-2 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600' + class='rounded-lg p-2 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:ring-2 focus:ring-blue-500 focus:outline-none' title='Delete Project' > diff --git a/packages/web/src/components/project-ui/ProjectDashboard.jsx b/packages/web/src/components/project-ui/ProjectDashboard.jsx index 8af3a8bde..3fb3238a2 100644 --- a/packages/web/src/components/project-ui/ProjectDashboard.jsx +++ b/packages/web/src/components/project-ui/ProjectDashboard.jsx @@ -164,7 +164,7 @@ export default function ProjectDashboard(props) { } > @@ -304,7 +304,7 @@ export default function DoiLookupSection() { e.stopPropagation(); studies.removeLookupRef(ref._id); }} - class='rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500' + class='rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:ring-2 focus:ring-blue-500 focus:outline-none' > diff --git a/packages/web/src/components/project-ui/add-studies/GoogleDriveSection.jsx b/packages/web/src/components/project-ui/add-studies/GoogleDriveSection.jsx index f567fa34f..4f2b6dcc7 100644 --- a/packages/web/src/components/project-ui/add-studies/GoogleDriveSection.jsx +++ b/packages/web/src/components/project-ui/add-studies/GoogleDriveSection.jsx @@ -45,7 +45,7 @@ export default function GoogleDriveSection() { {file => (
- +

{file.name}

{formatFileSize(file.size)}

@@ -53,7 +53,7 @@ export default function GoogleDriveSection() { diff --git a/packages/web/src/components/project-ui/add-studies/PdfUploadSection.jsx b/packages/web/src/components/project-ui/add-studies/PdfUploadSection.jsx index d9f72b556..b619a28b3 100644 --- a/packages/web/src/components/project-ui/add-studies/PdfUploadSection.jsx +++ b/packages/web/src/components/project-ui/add-studies/PdfUploadSection.jsx @@ -46,14 +46,14 @@ export default function PdfUploadSection() { class='h-5 w-5 shrink-0' classList={{ 'text-gray-500': !pdf.error, - 'text-red-500': pdf.error, + 'text-red-600': pdf.error, }} />
{/* Error state */}
- + {pdf.error}
-

{pdf.file.name}

+

{pdf.file.name}

{/* Extracting state */} @@ -130,7 +130,7 @@ export default function PdfUploadSection() { diff --git a/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx index e2f70caa6..d01db0b1a 100644 --- a/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx +++ b/packages/web/src/components/project-ui/completed-tab/CompletedStudyRow.jsx @@ -149,7 +149,7 @@ export default function CompletedStudyRow(props) { e.stopPropagation(); props.onOpenChecklist?.(completedChecklists()[0].id); }} - class='shrink-0 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700' + class='shrink-0 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none' > Open diff --git a/packages/web/src/components/project-ui/overview-tab/AMSTAR2ResultsTable.jsx b/packages/web/src/components/project-ui/overview-tab/AMSTAR2ResultsTable.jsx index 4763b0ce6..84e70cfe1 100644 --- a/packages/web/src/components/project-ui/overview-tab/AMSTAR2ResultsTable.jsx +++ b/packages/web/src/components/project-ui/overview-tab/AMSTAR2ResultsTable.jsx @@ -74,7 +74,7 @@ export default function AMSTAR2ResultsTable(props) { }; scores.forEach(item => { - if (counts.hasOwnProperty(item.score)) { + if (Object.prototype.hasOwnProperty.call(counts, item.score)) { counts[item.score]++; } }); diff --git a/packages/web/src/components/project-ui/overview-tab/AddMemberModal.jsx b/packages/web/src/components/project-ui/overview-tab/AddMemberModal.jsx index 63b45f7d0..ebeb5dde9 100644 --- a/packages/web/src/components/project-ui/overview-tab/AddMemberModal.jsx +++ b/packages/web/src/components/project-ui/overview-tab/AddMemberModal.jsx @@ -280,7 +280,7 @@ export default function AddMemberModal(props) { diff --git a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx index ef9ac398b..1e098ac1c 100644 --- a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx +++ b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx @@ -260,7 +260,7 @@ export default function OverviewTab() {
@@ -504,7 +504,7 @@ export default function ReviewerAssignment(props) { @@ -139,7 +139,7 @@ export default function TodoStudyRow(props) { e.stopPropagation(); props.onToggleChecklistForm?.(); }} - class='shrink-0 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700' + class='shrink-0 rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none' > Select Checklist diff --git a/packages/web/src/components/sidebar/LocalChecklistItem.jsx b/packages/web/src/components/sidebar/LocalChecklistItem.jsx index dba09d007..52015fab4 100644 --- a/packages/web/src/components/sidebar/LocalChecklistItem.jsx +++ b/packages/web/src/components/sidebar/LocalChecklistItem.jsx @@ -39,7 +39,7 @@ export default function LocalChecklistItem(props) {