From fbbf38f6a622ab1346b60096fae6c87c0e1d27be Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 1 Jan 2026 13:28:50 -0600 Subject: [PATCH 01/10] DO now scoped to project instead of org --- packages/workers/src/__tests__/helpers.js | 32 ++++----- .../workers/src/durable-objects/ProjectDoc.js | 65 ++++--------------- packages/workers/src/index.js | 9 ++- packages/workers/src/lib/project-doc-id.js | 25 ++++--- packages/workers/src/lib/project-sync.js | 10 ++- packages/workers/src/routes/admin/users.js | 4 +- packages/workers/src/routes/avatars.js | 4 +- packages/workers/src/routes/invitations.js | 2 +- .../workers/src/routes/orgs/invitations.js | 2 +- packages/workers/src/routes/orgs/members.js | 6 +- packages/workers/src/routes/orgs/projects.js | 5 +- packages/workers/src/routes/projects.js | 4 +- packages/workers/src/routes/users.js | 8 +-- 13 files changed, 62 insertions(+), 114 deletions(-) diff --git a/packages/workers/src/__tests__/helpers.js b/packages/workers/src/__tests__/helpers.js index 71098988d..96e581a9b 100644 --- a/packages/workers/src/__tests__/helpers.js +++ b/packages/workers/src/__tests__/helpers.js @@ -80,33 +80,27 @@ function parseSqlStatements(sqlContent) { } /** - * Get the org-scoped DO name for a project - * @param {string} orgId - Organization ID + * Get the project-scoped DO name for a project * @param {string} projectId - Project ID - * @returns {string} The DO instance name in format "orgId:projectId" + * @returns {string} The DO instance name in format "project:${projectId}" */ -export function getProjectDocName(orgId, projectId) { - return `${orgId}:${projectId}`; +export function getProjectDocName(projectId) { + return `project:${projectId}`; } /** - * Clear ProjectDoc Durable Objects for test org/project combinations + * Clear ProjectDoc Durable Objects for test projects * This prevents DO invalidation errors between tests - * @param {Array<{orgId: string, projectId: string}>} orgProjects - Array of org/project pairs + * @param {Array} projectIds - Array of project IDs */ -export async function clearProjectDOs(orgProjects = []) { - // Common test org/project combinations that might have DOs - const defaultOrgProjects = [ - { orgId: 'org-1', projectId: 'project-1' }, - { orgId: 'org-1', projectId: 'project-2' }, - { orgId: 'org-1', projectId: 'p1' }, - { orgId: 'org-1', projectId: 'p2' }, - ]; - const allOrgProjects = [...defaultOrgProjects, ...orgProjects]; +export async function clearProjectDOs(projectIds = []) { + // Common test project IDs that might have DOs + const defaultProjectIds = ['project-1', 'project-2', 'p1', 'p2']; + const allProjectIds = [...defaultProjectIds, ...projectIds]; - for (const { orgId, projectId } of allOrgProjects) { + for (const projectId of allProjectIds) { try { - const doName = getProjectDocName(orgId, projectId); + const doName = getProjectDocName(projectId); const doId = env.PROJECT_DOC.idFromName(doName); const stub = env.PROJECT_DOC.get(doId); await runInDurableObject(stub, async (instance, state) => { @@ -126,7 +120,7 @@ export async function clearProjectDOs(orgProjects = []) { error?.durableObjectReset === true; if (!isInvalidationError) { // Only log non-invalidation errors for debugging - console.warn(`Failed to clear ProjectDoc DO for ${orgId}:${projectId}:`, error.message); + console.warn(`Failed to clear ProjectDoc DO for ${projectId}:`, error.message); } } } diff --git a/packages/workers/src/durable-objects/ProjectDoc.js b/packages/workers/src/durable-objects/ProjectDoc.js index 2f563b159..6873c86de 100644 --- a/packages/workers/src/durable-objects/ProjectDoc.js +++ b/packages/workers/src/durable-objects/ProjectDoc.js @@ -36,22 +36,11 @@ export class ProjectDoc { this.sessions = new Map(); this.doc = null; this.awareness = null; - // Cache projectId → orgId to reduce D1 pressure on hot path - // Populated on first connection and persisted in DO storage - this.cachedOrgId = null; } async fetch(request) { const url = new URL(request.url); - // Load cached orgId from storage if not already loaded - if (this.cachedOrgId === null) { - const stored = await this.state.storage.get('cached-org-id'); - if (stored) { - this.cachedOrgId = stored; - } - } - // Note: CORS headers are added by the main worker (index.js) when wrapping responses // Do NOT add them here to avoid duplicate headers @@ -377,53 +366,23 @@ export class ProjectDoc { const db = createDb(this.env.DB); - // Use cached orgId if available, otherwise validate and cache it - // This reduces D1 pressure on hot path while keeping membership checks fresh - let orgId = this.cachedOrgId; - - if (!orgId) { - // First connection for this DO instance - validate project belongs to org from URL - const project = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(and(eq(projects.id, projectId), eq(projects.orgId, orgIdFromUrl))) - .get(); - - if (!project) { - return new Response('Project not found in organization', { - status: 404, - headers: { 'X-Close-Reason': 'project-not-found' }, - }); - } - - orgId = project.orgId; - this.cachedOrgId = orgId; - - // Persist to DO storage so it survives DO restarts - await this.state.storage.put('cached-org-id', orgId); - } else if (orgId !== orgIdFromUrl) { - // Cached orgId doesn't match URL - this shouldn't happen with org-scoped DO IDs - // but check anyway for safety - return new Response('Organization mismatch', { - status: 403, - headers: { 'X-Close-Reason': 'org-mismatch' }, - }); - } - - // ALWAYS verify org membership on connect/reconnect (fresh D1 check) - const orgMembership = await db - .select({ id: member.id, role: member.role }) - .from(member) - .where(and(eq(member.organizationId, orgId), eq(member.userId, user.id))) + // ALWAYS validate project belongs to org from URL (fresh D1 check, transfer-safe) + // This ensures tenant safety even if project is transferred between orgs + const project = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.orgId, orgIdFromUrl))) .get(); - if (!orgMembership) { - return new Response('Not an organization member', { - status: 403, - headers: { 'X-Close-Reason': 'not-org-member' }, + if (!project) { + return new Response('Project not found in organization', { + status: 404, + headers: { 'X-Close-Reason': 'project-not-found' }, }); } + const orgId = project.orgId; + // ALWAYS verify project membership on connect/reconnect (fresh D1 check) const projectMembership = await db .select({ role: projectMembers.role }) diff --git a/packages/workers/src/index.js b/packages/workers/src/index.js index d3f125051..5eea03333 100644 --- a/packages/workers/src/index.js +++ b/packages/workers/src/index.js @@ -310,7 +310,7 @@ app.post('/api/pdf-proxy', requireAuth, async c => { }); // Org-scoped Project Document Durable Object routes -// DO instance is identified by orgId:projectId for org isolation +// DO instance is project-scoped (project:${projectId}) but URL includes orgId for validation const handleOrgProjectDoc = async c => { const orgId = c.req.param('orgId'); const projectId = c.req.param('projectId'); @@ -319,10 +319,9 @@ const handleOrgProjectDoc = async c => { return c.json({ error: 'Organization ID and Project ID required' }, 400); } - // Compute org-scoped DO instance name - const projectDocName = `${orgId}:${projectId}`; - const id = c.env.PROJECT_DOC.idFromName(projectDocName); - const projectDoc = c.env.PROJECT_DOC.get(id); + // Compute project-scoped DO instance name (orgId is validated inside DO via DB check) + const { getProjectDocStub } = await import('./lib/project-doc-id.js'); + const projectDoc = getProjectDocStub(c.env, projectId); const response = await projectDoc.fetch(c.req.raw); // Don't wrap WebSocket upgrade responses diff --git a/packages/workers/src/lib/project-doc-id.js b/packages/workers/src/lib/project-doc-id.js index 1dcaab018..9241facbb 100644 --- a/packages/workers/src/lib/project-doc-id.js +++ b/packages/workers/src/lib/project-doc-id.js @@ -1,32 +1,31 @@ /** * Centralized helpers for ProjectDoc Durable Object ID derivation * - * All ProjectDoc DO instances are org-scoped, using the format: `${orgId}:${projectId}` - * This ensures complete isolation between organizations. + * ProjectDoc DO instances are project-scoped, using the format: `project:${projectId}` + * This makes project transfers between orgs safe (no DO state migration needed). + * Tenant safety is enforced via DB validation of project.orgId against URL orgId. */ /** - * Get the org-scoped name for a ProjectDoc DO instance - * @param {string} orgId - Organization ID + * Get the project-scoped name for a ProjectDoc DO instance * @param {string} projectId - Project ID - * @returns {string} The DO instance name in format "orgId:projectId" + * @returns {string} The DO instance name in format "project:${projectId}" */ -export function getProjectDocName(orgId, projectId) { - if (!orgId || !projectId) { - throw new Error('Both orgId and projectId are required for ProjectDoc DO name'); +export function getProjectDocName(projectId) { + if (!projectId) { + throw new Error('projectId is required for ProjectDoc DO name'); } - return `${orgId}:${projectId}`; + return `project:${projectId}`; } /** - * Get the ProjectDoc DO stub for a given org and project + * Get the ProjectDoc DO stub for a given project * @param {Object} env - Cloudflare environment with PROJECT_DOC binding - * @param {string} orgId - Organization ID * @param {string} projectId - Project ID * @returns {DurableObjectStub} The DO stub */ -export function getProjectDocStub(env, orgId, projectId) { - const name = getProjectDocName(orgId, projectId); +export function getProjectDocStub(env, projectId) { + const name = getProjectDocName(projectId); const id = env.PROJECT_DOC.idFromName(name); return env.PROJECT_DOC.get(id); } diff --git a/packages/workers/src/lib/project-sync.js b/packages/workers/src/lib/project-sync.js index 347d1f674..c4f93d707 100644 --- a/packages/workers/src/lib/project-sync.js +++ b/packages/workers/src/lib/project-sync.js @@ -7,14 +7,13 @@ import { getProjectDocStub } from './project-doc-id.js'; /** * Sync a member change to the Durable Object * @param {Env} env - Cloudflare environment - * @param {string} orgId - Organization ID * @param {string} projectId - Project ID * @param {'add' | 'update' | 'remove'} action - Action to perform * @param {object} memberData - Member data (userId, role, etc.) * @throws {Error} If sync fails */ -export async function syncMemberToDO(env, orgId, projectId, action, memberData) { - const projectDoc = getProjectDocStub(env, orgId, projectId); +export async function syncMemberToDO(env, projectId, action, memberData) { + const projectDoc = getProjectDocStub(env, projectId); await projectDoc.fetch( new Request('https://internal/sync-member', { @@ -31,14 +30,13 @@ export async function syncMemberToDO(env, orgId, projectId, action, memberData) /** * Sync project metadata and members to the Durable Object * @param {Env} env - Cloudflare environment - * @param {string} orgId - Organization ID * @param {string} projectId - Project ID * @param {object | null} meta - Project metadata (name, description, updatedAt, etc.) * @param {object[] | null} members - Array of member objects * @throws {Error} If sync fails */ -export async function syncProjectToDO(env, orgId, projectId, meta, members) { - const projectDoc = getProjectDocStub(env, orgId, projectId); +export async function syncProjectToDO(env, projectId, meta, members) { + const projectDoc = getProjectDocStub(env, projectId); await projectDoc.fetch( new Request('https://internal/sync', { diff --git a/packages/workers/src/routes/admin/users.js b/packages/workers/src/routes/admin/users.js index 9e3578458..3c4f316cf 100644 --- a/packages/workers/src/routes/admin/users.js +++ b/packages/workers/src/routes/admin/users.js @@ -491,8 +491,8 @@ userRoutes.delete('/users/:userId', async c => { // Sync all member removals to DOs atomically (fail fast if any fails) await Promise.all( - userProjects.map(({ orgId, projectId }) => - syncMemberToDO(c.env, orgId, projectId, 'remove', { userId }), + userProjects.map(({ projectId }) => + syncMemberToDO(c.env, projectId, 'remove', { userId }), ), ); diff --git a/packages/workers/src/routes/avatars.js b/packages/workers/src/routes/avatars.js index 860ff415d..246db1c0d 100644 --- a/packages/workers/src/routes/avatars.js +++ b/packages/workers/src/routes/avatars.js @@ -42,9 +42,9 @@ async function syncAvatarToProjects(env, userId, avatarUrl) { .where(eq(projectMembers.userId, userId)); // Update each project's Durable Object - for (const { orgId, projectId } of memberships) { + for (const { projectId } of memberships) { try { - const projectDoc = getProjectDocStub(env, orgId, projectId); + const projectDoc = getProjectDocStub(env, projectId); await projectDoc.fetch( new Request('https://internal/sync-member', { diff --git a/packages/workers/src/routes/invitations.js b/packages/workers/src/routes/invitations.js index a39434320..312ec0ecd 100644 --- a/packages/workers/src/routes/invitations.js +++ b/packages/workers/src/routes/invitations.js @@ -241,7 +241,7 @@ invitationRoutes.post( // Sync member to DO try { - await syncMemberToDO(c.env, invitation.orgId, invitation.projectId, 'add', { + await syncMemberToDO(c.env, invitation.projectId, 'add', { userId: authUser.id, role: invitation.role, joinedAt: nowDate.getTime(), diff --git a/packages/workers/src/routes/orgs/invitations.js b/packages/workers/src/routes/orgs/invitations.js index 85869ff40..58bef7d26 100644 --- a/packages/workers/src/routes/orgs/invitations.js +++ b/packages/workers/src/routes/orgs/invitations.js @@ -551,7 +551,7 @@ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), a // Sync member to DO try { - await syncMemberToDO(c.env, invitation.orgId, invitation.projectId, 'add', { + await syncMemberToDO(c.env, invitation.projectId, 'add', { userId: authUser.id, role: invitation.role, joinedAt: nowDate.getTime(), diff --git a/packages/workers/src/routes/orgs/members.js b/packages/workers/src/routes/orgs/members.js index dc6b97a71..320d8aca9 100644 --- a/packages/workers/src/routes/orgs/members.js +++ b/packages/workers/src/routes/orgs/members.js @@ -182,7 +182,7 @@ orgProjectMemberRoutes.post( // Sync member to DO try { - await syncMemberToDO(c.env, orgId, projectId, 'add', { + await syncMemberToDO(c.env, projectId, 'add', { userId: userToAdd.id, role, joinedAt: now.getTime(), @@ -267,7 +267,7 @@ orgProjectMemberRoutes.put( // Sync role update to DO try { - await syncMemberToDO(c.env, orgId, projectId, 'update', { + await syncMemberToDO(c.env, projectId, 'update', { userId: memberId, role, }); @@ -355,7 +355,7 @@ orgProjectMemberRoutes.delete( // Sync member removal to DO try { - await syncMemberToDO(c.env, orgId, projectId, 'remove', { + await syncMemberToDO(c.env, projectId, 'remove', { userId: memberId, }); } catch (err) { diff --git a/packages/workers/src/routes/orgs/projects.js b/packages/workers/src/routes/orgs/projects.js index 1f34f555c..b26da021a 100644 --- a/packages/workers/src/routes/orgs/projects.js +++ b/packages/workers/src/routes/orgs/projects.js @@ -138,7 +138,6 @@ orgProjectRoutes.post( try { await syncProjectToDO( c.env, - orgId, projectId, { name: name.trim(), @@ -258,7 +257,7 @@ orgProjectRoutes.put( if (description !== undefined) metaUpdate.description = description; try { - await syncProjectToDO(c.env, orgId, projectId, metaUpdate, null); + await syncProjectToDO(c.env, projectId, metaUpdate, null); } catch (err) { console.error('Failed to sync project update to DO:', err); } @@ -305,7 +304,7 @@ orgProjectRoutes.delete( // Disconnect all connected users from the ProjectDoc DO try { - const projectDoc = getProjectDocStub(c.env, orgId, projectId); + const projectDoc = getProjectDocStub(c.env, projectId); await projectDoc.fetch( new Request('https://internal/disconnect-all', { method: 'POST', diff --git a/packages/workers/src/routes/projects.js b/packages/workers/src/routes/projects.js index b83572252..3a4ddcb1f 100644 --- a/packages/workers/src/routes/projects.js +++ b/packages/workers/src/routes/projects.js @@ -14,6 +14,7 @@ import { projectSchemas, validateRequest } from '../config/validation.js'; import { EDIT_ROLES } from '../config/constants.js'; import { createDomainError, PROJECT_ERRORS, AUTH_ERRORS, SYSTEM_ERRORS } from '@corates/shared'; import { syncProjectToDO } from '../lib/project-sync.js'; +import { getProjectDocStub } from '../lib/project-doc-id.js'; const projectRoutes = new Hono(); @@ -274,8 +275,7 @@ projectRoutes.delete('/:id', async c => { // Disconnect all connected users from the ProjectDoc DO try { - const doId = c.env.PROJECT_DOC.idFromName(projectId); - const projectDoc = c.env.PROJECT_DOC.get(doId); + const projectDoc = getProjectDocStub(c.env, projectId); await projectDoc.fetch( new Request('https://internal/disconnect-all', { method: 'POST', diff --git a/packages/workers/src/routes/users.js b/packages/workers/src/routes/users.js index a582482e0..66f481f91 100644 --- a/packages/workers/src/routes/users.js +++ b/packages/workers/src/routes/users.js @@ -197,8 +197,8 @@ userRoutes.delete('/me', async c => { // Sync all member removals to DOs atomically (fail fast if any fails) await Promise.all( - userProjects.map(({ orgId, projectId }) => - syncMemberToDO(c.env, orgId, projectId, 'remove', { userId }), + userProjects.map(({ projectId }) => + syncMemberToDO(c.env, projectId, 'remove', { userId }), ), ); @@ -268,9 +268,9 @@ userRoutes.post('/sync-profile', async c => { .where(eq(projectMembers.userId, currentUser.id)); // Sync to each project's Durable Object - const syncPromises = userProjects.map(async ({ orgId, projectId }) => { + const syncPromises = userProjects.map(async ({ projectId }) => { try { - const projectDoc = getProjectDocStub(c.env, orgId, projectId); + const projectDoc = getProjectDocStub(c.env, projectId); await projectDoc.fetch( new Request('https://internal/sync-member', { From 679f132f222fe63a5da6624043538a99ee47bb76 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 1 Jan 2026 13:32:17 -0600 Subject: [PATCH 02/10] members from different orgs can be added to projects --- .../migrations/0003_worried_skreet.sql | 1 + .../migrations/meta/0003_snapshot.json | 1284 +++++++++++++++++ .../workers/migrations/meta/_journal.json | 9 +- packages/workers/src/config/validation.js | 9 +- packages/workers/src/db/schema.js | 8 +- .../workers/src/durable-objects/ProjectDoc.js | 1 + .../workers/src/routes/orgs/invitations.js | 53 +- 7 files changed, 1337 insertions(+), 28 deletions(-) create mode 100644 packages/workers/migrations/0003_worried_skreet.sql create mode 100644 packages/workers/migrations/meta/0003_snapshot.json diff --git a/packages/workers/migrations/0003_worried_skreet.sql b/packages/workers/migrations/0003_worried_skreet.sql new file mode 100644 index 000000000..f29f7b696 --- /dev/null +++ b/packages/workers/migrations/0003_worried_skreet.sql @@ -0,0 +1 @@ +ALTER TABLE `project_invitations` ADD `grantOrgMembership` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/workers/migrations/meta/0003_snapshot.json b/packages/workers/migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..dba2238d8 --- /dev/null +++ b/packages/workers/migrations/meta/0003_snapshot.json @@ -0,0 +1,1284 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c069d3ad-35db-439b-916d-1c23defe8dd5", + "prevId": "88480cf6-6bf1-46fa-b5d0-9230051aa6f3", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "mediaFiles": { + "name": "mediaFiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalName": { + "name": "originalName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileType": { + "name": "fileType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileSize": { + "name": "fileSize", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadedBy": { + "name": "uploadedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketKey": { + "name": "bucketKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "mediaFiles_uploadedBy_user_id_fk": { + "name": "mediaFiles_uploadedBy_user_id_fk", + "tableFrom": "mediaFiles", + "tableTo": "user", + "columnsFrom": [ + "uploadedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "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'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "member_userId_user_id_fk": { + "name": "member_userId_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organizationId_organization_id_fk": { + "name": "member_organizationId_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_invitations": { + "name": "project_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "orgRole": { + "name": "orgRole", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "grantOrgMembership": { + "name": "grantOrgMembership", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "project_invitations_token_unique": { + "name": "project_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_invitations_orgId_organization_id_fk": { + "name": "project_invitations_orgId_organization_id_fk", + "tableFrom": "project_invitations", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_projectId_projects_id_fk": { + "name": "project_invitations_projectId_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_invitedBy_user_id_fk": { + "name": "project_invitations_invitedBy_user_id_fk", + "tableFrom": "project_invitations", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_members": { + "name": "project_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projectId": { + "name": "projectId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "joinedAt": { + "name": "joinedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "project_members_projectId_projects_id_fk": { + "name": "project_members_projectId_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_userId_user_id_fk": { + "name": "project_members_userId_user_id_fk", + "tableFrom": "project_members", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_orgId_organization_id_fk": { + "name": "projects_orgId_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "orgId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_createdBy_user_id_fk": { + "name": "projects_createdBy_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonatedBy": { + "name": "impersonatedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonatedBy_user_id_fk": { + "name": "session_impersonatedBy_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "impersonatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_activeOrganizationId_organization_id_fk": { + "name": "session_activeOrganizationId_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "activeOrganizationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_stripeCustomerId_unique": { + "name": "subscriptions_stripeCustomerId_unique", + "columns": [ + "stripeCustomerId" + ], + "isUnique": true + }, + "subscriptions_stripeSubscriptionId_unique": { + "name": "subscriptions_stripeSubscriptionId_unique", + "columns": [ + "stripeSubscriptionId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "twoFactor": { + "name": "twoFactor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backupCodes": { + "name": "backupCodes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "twoFactor_userId_user_id_fk": { + "name": "twoFactor_userId_user_id_fk", + "tableFrom": "twoFactor", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatarUrl": { + "name": "avatarUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persona": { + "name": "persona", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileCompletedAt": { + "name": "profileCompletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "twoFactorEnabled": { + "name": "twoFactorEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "banReason": { + "name": "banReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banExpires": { + "name": "banExpires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json index 6498b9fc0..1ec1bb577 100644 --- a/packages/workers/migrations/meta/_journal.json +++ b/packages/workers/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1767117556513, "tag": "0002_equal_iron_man", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1767295843655, + "tag": "0003_worried_skreet", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/workers/src/config/validation.js b/packages/workers/src/config/validation.js index 4cfc6f52f..75745e52c 100644 --- a/packages/workers/src/config/validation.js +++ b/packages/workers/src/config/validation.js @@ -78,10 +78,10 @@ export const memberSchemas = { /** * Invitation schemas * - * Note: orgRole is intentionally not accepted from request body. - * Project owners should not be able to grant org-level roles. - * When accepting an invitation, users are added to the org as 'member' (lowest role). - * Only org admins/owners can grant higher org roles via org member management endpoints. + * Note: grantOrgMembership can only be set to true by org admins/owners. + * When accepting an invitation, users always get project membership. + * If grantOrgMembership is true, they also get org membership (for governance/billing). + * This does not grant project access - projects are always invite-only. */ export const invitationSchemas = { create: z.object({ @@ -89,6 +89,7 @@ export const invitationSchemas = { role: z.enum(PROJECT_ROLES, { error: `Role must be one of: ${PROJECT_ROLES.join(', ')}`, }), + grantOrgMembership: z.boolean().optional().default(false), // only org admins/owners can set to true }), accept: z.object({ token: z.string().min(1, 'Token is required'), diff --git a/packages/workers/src/db/schema.js b/packages/workers/src/db/schema.js index 407ac6d69..ded57ad3b 100644 --- a/packages/workers/src/db/schema.js +++ b/packages/workers/src/db/schema.js @@ -183,10 +183,11 @@ export const twoFactor = sqliteTable('twoFactor', { }); // Project invitations table (for inviting users to projects) -// Combined invite flow: accepting ensures org membership then project membership +// Projects are always invite-only: accepting grants project membership only by default. +// Optional: grantOrgMembership can be set to true by org admins/owners for governance/billing. export const projectInvitations = sqliteTable('project_invitations', { id: text('id').primaryKey(), - // Organization for this project invitation (accepting grants org membership if needed) + // Organization for this project invitation orgId: text('orgId') .notNull() .references(() => organization.id, { onDelete: 'cascade' }), @@ -195,7 +196,8 @@ export const projectInvitations = sqliteTable('project_invitations', { .references(() => projects.id, { onDelete: 'cascade' }), email: text('email').notNull(), role: text('role').default('member'), // project role - orgRole: text('orgRole').default('member'), // always 'member' - project owners cannot grant org roles + orgRole: text('orgRole').default('member'), // org role if grantOrgMembership is true + grantOrgMembership: integer('grantOrgMembership', { mode: 'boolean' }).default(false).notNull(), // if true, accepting invite also grants org membership token: text('token').notNull().unique(), invitedBy: text('invitedBy') .notNull() diff --git a/packages/workers/src/durable-objects/ProjectDoc.js b/packages/workers/src/durable-objects/ProjectDoc.js index 6873c86de..559acd87a 100644 --- a/packages/workers/src/durable-objects/ProjectDoc.js +++ b/packages/workers/src/durable-objects/ProjectDoc.js @@ -384,6 +384,7 @@ export class ProjectDoc { const orgId = project.orgId; // ALWAYS verify project membership on connect/reconnect (fresh D1 check) + // Projects are invite-only: org membership does not grant project access const projectMembership = await db .select({ role: projectMembers.role }) .from(projectMembers) diff --git a/packages/workers/src/routes/orgs/invitations.js b/packages/workers/src/routes/orgs/invitations.js index 58bef7d26..3c39ba1b1 100644 --- a/packages/workers/src/routes/orgs/invitations.js +++ b/packages/workers/src/routes/orgs/invitations.js @@ -113,16 +113,28 @@ orgInvitationRoutes.post( let token; let invitationId; + // Check if user is org admin/owner to allow grantOrgMembership + const { orgRole } = getOrgContext(c); + const canGrantOrgMembership = orgRole === 'admin' || orgRole === 'owner'; + const { grantOrgMembership = false } = c.get('validatedBody'); + + // Only org admins/owners can set grantOrgMembership to true + const finalGrantOrgMembership = canGrantOrgMembership ? grantOrgMembership : false; + if (existingInvitation && !existingInvitation.acceptedAt) { // Resend existing invitation - update role and extend expiration - // Note: orgRole is always 'member' - project owners cannot grant org roles invitationId = existingInvitation.id; token = existingInvitation.token; const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days await db .update(projectInvitations) - .set({ role, orgRole: 'member', expiresAt }) + .set({ + role, + orgRole: 'member', // org role is always 'member' if granted + grantOrgMembership: finalGrantOrgMembership, + expiresAt, + }) .where(eq(projectInvitations.id, existingInvitation.id)); } else if (existingInvitation && existingInvitation.acceptedAt) { const error = createDomainError(PROJECT_ERRORS.INVITATION_ALREADY_ACCEPTED, { @@ -131,7 +143,6 @@ orgInvitationRoutes.post( return c.json(error, error.statusCode); } else { // Create new invitation with orgId - // Note: orgRole is always 'member' - project owners cannot grant org roles invitationId = crypto.randomUUID(); token = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); @@ -142,7 +153,8 @@ orgInvitationRoutes.post( projectId, email: email.toLowerCase(), role, - orgRole: 'member', + orgRole: 'member', // org role is always 'member' if granted + grantOrgMembership: finalGrantOrgMembership, token, invitedBy: authUser.id, expiresAt, @@ -341,7 +353,7 @@ orgInvitationRoutes.delete( /** * POST /api/orgs/:orgId/projects/:projectId/invitations/accept * Accept a project invitation by token - * Combined flow: ensures org membership first, then adds project membership + * Hybrid flow: always adds project membership (invite-only), optionally adds org membership if grantOrgMembership is true * Uses db.batch() for atomicity */ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), async c => { @@ -350,7 +362,7 @@ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), a const { token } = c.get('validatedBody'); try { - // Find invitation by token (includes org fields for combined flow) + // Find invitation by token (includes org fields for hybrid flow) const invitation = await db .select({ id: projectInvitations.id, @@ -359,6 +371,7 @@ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), a email: projectInvitations.email, role: projectInvitations.role, orgRole: projectInvitations.orgRole, + grantOrgMembership: projectInvitations.grantOrgMembership, expiresAt: projectInvitations.expiresAt, acceptedAt: projectInvitations.acceptedAt, }) @@ -462,12 +475,23 @@ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), a }); } - // Combined flow: ensure org membership, then add project membership + // Hybrid flow: always add project membership, optionally add org membership const nowDate = new Date(); const batchOps = []; - // Check if user is already an org member - if (invitation.orgId) { + // Always add user to project (projects are invite-only) + batchOps.push( + db.insert(projectMembers).values({ + id: crypto.randomUUID(), + projectId: invitation.projectId, + userId: authUser.id, + role: invitation.role, + joinedAt: nowDate, + }), + ); + + // Optionally add user to org if grantOrgMembership is true + if (invitation.grantOrgMembership && invitation.orgId) { const existingOrgMembership = await db .select({ id: member.id }) .from(member) @@ -488,17 +512,6 @@ orgInvitationRoutes.post('/accept', validateRequest(invitationSchemas.accept), a } } - // Add user to project - batchOps.push( - db.insert(projectMembers).values({ - id: crypto.randomUUID(), - projectId: invitation.projectId, - userId: authUser.id, - role: invitation.role, - joinedAt: nowDate, - }), - ); - // Mark invitation as accepted batchOps.push( db From 5e481564d35c54b418e3dac2b5df72383176236e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 1 Jan 2026 19:32:56 +0000 Subject: [PATCH 03/10] Apply Prettier formatting --- .../migrations/meta/0003_snapshot.json | 178 +++++------------- .../workers/migrations/meta/_journal.json | 2 +- packages/workers/src/routes/admin/users.js | 4 +- packages/workers/src/routes/users.js | 4 +- 4 files changed, 48 insertions(+), 140 deletions(-) diff --git a/packages/workers/migrations/meta/0003_snapshot.json b/packages/workers/migrations/meta/0003_snapshot.json index dba2238d8..faf83a5ea 100644 --- a/packages/workers/migrations/meta/0003_snapshot.json +++ b/packages/workers/migrations/meta/0003_snapshot.json @@ -107,12 +107,8 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -190,12 +186,8 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviterId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -203,12 +195,8 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -284,12 +272,8 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": [ - "uploadedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploadedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -345,12 +329,8 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -358,12 +338,8 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -422,9 +398,7 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -528,9 +502,7 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -539,12 +511,8 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -552,12 +520,8 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -565,12 +529,8 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": [ - "invitedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitedBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -626,12 +586,8 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -639,12 +595,8 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -714,12 +666,8 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -727,12 +675,8 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": [ - "createdBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -820,9 +764,7 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true } }, @@ -831,12 +773,8 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -844,12 +782,8 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "impersonatedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["impersonatedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -857,12 +791,8 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "activeOrganizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["activeOrganizationId"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -960,23 +890,17 @@ "indexes": { "subscriptions_userId_unique": { "name": "subscriptions_userId_unique", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": true }, "subscriptions_stripeCustomerId_unique": { "name": "subscriptions_stripeCustomerId_unique", - "columns": [ - "stripeCustomerId" - ], + "columns": ["stripeCustomerId"], "isUnique": true }, "subscriptions_stripeSubscriptionId_unique": { "name": "subscriptions_stripeSubscriptionId_unique", - "columns": [ - "stripeSubscriptionId" - ], + "columns": ["stripeSubscriptionId"], "isUnique": true } }, @@ -985,12 +909,8 @@ "name": "subscriptions_userId_user_id_fk", "tableFrom": "subscriptions", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1053,12 +973,8 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1198,16 +1114,12 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": [ - "username" - ], + "columns": ["username"], "isUnique": true } }, @@ -1281,4 +1193,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/workers/migrations/meta/_journal.json b/packages/workers/migrations/meta/_journal.json index 1ec1bb577..cfaa2153c 100644 --- a/packages/workers/migrations/meta/_journal.json +++ b/packages/workers/migrations/meta/_journal.json @@ -31,4 +31,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/workers/src/routes/admin/users.js b/packages/workers/src/routes/admin/users.js index 3c4f316cf..c2b85229b 100644 --- a/packages/workers/src/routes/admin/users.js +++ b/packages/workers/src/routes/admin/users.js @@ -491,9 +491,7 @@ userRoutes.delete('/users/:userId', async c => { // Sync all member removals to DOs atomically (fail fast if any fails) await Promise.all( - userProjects.map(({ projectId }) => - syncMemberToDO(c.env, projectId, 'remove', { userId }), - ), + userProjects.map(({ projectId }) => syncMemberToDO(c.env, projectId, 'remove', { userId })), ); // Only proceed with database deletions if all DO syncs succeeded diff --git a/packages/workers/src/routes/users.js b/packages/workers/src/routes/users.js index 66f481f91..00aa353da 100644 --- a/packages/workers/src/routes/users.js +++ b/packages/workers/src/routes/users.js @@ -197,9 +197,7 @@ userRoutes.delete('/me', async c => { // Sync all member removals to DOs atomically (fail fast if any fails) await Promise.all( - userProjects.map(({ projectId }) => - syncMemberToDO(c.env, projectId, 'remove', { userId }), - ), + userProjects.map(({ projectId }) => syncMemberToDO(c.env, projectId, 'remove', { userId })), ); // Only proceed with database deletions if all DO syncs succeeded From eabf63deb817f7e66ae63c9c0156b5a0e0328397 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 1 Jan 2026 13:57:52 -0600 Subject: [PATCH 04/10] remove workspaces and context switching from the UI. tanstack query always refetch in dev --- packages/web/src/Routes.jsx | 22 +- packages/web/src/components/Dashboard.jsx | 29 +- packages/web/src/components/Navbar.jsx | 80 +----- .../checklist/ChecklistYjsWrapper.jsx | 26 +- .../compare/ReconciliationWrapper.jsx | 24 +- .../components/project/CreateProjectForm.jsx | 47 +++- .../src/components/project/MyProjectsPage.jsx | 253 ++++++++++++++++++ .../src/components/project/ProjectContext.jsx | 24 +- .../src/components/project/ProjectView.jsx | 31 +-- .../project/completed-tab/CompletedTab.jsx | 5 +- .../completed-tab/PreviousReviewersView.jsx | 5 +- .../project/overview-tab/OverviewTab.jsx | 5 +- .../components/sidebar/ChecklistTreeItem.jsx | 5 - .../components/sidebar/ProjectTreeItem.jsx | 14 +- .../web/src/components/sidebar/Sidebar.jsx | 65 ++--- .../src/components/sidebar/StudyTreeItem.jsx | 2 - packages/web/src/lib/queryClient.js | 15 +- packages/web/src/lib/queryKeys.js | 6 +- .../web/src/primitives/useMyProjectsList.js | 62 +++++ packages/web/src/primitives/useOrgs.js | 48 ++++ .../src/primitives/useProject/connection.js | 13 +- .../web/src/primitives/useProject/index.js | 13 +- packages/web/src/primitives/useProjectData.js | 5 +- .../web/src/primitives/useProjectOrgId.js | 44 +++ packages/web/src/primitives/useRecentsNav.js | 53 +++- .../workers/src/durable-objects/ProjectDoc.js | 24 +- packages/workers/src/index.js | 32 ++- packages/workers/src/lib/entitlements.js | 9 +- packages/workers/src/routes/users.js | 36 +++ 29 files changed, 705 insertions(+), 292 deletions(-) create mode 100644 packages/web/src/components/project/MyProjectsPage.jsx create mode 100644 packages/web/src/primitives/useMyProjectsList.js create mode 100644 packages/web/src/primitives/useOrgs.js create mode 100644 packages/web/src/primitives/useProjectOrgId.js diff --git a/packages/web/src/Routes.jsx b/packages/web/src/Routes.jsx index 0392c72ea..6a1792bfa 100644 --- a/packages/web/src/Routes.jsx +++ b/packages/web/src/Routes.jsx @@ -18,7 +18,9 @@ import { AdminDashboard } from '@/components/admin/index.js'; import StorageManagement from '@/components/admin/StorageManagement.jsx'; import { BASEPATH } from '@config/api.js'; import ProtectedGuard from '@/components/auth/ProtectedGuard.jsx'; -import { OrgProjectsPage, ProjectView, CreateOrgPage } from '@/components/org/index.js'; +import MyProjectsPage from '@/components/project/MyProjectsPage.jsx'; +import ProjectView from '@/components/project/ProjectView.jsx'; +import { CreateOrgPage } from '@/components/org/index.js'; export default function AppRoutes() { return ( @@ -34,31 +36,33 @@ export default function AppRoutes() { {/* Main app routes */} - {/* Dashboard redirects to org context */} + {/* Dashboard redirects to projects */} {/* Protected routes - requires login */} - {/* Global user routes (outside org context) */} + {/* Global user routes */} - {/* Organization routes */} + {/* Organization creation (still needed) */} - - - {/* Org-scoped checklist routes */} + {/* Project-scoped routes */} + + + + {/* Project-scoped checklist routes */} diff --git a/packages/web/src/components/Dashboard.jsx b/packages/web/src/components/Dashboard.jsx index c47ded62c..3bcc279b7 100644 --- a/packages/web/src/components/Dashboard.jsx +++ b/packages/web/src/components/Dashboard.jsx @@ -1,17 +1,34 @@ -import { Show } from 'solid-js'; +import { createEffect, Show } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; import ChecklistsDashboard from '@/components/checklist/ChecklistsDashboard.jsx'; -import { OrgRedirect } from '@/components/org/index.js'; import { useBetterAuth } from '@api/better-auth-store.js'; export default function Dashboard() { - const { isLoggedIn } = useBetterAuth(); + const { isLoggedIn, authLoading } = useBetterAuth(); + const navigate = useNavigate(); + + // Redirect logged-in users to projects page + createEffect(() => { + if (isLoggedIn() && !authLoading()) { + navigate('/projects', { replace: true }); + } + }); return (
- {/* Logged-in users are redirected to their org context */} - - + {/* Show sign-in prompt for guests */} + +
+

Welcome to CoRATES

+

Sign in to access your projects

+ + Sign In + +
{/* Local checklists work offline and don't need org context */} diff --git a/packages/web/src/components/Navbar.jsx b/packages/web/src/components/Navbar.jsx index 7d3a0b54b..46b38794a 100644 --- a/packages/web/src/components/Navbar.jsx +++ b/packages/web/src/components/Navbar.jsx @@ -1,9 +1,7 @@ import { Show, For, createEffect, createSignal, onMount, onCleanup } from 'solid-js'; import { A, useNavigate } from '@solidjs/router'; import { useBetterAuth } from '@api/better-auth-store.js'; -import { useOrgContext } from '@primitives/useOrgContext.js'; -import { FiMenu, FiWifiOff, FiChevronDown, FiPlus, FiX } from 'solid-icons/fi'; -import { BiRegularBuildings } from 'solid-icons/bi'; +import { FiMenu, FiWifiOff, FiChevronDown, FiX } from 'solid-icons/fi'; import { LANDING_URL } from '@config/api.js'; import useOnlineStatus from '@primitives/useOnlineStatus.js'; import { Avatar } from '@corates/ui'; @@ -13,13 +11,8 @@ export default function Navbar(props) { const navigate = useNavigate(); const isOnline = useOnlineStatus(); - // Org context for workspace switcher - const { orgs, currentOrg, orgSlug, isLoading: orgsLoading } = useOrgContext(); - const [showUserMenu, setShowUserMenu] = createSignal(false); - const [showOrgMenu, setShowOrgMenu] = createSignal(false); let userMenuRef; - let orgMenuRef; // Read from localStorage on render to avoid layout shift on refresh const storedName = localStorage.getItem('userName'); @@ -40,9 +33,6 @@ export default function Navbar(props) { if (userMenuRef && !userMenuRef.contains(event.target)) { setShowUserMenu(false); } - if (orgMenuRef && !orgMenuRef.contains(event.target)) { - setShowOrgMenu(false); - } }; document.addEventListener('mousedown', handleClickOutside); @@ -61,10 +51,6 @@ export default function Navbar(props) { } }; - const handleOrgSwitch = orgSlugValue => { - setShowOrgMenu(false); - navigate(`/orgs/${orgSlugValue}`); - }; return (
CoRATES - {/* Workspace switcher - only show when logged in */} - -
- - - -
-
-

Workspaces

-
- - -
Loading...
-
- - -
No workspaces
-
- - - {org => ( - - )} - - - -
-
-
-
- {/* Offline indicator */}
@@ -167,10 +93,10 @@ export default function Navbar(props) {
- Dashboard + Projects { const pid = params.projectId; const oid = orgId(); - if (pid && oid) { - projectActionsStore._setActiveProject(pid, oid); + if (pid) { + if (oid) { + projectActionsStore._setActiveProject(pid, oid); + } connect(); } }); @@ -56,13 +57,12 @@ export default function ChecklistYjsWrapper() { // Read data directly from store for faster reactivity const connectionState = () => projectStore.getConnectionState(params.projectId); - // Watch for access-denied errors and redirect to org projects + // Watch for access-denied errors and redirect to projects createEffect(() => { const state = connectionState(); if (state.error && ACCESS_DENIED_ERRORS.includes(state.error)) { showToast.error('Access Denied', state.error); - const slug = orgSlug(); - navigate(slug ? `/orgs/${slug}` : '/dashboard', { replace: true }); + navigate('/projects', { replace: true }); } }); @@ -363,13 +363,9 @@ export default function ChecklistYjsWrapper() { return tabFromUrl || 'overview'; }; - // Build back path with org context + // Build back path const getBackPath = () => { - const slug = orgSlug(); const tab = getBackTab(); - if (slug) { - return `/orgs/${slug}/projects/${params.projectId}?tab=${tab}`; - } return `/projects/${params.projectId}?tab=${tab}`; }; diff --git a/packages/web/src/components/checklist/compare/ReconciliationWrapper.jsx b/packages/web/src/components/checklist/compare/ReconciliationWrapper.jsx index 3cd6c7320..d28cb205c 100644 --- a/packages/web/src/components/checklist/compare/ReconciliationWrapper.jsx +++ b/packages/web/src/components/checklist/compare/ReconciliationWrapper.jsx @@ -6,7 +6,7 @@ import { createSignal, createMemo, createEffect, Show } from 'solid-js'; import { useParams, useNavigate } from '@solidjs/router'; import useProject from '@/primitives/useProject/index.js'; -import { useOrgContext } from '@primitives/useOrgContext.js'; +import { useProjectOrgId } from '@primitives/useProjectOrgId.js'; import projectStore from '@/stores/projectStore.js'; import projectActionsStore from '@/stores/projectActionsStore'; import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js'; @@ -24,15 +24,14 @@ export default function ReconciliationWrapper() { const params = useParams(); const navigate = useNavigate(); - // Get org context for navigation and API calls - const { orgSlug, orgId } = useOrgContext(); + // Get orgId from project data (for API calls) + const orgId = useProjectOrgId(params.projectId); // params.projectId, params.studyId, params.checklist1Id, params.checklist2Id const [error, setError] = createSignal(null); // Use project hook for Y.js operations - // orgId() is required for remote projects' WebSocket connection const { createChecklist: createProjectChecklist, updateChecklistAnswer, @@ -41,27 +40,28 @@ export default function ReconciliationWrapper() { getReconciliationProgress, getQuestionNote, saveReconciliationProgress, - } = useProject(orgId(), params.projectId); + } = useProject(params.projectId); // Set active project for action store createEffect(() => { const pid = params.projectId; const oid = orgId(); - if (pid && oid) { - projectActionsStore._setActiveProject(pid, oid); + if (pid) { + if (oid) { + projectActionsStore._setActiveProject(pid, oid); + } } }); // Read data from store const connectionState = () => projectStore.getConnectionState(params.projectId); - // Watch for access-denied errors and redirect to org projects + // Watch for access-denied errors and redirect to projects createEffect(() => { const state = connectionState(); if (state.error && ACCESS_DENIED_ERRORS.includes(state.error)) { showToast.error('Access Denied', state.error); - const slug = orgSlug(); - navigate(slug ? `/orgs/${slug}` : '/dashboard', { replace: true }); + navigate('/projects', { replace: true }); } }); @@ -371,9 +371,9 @@ export default function ReconciliationWrapper() { return member?.displayName || member?.name || member?.email || 'Unknown'; } - // Build org-scoped project path + // Build project path const getProjectPath = () => { - return `/orgs/${orgSlug()}/projects/${params.projectId}`; + return `/projects/${params.projectId}`; }; // Handle saving the reconciled checklist diff --git a/packages/web/src/components/project/CreateProjectForm.jsx b/packages/web/src/components/project/CreateProjectForm.jsx index 15a98e9c6..312846f7a 100644 --- a/packages/web/src/components/project/CreateProjectForm.jsx +++ b/packages/web/src/components/project/CreateProjectForm.jsx @@ -1,6 +1,6 @@ -import { createSignal, Show, onMount } from 'solid-js'; +import { createSignal, Show, onMount, createMemo, createEffect } from 'solid-js'; import AddStudiesForm from './add-studies/AddStudiesForm.jsx'; -import { showToast } from '@corates/ui'; +import { showToast, Select } from '@corates/ui'; import { AUTH_ERRORS } from '@corates/shared'; import { isUnlimitedQuota } from '@corates/shared/plans'; import { @@ -11,6 +11,7 @@ import { clearRestoreParamsFromUrl, } from '@lib/formStatePersistence.js'; import { isErrorCode, handleFetchError, handleError } from '@/lib/error-utils.js'; +import { useOrgs } from '@primitives/useOrgs.js'; /** * Form for creating a new project with optional study imports @@ -18,7 +19,6 @@ import { isErrorCode, handleFetchError, handleError } from '@/lib/error-utils.js * * @param {Object} props * @param {string} props.apiBase - API base URL - * @param {string} props.orgId - Organization ID (for org-scoped project creation) * @param {Function} props.onProjectCreated - Called with (project, pendingPdfs, allRefs) * @param {Function} props.onCancel - Called when form is cancelled */ @@ -27,6 +27,27 @@ export default function CreateProjectForm(props) { const [projectDescription, setProjectDescription] = createSignal(''); const [isCreating, setIsCreating] = createSignal(false); const [restoredState, setRestoredState] = createSignal(null); + const [selectedOrgId, setSelectedOrgId] = createSignal(null); + + // Get orgs list + const { orgs, isLoading: orgsLoading } = useOrgs(); + + // Default to first org when orgs are loaded (if multiple orgs and none selected) + createEffect(() => { + const orgsList = orgs(); + if (orgsList.length > 1 && !selectedOrgId()) { + setSelectedOrgId(orgsList[0].id); + } + }); + + // Auto-select org if user has only one, otherwise use selected + const resolvedOrgId = createMemo(() => { + const orgsList = orgs(); + if (orgsList.length === 1) { + return orgsList[0].id; + } + return selectedOrgId(); + }); // Collected studies from AddStudiesForm (via collect mode) const [collectedStudies, setCollectedStudies] = createSignal({ @@ -95,15 +116,16 @@ export default function CreateProjectForm(props) { if (!projectName().trim()) return; // Require orgId for project creation - if (!props.orgId) { - showToast.error('Error', 'No organization context. Please select a workspace.'); + const orgId = resolvedOrgId(); + if (!orgId) { + showToast.error('Error', 'Please select an organization.'); return; } setIsCreating(true); try { const response = await handleFetchError( - fetch(`${props.apiBase}/api/orgs/${props.orgId}/projects`, { + fetch(`${props.apiBase}/api/orgs/${orgId}/projects`, { method: 'POST', credentials: 'include', headers: { @@ -181,6 +203,19 @@ export default function CreateProjectForm(props) { />
+ {/* Organization selection - only show if user has multiple orgs */} + 1}> +
+ +