From dba4640b5bd2bb9e7adb52a8d06d94f2abf6ecca Mon Sep 17 00:00:00 2001 From: xiaodemen Date: Thu, 28 May 2026 11:39:03 +0800 Subject: [PATCH] refactor: move organization to service layer --- packages/insomnia/src/common/organization.ts | 33 +++ .../node-src/services/organization.ts | 15 ++ .../node-src/services/project.ts | 233 ++++++++++++++++- .../insomnia-data/node-src/services/vcs.ts | 55 ++++ .../node-src/services/workspace.ts | 71 +++++- .../src/routes/onboarding.migrate.tsx | 4 +- .../organization.$organizationId._index.tsx | 2 +- ...ganizationId.project.$projectId._index.tsx | 2 +- ...ion.$organizationId.project.$projectId.tsx | 2 +- ...$projectId.workspace.$workspaceId.spec.tsx | 2 +- ...ization.$organizationId.project._index.tsx | 2 +- ...nization.$organizationId.storage-rules.tsx | 2 +- ...nization.$organizationId.sync-projects.tsx | 2 +- .../src/routes/organization._index.tsx | 8 +- ...zation.sync-organizations-and-projects.tsx | 15 +- .../insomnia/src/routes/organization.sync.tsx | 2 +- packages/insomnia/src/routes/trial.start.tsx | 12 +- .../sync/vcs/initialize-backend-project.ts | 25 +- .../vcs/migrate-projects-into-organization.ts | 74 ------ .../dropdowns/git-project-sync-dropdown.tsx | 2 +- .../insomnia/src/ui/organization-utils.ts | 235 ------------------ 21 files changed, 447 insertions(+), 351 deletions(-) create mode 100644 packages/insomnia/src/common/organization.ts create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/vcs.ts delete mode 100644 packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts delete mode 100644 packages/insomnia/src/ui/organization-utils.ts diff --git a/packages/insomnia/src/common/organization.ts b/packages/insomnia/src/common/organization.ts new file mode 100644 index 000000000000..026e3b9d7d44 --- /dev/null +++ b/packages/insomnia/src/common/organization.ts @@ -0,0 +1,33 @@ +import { getCurrentPlan, getUserProfile } from 'insomnia-api'; + +import { projectLock } from '~/common/project'; +import { services } from '~/insomnia-data'; +import { invariant } from '~/utils/invariant'; + +export { DEFAULT_STORAGE_RULES, fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; + +export async function syncOrganizations(sessionId: string, accountId: string) { + try { + const [organizations, user, currentPlan] = await Promise.all([ + services.organization.list(), + getUserProfile({ sessionId }), + getCurrentPlan({ sessionId }), + ]); + + invariant(organizations, 'Failed to load organizations'); + invariant(user && user.id, 'Failed to load user'); + invariant(currentPlan && currentPlan.planId, 'Failed to load current plan'); + + invariant(accountId, 'Account ID is not defined'); + + localStorage.setItem(`${accountId}:organizations`, JSON.stringify(organizations)); + localStorage.setItem(`${accountId}:user`, JSON.stringify(user)); + localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlan)); + } catch (error) { + console.log('[organization] Failed to load Organizations', error); + } +} + +export const syncProjects = projectLock.wrapWithLock(async (organizationId: string) => { + await services.organization.syncProjectsOfOrg(organizationId); +}); diff --git a/packages/insomnia/src/insomnia-data/node-src/services/organization.ts b/packages/insomnia/src/insomnia-data/node-src/services/organization.ts index 48141388db19..f018422b3be4 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/organization.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/organization.ts @@ -2,6 +2,7 @@ import { getOrganizations, type Organization } from 'insomnia-api'; import { models } from '~/insomnia-data'; +import { getAllTeamProjects, syncProjectsWithBackend } from './project'; import * as userSessionService from './user-session'; function sortOrganizations(accountId: string, organizations: Organization[]): Organization[] { @@ -58,3 +59,17 @@ export async function get(id: string): Promise { const all = await list(); return all.find(org => org.id === id); } + +function isScratchpad(orgIdOrOrg: string | Organization) { + const organizationId = typeof orgIdOrOrg === 'string' ? orgIdOrOrg : orgIdOrOrg.id; + return models.organization.isScratchpadOrganizationId(organizationId); +} + +export async function syncProjectsOfOrg(organizationId: string) { + const user = await userSessionService.get(); + const teamProjects = await getAllTeamProjects(organizationId); + // ensure we don't sync projects in the wrong place + if (Array.isArray(teamProjects) && user.id && !isScratchpad(organizationId)) { + await syncProjectsWithBackend(teamProjects, organizationId); + } +} diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project.ts b/packages/insomnia/src/insomnia-data/node-src/services/project.ts index a6ca41a3b5b2..ece8d7e7c80f 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/project.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/project.ts @@ -1,6 +1,12 @@ -import type { Project, Query } from '~/insomnia-data'; +import { createTeamProject, fetchTeamProjects } from 'insomnia-api'; + +import type { Project, Query, RemoteProject } from '~/insomnia-data'; import { database as db, models } from '~/insomnia-data'; +import { get as getUser } from './user-session'; +import type { SyncVCSLike } from './vcs'; +import { commitAllAndPush, getWorkspacesOfProject, hasGitRepositoryId } from './workspace'; + const { type } = models.project; export function create(patch: Partial = {}) { @@ -46,3 +52,228 @@ export async function remove(idOrProject: string | Project) { const project = await getProjectByIdOrProject(idOrProject); return db.remove(project); } + +export async function getFirstProjectUnderOrg(organizationId: string) { + return db.findOne(type, { parentId: organizationId }); +} + +interface TeamProject { + id: string; + name: string; +} + +export async function syncProjectsWithBackend(teamProjects: TeamProject[], organizationId: string) { + // assumption: api teamProjects is the source of truth for migrated projects + // once migrated orgs become the source of truth for projects + // its important that migration be completed before this code is run + const existingRemoteProjects = await db.find(type, { + remoteId: { $in: teamProjects.map(p => p.id) }, + }); + + const existingRemoteProjectsRemoteIds = existingRemoteProjects.map(p => p.remoteId); + const remoteProjectsThatNeedToBeCreated = teamProjects.filter(p => !existingRemoteProjectsRemoteIds.includes(p.id)); + + // this will create a new project for any remote projects that don't exist in the current organization + await Promise.all( + remoteProjectsThatNeedToBeCreated.map(async prj => { + await create({ + remoteId: prj.id, + name: prj.name, + parentId: organizationId, + }); + }), + ); + + const remoteProjectsThatNeedToBeUpdated = await db.find(type, { + // Remote ID is in the list of remote projects + remoteId: { $in: teamProjects.map(p => p.id) }, + }); + + await Promise.all( + remoteProjectsThatNeedToBeUpdated.map(async prj => { + const remoteProject = teamProjects.find(p => p.id === prj.remoteId); + if (remoteProject && remoteProject.name !== prj.name) { + await update(prj, { + name: remoteProject.name, + }); + } + }), + ); + + // Turn remote projects from the current organization that are not in the list of remote projects into local projects. + const removedRemoteProjects = await db.find(type, { + // filter by this organization so no legacy data can be accidentally removed, because legacy had null parentId + parentId: organizationId, + // Remote ID is not in the list of remote projects. + // add `$ne: null` condition because if remoteId is already null, we dont need to remove it again. + // nedb use append-only format, all updates and deletes actually result in lines added + remoteId: { + $nin: teamProjects.map(p => p.id), + $ne: null, + }, + }); + + await Promise.all( + removedRemoteProjects.map(async prj => { + await update(prj, { + remoteId: null, + }); + }), + ); +} + +export async function migrateProjectsIntoOrganization(personalOrganizationId: string) { + // Legacy remote projects without organizations + // Local projects without organizations except scratchpad + const [legacyRemoteProjects, localProjects] = await Promise.all([ + db.find(models.project.type, { + remoteId: { $ne: null }, + parentId: null, + }), + db.find(models.project.type, { + remoteId: null, + parentId: null, + _id: { $ne: models.project.SCRATCHPAD_PROJECT_ID }, + }), + ]); + + const updatePromises = []; + // Legacy remoteId should be orgId and legacy _id should be remoteId + for (const remoteProject of legacyRemoteProjects) { + updatePromises.push( + update(remoteProject, { + parentId: remoteProject.remoteId, + remoteId: remoteProject._id, + }), + ); + } + + // Assign all local projects to personal organization + for (const localProject of localProjects) { + updatePromises.push( + update(localProject, { + parentId: personalOrganizationId, + }), + ); + } + + await Promise.all(updatePromises); +} + +// Migration: +// Team ~= Project > Workspaces +// In the previous API: { _id: 'proj_team_123', remoteId: 'team_123', parentId: null } + +// Organization > TeamProject > Workspaces +// In the new API: { _id: 'proj_team_123', remoteId: 'proj_team_123', parentId: 'team_123' } + +// the remote id field previously tracked "team_id" +// (remote concept for matching 1:1 with this project) which is now org_id +// the _id field previously tracked the "proj_team_id" +// which was a wrapper for the team_id prefixing proj_to the above id, +// which is now the remoteId for tracking the projects within an org +export async function hasProjectsToMigrate() { + const [localProjectCount, legacyRemoteProjectCount] = await Promise.all([ + db.count(models.project.type, { + remoteId: null, + parentId: null, + }), + db.count(models.project.type, { + remoteId: { $ne: null }, + parentId: null, + }), + ]); + + return localProjectCount > 0 || legacyRemoteProjectCount > 0; +} + +export async function getLocalProjectsOfOrg(orgId: string) { + return await db.find(models.project.type, { + parentId: orgId, + remoteId: null, + }); +} + +export async function pushLocalProject({ + project, + vcs, + organizationId, +}: { + project: Project; + vcs: SyncVCSLike; + organizationId: string; +}) { + const { id: sessionId } = await getUser(); + const newCloudProject = await createTeamProject({ + sessionId, + organizationId, + name: project.name, + }); + const updatedProject = await update(project, { + name: newCloudProject.name, + remoteId: newCloudProject.id, + }); + + // For each workspace in the local project + const projectWorkspaces = await getWorkspacesOfProject(updatedProject._id); + + for (const workspace of projectWorkspaces) { + const hasGitRepoId = await hasGitRepositoryId(workspace); + + // Initialize Sync on the workspace if it's not using Git sync + try { + if (!hasGitRepoId) { + if (!vcs) { + throw new Error('VCS must be initialized'); + } + + await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); + await commitAllAndPush({ workspace, vcs, message: 'Initial Snapshot', project: updatedProject }); + } + } catch (e) { + console.warn( + 'Failed to initialize sync on workspace. This will be retried when the workspace is opened on the app.', + e, + ); + // TODO: here we should show the try again dialog + } + } +} + +export async function getAllTeamProjects(organizationId: string) { + const { id: sessionId } = await getUser(); + if (!sessionId) { + return []; + } + + console.log('[project] Fetching', organizationId); + const response = await fetchTeamProjects({ sessionId, organizationId }); + return response.data; +} + +export async function migrateProjectsUnderOrganization( + orgId: string, + preferredProjectType: string | null, + vcs: SyncVCSLike, +) { + if (await hasProjectsToMigrate()) { + await migrateProjectsIntoOrganization(orgId); + + if (preferredProjectType === 'remote') { + const localProjects = await getLocalProjectsOfOrg(orgId); + + // If any of those fail projects will still be under the organization as local projects + for (const project of localProjects) { + try { + await pushLocalProject({ + project, + organizationId: orgId, + vcs, + }); + } catch { + console.log(`Failed to push project ${project._id} to the cloud`); + } + } + } + } +} diff --git a/packages/insomnia/src/insomnia-data/node-src/services/vcs.ts b/packages/insomnia/src/insomnia-data/node-src/services/vcs.ts new file mode 100644 index 000000000000..f85349fe35ad --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/vcs.ts @@ -0,0 +1,55 @@ +import type { BaseModel } from '~/insomnia-data'; + +export type DocumentKey = string; + +export type BlobId = string; + +export interface StageEntryDelete { + deleted: true; + key: string; + name: string; + blobId: BlobId; + previousBlobContent?: string; +} + +export interface StageEntryAdd { + added: true; + key: string; + name: string; + blobId: BlobId; + blobContent: string; +} + +export interface StageEntryModify { + modified: true; + key: string; + name: string; + blobId: BlobId; + blobContent: string; + previousBlobContent?: string; +} + +export type StageEntry = StageEntryDelete | StageEntryAdd | StageEntryModify; + +export type Stage = Record; + +export interface StatusCandidate { + key: DocumentKey; + name: string; + document: BaseModel; +} + +export interface Status { + key: string; + stage: Stage; + unstaged: Record; +} + +export interface SyncVCSLike { + hasBackendProject: () => boolean | Promise; + push: (options: { teamId: string; teamProjectId: string }) => Promise; + stage: (stageEntries: StageEntry[]) => Promise; + status: (candidates: StatusCandidate[]) => Promise; + switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise; + takeSnapshot: (name: string) => Promise; +} diff --git a/packages/insomnia/src/insomnia-data/node-src/services/workspace.ts b/packages/insomnia/src/insomnia-data/node-src/services/workspace.ts index 638d9bd858d7..8074e39f20c9 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/workspace.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/workspace.ts @@ -1,6 +1,12 @@ -import type { Workspace } from '~/insomnia-data'; +import type { BaseModel, Project, Workspace } from '~/insomnia-data'; import { database as db, models } from '~/insomnia-data'; +import type { SyncVCSLike } from './vcs'; +import { + getOrCreateByParentId as getMetaOrCreateByParentId, + updateByParentId as updateMetaByParentId, +} from './workspace-meta'; + const { type } = models.workspace; export function getById(id?: string) { @@ -33,8 +39,71 @@ export function remove(workspace: Workspace) { return db.remove(workspace); } +export function getWorkspacesOfProject(projectId: string) { + return db.find(type, { + parentId: projectId, + }); +} + function expectParentToBeProject(parentId?: string | null) { if (parentId && !models.project.isProjectId(parentId)) { throw new Error('Expected the parent of a Workspace to be a Project'); } } + +export async function commitAll({ + workspace, + vcs, + message, +}: { + workspace: Workspace; + vcs: SyncVCSLike; + message: string; +}) { + if (!vcs.hasBackendProject()) { + return; + } + // Everything unstaged + const candidates = (await db.getWithDescendants(workspace)).filter(models.canSync).map((doc: BaseModel) => ({ + key: doc._id, + name: doc.name || '', + document: doc, + })); + const status = await vcs.status(candidates); + + // Stage everything + await vcs.stage(Object.values(status.unstaged)); + + // Snapshot + await vcs.takeSnapshot(message); +} + +export async function commitAllAndPush({ + workspace, + vcs, + message, + project: { _id: projectId, remoteId: projectRemoteId, parentId: orgId }, +}: { + workspace: Workspace; + vcs: SyncVCSLike; + message: string; + project: Project; +}) { + if (!vcs.hasBackendProject()) { + return; + } + + await commitAll({ workspace, vcs, message }); + // Mark for pushing to the active project + await updateMetaByParentId(workspace._id, { pushSnapshotOnInitialize: true }); + const hasProject = await vcs.hasBackendProject(); + if (projectId === workspace.parentId && hasProject && projectRemoteId) { + await updateMetaByParentId(workspace._id, { pushSnapshotOnInitialize: false }); // after below? + await vcs.push({ teamId: orgId, teamProjectId: projectRemoteId }); + } +} + +export async function hasGitRepositoryId(workspace: Workspace) { + const workspaceMeta = await getMetaOrCreateByParentId(workspace._id); + return !!workspaceMeta.gitRepositoryId; +} diff --git a/packages/insomnia/src/routes/onboarding.migrate.tsx b/packages/insomnia/src/routes/onboarding.migrate.tsx index 1462557efc8f..5d3e47056eca 100644 --- a/packages/insomnia/src/routes/onboarding.migrate.tsx +++ b/packages/insomnia/src/routes/onboarding.migrate.tsx @@ -1,7 +1,7 @@ import { Button, Heading, Radio, RadioGroup } from 'react-aria-components'; import { href, redirect, useFetcher } from 'react-router'; -import { shouldMigrateProjectUnderOrganization } from '~/sync/vcs/migrate-projects-into-organization'; +import { services } from '~/insomnia-data'; import { Icon } from '~/ui/components/icon'; import { InsomniaLogo } from '~/ui/components/insomnia-icon'; import { TrailLinesContainer } from '~/ui/components/trail-lines-container'; @@ -10,7 +10,7 @@ import { invariant } from '~/utils/invariant'; import type { Route } from './+types/onboarding.migrate'; export async function clientLoader(_args: Route.ClientLoaderArgs) { - if (!(await shouldMigrateProjectUnderOrganization())) { + if (!(await services.project.hasProjectsToMigrate())) { return redirect(href('/organization')); } diff --git a/packages/insomnia/src/routes/organization.$organizationId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId._index.tsx index cc4514ca2ee0..0e9687a9a158 100644 --- a/packages/insomnia/src/routes/organization.$organizationId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId._index.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router'; -import { syncProjects } from '~/ui/organization-utils'; +import { syncProjects } from '~/common/organization'; import { getInitialRouteForOrganization } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId._index'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 372846b896cc..0389ad55d459 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -27,6 +27,7 @@ import { } from '~/common/constants'; import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; import { fuzzyMatchAll } from '~/common/misc'; +import { DEFAULT_STORAGE_RULES } from '~/common/organization'; import type { InsomniaFile } from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; import type { GitRepository, Project, WorkspaceScope } from '~/insomnia-data'; @@ -56,7 +57,6 @@ import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; -import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { isPrimaryClickModifier } from '~/ui/utils'; export interface ProjectLoaderData { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 50a33c9d2ed9..97168b339749 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -8,6 +8,7 @@ import * as reactUse from 'react-use'; import { logout } from '~/account/session'; import { Icon } from '~/basic-components/icon'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; +import { DEFAULT_STORAGE_RULES } from '~/common/organization'; import { checkAllProjectSyncStatus, getAllLocalFiles, @@ -24,7 +25,6 @@ import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus'; import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; -import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; import type { Route } from './+types/organization.$organizationId.project.$projectId'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 01698af2b9ff..13a13a0063a1 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -26,6 +26,7 @@ import YAML from 'yaml'; import { parseApiSpec } from '~/common/api-specs'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; import { debounce } from '~/common/misc'; +import { DEFAULT_STORAGE_RULES } from '~/common/organization'; import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { @@ -53,7 +54,6 @@ import WorkspacePaneHeader from '~/ui/components/workspace/workspace-pane-header import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features'; import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; -import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 5a5f61bb856e..2980593ba1c7 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -5,6 +5,7 @@ import { href, redirect, useParams } from 'react-router'; import { logout } from '~/account/session'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; +import { DEFAULT_STORAGE_RULES } from '~/common/organization'; import { getProjectsWithGitRepositories } from '~/common/project'; import type { GitRepository, Project } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; @@ -14,7 +15,6 @@ import { ProjectModal } from '~/ui/components/modals/project-modal'; import { NoProjectView } from '~/ui/components/panes/no-project-view'; import { EmptyProjectNavigationSidebar } from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; -import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; export interface ProjectIndexLoaderData { diff --git a/packages/insomnia/src/routes/organization.$organizationId.storage-rules.tsx b/packages/insomnia/src/routes/organization.$organizationId.storage-rules.tsx index ca1a4ce6fe8c..2eab389d8a94 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.storage-rules.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.storage-rules.tsx @@ -1,7 +1,7 @@ import type { StorageRules } from 'insomnia-api'; import { href } from 'react-router'; -import { fetchAndCacheOrganizationStorageRule } from '~/ui/organization-utils'; +import { fetchAndCacheOrganizationStorageRule } from '~/common/organization'; import { createFetcherLoadHook, createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.storage-rules'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.sync-projects.tsx b/packages/insomnia/src/routes/organization.$organizationId.sync-projects.tsx index a2c984892810..abf145876bff 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.sync-projects.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.sync-projects.tsx @@ -1,6 +1,6 @@ import { href } from 'react-router'; -import { syncProjects } from '~/ui/organization-utils'; +import { syncProjects } from '~/common/organization'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.sync-projects'; diff --git a/packages/insomnia/src/routes/organization._index.tsx b/packages/insomnia/src/routes/organization._index.tsx index 1d3b8f16a67f..2edc5f3d9c5f 100644 --- a/packages/insomnia/src/routes/organization._index.tsx +++ b/packages/insomnia/src/routes/organization._index.tsx @@ -2,8 +2,8 @@ import type { Organization } from 'insomnia-api'; import { href, redirect } from 'react-router'; import * as session from '~/account/session'; +import { syncOrganizations } from '~/common/organization'; import { models, services } from '~/insomnia-data'; -import { migrateProjectsUnderOrganization, syncOrganizations } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; import type { Route } from './+types/organization._index'; @@ -22,7 +22,11 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { 'Failed to find personal organization your account appears to be in an invalid state. Please contact support if this is a recurring issue.', ); const personalOrganizationId = personalOrganization.id; - await migrateProjectsUnderOrganization(personalOrganizationId, sessionId); + await services.project.migrateProjectsUnderOrganization( + personalOrganizationId, + localStorage.getItem('prefers-project-type'), + window.main.sync, + ); const specificOrgRedirectAfterAuthorize = window.localStorage.getItem('specificOrgRedirectAfterAuthorize'); if (specificOrgRedirectAfterAuthorize && specificOrgRedirectAfterAuthorize !== '') { diff --git a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx index e310d9f1179b..78d5d7966272 100644 --- a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx +++ b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx @@ -1,9 +1,8 @@ import type { Organization } from 'insomnia-api'; import { href, redirect } from 'react-router'; -import type { Project } from '~/insomnia-data'; -import { database, models, services } from '~/insomnia-data'; -import { migrateProjectsUnderOrganization, syncOrganizations, syncProjects } from '~/ui/organization-utils'; +import { syncOrganizations, syncProjects } from '~/common/organization'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { AsyncTask, createFetcherSubmitHook } from '~/utils/router'; @@ -39,7 +38,13 @@ export async function clientAction({ request }: Route.ClientActionArgs) { invariant(personalOrganization, 'personalOrganization is required'); invariant(personalOrganization.id, 'personalOrganizationId is required'); invariant(sessionId, 'sessionId is required'); - taskPromiseList.push(migrateProjectsUnderOrganization(personalOrganization.id, sessionId)); + taskPromiseList.push( + services.project.migrateProjectsUnderOrganization( + personalOrganization.id, + localStorage.getItem('prefers-project-type'), + window.main.sync, + ), + ); } if (asyncTaskList.includes(AsyncTask.SyncProjects)) { @@ -51,7 +56,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { // When user switch to a new organization, there is no project in db cache, we need to redirect to the first project after sync project if (!projectId && asyncTaskList.includes(AsyncTask.SyncProjects)) { - const firstProject = await database.findOne(models.project.type, { parentId: organizationId }); + const firstProject = await services.project.getFirstProjectUnderOrg(organizationId); if (firstProject?._id) { return redirect( href('/organization/:organizationId/project/:projectId', { diff --git a/packages/insomnia/src/routes/organization.sync.tsx b/packages/insomnia/src/routes/organization.sync.tsx index c3656e666f56..914e3e20ee68 100644 --- a/packages/insomnia/src/routes/organization.sync.tsx +++ b/packages/insomnia/src/routes/organization.sync.tsx @@ -1,5 +1,5 @@ +import { syncOrganizations } from '~/common/organization'; import { services } from '~/insomnia-data'; -import { syncOrganizations } from '~/ui/organization-utils'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.sync'; diff --git a/packages/insomnia/src/routes/trial.start.tsx b/packages/insomnia/src/routes/trial.start.tsx index 2b249193cc6e..efc8876aeeef 100644 --- a/packages/insomnia/src/routes/trial.start.tsx +++ b/packages/insomnia/src/routes/trial.start.tsx @@ -1,11 +1,19 @@ -import { startTrial } from 'insomnia-api'; +import { getCurrentPlan, startTrial } from 'insomnia-api'; import { services } from '~/insomnia-data'; -import { syncCurrentPlan } from '~/ui/organization-utils'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/settings.update'; +async function syncCurrentPlan(sessionId: string, accountId: string) { + const [currentPlanResult] = await Promise.allSettled([getCurrentPlan({ sessionId })]); + if (currentPlanResult.status === 'fulfilled' && currentPlanResult.value) { + localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlanResult.value)); + } else { + console.log('[current-plan] Failed to load current-plan', currentPlanResult.status); + } +} + export async function clientAction(_args: Route.ClientActionArgs) { const { id: sessionId, accountId } = await services.userSession.get(); diff --git a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts index 4cdbcdfd67c4..37c79e46faec 100644 --- a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts +++ b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts @@ -1,7 +1,6 @@ -import type { BaseModel, Project, Workspace } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; +import type { Project, Workspace } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; -import { database } from '../../common/database'; import type { Stage, StageEntry, Status, StatusCandidate } from '../types'; export interface SyncVCSLike { @@ -23,21 +22,7 @@ export const initializeLocalBackendProjectAndMarkForSync = async ({ // Create local project await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); - // Everything unstaged - const candidates = (await database.getWithDescendants(workspace)).filter(models.canSync).map( - (doc: BaseModel): StatusCandidate => ({ - key: doc._id, - name: doc.name || '', - document: doc, - }), - ); - const status = await vcs.status(candidates); - - // Stage everything - await vcs.stage(Object.values(status.unstaged)); - - // Snapshot - await vcs.takeSnapshot('Initial Snapshot'); + await services.workspace.commitAll({ workspace, vcs, message: 'Initial Snapshot' }); // Mark for pushing to the active project await services.workspaceMeta.updateByParentId(workspace._id, { pushSnapshotOnInitialize: true }); @@ -46,7 +31,7 @@ export const initializeLocalBackendProjectAndMarkForSync = async ({ export const pushSnapshotOnInitialize = async ({ vcs, workspace, - project: { _id: projectId, remoteId: projectRemoteId, parentId }, + project: { _id: projectId, remoteId: projectRemoteId, parentId: orgId }, }: { vcs: SyncVCSLike; workspace: Workspace; @@ -62,6 +47,6 @@ export const pushSnapshotOnInitialize = async ({ if (projectIsForWorkspace && projectRemoteId && hasProject) { await services.workspaceMeta.updateByParentId(workspace._id, { pushSnapshotOnInitialize: false }); - await vcs.push({ teamId: parentId, teamProjectId: projectRemoteId }); + await vcs.push({ teamId: orgId, teamProjectId: projectRemoteId }); } }; diff --git a/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts b/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts deleted file mode 100644 index 28e8cb4b60ba..000000000000 --- a/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Project, RemoteProject } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; - -import { database } from '../../common/database'; - -// Migration: -// Team ~= Project > Workspaces -// In the previous API: { _id: 'proj_team_123', remoteId: 'team_123', parentId: null } - -// Organization > TeamProject > Workspaces -// In the new API: { _id: 'proj_team_123', remoteId: 'proj_team_123', parentId: 'team_123' } - -// the remote id field previously tracked "team_id" -// (remote concept for matching 1:1 with this project) which is now org_id -// the _id field previously tracked the "proj_team_id" -// which was a wrapper for the team_id prefixing proj_to the above id, -// which is now the remoteId for tracking the projects within an org - -export const shouldMigrateProjectUnderOrganization = async () => { - const [localProjectCount, legacyRemoteProjectCount] = await Promise.all([ - database.count(models.project.type, { - remoteId: null, - parentId: null, - }), - database.count(models.project.type, { - remoteId: { $ne: null }, - parentId: null, - }), - ]); - - return localProjectCount > 0 || legacyRemoteProjectCount > 0; -}; - -export const migrateProjectsIntoOrganization = async ({ - personalOrganizationId, -}: { - personalOrganizationId: string; -}) => { - // Legacy remote projects without organizations - // Local projects without organizations except scratchpad - const [legacyRemoteProjects, localProjects] = await Promise.all([ - database.find(models.project.type, { - remoteId: { $ne: null }, - parentId: null, - }), - database.find(models.project.type, { - remoteId: null, - parentId: null, - _id: { $ne: models.project.SCRATCHPAD_PROJECT_ID }, - }), - ]); - - const updatePromises = []; - // Legacy remoteId should be orgId and legacy _id should be remoteId - for (const remoteProject of legacyRemoteProjects) { - updatePromises.push( - services.project.update(remoteProject, { - parentId: remoteProject.remoteId, - remoteId: remoteProject._id, - }), - ); - } - - // Assign all local projects to personal organization - for (const localProject of localProjects) { - updatePromises.push( - services.project.update(localProject, { - parentId: personalOrganizationId, - }), - ); - } - - await Promise.all(updatePromises); -}; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 3d8db81cebe5..71ab7a1b2129 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -15,6 +15,7 @@ import { import { useParams, useRevalidator } from 'react-router'; import * as reactUse from 'react-use'; +import { DEFAULT_STORAGE_RULES } from '~/common/organization'; import type { GitProject, GitRepository } from '~/insomnia-data'; import { models } from '~/insomnia-data'; import { useGitProjectCheckoutBranchActionFetcher } from '~/routes/git.branch.checkout'; @@ -29,7 +30,6 @@ import { ProjectModal } from '~/ui/components/modals/project-modal'; import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { useGitCredentials } from '~/ui/hooks/use-git-credentials'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; -import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { MergeConflict } from '../../../sync/types'; import { GitNonOriginBranchBanner } from '../git/git-non-origin-branch-banner'; diff --git a/packages/insomnia/src/ui/organization-utils.ts b/packages/insomnia/src/ui/organization-utils.ts deleted file mode 100644 index 60fc5d294752..000000000000 --- a/packages/insomnia/src/ui/organization-utils.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { createTeamProject, fetchTeamProjects, getCurrentPlan, getUserProfile, isApiError } from 'insomnia-api'; - -import { projectLock } from '~/common/project'; -import type { Project, Workspace } from '~/insomnia-data'; -import { database, models, services } from '~/insomnia-data'; -import { invariant } from '~/utils/invariant'; - -import { - initializeLocalBackendProjectAndMarkForSync, - pushSnapshotOnInitialize, - type SyncVCSLike, -} from '../sync/vcs/initialize-backend-project'; -import { - migrateProjectsIntoOrganization, - shouldMigrateProjectUnderOrganization, -} from '../sync/vcs/migrate-projects-into-organization'; -export { DEFAULT_STORAGE_RULES, fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; - -export async function syncCurrentPlan(sessionId: string, accountId: string) { - const [currentPlanResult] = await Promise.allSettled([getCurrentPlan({ sessionId })]); - if (currentPlanResult.status === 'fulfilled' && currentPlanResult.value) { - localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlanResult.value)); - } else { - console.log('[current-plan] Failed to load current-plan', currentPlanResult.status); - } -} - -export async function syncOrganizations(sessionId: string, accountId: string) { - try { - const [organizations, user, currentPlan] = await Promise.all([ - services.organization.list(), - getUserProfile({ sessionId }), - getCurrentPlan({ sessionId }), - ]); - - invariant(organizations, 'Failed to load organizations'); - invariant(user && user.id, 'Failed to load user'); - invariant(currentPlan && currentPlan.planId, 'Failed to load current plan'); - - invariant(accountId, 'Account ID is not defined'); - - localStorage.setItem(`${accountId}:organizations`, JSON.stringify(organizations)); - localStorage.setItem(`${accountId}:user`, JSON.stringify(user)); - localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlan)); - } catch (error) { - console.log('[organization] Failed to load Organizations', error); - } -} - -export async function updateLocalProjectToRemote({ - project, - vcs, - sessionId, - organizationId, -}: { - project: Project; - vcs: SyncVCSLike; - sessionId: string; - organizationId: string; -}) { - try { - const newCloudProject = await createTeamProject({ - sessionId, - organizationId, - name: project.name, - }); - const updatedProject = await services.project.update(project, { - name: newCloudProject.name, - remoteId: newCloudProject.id, - }); - - // For each workspace in the local project - const projectWorkspaces = await database.find('Workspace', { - parentId: updatedProject._id, - }); - - for (const workspace of projectWorkspaces) { - const workspaceMeta = await services.workspaceMeta.getOrCreateByParentId(workspace._id); - - // Initialize Sync on the workspace if it's not using Git sync - try { - if (!workspaceMeta.gitRepositoryId) { - invariant(vcs, 'VCS must be initialized'); - - await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace }); - await pushSnapshotOnInitialize({ vcs, workspace, project: updatedProject }); - } - } catch (e) { - console.warn( - 'Failed to initialize sync on workspace. This will be retried when the workspace is opened on the app.', - e, - ); - // TODO: here we should show the try again dialog - } - } - } catch (error: unknown) { - if (isApiError(error)) { - let errorMessage = 'An unexpected error occurred while connecting the project. Please try again.'; - if (error.name === 'FORBIDDEN' || error.name === 'NEEDS_TO_UPGRADE') { - errorMessage = error.message; - } - return { - error: errorMessage, - }; - } - return { - error: error instanceof Error ? error.message : String(error), - }; - } - - return { - error: null, - }; -} - -export async function migrateProjectsUnderOrganization(personalOrganizationId: string, sessionId: string) { - if (await shouldMigrateProjectUnderOrganization()) { - await migrateProjectsIntoOrganization({ - personalOrganizationId, - }); - - const preferredProjectType = localStorage.getItem('prefers-project-type'); - if (preferredProjectType === 'remote') { - const localProjects = await database.find('Project', { - parentId: personalOrganizationId, - remoteId: null, - }); - - // If any of those fail projects will still be under the organization as local projects - for (const project of localProjects) { - updateLocalProjectToRemote({ - project, - organizationId: personalOrganizationId, - sessionId, - vcs: window.main.sync, - }); - } - } - } -} - -interface TeamProject { - id: string; - name: string; -} - -async function getAllTeamProjects(organizationId: string) { - const { id: sessionId } = await services.userSession.get(); - if (!sessionId) { - return []; - } - - console.log('[project] Fetching', organizationId); - const response = await fetchTeamProjects({ sessionId, organizationId }); - - return response.data; -} - -async function syncTeamProjects({ - organizationId, - teamProjects, -}: { - teamProjects: TeamProject[]; - organizationId: string; -}) { - // assumption: api teamProjects is the source of truth for migrated projects - // once migrated orgs become the source of truth for projects - // its important that migration be completed before this code is run - const existingRemoteProjects = await database.find(models.project.type, { - remoteId: { $in: teamProjects.map(p => p.id) }, - }); - - const existingRemoteProjectsRemoteIds = existingRemoteProjects.map(p => p.remoteId); - const remoteProjectsThatNeedToBeCreated = teamProjects.filter(p => !existingRemoteProjectsRemoteIds.includes(p.id)); - - // this will create a new project for any remote projects that don't exist in the current organization - await Promise.all( - remoteProjectsThatNeedToBeCreated.map(async prj => { - await services.project.create({ - remoteId: prj.id, - name: prj.name, - parentId: organizationId, - }); - }), - ); - - const remoteProjectsThatNeedToBeUpdated = await database.find(models.project.type, { - // Remote ID is in the list of remote projects - remoteId: { $in: teamProjects.map(p => p.id) }, - }); - - await Promise.all( - remoteProjectsThatNeedToBeUpdated.map(async prj => { - const remoteProject = teamProjects.find(p => p.id === prj.remoteId); - if (remoteProject && remoteProject.name !== prj.name) { - await services.project.update(prj, { - name: remoteProject.name, - }); - } - }), - ); - - // Turn remote projects from the current organization that are not in the list of remote projects into local projects. - const removedRemoteProjects = await database.find(models.project.type, { - // filter by this organization so no legacy data can be accidentally removed, because legacy had null parentId - parentId: organizationId, - // Remote ID is not in the list of remote projects. - // add `$ne: null` condition because if remoteId is already null, we dont need to remove it again. - // nedb use append-only format, all updates and deletes actually result in lines added - remoteId: { - $nin: teamProjects.map(p => p.id), - $ne: null, - }, - }); - - await Promise.all( - removedRemoteProjects.map(async prj => { - await services.project.update(prj, { - remoteId: null, - }); - }), - ); -} - -export const syncProjects = projectLock.wrapWithLock(async (organizationId: string) => { - const user = await services.userSession.get(); - const teamProjects = await getAllTeamProjects(organizationId); - // ensure we don't sync projects in the wrong place - if (Array.isArray(teamProjects) && user.id && !models.organization.isScratchpadOrganizationId(organizationId)) { - await syncTeamProjects({ - organizationId, - teamProjects, - }); - } -});