Skip to content

Commit dba4640

Browse files
committed
refactor: move organization to service layer
1 parent 7cd8854 commit dba4640

21 files changed

Lines changed: 447 additions & 351 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getCurrentPlan, getUserProfile } from 'insomnia-api';
2+
3+
import { projectLock } from '~/common/project';
4+
import { services } from '~/insomnia-data';
5+
import { invariant } from '~/utils/invariant';
6+
7+
export { DEFAULT_STORAGE_RULES, fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules';
8+
9+
export async function syncOrganizations(sessionId: string, accountId: string) {
10+
try {
11+
const [organizations, user, currentPlan] = await Promise.all([
12+
services.organization.list(),
13+
getUserProfile({ sessionId }),
14+
getCurrentPlan({ sessionId }),
15+
]);
16+
17+
invariant(organizations, 'Failed to load organizations');
18+
invariant(user && user.id, 'Failed to load user');
19+
invariant(currentPlan && currentPlan.planId, 'Failed to load current plan');
20+
21+
invariant(accountId, 'Account ID is not defined');
22+
23+
localStorage.setItem(`${accountId}:organizations`, JSON.stringify(organizations));
24+
localStorage.setItem(`${accountId}:user`, JSON.stringify(user));
25+
localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlan));
26+
} catch (error) {
27+
console.log('[organization] Failed to load Organizations', error);
28+
}
29+
}
30+
31+
export const syncProjects = projectLock.wrapWithLock(async (organizationId: string) => {
32+
await services.organization.syncProjectsOfOrg(organizationId);
33+
});

packages/insomnia/src/insomnia-data/node-src/services/organization.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getOrganizations, type Organization } from 'insomnia-api';
22

33
import { models } from '~/insomnia-data';
44

5+
import { getAllTeamProjects, syncProjectsWithBackend } from './project';
56
import * as userSessionService from './user-session';
67

