Skip to content

Commit

Permalink
Add Jetstream Organizations
Browse files Browse the repository at this point in the history
Organizations allow users to group SFDC orgs so that they can work on groups of orgs at one time in isolation.

This feature is most useful for consultants working across many separate groups of orgs, but is also useful to separate out production from other sandboxes etc..

With our new authentication provider, it appears like we may not be able to support multiple accounts with the same email address which was the catalyst for this feature.

resolves #1018
  • Loading branch information
paustint committed Sep 6, 2024
1 parent 5261521 commit 2eed46f
Show file tree
Hide file tree
Showing 47 changed files with 1,967 additions and 180 deletions.
86 changes: 86 additions & 0 deletions apps/api/src/app/controllers/jetstream-organizations.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod';
import * as jetstreamOrganizationsDb from '../db/organization.db';
import { UserFacingError } from '../utils/error-handler';
import { sendJson } from '../utils/response.handlers';
import { createRoute } from '../utils/route.utils';

export const routeDefinition = {
getOrganizations: {
controllerFn: () => getOrganizations,
validators: {
hasSourceOrg: false,
},
},
createOrganization: {
controllerFn: () => createOrganization,
validators: {
body: z.object({
name: z.string(),
description: z.string().optional(),
}),
hasSourceOrg: false,
},
},
updateOrganization: {
controllerFn: () => updateOrganization,
validators: {
params: z.object({
id: z.string().uuid(),
}),
body: z.object({
name: z.string(),
description: z.string().optional(),
}),
hasSourceOrg: false,
},
},
deleteOrganization: {
controllerFn: () => deleteOrganization,
validators: {
params: z.object({
id: z.string().uuid(),
}),
hasSourceOrg: false,
},
},
};

const getOrganizations = createRoute(routeDefinition.getOrganizations.validators, async ({ user, query }, req, res, next) => {
try {
const organizations = await jetstreamOrganizationsDb.findByUserId({ userId: user.id });

sendJson(res, organizations);
} catch (ex) {
next(new UserFacingError(ex));
}
});

const createOrganization = createRoute(routeDefinition.createOrganization.validators, async ({ user, body }, req, res, next) => {
try {
const organization = await jetstreamOrganizationsDb.create(user.id, body);

sendJson(res, organization);
} catch (ex) {
next(new UserFacingError(ex));
}
});

const updateOrganization = createRoute(routeDefinition.updateOrganization.validators, async ({ body, params, user }, req, res, next) => {
try {
const organization = await jetstreamOrganizationsDb.update(user.id, params.id, body);

sendJson(res, organization, 201);
} catch (ex) {
next(new UserFacingError(ex));
}
});

const deleteOrganization = createRoute(routeDefinition.deleteOrganization.validators, async ({ params, user }, req, res, next) => {
try {
await jetstreamOrganizationsDb.deleteOrganization(user.id, params.id);

sendJson(res, undefined, 204);
} catch (ex) {
next(new UserFacingError(ex));
}
});
34 changes: 29 additions & 5 deletions apps/api/src/app/controllers/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ENV, getExceptionLog, logger } from '@jetstream/api-config';
import { ApiConnection, ApiRequestError, getApiRequestFactoryFn } from '@jetstream/salesforce-api';
import { ERROR_MESSAGES } from '@jetstream/shared/constants';
import { SObjectOrganization, SalesforceOrgUi } from '@jetstream/types';
import { getErrorMessage } from '@jetstream/shared/utils';
import { Maybe, SObjectOrganization, SalesforceOrgUi } from '@jetstream/types';
import { CallbackParamsType } from 'openid-client';
import { z } from 'zod';
import * as jetstreamOrganizationsDb from '../db/organization.db';
import * as salesforceOrgsDb from '../db/salesforce-org.db';
import * as oauthService from '../services/oauth.service';
import { createRoute } from '../utils/route.utils';
Expand All @@ -19,6 +21,7 @@ export const routeDefinition = {
.enum(['true', 'false'])
.nullish()
.transform((val) => val === 'true'),
jetstreamOrganizationId: z.string().nullish(),
}),
hasSourceOrg: false,
},
Expand All @@ -38,9 +41,9 @@ export const routeDefinition = {
* @param res
*/
const salesforceOauthInitAuth = createRoute(routeDefinition.salesforceOauthInitAuth.validators, async ({ query }, req, res, next) => {
const { loginUrl, addLoginParam } = query;
const { loginUrl, addLoginParam, jetstreamOrganizationId } = query;
const { authorizationUrl, code_verifier, nonce, state } = oauthService.salesforceOauthInit(loginUrl, { addLoginParam });
req.session.orgAuth = { code_verifier, nonce, state, loginUrl };
req.session.orgAuth = { code_verifier, nonce, state, loginUrl, jetstreamOrganizationId };
res.redirect(authorizationUrl);
});

Expand Down Expand Up @@ -78,7 +81,7 @@ const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallb
return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
}

const { code_verifier, nonce, state, loginUrl } = orgAuth;
const { code_verifier, nonce, state, loginUrl, jetstreamOrganizationId } = orgAuth;

