refactor: move organization to service layer

This commit is contained in:
xiaodemen
2026-05-28 11:39:03 +08:00
parent 7cd8854f24
commit dba4640b5b
21 changed files with 447 additions and 351 deletions

View File

@@ -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);
});

View File

@@ -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<Organization | undefined> {
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);
}
}

View File

@@ -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<Project> = {}) {
@@ -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<Project>(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<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 create({
remoteId: prj.id,
name: prj.name,
parentId: organizationId,
});
}),
);
const remoteProjectsThatNeedToBeUpdated = await db.find<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 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<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 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<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
}),
db.find<Project>(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<Project>(models.project.type, {
remoteId: null,
parentId: null,
}),
db.count<Project>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
}),
]);
return localProjectCount > 0 || legacyRemoteProjectCount > 0;
}
export async function getLocalProjectsOfOrg(orgId: string) {
return await db.find<Project>(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`);
}
}
}
}
}

View File

@@ -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<DocumentKey, StageEntry>;
export interface StatusCandidate {
key: DocumentKey;
name: string;
document: BaseModel;
}
export interface Status {
key: string;
stage: Stage;
unstaged: Record<DocumentKey, StageEntry>;
}
export interface SyncVCSLike {
hasBackendProject: () => boolean | Promise<boolean>;
push: (options: { teamId: string; teamProjectId: string }) => Promise<void>;
stage: (stageEntries: StageEntry[]) => Promise<Stage>;
status: (candidates: StatusCandidate[]) => Promise<Status>;
switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise<void>;
takeSnapshot: (name: string) => Promise<void>;
}

View File

@@ -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<Workspace>(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;
}

View File

@@ -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'));
}

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 !== '') {

View File

@@ -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<Project>(models.project.type, { parentId: organizationId });
const firstProject = await services.project.getFirstProjectUnderOrg(organizationId);
if (firstProject?._id) {
return redirect(
href('/organization/:organizationId/project/:projectId', {

View File

@@ -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';

View File

@@ -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();

View File

@@ -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 });
}
};

View File

@@ -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<Project>(models.project.type, {
remoteId: null,
parentId: null,
}),
database.count<Project>(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<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
}),
database.find<Project>(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);
};

View File

@@ -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';

View File

@@ -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>('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>('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<Project>(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<Project>(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<Project>(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,
});
}
});