Skip to content

backend migration to organizations completed#200

Merged
InfinityBowman merged 22 commits into
devfrom
199-add-organizations
Dec 30, 2025
Merged

backend migration to organizations completed#200
InfinityBowman merged 22 commits into
devfrom
199-add-organizations

Conversation

@InfinityBowman
Copy link
Copy Markdown
Owner

@InfinityBowman InfinityBowman commented Dec 30, 2025

Summary by CodeRabbit

  • New Features
    • Full organization/workspace support: create organizations, org dashboards, org-scoped projects, members, invitations, and role-based access.
    • Workspace switcher in the navbar and org-aware navigation (frontend routes and sidebar).
    • Org-aware PDF, Google Drive import, and project/project-member flows.
  • Bug Fixes / UX
    • Improved redirects and sign-in/redirect flows to respect organization context.
  • Documentation
    • Added Organizations guide and updated routing/middleware docs.

✏️ Tip: You can customize this high-level summary in your review settings.

@InfinityBowman InfinityBowman linked an issue Dec 30, 2025 that may be closed by this pull request
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 30, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

Adds multi-tenant organization support: database tables, org-scoped API routes and middleware, frontend org context/hooks/pages/navigation, org-aware PDF/Google Drive APIs and stores, durable-object and DO-auth changes, and extensive test/migration/documentation updates enabling /api/orgs/:orgId/... routing.

Changes

