diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 27ea8bdec..1ef0c7086 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -24,6 +24,38 @@ The web package is copied into the landing package during build and deployed as
Do not worry about migrations (client side or backend) unless specifically instructed. This project is not in production and has no users.
+## Build Commands
+
+```bash
+# Development
+pnpm dev:front # Frontend web app (port 5173 and port 3010)
+pnpm dev:workers # Backend workers (port 8787)
+pnpm --filter web build # Main web SPA frontend
+pnpm --filter landing build # Landing site only
+
+# Testing
+pnpm test # Run all tests
+pnpm --filter web test # Frontend tests only
+pnpm --filter workers test # Backend tests only
+pnpm --filter web vitest run path/file # Single test file
+
+# Code Quality
+pnpm lint # ESLint check
+pnpm lint:fix # Auto-fix lint issues
+pnpm format # Prettier format
+pnpm typecheck # TypeScript check
+
+# Database (workers package)
+pnpm --filter workers db:generate # Generate Drizzle migrations
+pnpm --filter workers db:migrate # Apply migrations locally
+
+# Other
+pnpm build # Build all packages
+pnpm docs # View docs site (port 8080)
+pnpm openapi # Generate OpenAPI schema
+pnpm logs # View production worker logs
+```
+
## Critical Rules
### Coding Standards
diff --git a/.claude/settings.json b/.claude/settings.json
index 903088848..24448ed81 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,5 +1,6 @@
{
"enabledPlugins": {
- "frontend-design@claude-plugins-official": true
+ "frontend-design@claude-plugins-official": true,
+ "plugin-dev@claude-plugins-official": true
}
}
diff --git a/.claude/skills/api-route/SKILL.md b/.claude/skills/api-route/SKILL.md
new file mode 100644
index 000000000..1228d2e77
--- /dev/null
+++ b/.claude/skills/api-route/SKILL.md
@@ -0,0 +1,368 @@
+---
+name: API Route
+description: This skill should be used when the user asks to "create an API route", "add an endpoint", "build a backend route", "create an API endpoint", "add a Hono route", or mentions creating REST endpoints, API handlers, or backend routes. Provides Hono API patterns for CoRATES workers.
+---
+
+# API Route Creation
+
+Create Hono API routes following CoRATES workers patterns.
+
+## Core Principles
+
+1. **Middleware composition** - Chain auth, permissions, validation middleware
+2. **Domain errors** - Use createDomainError from @corates/shared
+3. **Zod validation** - Define schemas centrally in config/validation.js
+4. **Drizzle ORM** - All database operations through Drizzle
+5. **Context isolation** - Attach data to Hono context, read via getters
+
+## Quick Reference
+
+### File Location
+
+```
+packages/workers/src/
+ routes/
+ [feature].js # Standalone routes
+ [feature]/
+ index.js # Route composition
+ [subroute].js # Nested routes
+ config/
+ validation.js # Zod schemas
+ middleware/
+ auth.js # Authentication
+ requireOrg.js # Org membership
+```
+
+### Basic Route Template
+
+```javascript
+import { Hono } from 'hono';
+import { eq, and } from 'drizzle-orm';
+import { createDb } from '@/db/client.js';
+import { items } from '@/db/schema.js';
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { validateRequest } from '@/config/validation.js';
+import { createDomainError, ITEM_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
+import { itemSchemas } from '@/config/validation.js';
+
+const itemRoutes = new Hono();
+
+// Apply auth to all routes
+itemRoutes.use('*', requireAuth);
+
+// GET - List items
+itemRoutes.get('/', async c => {
+ const { user } = getAuth(c);
+ const db = createDb(c.env.DB);
+
+ try {
+ const results = await db.select().from(items).where(eq(items.userId, user.id));
+
+ return c.json(results);
+ } catch (error) {
+ console.error('Error fetching items:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_items',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+// POST - Create item
+itemRoutes.post('/', validateRequest(itemSchemas.create), async c => {
+ const { user } = getAuth(c);
+ const { name, description } = c.get('validatedBody');
+ const db = createDb(c.env.DB);
+
+ try {
+ const id = crypto.randomUUID();
+ const now = new Date();
+
+ await db.insert(items).values({
+ id,
+ name,
+ description,
+ userId: user.id,
+ createdAt: now,
+ });
+
+ return c.json({ id, name, description }, 201);
+ } catch (error) {
+ console.error('Error creating item:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'create_item',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+export { itemRoutes };
+```
+
+## Middleware Chain
+
+Apply middleware in order of specificity:
+
+```javascript
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { requireOrgMembership, getOrgContext } from '@/middleware/requireOrg.js';
+import { requireOrgWriteAccess } from '@/middleware/requireOrgWriteAccess.js';
+import { requireEntitlement } from '@/middleware/requireEntitlement.js';
+import { requireQuota } from '@/middleware/requireQuota.js';
+import { validateRequest } from '@/config/validation.js';
+
+// Full middleware chain for protected route
+routes.post(
+ '/',
+ requireOrgMembership(), // Check org membership
+ requireOrgWriteAccess(), // Check write permission
+ requireEntitlement('feature.x'), // Check feature access
+ requireQuota('items.max', getItemCount, 1), // Check quota
+ validateRequest(schema), // Validate body
+ async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const data = c.get('validatedBody');
+ // Handler code...
+ },
+);
+```
+
+### Context Getters
+
+```javascript
+// Authentication
+const { user, session } = getAuth(c);
+
+// Organization context (after requireOrgMembership)
+const { orgId, orgRole, org } = getOrgContext(c);
+
+// Validated request body (after validateRequest)
+const data = c.get('validatedBody');
+
+// Billing/entitlements (after requireEntitlement)
+const entitlements = c.get('entitlements');
+const quotas = c.get('quotas');
+```
+
+## Zod Validation
+
+### Define Schemas
+
+Add to `packages/workers/src/config/validation.js`:
+
+```javascript
+export const itemSchemas = {
+ create: z.object({
+ name: z
+ .string()
+ .min(1, 'Name is required')
+ .max(255)
+ .transform(val => val.trim()),
+ description: z
+ .string()
+ .max(2000)
+ .optional()
+ .transform(val => val?.trim() || null),
+ }),
+
+ update: z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().max(2000).optional(),
+ }),
+};
+```
+
+### Common Patterns
+
+```javascript
+// Required string with trim
+name: z.string().min(1).max(255).transform(val => val.trim()),
+
+// Optional with null fallback
+description: z.string().max(2000).optional().transform(val => val?.trim() || null),
+
+// Email
+email: z.string().email('Invalid email'),
+
+// UUID
+id: z.string().uuid(),
+
+// Enum
+role: z.enum(['owner', 'admin', 'member', 'viewer']),
+
+// Boolean with default
+active: z.boolean().optional().default(true),
+```
+
+## Error Handling
+
+### Domain Errors
+
+```javascript
+import { createDomainError, PROJECT_ERRORS, AUTH_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
+
+// Not found
+const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId });
+return c.json(error, error.statusCode);
+
+// Forbidden with reason
+const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'insufficient_role',
+ required: 'admin',
+});
+return c.json(error, error.statusCode);
+
+// Database error
+const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'create_item',
+ originalError: error.message,
+});
+return c.json(dbError, dbError.statusCode);
+```
+
+### Try-Catch Pattern
+
+```javascript
+routes.get('/:id', async c => {
+ const { user } = getAuth(c);
+ const id = c.req.param('id');
+ const db = createDb(c.env.DB);
+
+ try {
+ const result = await db
+ .select()
+ .from(items)
+ .where(and(eq(items.id, id), eq(items.userId, user.id)))
+ .get();
+
+ if (!result) {
+ const error = createDomainError(ITEM_ERRORS.NOT_FOUND, { id });
+ return c.json(error, error.statusCode);
+ }
+
+ return c.json(result);
+ } catch (error) {
+ console.error('Error fetching item:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_item',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+```
+
+## Database Operations
+
+### Query Patterns
+
+```javascript
+import { createDb } from '@/db/client.js';
+import { eq, and, or, like, desc, sql } from 'drizzle-orm';
+
+const db = createDb(c.env.DB);
+
+// Select with join
+const results = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ role: projectMembers.role,
+ })
+ .from(projects)
+ .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId))
+ .where(eq(projectMembers.userId, user.id))
+ .orderBy(desc(projects.updatedAt));
+
+// Single record
+const item = await db
+ .select()
+ .from(items)
+ .where(eq(items.id, id))
+ .get();
+
+// Insert
+await db.insert(items).values({ id, name, createdAt: now });
+
+// Update
+await db.update(items)
+ .set({ name, updatedAt: now })
+ .where(eq(items.id, id));
+
+// Delete
+await db.delete(items).where(eq(items.id, id));
+
+// Batch for atomic operations
+await db.batch([
+ db.insert(projects).values({ ... }),
+ db.insert(projectMembers).values({ ... }),
+]);
+```
+
+## Response Patterns
+
+```javascript
+// Success with data
+return c.json(result);
+
+// Created (201)
+return c.json(newItem, 201);
+
+// Success flag
+return c.json({ success: true, id: itemId });
+
+// Array response
+return c.json(results);
+
+// Error response
+return c.json(error, error.statusCode);
+```
+
+## Route Registration
+
+### Mount in Main App
+
+```javascript
+// packages/workers/src/index.js
+import { itemRoutes } from './routes/items.js';
+
+app.route('/api/items', itemRoutes);
+```
+
+### Nested Routes
+
+```javascript
+// In parent route file
+import { subRoutes } from './subroute.js';
+
+parentRoutes.route('/:parentId/children', subRoutes);
+
+// Creates: /api/parent/:parentId/children/...
+```
+
+## Creation Checklist
+
+When creating an API route:
+
+1. Create route file in `packages/workers/src/routes/`
+2. Define Zod schemas in `config/validation.js`
+3. Apply appropriate middleware chain
+4. Use context getters for auth/org/validated data
+5. Use Drizzle for all database operations
+6. Return domain errors with proper status codes
+7. Register route in `index.js`
+
+## Additional Resources
+
+### Reference Files
+
+For detailed patterns:
+
+- **`references/patterns.md`** - Middleware details, complex queries, nested routes
+- **`references/examples.md`** - Real route examples from the codebase
+
+### Example Files
+
+Working templates in `examples/`:
+
+- **`ExampleRoutes.js`** - Complete CRUD route template
diff --git a/.claude/skills/api-route/examples/ExampleRoutes.js b/.claude/skills/api-route/examples/ExampleRoutes.js
new file mode 100644
index 000000000..157846108
--- /dev/null
+++ b/.claude/skills/api-route/examples/ExampleRoutes.js
@@ -0,0 +1,384 @@
+/**
+ * Example API Route Template
+ *
+ * Demonstrates all key patterns for CoRATES API routes.
+ * Copy and modify for new route files.
+ */
+
+import { Hono } from 'hono';
+import { eq, and, desc, count } from 'drizzle-orm';
+import { z } from 'zod';
+
+// Database
+import { createDb } from '@/db/client.js';
+import { items, itemMembers } from '@/db/schema.js';
+
+// Middleware
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { requireOrgMembership, getOrgContext } from '@/middleware/requireOrg.js';
+import { requireOrgWriteAccess } from '@/middleware/requireOrgWriteAccess.js';
+import { requireEntitlement } from '@/middleware/requireEntitlement.js';
+import { requireQuota } from '@/middleware/requireQuota.js';
+import { validateRequest } from '@/config/validation.js';
+
+// Errors
+import {
+ createDomainError,
+ createValidationError,
+ AUTH_ERRORS,
+ SYSTEM_ERRORS,
+ VALIDATION_ERRORS,
+} from '@corates/shared';
+
+// ---------------------
+// Zod Schemas
+// ---------------------
+// Add these to packages/workers/src/config/validation.js
+
+export const itemSchemas = {
+ create: z.object({
+ name: z
+ .string()
+ .min(1, 'Name is required')
+ .max(255, 'Name must be 255 characters or less')
+ .transform(val => val.trim()),
+ description: z
+ .string()
+ .max(2000, 'Description must be 2000 characters or less')
+ .optional()
+ .transform(val => val?.trim() || null),
+ type: z.enum(['typeA', 'typeB', 'typeC']).optional().default('typeA'),
+ active: z.boolean().optional().default(true),
+ }),
+
+ update: z.object({
+ name: z
+ .string()
+ .min(1)
+ .max(255)
+ .optional()
+ .transform(val => val?.trim()),
+ description: z
+ .string()
+ .max(2000)
+ .optional()
+ .transform(val => val?.trim() || null),
+ type: z.enum(['typeA', 'typeB', 'typeC']).optional(),
+ active: z.boolean().optional(),
+ }),
+};
+
+// Define custom error codes in @corates/shared if needed
+const ITEM_ERRORS = {
+ NOT_FOUND: { code: 'ITEM_NOT_FOUND', statusCode: 404, message: 'Item not found' },
+ ALREADY_EXISTS: { code: 'ITEM_ALREADY_EXISTS', statusCode: 409, message: 'Item already exists' },
+};
+
+// ---------------------
+// Route Setup
+// ---------------------
+
+const itemRoutes = new Hono();
+
+// Apply authentication to all routes
+itemRoutes.use('*', requireAuth);
+
+// ---------------------
+// Helper Functions
+// ---------------------
+
+/**
+ * Get item count for quota check
+ */
+async function getItemCount(c, user) {
+ const { orgId } = getOrgContext(c);
+ const db = createDb(c.env.DB);
+ const [result] = await db.select({ count: count() }).from(items).where(eq(items.orgId, orgId));
+ return result?.count || 0;
+}
+
+// ---------------------
+// Routes
+// ---------------------
+
+/**
+ * GET / - List items
+ *
+ * Query params:
+ * - limit: number (default 50, max 100)
+ * - offset: number (default 0)
+ * - type: string (filter by type)
+ */
+itemRoutes.get('/', requireOrgMembership(), async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const db = createDb(c.env.DB);
+
+ // Parse query params
+ const limit = Math.min(parseInt(c.req.query('limit') || '50', 10), 100);
+ const offset = parseInt(c.req.query('offset') || '0', 10);
+ const typeFilter = c.req.query('type');
+
+ try {
+ let query = db
+ .select({
+ id: items.id,
+ name: items.name,
+ description: items.description,
+ type: items.type,
+ active: items.active,
+ createdAt: items.createdAt,
+ updatedAt: items.updatedAt,
+ })
+ .from(items)
+ .where(eq(items.orgId, orgId))
+ .orderBy(desc(items.updatedAt))
+ .limit(limit)
+ .offset(offset);
+
+ // Apply type filter if provided
+ if (typeFilter) {
+ query = query.where(and(eq(items.orgId, orgId), eq(items.type, typeFilter)));
+ }
+
+ const results = await query;
+
+ // Get total count for pagination
+ const [countResult] = await db
+ .select({ count: count() })
+ .from(items)
+ .where(eq(items.orgId, orgId));
+
+ return c.json({
+ items: results,
+ pagination: {
+ total: countResult?.count || 0,
+ limit,
+ offset,
+ },
+ });
+ } catch (error) {
+ console.error('Error fetching items:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_items',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+/**
+ * GET /:id - Get single item
+ */
+itemRoutes.get('/:id', requireOrgMembership(), async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const itemId = c.req.param('id');
+ const db = createDb(c.env.DB);
+
+ try {
+ const result = await db
+ .select()
+ .from(items)
+ .where(and(eq(items.id, itemId), eq(items.orgId, orgId)))
+ .get();
+
+ if (!result) {
+ const error = createDomainError(ITEM_ERRORS.NOT_FOUND, { itemId });
+ return c.json(error, error.statusCode);
+ }
+
+ return c.json(result);
+ } catch (error) {
+ console.error('Error fetching item:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_item',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+/**
+ * POST / - Create item
+ *
+ * Full middleware chain:
+ * 1. requireOrgMembership - Check user is org member
+ * 2. requireOrgWriteAccess - Check user has write permission
+ * 3. requireEntitlement - Check org has feature access
+ * 4. requireQuota - Check org hasn't exceeded quota
+ * 5. validateRequest - Validate request body
+ */
+itemRoutes.post(
+ '/',
+ requireOrgMembership(),
+ requireOrgWriteAccess(),
+ requireEntitlement('items.create'),
+ requireQuota('items.max', getItemCount, 1),
+ validateRequest(itemSchemas.create),
+ async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const { name, description, type, active } = c.get('validatedBody');
+ const db = createDb(c.env.DB);
+
+ const itemId = crypto.randomUUID();
+ const now = new Date();
+
+ try {
+ await db.insert(items).values({
+ id: itemId,
+ orgId,
+ name,
+ description,
+ type,
+ active,
+ createdBy: user.id,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ const newItem = {
+ id: itemId,
+ orgId,
+ name,
+ description,
+ type,
+ active,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ return c.json(newItem, 201);
+ } catch (error) {
+ console.error('Error creating item:', error);
+
+ // Handle unique constraint violation
+ if (error?.message?.includes('UNIQUE constraint failed')) {
+ const conflictError = createDomainError(ITEM_ERRORS.ALREADY_EXISTS, {
+ name,
+ });
+ return c.json(conflictError, conflictError.statusCode);
+ }
+
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'create_item',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+ },
+);
+
+/**
+ * PATCH /:id - Update item
+ */
+itemRoutes.patch(
+ '/:id',
+ requireOrgMembership(),
+ requireOrgWriteAccess(),
+ validateRequest(itemSchemas.update),
+ async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const itemId = c.req.param('id');
+ const updates = c.get('validatedBody');
+ const db = createDb(c.env.DB);
+
+ try {
+ // Verify item exists and belongs to org
+ const existing = await db
+ .select({ id: items.id })
+ .from(items)
+ .where(and(eq(items.id, itemId), eq(items.orgId, orgId)))
+ .get();
+
+ if (!existing) {
+ const error = createDomainError(ITEM_ERRORS.NOT_FOUND, { itemId });
+ return c.json(error, error.statusCode);
+ }
+
+ // Build update object with only provided fields
+ const updateData = { updatedAt: new Date() };
+ if (updates.name !== undefined) updateData.name = updates.name;
+ if (updates.description !== undefined) updateData.description = updates.description;
+ if (updates.type !== undefined) updateData.type = updates.type;
+ if (updates.active !== undefined) updateData.active = updates.active;
+
+ await db.update(items).set(updateData).where(eq(items.id, itemId));
+
+ return c.json({ success: true, itemId });
+ } catch (error) {
+ console.error('Error updating item:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'update_item',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+ },
+);
+
+/**
+ * DELETE /:id - Delete item
+ *
+ * Requires owner/admin role via requireOrgMembership('admin')
+ */
+itemRoutes.delete('/:id', requireOrgMembership('admin'), async c => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const itemId = c.req.param('id');
+ const db = createDb(c.env.DB);
+
+ try {
+ // Verify item exists
+ const existing = await db
+ .select({ id: items.id })
+ .from(items)
+ .where(and(eq(items.id, itemId), eq(items.orgId, orgId)))
+ .get();
+
+ if (!existing) {
+ const error = createDomainError(ITEM_ERRORS.NOT_FOUND, { itemId });
+ return c.json(error, error.statusCode);
+ }
+
+ // Delete with cascade (if needed)
+ await db.batch([
+ // Delete related records first
+ db.delete(itemMembers).where(eq(itemMembers.itemId, itemId)),
+ // Then delete main record
+ db.delete(items).where(eq(items.id, itemId)),
+ ]);
+
+ return c.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting item:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'delete_item',
+ originalError: error.message,
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+// ---------------------
+// Nested Routes Example
+// ---------------------
+
+// Import and mount nested routes
+// import { itemMemberRoutes } from './item-members.js';
+// itemRoutes.route('/:itemId/members', itemMemberRoutes);
+
+// ---------------------
+// Export
+// ---------------------
+
+export { itemRoutes };
+
+// ---------------------
+// Registration (in index.js)
+// ---------------------
+
+// import { itemRoutes } from './routes/items.js';
+// app.route('/api/orgs/:orgId/items', itemRoutes);
diff --git a/.claude/skills/api-route/references/examples.md b/.claude/skills/api-route/references/examples.md
new file mode 100644
index 000000000..a2385c262
--- /dev/null
+++ b/.claude/skills/api-route/references/examples.md
@@ -0,0 +1,572 @@
+# Real API Route Examples
+
+Working examples from the CoRATES workers codebase.
+
+## Project Routes (Org-Scoped)
+
+Location: `packages/workers/src/routes/orgs/projects.js`
+
+Full CRUD with middleware chain:
+
+```javascript
+import { Hono } from 'hono';
+import { eq, and, desc } from 'drizzle-orm';
+import { createDb } from '@/db/client.js';
+import { projects, projectMembers, user } from '@/db/schema.js';
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { requireOrgMembership, getOrgContext } from '@/middleware/requireOrg.js';
+import { requireOrgWriteAccess } from '@/middleware/requireOrgWriteAccess.js';
+import { requireEntitlement } from '@/middleware/requireEntitlement.js';
+import { requireQuota } from '@/middleware/requireQuota.js';
+import { validateRequest, projectSchemas } from '@/config/validation.js';
+import { createDomainError, PROJECT_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
+import { syncProjectToDO } from '@/lib/project-sync.js';
+
+const orgProjectRoutes = new Hono();
+
+// Auth required for all routes
+orgProjectRoutes.use('*', requireAuth);
+
+// Helper for quota check
+async function getProjectCount(c, user) {
+ const { orgId } = getOrgContext(c);
+ const db = createDb(c.env.DB);
+ const [result] = await db.select({ count: count() }).from(projects).where(eq(projects.orgId, orgId));
+ return result?.count || 0;
+}
+
+// GET /api/orgs/:orgId/projects - List org projects
+orgProjectRoutes.get('/', requireOrgMembership(), async c => {
+ const { user: authUser } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const db = createDb(c.env.DB);
+
+ try {
+ const results = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ description: projects.description,
+ createdAt: projects.createdAt,
+ updatedAt: projects.updatedAt,
+ role: projectMembers.role,
+ })
+ .from(projects)
+ .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId))
+ .where(and(eq(projects.orgId, orgId), eq(projectMembers.userId, authUser.id)))
+ .orderBy(desc(projects.updatedAt));
+
+ return c.json(results);
+ } catch (error) {
+ console.error('Error fetching org projects:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_org_projects',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+// POST /api/orgs/:orgId/projects - Create project
+orgProjectRoutes.post(
+ '/',
+ requireOrgMembership(),
+ requireOrgWriteAccess(),
+ requireEntitlement('project.create'),
+ requireQuota('projects.max', getProjectCount, 1),
+ validateRequest(projectSchemas.create),
+ async c => {
+ const { user: authUser } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+ const { name, description } = c.get('validatedBody');
+ const db = createDb(c.env.DB);
+
+ const projectId = crypto.randomUUID();
+ const memberId = crypto.randomUUID();
+ const now = new Date();
+
+ try {
+ await db.batch([
+ db.insert(projects).values({
+ id: projectId,
+ orgId,
+ name: name.trim(),
+ description: description?.trim() || null,
+ createdBy: authUser.id,
+ createdAt: now,
+ updatedAt: now,
+ }),
+ db.insert(projectMembers).values({
+ id: memberId,
+ projectId,
+ userId: authUser.id,
+ role: 'owner',
+ joinedAt: now,
+ }),
+ ]);
+
+ // Get creator info for DO sync
+ const creator = await db
+ .select({ name: user.name, email: user.email, image: user.image })
+ .from(user)
+ .where(eq(user.id, authUser.id))
+ .get();
+
+ const newProject = {
+ id: projectId,
+ orgId,
+ name: name.trim(),
+ description: description?.trim() || null,
+ role: 'owner',
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ // Sync to Durable Object (best-effort)
+ try {
+ await syncProjectToDO(
+ c.env,
+ projectId,
+ {
+ meta: { title: name.trim(), description: description?.trim() || '' },
+ },
+ [
+ {
+ odId: authUser.id,
+ name: creator?.name || '',
+ email: creator?.email || '',
+ image: creator?.image || '',
+ role: 'owner',
+ },
+ ],
+ );
+ } catch (err) {
+ console.error('Failed to sync project to DO:', err);
+ }
+
+ return c.json(newProject, 201);
+ } catch (error) {
+ console.error('Error creating project:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_TRANSACTION_FAILED, {
+ operation: 'create_project',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+ },
+);
+
+// GET /api/orgs/:orgId/projects/:projectId - Get project
+orgProjectRoutes.get('/:projectId', requireOrgMembership(), async c => {
+ const { user: authUser } = getAuth(c);
+ const projectId = c.req.param('projectId');
+ const db = createDb(c.env.DB);
+
+ try {
+ const result = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ description: projects.description,
+ role: projectMembers.role,
+ createdAt: projects.createdAt,
+ updatedAt: projects.updatedAt,
+ })
+ .from(projects)
+ .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId))
+ .where(and(eq(projects.id, projectId), eq(projectMembers.userId, authUser.id)))
+ .get();
+
+ if (!result) {
+ const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId });
+ return c.json(error, error.statusCode);
+ }
+
+ return c.json(result);
+ } catch (error) {
+ console.error('Error fetching project:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'fetch_project',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+// PATCH /api/orgs/:orgId/projects/:projectId - Update project
+orgProjectRoutes.patch(
+ '/:projectId',
+ requireOrgMembership(),
+ requireOrgWriteAccess(),
+ validateRequest(projectSchemas.update),
+ async c => {
+ const { user: authUser } = getAuth(c);
+ const projectId = c.req.param('projectId');
+ const updates = c.get('validatedBody');
+ const db = createDb(c.env.DB);
+
+ try {
+ // Check membership
+ const membership = await db
+ .select({ role: projectMembers.role })
+ .from(projectMembers)
+ .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, authUser.id)))
+ .get();
+
+ if (!membership) {
+ const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, { projectId });
+ return c.json(error, error.statusCode);
+ }
+
+ // Build update object
+ const updateData = { updatedAt: new Date() };
+ if (updates.name !== undefined) updateData.name = updates.name.trim();
+ if (updates.description !== undefined) {
+ updateData.description = updates.description?.trim() || null;
+ }
+
+ await db.update(projects).set(updateData).where(eq(projects.id, projectId));
+
+ return c.json({ success: true, projectId });
+ } catch (error) {
+ console.error('Error updating project:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'update_project',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+ },
+);
+
+// DELETE /api/orgs/:orgId/projects/:projectId - Delete project
+orgProjectRoutes.delete('/:projectId', requireOrgMembership('owner'), async c => {
+ const { user: authUser } = getAuth(c);
+ const projectId = c.req.param('projectId');
+ const db = createDb(c.env.DB);
+
+ try {
+ // Verify owner role
+ const membership = await db
+ .select({ role: projectMembers.role })
+ .from(projectMembers)
+ .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, authUser.id)))
+ .get();
+
+ if (!membership || membership.role !== 'owner') {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'not_project_owner',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Cascade delete
+ await db.batch([
+ db.delete(projectMembers).where(eq(projectMembers.projectId, projectId)),
+ db.delete(invitations).where(eq(invitations.projectId, projectId)),
+ db.delete(projects).where(eq(projects.id, projectId)),
+ ]);
+
+ return c.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting project:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'delete_project',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+export { orgProjectRoutes };
+```
+
+---
+
+## User Search Route
+
+Location: `packages/workers/src/routes/users.js`
+
+Search with privacy controls:
+
+```javascript
+import { Hono } from 'hono';
+import { eq, or, like, sql } from 'drizzle-orm';
+import { createDb } from '@/db/client.js';
+import { user, projectMembers } from '@/db/schema.js';
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { searchRateLimit } from '@/middleware/rateLimit.js';
+import { createDomainError, createValidationError, VALIDATION_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
+
+const userRoutes = new Hono();
+
+userRoutes.use('*', requireAuth);
+
+// Helper to mask email for privacy
+function maskEmail(email) {
+ if (!email) return '';
+ const [local, domain] = email.split('@');
+ if (!domain) return email;
+ const maskedLocal = local.charAt(0) + '***';
+ return `${maskedLocal}@${domain}`;
+}
+
+// GET /api/users/search - Search users
+userRoutes.get('/search', searchRateLimit, async c => {
+ const { user: currentUser } = getAuth(c);
+ const query = c.req.query('q')?.trim();
+ const projectId = c.req.query('projectId');
+ const limit = Math.min(parseInt(c.req.query('limit') || '10', 10), 20);
+
+ // Validate query
+ if (!query || query.length < 2) {
+ const error = createValidationError('query', VALIDATION_ERRORS.FIELD_TOO_SHORT.code);
+ return c.json(error, error.statusCode);
+ }
+
+ const db = createDb(c.env.DB);
+ const searchPattern = `%${query.toLowerCase()}%`;
+
+ try {
+ // Search across multiple fields
+ let results = await db
+ .select({
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ displayName: user.displayName,
+ image: user.image,
+ })
+ .from(user)
+ .where(
+ or(
+ like(sql`lower(${user.email})`, searchPattern),
+ like(sql`lower(${user.name})`, searchPattern),
+ like(sql`lower(${user.displayName})`, searchPattern),
+ ),
+ )
+ .limit(limit);
+
+ // Filter out users already in project
+ if (projectId) {
+ const existingMembers = await db
+ .select({ userId: projectMembers.userId })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId));
+
+ const existingUserIds = new Set(existingMembers.map(m => m.userId));
+ results = results.filter(u => !existingUserIds.has(u.id));
+ }
+
+ // Exclude current user
+ results = results.filter(u => u.id !== currentUser.id);
+
+ // Privacy: mask email unless exact match
+ const sanitizedResults = results.map(u => ({
+ id: u.id,
+ name: u.name || u.displayName,
+ email: query.includes('@') ? u.email : maskEmail(u.email),
+ image: u.image,
+ }));
+
+ return c.json(sanitizedResults);
+ } catch (error) {
+ console.error('Error searching users:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'search_users',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+export { userRoutes };
+```
+
+---
+
+## Contact Form Route (Public)
+
+Location: `packages/workers/src/routes/contact.js`
+
+Public endpoint with rate limiting:
+
+```javascript
+import { Hono } from 'hono';
+import { z } from 'zod';
+import { PostmarkClient } from 'postmark';
+import { contactRateLimit } from '@/middleware/rateLimit.js';
+import { createDomainError, createValidationError, SYSTEM_ERRORS, VALIDATION_ERRORS } from '@corates/shared';
+
+const contact = new Hono();
+
+// Rate limit public endpoint
+contact.use('*', contactRateLimit);
+
+const contactSchema = z.object({
+ name: z.string().trim().min(1).max(100),
+ email: z.string().email().trim().min(1).max(254),
+ subject: z.string().trim().max(150).optional().default(''),
+ message: z.string().trim().min(1).max(2000),
+});
+
+contact.post('/', async c => {
+ // Parse body
+ let body;
+ try {
+ body = await c.req.json();
+ } catch {
+ const error = createValidationError('body', VALIDATION_ERRORS.INVALID_INPUT.code);
+ return c.json(error, error.statusCode);
+ }
+
+ // Validate
+ const result = contactSchema.safeParse(body);
+ if (!result.success) {
+ const firstIssue = result.error.issues[0];
+ const fieldName = firstIssue?.path[0] || 'input';
+ const error = createValidationError(fieldName, VALIDATION_ERRORS.INVALID_INPUT.code);
+ return c.json(error, error.statusCode);
+ }
+
+ const { name, email, subject, message } = result.data;
+
+ // Check service availability
+ if (!c.env.POSTMARK_SERVER_TOKEN) {
+ const error = createDomainError(SYSTEM_ERRORS.SERVICE_UNAVAILABLE, {
+ service: 'email',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ try {
+ const postmark = new PostmarkClient(c.env.POSTMARK_SERVER_TOKEN);
+
+ const response = await postmark.sendEmail({
+ From: `CoRATES Contact <${c.env.EMAIL_FROM}>`,
+ To: c.env.CONTACT_EMAIL,
+ ReplyTo: email,
+ Subject: `[Contact Form] ${subject || 'New Inquiry'}`,
+ TextBody: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
+ HtmlBody: `
+
New Contact Form Submission
+ Name: ${name}
+ Email: ${email}
+ Subject: ${subject || 'None'}
+
+ ${message.replace(/\n/g, '
')}
+ `,
+ });
+
+ if (response.ErrorCode !== 0) {
+ console.error('[Contact] Postmark error:', response);
+ const error = createDomainError(SYSTEM_ERRORS.EMAIL_SEND_FAILED);
+ return c.json(error, error.statusCode);
+ }
+
+ return c.json({ success: true, messageId: response.MessageID });
+ } catch (err) {
+ console.error('[Contact] Exception:', err.message);
+ const error = createDomainError(SYSTEM_ERRORS.EMAIL_SEND_FAILED);
+ return c.json(error, error.statusCode);
+ }
+});
+
+export { contact as contactRoutes };
+```
+
+---
+
+## Avatar Upload Route
+
+Location: `packages/workers/src/routes/avatars.js`
+
+File upload with R2:
+
+```javascript
+import { Hono } from 'hono';
+import { eq } from 'drizzle-orm';
+import { createDb } from '@/db/client.js';
+import { user } from '@/db/schema.js';
+import { requireAuth, getAuth } from '@/middleware/auth.js';
+import { createDomainError, createValidationError, SYSTEM_ERRORS, VALIDATION_ERRORS } from '@corates/shared';
+
+const avatarRoutes = new Hono();
+
+avatarRoutes.use('*', requireAuth);
+
+// POST /api/avatars - Upload avatar
+avatarRoutes.post('/', async c => {
+ const { user: authUser } = getAuth(c);
+
+ const formData = await c.req.formData();
+ const file = formData.get('file');
+
+ // Validate file exists
+ if (!file || !(file instanceof File)) {
+ const error = createValidationError('file', VALIDATION_ERRORS.FIELD_REQUIRED.code);
+ return c.json(error, error.statusCode);
+ }
+
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+ if (!allowedTypes.includes(file.type)) {
+ const error = createValidationError('file', VALIDATION_ERRORS.FIELD_INVALID_FORMAT.code);
+ return c.json(error, error.statusCode);
+ }
+
+ // Validate file size (2MB max)
+ const maxSize = 2 * 1024 * 1024;
+ if (file.size > maxSize) {
+ const error = createValidationError('file', VALIDATION_ERRORS.FIELD_TOO_LONG.code);
+ return c.json(error, error.statusCode);
+ }
+
+ try {
+ const buffer = await file.arrayBuffer();
+ const key = `avatars/${authUser.id}`;
+
+ // Upload to R2
+ await c.env.BUCKET.put(key, buffer, {
+ httpMetadata: {
+ contentType: file.type,
+ },
+ });
+
+ // Generate public URL
+ const avatarUrl = `${c.env.R2_PUBLIC_URL}/${key}`;
+
+ // Update user record
+ const db = createDb(c.env.DB);
+ await db.update(user).set({ image: avatarUrl, updatedAt: new Date() }).where(eq(user.id, authUser.id));
+
+ return c.json({ success: true, url: avatarUrl });
+ } catch (error) {
+ console.error('Error uploading avatar:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.UPLOAD_FAILED, {
+ operation: 'upload_avatar',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+// DELETE /api/avatars - Remove avatar
+avatarRoutes.delete('/', async c => {
+ const { user: authUser } = getAuth(c);
+ const db = createDb(c.env.DB);
+
+ try {
+ const key = `avatars/${authUser.id}`;
+
+ // Delete from R2
+ await c.env.BUCKET.delete(key);
+
+ // Clear image in database
+ await db.update(user).set({ image: null, updatedAt: new Date() }).where(eq(user.id, authUser.id));
+
+ return c.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting avatar:', error);
+ const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'delete_avatar',
+ });
+ return c.json(dbError, dbError.statusCode);
+ }
+});
+
+export { avatarRoutes };
+```
diff --git a/.claude/skills/api-route/references/patterns.md b/.claude/skills/api-route/references/patterns.md
new file mode 100644
index 000000000..788d30cf8
--- /dev/null
+++ b/.claude/skills/api-route/references/patterns.md
@@ -0,0 +1,705 @@
+# Detailed API Route Patterns
+
+Comprehensive patterns for Hono API routes in CoRATES workers.
+
+## Middleware Deep Dive
+
+### Authentication Middleware
+
+```javascript
+// File: packages/workers/src/middleware/auth.js
+
+// Attach auth to context (non-blocking)
+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();
+}
+
+// Require authentication (blocking)
+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);
+ }
+}
+
+// Get auth from context
+export function getAuth(c) {
+ return {
+ user: c.get('user'),
+ session: c.get('session'),
+ };
+}
+```
+
+### Organization Membership Middleware
+
+```javascript
+// File: packages/workers/src/middleware/requireOrg.js
+
+export function requireOrgMembership(minRole) {
+ return async (c, next) => {
+ const { user } = getAuth(c);
+ const orgId = c.req.param('orgId');
+
+ if (!user) {
+ const error = createDomainError(AUTH_ERRORS.REQUIRED);
+ return c.json(error, error.statusCode);
+ }
+
+ if (!orgId) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'org_id_required',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ const db = createDb(c.env.DB);
+
+ const membership = await db
+ .select({
+ id: member.id,
+ role: member.role,
+ orgName: organization.name,
+ })
+ .from(member)
+ .innerJoin(organization, eq(member.organizationId, organization.id))
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, user.id)))
+ .get();
+
+ if (!membership) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'not_org_member',
+ orgId,
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Check minimum role if specified
+ if (minRole && !hasMinimumOrgRole(membership.role, minRole)) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'insufficient_org_role',
+ required: minRole,
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Attach org context
+ c.set('orgId', orgId);
+ c.set('orgRole', membership.role);
+ c.set('org', { id: orgId, name: membership.orgName });
+
+ await next();
+ };
+}
+
+export function getOrgContext(c) {
+ return {
+ orgId: c.get('orgId') || null,
+ orgRole: c.get('orgRole') || null,
+ org: c.get('org') || null,
+ };
+}
+```
+
+### Entitlement Middleware
+
+```javascript
+// File: packages/workers/src/middleware/requireEntitlement.js
+
+export function requireEntitlement(entitlement) {
+ return async (c, next) => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+
+ if (!user || !orgId) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN);
+ return c.json(error, error.statusCode);
+ }
+
+ const db = createDb(c.env.DB);
+ const orgBilling = await resolveOrgAccess(db, orgId);
+
+ if (!orgBilling.entitlements[entitlement]) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'missing_entitlement',
+ entitlement,
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ c.set('orgBilling', orgBilling);
+ c.set('entitlements', orgBilling.entitlements);
+
+ await next();
+ };
+}
+```
+
+### Quota Middleware
+
+```javascript
+// File: packages/workers/src/middleware/requireQuota.js
+
+export function requireQuota(quotaKey, getUsage, requested = 1) {
+ return async (c, next) => {
+ const { user } = getAuth(c);
+ const { orgId } = getOrgContext(c);
+
+ if (!user || !orgId) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN);
+ return c.json(error, error.statusCode);
+ }
+
+ const db = createDb(c.env.DB);
+ const orgBilling = await resolveOrgAccess(db, orgId);
+
+ // Get current usage
+ const used = await getUsage(c, user);
+
+ // Check quota
+ const limit = orgBilling.quotas[quotaKey];
+ if (!isUnlimitedQuota(limit) && used + requested > limit) {
+ const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
+ reason: 'quota_exceeded',
+ quotaKey,
+ used,
+ limit,
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ c.set('orgBilling', orgBilling);
+ c.set('quotas', orgBilling.quotas);
+
+ await next();
+ };
+}
+
+// Example usage function
+async function getProjectCount(c, user) {
+ const { orgId } = getOrgContext(c);
+ const db = createDb(c.env.DB);
+ const [result] = await db.select({ count: count() }).from(projects).where(eq(projects.orgId, orgId));
+ return result?.count || 0;
+}
+```
+
+### Validation Middleware
+
+```javascript
+// File: 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) {
+ return c.json(result.error, result.error.statusCode);
+ }
+
+ c.set('validatedBody', result.data);
+ await next();
+ } catch (error) {
+ const invalidJsonError = createValidationError('body', 'VALIDATION_INVALID_INPUT', null, 'invalid_json');
+ return c.json(invalidJsonError, invalidJsonError.statusCode);
+ }
+ };
+}
+
+function validateBody(schema, body) {
+ const result = schema.safeParse(body);
+
+ if (!result.success) {
+ const firstIssue = result.error.issues[0];
+ const fieldName = firstIssue?.path[0] || 'input';
+ const validationCode = mapZodErrorToValidationCode(firstIssue);
+
+ return {
+ success: false,
+ error: createValidationError(fieldName, validationCode),
+ };
+ }
+
+ return { success: true, data: result.data };
+}
+```
+
+## Complex Database Queries
+
+### Joins with Field Selection
+
+```javascript
+const results = await db
+ .select({
+ id: projects.id,
+ name: projects.name,
+ description: projects.description,
+ role: projectMembers.role,
+ memberCount: sql`(
+ SELECT COUNT(*) FROM ${projectMembers}
+ WHERE ${projectMembers.projectId} = ${projects.id}
+ )`.as('memberCount'),
+ })
+ .from(projects)
+ .innerJoin(projectMembers, eq(projects.id, projectMembers.projectId))
+ .where(eq(projectMembers.userId, user.id))
+ .orderBy(desc(projects.updatedAt));
+```
+
+### Search with Multiple Conditions
+
+```javascript
+const searchPattern = `%${query.toLowerCase()}%`;
+
+const results = await db
+ .select({
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ })
+ .from(user)
+ .where(
+ or(
+ like(sql`lower(${user.email})`, searchPattern),
+ like(sql`lower(${user.name})`, searchPattern),
+ like(sql`lower(${user.displayName})`, searchPattern),
+ ),
+ )
+ .limit(limit);
+```
+
+### Filtering Results Post-Query
+
+```javascript
+// Get members already in project
+const existingMembers = await db
+ .select({ userId: projectMembers.userId })
+ .from(projectMembers)
+ .where(eq(projectMembers.projectId, projectId));
+
+const existingUserIds = new Set(existingMembers.map(m => m.userId));
+
+// Filter search results
+results = results.filter(u => !existingUserIds.has(u.id));
+```
+
+### Count Queries
+
+```javascript
+const [result] = await db.select({ count: count() }).from(projects).where(eq(projects.orgId, orgId));
+
+const projectCount = result?.count || 0;
+```
+
+### Batch Operations
+
+```javascript
+// D1 batch for pseudo-atomic operations
+const projectId = crypto.randomUUID();
+const memberId = crypto.randomUUID();
+const now = new Date();
+
+await db.batch([
+ db.insert(projects).values({
+ id: projectId,
+ name: name.trim(),
+ createdBy: authUser.id,
+ createdAt: now,
+ updatedAt: now,
+ }),
+ db.insert(projectMembers).values({
+ id: memberId,
+ projectId,
+ userId: authUser.id,
+ role: 'owner',
+ joinedAt: now,
+ }),
+]);
+```
+
+### Cascade Deletes
+
+```javascript
+await db.batch([
+ // Set nullable foreign keys to null
+ db.update(mediaFiles).set({ uploadedBy: null }).where(eq(mediaFiles.uploadedBy, userId)),
+
+ // Delete dependent records
+ db.delete(projectMembers).where(eq(projectMembers.userId, userId)),
+ db.delete(invitations).where(eq(invitations.invitedUserId, userId)),
+
+ // Delete main record
+ db.delete(user).where(eq(user.id, userId)),
+]);
+```
+
+## Nested Route Composition
+
+### Parent Route
+
+```javascript
+// File: packages/workers/src/routes/orgs/index.js
+import { Hono } from 'hono';
+import { requireAuth } from '@/middleware/auth.js';
+import { orgProjectRoutes } from './projects.js';
+import { orgMemberRoutes } from './members.js';
+
+const orgRoutes = new Hono();
+
+orgRoutes.use('*', requireAuth);
+
+// Org CRUD
+orgRoutes.get('/', async c => { ... });
+orgRoutes.post('/', async c => { ... });
+orgRoutes.get('/:orgId', async c => { ... });
+
+// Mount nested routes
+orgRoutes.route('/:orgId/projects', orgProjectRoutes);
+orgRoutes.route('/:orgId/members', orgMemberRoutes);
+
+export { orgRoutes };
+```
+
+### Child Route
+
+```javascript
+// File: packages/workers/src/routes/orgs/projects.js
+import { Hono } from 'hono';
+import { requireOrgMembership, getOrgContext } from '@/middleware/requireOrg.js';
+import { orgProjectMemberRoutes } from './project-members.js';
+
+const orgProjectRoutes = new Hono();
+
+// Org membership required for all project routes
+orgProjectRoutes.get('/', requireOrgMembership(), async c => {
+ const { orgId } = getOrgContext(c);
+ // List projects in org...
+});
+
+orgProjectRoutes.post('/', requireOrgMembership(), ..., async c => {
+ const { orgId } = getOrgContext(c);
+ // Create project in org...
+});
+
+// Mount deeper nested routes
+orgProjectRoutes.route('/:projectId/members', orgProjectMemberRoutes);
+
+export { orgProjectRoutes };
+```
+
+### URL Structure
+
+```
+/api/orgs -> orgRoutes GET /
+/api/orgs/:orgId -> orgRoutes GET /:orgId
+/api/orgs/:orgId/projects -> orgProjectRoutes GET /
+/api/orgs/:orgId/projects/:projectId/members -> orgProjectMemberRoutes
+```
+
+## Rate Limiting
+
+### Configure Rate Limits
+
+```javascript
+// File: packages/workers/src/middleware/rateLimit.js
+import { createRateLimiter } from '@/lib/rateLimit.js';
+
+// Strict limit for public endpoints
+export const contactRateLimit = createRateLimiter({
+ limit: 5,
+ window: 60 * 1000, // 1 minute
+ keyPrefix: 'contact',
+});
+
+// Moderate limit for search
+export const searchRateLimit = createRateLimiter({
+ limit: 30,
+ window: 60 * 1000,
+ keyPrefix: 'search',
+});
+
+// Relaxed limit for authenticated APIs
+export const apiRateLimit = createRateLimiter({
+ limit: 100,
+ window: 60 * 1000,
+ keyPrefix: 'api',
+});
+```
+
+### Apply Rate Limits
+
+```javascript
+import { contactRateLimit } from '@/middleware/rateLimit.js';
+
+const contact = new Hono();
+contact.use('*', contactRateLimit);
+
+contact.post('/', async c => {
+ // Handler...
+});
+```
+
+## Error Handler
+
+### Global Error Handler
+
+```javascript
+// File: packages/workers/src/middleware/errorHandler.js
+
+export function errorHandler(err, c) {
+ console.error(`[${c.req.method}] ${c.req.path}:`, err);
+
+ // Domain errors
+ if (isDomainError(err)) {
+ return c.json(err, err.statusCode);
+ }
+
+ // Zod validation errors
+ if (isZodError(err)) {
+ const error = createDomainError(SYSTEM_ERRORS.VALIDATION_ERROR, {
+ message: formatZodErrors(err),
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Hono HTTP exceptions
+ if (err?.getResponse) {
+ return err.getResponse();
+ }
+
+ // Database errors
+ if (err?.message?.includes('D1_')) {
+ const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'database_operation',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ // Unique constraint violation
+ if (err?.message?.includes('UNIQUE constraint failed')) {
+ const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+ operation: 'unique_constraint_violation',
+ });
+ return c.json(error, 409);
+ }
+
+ // Fallback
+ const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR);
+ return c.json(error, error.statusCode);
+}
+```
+
+### Register in Main App
+
+```javascript
+// File: packages/workers/src/index.js
+import { errorHandler } from './middleware/errorHandler.js';
+
+const app = new Hono();
+
+// ... routes ...
+
+app.onError(errorHandler);
+
+app.notFound(c => {
+ return c.json({ error: 'Not Found' }, 404);
+});
+
+export default app;
+```
+
+## Query Parameters
+
+### Parse and Validate
+
+```javascript
+routes.get('/search', async c => {
+ const query = c.req.query('q')?.trim();
+ const limit = Math.min(parseInt(c.req.query('limit') || '10', 10), 20);
+ const offset = parseInt(c.req.query('offset') || '0', 10);
+ const sortBy = c.req.query('sortBy') || 'createdAt';
+ const order = c.req.query('order') === 'asc' ? 'asc' : 'desc';
+
+ if (!query || query.length < 2) {
+ const error = createValidationError('query', 'VALIDATION_FIELD_TOO_SHORT');
+ return c.json(error, error.statusCode);
+ }
+
+ // Use validated params...
+});
+```
+
+### Path Parameters
+
+```javascript
+routes.get('/:id', async c => {
+ const id = c.req.param('id');
+
+ // Validate UUID format if needed
+ if (!isValidUUID(id)) {
+ const error = createValidationError('id', 'VALIDATION_FIELD_INVALID_FORMAT');
+ return c.json(error, error.statusCode);
+ }
+
+ // Use id...
+});
+
+// Multiple path params
+routes.get('/:projectId/studies/:studyId', async c => {
+ const projectId = c.req.param('projectId');
+ const studyId = c.req.param('studyId');
+ // ...
+});
+```
+
+## Response Headers
+
+```javascript
+// Rate limit headers
+return c.json(data, 200, {
+ 'X-RateLimit-Limit': String(limit),
+ 'X-RateLimit-Remaining': String(remaining),
+ 'X-RateLimit-Reset': String(resetTime),
+});
+
+// Cache headers
+return c.json(data, 200, {
+ 'Cache-Control': 'public, max-age=300',
+});
+
+// No content
+return c.body(null, 204);
+```
+
+## File Uploads
+
+### Handle Multipart Form Data
+
+```javascript
+import { z } from 'zod';
+
+routes.post('/upload', async c => {
+ const { user } = getAuth(c);
+
+ const formData = await c.req.formData();
+ const file = formData.get('file');
+
+ if (!file || !(file instanceof File)) {
+ const error = createValidationError('file', 'VALIDATION_FIELD_REQUIRED');
+ return c.json(error, error.statusCode);
+ }
+
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
+ if (!allowedTypes.includes(file.type)) {
+ const error = createValidationError('file', 'VALIDATION_FIELD_INVALID_FORMAT');
+ return c.json(error, error.statusCode);
+ }
+
+ // Validate file size (e.g., 5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ const error = createValidationError('file', 'VALIDATION_FIELD_TOO_LONG');
+ return c.json(error, error.statusCode);
+ }
+
+ // Process file...
+ const buffer = await file.arrayBuffer();
+
+ // Upload to R2 or process
+ await c.env.BUCKET.put(`avatars/${user.id}`, buffer, {
+ httpMetadata: { contentType: file.type },
+ });
+
+ return c.json({ success: true });
+});
+```
+
+## External Service Integration
+
+### Best-Effort External Calls
+
+```javascript
+routes.post('/', ..., async c => {
+ // Main database operation
+ await db.insert(projects).values({ ... });
+
+ // External service sync (best-effort)
+ try {
+ await syncProjectToDO(c.env, projectId, { ... });
+ } catch (err) {
+ console.error('Failed to sync to DO:', err);
+ // Continue - don't fail the request
+ }
+
+ return c.json(newProject, 201);
+});
+```
+
+### Required External Calls
+
+```javascript
+routes.post('/send-email', async c => {
+ const { email, subject, body } = c.get('validatedBody');
+
+ if (!c.env.POSTMARK_SERVER_TOKEN) {
+ const error = createDomainError(SYSTEM_ERRORS.SERVICE_UNAVAILABLE, {
+ service: 'email',
+ });
+ return c.json(error, error.statusCode);
+ }
+
+ try {
+ const postmark = new PostmarkClient(c.env.POSTMARK_SERVER_TOKEN);
+ const response = await postmark.sendEmail({
+ From: c.env.EMAIL_FROM,
+ To: email,
+ Subject: subject,
+ TextBody: body,
+ });
+
+ if (response.ErrorCode !== 0) {
+ console.error('Postmark error:', response);
+ const error = createDomainError(SYSTEM_ERRORS.EMAIL_SEND_FAILED);
+ return c.json(error, error.statusCode);
+ }
+
+ return c.json({ success: true, messageId: response.MessageID });
+ } catch (err) {
+ console.error('Email exception:', err.message);
+ const error = createDomainError(SYSTEM_ERRORS.EMAIL_SEND_FAILED);
+ return c.json(error, error.statusCode);
+ }
+});
+```
diff --git a/.claude/skills/component/SKILL.md b/.claude/skills/component/SKILL.md
new file mode 100644
index 000000000..2b7f161ab
--- /dev/null
+++ b/.claude/skills/component/SKILL.md
@@ -0,0 +1,290 @@
+---
+name: Component
+description: This skill should be used when the user asks to "create a component", "scaffold a component", "add a new component", "build a SolidJS component", or mentions creating UI elements, views, or feature components. Provides SolidJS component patterns specific to CoRATES.
+---
+
+# SolidJS Component Creation
+
+Create SolidJS components following CoRATES patterns and conventions.
+
+## Core Principles
+
+1. **No prop destructuring** - Access props via `props.field` directly
+2. **No prop drilling** - Import stores directly where needed
+3. **Minimal props** - Components receive 1-5 local config props only
+4. **Small and focused** - Each component handles one responsibility
+5. **No emojis** - Use `solid-icons` library for all icons
+
+## Quick Reference
+
+### File Location
+
+```
+packages/web/src/components/
+ [feature]/ # Feature directory
+ ComponentName.jsx # PascalCase naming
+ index.js # Barrel export (optional, for lazy loading)
+```
+
+### Basic Component Template
+
+```jsx
+import { createSignal, createMemo, Show, For } from 'solid-js';
+import { FiIcon } from 'solid-icons/fi';
+import { Button, Dialog } from '@corates/ui';
+import someStore from '@/stores/someStore.js';
+
+export function ComponentName(props) {
+ // Local state only
+ const [localState, setLocalState] = createSignal(false);
+
+ // Derived values
+ const computed = createMemo(() => {
+ return props.items?.length ?? 0;
+ });
+
+ // Access props directly - NEVER destructure
+ const handleClick = () => {
+ props.onAction?.(props.itemId);
+ };
+
+ return (
+
+
+ {props.title}
+
+ {computed()} items
+
+ );
+}
+
+export default ComponentName;
+```
+
+## Critical Rules
+
+### Props - NEVER Destructure
+
+```jsx
+// WRONG - breaks reactivity
+function Component({ title, items, onSelect }) {
+ return {title}
;
+}
+
+// CORRECT - preserves reactivity
+function Component(props) {
+ return {props.title}
;
+}
+```
+
+For derived values, wrap in function or createMemo:
+
+```jsx
+// Arrow function for simple access
+const isOwner = () => props.role === 'owner';
+
+// createMemo for computed values
+const progress = createMemo(() => ({
+ completed: props.completed ?? 0,
+ total: props.total ?? 0,
+ percentage: props.total ? Math.round((props.completed / props.total) * 100) : 0,
+}));
+
+// Usage
+...
+{progress().percentage}%
+```
+
+### Stores - Import Directly
+
+```jsx
+// WRONG - prop drilling
+function Parent() {
+ return ;
+}
+
+// CORRECT - import stores where needed
+import projectStore from '@/stores/projectStore.js';
+import { user } from '@/stores/authStore.js';
+
+function Component(props) {
+ // Read from store directly
+ const studies = () => projectStore.getStudies(props.projectId);
+
+ return {study => ...};
+}
+```
+
+### Icons - Use solid-icons
+
+```jsx
+import { FiCheck, FiX, FiFolder } from 'solid-icons/fi';
+import { AiOutlineFolder } from 'solid-icons/ai';
+import { HiOutlineDocumentCheck } from 'solid-icons/hi';
+
+// Usage
+
+
+// Conditional icons
+}>
+
+
+```
+
+### UI Components - Import from @corates/ui
+
+```jsx
+// CORRECT
+import { Dialog, Select, Toast, Avatar, Tooltip, useConfirmDialog } from '@corates/ui';
+
+// WRONG - no local wrappers
+import { Dialog } from '@/components/ark/Dialog.jsx';
+```
+
+## Import Aliases
+
+Use path aliases from jsconfig.json:
+
+```jsx
+import Component from '@components/feature/Component.jsx';
+import { useHook } from '@primitives/useHook.js';
+import store from '@/stores/store.js';
+import { config } from '@config/api.js';
+import { utility } from '@lib/utils.js';
+```
+
+## State Patterns
+
+### Local State (createSignal)
+
+For UI state within the component:
+
+```jsx
+const [isOpen, setIsOpen] = createSignal(false);
+const [search, setSearch] = createSignal('');
+```
+
+### Derived State (createMemo)
+
+For computed values that depend on props or signals:
+
+```jsx
+const stats = createMemo(() => {
+ const items = props.items ?? [];
+ return {
+ total: items.length,
+ completed: items.filter(i => i.done).length,
+ };
+});
+```
+
+### Complex Local State (createStore)
+
+For nested objects within the component:
+
+```jsx
+import { createStore, produce } from 'solid-js/store';
+
+const [form, setForm] = createStore({
+ name: '',
+ settings: { notify: true },
+});
+
+// Update nested
+setForm(
+ produce(f => {
+ f.settings.notify = false;
+ }),
+);
+```
+
+## Component Patterns
+
+### Conditional Rendering
+
+```jsx
+}>
+ {data => }
+
+
+
+
+
+
+
+```
+
+### List Rendering
+
+```jsx
+
+ {(item, index) => (
+ - props.onSelect?.(item.id)}
+ />
+ )}
+
+
+// With keyed for identity-based updates
+
+ {(item, index) => }
+
+```
+
+### Event Handlers
+
+```jsx
+// Call props handlers with optional chaining
+
+
+// Stop propagation when needed
+
+```
+
+## Barrel Exports
+
+For lazy-loaded routes, create index.js:
+
+```jsx
+// packages/web/src/components/feature/index.js
+export { default } from './FeatureMain.jsx';
+export { FeatureCard } from './FeatureCard.jsx';
+export { FeatureList } from './FeatureList.jsx';
+```
+
+## Creation Checklist
+
+When creating a component:
+
+1. Determine file location under `packages/web/src/components/`
+2. Identify required props (keep to 1-5 max)
+3. Identify stores to import (no prop drilling)
+4. Choose icons from solid-icons
+5. Use Ark UI components from @corates/ui
+6. Apply proper state patterns (signal/memo/store)
+7. Ensure no prop destructuring
+8. Keep component focused and small
+
+## Additional Resources
+
+### Reference Files
+
+For detailed patterns and examples:
+
+- **`references/patterns.md`** - Comprehensive state patterns, effects, and cleanup
+- **`references/examples.md`** - Real component examples from the codebase
+
+### Example Files
+
+Working templates in `examples/`:
+
+- **`ExampleComponent.jsx`** - Complete component template with all patterns
diff --git a/.claude/skills/component/examples/ExampleComponent.jsx b/.claude/skills/component/examples/ExampleComponent.jsx
new file mode 100644
index 000000000..e9be9499b
--- /dev/null
+++ b/.claude/skills/component/examples/ExampleComponent.jsx
@@ -0,0 +1,270 @@
+/**
+ * Example SolidJS Component Template
+ *
+ * Demonstrates all key patterns for CoRATES components.
+ * Copy and modify for new components.
+ */
+
+import { createSignal, createMemo, createEffect, onMount, onCleanup, Show, For } from 'solid-js';
+import { useNavigate, useParams } from '@solidjs/router';
+
+// Icons - use solid-icons packages
+import { FiEdit, FiTrash2, FiCheck, FiX } from 'solid-icons/fi';
+
+// UI components - import from @corates/ui
+import { Dialog, Tooltip, useConfirmDialog } from '@corates/ui';
+
+// Stores - import directly, no prop drilling
+import projectStore from '@/stores/projectStore.js';
+
+// Primitives/hooks
+import { useProjectData } from '@primitives/useProjectData.js';
+
+/**
+ * ExampleComponent
+ *
+ * @param {Object} props
+ * @param {string} props.itemId - Required item identifier
+ * @param {string} [props.title] - Optional display title
+ * @param {Function} [props.onSave] - Callback when item is saved
+ * @param {Function} [props.onDelete] - Callback when item is deleted
+ */
+export function ExampleComponent(props) {
+ const navigate = useNavigate();
+ const params = useParams();
+ const confirmDialog = useConfirmDialog();
+
+ // Refs for DOM access
+ let containerRef;
+
+ // ---------------------
+ // Local State (signals)
+ // ---------------------
+
+ const [isEditing, setIsEditing] = createSignal(false);
+ const [localValue, setLocalValue] = createSignal('');
+
+ // ---------------------
+ // Store Data (no prop drilling)
+ // ---------------------
+
+ // Option 1: Direct store access
+ const item = () => projectStore.getItem(props.itemId);
+
+ // Option 2: Using a hook
+ const projectData = useProjectData(params.projectId);
+
+ // ---------------------
+ // Derived Values (createMemo)
+ // ---------------------
+
+ const displayTitle = createMemo(() => {
+ return props.title || item()?.name || 'Untitled';
+ });
+
+ const stats = createMemo(() => {
+ const data = item();
+ if (!data) return { total: 0, completed: 0, percentage: 0 };
+
+ const total = data.items?.length ?? 0;
+ const completed = data.items?.filter(i => i.done).length ?? 0;
+
+ return {
+ total,
+ completed,
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
+ };
+ });
+
+ // Simple derived value as arrow function
+ const canEdit = () => item()?.permissions?.includes('edit');
+ const isComplete = () => stats().percentage === 100;
+
+ // ---------------------
+ // Effects
+ // ---------------------
+
+ // Sync local state when item changes
+ createEffect(() => {
+ const currentItem = item();
+ if (currentItem) {
+ setLocalValue(currentItem.value ?? '');
+ }
+ });
+
+ // ---------------------
+ // Lifecycle
+ // ---------------------
+
+ onMount(() => {
+ // Focus container on mount
+ containerRef?.focus();
+
+ // Example: keyboard shortcut
+ const handleKeyDown = e => {
+ if (e.key === 'Escape' && isEditing()) {
+ setIsEditing(false);
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ // Always clean up event listeners
+ onCleanup(() => {
+ document.removeEventListener('keydown', handleKeyDown);
+ });
+ });
+
+ // ---------------------
+ // Event Handlers
+ // ---------------------
+
+ const handleSave = async () => {
+ // Update store
+ await projectStore.updateItem(props.itemId, { value: localValue() });
+
+ // Call prop handler with optional chaining
+ props.onSave?.(props.itemId, localValue());
+
+ setIsEditing(false);
+ };
+
+ const handleDelete = async () => {
+ // Use confirm dialog from @corates/ui
+ const confirmed = await confirmDialog.confirm({
+ title: 'Delete Item?',
+ message: 'This action cannot be undone.',
+ });
+
+ if (confirmed) {
+ await projectStore.deleteItem(props.itemId);
+ props.onDelete?.(props.itemId);
+ }
+ };
+
+ const handleCancel = () => {
+ // Reset to original value
+ setLocalValue(item()?.value ?? '');
+ setIsEditing(false);
+ };
+
+ // ---------------------
+ // Render
+ // ---------------------
+
+ return (
+
+ {/* Header */}
+
+
{displayTitle()}
+
+
+ {/* Edit button - only shown if user can edit */}
+
+
+
+
+
+
+ {/* Delete button - stop propagation if inside clickable container */}
+
+
+
+
+
+
+
+
+ {/* Content - switch between view and edit modes */}
+
+ {item()?.value || 'No content'}
+
+ }
+ >
+
+
+
+ {/* Stats footer */}
+
+
+ Progress
+
+ {stats().completed} / {stats().total} ({stats().percentage}%)
+
+
+
+ {/* Progress bar */}
+
+
+
+ {/* List rendering example */}
+ 0}>
+
+
+ {(subItem, index) => (
+
+
+ {index() + 1}. {subItem.name}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default ExampleComponent;
diff --git a/.claude/skills/component/references/examples.md b/.claude/skills/component/references/examples.md
new file mode 100644
index 000000000..bf13413cc
--- /dev/null
+++ b/.claude/skills/component/references/examples.md
@@ -0,0 +1,436 @@
+# Real Component Examples
+
+Working examples from the CoRATES codebase demonstrating best practices.
+
+## ProjectCard Component
+
+Location: `packages/web/src/components/dashboard/ProjectCard.jsx`
+
+A card component showing project info with computed stats and conditional rendering:
+
+```jsx
+import { createMemo, Show } from 'solid-js';
+import { FiTrash2, FiUsers } from 'solid-icons/fi';
+import { Tooltip } from '@corates/ui';
+import projectStore from '@/stores/projectStore.js';
+
+const ACCENT_COLORS = [
+ { bg: 'bg-blue-500', light: 'bg-blue-50', text: 'text-blue-700' },
+ { bg: 'bg-emerald-500', light: 'bg-emerald-50', text: 'text-emerald-700' },
+ // ... more colors
+];
+
+function hashToColorIndex(str) {
+ if (!str) return 0;
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return Math.abs(hash) % ACCENT_COLORS.length;
+}
+
+export function ProjectCard(props) {
+ // Computed color based on project ID
+ const colors = createMemo(() => {
+ const index = hashToColorIndex(props.project?.id);
+ return ACCENT_COLORS[index];
+ });
+
+ // Computed progress from store or props fallback
+ const progress = createMemo(() => {
+ const cachedStats = projectStore.getProjectStats(props.project?.id);
+ const completed = cachedStats?.completedCount ?? props.project?.completedCount ?? 0;
+ const total = cachedStats?.studyCount ?? props.project?.studyCount ?? 0;
+
+ if (total === 0) return { completed: 0, total: 0, percentage: 0 };
+
+ return {
+ completed,
+ total,
+ percentage: Math.round((completed / total) * 100),
+ };
+ });
+
+ // Simple derived value as arrow function
+ const isOwner = () => props.project?.role === 'owner';
+
+ return (
+ props.onSelect?.(props.project)}
+ >
+ {/* Color accent bar */}
+
+
+ {/* Header with title and delete button */}
+
+
{props.project?.title || 'Untitled Project'}
+
+
+
+
+
+
+
+
+ {/* Progress section */}
+
+
+ Progress
+ {progress().percentage}%
+
+
+
+
+
+
+ {progress().completed} / {progress().total} studies
+
+ 1}>
+
+
+ {props.project.memberCount}
+
+
+
+
+
+ );
+}
+```
+
+Key patterns:
+
+- Props accessed directly via `props.project`, `props.onDelete`, etc.
+- `createMemo` for computed values that derive from props
+- Arrow functions for simple boolean checks: `isOwner()`
+- Store imported directly for additional data
+- Icons from solid-icons
+- Tooltip from @corates/ui
+- Event handler stops propagation and calls prop handler
+
+---
+
+## StatsRow Component
+
+Location: `packages/web/src/components/dashboard/StatsRow.jsx`
+
+A row of stat cards using context for animations:
+
+```jsx
+import { For, useContext } from 'solid-js';
+import { FiFolder, FiCheck, FiFileText, FiUsers } from 'solid-icons/fi';
+import { AnimationContext } from './Dashboard.jsx';
+
+export function StatsRow(props) {
+ const animation = useContext(AnimationContext);
+
+ // Computed stats array with icons
+ const stats = () => [
+ {
+ label: 'Active Projects',
+ value: props.projectCount ?? 0,
+ icon: ,
+ iconBg: 'bg-blue-50',
+ },
+ {
+ label: 'Studies Reviewed',
+ value: `${props.completedStudies ?? 0}/${props.totalStudies ?? 0}`,
+ icon: ,
+ iconBg: 'bg-emerald-50',
+ },
+ {
+ label: 'Local Appraisals',
+ value: props.localAppraisalCount ?? 0,
+ icon: ,
+ iconBg: 'bg-purple-50',
+ },
+ {
+ label: 'Team Members',
+ value: props.teamMemberCount ?? '-',
+ icon: ,
+ iconBg: 'bg-amber-50',
+ },
+ ];
+
+ return (
+
+ );
+}
+
+export function StatCard(props) {
+ return (
+
+
+
{props.stat.icon}
+
+
{props.stat.value}
+
{props.stat.label}
+
+
+
+ );
+}
+```
+
+Key patterns:
+
+- Context used for animation values (not prop drilling)
+- Stats computed as array with JSX icons
+- Props accessed directly: `props.projectCount`, `props.stat.label`
+- For loop with index for staggered animations
+- Small, focused components (StatsRow + StatCard)
+
+---
+
+## Sidebar Component (Excerpt)
+
+Location: `packages/web/src/components/sidebar/Sidebar.jsx`
+
+Demonstrates local state, effects, and cleanup:
+
+```jsx
+import { createSignal, createEffect, onMount, onCleanup, Show, For } from 'solid-js';
+import { useNavigate, useParams } from '@solidjs/router';
+import { FiPlus, FiChevronLeft, FiChevronRight } from 'solid-icons/fi';
+import { useConfirmDialog, Tooltip } from '@corates/ui';
+import projectStore from '@/stores/projectStore.js';
+import localChecklistsStore from '@/stores/localChecklistsStore.js';
+
+export default function Sidebar(props) {
+ const navigate = useNavigate();
+ const params = useParams();
+ const confirmDialog = useConfirmDialog();
+
+ // Local UI state
+ const [expandedProjects, setExpandedProjects] = createSignal({});
+ const [isResizing, setIsResizing] = createSignal(false);
+ const [width, setWidth] = createSignal(280);
+
+ // Read from stores directly
+ const projects = () => projectStore.store.projectList ?? [];
+ const localChecklists = () => localChecklistsStore.checklists();
+
+ // Toggle expand state
+ const toggleProject = projectId => {
+ setExpandedProjects(prev => ({
+ ...prev,
+ [projectId]: !prev[projectId],
+ }));
+ };
+
+ // Handle resize with cleanup
+ onMount(() => {
+ const handleMouseMove = e => {
+ if (!isResizing()) return;
+ const newWidth = Math.max(200, Math.min(400, e.clientX));
+ setWidth(newWidth);
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ onCleanup(() => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ });
+ });
+
+ // Delete with confirmation
+ const handleDeleteChecklist = async checklistId => {
+ const confirmed = await confirmDialog.confirm({
+ title: 'Delete Checklist?',
+ message: 'This action cannot be undone.',
+ });
+
+ if (confirmed) {
+ await localChecklistsStore.deleteChecklist(checklistId);
+ }
+ };
+
+ return (
+