const { access_token, refresh_token, userInfo } = await oauthService.salesforceOauthCallback(loginUrl, query, {
code_verifier,
Expand All @@ -101,6 +104,7 @@ const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallb
const salesforceOrg = await initConnectionFromOAuthResponse({
jetstreamConn,
userId: user.id,
jetstreamOrganizationId,
});

returnParams.data = JSON.stringify(salesforceOrg);
Expand All @@ -115,7 +119,15 @@ const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallb
}
});

export async function initConnectionFromOAuthResponse({ jetstreamConn, userId }: { jetstreamConn: ApiConnection; userId: string }) {
export async function initConnectionFromOAuthResponse({
jetstreamConn,
userId,
jetstreamOrganizationId,
}: {
jetstreamConn: ApiConnection;
userId: string;
jetstreamOrganizationId?: Maybe<string>;
}) {
const identity = await jetstreamConn.org.identity();
let companyInfoRecord: SObjectOrganization | undefined;

Expand Down Expand Up @@ -157,6 +169,18 @@ export async function initConnectionFromOAuthResponse({ jetstreamConn, userId }:
orgTrialExpirationDate: companyInfoRecord?.TrialExpirationDate,
};

if (jetstreamOrganizationId) {
try {
salesforceOrgUi.jetstreamOrganizationId = (await jetstreamOrganizationsDb.findById({ id: jetstreamOrganizationId, userId })).id;
} catch (ex) {
logger.warn(
{ userId, jetstreamOrganizationId, ...getExceptionLog(ex) },
'Error getting jetstream org with provided id %s',
getErrorMessage(ex)
);
}
}

const salesforceOrg = await salesforceOrgsDb.createOrUpdateSalesforceOrg(userId, salesforceOrgUi);
return salesforceOrg;
}
23 changes: 23 additions & 0 deletions apps/api/src/app/controllers/orgs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export const routeDefinition = {
controllerFn: () => checkOrgHealth,
validators: {},
},
moveOrg: {
controllerFn: () => moveOrg,
validators: {
params: z.object({
uniqueId: z.string().min(1),
}),
body: z.object({
jetstreamOrganizationId: z.string().uuid().nullish(),
}),
hasSourceOrg: false,
},
},
};

const getOrgs = createRoute(routeDefinition.getOrgs.validators, async ({ user }, req, res, next) => {
Expand Down Expand Up @@ -121,3 +133,14 @@ const checkOrgHealth = createRoute(routeDefinition.checkOrgHealth.validators, as
next(new UserFacingError(ex));
}
});

const moveOrg = createRoute(routeDefinition.moveOrg.validators, async ({ body, params, user }, req, res, next) => {
try {
const { uniqueId } = params;
const salesforceOrg = await salesforceOrgsDb.moveSalesforceOrg(user.id, uniqueId, body);

sendJson(res, salesforceOrg, 201);
} catch (ex) {
next(new UserFacingError(ex));
}
});
67 changes: 67 additions & 0 deletions apps/api/src/app/db/organization.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { prisma } from '@jetstream/api-config';
import { Maybe } from '@jetstream/types';
import { Prisma } from '@prisma/client';
import { findIdByUserId } from './user.db';

const SELECT = Prisma.validator<Prisma.JetstreamOrganizationSelect>()({
id: true,
orgs: {
select: { uniqueId: true },
},
name: true,
description: true,
createdAt: true,
updatedAt: true,
});

export const findByUserId = async ({ userId }: { userId: string }) => {
return await prisma.jetstreamOrganization.findMany({ where: { user: { userId } }, select: SELECT });
};

export const findById = async ({ id, userId }: { id: string; userId: string }) => {
return await prisma.jetstreamOrganization.findFirstOrThrow({ where: { id, user: { userId } }, select: SELECT });
};

export const create = async (
userId: string,
payload: {
name: string;
description?: Maybe<string>;
}
) => {
const userActualId = await findIdByUserId({ userId });
return await prisma.jetstreamOrganization.create({
select: SELECT,
data: {
userId: userActualId,
name: payload.name.trim(),
description: payload.description?.trim(),
},
});
};

export const update = async (
userId,
id,
payload: {
name: string;
description?: Maybe<string>;
}
) => {
return await prisma.jetstreamOrganization.update({
select: SELECT,
where: { user: { userId }, id },
data: {
name: payload.name.trim(),
description: payload.description?.trim() ?? null,
},
});
};

export const deleteOrganization = async (userId, id) => {
return await prisma.jetstreamOrganization.delete({
select: SELECT,
where: { user: { userId }, id },
});
};
28 changes: 27 additions & 1 deletion apps/api/src/app/db/salesforce-org.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseISO } from 'date-fns/parseISO';
import isUndefined from 'lodash/isUndefined';