Cohort / File(s) Summary
Error Domain Extensions
packages/shared/src/errors/domains/domain.ts
Added PROJECT_ERRORS.NOT_IN_ORG (code: PROJECT_NOT_IN_ORG, status 403).
Database Migrations & Schema
packages/workers/migrations/*, packages/workers/src/db/schema.js
Added organization, member, invitation tables; added orgId fields to projects and project_invitations; added activeOrganizationId to session; unique index on organization.slug.
Auth & Bootstrap Hooks
packages/workers/src/auth/config.js
Integrated Better-Auth organization plugin and an after-hook to bootstrap a personal organization on first login; exposed org tables to auth adapter.
Access Control Middleware
packages/workers/src/middleware/requireOrg.js
New requireOrgMembership and requireProjectAccess middlewares, role hierarchies, and context accessors (getOrgContext, getProjectContext).
Org-scoped Routes (Server)
packages/workers/src/routes/orgs/*.js, packages/workers/src/index.js
New orgRoutes with CRUD org endpoints; mounted org project/member/pdf/invitation routes; replaced legacy project/invitation mounts with 410 handlers.
Org-scoped Project APIs (Server)
packages/workers/src/routes/orgs/projects.js, orgs/members.js, orgs/pdfs.js, orgs/invitations.js
Full org-scoped project CRUD, member management, PDF handling and invitation lifecycle (including magic-link acceptance and org membership creation).
Durable Object / Auth Hardening
packages/workers/src/durable-objects/ProjectDoc.js
DB-backed membership checks (org & project), cached orgId in DO storage, stricter WebSocket auth and post-auth Yjs sync.
Frontend Routing & Pages
packages/web/src/Routes.jsx, components/org/*, components/project/*
Reorganized frontend routes under /orgs/:orgSlug/...; added CreateOrgPage, OrgProjectsPage, OrgRedirect, ProjectView org-integration; removed legacy ProjectDashboard.
Frontend Org Context & Hooks
packages/web/src/primitives/useOrgContext.js, useOrgProjectContext.js, useOrgProjectList.js
New hooks: resolve org from URL, persist last-org slug, provide orgId/orgSlug/orgName, org+project path builders, and org-scoped project list hook.
Frontend API Signatures (orgId)
packages/web/src/api/pdf-api.js, google-drive.js, auth-client.js
Added orgId parameter to PDF and Google Drive APIs; integrated organization in auth-client plugins and exports; PDF APIs now use buildPdfBaseUrl(orgId,...).
Frontend Stores & Actions (org-aware)
packages/web/src/stores/projectActionsStore/**
Added activeOrgId, updated _setActiveProject(pid, orgId), exposed getActiveOrgId, and threaded getActiveOrgId into create*Actions (pdfs, studies, members, projects).
Frontend UI/Nav & Sidebar
packages/web/src/components/Navbar.jsx, Sidebar.jsx, ProjectTreeItem.jsx, StudyTreeItem.jsx, ChecklistTreeItem.jsx
Workspace switcher added to Navbar; Sidebar and tree items now propagate orgSlug and build org-prefixed paths; useOrgProjectList replaces useProjectList where applicable.
Frontend Checklist & Reconcile
packages/web/src/components/checklist/*
ChecklistYjsWrapper and ReconciliationWrapper updated to use org context for navigation and all PDF operations; added safety checks when orgId missing.
Frontend Project Forms & Modals
packages/web/src/components/project/CreateProjectForm.jsx, overview-tab/*
CreateProjectForm now requires and passes orgId to /api/orgs/:orgId/projects; AddMemberModal and OverviewTab updated to use orgId.
Frontend Query Keys & Tests
packages/web/src/lib/queryKeys.js, primitives/__tests__/projectStore.test.js, many packages/workers/src/**/__tests__
Added queryKeys.orgs and projects.byOrg; removed legacy projectStore list tests; tests updated to seed orgs/orgMembers and call org-scoped endpoints.
Workers Test Helpers & Seeds
packages/workers/src/__tests__/helpers.js, seed-schemas.js
Added seedOrganization, seedOrgMember, seedMediaFile; extended seedProject to accept orgId; updated test DB reset and schemas.
Docs & Guides
packages/docs/**, .cursor/rules/**
New Organizations guide, updated architecture, API and middleware docs describing org-scoped routes and middleware ordering.
Misc (admin/users/media)
packages/workers/src/routes/admin/users.js, users.js, migrations 0002*
On user deletion, mediaFiles.uploadedBy is nulled before deletion; mediaFiles migration added to safely alter FK; tests added/updated accordingly.

Sequence Diagram(s)

%% Org redirect and project listing (frontend + auth + org API + DB)
sequenceDiagram
    actor User
    participant Client as Web Client
    participant OrgRedirect as OrgRedirect
    participant Auth as Auth Service
    participant OrgAPI as Org API
    participant DB as Database

    User->>Client: Open dashboard
    Client->>OrgRedirect: render
    OrgRedirect->>Auth: isLoggedIn()
    Auth-->>OrgRedirect: authenticated
    OrgRedirect->>OrgAPI: GET /api/orgs (user's orgs)
    OrgAPI->>DB: SELECT member/orgs WHERE userId
    DB-->>OrgAPI: org list
    OrgAPI-->>OrgRedirect: orgs
    alt has lastOrgSlug
        OrgRedirect->>Client: navigate to /orgs/{lastOrg}
    else
        OrgRedirect->>Client: navigate to /orgs/{firstOrg}
    end
    Client->>OrgAPI: GET /api/orgs/{orgId}/projects
    OrgAPI->>DB: SELECT projects WHERE orgId
    DB-->>OrgAPI: projects list
    OrgAPI-->>Client: projects
Loading
%% Org-scoped request middleware flow (API request -> middleware -> DB -> handler)
sequenceDiagram
    actor Client
    participant API as Org API
    participant Middleware as requireOrgMembership/requireProjectAccess
    participant DB as Database
    participant Handler as Route Handler

    Client->>API: GET /api/orgs/{orgId}/projects/{projectId}
    API->>Middleware: run auth middleware
    Middleware->>DB: resolve session/user
    DB-->>Middleware: user
    rect rgb(220,235,255)
        Note over Middleware,DB: requireOrgMembership
        Middleware->>DB: SELECT member WHERE userId & orgId
        DB-->>Middleware: member row or null
    end
    rect rgb(220,235,255)
        Note over Middleware,DB: requireProjectAccess
        Middleware->>DB: SELECT project WHERE id=projectId
        DB-->>Middleware: project (with orgId)
        Middleware->>DB: SELECT projectMembers WHERE projectId & userId
        DB-->>Middleware: project member
    end
    alt allowed
        Middleware->>Handler: continue
        Handler->>DB: read project details
        DB-->>Handler: project data
        Handler-->>Client: 200 + data
    else denied
        Middleware-->>Client: 403 ORGANIZATION/PROJECT error
    end
Loading
%% Invitation accept flow (magic link -> org + project membership creation)
sequenceDiagram
    actor Invitee
    participant Client as Web Client
    participant OrgAPI as Org API
    participant DB as Database
    participant AuthEmail as Better Auth Email

    Invitee->>Client: Click magic link -> POST /accept?token
    Client->>OrgAPI: POST /api/orgs/{orgId}/projects/{projectId}/invitations/accept
    OrgAPI->>DB: SELECT invitation WHERE token
    DB-->>OrgAPI: invitation (valid)
    rect rgb(200,255,200)
        Note over OrgAPI,DB: Atomic accept flow
        OrgAPI->>DB: ENSURE org membership (insert if missing)
        DB-->>OrgAPI: org membership ensured
        OrgAPI->>DB: INSERT project member
        DB-->>OrgAPI: project member inserted
        OrgAPI->>DB: UPDATE invitation acceptedAt
        DB-->>OrgAPI: batch committed
    end
    OrgAPI->>AuthEmail: notify user / sync DO (best-effort)
    AuthEmail-->>OrgAPI: queued
    OrgAPI-->>Client: 200 + { orgId, orgSlug, projectId, role }
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'backend migration to organizations completed' clearly and concisely summarizes the main change: completing a backend migration to an organizational architecture.
Docstring Coverage ✅ Passed Docstring coverage is 83.52% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
🔵 In progress
View logs
corates 6629488 Dec 30 2025, 05:22 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Dec 30, 2025

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
corates 63b57e8 Commit Preview URL Dec 30 2025, 07:03 PM

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (23)
packages/web/src/components/Navbar.jsx (1)

19-22: Consider extracting dropdown logic into a reusable primitive.

The dropdown pattern (show signal, ref, click-outside handler) is now duplicated for both user and org menus. If more dropdowns are anticipated, consider extracting a useDropdown primitive in packages/web/src/primitives/ that encapsulates:

  • Show/hide signal
  • Ref management
  • Click-outside handling

This is a low-priority refactor that can be deferred. Based on learnings, reusable logic should be created in primitives.

packages/web/src/components/project/CreateProjectForm.jsx (1)

99-99: Terminology consistency: "organization" vs "workspace".

The error message uses "workspace" while the code uses "organization"/"org" throughout (prop name, API path). Verify this terminology is consistent with other user-facing messages in the app. If "workspace" is the preferred user-facing term, consider documenting this convention.

packages/workers/migrations/meta/0001_snapshot.json (1)

368-373: Nullable slug field with unique constraint.

The organization.slug field is nullable but has a unique index. This allows multiple organizations with null slugs, which may be unexpected if slugs are intended as unique identifiers. Consider making slug required (notNull: true) if every organization should have a unique slug.

packages/workers/src/routes/invitations.js (1)

210-219: Consider handling missing organization gracefully.

If invitation.orgId is set but the organization doesn't exist (e.g., deleted between invitation creation and acceptance), org will be undefined and orgSlug will be null. This is handled safely, but consider whether a warning log would help debugging orphaned invitations.

packages/web/src/components/checklist/compare/ReconciliationWrapper.jsx (1)

375-382: Consider extracting path builder to a shared utility.

This getProjectPath pattern appears to be duplicated across components. Per coding guidelines, consider moving this to a shared primitive (e.g., useOrgProjectContext.js mentioned in the summary) to ensure consistency and reduce duplication.

packages/workers/migrations/0001_nice_blonde_phantom.sql (1)

14-22: Consider adding unique constraint on member table.

As noted in the schema review, the member table allows duplicate (userId, organizationId) combinations. Consider adding:

CREATE UNIQUE INDEX `member_user_org_unique` ON `member` (`userId`, `organizationId`);
packages/workers/src/db/schema.js (1)

36-47: Add a unique constraint on (userId, organizationId) to the member table for data integrity.

While the code currently checks for existing membership before inserting (in ensureOrgMembership and similar places), the database lacks a unique constraint to enforce this at the schema level. This leaves room for duplicates if application logic is ever bypassed. Adding a unique index in the migration would provide a safety net:

CREATE UNIQUE INDEX member_user_org_unique ON member(userId, organizationId);
packages/workers/src/middleware/requireOrg.js (1)

109-109: Import projectMembers at the top level for consistency.

The projectMembers schema is dynamically imported here, which is inconsistent with how other schema entities (member, organization, projects) are imported at the top of the file. Dynamic imports add unnecessary complexity and can make the code harder to follow.

Proposed refactor

At the top of the file, update line 7:

-import { member, organization, projects } from '../db/schema.js';
+import { member, organization, projects, projectMembers } from '../db/schema.js';

Then remove the dynamic import at line 109:

   const db = createDb(c.env.DB);
-  const { projectMembers } = await import('../db/schema.js');
packages/web/src/stores/projectActionsStore/members.js (1)

28-50: Consider using handleFetchError for consistent error handling.

Per coding guidelines, frontend fetch calls should use handleFetchError from @/lib/error-utils.js. The current implementation manually parses the error response, which works but diverges from the established pattern used elsewhere.

Proposed refactor using handleFetchError
+import { handleFetchError } from '@/lib/error-utils.js';

// ... inside remove function:
    try {
      const response = await fetch(
        `${API_BASE}/api/orgs/${orgId}/projects/${projectId}/members/${memberId}`,
        {
          method: 'DELETE',
          credentials: 'include',
        },
      );
      if (!response.ok) {
-        const data = await response.json();
-        throw new Error(data.error || 'Failed to remove member');
+        await handleFetchError(response, { showToast: false });
      }
packages/web/src/primitives/useOrgProjectList.js (1)

21-24: Content-Type header is unnecessary for GET requests.

GET requests don't have a body, so the Content-Type: application/json header is superfluous. While it doesn't cause issues, removing it keeps the code clean.

Proposed fix
   const response = await fetch(`${API_BASE}/api/orgs/${orgId}/projects`, {
     credentials: 'include',
-    headers: { 'Content-Type': 'application/json' },
   });
packages/web/src/components/org/CreateOrgPage.jsx (1)

29-36: Slug sanitization differs between auto-generation and manual input.

The auto-generated slug (Lines 30-35) removes leading/trailing hyphens with .replace(/^-|-$/g, ''), but the manual input handler (Line 140) only applies .toLowerCase().replace(/[^a-z0-9-]/g, ''). A user could manually enter --my-org-- which passes validation but may look odd in URLs.

Consider applying the same sanitization to manual input:

Proposed fix
                 <input
                   id='org-slug'
                   type='text'
                   value={slug()}
-                  onInput={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
+                  onInput={e => {
+                    const sanitized = e.target.value
+                      .toLowerCase()
+                      .replace(/[^a-z0-9-]/g, '')
+                      .replace(/-+/g, '-')
+                      .replace(/^-|-$/g, '');
+                    setSlug(sanitized);
+                  }}
                   placeholder='my-research-lab'

Also applies to: 140-140

packages/web/src/stores/projectActionsStore/project.js (1)

58-76: Org-scoped deletion with proper cache invalidation.

The deleteById function correctly:

  • Falls back to getActiveOrgId() when no explicit org is provided
  • Uses the org-scoped API endpoint /api/orgs/${orgId}/projects/${projectId}
  • Invalidates both projects.all and projects.byOrg(orgId) to ensure UI consistency

However, if orgId is null/undefined, the API call will fail with an invalid URL. Consider adding a guard:

Proposed fix
   async function deleteById(targetProjectId, targetOrgId) {
     const orgId = targetOrgId || getActiveOrgId();
+    if (!orgId) {
+      throw new Error('Organization ID is required to delete a project');
+    }

     try {
       const response = await fetch(`${API_BASE}/api/orgs/${orgId}/projects/${targetProjectId}`, {
packages/web/src/components/org/OrgProjectsPage.jsx (2)

93-119: Delete logic duplicates projectActionsStore.deleteById.

The delete handler (Lines 103-118) duplicates the fetch logic that exists in projectActionsStore/project.js. Consider reusing the store action to maintain a single source of truth:

Proposed fix
+import { projectActionsStore } from '@/stores/projectActionsStore/index.js';
+
   // Delete a project
   const handleDeleteProject = async targetProjectId => {
     const confirmed = await confirmDialog.open({
       title: 'Delete Project',
       description:
         'Are you sure you want to delete this entire project? This action cannot be undone.',
       confirmText: 'Delete Project',
       variant: 'danger',
     });
     if (!confirmed) return;

     try {
-      const response = await fetch(`${API_BASE}/api/orgs/${orgId()}/projects/${targetProjectId}`, {
-        method: 'DELETE',
-        credentials: 'include',
-      });
-      if (!response.ok) {
-        const data = await response.json().catch(() => ({}));
-        throw new Error(data.error || 'Failed to delete project');
-      }
-
-      queryClient.invalidateQueries({ queryKey: queryKeys.projects.byOrg(orgId()) });
+      await projectActionsStore.project.deleteById(targetProjectId, orgId());
       showToast.success('Project Deleted', 'The project has been deleted successfully');
     } catch (err) {
       const { handleError } = await import('@/lib/error-utils.js');
       await handleError(err, { toastTitle: 'Delete Failed' });
     }
   };

116-117: Dynamic import in catch block adds latency to error display.

The dynamic import('@/lib/error-utils.js') in the catch block delays error handling. If handleError is lightweight, consider importing it statically at the top of the file.

Proposed fix
+import { handleError } from '@/lib/error-utils.js';
...
     } catch (err) {
-      const { handleError } = await import('@/lib/error-utils.js');
       await handleError(err, { toastTitle: 'Delete Failed' });
     }
packages/workers/src/auth/config.js (1)

346-361: Consider using db.batch() for atomic org bootstrap.

The org and member inserts (Lines 347-361) are related operations that should succeed or fail together. Per coding guidelines, use db.batch() for multiple related database operations to ensure atomicity:

Proposed fix
-          // Insert org and membership
-          await db.insert(schema.organization).values({
-            id: orgId,
-            name: `${userName}'s Workspace`,
-            slug,
-            metadata: JSON.stringify({ type: 'personal' }),
-            createdAt: now,
-          });
-
-          await db.insert(schema.member).values({
-            id: memberId,
-            userId,
-            organizationId: orgId,
-            role: 'owner',
-            createdAt: now,
-          });
+          // Insert org and membership atomically
+          await db.batch([
+            db.insert(schema.organization).values({
+              id: orgId,
+              name: `${userName}'s Workspace`,
+              slug,
+              metadata: JSON.stringify({ type: 'personal' }),
+              createdAt: now,
+            }),
+            db.insert(schema.member).values({
+              id: memberId,
+              userId,
+              organizationId: orgId,
+              role: 'owner',
+              createdAt: now,
+            }),
+          ]);

Based on learnings, database operations should use db.batch() for atomicity.

packages/workers/src/routes/orgs/members.js (1)

567-569: Consider removing sensitive URL logging in non-production.

While guarded by environment check, logging magic link URLs even in development could be a security concern if logs are inadvertently exposed. Consider using a more secure mechanism for development testing.

packages/workers/src/routes/orgs/projects.js (1)

325-331: Consider parallel deletion with concurrency limit.

While Promise.all works, for projects with many PDFs, a large batch could create significant concurrent requests. Consider using a concurrency limiter for very large projects.

Optional: Add concurrency control for large deletions

For very large projects, consider batching deletions:

// Example with batch size limit
const BATCH_SIZE = 50;
for (let i = 0; i < keysToDelete.length; i += BATCH_SIZE) {
  const batch = keysToDelete.slice(i, i + BATCH_SIZE);
  await Promise.all(batch.map(key => c.env.PDF_BUCKET.delete(key)));
}
packages/workers/src/routes/orgs/index.js (1)

47-56: Consider using validateRequest middleware for consistency.

The POST / route performs inline validation for the name field. For consistency with other routes and per coding guidelines, consider using the validateRequest middleware with a schema.

Use validateRequest for org creation
+import { orgSchemas } from '../../config/validation.js';
+
 orgRoutes.post(
   '/',
+  validateRequest(orgSchemas.create),
   async c => {
-    try {
-      const body = await c.req.json();
-
-      if (!body.name?.trim()) {
-        const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
-          reason: 'name_required',
-        });
-        return c.json(error, error.statusCode);
-      }
+    const { name, slug, logo, metadata } = c.get('validatedBody');
packages/workers/src/routes/orgs/pdfs.js (1)

46-53: Consider adding check for path traversal sequences.

The validation blocks slashes and control characters but doesn't explicitly check for .. sequences. While the key construction prevents traversal since it's prefixed, adding this check provides defense in depth.

Proposed enhancement
 function isValidFileName(fileName) {
   if (!fileName) return false;
   if (fileName.length > 200) return false;
   if (/[\\/]/.test(fileName)) return false;
   if (/\p{C}/u.test(fileName)) return false;
   if (fileName.includes('"')) return false;
+  if (fileName.includes('..')) return false;
   return true;
 }
packages/web/src/primitives/useOrgProjectContext.js (1)

96-112: Consider potential reactivity concern with path builder closures.

The path builder functions (getStudyPath, getChecklistPath, getReconcilePath) capture the current values of orgContext.orgSlug() and projectId() when called, which is the intended behavior for imperative navigation. However, if these are used in reactive contexts (e.g., passed to createMemo), the outer function reference won't trigger updates.

This is likely fine given the navigation use case, but worth documenting if consumers might use them reactively.

packages/workers/src/routes/orgs/invitations.js (3)

176-210: Consider extracting magic link generation to avoid per-request auth instantiation.

Creating a new betterAuth instance with full adapter setup for each invitation request is heavyweight. Consider extracting this to a shared utility or caching the auth instance.


310-316: Consider using a more semantically appropriate error for not-found invitation.

VALIDATION_ERRORS.FIELD_INVALID_FORMAT suggests a format issue, but the actual problem is that the invitation doesn't exist. A 404-style error would be clearer.

Proposed fix

Consider adding an INVITATION_NOT_FOUND error to PROJECT_ERRORS or reusing an existing not-found pattern:

       if (!invitation) {
-        const error = createDomainError(VALIDATION_ERRORS.FIELD_INVALID_FORMAT, {
-          field: 'invitationId',
-          value: invitationId,
+        const error = createDomainError(PROJECT_ERRORS.NOT_FOUND, {
+          resource: 'invitation',
+          id: invitationId,
         });
         return c.json(error, error.statusCode);
       }

367-384: Consider using more specific error types for invalid/expired tokens.

Using VALIDATION_ERRORS.FIELD_INVALID_FORMAT for both "not found" (line 368) and "expired" (line 379) scenarios conflates different error conditions. Distinct error types would improve debugging and client-side error handling.

Comment on lines +46 to +53
createEffect(() => {
const pid = params.projectId;
const oid = orgId();
if (pid && oid) {
projectActionsStore._setActiveProject(pid, oid);
connect();
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find and examine the useProject hook's connect/disconnect logic
rg -n "connect|disconnect" packages/web/src/primitives/useProject/ -A 3 -B 1 | head -100

Repository: InfinityBowman/corates

Length of output: 8497


🏁 Script executed:

# Check the full useProject implementation structure
ls -la packages/web/src/primitives/useProject/

Repository: InfinityBowman/corates

Length of output: 611


🏁 Script executed:

# Examine the specific effect in ChecklistYjsWrapper.jsx with context
cat -n packages/web/src/components/checklist/ChecklistYjsWrapper.jsx | sed -n '40,65p'

Repository: InfinityBowman/corates

Length of output: 1074


🏁 Script executed:

# Get the full connect() function implementation
sed -n '63,145p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 2707


🏁 Script executed:

# Check how useProject manages the connect function and connection lifecycle
sed -n '1,100p' packages/web/src/primitives/useProject/index.js | grep -A 30 "function useProject"

Repository: InfinityBowman/corates

Length of output: 48


🏁 Script executed:

# Look for disconnect() implementation
rg -n "function disconnect|const disconnect" packages/web/src/primitives/useProject/connection.js -A 10

Repository: InfinityBowman/corates

Length of output: 351


🏁 Script executed:

# Check how connect() is exported and managed from useProject
sed -n '1,50p' packages/web/src/primitives/useProject/index.js

Repository: InfinityBowman/corates

Length of output: 1845


🏁 Script executed:

# Look for the return statement and how connect is exposed in useProject
rg -n "return {" packages/web/src/primitives/useProject/index.js -A 30 | head -60

Repository: InfinityBowman/corates

Length of output: 145


🏁 Script executed:

# Check if there's reference counting or cleanup in useProject
rg -n "refCount|cleanup|onCleanup" packages/web/src/primitives/useProject/index.js -B 2 -A 2

Repository: InfinityBowman/corates

Length of output: 1636


🏁 Script executed:

# Check if connect() checks for existing provider before creating a new one
sed -n '63,80p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 617


🏁 Script executed:

# Look at how the connection is used in ChecklistYjsWrapper - check if there are multiple calls
rg -n "connect\(\)" packages/web/src/components/checklist/ChecklistYjsWrapper.jsx

Repository: InfinityBowman/corates

Length of output: 85


🏁 Script executed:

# Check the broader context of useProject hook's onCleanup
sed -n '300,320p' packages/web/src/primitives/useProject/index.js

Repository: InfinityBowman/corates

Length of output: 328


🏁 Script executed:

# Check what connect() returns and if it's safe to call multiple times
sed -n '63,150p' packages/web/src/primitives/useProject/connection.js | grep -A 5 "provider ="

Repository: InfinityBowman/corates

Length of output: 336


🏁 Script executed:

# Verify if connect() checks for existing provider before creating new one
sed -n '63,75p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 390


🏁 Script executed:

# Check the full disconnect function and what it does
sed -n '208,220p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 331


🏁 Script executed:

# Check if there's a guard in connect() that prevents creating provider if one exists
sed -n '63,105p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 1279


🏁 Script executed:

# Verify the entire ChecklistYjsWrapper effect structure and what happens on remount
sed -n '45,70p' packages/web/src/components/checklist/ChecklistYjsWrapper.jsx

Repository: InfinityBowman/corates

Length of output: 967


🏁 Script executed:

# Check if connect is called elsewhere in ChecklistYjsWrapper
rg -n "connect" packages/web/src/components/checklist/ChecklistYjsWrapper.jsx

Repository: InfinityBowman/corates

Length of output: 299


🏁 Script executed:

# Examine the full logic - check if provider is checked before creating new one
sed -n '63,78p' packages/web/src/primitives/useProject/connection.js

Repository: InfinityBowman/corates

Length of output: 608


🏁 Script executed:

# Verify the reference counting behavior in useProject - check how connections are managed
sed -n '40,65p' packages/web/src/primitives/useProject/index.js

Repository: InfinityBowman/corates

Length of output: 594


Add onCleanup() to prevent duplicate connections when org context changes.

The effect at lines 46-53 calls connect() but lacks onCleanup(). When orgId() changes, the effect re-runs and creates a new connection without destroying the previous one. Per the coding guidelines, effects creating subscriptions must use onCleanup() to call disconnect() on unmount or dependency changes.

createEffect(() => {
  const pid = params.projectId;
  const oid = orgId();
  if (pid && oid) {
    projectActionsStore._setActiveProject(pid, oid);
    connect();
    onCleanup(() => {
      disconnect();
    });
  }
});
🤖 Prompt for AI Agents
In packages/web/src/components/checklist/ChecklistYjsWrapper.jsx around lines
46-53, the createEffect calls connect() when pid and orgId are present but does
not register an onCleanup, causing duplicate connections when org context
changes; fix by adding onCleanup() inside the same conditional to call
disconnect() (or otherwise tear down the created connection) so every connect()
is paired with a disconnect() on re-run/unmount, ensuring cleanup only runs if
connect() was invoked.

Comment on lines +360 to +361
// Note: Project list (dashboard) functionality is now handled by TanStack Query
// via useProjectList and useOrgProjectList hooks. Those hooks are tested separately.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate and examine the projectStore.test.js file
fd projectStore.test.js

Repository: InfinityBowman/corates

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check the specific lines mentioned (360-361)
if [ -f "packages/web/src/primitives/__tests__/projectStore.test.js" ]; then
  echo "=== Lines 360-361 context ==="
  sed -n '355,365p' packages/web/src/primitives/__tests__/projectStore.test.js
  echo ""
  echo "=== Total lines in file ==="
  wc -l packages/web/src/primitives/__tests__/projectStore.test.js
fi

Repository: InfinityBowman/corates

Length of output: 383


🏁 Script executed:

#!/bin/bash
# Search for useProjectList and useOrgProjectList tests
echo "=== Searching for useProjectList tests ==="
fd -e test.js -e test.jsx -e test.ts -e test.tsx . --exec grep -l 'useProjectList' {} \;

echo ""
echo "=== Searching for useOrgProjectList tests ==="
fd -e test.js -e test.jsx -e test.ts -e test.tsx . --exec grep -l 'useOrgProjectList' {} \;

echo ""
echo "=== Searching for hook implementations ==="
fd 'useProjectList|useOrgProjectList' --extension js --extension jsx --extension ts --extension tsx

Repository: InfinityBowman/corates

Length of output: 416


🏁 Script executed:

#!/bin/bash
# Check if these hooks are exported and where they're defined
echo "=== Searching for useProjectList hook definition ==="
rg 'export.*useProjectList|function useProjectList|const useProjectList' --type js --type jsx --type ts --type tsx

echo ""
echo "=== Searching for useOrgProjectList hook definition ==="
rg 'export.*useOrgProjectList|function useOrgProjectList|const useOrgProjectList' --type js --type jsx --type ts --type tsx

Repository: InfinityBowman/corates

Length of output: 248


🏁 Script executed:

#!/bin/bash
# Check if hooks have dedicated test files
echo "=== Searching for dedicated hook test files ==="
fd 'useProjectList.*test|useOrgProjectList.*test' --extension js --extension jsx

echo ""
echo "=== Check if hooks are tested in other files ==="
rg 'useProjectList|useOrgProjectList' packages/web/src/primitives/__tests__/ -l

echo ""
echo "=== Examine useProjectList hook ==="
head -20 packages/web/src/primitives/useProjectList.js

echo ""
echo "=== Examine useOrgProjectList hook ==="
head -20 packages/web/src/primitives/useOrgProjectList.js

Repository: InfinityBowman/corates

Length of output: 1508


🏁 Script executed:

#!/bin/bash
# Search for test files that might test these hooks
echo "=== All test files in primitives ==="
fd -e test.js packages/web/src/primitives/__tests__/

echo ""
echo "=== Check if hooks are tested elsewhere in the codebase ==="
rg 'describe.*useProjectList|test.*useProjectList|describe.*useOrgProjectList|test.*useOrgProjectList' --type js

Repository: InfinityBowman/corates

Length of output: 638


🏁 Script executed:

#!/bin/bash
# Corrected search for test files in primitives
echo "=== All test files in primitives __tests__ directory ==="
fd . 'packages/web/src/primitives/__tests__/' -type f

echo ""
echo "=== Search for any mention of useProjectList in test files ==="
rg 'useProjectList' packages/web/src/primitives/__tests__/ -A 2 -B 2

echo ""
echo "=== Search for any mention of useOrgProjectList in test files ==="
rg 'useOrgProjectList' packages/web/src/primitives/__tests__/ -A 2 -B 2

Repository: InfinityBowman/corates

Length of output: 1131


🏁 Script executed:

#!/bin/bash
# Check if hooks are tested in hooks-specific test directory
echo "=== Looking for hooks test directory ==="
fd --full-path '.*hooks.*test' packages/web/src/primitives/

echo ""
echo "=== Check for useProject tests (parent hook) ==="
find packages/web/src -type f -name '*test*' | grep -i 'useproject' | head -20

echo ""
echo "=== Check directory structure of primitives ==="
ls -la packages/web/src/primitives/ | head -30

Repository: InfinityBowman/corates

Length of output: 1470


🏁 Script executed:

#!/bin/bash
# Search entire web package for any tests of these hooks
echo "=== Search entire web package for useProjectList tests ==="
rg 'describe.*useProjectList|test.*useProjectList' packages/web/src/ -type js

echo ""
echo "=== Search entire web package for useOrgProjectList tests ==="
rg 'describe.*useOrgProjectList|test.*useOrgProjectList' packages/web/src/ -type js

echo ""
echo "=== Count test files in web package ==="
find packages/web/src -name '*test.js' -o -name '*test.jsx' | wc -l

Repository: InfinityBowman/corates

Length of output: 304


Tests for useProjectList and useOrgProjectList hooks do not exist. The comment states these hooks are "tested separately," but no test files exist for either hook. Dedicated tests are required to ensure the migrated project list functionality is properly covered.

Comment on lines +34 to +36
ALTER TABLE `project_invitations` ADD `orgId` text NOT NULL REFERENCES organization(id);--> statement-breakpoint
ALTER TABLE `project_invitations` ADD `orgRole` text DEFAULT 'member';--> statement-breakpoint
ALTER TABLE `projects` ADD `orgId` text NOT NULL REFERENCES organization(id);--> statement-breakpoint
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Migration will fail on databases with existing data.

SQLite's ALTER TABLE ADD COLUMN with NOT NULL and no DEFAULT will fail if the table contains any rows. Lines 34 and 36 add orgId as NOT NULL to project_invitations and projects respectively.

Options to fix:

  1. Add DEFAULT '' (or a placeholder) temporarily, then update and remove default
  2. Use a multi-step migration: add nullable column, backfill data, then use a new migration to add constraint
  3. Document that this migration only works on fresh databases
Example multi-step approach
-- Step 1: Add nullable column
ALTER TABLE `projects` ADD `orgId` text REFERENCES organization(id);

-- Step 2: Backfill existing projects (requires creating default org first)
-- UPDATE projects SET orgId = 'default-org-id' WHERE orgId IS NULL;

-- Step 3: In a subsequent migration, add NOT NULL constraint
-- (SQLite doesn't support ALTER COLUMN, so this requires table recreation)
🤖 Prompt for AI Agents
packages/workers/migrations/0001_nice_blonde_phantom.sql around lines 34-36: the
migration adds orgId as NOT NULL to project_invitations and projects which will
fail on databases with existing rows; change to a safe multi-step migration: 1)
add orgId as a nullable column with the same REFERENCES clause (no NOT NULL), 2)
backfill existing rows with a valid org id or a temporary placeholder (perform
UPDATEs after creating/choosing the default org), and 3) in a following
migration enforce NOT NULL (and recreate the table in SQLite if necessary to add
the constraint) — alternatively, if you need a single-step change, add a
temporary DEFAULT value when adding the column, backfill and then remove the
default in a later migration; implement one of these approaches so the migration
won’t error on non-empty databases.

Comment on lines +120 to +207
"invitation": {
"name": "invitation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"inviterId": {
"name": "inviterId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"organizationId": {
"name": "organizationId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'member'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"invitation_inviterId_user_id_fk": {
"name": "invitation_inviterId_user_id_fk",
"tableFrom": "invitation",
"tableTo": "user",
"columnsFrom": ["inviterId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"invitation_organizationId_organization_id_fk": {
"name": "invitation_organizationId_organization_id_fk",
"tableFrom": "invitation",
"tableTo": "organization",
"columnsFrom": ["organizationId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for invitation schema definition
rg -A 20 -B 5 'invitation.*=.*(?:pgTable|sqliteTable|mysqlTable)' --type ts

Repository: InfinityBowman/corates

Length of output: 48


🏁 Script executed:

#!/bin/bash
# Find files that contain "invitation" and likely have schema definitions
fd -e ts -e js | head -20
echo "---"
# Search for invitation in schema-related files
rg "invitation" --type ts -l | grep -E "(schema|migration|db)" | head -10
echo "---"
# Broader search for invitation table definition
rg "invitation" --type ts -A 5 -B 2 | head -100

Repository: InfinityBowman/corates

Length of output: 739


🏁 Script executed:

#!/bin/bash
# Check the structure of packages/workers
ls -la packages/workers/
echo "---"
# Find all TypeScript schema files
fd -e ts -p "*schema*" packages/
echo "---"
# Search specifically for "invitation" in the codebase with context
rg "invitation" packages/ -A 10 | head -150

Repository: InfinityBowman/corates

Length of output: 11958


🏁 Script executed:

#!/bin/bash
# Look for schema definition files in packages/workers/src
find packages/workers/src -type f -name "*.ts" -o -name "*.js" | head -30
echo "---"
# Search for projectInvitations definition
rg "projectInvitations" packages/workers/src --type ts --type js -B 5 -A 5 | head -80

Repository: InfinityBowman/corates

Length of output: 6705


🏁 Script executed:

#!/bin/bash
# Read the schema file to find invitation table definition
cat -n packages/workers/src/db/schema.js | head -200

Repository: InfinityBowman/corates

Length of output: 10225


🏁 Script executed:

#!/bin/bash
# Continue reading the schema file to see the complete projectInvitations table definition
cat -n packages/workers/src/db/schema.js | sed -n '200,220p'

Repository: InfinityBowman/corates

Length of output: 724


🏁 Script executed:

#!/bin/bash
# Check the migrations directory for unique constraint definitions
ls -la packages/workers/migrations/
echo "---"
# Search for unique constraints in migration files
rg "unique" packages/workers/migrations/ -A 2 -B 2 | head -100

Repository: InfinityBowman/corates

Length of output: 7406


🏁 Script executed:

#!/bin/bash
# Double-check the invitation table section in the snapshot file to be thorough
sed -n '120,207p' packages/workers/migrations/meta/0001_snapshot.json | tail -20

Repository: InfinityBowman/corates

Length of output: 704


Add unique constraint to prevent duplicate invitations per organization.

The invitation table allows the same email address to receive multiple pending invitations to the same organization. Add a unique constraint on (email, organizationId) to the schema definition in packages/workers/src/db/schema.js.

🤖 Prompt for AI Agents
In packages/workers/migrations/meta/0001_snapshot.json around lines 120-207, the
invitation table lacks a unique constraint for (email, organizationId); add a
unique constraint in the authoritative schema source
(packages/workers/src/db/schema.js) by adding a uniqueConstraints entry for
invitation with columns ["email","organizationId"], then regenerate or update
the migration snapshot so the JSON includes that unique constraint under the
invitation object's "uniqueConstraints" key (or add it manually), and ensure the
migration that runs against existing DB either creates a UNIQUE index or handles
existing duplicate rows before applying the constraint.

Comment thread packages/workers/migrations/meta/0001_snapshot.json
Comment thread packages/workers/src/middleware/requireOrg.js
Comment thread packages/workers/src/middleware/requireOrg.js
Comment thread packages/workers/src/routes/orgs/index.js
Comment thread packages/workers/src/routes/orgs/invitations.js Outdated
Comment on lines +418 to +427
console.error(
`[Invitation] Email mismatch: user email="${currentUser.email}", invitation email="${invitation.email}"`,
);
const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
reason: 'email_mismatch',
userEmail: currentUser.email,
invitationEmail: invitation.email,
});
return c.json(error, error.statusCode);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid logging PII (email addresses) in error messages.

Logging user emails constitutes PII exposure, which raises compliance concerns (GDPR/CCPA). Consider logging only non-identifying information or hashed/masked values.

Proposed fix
     if (normalizedUserEmail !== normalizedInvitationEmail) {
       console.error(
-        `[Invitation] Email mismatch: user email="${currentUser.email}", invitation email="${invitation.email}"`,
+        `[Invitation] Email mismatch for user=${authUser.id}, invitation=${invitation.id}`,
       );
       const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
         reason: 'email_mismatch',
-        userEmail: currentUser.email,
-        invitationEmail: invitation.email,
       });
       return c.json(error, error.statusCode);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.error(
`[Invitation] Email mismatch: user email="${currentUser.email}", invitation email="${invitation.email}"`,
);
const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
reason: 'email_mismatch',
userEmail: currentUser.email,
invitationEmail: invitation.email,
});
return c.json(error, error.statusCode);
}
console.error(
`[Invitation] Email mismatch for user=${currentUser.id}, invitation=${invitation.id}`,
);
const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
reason: 'email_mismatch',
});
return c.json(error, error.statusCode);
🤖 Prompt for AI Agents
In packages/workers/src/routes/orgs/invitations.js around lines 418 to 427, the
code currently logs and returns raw user and invitation email addresses which
exposes PII; remove raw emails from console.error and from the createDomainError
payload, replacing them with non-identifying info such as a reason and either a
hashed/masked version of the emails (e.g., mask local part or store a short
hash) or omit them entirely; update the console message to a generic "Email
mismatch" with contextual IDs only, and ensure the error object passed to c.json
excludes raw email fields and uses only non-PII fields (reason, relevant IDs, or
masked/hash values).

@InfinityBowman
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 30, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (5)
packages/web/src/stores/adminStore.js (1)

250-262: Cache invalidation target appears incorrect.

Same issue as in grantAccess - when revoking subscription access from userId, the code invalidates queryKeys.subscription.current (the admin's subscription cache) instead of the target user's cache.

Apply the same fix pattern: invalidate user-specific caches that actually display the modified subscription data.

packages/workers/src/__tests__/seed-schemas.js (1)

81-81: Schema mismatch: orgId must match database definition.

The orgId field is nullable().optional().default(null) in the seed schema, but in packages/workers/src/db/schema.js the projects.orgId column is .notNull() with a required foreign key reference. Update the seed schema to require orgId by removing nullable(), optional(), and default(null).

Proposed fix
 export const seedProjectSchema = z.object({
   id: z.string().min(1, 'Project ID is required'),
   name: z.string().min(1, 'Project name is required'),
   description: z.string().nullable().optional().default(null),
-  orgId: z.string().nullable().optional().default(null),
+  orgId: z.string().min(1, 'Organization ID is required'),
   createdBy: z.string().min(1, 'Created by user ID is required'),
   createdAt: dateOrTimestampToNumber,
   updatedAt: dateOrTimestampToNumber,
 });
packages/workers/src/middleware/requireOrg.js (1)

35-56: Wrap database operation in try-catch block.

The organization membership query (lines 38-48) is not wrapped in a try-catch block, while the database queries in requireProjectAccess (lines 113-132, 151-168) correctly use try-catch. Database operations can fail due to connection issues or timeouts and should be handled gracefully.

As per coding guidelines:

Wrap database operations in try-catch blocks and return domain errors using createDomainError(SYSTEM_ERRORS.DB_ERROR, ...) with operation metadata

Proposed fix
   const db = createDb(c.env.DB);

-  // Check if user is a member of the organization
-  const membership = await db
-    .select({
-      id: member.id,
-      role: member.role,
-      orgName: organization.name,
-      orgSlug: organization.slug,
-    })
-    .from(member)
-    .innerJoin(organization, eq(member.organizationId, organization.id))
-    .where(and(eq(member.organizationId, orgId), eq(member.userId, user.id)))
-    .get();
+  let membership;
+  try {
+    // Check if user is a member of the organization
+    membership = await db
+      .select({
+        id: member.id,
+        role: member.role,
+        orgName: organization.name,
+        orgSlug: organization.slug,
+      })
+      .from(member)
+      .innerJoin(organization, eq(member.organizationId, organization.id))
+      .where(and(eq(member.organizationId, orgId), eq(member.userId, user.id)))
+      .get();
+  } catch (error) {
+    const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+      operation: 'fetch_org_membership',
+      orgId,
+      userId: user.id,
+      originalError: error.message,
+    });
+    return c.json(dbError, dbError.statusCode);
+  }

   if (!membership) {
packages/workers/src/db/schema.js (1)

120-123: Migration concern already flagged in previous review.

The orgId column is defined as notNull(), which will cause the migration to fail on databases with existing projects. This was already identified in a previous review comment.

packages/workers/src/routes/orgs/invitations.js (1)

415-425: Avoid logging PII (email addresses) in error messages.

This issue was flagged in a previous review but appears unaddressed. Logging raw email addresses constitutes PII exposure, which raises compliance concerns (GDPR/CCPA). The error response also exposes both emails to the client.

Proposed fix
     if (normalizedUserEmail !== normalizedInvitationEmail) {
       console.error(
-        `[Invitation] Email mismatch: user email="${currentUser.email}", invitation email="${invitation.email}"`,
+        `[Invitation] Email mismatch for user=${authUser.id}, invitation=${invitation.id}`,
       );
       const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
         reason: 'email_mismatch',
-        userEmail: currentUser.email,
-        invitationEmail: invitation.email,
       });
       return c.json(error, error.statusCode);
     }
🧹 Nitpick comments (12)
.cursor/rules/api-routes.mdc (1)

214-232: Organization-scoped routes section is clear but examples lack error handling.

The new section (lines 214–232) properly documents the route structure and middleware usage for org-scoped routes. However, the examples show only the happy path. Consider adding a note about error handling for org membership and project access failures, or reference the error handling section for completeness.

For example, you might add context that org/project access failures should return appropriate AUTHORIZATION_ERRORS or similar domain errors (as per the error handling guide on line 120–142).

packages/web/src/components/checklist/LocalAppraisalsPanel.jsx (3)

34-45: Consider adding error handling for the delete operation.

If deleteChecklist fails, the user receives no feedback. While the hook may handle errors internally, explicit handling here would improve UX.

Proposed improvement
 const handleDelete = async (e, checklistId) => {
   e.stopPropagation();
   const confirmed = await confirmDialog.open({
     title: 'Delete Appraisal',
     description: 'Are you sure you want to delete this appraisal? This cannot be undone.',
     confirmText: 'Delete',
     variant: 'danger',
   });
   if (confirmed) {
-    await deleteChecklist(checklistId);
+    try {
+      await deleteChecklist(checklistId);
+    } catch (err) {
+      console.error('Failed to delete checklist:', err);
+      // Consider showing a toast notification here
+    }
   }
 };

102-118: **Loading state shows empty grid.**When loading() is true and checklists() is empty, the grid renders nothing. Consider showing a loading skeleton or spinner for better perceived performance during initial data fetch.


165-171: Add aria-label for screen reader accessibility.

The delete button has a title attribute, but screen readers may not reliably announce it. Adding an explicit aria-label improves accessibility.

Proposed fix
 <button
   onClick={e => handleDelete(e, checklist.id)}
   class='rounded-lg p-1.5 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 appraisal'
+  aria-label='Delete appraisal'
 >
   <FiTrash2 class={compact() ? 'h-4 w-4' : 'h-5 w-5'} />
 </button>
packages/web/src/primitives/useRecentsNav.js (1)

30-49: Consider preventing duplicate tracking on initial render.

The createEffect will track the initial location.pathname on mount, which may add the current page to recents before the user intentionally navigates. This could be intentional behavior, but if you only want to track actual navigation events, consider comparing against a previous path.

Optional: Skip initial tracking
+  let isFirstRun = true;
+
   // Track navigation and add to recents
   createEffect(() => {
     const path = location.pathname;
+    if (isFirstRun) {
+      isFirstRun = false;
+      return;
+    }
     const item = parsePathToRecentItem(path);
     if (!item) return;
packages/web/src/components/sidebar/Sidebar.jsx (2)

170-178: Route change detection uses mutable variable outside reactive scope.

The lastPathname variable is declared outside the effect but mutated inside it. While this works, it's a pattern that can be confusing. Consider using a signal or ref for clarity.

Optional: Use a ref-like pattern for previous pathname
-  // Close mobile sidebar when route changes
-  let lastPathname = location.pathname;
-  createEffect(() => {
-    const currentPathname = location.pathname;
-    if (props.mobileOpen && currentPathname !== lastPathname) {
-      props.onCloseMobile?.();
-    }
-    lastPathname = currentPathname;
-  });
+  // Close mobile sidebar when route changes
+  const lastPathnameRef = { current: location.pathname };
+  createEffect(() => {
+    const currentPathname = location.pathname;
+    if (props.mobileOpen && currentPathname !== lastPathnameRef.current) {
+      props.onCloseMobile?.();
+    }
+    lastPathnameRef.current = currentPathname;
+  });

248-391: Consider extracting shared sidebar content to reduce duplication.

The desktop expanded content (lines 248-391) and mobile panel content (lines 494-636) are nearly identical. This duplication increases maintenance burden and risk of divergence.

Consider extracting the shared content into a SidebarContent component or a render function that both views can use:

const SidebarContent = () => (
  <div class='sidebar-scrollbar flex-1 overflow-x-hidden overflow-y-auto'>
    {/* Dashboard/Workspace Link */}
    {/* Recents Section */}
    {/* Cloud Projects Section */}
    {/* Local Checklists Section */}
    <div class='h-8' />
  </div>
);