78
function sortOrganizations(accountId: string, organizations: Organization[]): Organization[] {
@@ -58,3 +59,17 @@ export async function get(id: string): Promise<Organization | undefined> {
5859
const all = await list();
5960
return all.find(org => org.id === id);
6061
}
62+
63+
function isScratchpad(orgIdOrOrg: string | Organization) {
64+
const organizationId = typeof orgIdOrOrg === 'string' ? orgIdOrOrg : orgIdOrOrg.id;
65+
return models.organization.isScratchpadOrganizationId(organizationId);
66+
}
67+
68+
export async function syncProjectsOfOrg(organizationId: string) {
69+
const user = await userSessionService.get();
70+
const teamProjects = await getAllTeamProjects(organizationId);
71+
// ensure we don't sync projects in the wrong place
72+
if (Array.isArray(teamProjects) && user.id && !isScratchpad(organizationId)) {
73+
await syncProjectsWithBackend(teamProjects, organizationId);
74+
}
75+
}

packages/insomnia/src/insomnia-data/node-src/services/project.ts

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import type { Project, Query } from '~/insomnia-data';
1+
import { createTeamProject, fetchTeamProjects } from 'insomnia-api';
2+
3+
import type { Project, Query, RemoteProject } from '~/insomnia-data';
24
import { database as db, models } from '~/insomnia-data';
35

6+
import { get as getUser } from './user-session';
7+
import type { SyncVCSLike } from './vcs';
8+
import { commitAllAndPush, getWorkspacesOfProject, hasGitRepositoryId } from './workspace';
9+
410
const { type } = models.project;
511

612
export function create(patch: Partial<Project> = {}) {
@@ -46,3 +52,228 @@ export async function remove(idOrProject: string | Project) {
4652
const project = await getProjectByIdOrProject(idOrProject);
4753
return db.remove(project);
4854
}
55+
56+
export async function getFirstProjectUnderOrg(organizationId: string) {
57+
return db.findOne<Project>(type, { parentId: organizationId });
58+
}
59+
60+
interface TeamProject {
61+
id: string;
62+
name: string;
63+
}
64+
65+
export async function syncProjectsWithBackend(teamProjects: TeamProject[], organizationId: string) {
66+
// assumption: api teamProjects is the source of truth for migrated projects
67+
// once migrated orgs become the source of truth for projects
68+
// its important that migration be completed before this code is run
69+
const existingRemoteProjects = await db.find<Project>(type, {
70+
remoteId: { $in: teamProjects.map(p => p.id) },
71+
});
72+
73+
const existingRemoteProjectsRemoteIds = existingRemoteProjects.map(p => p.remoteId);
74+
const remoteProjectsThatNeedToBeCreated = teamProjects.filter(p => !existingRemoteProjectsRemoteIds.includes(p.id));
75+
76+
// this will create a new project for any remote projects that don't exist in the current organization
77+
await Promise.all(
78+
remoteProjectsThatNeedToBeCreated.map(async prj => {
79+
await create({
80+
remoteId: prj.id,
81+
name: prj.name,
82+
parentId: organizationId,
83+
});
84+
}),
85+
);
86+
87+
const remoteProjectsThatNeedToBeUpdated = await db.find<Project>(type, {
88+
// Remote ID is in the list of remote projects
89+
remoteId: { $in: teamProjects.map(p => p.id) },
90+
});
91+
92+
await Promise.all(
93+
remoteProjectsThatNeedToBeUpdated.map(async prj => {
94+
const remoteProject = teamProjects.find(p => p.id === prj.remoteId);
95+
if (remoteProject && remoteProject.name !== prj.name) {
96+
await update(prj, {
97+
name: remoteProject.name,
98+
});
99+
}
100+
}),
101+
);
102+
103+
// Turn remote projects from the current organization that are not in the list of remote projects into local projects.
104+
const removedRemoteProjects = await db.find<Project>(type, {
105+
// filter by this organization so no legacy data can be accidentally removed, because legacy had null parentId
106+
parentId: organizationId,
107+
// Remote ID is not in the list of remote projects.
108+
// add `$ne: null` condition because if remoteId is already null, we dont need to remove it again.
109+
// nedb use append-only format, all updates and deletes actually result in lines added
110+
remoteId: {
111+
$nin: teamProjects.map(p => p.id),
112+
$ne: null,
113+
},
114+
});
115+
116+
await Promise.all(
117+
removedRemoteProjects.map(async prj => {
118+
await update(prj, {
119+
remoteId: null,
120+
});
121+
}),
122+
);
123+
}
124+
125+
export async function migrateProjectsIntoOrganization(personalOrganizationId: string) {
126+
// Legacy remote projects without organizations
127+
// Local projects without organizations except scratchpad
128+
const [legacyRemoteProjects, localProjects] = await Promise.all([
129+
db.find<RemoteProject>(models.project.type, {
130+
remoteId: { $ne: null },
131+
parentId: null,
132+
}),
133+
db.find<Project>(models.project.type, {
134+
remoteId: null,
135+
parentId: null,
136+
_id: { $ne: models.project.SCRATCHPAD_PROJECT_ID },
137+
}),
138+
]);
139+
140+
const updatePromises = [];
141+
// Legacy remoteId should be orgId and legacy _id should be remoteId
142+
for (const remoteProject of legacyRemoteProjects) {
143+
updatePromises.push(
144+
update(remoteProject, {
145+
parentId: remoteProject.remoteId,
146+
remoteId: remoteProject._id,
147+
}),
148+
);
149+
}
150+
151+
// Assign all local projects to personal organization
152+
for (const localProject of localProjects) {
153+
updatePromises.push(
154+
update(localProject, {
155+
parentId: personalOrganizationId,
156+
}),
157+
);
158+
}
159+
160+
await Promise.all(updatePromises);
161+
}
162+
163+
// Migration:
164+
// Team ~= Project > Workspaces
165+
// In the previous API: { _id: 'proj_team_123', remoteId: 'team_123', parentId: null }
166+
167+
// Organization > TeamProject > Workspaces
168+
// In the new API: { _id: 'proj_team_123', remoteId: 'proj_team_123', parentId: 'team_123' }
169+
170+
// the remote id field previously tracked "team_id"
171+
// (remote concept for matching 1:1 with this project) which is now org_id
172+
// the _id field previously tracked the "proj_team_id"
173+
// which was a wrapper for the team_id prefixing proj_to the above id,
174+
// which is now the remoteId for tracking the projects within an org
175+
export async function hasProjectsToMigrate() {
176+
const [localProjectCount, legacyRemoteProjectCount] = await Promise.all([
177+
db.count<Project>(models.project.type, {
178+
remoteId: null,
179+
parentId: null,
180+
}),
181+
db.count<Project>(models.project.type, {
182+
remoteId: { $ne: null },
183+
parentId: null,
184+
}),
185+
]);
186+
187+
return localProjectCount > 0 || legacyRemoteProjectCount > 0;
188+
}
189+
190+
export async function getLocalProjectsOfOrg(orgId: string) {
191+
return await db.find<Project>(models.project.type, {
192+
parentId: orgId,
193+
remoteId: null,
194+
});
195+
}
196+
197+
export async function pushLocalProject({
198+
project,
199+
vcs,
200+
organizationId,
201+
}: {
202+
project: Project;
203+
vcs: SyncVCSLike;
204+
organizationId: string;
205+
}) {
206+
const { id: sessionId } = await getUser();
207+
const newCloudProject = await createTeamProject({
208+
sessionId,
209+
organizationId,
210+
name: project.name,
211+
});
212+
const updatedProject = await update(project, {
213+
name: newCloudProject.name,
214+
remoteId: newCloudProject.id,
215+
});
216+
217+
// For each workspace in the local project
218+
const projectWorkspaces = await getWorkspacesOfProject(updatedProject._id);
219+
220+
for (const workspace of projectWorkspaces) {
221+
const hasGitRepoId = await hasGitRepositoryId(workspace);
222+
223+
// Initialize Sync on the workspace if it's not using Git sync
224+
try {
225+
if (!hasGitRepoId) {
226+
if (!vcs) {
227+
throw new Error('VCS must be initialized');
228+
}
229+
230+
await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name);
231+
await commitAllAndPush({ workspace, vcs, message: 'Initial Snapshot', project: updatedProject });
232+
}
233+
} catch (e) {
234+
console.warn(
235+
'Failed to initialize sync on workspace. This will be retried when the workspace is opened on the app.',
236+
e,
237+
);
238+
// TODO: here we should show the try again dialog
239+
}
240+
}
241+
}
242+
243+
export async function getAllTeamProjects(organizationId: string) {
244+
const { id: sessionId } = await getUser();
245+
if (!sessionId) {
246+
return [];
247+
}
248+
249+
console.log('[project] Fetching', organizationId);
250+
const response = await fetchTeamProjects({ sessionId, organizationId });
251+
return response.data;
252+
}
253+
254+
export async function migrateProjectsUnderOrganization(
255+
orgId: string,
256+
preferredProjectType: string | null,
257+
vcs: SyncVCSLike,
258+
) {
259+
if (await hasProjectsToMigrate()) {
260+
await migrateProjectsIntoOrganization(orgId);
261+
262+
if (preferredProjectType === 'remote') {
263+
const localProjects = await getLocalProjectsOfOrg(orgId);
264+
265+
// If any of those fail projects will still be under the organization as local projects
266+
for (const project of localProjects) {
267+
try {
268+
await pushLocalProject({
269+
project,
270+
organizationId: orgId,
271+
vcs,
272+
});
273+
} catch {
274+
console.log(`Failed to push project ${project._id} to the cloud`);
275+
}
276+
}
277+
}
278+
}
279+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { BaseModel } from '~/insomnia-data';
2+
3+
export type DocumentKey = string;
4+
5+
export type BlobId = string;
6+
7+
export interface StageEntryDelete {
8+
deleted: true;
9+
key: string;
10+
name: string;
11+
blobId: BlobId;
12+
previousBlobContent?: string;
13+
}
14+
15+
export interface StageEntryAdd {
16+
added: true;
17+
key: string;
18+
name: string;
19+
blobId: BlobId;
20+
blobContent: string;
21+
}
22+
23+
export interface StageEntryModify {
24+
modified: true;
25+
key: string;
26+
name: string;
27+
blobId: BlobId;
28+
blobContent: string;
29+
previousBlobContent?: string;
30+
}
31+
32+
export type StageEntry = StageEntryDelete | StageEntryAdd | StageEntryModify;
33+
34+
export type Stage = Record<DocumentKey, StageEntry>;
35+
36+
export interface StatusCandidate {
37+
key: DocumentKey;
38+
name: string;
39+
document: BaseModel;
40+
}
41+
42+
export interface Status {
43+
key: string;
44+
stage: Stage;
45+
unstaged: Record<DocumentKey, StageEntry>;
46+
}
47+
48+
export interface SyncVCSLike {
49+
hasBackendProject: () => boolean | Promise<boolean>;
50+
push: (options: { teamId: string; teamProjectId: string }) => Promise<void>;
51+
stage: (stageEntries: StageEntry[]) => Promise<Stage>;
52+
status: (candidates: StatusCandidate[]) => Promise<Status>;
53+
switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise<void>;
54+
takeSnapshot: (name: string) => Promise<void>;
55+
}

0 commit comments

Comments
 (0)