const SELECT = Prisma.validator<Prisma.SalesforceOrgSelect>()({
jetstreamOrganizationId: true,
uniqueId: true,
label: true,
filterText: true,
Expand All @@ -33,6 +34,8 @@ const SELECT = Prisma.validator<Prisma.SalesforceOrgSelect>()({
updatedAt: true,
});

export const SALESFORCE_ORG_SELECT = SELECT;

/**
* TODO: add better error handling with non-db error messages!
*/
Expand Down Expand Up @@ -111,6 +114,8 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales
where: findUniqueOrg({ jetstreamUserId, uniqueId: salesforceOrgUi.uniqueId! }),
});

// FIXME: need to include organization - added orgs should be added to current organization

let orgToDelete: Maybe<{ id: number }>;
/**
* After a sandbox refresh, the orgId will change but the username will remain the same
Expand All @@ -130,7 +135,8 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales
}

if (existingOrg) {
const data: Prisma.SalesforceOrgUpdateInput = {
const data: Prisma.XOR<Prisma.SalesforceOrgUpdateInput, Prisma.SalesforceOrgUncheckedUpdateInput> = {
jetstreamOrganizationId: salesforceOrgUi.jetstreamOrganizationId ?? existingOrg.jetstreamOrganizationId,
uniqueId: salesforceOrgUi.uniqueId ?? existingOrg.uniqueId,
accessToken: salesforceOrgUi.accessToken ?? existingOrg.accessToken,
instanceUrl: salesforceOrgUi.instanceUrl ?? existingOrg.instanceUrl,
Expand Down Expand Up @@ -174,6 +180,7 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales
data: {
jetstreamUserId,
jetstreamUrl: ENV.JETSTREAM_SERVER_URL,
jetstreamOrganizationId: salesforceOrgUi.jetstreamOrganizationId,
label: salesforceOrgUi.label || salesforceOrgUi.username,
uniqueId: salesforceOrgUi.uniqueId!,
accessToken: salesforceOrgUi.accessToken!,
Expand Down Expand Up @@ -229,6 +236,25 @@ export async function updateSalesforceOrg(jetstreamUserId: string, uniqueId: str
});
}

export async function moveSalesforceOrg(jetstreamUserId: string, uniqueId: string, data: { jetstreamOrganizationId?: Maybe<string> }) {
const existingOrg = await prisma.salesforceOrg.findUnique({
select: { id: true },
where: findUniqueOrg({ jetstreamUserId, uniqueId }),
});

if (!existingOrg) {
throw new Error('An org does not exist with the provided input');
}

return await prisma.salesforceOrg.update({
select: SELECT,
where: { id: existingOrg.id },
data: {
jetstreamOrganizationId: data.jetstreamOrganizationId ?? null,
},
});
}

export async function deleteSalesforceOrg(jetstreamUserId: string, uniqueId: string) {
const existingOrg = await prisma.salesforceOrg.findUnique({
select: { id: true, username: true, label: true, orgName: true },
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/app/db/user.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const userSelect: Prisma.UserSelect = {
userId: true,
};

export const findIdByUserId = ({ userId }: { userId: string }) => {
return prisma.user.findFirstOrThrow({ where: { userId }, select: { id: true } }).then(({ id }) => id);
};

/**
* Find by Auth0 userId, not Jetstream Id
*/
Expand Down Expand Up @@ -72,6 +76,7 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre
where: { userId: user.id },
data: {
appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]),
deletedAt: null,
preferences: {
upsert: {
create: { skipFrontdoorLogin: false },
Expand All @@ -92,6 +97,7 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre
nickname: user._json.nickname,
picture: user._json.picture,
appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]),
deletedAt: null,
preferences: { create: { skipFrontdoorLogin: false } },
},
select: userSelect,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/app/routes/api.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express from 'express';
import Router from 'express-promise-router';
import multer from 'multer';
import { routeDefinition as imageController } from '../controllers/image.controller';
import { routeDefinition as jetstreamOrganizationsController } from '../controllers/jetstream-organizations.controller';
import { routeDefinition as orgsController } from '../controllers/orgs.controller';
import { routeDefinition as salesforceApiReqController } from '../controllers/salesforce-api-requests.controller';
import { routeDefinition as bulkApiController } from '../controllers/sf-bulk-api.controller';
Expand Down Expand Up @@ -50,6 +51,12 @@ routes.post('/orgs/health-check', orgsController.checkOrgHealth.controllerFn());
routes.get('/orgs', orgsController.getOrgs.controllerFn());
routes.patch('/orgs/:uniqueId', orgsController.updateOrg.controllerFn());
routes.delete('/orgs/:uniqueId', orgsController.deleteOrg.controllerFn());
routes.put('/orgs/:uniqueId/move', orgsController.moveOrg.controllerFn());

routes.get('/jetstream-organizations', jetstreamOrganizationsController.getOrganizations.controllerFn());
routes.post('/jetstream-organizations', jetstreamOrganizationsController.createOrganization.controllerFn());
routes.put('/jetstream-organizations/:id', jetstreamOrganizationsController.updateOrganization.controllerFn());
routes.delete('/jetstream-organizations/:id', jetstreamOrganizationsController.deleteOrganization.controllerFn());

/**
* ************************************
Expand Down
Loading

0 comments on commit 2eed46f

Please sign in to comment.