Then use <SidebarContent /> in both the desktop and mobile views.

Also applies to: 494-636

packages/web/src/stores/adminStore.js (1)

20-319: Consider applying consistent error handling across all fetch calls.

While grantAccess and revokeAccess now use handleFetchError, the remaining 14 async functions still use manual error handling patterns. For consistency and to align with coding guidelines, consider refactoring the other functions to use handleFetchError as well.

This would provide uniform error handling and make future maintenance easier.

As per coding guidelines, handleFetchError should be used for all frontend fetch calls in this file.

packages/workers/migrations/0002_equal_iron_man.sql (1)

2-12: Consider adding an index on the foreign key column.

SQLite doesn't automatically create indexes on foreign key columns. If queries frequently join mediaFiles to user via uploadedBy, consider adding an index for better performance:

CREATE INDEX idx_mediaFiles_uploadedBy ON mediaFiles(uploadedBy);

This can be added in a separate migration if needed. Based on coding guidelines, use DrizzleKit to generate the index migration.

packages/workers/src/config/validation.js (1)

82-90: Consider extracting org roles to a constant for consistency.

The orgRole validation uses a hardcoded enum ['member', 'admin', 'owner'], while role uses the PROJECT_ROLES constant. For consistency and maintainability, consider extracting org roles to a constant in config/constants.js (similar to PROJECT_ROLES), especially since the same hierarchy is defined in requireOrg.js as ORG_ROLE_HIERARCHY.

Proposed refactor

In packages/workers/src/config/constants.js, add:

export const ORG_ROLES = ['member', 'admin', 'owner'];

Then update this schema:

+import { PROJECT_ROLES, ORG_ROLES } from './constants.js';

 export const invitationSchemas = {
   create: z.object({
     email: z.string().email('Invalid email address'),
     role: z.enum(PROJECT_ROLES, {
       error: `Role must be one of: ${PROJECT_ROLES.join(', ')}`,
     }),
-    orgRole: z.enum(['member', 'admin', 'owner'], {
+    orgRole: z.enum(ORG_ROLES, {
-      error: "Org role must be one of: 'member', 'admin', 'owner'",
+      error: `Org role must be one of: ${ORG_ROLES.join(', ')}`,
     }),
   }),

Also update packages/workers/src/middleware/requireOrg.js:

+import { ORG_ROLES } from '../config/constants.js';

-const ORG_ROLE_HIERARCHY = ['member', 'admin', 'owner'];
+const ORG_ROLE_HIERARCHY = ORG_ROLES;
packages/docs/guides/organizations.md (1)

20-34: Add language specifier to fenced code blocks.

Per static analysis, this diagram block should have a language specifier. Use text or plaintext for ASCII diagrams:

Suggested fix
-```
+```text
 Organization
     |
     +-- Projects (owned by org)

The same applies to blocks at lines 150, 274, and 406.

packages/workers/src/routes/orgs/invitations.js (1)

163-264: Consider extracting email sending logic to a separate utility.

The magic link generation and email queueing logic (lines 163-264) is complex and spans ~100 lines within the route handler. This could be extracted to a utility function for better testability and reusability.

Suggested approach

Consider creating a utility like sendProjectInvitationEmail(c.env, { email, token, project, inviter, role }) in src/lib/invitation-email.js that encapsulates:

  • Magic link URL generation via Better Auth
  • Email template generation
  • Queue submission

This would make the route handler more focused on business logic and make the email logic reusable/testable.

Comment thread packages/docs/guides/api-development.md Outdated
Comment on lines +86 to +88
```
/api/orgs/:orgId/projects/:projectId/...
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code block.

The code block for route structure should have a language specifier for proper syntax highlighting and consistency.

Proposed fix
-```
+```text
 /api/orgs/:orgId/projects/:projectId/...
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

86-86: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In packages/docs/guides/api-development.md around lines 86 to 88, the fenced
code block for the route structure is missing a language specifier; update the
opening fence to include a language (e.g., change ``` to ```text) so the block
becomes ```text /api/orgs/:orgId/projects/:projectId/... ``` to enable proper
syntax highlighting and consistency.

Comment on lines +229 to +243
const response = await handleFetchError(
fetch(`${API_BASE}/api/admin/users/${userId}/subscription`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
{ showToast: false },
);
const result = await response.json();

// Invalidate subscription cache so frontend immediately reflects the change
queryClient.invalidateQueries({ queryKey: queryKeys.subscription.current });

return result;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache invalidation target appears incorrect.

When granting subscription access to userId, the code invalidates queryKeys.subscription.current, which likely refers to the currently logged-in admin's subscription cache, not the target user's subscription. This means the UI won't reflect the subscription changes for the user being modified.

Consider invalidating a user-specific cache or the user details/list cache instead. For example:

  • queryKeys.users.detail(userId) if viewing user details
  • queryKeys.users.list if on the users list page
  • Or pass the appropriate query key as a parameter
Proposed fix example
  const result = await response.json();

- // Invalidate subscription cache so frontend immediately reflects the change
- queryClient.invalidateQueries({ queryKey: queryKeys.subscription.current });
+ // Invalidate user cache so frontend immediately reflects the change
+ queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(userId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.users.list });

  return result;

Comment on lines +1 to +17
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_mediaFiles` (
`id` text PRIMARY KEY NOT NULL,
`filename` text NOT NULL,
`originalName` text,
`fileType` text,
`fileSize` integer,
`uploadedBy` text,
`bucketKey` text NOT NULL,
`createdAt` integer DEFAULT (unixepoch()),
FOREIGN KEY (`uploadedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_mediaFiles`("id", "filename", "originalName", "fileType", "fileSize", "uploadedBy", "bucketKey", "createdAt") SELECT "id", "filename", "originalName", "fileType", "fileSize", "uploadedBy", "bucketKey", "createdAt" FROM `mediaFiles`;--> statement-breakpoint
DROP TABLE `mediaFiles`;--> statement-breakpoint
ALTER TABLE `__new_mediaFiles` RENAME TO `mediaFiles`;--> statement-breakpoint
PRAGMA foreign_keys=ON; No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find the drizzle configuration and schema definitions
fd -t f "drizzle" --max-one-result
fd -t f "schema" packages/workers/src
find packages/workers -name "*.config.*" -o -name "drizzle.config.*"

Repository: InfinityBowman/corates

Length of output: 466


🏁 Script executed:

# Look for the mediaFiles and user table schema definitions
rg -t ts "export const mediaFiles" packages/workers/src
rg -t ts "export const user" packages/workers/src

Repository: InfinityBowman/corates

Length of output: 48


🏁 Script executed:

# Check the git history of the migration file to understand its origin
git log --oneline -n 20 -- packages/workers/migrations/0002_equal_iron_man.sql

Repository: InfinityBowman/corates

Length of output: 182


🏁 Script executed:

# Look for drizzle migration patterns and configuration
find packages/workers -type f -name "*.config.*"
cat packages/workers/drizzle.config.ts 2>/dev/null || echo "Config not found"

Repository: InfinityBowman/corates

Length of output: 289


🏁 Script executed:

# Read the schema file to find table definitions
cat packages/workers/src/db/schema.js

Repository: InfinityBowman/corates

Length of output: 9374


🏁 Script executed:

# Check if there are any other migration-related files or patterns
ls -la packages/workers/migrations/ | head -20

Repository: InfinityBowman/corates

Length of output: 460


🏁 Script executed:

# Look for any data migration or validation scripts
find packages/workers -type f -name "*seed*" -o -name "*migration*" -o -name "*validate*"

Repository: InfinityBowman/corates

Length of output: 140


Verify data consistency before running this migration: ensure no orphaned uploadedBy references exist.

This migration adds a foreign key constraint from mediaFiles.uploadedBy to user(id) with ON DELETE set null. If any existing mediaFiles rows reference non-existent user IDs, the INSERT operation on line 14 will fail with a foreign key constraint violation.

Before deploying, verify that all current uploadedBy values correspond to valid users in the user table. If orphaned references exist, they must be cleaned up first (set to NULL or delete the rows).

🤖 Prompt for AI Agents
In packages/workers/migrations/0002_equal_iron_man.sql around lines 1-17, the
migration adds a foreign key on mediaFiles.uploadedBy but does not handle
existing orphaned uploadedBy values, which will cause the INSERT to fail; before
creating __new_mediaFiles and copying data, add a pre-migration cleanup step
that finds mediaFiles rows whose uploadedBy is not present in user and either
sets uploadedBy = NULL for those rows (recommended) or deletes them, e.g. run a
SELECT to identify orphans and then an UPDATE to NULL them, or include those
cleanup SQL statements in this migration before the CREATE/INSERT so the data
copy succeeds.

Comment on lines +682 to +686
await env.DB.prepare(
'INSERT INTO mediaFiles (id, filename, bucketKey, uploadedBy, createdAt) VALUES (?1, ?2, ?3, ?4, ?5)',
)
.bind('media-1', 'test.pdf', 'bucket-key-1', 'u1', nowSec)
.run();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the resetSchema function to see all tables it creates
head -n 250 packages/workers/src/__tests__/admin.test.js | tail -n +49 | grep -E "CREATE TABLE|mediaFiles"

Repository: InfinityBowman/corates

Length of output: 300


🏁 Script executed:

# Let's search for any mediaFiles table creation in the entire test file
rg -n "mediaFiles" packages/workers/src/__tests__/admin.test.js | head -20

Repository: InfinityBowman/corates

Length of output: 366


🏁 Script executed:

# Let's check the full resetSchema function more carefully
sed -n '49,225p' packages/workers/src/__tests__/admin.test.js

Repository: InfinityBowman/corates

Length of output: 5263


🏁 Script executed:

# Search for mediaFiles table creation anywhere in the workers directory
rg -n "CREATE TABLE.*mediaFiles" packages/workers/

Repository: InfinityBowman/corates

Length of output: 241


🏁 Script executed:

# Check if there are migration files that might define mediaFiles
fd -t f "\.sql$|migration" packages/workers/

Repository: InfinityBowman/corates

Length of output: 231


🏁 Script executed:

# Search for any mediaFiles schema definition in the entire codebase
rg -n "mediaFiles" packages/workers/src/ -A 2 | head -40

Repository: InfinityBowman/corates

Length of output: 3155


🏁 Script executed:

# Check if there's a database schema file or migrations directory
find packages/workers -type f -name "*schema*" -o -name "*migration*" | head -20

Repository: InfinityBowman/corates

Length of output: 174


🏁 Script executed:

# Check the beginning of admin.test.js to see test setup
head -n 50 packages/workers/src/__tests__/admin.test.js

Repository: InfinityBowman/corates

Length of output: 1624


🏁 Script executed:

# Look for beforeEach or beforeAll hooks in admin.test.js
rg -n "beforeEach|beforeAll|env\.DB|resetSchema" packages/workers/src/__tests__/admin.test.js | head -20

Repository: InfinityBowman/corates

Length of output: 1102


🏁 Script executed:

# Check what seed-schemas.js does
cat packages/workers/src/__tests__/seed-schemas.js

Repository: InfinityBowman/corates

Length of output: 5755


🏁 Script executed:

# Verify the migration file has mediaFiles table definition
head -n 30 packages/workers/migrations/0000_acoustic_power_pack.sql

Repository: InfinityBowman/corates

Length of output: 965


Test will fail: mediaFiles table not created in resetSchema().

The test inserts directly into the mediaFiles table at line 683, but resetSchema() does not create this table. The function only creates: user, account, projects, project_members, session, verification, twoFactor, and subscriptions. The mediaFiles table is defined in the production migrations but excluded from the test schema.

Either add mediaFiles table creation to resetSchema(), or use the full migration SQL to initialize the test database.

🤖 Prompt for AI Agents
packages/workers/src/__tests__/admin.test.js lines 682-686: the test inserts
into the mediaFiles table but resetSchema() used for tests does not create that
table, causing the test to fail; fix by either adding the mediaFiles table DDL
to resetSchema() (create table mediaFiles with columns id, filename, bucketKey,
uploadedBy, createdAt and any required types/indexes matching production) or
change the test setup to run the full migration SQL before tests so the
mediaFiles table is present.

@InfinityBowman InfinityBowman changed the base branch from main to dev December 30, 2025 19:08
@InfinityBowman InfinityBowman merged commit a0bdad4 into dev Dec 30, 2025
3 checks passed
@InfinityBowman InfinityBowman deleted the 199-add-organizations branch December 30, 2025 19:08
This was referenced Dec 30, 2025
Merged
This was referenced Jan 9, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jan 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

add organizations

2 participants