diff --git a/.env.example b/.env.example index 85a470ffe..342401257 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ JETSTREAM_SERVER_DOMAIN='localhost:3333' JETSTREAM_SERVER_URL='http://localhost:3333' JETSTREAM_POSTGRES_DBURI='postgres://postgres@localhost:5432/postgres' +# trace, debug (default), info, warn, error, fatal, silent - determines how much server logging is done +LOG_LEVEL='trace' + # PLAYWRIGHT INTEGRATION TEST LOGIN E2E_LOGIN_USERNAME='integration@jetstream.app.e2e' E2E_LOGIN_PASSWORD='TODO' diff --git a/README.md b/README.md index 54b1457f7..74b390c40 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ This project was generated using [Nx](https://nx.dev) - This repository is consi ├── electron-scripts ├── libs (CORE LIBRARIES SHARED ACROSS ALL APPLICATIONS) │ ├── api-config -│ ├── api-interfaces │ ├── connected (FRONTEND DATA LIBRARY) │ ├── icon-factory (SFDC ICONS) │ ├── monaco-configuration diff --git a/app/package.json b/app/package.json index fef54b896..2ba8a4f08 100644 --- a/app/package.json +++ b/app/package.json @@ -6,7 +6,8 @@ "license": "PRIVATE", "repository": "https://github.com/jetstreamapp/jetstream", "engines": { - "node": ">=16 <17" + "node": ">= 20", + "yarn": "1.22.21" }, "private": true, "dependencies": { diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 4671cca49..565ebca6f 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -1,16 +1,17 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { ENV, logger } from '@jetstream/api-config'; +import { ENV, getExceptionLog } from '@jetstream/api-config'; import { UserProfileServer } from '@jetstream/types'; -import { NextFunction, Request, Response } from 'express'; +import { NextFunction } from 'express'; import { isString } from 'lodash'; import * as passport from 'passport'; import { URL } from 'url'; import { hardDeleteUserAndOrgs } from '../db/transactions.db'; import { createOrUpdateUser } from '../db/user.db'; import { checkAuth } from '../routes/route.middleware'; -// import { sendWelcomeEmail } from '../services/worker-jobs'; import { linkIdentity } from '../services/auth0'; +import { Request, Response } from '../types/types'; import { AuthenticationError } from '../utils/error-handler'; +// import { sendWelcomeEmail } from '../services/worker-jobs'; export interface OauthLinkParams { type: 'auth' | 'salesforce'; @@ -30,17 +31,17 @@ export async function login(req: Request, res: Response) { const user = req.user as UserProfileServer; req.logIn(user, async (err) => { if (err) { - logger.warn('[AUTH][ERROR] Error logging in %o', err, { requestId: res.locals.requestId }); + req.log.warn({ ...getExceptionLog(err) }, '[AUTH][ERROR] Error logging in %o', err); return res.redirect('/'); } createOrUpdateUser(user) .then(async ({ user: _user }) => { - logger.info('[AUTH][SUCCESS] Logged in %s', _user.email, { userId: user.id, requestId: res.locals.requestId }); + req.log.info('[AUTH][SUCCESS] Logged in %s', _user.email); res.redirect(ENV.JETSTREAM_CLIENT_URL!); }) .catch((err) => { - logger.error('[AUTH][DB][ERROR] Error creating or sending welcome email %o', err, { requestId: res.locals.requestId }); + req.log.error({ ...getExceptionLog(err) }, '[AUTH][DB][ERROR] Error creating or sending welcome email %o', err); res.redirect('/'); }); }); @@ -59,28 +60,28 @@ export async function callback(req: Request, res: Response, next: NextFunction) }, (err, user, info) => { if (err) { - logger.warn('[AUTH][ERROR] Error with authentication %o', err, { requestId: res.locals.requestId }); + req.log.warn({ ...getExceptionLog(err) }, '[AUTH][ERROR] Error with authentication %o', err); return next(new AuthenticationError(err)); } if (!user) { - logger.warn('[AUTH][ERROR] no user', { requestId: res.locals.requestId }); - logger.warn('[AUTH][ERROR] no info %o', info, { requestId: res.locals.requestId }); + req.log.warn('[AUTH][ERROR] no user'); + req.log.warn('[AUTH][ERROR] no info %o', info); return res.redirect('/oauth/login'); } req.logIn(user, async (err) => { if (err) { - logger.warn('[AUTH][ERROR] Error logging in %o', err, { requestId: res.locals.requestId }); + req.log.warn('[AUTH][ERROR] Error logging in %o', err); return next(new AuthenticationError(err)); } createOrUpdateUser(user).catch((err) => { - logger.error('[AUTH][DB][ERROR] Error creating or sending welcome email %o', err, { requestId: res.locals.requestId }); + req.log.error({ ...getExceptionLog(err) }, '[AUTH][DB][ERROR] Error creating or sending welcome email %o', err); }); // TODO: confirm returnTo 0 it suddenly was reported as bad const returnTo = (req.session as any).returnTo; delete (req.session as any).returnTo; - logger.info('[AUTH][SUCCESS] Logged in %s', user.email, { userId: user.id, requestId: res.locals.requestId }); + req.log.info('[AUTH][SUCCESS] Logged in %s', user.email); res.redirect(returnTo || ENV.JETSTREAM_CLIENT_URL); }); } @@ -115,14 +116,13 @@ export async function linkCallback(req: Request, res: Response, next: NextFuncti clientUrl: new URL(ENV.JETSTREAM_CLIENT_URL!).origin, }; if (err) { - logger.warn('[AUTH][LINK][ERROR] Error with authentication %o', err, { requestId: res.locals.requestId }); + req.log.warn({ ...getExceptionLog(err) }, '[AUTH][LINK][ERROR] Error with authentication %o', err); params.error = isString(err) ? err : err.message || 'Unknown Error'; params.message = (req.query.error_description as string) || undefined; return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); } if (!userProfile) { - logger.warn('[AUTH][LINK][ERROR] no user', { requestId: res.locals.requestId }); - logger.warn('[AUTH][LINK][ERROR] no info %o', info, { requestId: res.locals.requestId }); + req.log.warn('[AUTH][LINK][ERROR] no user'); params.error = 'Authentication Error'; params.message = (req.query.error_description as string) || undefined; return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); @@ -137,16 +137,21 @@ export async function linkCallback(req: Request, res: Response, next: NextFuncti try { await hardDeleteUserAndOrgs(userProfile.user_id); } catch (ex) { - logger.warn('[AUTH0][IDENTITY][LINK][ERROR] Failed to delete the secondary user orgs %s', userProfile.user_id, { - userId: user.id, - secondaryUserId: userProfile.user_id, - requestId: res.locals.requestId, - }); + req.log.warn( + { + userId: user.id, + secondaryUserId: userProfile.user_id, + + ...getExceptionLog(ex), + }, + '[AUTH0][IDENTITY][LINK][ERROR] Failed to delete the secondary user orgs %s', + userProfile.user_id + ); } return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); } catch (ex) { - logger.warn('[AUTH][LINK][ERROR] Error linking account %o', err, { requestId: res.locals.requestId }); + req.log.warn({ ...getExceptionLog(ex) }, '[AUTH][LINK][ERROR] Error linking account %s', ex); params.error = 'Unexpected Error'; return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}&clientUrl=${ENV.JETSTREAM_CLIENT_URL}`); } diff --git a/apps/api/src/app/controllers/image.controller.ts b/apps/api/src/app/controllers/image.controller.ts index bb9aa101d..042ee2a75 100644 --- a/apps/api/src/app/controllers/image.controller.ts +++ b/apps/api/src/app/controllers/image.controller.ts @@ -1,19 +1,21 @@ import { ENV } from '@jetstream/api-config'; -import { UserProfileServer } from '@jetstream/types'; import { v2 as cloudinary } from 'cloudinary'; -import { NextFunction, Request, Response } from 'express'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; cloudinary.config({ secure: true }); -export const routeValidators = { - getUploadSignature: [], +export const routeDefinition = { + getUploadSignature: { + controllerFn: () => getUploadSignature, + validators: { + hasSourceOrg: false, + }, + }, }; - -export async function getUploadSignature(req: Request, res: Response, next: NextFunction) { +const getUploadSignature = createRoute(routeDefinition.getUploadSignature.validators, async ({ user }, req, res, next) => { try { - const user = req.user as UserProfileServer; const timestamp = Math.round(new Date().getTime() / 1000); const cloudName = cloudinary.config().cloud_name; const apiKey = cloudinary.config().api_key; @@ -21,10 +23,10 @@ export async function getUploadSignature(req: Request, res: Response, next: Next const apiSecret = cloudinary.config().api_secret!; const context = `caption=${user.id.replace('|', '\\|')}|environment=${ENV.JETSTREAM_SERVER_URL}`; - const signature = cloudinary.utils.api_sign_request({ timestamp: timestamp, upload_preset: 'jetstream-issues', context }, apiSecret); + const signature = cloudinary.utils.api_sign_request({ timestamp, upload_preset: 'jetstream-issues', context }, apiSecret); - sendJson(res, { signature: signature, timestamp: timestamp, cloudName: cloudName, apiKey: apiKey, context }, 200); + sendJson(res, { signature: signature, timestamp, cloudName: cloudName, apiKey: apiKey, context }, 200); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); diff --git a/apps/api/src/app/controllers/oauth.controller.ts b/apps/api/src/app/controllers/oauth.controller.ts index 765082007..f26d03e65 100644 --- a/apps/api/src/app/controllers/oauth.controller.ts +++ b/apps/api/src/app/controllers/oauth.controller.ts @@ -1,125 +1,138 @@ -import { ENV, logger } from '@jetstream/api-config'; -import { SObjectOrganization, SalesforceOrgUi, UserProfileServer } from '@jetstream/types'; -import * as express from 'express'; -import * as jsforce from 'jsforce'; +import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; +import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; +import { SObjectOrganization, SalesforceOrgUi } from '@jetstream/types'; +import { CallbackParamsType } from 'openid-client'; +import { z } from 'zod'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; -import { getJsforceOauth2 } from '../utils/auth-utils'; +import * as oauthService from '../services/oauth.service'; +import { createRoute } from '../utils/route.utils'; import { OauthLinkParams } from './auth.controller'; +export const routeDefinition = { + salesforceOauthInitAuth: { + controllerFn: () => salesforceOauthInitAuth, + validators: { + query: z.object({ + loginUrl: z.string().min(1), + }), + hasSourceOrg: false, + }, + }, + salesforceOauthCallback: { + controllerFn: () => salesforceOauthCallback, + validators: { + query: z.record(z.any()), + hasSourceOrg: false, + }, + }, +}; + /** * Prepare SFDC auth and redirect to Salesforce * @param req * @param res */ -export function salesforceOauthInitAuth(req: express.Request, res: express.Response) { - const loginUrl = req.query.loginUrl as string; - const clientUrl = req.query.clientUrl as string; - const state = new URLSearchParams({ loginUrl, clientUrl }).toString(); - - let options = { - scope: 'api web refresh_token', - state, - prompt: 'login', - }; - - if (req.query.username) { - options = Object.assign(options, { login_hint: req.query.username }); - } - - res.redirect(getJsforceOauth2(loginUrl).getAuthorizationUrl(options)); -} +const salesforceOauthInitAuth = createRoute(routeDefinition.salesforceOauthInitAuth.validators, async ({ query }, req, res, next) => { + const loginUrl = query.loginUrl; + const { authorizationUrl, code_verifier, nonce, state } = oauthService.salesforceOauthInit(loginUrl); + req.session.orgAuth = { code_verifier, nonce, state, loginUrl }; + res.redirect(authorizationUrl); +}); /** * Prepare SFDC auth and redirect to Salesforce * @param req * @param res */ -export async function salesforceOauthCallback(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; - const state = new URLSearchParams(req.query.state as string); - const loginUrl = state.get('loginUrl'); - const clientUrl = state.get('clientUrl') || new URL(ENV.JETSTREAM_CLIENT_URL!).origin; +const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallback.validators, async ({ query, user }, req, res, next) => { + const queryParams = query as CallbackParamsType; + const clientUrl = new URL(ENV.JETSTREAM_CLIENT_URL!).origin; const returnParams: OauthLinkParams = { type: 'salesforce', clientUrl, }; try { + const orgAuth = req.session.orgAuth; + req.session.orgAuth = undefined; + // ERROR PATH - if (req.query.error) { - returnParams.error = (req.query.error as string) || 'Unexpected Error'; - returnParams.message = req.query.error_description - ? (req.query.error_description as string) + if (queryParams.error) { + returnParams.error = (queryParams.error as string) || 'Unexpected Error'; + returnParams.message = queryParams.error_description + ? (queryParams.error_description as string) : 'There was an error authenticating with Salesforce.'; - logger.info('[OAUTH][ERROR] %s', req.query.error, { ...req.query, requestId: res.locals.requestId }); - return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString()}`); + req.log.info({ ...query, requestId: res.locals.requestId }, '[OAUTH][ERROR] %s', queryParams.error); + return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`); + } else if (!orgAuth) { + returnParams.error = 'Authentication Error'; + returnParams.message = queryParams.error_description + ? (queryParams.error_description as string) + : 'There was an error authenticating with Salesforce.'; + req.log.info({ ...query, requestId: res.locals.requestId }, '[OAUTH][ERROR] %s', queryParams.error); + return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`); } - const conn = new jsforce.Connection({ oauth2: getJsforceOauth2(loginUrl as string) }); - const userInfo = await conn.authorize(req.query.code as string); + const { code_verifier, nonce, state, loginUrl } = orgAuth; + + const { access_token, refresh_token, userInfo } = await oauthService.salesforceOauthCallback(loginUrl, query, { + code_verifier, + nonce, + state, + }); + + const jetstreamConn = new ApiConnection({ + apiRequestAdapter: getApiRequestFactoryFn(fetch), + userId: userInfo.user_id, + organizationId: userInfo.organization_id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + accessToken: access_token!, + apiVersion: ENV.SFDC_API_VERSION, + instanceUrl: userInfo.urls.custom_domain || loginUrl, + refreshToken: refresh_token, + logging: ENV.LOG_LEVEL === 'trace', + }); const salesforceOrg = await initConnectionFromOAuthResponse({ - conn, - userInfo, - loginUrl: loginUrl as string, + jetstreamConn, userId: user.id, }); - // TODO: figure out what other data we need - // try { - // TODO: what about if a user is assigned a permission set that gives PermissionsModifyAllData? - // const data = await getExtendedOrgInfo(conn, returnObject); - // returnObject = Object.assign({}, returnObject, data); - // } catch (ex) { - // logger.log('Error adding extended org data'); - // } - returnParams.data = JSON.stringify(salesforceOrg); - return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString()}`); + return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`); } catch (ex) { - const userInfo = req.user ? { username: (req.user as any)?.displayName, userId: (req.user as any)?.user_id } : undefined; - logger.info('[OAUTH][ERROR] %o', ex.message, { userInfo, requestId: res.locals.requestId }); + req.log.info({ ...getExceptionLog(ex) }, '[OAUTH][ERROR]'); returnParams.error = ex.message || 'Unexpected Error'; - returnParams.message = req.query.error_description - ? (req.query.error_description as string) + returnParams.message = query.error_description + ? (query.error_description as string) : 'There was an error authenticating with Salesforce.'; - return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString()}`); + return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`); } -} +}); -export async function initConnectionFromOAuthResponse({ - conn, - userInfo, - loginUrl, - userId, -}: { - conn: jsforce.Connection; - userInfo: jsforce.UserInfo; - loginUrl: string; - userId: string; -}) { - const identity = await conn.identity(); +export async function initConnectionFromOAuthResponse({ jetstreamConn, userId }: { jetstreamConn: ApiConnection; userId: string }) { + const identity = await jetstreamConn.org.identity(); let companyInfoRecord: SObjectOrganization | undefined; try { - const results = await conn.query( + const { queryResults: results } = await jetstreamConn.query.query( `SELECT Id, Name, Country, OrganizationType, InstanceName, IsSandbox, LanguageLocaleKey, NamespacePrefix, TrialExpirationDate FROM Organization` ); if (results.totalSize > 0) { companyInfoRecord = results.records[0]; } } catch (ex) { - logger.warn('Error getting org info %o', ex); + logger.warn({ userId, ...getExceptionLog(ex) }, 'Error getting org info %o', ex); } const orgName = companyInfoRecord?.Name || 'Unknown Organization'; const salesforceOrgUi: Partial = { - uniqueId: `${userInfo.organizationId}-${userInfo.id}`, + uniqueId: `${jetstreamConn.sessionInfo.organizationId}-${jetstreamConn.sessionInfo.userId}`, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accessToken: salesforceOrgsDb.encryptAccessToken(conn.accessToken, conn.refreshToken!), - instanceUrl: conn.instanceUrl, - loginUrl, + accessToken: salesforceOrgsDb.encryptAccessToken(jetstreamConn.sessionInfo.accessToken, jetstreamConn.sessionInfo.refreshToken!), + instanceUrl: jetstreamConn.sessionInfo.instanceUrl, + loginUrl: jetstreamConn.sessionInfo.instanceUrl, userId: identity.user_id, email: identity.email, organizationId: identity.organization_id, diff --git a/apps/api/src/app/controllers/orgs.controller.ts b/apps/api/src/app/controllers/orgs.controller.ts index c0915ce85..a4070dc98 100644 --- a/apps/api/src/app/controllers/orgs.controller.ts +++ b/apps/api/src/app/controllers/orgs.controller.ts @@ -1,67 +1,90 @@ -import { logger } from '@jetstream/api-config'; +import { getExceptionLog } from '@jetstream/api-config'; import { ERROR_MESSAGES } from '@jetstream/shared/constants'; -import { UserProfileServer } from '@jetstream/types'; -import { SalesforceOrg } from '@prisma/client'; -import { NextFunction, Request, Response } from 'express'; -import * as jsforce from 'jsforce'; +import { z } from 'zod'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; -export async function getOrgs(req: Request, res: Response, next: NextFunction) { +export const routeDefinition = { + getOrgs: { + controllerFn: () => getOrgs, + validators: { hasSourceOrg: false }, + }, + updateOrg: { + controllerFn: () => updateOrg, + validators: { + params: z.object({ + uniqueId: z.string().min(1), + }), + body: z.object({ + label: z.string(), + color: z.string().optional(), + }), + hasSourceOrg: false, + }, + }, + deleteOrg: { + controllerFn: () => deleteOrg, + validators: { + params: z.object({ + uniqueId: z.string().min(1), + }), + hasSourceOrg: false, + }, + }, + checkOrgHealth: { + controllerFn: () => checkOrgHealth, + validators: {}, + }, +}; + +const getOrgs = createRoute(routeDefinition.getOrgs.validators, async ({ user }, req, res, next) => { try { - const user = req.user as UserProfileServer; const orgs = await salesforceOrgsDb.findByUserId(user.id); sendJson(res, orgs); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function updateOrg(req: Request, res: Response, next: NextFunction) { +const updateOrg = createRoute(routeDefinition.updateOrg.validators, async ({ body, params, user }, req, res, next) => { try { - const user = req.user as UserProfileServer; - - const data = { label: req.body.label, color: req.body.color }; - const salesforceOrg = await salesforceOrgsDb.updateSalesforceOrg(user.id, req.params.uniqueId, data); + const data = { label: body.label, color: body.color }; + const salesforceOrg = await salesforceOrgsDb.updateSalesforceOrg(user.id, params.uniqueId, data); sendJson(res, salesforceOrg, 201); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function deleteOrg(req: Request, res: Response, next: NextFunction) { +const deleteOrg = createRoute(routeDefinition.deleteOrg.validators, async ({ params, user }, req, res, next) => { try { - const user = req.user as UserProfileServer; - salesforceOrgsDb.deleteSalesforceOrg(user.id, req.params.uniqueId); + salesforceOrgsDb.deleteSalesforceOrg(user.id, params.uniqueId); sendJson(res, undefined, 204); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); /** * Check if the org is still valid * This can be used to retry an org that has been marked as invalid */ -export async function checkOrgHealth(req: Request, res: Response, next: NextFunction) { +const checkOrgHealth = createRoute(routeDefinition.checkOrgHealth.validators, async ({ jetstreamConn, org }, req, res, next) => { try { - const userInfo = req.user ? { username: (req.user as any)?.displayName, userId: (req.user as any)?.user_id } : undefined; - const conn: jsforce.Connection = res.locals.jsforceConn; - const org = res.locals.org as SalesforceOrg; - let connectionError = org.connectionError; try { - await conn.identity(); + await jetstreamConn.org.identity(); connectionError = null; - logger.warn('[ORG CHECK][VALID ORG]', { requestId: res.locals.requestId }); + req.log.warn('[ORG CHECK][VALID ORG]'); } catch (ex) { connectionError = ERROR_MESSAGES.SFDC_EXPIRED_TOKEN; - logger.warn('[ORG CHECK][INVALID ORG] %s', ex.message, { requestId: res.locals.requestId }); + req.log.warn(getExceptionLog(ex), '[ORG CHECK][INVALID ORG] %s', ex.message); } try { @@ -69,7 +92,7 @@ export async function checkOrgHealth(req: Request, res: Response, next: NextFunc await salesforceOrgsDb.updateOrg_UNSAFE(org, { connectionError }); } } catch (ex) { - logger.warn('[ERROR UPDATING INVALID ORG] %s', ex.message, { error: ex.message, userInfo, requestId: res.locals.requestId }); + req.log.warn({ orgId: org?.id, ...getExceptionLog(ex) }, '[ERROR UPDATING INVALID ORG] %s', ex.message); } if (connectionError) { @@ -80,4 +103,4 @@ export async function checkOrgHealth(req: Request, res: Response, next: NextFunc } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); diff --git a/apps/api/src/app/controllers/salesforce-api-requests.controller.ts b/apps/api/src/app/controllers/salesforce-api-requests.controller.ts index 8f74cbc4b..e1a8fa150 100644 --- a/apps/api/src/app/controllers/salesforce-api-requests.controller.ts +++ b/apps/api/src/app/controllers/salesforce-api-requests.controller.ts @@ -1,12 +1,20 @@ -import { Request, Response } from 'express'; import * as salesforceApiDb from '../db/salesforce-api.db'; +import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; -export const routeValidators = { - getSalesforceApiRequests: [], +export const routeDefinition = { + getSalesforceApiRequests: { + controllerFn: () => getSalesforceApiRequests, + validators: { hasSourceOrg: false }, + }, }; -export async function getSalesforceApiRequests(req: Request, res: Response) { - const results = await salesforceApiDb.findAll(); - sendJson(res, results); -} +const getSalesforceApiRequests = createRoute(routeDefinition.getSalesforceApiRequests.validators, async (_, req, res, next) => { + try { + const results = await salesforceApiDb.findAll(); + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); diff --git a/apps/api/src/app/controllers/sf-bulk-api.controller.ts b/apps/api/src/app/controllers/sf-bulk-api.controller.ts index 15cd05b33..b3e0a0d54 100644 --- a/apps/api/src/app/controllers/sf-bulk-api.controller.ts +++ b/apps/api/src/app/controllers/sf-bulk-api.controller.ts @@ -1,217 +1,250 @@ -import { logger } from '@jetstream/api-config'; -import * as services from '@jetstream/server-services'; +import { getExceptionLog, logger } from '@jetstream/api-config'; +import { BooleanQueryParamSchema, CreateJobRequestSchema } from '@jetstream/api-types'; import { HTTP } from '@jetstream/shared/constants'; import { ensureBoolean, toBoolean } from '@jetstream/shared/utils'; -import { BulkApiCreateJobRequestPayload, BulkApiDownloadType, BulkJobBatchInfo } from '@jetstream/types'; -import { NextFunction, Request, Response } from 'express'; -import { body, param, query } from 'express-validator'; -import * as jsforce from 'jsforce'; import { NODE_STREAM_INPUT, parse as parseCsv } from 'papaparse'; -import { sfBulkDownloadRecords, sfBulkGetQueryResultsJobIds } from '../services/sf-bulk'; +import { Readable } from 'stream'; +import { z } from 'zod'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; - -export const routeValidators = { - createJob: [ - body('type').isIn(['INSERT', 'UPDATE', 'UPSERT', 'DELETE', 'QUERY', 'QUERY_ALL']), - body('sObject').isString(), - body('serialMode').optional().isBoolean(), - body('externalIdFieldName').optional().isString(), - body('externalId') - .if(body('type').isIn(['UPSERT'])) - .isString(), - ], - getJob: [param('jobId').isString()], - closeJob: [param('jobId').isString()], - downloadResults: [ - param('jobId').isString(), - param('batchId').isString(), - query('type').isIn(['request', 'result']), - query('isQuery').optional().isBoolean(), - ], - downloadResultsFile: [ - param('jobId').isString(), - param('batchId').isString(), - query('type').isIn(['request', 'result']), - query('isQuery').optional().isBoolean(), - query('fileName').optional().isString(), - ], - addBatchToJob: [param('jobId').isString(), body().exists({ checkNull: true }), query('closeJob').optional().toBoolean()], - addBatchToJobWithBinaryAttachment: [ - param('jobId').isString(), - body().exists({ checkNull: true }), - query('closeJob').optional().toBoolean(), - ], +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + createJob: { + controllerFn: () => createJob, + validators: { + body: CreateJobRequestSchema, + }, + }, + getJob: { + controllerFn: () => getJob, + validators: { + params: z.object({ jobId: z.string().min(1) }), + }, + }, + closeOrAbortJob: { + controllerFn: () => closeOrAbortJob, + validators: { + params: z.object({ jobId: z.string().min(1), action: z.enum(['close', 'abort']).optional() }), + }, + }, + downloadResults: { + controllerFn: () => downloadResults, + validators: { + params: z.object({ + jobId: z.string().min(1), + batchId: z.string().min(1), + }), + query: z.object({ + type: z.enum(['request', 'result']), + isQuery: BooleanQueryParamSchema, + }), + }, + }, + downloadResultsFile: { + controllerFn: () => downloadResultsFile, + validators: { + params: z.object({ + jobId: z.string().min(1), + batchId: z.string().min(1), + }), + query: z.object({ + type: z.enum(['request', 'result']), + isQuery: BooleanQueryParamSchema, + fileName: z.string().optional(), + }), + }, + }, + addBatchToJob: { + controllerFn: () => addBatchToJob, + validators: { + params: z.object({ jobId: z.string().min(1) }), + body: z.any(), + query: z.object({ + closeJob: BooleanQueryParamSchema, + }), + }, + }, + addBatchToJobWithBinaryAttachment: { + controllerFn: () => addBatchToJobWithBinaryAttachment, + validators: { + params: z.object({ jobId: z.string().min(1), batchId: z.string().min(1) }), + body: z.any(), + query: z.object({ + closeJob: BooleanQueryParamSchema, + }), + }, + }, }; // https://github.com/jsforce/jsforce/issues/934 -export async function createJob(req: Request, res: Response, next: NextFunction) { +const createJob = createRoute(routeDefinition.createJob.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - const options = req.body as BulkApiCreateJobRequestPayload; - const conn: jsforce.Connection = res.locals.jsforceConn; + const options = body; - const results = await services.sfBulkCreateJob(conn, options); + const results = await jetstreamConn.bulk.createJob(options); sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function getJob(req: Request, res: Response, next: NextFunction) { +const getJob = createRoute(routeDefinition.getJob.validators, async ({ params, jetstreamConn }, req, res, next) => { try { - const jobId = req.params.jobId; - const conn: jsforce.Connection = res.locals.jsforceConn; + const jobId = params.jobId; - const jobInfo = await services.sfBulkGetJobInfo(conn, jobId); + const results = await jetstreamConn.bulk.getJob(jobId); - sendJson(res, jobInfo); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function closeOrAbortJob(req: Request, res: Response, next: NextFunction) { +const closeOrAbortJob = createRoute(routeDefinition.closeOrAbortJob.validators, async ({ params, jetstreamConn }, req, res, next) => { try { - const jobId = req.params.jobId; - const action: 'Closed' | 'Aborted' = req.params.action === 'abort' ? 'Aborted' : 'Closed'; - const conn: jsforce.Connection = res.locals.jsforceConn; + const jobId = params.jobId; + const action: 'Closed' | 'Aborted' = params.action === 'abort' ? 'Aborted' : 'Closed'; - const jobInfo = await services.sfBulkCloseOrAbortJob(conn, jobId, action); + const results = await jetstreamConn.bulk.closeJob(jobId, action); - sendJson(res, jobInfo); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} - -export async function addBatchToJob(req: Request, res: Response, next: NextFunction) { - try { - const jobId = req.params.jobId; - const csv = req.body; - const closeJob = req.query.closeJob as any; - const conn: jsforce.Connection = res.locals.jsforceConn; +}); - const results: BulkJobBatchInfo = await services.sfBulkAddBatchToJob(conn, csv, jobId, closeJob); +const addBatchToJob = createRoute( + routeDefinition.addBatchToJob.validators, + async ({ body, params, query, user, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + const csv = body; + const closeJob = query.closeJob; - // try { - // results = await sfBulkGetJobInfo(conn, jobId); - // } catch (ex) { - // // ignore error - // } + const results = await jetstreamConn.bulk.addBatchToJob(csv, jobId, closeJob); - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } } -} +); -export async function addBatchToJobWithBinaryAttachment(req: Request, res: Response, next: NextFunction) { - try { - const jobId = req.params.jobId; - const zip = req.body; - const closeJob = req.query.closeJob as any; - const conn: jsforce.Connection = res.locals.jsforceConn; +const addBatchToJobWithBinaryAttachment = createRoute( + routeDefinition.addBatchToJobWithBinaryAttachment.validators, + async ({ body, params, query, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + const zip = body; + const closeJob = query.closeJob; - const results: BulkJobBatchInfo = await services.sfBulkAddBatchWithZipAttachmentToJob(conn, zip, jobId, closeJob); + const results = await jetstreamConn.bulk.addBatchToJob(zip, jobId, closeJob, HTTP.CONTENT_TYPE.ZIP_CSV); - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } } -} +); /** * Download request or results as a CSV file directly streamed from SFDC * this should only be called from a link and not a JSON API call * - * This is not used AFAIK + * THIS IS USED BY BULK QUERY DOWNLOAD * */ -export async function downloadResultsFile(req: Request, res: Response, next: NextFunction) { - try { - const jobId = req.params.jobId; - const batchId = req.params.batchId; - const type = req.query.type as BulkApiDownloadType; - const isQuery = ensureBoolean(req.query.isQuery as string); - const fileName = req.query.fileName || `${type}.csv`; - const conn: jsforce.Connection = res.locals.jsforceConn; - - res.setHeader(HTTP.HEADERS.CONTENT_TYPE, HTTP.CONTENT_TYPE.CSV); - res.setHeader(HTTP.HEADERS.CONTENT_DISPOSITION, `attachment; filename="${fileName}"`); - - let resultId: string | undefined; +const downloadResultsFile = createRoute( + routeDefinition.downloadResultsFile.validators, + async ({ params, query, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + const batchId = params.batchId; + const type = query.type; + const isQuery = ensureBoolean(query.isQuery); + const fileName = query.fileName || `${type}.csv`; + + res.setHeader(HTTP.HEADERS.CONTENT_TYPE, HTTP.CONTENT_TYPE.CSV); + res.setHeader(HTTP.HEADERS.CONTENT_DISPOSITION, `attachment; filename="${fileName}"`); + + let resultId: string | undefined; + + if (isQuery) { + resultId = (await jetstreamConn.bulk.getQueryResultsJobIds(jobId, batchId))[0]; + } - if (isQuery) { - resultId = (await sfBulkGetQueryResultsJobIds(conn, jobId, batchId))[0]; + const results = await jetstreamConn.bulk.downloadRecords(jobId, batchId, type, resultId); + Readable.fromWeb(results as any).pipe(res); + } catch (ex) { + next(new UserFacingError(ex.message)); } - - sfBulkDownloadRecords(conn, jobId, batchId, type, resultId).buffer(false).pipe(res); - } catch (ex) { - next(new UserFacingError(ex.message)); } -} +); /** * Download requests or results as JSON, streamed from Salesforce as CSV, and transformed to JSON */ -export async function downloadResults(req: Request, res: Response, next: NextFunction) { - try { - const jobId = req.params.jobId; - const batchId = req.params.batchId; - const type = req.query.type as BulkApiDownloadType; - const isQuery = ensureBoolean(req.query.isQuery as string); - const conn: jsforce.Connection = res.locals.jsforceConn; - - const csvParseStream = parseCsv(NODE_STREAM_INPUT, { - delimiter: ',', - header: true, - skipEmptyLines: true, - transform: (data, field) => { - if (field === 'Success' || field === 'Created') { - return toBoolean(data); - } else if (field === 'Id' || field === 'Error') { - return data || null; - } - return data; - }, - }); - if (isQuery) { - const resultIds = await sfBulkGetQueryResultsJobIds(conn, jobId, batchId); - sfBulkDownloadRecords(conn, jobId, batchId, type, resultIds[0]).buffer(false).pipe(csvParseStream); - } else { - sfBulkDownloadRecords(conn, jobId, batchId, type).buffer(false).pipe(csvParseStream); - } - - let isFirstChunk = true; - - csvParseStream.on('data', (data) => { - data = JSON.stringify(data); - if (isFirstChunk) { - isFirstChunk = false; - data = `{"data":[${data}`; - } else { - data = `,${data}`; - } - res.write(data); - }); - csvParseStream.on('finish', () => { - res.write(']}'); - if (!res.headersSent) { - res.status(200).send(); +const downloadResults = createRoute( + routeDefinition.downloadResults.validators, + async ({ params, query, jetstreamConn, requestId }, req, res, next) => { + try { + const jobId = params.jobId; + const batchId = params.batchId; + const type = query.type; + const isQuery = ensureBoolean(query.isQuery); + + const csvParseStream = parseCsv(NODE_STREAM_INPUT, { + delimiter: ',', + header: true, + skipEmptyLines: true, + transform: (data, field) => { + if (field === 'Success' || field === 'Created') { + return toBoolean(data); + } else if (field === 'Id' || field === 'Error') { + return data || null; + } + return data; + }, + }); + + if (isQuery) { + const resultIds = await jetstreamConn.bulk.getQueryResultsJobIds(jobId, batchId); + const results = await jetstreamConn.bulk.downloadRecords(jobId, batchId, type, resultIds[0]); + Readable.fromWeb(results as any).pipe(csvParseStream); } else { - logger.warn('Response headers already sent. csvParseStream[finish]', { requestId: res.locals.requestId }); + const results = await jetstreamConn.bulk.downloadRecords(jobId, batchId, type); + Readable.fromWeb(results as any).pipe(csvParseStream); } - }); - csvParseStream.on('error', (err) => { - logger.warn('Error streaming files from Salesforce. %o', err, { requestId: res.locals.requestId }); - if (!res.headersSent) { - res.status(400).send(); - } else { - logger.warn('Response headers already sent. csvParseStream[error]', { requestId: res.locals.requestId }); - } - }); - } catch (ex) { - next(new UserFacingError(ex.message)); + + let isFirstChunk = true; + + csvParseStream.on('data', (data) => { + data = JSON.stringify(data); + if (isFirstChunk) { + isFirstChunk = false; + data = `{"data":[${data}`; + } else { + data = `,${data}`; + } + res.write(data); + }); + csvParseStream.on('finish', () => { + res.write(']}'); + res.end(); + logger.info({ requestId }, 'Finished streaming download from Salesforce'); + }); + csvParseStream.on('error', (err) => { + logger.warn({ requestId, ...getExceptionLog(err) }, 'Error streaming files from Salesforce.'); + if (!res.headersSent) { + res.status(400).json({ error: true, message: 'Error streaming files from Salesforce' }); + } else { + res.status(400).end(); + } + }); + } catch (ex) { + next(new UserFacingError(ex.message)); + } } -} +); diff --git a/apps/api/src/app/controllers/sf-bulk-query-20-api.controller.ts b/apps/api/src/app/controllers/sf-bulk-query-20-api.controller.ts new file mode 100644 index 000000000..27164ff03 --- /dev/null +++ b/apps/api/src/app/controllers/sf-bulk-query-20-api.controller.ts @@ -0,0 +1,147 @@ +import { BooleanQueryParamSchema, CreateQueryJobRequestSchema } from '@jetstream/api-types'; +import { z } from 'zod'; +import { UserFacingError } from '../utils/error-handler'; +import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + createJob: { + controllerFn: () => createJob, + validators: { + body: CreateQueryJobRequestSchema, + }, + }, + getJobs: { + controllerFn: () => getJobs, + validators: { + query: z.object({ + isPkChunkingEnabled: BooleanQueryParamSchema, + jobType: z.enum(['Classic', 'V2Query', 'V2Ingest']).optional(), + concurrencyMode: z.enum(['parallel']).optional(), + queryLocator: z.string().optional(), + }), + }, + }, + getJob: { + controllerFn: () => getJob, + validators: { + params: z.object({ jobId: z.string().min(1) }), + }, + }, + abortJob: { + controllerFn: () => abortJob, + validators: { + params: z.object({ jobId: z.string().min(1) }), + }, + }, + deleteJob: { + controllerFn: () => deleteJob, + validators: { + params: z.object({ jobId: z.string().min(1) }), + }, + }, + downloadResults: { + controllerFn: () => downloadResults, + validators: { + params: z.object({ jobId: z.string().min(1) }), + query: z.object({ + maxRecords: z + .string() + .optional() + .refine((val) => { + if (!val) { + return true; + } + return /[0-9]+/.test(val); + }, 'maxRecords must be an integer') + .transform((val) => (val ? parseInt(val, 10) : undefined)), + }), + }, + }, +}; + +const createJob = createRoute(routeDefinition.createJob.validators, async ({ body, jetstreamConn }, req, res, next) => { + try { + const { query, queryAll } = body; + + const results = await jetstreamConn.bulkQuery20.createJob(query, queryAll); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); + +const getJobs = createRoute(routeDefinition.getJobs.validators, async ({ query, jetstreamConn }, req, res, next) => { + try { + const options = query; + + const results = await jetstreamConn.bulkQuery20.getJobs(options); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); + +const getJob = createRoute(routeDefinition.getJob.validators, async ({ params, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + + const results = await jetstreamConn.bulkQuery20.getJob(jobId); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); + +const abortJob = createRoute(routeDefinition.abortJob.validators, async ({ params, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + + const results = await jetstreamConn.bulkQuery20.abortJob(jobId); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); + +const deleteJob = createRoute(routeDefinition.deleteJob.validators, async ({ params, jetstreamConn }, req, res, next) => { + try { + const jobId = params.jobId; + + await jetstreamConn.bulkQuery20.deleteJob(jobId); + + res.status(204).end(); + } catch (ex) { + next(new UserFacingError(ex.message)); + } +}); + +/** + * Stream CSV results to API caller + */ +const downloadResults = createRoute( + routeDefinition.downloadResults.validators, + async ({ params, query, jetstreamConn, requestId }, req, res, next) => { + try { + res.setHeader('Content-Type', 'text/csv'); + + const jobId = params.jobId; + const maxRecords = query.maxRecords; + + const resultsStream = jetstreamConn.bulkQuery20.getResultsStream(jobId, maxRecords); + + for await (const chunk of resultsStream) { + res.write(chunk); + } + + // End the response when the stream is complete + res.end(); + } catch (ex) { + next(new UserFacingError(ex.message)); + } + } +); diff --git a/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts b/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts index 9161b209f..b28aee5d0 100644 --- a/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts +++ b/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts @@ -1,449 +1,351 @@ -import { ENV, logger } from '@jetstream/api-config'; -import { HTTP, LOG_LEVELS, MIME_TYPES } from '@jetstream/shared/constants'; -import { ensureArray, getValueOrSoapNull, sanitizeForXml, splitArrayToMaxSize, toBoolean } from '@jetstream/shared/utils'; -import { AnonymousApexResponse, ApexCompletionResponse, ListMetadataResult, MapOf } from '@jetstream/types'; -import { NextFunction, Request, Response } from 'express'; -import { body, param, query } from 'express-validator'; -import type { DeployOptions, RetrieveRequest } from 'jsforce'; -import * as jsforce from 'jsforce'; -import JSZip from 'jszip'; -import { isObject, isString, toNumber } from 'lodash'; -import xml2js from 'xml2js'; +import { ENV } from '@jetstream/api-config'; import { - SalesforceRequestViaAxiosOptions, - buildPackageXml, - getRetrieveRequestFromListMetadata, - getRetrieveRequestFromManifest, - salesforceRequestViaAxios, -} from '../services/sf-misc'; + AnonymousApexSchema, + BooleanQueryParamSchema, + CheckRetrieveStatusAndRedeployRequestSchema, + DeployMetadataRequestSchema, + DeployOptionsSchema, + GetPackageXmlSchema, + ListMetadataRequestSchema, + ReadMetadataRequestSchema, + RetrievePackageFromExistingServerPackagesRequestSchema, + RetrievePackageFromLisMetadataResultsRequestSchema, +} from '@jetstream/api-types'; +import { RetrieveRequest } from '@jetstream/types'; +import JSZip from 'jszip'; +import { isString } from 'lodash'; +import { z } from 'zod'; +import { buildPackageXml, getRetrieveRequestFromListMetadata, getRetrieveRequestFromManifest } from '../services/salesforce.service'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; - -export const routeValidators = { - listMetadata: [body('types').isArray().isLength({ min: 1 })], - readMetadata: [body('fullNames').isArray().isLength({ min: 1 })], - deployMetadata: [body('files').isArray().isLength({ min: 1 })], - deployMetadataZip: [body().exists({ checkNull: true }), query('options').isJSON()], - checkMetadataResults: [param('id').isLength({ min: 15, max: 18 }), query('includeDetails').toBoolean()], - retrievePackageFromLisMetadataResults: [body().notEmpty(), body().not().isString(), body().not().isArray()], - retrievePackageFromExistingServerPackages: [body('packageNames').isArray().isLength({ min: 1 })], - retrievePackageFromManifest: [body('packageManifest').isString()], - checkRetrieveStatus: [param('id').isLength({ min: 15, max: 18 })], - checkRetrieveStatusAndRedeploy: [ - param('id').isLength({ min: 15, max: 18 }), - body('deployOptions').notEmpty(), - // TODO: make changesetName required if replacementPackageXml is specified - body('replacementPackageXml').optional().isString(), - body('changesetName').optional().isString(), - ], - getPackageXml: [ - body('metadata').notEmpty(), - body('metadata').not().isString(), - body('metadata').not().isArray(), - body('otherFields').optional().not().isArray(), - body('otherFields').optional().not().isString(), - ], - anonymousApex: [body('apex').isString().isLength({ min: 1 }), body('logLevel').optional().isString().isIn(LOG_LEVELS)], - apexCompletions: [param('type').isIn(['apex', 'visualforce'])], +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + describeMetadata: { + controllerFn: () => describeMetadata, + validators: {}, + }, + listMetadata: { + controllerFn: () => listMetadata, + validators: { + body: ListMetadataRequestSchema, + }, + }, + readMetadata: { + controllerFn: () => readMetadata, + validators: { + params: z.object({ type: z.string() }), + body: ReadMetadataRequestSchema, + }, + }, + deployMetadata: { + controllerFn: () => deployMetadata, + validators: { + body: DeployMetadataRequestSchema, + }, + }, + deployMetadataZip: { + controllerFn: () => deployMetadataZip, + validators: { + body: z.any(), + query: z.object({ options: z.string() }), + }, + }, + checkMetadataResults: { + controllerFn: () => checkMetadataResults, + validators: { + params: z.object({ id: z.string().min(15).max(18) }), + query: z.object({ includeDetails: BooleanQueryParamSchema }), + }, + }, + retrievePackageFromLisMetadataResults: { + controllerFn: () => retrievePackageFromLisMetadataResults, + validators: { + body: RetrievePackageFromLisMetadataResultsRequestSchema, + }, + }, + retrievePackageFromExistingServerPackages: { + controllerFn: () => retrievePackageFromExistingServerPackages, + validators: { + body: RetrievePackageFromExistingServerPackagesRequestSchema, + }, + }, + retrievePackageFromManifest: { + controllerFn: () => retrievePackageFromManifest, + validators: { + body: z.object({ packageManifest: z.string() }), + }, + }, + checkRetrieveStatus: { + controllerFn: () => checkRetrieveStatus, + validators: { + query: z.object({ id: z.string().min(15).max(18) }), + }, + }, + checkRetrieveStatusAndRedeploy: { + controllerFn: () => checkRetrieveStatusAndRedeploy, + validators: { + hasTargetOrg: true, + query: z.object({ id: z.string().min(15).max(18) }), + body: CheckRetrieveStatusAndRedeployRequestSchema, + }, + }, + getPackageXml: { + controllerFn: () => getPackageXml, + validators: { + body: GetPackageXmlSchema, + }, + }, + anonymousApex: { + controllerFn: () => anonymousApex, + validators: { + body: AnonymousApexSchema, + }, + }, + apexCompletions: { + controllerFn: () => apexCompletions, + validators: { + params: z.object({ + type: z.enum(['apex', 'visualforce']), + }), + }, + }, }; -export function correctInvalidArrayXmlResponseTypes(item: T[]): T[] { - if (!Array.isArray(item)) { - if (item) { - item = [item] as any; - } else { - return []; // null response - } - } - return item.map(correctInvalidXmlResponseTypes); -} - -export function correctInvalidXmlResponseTypes(item: T): T { - // TODO: what about number types? - Object.keys(item!).forEach((key) => { - if (isString(item[key]) && (item[key] === 'true' || item[key] === 'false')) { - item[key] = item[key] === 'true'; - } else if (!Array.isArray(item[key]) && isObject(item[key]) && item[key]['$']) { - // {$: {"xsi:nil": true}} - item[key] = null; - } - }); - return item; -} - -export async function describeMetadata(req: Request, res: Response, next: NextFunction) { +// export async function describeMetadata(req: Request, res: Response, next: NextFunction) { +const describeMetadata = createRoute(routeDefinition.describeMetadata.validators, async ({ jetstreamConn }, req, res, next) => { try { - const conn: jsforce.Connection = res.locals.jsforceConn; - const response = await conn.metadata.describe(); + const results = await jetstreamConn.metadata.describe(); - sendJson(res, response); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function listMetadata(req: Request, res: Response, next: NextFunction) { +const listMetadata = createRoute(routeDefinition.listMetadata.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - // for some types, if folder is null then no data will be returned - const types: { type: string; folder?: string }[] = req.body.types.map(({ type, folder }) => { - if (folder) { - return { type, folder }; - } - return { type }; - }); - const conn: jsforce.Connection = res.locals.jsforceConn; - const response = await conn.metadata.list(types); + const results = await jetstreamConn.metadata.list(body.types); - sendJson(res, correctInvalidArrayXmlResponseTypes(response)); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function readMetadata(req: Request, res: Response, next: NextFunction) { +const readMetadata = createRoute(routeDefinition.readMetadata.validators, async ({ body, params, jetstreamConn }, req, res, next) => { try { - const fullNames: string[] = req.body.fullNames; - const metadataType = req.params.type; - const conn: jsforce.Connection = res.locals.jsforceConn; + const fullNames = body.fullNames; + const metadataType = params.type; - const results = await ( - await Promise.all(splitArrayToMaxSize(fullNames, 10).map((fullNames) => conn.metadata.read(metadataType, fullNames))) - ).flat(); + const results = await jetstreamConn.metadata.read(metadataType, fullNames); sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function deployMetadata(req: Request, res: Response, next: NextFunction) { +const deployMetadata = createRoute(routeDefinition.deployMetadata.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - const conn: jsforce.Connection = res.locals.jsforceConn; - const files: { fullFilename: string; content: string }[] = req.body.files; - - const zip = new JSZip(); - files.forEach((file) => zip.file(file.fullFilename, file.content)); + const files = body.files; + const options = body.options; - const results = await conn.metadata.deploy(zip.generateNodeStream() as any, req.body.options); + const results = await jetstreamConn.metadata.deployMetadata(files, options); sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function deployMetadataZip(req: Request, res: Response, next: NextFunction) { - try { - const conn: jsforce.Connection = res.locals.jsforceConn; - const metadataPackage = req.body; // buffer - // this is validated as valid JSON previously - const options = JSON.parse(req.query.options as string); +const deployMetadataZip = createRoute( + routeDefinition.deployMetadataZip.validators, + async ({ body, query, jetstreamConn }, req, res, next) => { + try { + const metadataPackage = body; // buffer + // this is validated as valid JSON previously + const options = DeployOptionsSchema.parse(JSON.parse(query.options)); - const results = await conn.metadata.deploy(metadataPackage, options); + const results = await jetstreamConn.metadata.deploy(metadataPackage, options); - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } } -} +); -export async function checkMetadataResults(req: Request, res: Response, next: NextFunction) { - try { - const conn: jsforce.Connection = res.locals.jsforceConn; - const id = req.params.id; - const includeDetails: boolean = req.query.includeDetails as any; // express validator conversion +const checkMetadataResults = createRoute( + routeDefinition.checkMetadataResults.validators, + async ({ params, query, jetstreamConn }, req, res, next) => { + try { + const id = params.id; + const includeDetails = query.includeDetails; - // JSForce has invalid types, and XML is poorly formatted - let results = (await conn.metadata.checkDeployStatus(id, includeDetails)) as any; + const results = await jetstreamConn.metadata.checkDeployStatus(id, includeDetails); - try { - if (results) { - results = correctInvalidXmlResponseTypes(results); - } - if (results.details) { - results.details.componentFailures = ensureArray(results.details.componentFailures).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.componentSuccesses = ensureArray(results.details.componentSuccesses).map((item) => - correctInvalidXmlResponseTypes(item) - ); - - if (results.details.runTestResult) { - results.details.runTestResult.numFailures = Number.parseInt(results.details.runTestResult.numFailures); - results.details.runTestResult.numTestsRun = Number.parseInt(results.details.runTestResult.numFailures); - results.details.runTestResult.totalTime = Number.parseFloat(results.details.runTestResult.numFailures); - - results.details.runTestResult.codeCoverage = ensureArray(results.details.runTestResult.codeCoverage).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.runTestResult.codeCoverageWarnings = ensureArray(results.details.runTestResult.codeCoverageWarnings).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.runTestResult.failures = ensureArray(results.details.runTestResult.failures).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.runTestResult.flowCoverage = ensureArray(results.details.runTestResult.flowCoverage).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.runTestResult.flowCoverageWarnings = ensureArray(results.details.runTestResult.flowCoverageWarnings).map((item) => - correctInvalidXmlResponseTypes(item) - ); - results.details.runTestResult.successes = ensureArray(results.details.runTestResult.successes).map((item) => - correctInvalidXmlResponseTypes(item) - ); - } - } + sendJson(res, results); } catch (ex) { - logger.warn('Error converting checkDeployStatus results'); + next(new UserFacingError(ex.message)); } - - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); } -} +); -export async function retrievePackageFromLisMetadataResults(req: Request, res: Response, next: NextFunction) { - try { - const types: MapOf = req.body; - const conn: jsforce.Connection = res.locals.jsforceConn; +const retrievePackageFromLisMetadataResults = createRoute( + routeDefinition.retrievePackageFromLisMetadataResults.validators, + async ({ body, jetstreamConn }, req, res, next) => { + try { + const types = body; - const results = await conn.metadata.retrieve(getRetrieveRequestFromListMetadata(types, conn.version)); + const results = await jetstreamConn.metadata.retrieve( + getRetrieveRequestFromListMetadata(types, jetstreamConn.sessionInfo.apiVersion) + ); - sendJson(res, correctInvalidXmlResponseTypes(results)); - } catch (ex) { - next(ex); + sendJson(res, results); + } catch (ex) { + next(ex); + } } -} +); -export async function retrievePackageFromExistingServerPackages(req: Request, res: Response, next: NextFunction) { - try { - const packageNames: string[] = req.body.packageNames; - const conn: jsforce.Connection = res.locals.jsforceConn; +const retrievePackageFromExistingServerPackages = createRoute( + routeDefinition.retrievePackageFromExistingServerPackages.validators, + async ({ body, jetstreamConn }, req, res, next) => { + try { + const packageNames = body.packageNames; - const retrieveRequest: RetrieveRequest = { - apiVersion: conn.version, - packageNames, - singlePackage: false, - }; + const retrieveRequest: RetrieveRequest = { + apiVersion: jetstreamConn.sessionInfo.apiVersion, + packageNames, + singlePackage: false, + }; - const results = await conn.metadata.retrieve(retrieveRequest); + const results = await jetstreamConn.metadata.retrieve(retrieveRequest); - sendJson(res, correctInvalidXmlResponseTypes(results)); - } catch (ex) { - next(ex); + sendJson(res, results); + } catch (ex) { + next(ex); + } } -} - -export async function retrievePackageFromManifest(req: Request, res: Response, next: NextFunction) { - try { - const packageManifest: string = req.body.packageManifest; - const conn: jsforce.Connection = res.locals.jsforceConn; +); - const results = await conn.metadata.retrieve(getRetrieveRequestFromManifest(packageManifest)); +const retrievePackageFromManifest = createRoute( + routeDefinition.retrievePackageFromManifest.validators, + async ({ body, jetstreamConn }, req, res, next) => { + try { + const packageManifest = body.packageManifest; + const results = await jetstreamConn.metadata.retrieve(getRetrieveRequestFromManifest(packageManifest)); - sendJson(res, correctInvalidXmlResponseTypes(results)); - } catch (ex) { - next(ex); + sendJson(res, results); + } catch (ex) { + next(ex); + } } -} +); -export async function checkRetrieveStatus(req: Request, res: Response, next: NextFunction) { - try { - const id: string = req.query.id as string; - const conn: jsforce.Connection = res.locals.jsforceConn; +const checkRetrieveStatus = createRoute( + routeDefinition.checkRetrieveStatus.validators, + async ({ query, jetstreamConn }, req, res, next) => { + try { + const id: string = query.id; - const results = await conn.metadata.checkRetrieveStatus(id); - results.fileProperties = ensureArray(results.fileProperties); - results.messages = ensureArray(results.messages); - sendJson(res, correctInvalidXmlResponseTypes(results)); - } catch (ex) { - next(ex); + const results = await jetstreamConn.metadata.checkRetrieveStatus(id); + + sendJson(res, results); + } catch (ex) { + next(ex); + } } -} +); -// TODO: split this into one option to deploy to a changeset -// TODO: get from new shared service (code copied over) -// and another to deploy as-is -export async function checkRetrieveStatusAndRedeploy(req: Request, res: Response, next: NextFunction) { - try { - const id: string = req.query.id as string; - const deployOptions: DeployOptions = req.body.deployOptions; - const replacementPackageXml: string = req.body.replacementPackageXml; - const changesetName: string = req.body.changesetName; - const conn: jsforce.Connection = res.locals.jsforceConn; - const targetConn: jsforce.Connection = res.locals.targetJsforceConn; - const results = correctInvalidXmlResponseTypes(await conn.metadata.checkRetrieveStatus(id)); - - if (isString(results.zipFile)) { - const oldPackage = await JSZip.loadAsync(results.zipFile, { base64: true }); - // create a new zip in the correct structure to add to changeset - if (replacementPackageXml && changesetName) { - const newPackage = JSZip(); - newPackage - .folder('unpackaged') - ?.file( - 'package.xml', - `\n\n\t${ - conn.version || ENV.SFDC_API_VERSION - }\n` +const checkRetrieveStatusAndRedeploy = createRoute( + routeDefinition.checkRetrieveStatusAndRedeploy.validators, + async ({ body, query, jetstreamConn, targetJetstreamConn }, req, res, next) => { + try { + const id = query.id; + const deployOptions = body.deployOptions; + const replacementPackageXml = body.replacementPackageXml; + const changesetName = body.changesetName; + + // const results = correctInvalidXmlResponseTypes(await conn.metadata.checkRetrieveStatus(id)); + const results = await jetstreamConn.metadata.checkRetrieveStatus(id); + + if (isString(results.zipFile)) { + // create a new zip in the correct structure to add to changeset + if (replacementPackageXml && changesetName) { + const oldPackage = await JSZip.loadAsync(results.zipFile, { base64: true }); + const newPackage = JSZip(); + newPackage + .folder('unpackaged') + ?.file( + 'package.xml', + `\n\n\t${ + jetstreamConn.sessionInfo.apiVersion || ENV.SFDC_API_VERSION + }\n` + ); + + oldPackage.forEach((relativePath, file) => { + if (file.name === 'package.xml') { + newPackage.folder(changesetName)?.file(relativePath, replacementPackageXml); + } else if (!file.dir) { + newPackage.folder(changesetName)?.file(relativePath, file.async('uint8array'), { binary: true }); + } + }); + const deployResults = await targetJetstreamConn.metadata.deploy( + await newPackage.generateAsync({ type: 'base64', compression: 'STORE', mimeType: 'application/zip', platform: 'UNIX' }), + deployOptions ); - - oldPackage.forEach((relativePath, file) => { - if (file.name === 'package.xml') { - newPackage.folder(changesetName)?.file(relativePath, replacementPackageXml); - } else if (!file.dir) { - newPackage.folder(changesetName)?.file(relativePath, file.async('uint8array'), { binary: true }); - } - }); - const deployResults = await targetConn.metadata.deploy( - await newPackage.generateAsync({ type: 'base64', compression: 'STORE', mimeType: 'application/zip', platform: 'UNIX' }), - deployOptions - ); - sendJson(res, { type: 'deploy', results: correctInvalidXmlResponseTypes(deployResults), zipFile: results.zipFile }); + sendJson(res, { type: 'deploy', results: deployResults, zipFile: results.zipFile }); + } else { + // Deploy package as-is + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deployResults = await targetJetstreamConn.metadata.deploy(results.zipFile!, deployOptions); + sendJson(res, { type: 'deploy', results: deployResults, zipFile: results.zipFile }); + } } else { - // Deploy package as-is - const deployResults = await targetConn.metadata.deploy(oldPackage.generateNodeStream() as any, deployOptions); - sendJson(res, { type: 'deploy', results: correctInvalidXmlResponseTypes(deployResults), zipFile: results.zipFile }); + sendJson(res, { type: 'retrieve', results }); } - } else { - sendJson(res, { type: 'retrieve', results }); + } catch (ex) { + next(ex); } - } catch (ex) { - next(ex); } -} +); -export async function getPackageXml(req: Request, res: Response, next: NextFunction) { +const getPackageXml = createRoute(routeDefinition.getPackageXml.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - const types: MapOf = req.body.metadata; - const otherFields: MapOf = req.body.otherFields; - const conn: jsforce.Connection = res.locals.jsforceConn; + const types = body.metadata; + const otherFields = body.otherFields; - sendJson(res, buildPackageXml(types, conn.version, otherFields)); + sendJson(res, buildPackageXml(types, jetstreamConn.sessionInfo.apiVersion, otherFields)); } catch (ex) { next(ex); } -} +}); -// TODO: use from shared service /** * This uses the SOAP api to allow returning logs */ -export async function anonymousApex(req: Request, res: Response, next: NextFunction) { +const anonymousApex = createRoute(routeDefinition.anonymousApex.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - // eslint-disable-next-line prefer-const - let { apex, logLevel }: { apex: string; logLevel?: string } = req.body; - logLevel = logLevel || 'FINEST'; - const conn: jsforce.Connection = res.locals.jsforceConn; - const requestOptions: SalesforceRequestViaAxiosOptions = { - conn, - method: 'POST', - url: `${conn.instanceUrl}/services/Soap/s/${conn.version}`, - headers: { - [HTTP.HEADERS.CONTENT_TYPE]: MIME_TYPES.XML, - [HTTP.HEADERS.ACCEPT]: MIME_TYPES.XML, - SOAPAction: '""', - }, - body: ` - - - jetstream - - - - Db - ${logLevel} - - - Workflow - ${logLevel} - - - Validation - ${logLevel} - - - Callout - ${logLevel} - - - Apex_code - ${logLevel} - - - Apex_profiling - ${logLevel} - - - Visualforce - ${logLevel} - - - System - ${logLevel} - - - All - ${logLevel} - - - - {sessionId} - - - - - ${sanitizeForXml(apex)} - - -`, - }; - - const response = await salesforceRequestViaAxios(requestOptions); - if (!response.error) { - const soapResponse = await xml2js.parseStringPromise(response.body, { explicitArray: false }); - const header = soapResponse['soapenv:Envelope']['soapenv:Header']; - const body = soapResponse['soapenv:Envelope']?.['soapenv:Body']?.executeAnonymousResponse?.result; - const results: AnonymousApexResponse = { - debugLog: header?.DebuggingInfo?.debugLog || '', - result: { - column: toNumber(getValueOrSoapNull(body.column) || -1), - compileProblem: getValueOrSoapNull(body.compileProblem) || null, - compiled: toBoolean(getValueOrSoapNull(body.compiled)) || false, - exceptionMessage: getValueOrSoapNull(body.exceptionMessage) || null, - exceptionStackTrace: getValueOrSoapNull(body.exceptionStackTrace) || null, - line: toNumber(getValueOrSoapNull(body.line)) || -1, - success: toBoolean(getValueOrSoapNull(body.success)) || false, - }, - }; - sendJson(res, results); - } else { - next(new UserFacingError(response.errorMessage)); - } + const { apex, logLevel } = body; + + const results = await jetstreamConn.apex.anonymousApex({ apex, logLevel }); + + sendJson(res, results); } catch (ex) { next(ex); } -} +}); -export async function apexCompletions(req: Request, res: Response, next: NextFunction) { +const apexCompletions = createRoute(routeDefinition.apexCompletions.validators, async ({ params, jetstreamConn }, req, res, next) => { try { - const type = req.params.type; - const conn: jsforce.Connection = res.locals.jsforceConn; - const requestOptions: jsforce.RequestInfo = { - method: 'GET', - url: `${conn.instanceUrl}/services/data/v${conn.version}/tooling/completions?type=${type}`, - headers: { - [HTTP.HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON, - [HTTP.HEADERS.ACCEPT]: MIME_TYPES.JSON, - }, - }; - - logger.info('Apex Completion %s', requestOptions.url, { requestId: res.locals.requestId }); - const completions = await conn.request(requestOptions); - - sendJson(res, completions); + const type = params.type; + + const results = await jetstreamConn.apex.apexCompletions(type); + + sendJson(res, results); } catch (ex) { next(ex); } -} +}); diff --git a/apps/api/src/app/controllers/sf-misc.controller.ts b/apps/api/src/app/controllers/sf-misc.controller.ts index 2edb7e45c..c0674915f 100644 --- a/apps/api/src/app/controllers/sf-misc.controller.ts +++ b/apps/api/src/app/controllers/sf-misc.controller.ts @@ -1,181 +1,109 @@ -import { toBoolean } from '@jetstream/shared/utils'; -import { GenericRequestPayload, ManualRequestPayload, ManualRequestResponse } from '@jetstream/types'; -import { NextFunction, Request, Response } from 'express'; -import { body, query } from 'express-validator'; -import * as jsforce from 'jsforce'; -import { isObject, isString } from 'lodash'; -import * as request from 'superagent'; -import { salesforceRequestViaAxios } from '../services/sf-misc'; +import { SalesforceApiRequestSchema, SalesforceRequestManualRequestSchema } from '@jetstream/api-types'; +import { FetchResponse } from '@jetstream/salesforce-api'; +import { ManualRequestResponse } from '@jetstream/types'; +import { Readable } from 'stream'; +import { z } from 'zod'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; - -export const routeValidators = { - getFrontdoorLoginUrl: [], - streamFileDownload: [query('url').isString()], - makeJsforceRequest: [ - body('url').isString(), - body('method').isIn(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), - body('method') - .if(body('method').isIn(['POST', 'PUT', 'PATCH'])) - .custom((value, { req }) => isObject(req.body.body)), - body('isTooling').optional().toBoolean(), - body('body').optional(), - body('headers').optional(), - body('options').optional(), - ], - makeJsforceRequestViaAxios: [ - body('url').isString(), - body('method').isIn(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), - body('method') - .if(body('method').isIn(['POST', 'PUT', 'PATCH'])) - .custom((value, { req }) => isString(req.body.body)), - body('body').optional(), - body('headers').optional(), - ], - recordOperation: [ - // TODO: move all validation here (entire switch statement replaced with validator) - ], +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + getFrontdoorLoginUrl: { + controllerFn: () => getFrontdoorLoginUrl, + validators: { + query: z.object({ returnUrl: z.string().min(1) }), + }, + }, + streamFileDownload: { + controllerFn: () => streamFileDownload, + validators: { + query: z.object({ + url: z.string().min(1), + }), + }, + }, + salesforceRequest: { + controllerFn: () => salesforceRequest, + validators: { + body: SalesforceApiRequestSchema, + }, + }, + salesforceRequestManual: { + controllerFn: () => salesforceRequestManual, + validators: { + body: SalesforceRequestManualRequestSchema, + }, + }, }; -export async function getFrontdoorLoginUrl(req: Request, res: Response, next: NextFunction) { - try { - const { returnUrl } = req.query; - const conn: jsforce.Connection = res.locals.jsforceConn; - // ensure that our token is valid and not expired - await conn.identity(); - let url = `${conn.instanceUrl}/secur/frontdoor.jsp?sid=${conn.accessToken}`; - if (returnUrl) { - url += `&retURL=${returnUrl}`; +const getFrontdoorLoginUrl = createRoute( + routeDefinition.getFrontdoorLoginUrl.validators, + async ({ query, jetstreamConn }, req, res, next) => { + try { + const { returnUrl } = query; + // ensure that our token is valid and not expired + await jetstreamConn.org.identity(); + res.redirect(jetstreamConn.org.getFrontdoorLoginUrl(returnUrl as string)); + } catch (ex) { + next(ex); } - res.redirect(url); - } catch (ex) { - next(ex); } -} +); /** * Stream a file download from Salesforce * Query parameter of url is required (e.x. `/services/data/v54.0/sobjects/Attachment/00P6g000007BzmTEAS/Body`) * @returns */ -export async function streamFileDownload(req: Request, res: Response, next: NextFunction) { +const streamFileDownload = createRoute(routeDefinition.streamFileDownload.validators, async ({ query, jetstreamConn }, req, res, next) => { try { - const { url } = req.query; - const conn: jsforce.Connection = res.locals.jsforceConn; - // ensure that our token is valid and not expired - await conn.identity(); - return request - .get(`${conn.instanceUrl}${url}`) - .set({ ['Authorization']: `Bearer ${conn.accessToken}`, ['X-SFDC-Session']: conn.accessToken }) - .buffer(false) - .pipe(res); + const { url } = query; + + const results = await jetstreamConn.org.streamDownload(url as string); + Readable.fromWeb(results as any).pipe(res); } catch (ex) { next(ex); } -} +}); // https://github.com/jsforce/jsforce/issues/934 // TODO: the api version in the urls needs to match - we should not have this hard-coded on front-end -export async function makeJsforceRequest(req: Request, res: Response, next: NextFunction) { +const salesforceRequest = createRoute(routeDefinition.salesforceRequest.validators, async ({ body, jetstreamConn }, req, res, next) => { try { - const { url, method, isTooling, body, headers, options } = req.body as GenericRequestPayload; - const conn: jsforce.Connection | jsforce.Tooling = isTooling ? res.locals.jsforceConn.tooling : res.locals.jsforceConn; - - const requestOptions: jsforce.RequestInfo = { - method, - url, - body: isObject(body) ? JSON.stringify(body) : body, - headers: - (isObject(headers) || isObject(body)) && !headers?.['Content-Type'] - ? { ...headers, ['Content-Type']: 'application/json' } - : headers, - }; - - const results = await conn.request(requestOptions, options); + const payload = body; + const results = await jetstreamConn.request.manualRequest(payload, payload.options?.responseType || 'json', true); sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} - -export async function makeJsforceRequestViaAxios(req: Request, res: Response, next: NextFunction) { - try { - const { method, headers, body, url } = req.body as ManualRequestPayload; - const conn: jsforce.Connection = res.locals.jsforceConn; - - const response = await salesforceRequestViaAxios({ - conn, - url, - method, - headers, - body, - }); - - sendJson(res, response); - } catch (ex) { - next(new UserFacingError(ex.message)); - } -} - -export async function recordOperation(req: Request, res: Response, next: NextFunction) { - try { - // FIXME: add express validator to operation - const { sobject, operation } = req.params; - const { externalId } = req.query; - // FIXME: move to express validator to do data conversion - const allOrNone = toBoolean(req.query.allOrNone as string, true); - // TODO: validate combination based on operation or add validation to case statement - // ids and records can be one or an array - const { ids, records } = req.body; - - const conn: jsforce.Connection = res.locals.jsforceConn; - const sobjectOperation = conn.sobject(sobject); - - // FIXME: submit PR to fix these types - allOrNone / allowRecursive - const options: any = { allOrNone }; - - let operationPromise: Promise; - - switch (operation) { - case 'retrieve': - if (!ids) { - return next(new UserFacingError(`The ids property must be included`)); - } - operationPromise = sobjectOperation.retrieve(ids, options); - break; - case 'create': - if (!records) { - return next(new UserFacingError(`The records property must be included`)); - } - operationPromise = sobjectOperation.create(records, options); - break; - case 'update': - if (!records) { - return next(new UserFacingError(`The records property must be included`)); - } - operationPromise = sobjectOperation.update(records, options); - break; - case 'upsert': - if (!records || !externalId) { - return next(new UserFacingError(`The records and external id properties must be included`)); - } - operationPromise = sobjectOperation.upsert(records, externalId as string, options); - break; - case 'delete': - if (!ids) { - return next(new UserFacingError(`The ids property must be included`)); - } - operationPromise = sobjectOperation.delete(ids, options); - break; - default: - return next(new UserFacingError(`The operation ${operation} is not valid`)); +}); + +// TODO: combine with salesforceRequest and rename +// The request payload and response are slightly different, but the logic is the same +// The only difference is the caller is expected to pass in the full url to call (AFAIK) +const salesforceRequestManual = createRoute( + routeDefinition.salesforceRequestManual.validators, + async ({ body, jetstreamConn }, req, res, next) => { + try { + // const { method, headers, body, url } = body as ManualRequestPayload; + const payload = body; + + const results = await jetstreamConn.request.manualRequest(payload, 'response').then(async (apiResponse) => { + const { status, statusText, headers } = apiResponse; + const response: ManualRequestResponse = { + error: status < 200 || status > 300, + status, + statusText, + headers: JSON.stringify(Object.fromEntries(headers.entries()) || {}, null, 2), + body: await apiResponse.text(), // FIXME: what should this be? + }; + return response; + }); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); } - - const results = await operationPromise; - - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); } -} +); diff --git a/apps/api/src/app/controllers/sf-query.controller.ts b/apps/api/src/app/controllers/sf-query.controller.ts index 1f60b9fc8..18b157ae4 100644 --- a/apps/api/src/app/controllers/sf-query.controller.ts +++ b/apps/api/src/app/controllers/sf-query.controller.ts @@ -1,62 +1,89 @@ -import { NextFunction, Request, Response } from 'express'; -import { body, query as queryString } from 'express-validator'; -import * as jsforce from 'jsforce'; -import * as queryService from '../services/query'; +import { BooleanQueryParamSchema } from '@jetstream/api-types'; +import { z } from 'zod'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; -export const routeValidators = { - query: [body('query').isString()], - queryMore: [queryString('nextRecordsUrl').isString()], +export const routeDefinition = { + describe: { + controllerFn: () => describe, + validators: { + query: z.object({ isTooling: BooleanQueryParamSchema }), + }, + }, + describeSObject: { + controllerFn: () => describeSObject, + validators: { + query: z.object({ isTooling: BooleanQueryParamSchema }), + params: z.object({ sobject: z.string().min(1).max(255) }), + }, + }, + query: { + controllerFn: () => query, + validators: { + body: z.object({ query: z.string().min(1) }), + query: z.object({ + isTooling: BooleanQueryParamSchema, + includeDeletedRecords: BooleanQueryParamSchema, + }), + }, + }, + queryMore: { + controllerFn: () => queryMore, + validators: { + query: z.object({ + nextRecordsUrl: z.string().min(1), + }), + }, + }, }; -export async function describe(req: Request, res: Response, next: NextFunction) { +const describe = createRoute(routeDefinition.describe.validators, async ({ query, jetstreamConn }, req, res, next) => { try { - const isTooling = req.query.isTooling === 'true'; - const conn: jsforce.Connection = res.locals.jsforceConn; - const results = await (isTooling ? conn.tooling.describeGlobal() : conn.describeGlobal()); + const isTooling = query.isTooling; + const results = await jetstreamConn.sobject.describe(isTooling); sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function describeSObject(req: Request, res: Response, next: NextFunction) { - try { - const isTooling = req.query.isTooling === 'true'; - const conn: jsforce.Connection = res.locals.jsforceConn; - const results = await (isTooling ? conn.tooling.describe(req.params.sobject) : conn.describe(req.params.sobject)); - sendJson(res, results); - } catch (ex) { - next(new UserFacingError(ex.message)); +const describeSObject = createRoute( + routeDefinition.describeSObject.validators, + async ({ params, query, jetstreamConn }, req, res, next) => { + try { + const isTooling = query.isTooling; + const results = await jetstreamConn.sobject.describeSobject(params.sobject, isTooling); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } } -} +); -export async function query(req: Request, res: Response, next: NextFunction) { +const query = createRoute(routeDefinition.query.validators, async ({ body, query, jetstreamConn }, req, res, next) => { try { - const isTooling = req.query.isTooling === 'true'; - const includeDeletedRecords = req.query.includeDeletedRecords === 'true'; - const query = req.body.query; - const conn: jsforce.Connection = res.locals.jsforceConn; + const isTooling = query.isTooling; + const includeDeletedRecords = query.includeDeletedRecords; + const soql = body.query; - const response = await queryService.queryRecords(conn, query, isTooling, includeDeletedRecords); + const results = await jetstreamConn.query.query(soql, isTooling, includeDeletedRecords); - sendJson(res, response); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); -export async function queryMore(req: Request, res: Response, next: NextFunction) { +const queryMore = createRoute(routeDefinition.queryMore.validators, async ({ query, jetstreamConn }, req, res, next) => { try { - const isTooling = req.query.isTooling === 'true'; - const nextRecordsUrl = req.query.nextRecordsUrl as string; - const conn: jsforce.Connection = res.locals.jsforceConn; + const nextRecordsUrl = query.nextRecordsUrl as string; - const response = await queryService.queryMoreRecords(conn, nextRecordsUrl, isTooling); + const results = await jetstreamConn.query.queryMore(nextRecordsUrl); - sendJson(res, response); + sendJson(res, results); } catch (ex) { next(new UserFacingError(ex.message)); } -} +}); diff --git a/apps/api/src/app/controllers/sf-record.controller.ts b/apps/api/src/app/controllers/sf-record.controller.ts new file mode 100644 index 000000000..2b50b677f --- /dev/null +++ b/apps/api/src/app/controllers/sf-record.controller.ts @@ -0,0 +1,53 @@ +import { BooleanQueryParamSchema, RecordOperationRequestSchema } from '@jetstream/api-types'; +import { toBoolean } from '@jetstream/shared/utils'; +import { z } from 'zod'; +import { UserFacingError } from '../utils/error-handler'; +import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + recordOperation: { + controllerFn: () => recordOperation, + validators: { + params: z.object({ + sobject: z.string(), + operation: z.string(), + }), + body: RecordOperationRequestSchema, + query: z.object({ + externalId: z.string().optional(), + allOrNone: BooleanQueryParamSchema, + }), + }, + }, +}; + +const recordOperation = createRoute( + routeDefinition.recordOperation.validators, + async ({ body, params, query, jetstreamConn }, req, res, next) => { + try { + // FIXME: add express validator to operation + const { sobject, operation } = params; + const { externalId } = query; + // FIXME: move to express validator to do data conversion + const allOrNone = toBoolean(query.allOrNone, true); + // TODO: validate combination based on operation or add validation to case statement + // ids and records can be one or an array + const { ids, records } = body; + + const results = await jetstreamConn.sobject.recordOperation({ + sobject, + operation, + externalId, + records, + allOrNone, + ids, + // isTooling, + }); + + sendJson(res, results); + } catch (ex) { + next(new UserFacingError(ex.message)); + } + } +); diff --git a/apps/api/src/app/controllers/socket.controller.ts b/apps/api/src/app/controllers/socket.controller.ts index 67e61d06d..b4adfa123 100644 --- a/apps/api/src/app/controllers/socket.controller.ts +++ b/apps/api/src/app/controllers/socket.controller.ts @@ -1,4 +1,4 @@ -import { logger } from '@jetstream/api-config'; +import { getExceptionLog, logger } from '@jetstream/api-config'; import { UserProfileServer } from '@jetstream/types'; import * as cometdClient from 'cometd-nodejs-client'; import * as express from 'express'; @@ -85,14 +85,14 @@ export function initSocketServer(app: express.Express, middlewareFns: express.Re socket, cometdConnections: {}, }; - logger.debug('[SOCKET][CONNECT] %s', socket.id, { socketId: socket.id, userId: user?.id || 'unknown' }); + logger.debug({ socketId: socket.id, userId: user?.id || 'unknown' }, '[SOCKET][CONNECT] %s', socket.id); if (user) { socket.join(user.id); } // server namespace disconnect, client namespace disconnect, server shutting down, ping timeout, transport close, transport error socket.on('disconnect', (reason) => { - logger.debug('[SOCKET][DISCONNECT] %s', reason, { socketId: socket.id, userId: user?.id || 'unknown' }); + logger.debug({ socketId: socket.id, userId: user?.id || 'unknown' }, '[SOCKET][DISCONNECT] %s', reason); // TODO: should we distinguish specific reason for disconnect before unsubscribing from cometd? // If browser did not really disconnect, how will it know that it is no longer subscribed to cometd? Object.values(userSocketState.cometdConnections).forEach(({ cometd, subscriptions }) => { @@ -103,7 +103,7 @@ export function initSocketServer(app: express.Express, middlewareFns: express.Re }); socket.on('error', (err) => { - logger.warn('[SOCKET][ERROR] %s', err.message, { socketId: socket.id, userId: user?.id || 'unknown' }); + logger.warn({ socketId: socket.id, userId: user?.id || 'unknown', ...getExceptionLog(err) }, '[SOCKET][ERROR] %s', err.message); }); /** diff --git a/apps/api/src/app/controllers/user.controller.ts b/apps/api/src/app/controllers/user.controller.ts index 4655d874e..49905e0c3 100644 --- a/apps/api/src/app/controllers/user.controller.ts +++ b/apps/api/src/app/controllers/user.controller.ts @@ -1,25 +1,75 @@ -import { ENV, logger, mailgun } from '@jetstream/api-config'; +import { ENV, getExceptionLog, mailgun } from '@jetstream/api-config'; +import { UpdateProfileRequestSchema } from '@jetstream/api-types'; import { UserProfileAuth0Ui, UserProfileServer, UserProfileUi, UserProfileUiWithIdentities } from '@jetstream/types'; import { AxiosError } from 'axios'; -import * as express from 'express'; -import { body, query as queryString } from 'express-validator'; +import { z } from 'zod'; import { deleteUserAndOrgs } from '../db/transactions.db'; import * as userDbService from '../db/user.db'; import * as auth0Service from '../services/auth0'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; -export const routeValidators = { - updateProfile: [body('name').isString().isLength({ min: 1, max: 255 }), body('preferences').isObject().optional()], - unlinkIdentity: [queryString('provider').isString().isLength({ min: 1 }), queryString('userId').isString().isLength({ min: 1 })], - resendVerificationEmail: [queryString('provider').isString().isLength({ min: 1 }), queryString('userId').isString().isLength({ min: 1 })], - deleteAccount: [body('reason').isString().optional()], +export const routeDefinition = { + emailSupport: { + controllerFn: () => emailSupport, + validators: { + hasSourceOrg: false, + }, + }, + getUserProfile: { + controllerFn: () => getUserProfile, + validators: { + hasSourceOrg: false, + }, + }, + getFullUserProfile: { + controllerFn: () => getFullUserProfile, + validators: { + hasSourceOrg: false, + }, + }, + updateProfile: { + controllerFn: () => updateProfile, + validators: { + hasSourceOrg: false, + body: UpdateProfileRequestSchema, + }, + }, + unlinkIdentity: { + controllerFn: () => unlinkIdentity, + validators: { + hasSourceOrg: false, + query: z.object({ + provider: z.string().min(1), + userId: z.string().min(1), + }), + }, + }, + resendVerificationEmail: { + controllerFn: () => resendVerificationEmail, + validators: { + hasSourceOrg: false, + query: z.object({ + provider: z.string().min(1), + userId: z.string().min(1), + }), + }, + }, + deleteAccount: { + controllerFn: () => deleteAccount, + validators: { + hasSourceOrg: false, + body: z.object({ + reason: z.string().optional(), + }), + }, + }, }; -export async function emailSupport(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; +const emailSupport = createRoute(routeDefinition.emailSupport.validators, async ({ body, user }, req, res, next) => { const files = Array.isArray(req.files) ? req.files : []; - const { emailBody } = req.body || {}; + const { emailBody } = body || {}; try { const results = await mailgun.messages.create('mail.getjetstream.app', { @@ -46,21 +96,15 @@ export async function emailSupport(req: express.Request, res: express.Response) }), 'h:Reply-To': 'support@getjetstream.app', }); - logger.info('[SUPPORT EMAIL][EMAIL SENT] %s', results.id, { requestId: res.locals.requestId }); + req.log.info('[SUPPORT EMAIL][EMAIL SENT] %s', results.id); sendJson(res); } catch (ex) { - logger.error('[SUPPORT EMAIL][ERROR] %s', ex.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); - logger.error('%o', ex.stack, { requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[SUPPORT EMAIL][ERROR] %s', ex.message || 'An unknown error has occurred.'); throw new UserFacingError('There was a problem sending the email'); } -} - -export async function getUserProfile(req: express.Request, res: express.Response) { - const auth0User = req.user as UserProfileServer; +}); +const getUserProfile = createRoute(routeDefinition.getUserProfile.validators, async ({ user: auth0User }, req, res, next) => { // use fallback locally and on CI if (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER_PROFILE && req.hostname === 'localhost') { sendJson(res, ENV.EXAMPLE_USER_PROFILE); @@ -82,7 +126,7 @@ export async function getUserProfile(req: express.Request, res: express.Response }, }; sendJson(res, userProfileUi); -} +}); async function getFullUserProfileFn(sessionUser: UserProfileServer, auth0User?: UserProfileAuth0Ui) { auth0User = auth0User || (await auth0Service.getUser(sessionUser)); @@ -110,8 +154,7 @@ async function getFullUserProfileFn(sessionUser: UserProfileServer, auth0User?: } /** Get profile from Auth0 */ -export async function getFullUserProfile(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; +const getFullUserProfile = createRoute(routeDefinition.getFullUserProfile.validators, async ({ user }, req, res, next) => { try { const response = await getFullUserProfileFn(user); sendJson(res, response); @@ -119,25 +162,21 @@ export async function getFullUserProfile(req: express.Request, res: express.Resp if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[AUTH0][PROFILE FETCH][ERROR] %o', error.response.data, { userId: user.id, requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE FETCH][ERROR] %o', error.response.data); } else if (error.request) { - logger.error('[AUTH0][PROFILE FETCH][ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); + req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE FETCH][ERROR] %s', error.message || 'An unknown error has occurred.'); } } throw new UserFacingError('There was an error obtaining your profile information'); } -} +}); -export async function updateProfile(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; - const userProfile = req.body as UserProfileUiWithIdentities; +const updateProfile = createRoute(routeDefinition.updateProfile.validators, async ({ body, user }, req, res, next) => { + const userProfile = body; try { // check for name change, if so call auth0 to update - const auth0User = await auth0Service.updateUser(user, userProfile); + const auth0User = await auth0Service.updateUser(user, userProfile as any); // update name and preferences locally const response = await getFullUserProfileFn(user, auth0User); sendJson(res, response); @@ -145,23 +184,19 @@ export async function updateProfile(req: express.Request, res: express.Response) if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[AUTH0][PROFILE][ERROR] %o', error.response.data, { userId: user.id, requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE][ERROR] %o', error.response.data); } else if (error.request) { - logger.error('[AUTH0][PROFILE][ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); + req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE][ERROR] %s', error.message || 'An unknown error has occurred.'); } } throw new UserFacingError('There was an error updating the user profile'); } -} +}); -export async function unlinkIdentity(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; +const unlinkIdentity = createRoute(routeDefinition.unlinkIdentity.validators, async ({ query, user }, req, res, next) => { try { - const provider = req.query.provider as string; - const userId = req.query.userId as string; + const provider = query.provider; + const userId = query.userId; const auth0User = await auth0Service.unlinkIdentity(user, { provider, userId }); const response = await getFullUserProfileFn(user, auth0User); @@ -170,22 +205,18 @@ export async function unlinkIdentity(req: express.Request, res: express.Response if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[AUTH0][UNLINK][ERROR] %o', error.response.data, { userId: user.id, requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[AUTH0][UNLINK][ERROR] %o', error.response.data); } else if (error.request) { - logger.error('[AUTH0][UNLINK][ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); + req.log.error(getExceptionLog(ex), '[AUTH0][UNLINK][ERROR] %s', error.message || 'An unknown error has occurred.'); } } throw new UserFacingError('There was an error unlinking the account'); } -} +}); -export async function resendVerificationEmail(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; - const provider = req.query.provider as string; - const userId = req.query.userId as string; +const resendVerificationEmail = createRoute(routeDefinition.resendVerificationEmail.validators, async ({ query, user }, req, res, next) => { + const provider = query.provider; + const userId = query.userId; try { await auth0Service.resendVerificationEmail(user, { provider, userId }); sendJson(res); @@ -193,22 +224,18 @@ export async function resendVerificationEmail(req: express.Request, res: express if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[AUTH0][EMAIL VERIFICATION][ERROR] %o', error.response.data, { userId: user.id, requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[AUTH0][EMAIL VERIFICATION][ERROR] %o', error.response.data); } else if (error.request) { - logger.error('[AUTH0][EMAIL VERIFICATION][ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); + req.log.error(getExceptionLog(ex), '[AUTH0][EMAIL VERIFICATION][ERROR] %s', error.message || 'An unknown error has occurred.'); } } throw new UserFacingError('There was an error re-sending the verification email'); } -} +}); -export async function deleteAccount(req: express.Request, res: express.Response) { - const user = req.user as UserProfileServer; +const deleteAccount = createRoute(routeDefinition.deleteAccount.validators, async ({ body, user, requestId }, req, res, next) => { try { - const reason = req.body.reason as string | undefined; + const reason = body.reason; // delete from Auth0 await auth0Service.deleteUser(user); @@ -243,19 +270,19 @@ export async function deleteAccount(req: express.Request, res: express.Response) 'h:Reply-To': 'support@getjetstream.app', }) .then((results) => { - logger.info('[ACCOUNT DELETE][EMAIL SENT] %s', results.id, { requestId: res.locals.requestId }); + req.log.info('[ACCOUNT DELETE][EMAIL SENT] %s', results.id); }) .catch((error) => { - logger.error('[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', error.message, { requestId: res.locals.requestId }); + req.log.error({ requestId, ...getExceptionLog(error) }, '[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', error.message); }); } catch (ex) { - logger.error('[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', ex.message, { requestId: res.locals.requestId }); + req.log.error('[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', ex.message); } // Destroy session - don't wait for response req.session.destroy((error) => { if (error) { - logger.error('[ACCOUNT DELETE][ERROR DESTROYING SESSION] %s', error.message, { requestId: res.locals.requestId }); + req.log.error({ requestId, ...getExceptionLog(error) }, '[ACCOUNT DELETE][ERROR DESTROYING SESSION] %s', error.message); } }); @@ -264,14 +291,11 @@ export async function deleteAccount(req: express.Request, res: express.Response) if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[ACCOUNT DELETE][FATAL ERROR] %o', error.response.data, { userId: user.id, requestId: res.locals.requestId }); + req.log.error(getExceptionLog(ex), '[ACCOUNT DELETE][FATAL ERROR] %o', error.response.data); } else if (error.request) { - logger.error('[ACCOUNT DELETE][FATAL ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: user.id, - requestId: res.locals.requestId, - }); + req.log.error(getExceptionLog(ex), '[ACCOUNT DELETE][FATAL ERROR] %s', error.message || 'An unknown error has occurred.'); } } throw new UserFacingError('There was a problem deleting your account, contact support@getjetstream.app for assistance.'); } -} +}); diff --git a/apps/api/src/app/db/transactions.db.ts b/apps/api/src/app/db/transactions.db.ts index b300b92d3..eca010b4d 100644 --- a/apps/api/src/app/db/transactions.db.ts +++ b/apps/api/src/app/db/transactions.db.ts @@ -1,4 +1,4 @@ -import { logger, prisma } from '@jetstream/api-config'; +import { getExceptionLog, logger, prisma } from '@jetstream/api-config'; import { UserProfileServer } from '@jetstream/types'; import { PrismaPromise } from '@prisma/client'; @@ -24,7 +24,7 @@ export async function deleteUserAndOrgs(user: UserProfileServer) { await prisma.$transaction([deleteOrgs, deleteUser]); } catch (ex) { - logger.error('[DB][TX][DEL_ORGS_AND_USER][ERROR] %o', ex, { userId: user?.id }); + logger.error({ userId: user?.id, ...getExceptionLog(ex) }, '[DB][TX][DEL_ORGS_AND_USER][ERROR] %o', ex); throw ex; } } @@ -64,7 +64,7 @@ export async function hardDeleteUserAndOrgs(userId: string) { await prisma.$transaction(dbTransactions); } } catch (ex) { - logger.error('[DB][TX][DEL_ORGS_AND_USER][ERROR] %o', ex, { userId }); + logger.error({ userId, ...getExceptionLog(ex) }, '[DB][TX][DEL_ORGS_AND_USER][ERROR] %o', ex); throw ex; } } diff --git a/apps/api/src/app/db/user.db.ts b/apps/api/src/app/db/user.db.ts index 6200e2b3c..f11d05dfe 100644 --- a/apps/api/src/app/db/user.db.ts +++ b/apps/api/src/app/db/user.db.ts @@ -1,4 +1,4 @@ -import { ENV, logger, prisma } from '@jetstream/api-config'; +import { ENV, getExceptionLog, logger, prisma } from '@jetstream/api-config'; import { UserProfileServer } from '@jetstream/types'; import { Prisma, User } from '@prisma/client'; @@ -55,7 +55,7 @@ export async function updateUser( }); return updatedUser; } catch (ex) { - logger.error('[DB][USER][UPDATE][ERROR] %o', ex, { user }); + logger.error({ user, ...getExceptionLog(ex) }, '[DB][USER][UPDATE][ERROR]'); throw ex; } } @@ -81,7 +81,7 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre }, select: userSelect, }); - logger.debug('[DB][USER][UPDATED] %s', user.id, { userId: user.id, id: existingUser.id }); + logger.debug({ userId: user.id, id: existingUser.id }, '[DB][USER][UPDATED] %s', user.id); return { created: false, user: updatedUser }; } else { const createdUser = await prisma.user.create({ @@ -96,11 +96,11 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre }, select: userSelect, }); - logger.debug('[DB][USER][CREATED] %s', user.id, { userId: user.id, id: createdUser.id }); + logger.debug({ userId: user.id, id: createdUser.id }, '[DB][USER][CREATED] %s', user.id); return { created: true, user: createdUser }; } } catch (ex) { - logger.error('[DB][USER][CREATE][ERROR] %o', ex, { user }); + logger.error({ user, ...getExceptionLog(ex) }, '[DB][USER][CREATE][ERROR] %o', ex); throw ex; } } diff --git a/apps/api/src/app/routes/api.routes.ts b/apps/api/src/app/routes/api.routes.ts index ef5c817a7..d09918157 100644 --- a/apps/api/src/app/routes/api.routes.ts +++ b/apps/api/src/app/routes/api.routes.ts @@ -2,16 +2,18 @@ import { ENV } from '@jetstream/api-config'; import express from 'express'; import Router from 'express-promise-router'; import multer from 'multer'; -import * as imageController from '../controllers/image.controller'; -import * as orgsController from '../controllers/orgs.controller'; -import * as salesforceApiReqController from '../controllers/salesforce-api-requests.controller'; -import * as bulkApiController from '../controllers/sf-bulk-api.controller'; -import * as metadataToolingController from '../controllers/sf-metadata-tooling.controller'; -import * as sfMiscController from '../controllers/sf-misc.controller'; -import * as sfQueryController from '../controllers/sf-query.controller'; -import * as userController from '../controllers/user.controller'; +import { routeDefinition as imageController } from '../controllers/image.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'; +import { routeDefinition as bulkQuery20ApiController } from '../controllers/sf-bulk-query-20-api.controller'; +import { routeDefinition as metadataToolingController } from '../controllers/sf-metadata-tooling.controller'; +import { routeDefinition as miscController } from '../controllers/sf-misc.controller'; +import { routeDefinition as queryController } from '../controllers/sf-query.controller'; +import { routeDefinition as recordController } from '../controllers/sf-record.controller'; +import { routeDefinition as userController } from '../controllers/user.controller'; import { sendJson } from '../utils/response.handlers'; -import { addOrgsToLocal, checkAuth, ensureOrgExists, ensureTargetOrgExists, validate } from './route.middleware'; +import { addOrgsToLocal, checkAuth, ensureTargetOrgExists } from './route.middleware'; const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); @@ -26,171 +28,117 @@ routes.get('/heartbeat', (req: express.Request, res: express.Response) => { sendJson(res, { version: ENV.GIT_VERSION || null }); }); -routes.get('/me', userController.getUserProfile); -routes.delete('/me', validate(userController.routeValidators.deleteAccount), userController.deleteAccount); -routes.get('/me/profile', userController.getFullUserProfile); -routes.post('/me/profile', validate(userController.routeValidators.updateProfile), userController.updateProfile); -routes.delete('/me/profile/identity', validate(userController.routeValidators.unlinkIdentity), userController.unlinkIdentity); -routes.post( - '/me/profile/identity/verify-email', - validate(userController.routeValidators.resendVerificationEmail), - userController.resendVerificationEmail -); - -routes.post('/support/email', upload.array('files', 5), userController.emailSupport); - -/** Download file or attachment */ -routes.get( - '/file/stream-download', - ensureOrgExists, - validate(sfMiscController.routeValidators.streamFileDownload), - sfMiscController.streamFileDownload -); -routes.post('/orgs/health-check', ensureOrgExists, orgsController.checkOrgHealth); - -routes.get('/orgs', orgsController.getOrgs); -routes.patch('/orgs/:uniqueId', orgsController.updateOrg); -routes.delete('/orgs/:uniqueId', orgsController.deleteOrg); - -routes.get('/images/upload-signature', validate(imageController.routeValidators.getUploadSignature), imageController.getUploadSignature); - -routes.get('/describe', ensureOrgExists, sfQueryController.describe); -routes.get('/describe/:sobject', ensureOrgExists, sfQueryController.describeSObject); -routes.post('/query', ensureOrgExists, validate(sfQueryController.routeValidators.query), sfQueryController.query); -routes.get('/query-more', ensureOrgExists, validate(sfQueryController.routeValidators.queryMore), sfQueryController.queryMore); - -routes.post('/record/:operation/:sobject', ensureOrgExists, sfMiscController.recordOperation); - -routes.get('/metadata/describe', ensureOrgExists, metadataToolingController.describeMetadata); -routes.post( - '/metadata/list', - ensureOrgExists, - validate(metadataToolingController.routeValidators.listMetadata), - metadataToolingController.listMetadata -); -routes.post( - '/metadata/read/:type', - ensureOrgExists, - validate(metadataToolingController.routeValidators.readMetadata), - metadataToolingController.readMetadata -); - -routes.post( - '/metadata/deploy', - ensureOrgExists, - validate(metadataToolingController.routeValidators.deployMetadata), - metadataToolingController.deployMetadata -); - +/** + * ************************************ + * userController Routes + * ************************************ + */ +routes.get('/me', userController.getUserProfile.controllerFn()); +routes.delete('/me', userController.deleteAccount.controllerFn()); +routes.get('/me/profile', userController.getFullUserProfile.controllerFn()); +routes.post('/me/profile', userController.updateProfile.controllerFn()); +routes.delete('/me/profile/identity', userController.unlinkIdentity.controllerFn()); +routes.post('/me/profile/identity/verify-email', userController.resendVerificationEmail.controllerFn()); +routes.post('/support/email', upload.array('files', 5), userController.emailSupport.controllerFn()); + +/** + * ************************************ + * orgsController Routes + * ************************************ + */ +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()); + +/** + * ************************************ + * imageController Routes + * ************************************ + */ +routes.get('/images/upload-signature', imageController.getUploadSignature.controllerFn()); + +/** + * ************************************ + * queryController Routes + * ************************************ + */ +routes.get('/describe', queryController.describe.controllerFn()); +routes.get('/describe/:sobject', queryController.describeSObject.controllerFn()); +routes.post('/query', queryController.query.controllerFn()); +routes.get('/query-more', queryController.queryMore.controllerFn()); + +/** + * ************************************ + * metadataToolingController Routes + * ************************************ + */ +routes.get('/metadata/describe', metadataToolingController.describeMetadata.controllerFn()); +routes.post('/metadata/list', metadataToolingController.listMetadata.controllerFn()); +routes.post('/metadata/read/:type', metadataToolingController.readMetadata.controllerFn()); +routes.post('/metadata/deploy', metadataToolingController.deployMetadata.controllerFn()); // Content-Type=Application/zip -routes.post( - '/metadata/deploy-zip', - ensureOrgExists, - validate(metadataToolingController.routeValidators.deployMetadataZip), - metadataToolingController.deployMetadataZip -); - -routes.get( - '/metadata/deploy/:id', - ensureOrgExists, - validate(metadataToolingController.routeValidators.checkMetadataResults), - metadataToolingController.checkMetadataResults -); - -routes.post( - '/metadata/retrieve/list-metadata', - ensureOrgExists, - metadataToolingController.routeValidators.retrievePackageFromLisMetadataResults, - metadataToolingController.retrievePackageFromLisMetadataResults -); -routes.post( - '/metadata/retrieve/package-names', - ensureOrgExists, - metadataToolingController.routeValidators.retrievePackageFromExistingServerPackages, - metadataToolingController.retrievePackageFromExistingServerPackages -); -routes.post( - '/metadata/retrieve/manifest', - ensureOrgExists, - metadataToolingController.routeValidators.retrievePackageFromManifest, - metadataToolingController.retrievePackageFromManifest -); -routes.get( - '/metadata/retrieve/check-results', - ensureOrgExists, - metadataToolingController.routeValidators.checkRetrieveStatus, - metadataToolingController.checkRetrieveStatus -); - +routes.post('/metadata/deploy-zip', metadataToolingController.deployMetadataZip.controllerFn()); +routes.get('/metadata/deploy/:id', metadataToolingController.checkMetadataResults.controllerFn()); +routes.post('/metadata/retrieve/list-metadata', metadataToolingController.retrievePackageFromLisMetadataResults.controllerFn()); +routes.post('/metadata/retrieve/package-names', metadataToolingController.retrievePackageFromExistingServerPackages.controllerFn()); +routes.post('/metadata/retrieve/manifest', metadataToolingController.retrievePackageFromManifest.controllerFn()); +routes.get('/metadata/retrieve/check-results', metadataToolingController.checkRetrieveStatus.controllerFn()); routes.post( '/metadata/retrieve/check-and-redeploy', - ensureOrgExists, ensureTargetOrgExists, - metadataToolingController.routeValidators.checkRetrieveStatusAndRedeploy, - metadataToolingController.checkRetrieveStatusAndRedeploy -); - -routes.post( - '/metadata/package-xml', - ensureOrgExists, - validate(metadataToolingController.routeValidators.getPackageXml), - metadataToolingController.getPackageXml -); - -routes.post( - '/request', - ensureOrgExists, - validate(sfMiscController.routeValidators.makeJsforceRequest), - sfMiscController.makeJsforceRequest -); - -routes.post( - '/request-manual', - ensureOrgExists, - validate(sfMiscController.routeValidators.makeJsforceRequestViaAxios), - sfMiscController.makeJsforceRequestViaAxios -); - -routes.post('/bulk', ensureOrgExists, validate(bulkApiController.routeValidators.createJob), bulkApiController.createJob); -routes.get('/bulk/:jobId', ensureOrgExists, validate(bulkApiController.routeValidators.getJob), bulkApiController.getJob); -routes.delete( - '/bulk/:jobId/:action', - ensureOrgExists, - validate(bulkApiController.routeValidators.closeJob), - bulkApiController.closeOrAbortJob -); -routes.post('/bulk/:jobId', ensureOrgExists, validate(bulkApiController.routeValidators.addBatchToJob), bulkApiController.addBatchToJob); -routes.post( - '/bulk/zip/:jobId', - ensureOrgExists, - validate(bulkApiController.routeValidators.addBatchToJobWithBinaryAttachment), - bulkApiController.addBatchToJobWithBinaryAttachment -); -routes.get( - '/bulk/:jobId/:batchId', - ensureOrgExists, - validate(bulkApiController.routeValidators.downloadResults), - bulkApiController.downloadResults -); - -routes.post( - '/apex/anonymous', - ensureOrgExists, - validate(metadataToolingController.routeValidators.anonymousApex), - metadataToolingController.anonymousApex -); - -routes.get( - '/apex/completions/:type', - ensureOrgExists, - validate(metadataToolingController.routeValidators.apexCompletions), - metadataToolingController.apexCompletions -); - -routes.get( - '/salesforce-api/requests', - validate(salesforceApiReqController.routeValidators.getSalesforceApiRequests), - salesforceApiReqController.getSalesforceApiRequests -); + metadataToolingController.checkRetrieveStatusAndRedeploy.controllerFn() +); +routes.post('/metadata/package-xml', metadataToolingController.getPackageXml.controllerFn()); +routes.post('/apex/anonymous', metadataToolingController.anonymousApex.controllerFn()); +routes.get('/apex/completions/:type', metadataToolingController.apexCompletions.controllerFn()); + +/** + * ************************************ + * miscController Routes + * ************************************ + */ +routes.get('/file/stream-download', miscController.streamFileDownload.controllerFn()); +routes.post('/request', miscController.salesforceRequest.controllerFn()); +routes.post('/request-manual', miscController.salesforceRequestManual.controllerFn()); + +/** + * ************************************ + * recordController Routes + * ************************************ + */ +routes.post('/record/:operation/:sobject', recordController.recordOperation.controllerFn()); + +/** + * ************************************ + * bulkApiController Routes + * ************************************ + */ +routes.post('/bulk', bulkApiController.createJob.controllerFn()); +routes.get('/bulk/:jobId', bulkApiController.getJob.controllerFn()); +routes.delete('/bulk/:jobId/:action', bulkApiController.closeOrAbortJob.controllerFn()); +routes.post('/bulk/:jobId', bulkApiController.addBatchToJob.controllerFn()); +routes.post('/bulk/zip/:jobId', bulkApiController.addBatchToJobWithBinaryAttachment.controllerFn()); +routes.get('/bulk/:jobId/:batchId', bulkApiController.downloadResults.controllerFn()); + +/** + * ************************************ + * bulkQuery20ApiController Routes + * These use the Bulk Query 2.0 API + * ************************************ + */ +routes.post('/bulk-query', bulkQuery20ApiController.createJob.controllerFn()); +routes.get('/bulk-query', bulkQuery20ApiController.getJobs.controllerFn()); +routes.get('/bulk-query/:jobId/results', bulkQuery20ApiController.downloadResults.controllerFn()); +routes.get('/bulk-query/:jobId', bulkQuery20ApiController.getJob.controllerFn()); +routes.post('/bulk-query/:jobId/abort', bulkQuery20ApiController.abortJob.controllerFn()); +routes.delete('/bulk-query/:jobId', bulkQuery20ApiController.deleteJob.controllerFn()); + +/** + * ************************************ + * salesforceApiReqController Routes + * ************************************ + */ +routes.get('/salesforce-api/requests', salesforceApiReqController.getSalesforceApiRequests.controllerFn()); export default routes; diff --git a/apps/api/src/app/routes/oauth.routes.ts b/apps/api/src/app/routes/oauth.routes.ts index c34888a40..5172950d3 100644 --- a/apps/api/src/app/routes/oauth.routes.ts +++ b/apps/api/src/app/routes/oauth.routes.ts @@ -3,7 +3,7 @@ import * as express from 'express'; import Router from 'express-promise-router'; import * as passport from 'passport'; import * as authController from '../controllers/auth.controller'; -import { salesforceOauthCallback, salesforceOauthInitAuth } from '../controllers/oauth.controller'; +import { routeDefinition as oauthController } from '../controllers/oauth.controller'; import { checkAuth } from './route.middleware'; export const routes: express.Router = Router(); @@ -46,7 +46,7 @@ routes.get('/identity/link', (req: express.Request, res: express.Response, next: routes.get('/identity/link/callback', authController.linkCallback); // salesforce org authentication -routes.get('/sfdc/auth', checkAuth, salesforceOauthInitAuth); -routes.get('/sfdc/callback', checkAuth, salesforceOauthCallback); +routes.get('/sfdc/auth', checkAuth, oauthController.salesforceOauthInitAuth.controllerFn()); +routes.get('/sfdc/callback', checkAuth, oauthController.salesforceOauthCallback.controllerFn()); export default routes; diff --git a/apps/api/src/app/routes/platform-event.routes.ts b/apps/api/src/app/routes/platform-event.routes.ts index 032612084..c16b941a0 100644 --- a/apps/api/src/app/routes/platform-event.routes.ts +++ b/apps/api/src/app/routes/platform-event.routes.ts @@ -1,10 +1,10 @@ -import { ENV, logger } from '@jetstream/api-config'; +import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; +import { ApiConnection } from '@jetstream/salesforce-api'; import { HTTP } from '@jetstream/shared/constants'; import * as express from 'express'; import Router from 'express-promise-router'; import type * as http from 'http'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import * as jsforce from 'jsforce'; import { Url } from 'url'; import { checkAuth, getOrgFromHeaderOrQuery } from './route.middleware'; @@ -25,7 +25,7 @@ routes.use( createProxyMiddleware({ logLevel: 'debug', onError: (err: Error, req: http.IncomingMessage, res: http.ServerResponse, target?: string | Partial) => { - logger.warn('[PROXY][ERROR]! %s', err.message, { target }); + logger.warn({ target, ...getExceptionLog(err) }, '[PROXY][ERROR]'); }, //TODO: make sure that if we throw here the world does not blow up (ensure server does not freeze) router: async (req) => { @@ -34,9 +34,9 @@ routes.use( throw new Error('A valid salesforce org must be included with the request'); } (req as any).locals = { - jsforceConn: result.connection, + jetstreamConn: result.jetstreamConn, }; - return result.connection.instanceUrl; + return result.jetstreamConn.sessionInfo.instanceUrl; }, pathRewrite: { '^/platform-event': `/cometd/${ENV.SFDC_API_VERSION}`, @@ -48,12 +48,12 @@ routes.use( }, onProxyReq: (proxyReq: http.ClientRequest, req: http.IncomingMessage, res: http.ServerResponse, options) => { try { - const conn: jsforce.Connection = (req as any).locals.jsforceConn; - proxyReq.setHeader('Authorization', `Bearer ${conn.accessToken}`); + const jetstreamConn = (req as any).locals.jetstreamConn as ApiConnection; + proxyReq.setHeader('Authorization', `Bearer ${jetstreamConn.sessionInfo.accessToken}`); // not sure if this one is required res.setHeader('Access-Control-Allow-Credentials', 'true'); } catch (ex) { - logger.error('[PROXY][EXCEPTION] %s', ex.message); + logger.error(getExceptionLog(ex), '[PROXY][EXCEPTION]'); } }, }) diff --git a/apps/api/src/app/routes/route.middleware.ts b/apps/api/src/app/routes/route.middleware.ts index cc9573fe0..64077e85d 100644 --- a/apps/api/src/app/routes/route.middleware.ts +++ b/apps/api/src/app/routes/route.middleware.ts @@ -1,4 +1,5 @@ -import { ENV, logger, rollbarServer, telemetryAddUserToAttributes } from '@jetstream/api-config'; +import { ENV, getExceptionLog, rollbarServer, telemetryAddUserToAttributes } from '@jetstream/api-config'; +import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; import { HTTP } from '@jetstream/shared/constants'; import { ensureBoolean } from '@jetstream/shared/utils'; import { ApplicationCookie, UserProfileServer } from '@jetstream/types'; @@ -6,16 +7,16 @@ import { AxiosError } from 'axios'; import { addDays, fromUnixTime, getUnixTime } from 'date-fns'; import * as express from 'express'; import { ValidationChain, validationResult } from 'express-validator'; -import * as jsforce from 'jsforce'; import { isNumber } from 'lodash'; +import pino from 'pino'; import { v4 as uuid } from 'uuid'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; import { updateUserLastActivity } from '../services/auth0'; -import { getJsforceOauth2 } from '../utils/auth-utils'; import { AuthenticationError, NotFoundError, UserFacingError } from '../utils/error-handler'; export function addContextMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { - res.locals.requestId = uuid(); + res.locals.requestId = res.locals.requestId || uuid(); + res.setHeader('X-Request-Id', res.locals.requestId); next(); } @@ -27,32 +28,21 @@ export function addContextMiddleware(req: express.Request, res: express.Response */ export function setApplicationCookieMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { const appCookie: ApplicationCookie = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion serverUrl: ENV.JETSTREAM_SERVER_URL!, environment: ENV.ENVIRONMENT as any, defaultApiVersion: `v${ENV.SFDC_API_VERSION}`, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion google_appId: ENV.GOOGLE_APP_ID!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion google_apiKey: ENV.GOOGLE_API_KEY!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion google_clientId: ENV.GOOGLE_CLIENT_ID!, }; res.cookie(HTTP.COOKIE.JETSTREAM, appCookie, { httpOnly: false, sameSite: 'strict' }); next(); } -export function logRoute(req: express.Request, res: express.Response, next: express.NextFunction) { - res.locals.path = req.path; - const userInfo = req.user ? { username: (req.user as any)?.displayName, userId: (req.user as any)?.user_id } : undefined; - logger.debug('[REQ] %s %s', req.method, req.originalUrl, { - method: req.method, - url: req.originalUrl, - requestId: res.locals.requestId, - agent: req.header('User-Agent'), - ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, - country: req.headers[HTTP.HEADERS.CF_IPCountry], - ...userInfo, - }); - next(); -} - export function validate(validations: ValidationChain[]) { return async (req: express.Request, res: express.Response, next: express.NextFunction) => { await Promise.all(validations.map((validation) => validation.run(req))); @@ -79,16 +69,21 @@ export function notFoundMiddleware(req: express.Request, res: express.Response, export function blockBotByUserAgentMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { const userAgent = req.header('User-Agent'); if (userAgent?.toLocaleLowerCase().includes('python')) { - logger.debug('[BLOCKED REQUEST][USER AGENT] %s %s', req.method, req.originalUrl, { - blocked: true, - method: req.method, - url: req.originalUrl, - requestId: res.locals.requestId, - agent: req.header('User-Agent'), - referrer: req.get('Referrer'), - ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, - country: req.headers[HTTP.HEADERS.CF_IPCountry], - }); + req.log.debug( + { + blocked: true, + method: req.method, + url: req.originalUrl, + requestId: res.locals.requestId, + agent: req.header('User-Agent'), + referrer: req.get('Referrer'), + ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, + country: req.headers[HTTP.HEADERS.CF_IPCountry], + }, + '[BLOCKED REQUEST][USER AGENT] %s %s', + req.method, + req.originalUrl + ); return res.status(403).send('Forbidden'); } next(); @@ -113,24 +108,38 @@ export async function checkAuth(req: express.Request, res: express.Response, nex // Update auth0 with expiration date updateUserLastActivity(req.user as UserProfileServer, fromUnixTime(req.session.activityExp)) .then(() => { - logger.debug('[AUTH][LAST-ACTIVITY][UPDATED] %s', req.session.activityExp, { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - }); + req.log.debug( + { + userId: (req.user as any)?.user_id, + requestId: res.locals.requestId, + }, + '[AUTH][LAST-ACTIVITY][UPDATED] %s', + req.session.activityExp + ); }) .catch((err) => { // send error to rollbar const error: AxiosError = err; if (error.response) { - logger.error('[AUTH][LAST-ACTIVITY][ERROR] %o', error.response.data, { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - }); + req.log.error( + { + userId: (req.user as any)?.user_id, + requestId: res.locals.requestId, + ...getExceptionLog(err), + }, + '[AUTH][LAST-ACTIVITY][ERROR] %o', + error.response.data + ); } else if (error.request) { - logger.error('[AUTH][LAST-ACTIVITY][ERROR] %s', error.message || 'An unknown error has occurred.', { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - }); + req.log.error( + { + userId: (req.user as any)?.user_id, + requestId: res.locals.requestId, + ...getExceptionLog(err), + }, + '[AUTH][LAST-ACTIVITY][ERROR] %s', + error.message || 'An unknown error has occurred.' + ); } rollbarServer.error('Error updating Auth0 activityExp', { message: err.message, @@ -140,22 +149,11 @@ export async function checkAuth(req: express.Request, res: express.Response, nex }); } } catch (ex) { - logger.warn('[AUTH][LAST-ACTIVITY][ERROR] Exception: %s', ex.message, { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - }); + req.log.warn(getExceptionLog(ex), '[AUTH][LAST-ACTIVITY][ERROR] Exception: %s', ex.message); } return next(); } - logger.error('[AUTH][UNAUTHORIZED] %s %s', req.method, req.originalUrl, { - blocked: true, - method: req.method, - url: req.originalUrl, - requestId: res.locals.requestId, - agent: req.header('User-Agent'), - ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, - country: req.headers[HTTP.HEADERS.CF_IPCountry], - }); + req.log.error('[AUTH][UNAUTHORIZED]'); next(new AuthenticationError('Unauthorized')); } @@ -165,9 +163,9 @@ export async function addOrgsToLocal(req: express.Request, res: express.Response res.locals = res.locals || {}; const results = await getOrgFromHeaderOrQuery(req, HTTP.HEADERS.X_SFDC_ID, HTTP.HEADERS.X_SFDC_API_VERSION, res.locals.requestId); if (results) { - const { org, connection } = results; + const { org, jetstreamConn } = results; res.locals.org = org; - res.locals.jsforceConn = connection; + res.locals.jetstreamConn = jetstreamConn; } } if (req.get(HTTP.HEADERS.X_SFDC_ID_TARGET) || req.query[HTTP.HEADERS.X_SFDC_ID_TARGET]) { @@ -180,38 +178,14 @@ export async function addOrgsToLocal(req: express.Request, res: express.Response ); if (results) { if (results) { - const { org, connection } = results; + const { org, jetstreamConn } = results; res.locals.targetOrg = org; - res.locals.targetJsforceConn = connection; + res.locals.targetJetstreamConn = jetstreamConn; } } } } catch (ex) { - logger.warn('[INIT-ORG][ERROR] %o', ex, { requestId: res.locals.requestId }); - return next(new UserFacingError('There was an error initializing the connection to Salesforce')); - } - - next(); -} - -/** - * Add locals to request object - */ -export async function monkeyPatchOrgsToRequest(req: express.Request, res: express.Response, next: express.NextFunction) { - try { - if (req.get(HTTP.HEADERS.X_SFDC_ID) || req.query[HTTP.HEADERS.X_SFDC_ID]) { - const results = await getOrgFromHeaderOrQuery(req, HTTP.HEADERS.X_SFDC_ID, HTTP.HEADERS.X_SFDC_API_VERSION, res.locals.requestId); - if (results) { - const { org, connection } = results; - res.locals = { org, jsforceConn: connection }; - (req as any).locals = res.locals; - } else { - logger.info('[INIT-ORG][ERROR] An org did not exist on locals - Monkey Patch', { requestId: res.locals.requestId }); - return next(new UserFacingError('An org is required for this action')); - } - } - } catch (ex) { - logger.warn('[INIT-ORG][ERROR] %o', ex, { requestId: res.locals.requestId }); + req.log.warn(getExceptionLog(ex), '[INIT-ORG][ERROR]'); return next(new UserFacingError('There was an error initializing the connection to Salesforce')); } @@ -219,16 +193,16 @@ export async function monkeyPatchOrgsToRequest(req: express.Request, res: expres } export function ensureOrgExists(req: express.Request, res: express.Response, next: express.NextFunction) { - if (!res.locals?.jsforceConn) { - logger.info('[INIT-ORG][ERROR] An org did not exist on locals', { requestId: res.locals.requestId }); + if (!res.locals?.jetstreamConn) { + req.log.info('[INIT-ORG][ERROR] An org did not exist on locals'); return next(new UserFacingError('An org is required for this action')); } next(); } export function ensureTargetOrgExists(req: express.Request, res: express.Response, next: express.NextFunction) { - if (!res.locals?.targetJsforceConn) { - logger.info('[INIT-ORG][ERROR] A target org did not exist on locals', { requestId: res.locals.requestId }); + if (!res.locals?.targetJetstreamConn) { + req.log.info('[INIT-ORG][ERROR] A target org did not exist on locals'); return next(new UserFacingError('A target org is required for this action')); } next(); @@ -257,12 +231,13 @@ export async function getOrgFromHeaderOrQuery(req: express.Request, headerKey: s return; } - return getOrgForRequest(user, uniqueId, apiVersion, includeCallOptions, requestId); + return getOrgForRequest(user, uniqueId, req.log, apiVersion, includeCallOptions, requestId); } export async function getOrgForRequest( user: UserProfileServer, uniqueId: string, + logger: pino.Logger | typeof console = console, apiVersion?: string, includeCallOptions?: boolean, requestId?: string @@ -272,47 +247,49 @@ export async function getOrgForRequest( throw new UserFacingError('An org was not found with the provided id'); } - const { accessToken: encryptedAccessToken, loginUrl, instanceUrl, orgNamespacePrefix } = org; + const { accessToken: encryptedAccessToken, instanceUrl, orgNamespacePrefix, userId, organizationId } = org; const [accessToken, refreshToken] = salesforceOrgsDb.decryptAccessToken(encryptedAccessToken); - const connData: jsforce.ConnectionOptions = { - oauth2: getJsforceOauth2(loginUrl), - instanceUrl, - accessToken, - refreshToken, - maxRequest: 5, - version: apiVersion || org.apiVersion || ENV.SFDC_API_VERSION, - callOptions: { - // Magical metadata shows up when using this - // http://www.fishofprey.com/2016/03/salesforce-forcecom-ide-superpowers.html - // FIXME: this breaks some orgs - // client: `apex_eclipse/v${apiVersion || org.apiVersion || ENV.SFDC_API_VERSION}`, - client: 'jetstream', - }, + apiVersion = apiVersion || org.apiVersion || ENV.SFDC_API_VERSION; + let callOptions = { + client: 'jetstream', }; if (orgNamespacePrefix && includeCallOptions) { - connData.callOptions = { ...connData.callOptions, defaultNamespace: orgNamespacePrefix } as any; + callOptions = { ...callOptions, defaultNamespace: orgNamespacePrefix } as any; } - const conn = new jsforce.Connection(connData); - // Handle org refresh - then remove event listener if refreshed - const handleRefresh = async (accessToken) => { + const handleRefresh = async (accessToken: string, refreshToken: string) => { // Refresh event will be fired when renewed access token // to store it in your storage for next request try { - if (!conn.refreshToken) { + if (!refreshToken) { return; } - await salesforceOrgsDb.updateAccessToken_UNSAFE(org, accessToken, conn.refreshToken); - logger.info('[ORG][REFRESH] Org refreshed successfully', { requestId }); + await salesforceOrgsDb.updateAccessToken_UNSAFE(org, accessToken, refreshToken); } catch (ex) { - logger.error('[ORG][REFRESH] Error saving refresh token', ex, { requestId }); + logger.error({ requestId, ...getExceptionLog(ex) }, '[ORG][REFRESH] Error saving refresh token'); } }; - conn.on('refresh', handleRefresh); + const jetstreamConn = new ApiConnection( + { + apiRequestAdapter: getApiRequestFactoryFn(fetch), + userId, + organizationId, + accessToken, + apiVersion, + callOptions, + instanceUrl, + refreshToken, + logging: ENV.ENVIRONMENT === 'development', + logger, + sfdcClientId: ENV.SFDC_CONSUMER_KEY, + sfdcClientSecret: ENV.SFDC_CONSUMER_SECRET, + }, + handleRefresh + ); - return { org, connection: conn }; + return { org, jetstreamConn }; } diff --git a/apps/api/src/app/routes/static-authenticated.routes.ts b/apps/api/src/app/routes/static-authenticated.routes.ts index 5879f90d3..d2f0ee606 100644 --- a/apps/api/src/app/routes/static-authenticated.routes.ts +++ b/apps/api/src/app/routes/static-authenticated.routes.ts @@ -1,24 +1,14 @@ import * as express from 'express'; import Router from 'express-promise-router'; -import * as bulkApiController from '../controllers/sf-bulk-api.controller'; -import * as sfMiscController from '../controllers/sf-misc.controller'; -import { addOrgsToLocal, checkAuth, ensureOrgExists, validate } from './route.middleware'; +import { routeDefinition as bulkApiController } from '../controllers/sf-bulk-api.controller'; +import { routeDefinition as miscController } from '../controllers/sf-misc.controller'; +import { addOrgsToLocal, checkAuth } from './route.middleware'; const routes: express.Router = Router(); routes.use(checkAuth); routes.use(addOrgsToLocal); -routes.get( - '/sfdc/login', - ensureOrgExists, - validate(sfMiscController.routeValidators.getFrontdoorLoginUrl), - sfMiscController.getFrontdoorLoginUrl -); -routes.get( - '/bulk/:jobId/:batchId/file', - ensureOrgExists, - validate(bulkApiController.routeValidators.downloadResultsFile), - bulkApiController.downloadResultsFile -); +routes.get('/sfdc/login', miscController.getFrontdoorLoginUrl.controllerFn()); +routes.get('/bulk/:jobId/:batchId/file', bulkApiController.downloadResultsFile.controllerFn()); export default routes; diff --git a/apps/api/src/app/routes/test.routes.ts b/apps/api/src/app/routes/test.routes.ts index 81e4b7f13..cc114e444 100644 --- a/apps/api/src/app/routes/test.routes.ts +++ b/apps/api/src/app/routes/test.routes.ts @@ -1,8 +1,9 @@ import { ENV } from '@jetstream/api-config'; +import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; import * as express from 'express'; import Router from 'express-promise-router'; -import * as jsforce from 'jsforce'; import { initConnectionFromOAuthResponse } from '../controllers/oauth.controller'; +import { salesforceLoginUsernamePassword_UNSAFE } from '../services/oauth.service'; import { NotAllowedError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; @@ -36,22 +37,32 @@ routes.post( async (req: express.Request, res: express.Response) => { const E2E_LOGIN_USERNAME = process.env.E2E_LOGIN_USERNAME; const E2E_LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD; - const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL; + const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL!; + + const { id, access_token, instance_url } = await salesforceLoginUsernamePassword_UNSAFE( + E2E_LOGIN_URL, + E2E_LOGIN_USERNAME!, + E2E_LOGIN_PASSWORD! + ); + const [userId, organizationId] = new URL(id).pathname.split('/').reverse(); + + console.log({ organizationId, userId }); - const conn = new jsforce.Connection({ - oauth2: { - clientId: ENV.SFDC_CONSUMER_KEY, - clientSecret: ENV.SFDC_CONSUMER_SECRET, - loginUrl: E2E_LOGIN_URL, - }, + const jetstreamConn = new ApiConnection({ + apiRequestAdapter: getApiRequestFactoryFn(fetch), + userId, + organizationId, + accessToken: access_token, + apiVersion: ENV.SFDC_API_VERSION, + instanceUrl: instance_url, + logging: false, }); - const userInfo = await conn.loginByOAuth2(E2E_LOGIN_USERNAME!, E2E_LOGIN_PASSWORD!); + const salesforceOrg = await initConnectionFromOAuthResponse({ - conn, - userInfo, - loginUrl: E2E_LOGIN_URL!, + jetstreamConn, userId: 'EXAMPLE_USER', }); + sendJson(res, salesforceOrg); } ); diff --git a/apps/api/src/app/services/auth0.ts b/apps/api/src/app/services/auth0.ts index ec13e9861..f6a407324 100644 --- a/apps/api/src/app/services/auth0.ts +++ b/apps/api/src/app/services/auth0.ts @@ -26,7 +26,7 @@ let _expires: Date; async function initAuthorizationToken(user: UserProfileServer) { try { if (_accessToken && _expires && isBefore(new Date(), _expires)) { - logger.info('[AUTH0] Using existing M2M token', { userId: user.id }); + logger.info( { userId: user.id }, '[AUTH0] Using existing M2M token',); return; } @@ -42,13 +42,13 @@ async function initAuthorizationToken(user: UserProfileServer) { _expires = addHours(addSeconds(new Date(), response.data.expires_in), -1); axiosAuth0.defaults.headers.common['Authorization'] = `Bearer ${_accessToken}`; } catch (ex) { - logger.error('[AUTH0][M2M][ERROR] Obtaining token %s', ex.message, { userId: user.id }); + logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR] Obtaining token %s', ex.message,); if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { - logger.error('[AUTH0][M2M][ERROR][RESPONSE] %o', error.response.data, { userId: user.id }); + logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR][RESPONSE] %o', error.response.data,); } else if (error.request) { - logger.error('[AUTH0][M2M][ERROR][REQUEST] %s', error.message || 'An unknown error has occurred.', { userId: user.id }); + logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR][REQUEST] %s', error.message || 'An unknown error has occurred.',); } } throw new UserFacingError('An unknown error has occurred'); @@ -102,7 +102,7 @@ export async function linkIdentity(user: UserProfileServer, newUserId: string): await initAuthorizationToken(user); const [provider, user_id] = newUserId.split('|'); - logger.info('[AUTH0][IDENTITY][LINK] %s', newUserId, { userId: user.id, provider, secondaryUserId: user_id }); + logger.info({ userId: user.id, provider, secondaryUserId: user_id }, '[AUTH0][IDENTITY][LINK] %s', newUserId); await axiosAuth0.post(`/api/v2/users/${user.id}/identities`, { provider, user_id }); return await getUser(user); @@ -115,7 +115,7 @@ export async function unlinkIdentity( await initAuthorizationToken(user); // TODO: handle better if one step fails - logger.info('[AUTH0][IDENTITY][UNLINK+DELETING]', { userId: user.id, provider, unlinkedUserId: userId }); + logger.info({ userId: user.id, provider, unlinkedUserId: userId }, '[AUTH0][IDENTITY][UNLINK+DELETING]'); await axiosAuth0.delete(`/api/v2/users/${user.id}/identities/${provider}/${userId}`); const userIdToDelete = `${provider}|${userId}`; diff --git a/apps/api/src/app/services/comtd/cometd-init.ts b/apps/api/src/app/services/comtd/cometd-init.ts index 84be661f0..93c1c8a1c 100644 --- a/apps/api/src/app/services/comtd/cometd-init.ts +++ b/apps/api/src/app/services/comtd/cometd-init.ts @@ -2,20 +2,20 @@ * ENDED UP NOT USING THIS STUFF */ import { ENV, logger } from '@jetstream/api-config'; +import { ApiConnection } from '@jetstream/salesforce-api'; import { UserProfileServer } from '@jetstream/types'; import { CometD } from 'cometd'; -import * as jsforce from 'jsforce'; import { CometdReplayExtension } from './cometd-replay-extension'; -export function initCometD(user: UserProfileServer, cometd: CometD, connection: jsforce.Connection) { +export function initCometD(user: UserProfileServer, cometd: CometD, jetstreamConn: ApiConnection) { return new Promise((resolve, reject) => { if (cometd.isDisconnected()) { // This appears to be unsupported cometd.unregisterTransport('websocket'); cometd.configure({ - url: `${connection.instanceUrl}/cometd/${connection.version || ENV.SFDC_API_VERSION}`, + url: `${jetstreamConn.sessionInfo.instanceUrl}/cometd/${jetstreamConn.sessionInfo.apiVersion || ENV.SFDC_API_VERSION}`, requestHeaders: { - Authorization: `Bearer ${connection.accessToken}`, + Authorization: `Bearer ${jetstreamConn.sessionInfo.accessToken}`, }, appendMessageTypeToURL: false, }); @@ -28,28 +28,34 @@ export function initCometD(user: UserProfileServer, cometd: CometD, connection: cometd.handshake((shake) => { if (shake.successful) { - logger.debug('[COMETD][HANDSHAKE][SUCCESS] %s', user.id, { userId: user.id }); + logger.debug({ userId: user.id }, '[COMETD][HANDSHAKE][SUCCESS] %s', user.id); resolve(); } else { - logger.warn('[COMETD][HANDSHAKE][ERROR] %s - %s', shake.error, user.id, { userId: user.id }); + logger.warn({ userId: user.id }, '[COMETD][HANDSHAKE][ERROR] %s - %s', shake.error, user.id); reject(shake); } }); cometd.addListener('/meta/connect', (message) => { - logger.debug('[COMETD] connect - %s', message, { userId: user.id }); + logger.debug({ userId: user.id }, '[COMETD] connect - %s', message); }); cometd.addListener('/meta/disconnect', (message) => { - logger.debug('[COMETD] disconnect - %s', message, { userId: user.id }); + logger.debug({ userId: user.id }, '[COMETD] disconnect - %s', message); }); cometd.addListener('/meta/unsuccessful', (message) => { - logger.debug('[COMETD] unsuccessful - %s', message, { userId: user.id }); + logger.debug({ userId: user.id }, '[COMETD] unsuccessful - %s', message); }); (cometd as any).onListenerException = (exception, subscriptionHandle, isListener, message) => { - logger.warn('[COMETD][LISTENER][ERROR] %s - %s - %o', exception?.message, message, subscriptionHandle, { - isListener, - userId: user.id, - }); + logger.warn( + { + isListener, + userId: user.id, + }, + '[COMETD][LISTENER][ERROR] %s - %s - %o', + exception?.message, + message, + subscriptionHandle + ); }; } else { resolve(); diff --git a/apps/api/src/app/services/oauth.service.ts b/apps/api/src/app/services/oauth.service.ts new file mode 100644 index 000000000..9a399dd91 --- /dev/null +++ b/apps/api/src/app/services/oauth.service.ts @@ -0,0 +1,118 @@ +import { ENV } from '@jetstream/api-config'; +import { SalesforceUserInfo } from '@jetstream/types'; +import { CallbackParamsType, Issuer, generators } from 'openid-client'; + +function getSalesforceAuthClient(loginUrl: string) { + const { Client } = new Issuer({ + authorization_endpoint: `${loginUrl}/services/oauth2/authorize`, + end_session_endpoint: `${loginUrl}/services/oauth2/logout`, + issuer: loginUrl, + jwks_uri: `${loginUrl}/id/keys`, + registration_endpoint: `${loginUrl}/services/oauth2/register`, + revocation_endpoint: `${loginUrl}/services/oauth2/revoke`, + token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'private_key_jwt'], + token_endpoint: `${loginUrl}/services/oauth2/token`, + userinfo_endpoint: `${loginUrl}/services/oauth2/userinfo`, + }); + + const authClient = new Client({ + client_id: ENV.SFDC_CONSUMER_KEY, + client_secret: ENV.SFDC_CONSUMER_SECRET, + redirect_uris: [ENV.SFDC_CALLBACK_URL], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + token_endpoint_auth_signing_alg: 'RS256', + }); + return authClient; +} + +/** + * Get redirectUrl and authData for Salesforce OAuth + */ +export function salesforceOauthInit(loginUrl: string, loginHint?: string) { + // https://login.salesforce.com/.well-known/openid-configuration + + const nonce = generators.nonce(); + const code_verifier = generators.codeVerifier(); + const state = generators.state(); + const code_challenge = generators.codeChallenge(code_verifier); + + const authClient = getSalesforceAuthClient(loginUrl); + + const authorizationUrl = authClient.authorizationUrl({ + code_challenge_method: 'S256', + code_challenge, + login_hint: loginHint, + nonce, + prompt: 'login', + scope: 'api web refresh_token', + state, + }); + + return { code_verifier, nonce, state, authorizationUrl }; +} + +/** + * Verify OAuth callback and get access_token, refresh_token, and userInfo + */ +export async function salesforceOauthCallback( + loginUrl: string, + callbackQueryParams: CallbackParamsType, + authData: { + code_verifier: string; + nonce: string; + state: string; + } +) { + const authClient = getSalesforceAuthClient(loginUrl); + + const tokenSet = await authClient.oauthCallback(ENV.SFDC_CALLBACK_URL, callbackQueryParams, authData); + const { access_token, refresh_token } = tokenSet; + const userInfo = await authClient.userinfo(access_token!); + + return { + access_token, + refresh_token, + userInfo, + }; +} + +export async function salesforceOauthRefresh(loginUrl: string, refreshToken: string) { + const authClient = getSalesforceAuthClient(loginUrl); + const tokenSet = await authClient.refresh(refreshToken); + const { access_token } = tokenSet; + return { + access_token, + }; +} + +/** + * Login to Salesforce using username and password + */ +export async function salesforceLoginUsernamePassword_UNSAFE( + loginUrl: string, + username: string, + password: string +): Promise<{ + access_token: string; + instance_url: string; + id: string; + token_type: string; + issued_at: string; + signature: string; +}> { + return await fetch(`${loginUrl}/services/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'password', + username, + password, + client_id: ENV.SFDC_CONSUMER_KEY, + client_secret: ENV.SFDC_CONSUMER_SECRET, + redirect_uri: ENV.SFDC_CALLBACK_URL, + }).toString(), + }).then((res) => res.json()); +} diff --git a/apps/api/src/app/services/query.ts b/apps/api/src/app/services/query.ts deleted file mode 100644 index 00585c1ea..000000000 --- a/apps/api/src/app/services/query.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ -import { logger } from '@jetstream/api-config'; -import { QueryResults, QueryResultsColumn, QueryResultsColumns } from '@jetstream/api-interfaces'; -import type { Connection } from 'jsforce'; -import { Query, parseQuery } from 'soql-parser-js'; -import { QueryColumnMetadata, QueryColumnsSfdc } from '../types/types'; - -/** - * TODO: - * this code should be moved to shared query utils - * code was already copied there - */ - -export async function queryRecords( - conn: Connection, - query: string, - isTooling = false, - includeDeletedRecords = false -): Promise { - // Fetch records from SFDC - const queryResults = await (isTooling - ? conn.tooling.query(query, { scanAll: includeDeletedRecords }) - : conn.query(query, { scanAll: includeDeletedRecords })); - - let columns: QueryResultsColumns | undefined; - let parsedQuery: Query | undefined; - - // get column info from SFDC - try { - const tempColumns = (await conn.request({ - method: 'GET', - url: `${isTooling ? '/tooling' : ''}/query/?${new URLSearchParams({ - q: query, - columns: 'true', - }).toString()}`, - })) as QueryColumnsSfdc; - - columns = { - entityName: tempColumns.entityName, - groupBy: tempColumns.groupBy, - idSelected: tempColumns.idSelected, - keyPrefix: tempColumns.keyPrefix, - columns: tempColumns.columnMetadata?.flatMap((column) => flattenQueryColumn(column)), - }; - } catch (ex) { - logger.error('Error fetching columns', ex); - } - - // Attempt to parse columns from query - try { - parsedQuery = parseQuery(query); - } catch (ex) { - logger.info('Error parsing query'); - } - - return { queryResults, columns, parsedQuery }; -} - -export async function queryMoreRecords(conn: Connection, nextRecordsUrl: string, isTooling = false): Promise { - const queryResults = await (isTooling ? conn.tooling.queryMore(nextRecordsUrl) : conn.queryMore(nextRecordsUrl)); - return { queryResults }; -} - -////////// PRIVATE /////////////// - -/** - * - * @param column - * @param prevColumnPath - */ - -function flattenQueryColumn(column: QueryColumnMetadata, prevColumnPath?: string): QueryResultsColumn[] { - let output: QueryResultsColumn[] = []; - const currColumnPath = `${prevColumnPath ? `${prevColumnPath}.` : ''}${column.columnName}`; - - if (Array.isArray(column.joinColumns) && column.joinColumns.length > 0) { - if (column.foreignKeyName) { - // Parent Query - output = output.concat((column.joinColumns || [])?.flatMap((joinColumn) => flattenQueryColumn(joinColumn, currColumnPath))); - } else { - // Child query - output.push({ - columnFullPath: currColumnPath, - aggregate: column.aggregate, - apexType: column.apexType, - booleanType: column.booleanType, - columnName: column.columnName, - custom: column.custom, - displayName: column.displayName, - foreignKeyName: column.foreignKeyName, - insertable: column.insertable, - numberType: column.numberType, - textType: column.textType, - updatable: column.updatable, - childColumnPaths: (column.joinColumns || [])?.flatMap((joinColumn) => flattenQueryColumn(joinColumn, currColumnPath)), - }); - } - } else { - output.push({ - columnFullPath: currColumnPath, - aggregate: column.aggregate, - apexType: column.apexType, - booleanType: column.booleanType, - columnName: column.columnName, - custom: column.custom, - displayName: column.displayName, - foreignKeyName: column.foreignKeyName, - insertable: column.insertable, - numberType: column.numberType, - textType: column.textType, - updatable: column.updatable, - }); - } - return output; -} diff --git a/apps/api/src/app/services/salesforce.service.ts b/apps/api/src/app/services/salesforce.service.ts new file mode 100644 index 000000000..28b490c93 --- /dev/null +++ b/apps/api/src/app/services/salesforce.service.ts @@ -0,0 +1,110 @@ +import { ensureArray, getFullNameFromListMetadata, orderObjectsBy } from '@jetstream/shared/utils'; +import { ListMetadataResult, MapOf, Maybe, PackageTypeMembers, RetrieveRequest } from '@jetstream/types'; +import { isObjectLike, isString, get as lodashGet } from 'lodash'; +import { create as xmlBuilder } from 'xmlbuilder2'; +import { UserFacingError } from '../utils/error-handler'; + +const VALID_PACKAGE_VERSION = /^[0-9]+\.[0-9]+$/; + +export function buildPackageXml( + types: MapOf[]>, + version: string, + otherFields: Maybe> = {}, + prettyPrint = true +) { + // prettier-ignore + const packageNode = xmlBuilder({ version: '1.0', encoding: 'UTF-8' }) + .ele('Package', { xmlns: 'http://soap.sforce.com/2006/04/metadata' }); + + Object.keys(types).forEach((metadataType) => { + const typesNode = packageNode.ele('types'); + if (types[metadataType].length) { + orderObjectsBy(types[metadataType], 'fullName').forEach(({ fullName, namespacePrefix }) => { + typesNode.ele('members').txt( + getFullNameFromListMetadata({ + fullName, + metadataType, + namespace: namespacePrefix, + }) + ); + }); + typesNode.ele('name').txt(metadataType); + } + }); + + if (otherFields) { + Object.keys(otherFields).forEach((key) => { + packageNode.ele(key).txt(otherFields[key]); + }); + } + + packageNode.ele('version').txt(version); + + return packageNode.end({ prettyPrint }); +} + +export function getRetrieveRequestFromListMetadata( + types: MapOf[]>, + version: string +) { + // https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_retrieve_request.htm + const retrieveRequest: RetrieveRequest = { + apiVersion: version, + singlePackage: true, + unpackaged: { + types: Object.keys(types).map((metadataName) => { + const members = types[metadataName]; + return { + members: members.map(({ fullName, namespacePrefix }) => { + return getFullNameFromListMetadata({ + fullName, + metadataType: metadataName, + namespace: namespacePrefix, + }); + }), + name: metadataName, + }; + }), + version: version, + }, + }; + return retrieveRequest; +} + +/** + * TODO: should we handle other packages fields? + * + * @param packageManifest + */ +export function getRetrieveRequestFromManifest(packageManifest: string) { + let manifestXml; + try { + manifestXml = xmlBuilder(packageManifest).toObject({ wellFormed: true }) as any; + } catch (ex) { + throw new UserFacingError('The package manifest format is invalid'); + } + // validate parsed package manifest + if (!manifestXml || Array.isArray(manifestXml)) { + throw new UserFacingError('The package manifest format is invalid'); + } else { + const version: string = lodashGet(manifestXml, 'Package.version'); + let types: PackageTypeMembers[] = lodashGet(manifestXml, 'Package.types'); + if (isObjectLike(types)) { + types = ensureArray(types); + } + if (!isString(version) || !VALID_PACKAGE_VERSION.test(version)) { + throw new UserFacingError('The package manifest version is invalid or is missing'); + } else if (!Array.isArray(types) || !types.length) { + throw new UserFacingError('The package manifest is missing types'); + } + + const retrieveRequest: RetrieveRequest = { + apiVersion: version, + unpackaged: { + types, + version: version, + }, + }; + return retrieveRequest; + } +} diff --git a/apps/api/src/app/services/sf-bulk.ts b/apps/api/src/app/services/sf-bulk.ts deleted file mode 100644 index e0657ae17..000000000 --- a/apps/api/src/app/services/sf-bulk.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { HTTP } from '@jetstream/shared/constants'; -import { bulkApiEnsureTyped, ensureArray } from '@jetstream/shared/utils'; -import { - BulkApiCreateJobRequestPayload, - BulkApiDownloadType, - BulkJob, - BulkJobBatchInfo, - BulkJobBatchInfoUntyped, - BulkJobUntyped, - BulkJobWithBatches, -} from '@jetstream/types'; -import * as jsforce from 'jsforce'; -import { isString } from 'lodash'; -import * as request from 'superagent'; -import { create as xmlBuilder, convert as xmlConverter } from 'xmlbuilder2'; - -const { HEADERS, CONTENT_TYPE } = HTTP; - -export async function sfBulkCreateJob(conn: jsforce.Connection, options: BulkApiCreateJobRequestPayload): Promise { - const { type, sObject, assignmentRuleId, serialMode, externalId, hasZipAttachment } = options; - // prettier-ignore - const jobInfoNode = xmlBuilder({ version: '1.0', encoding: 'UTF-8' }) - .ele('jobInfo', { xmlns: 'http://www.force.com/2009/06/asyncapi/dataload' }) - .ele('operation').txt(type.toLowerCase()).up() - .ele('object').txt(sObject).up(); - - if (type === 'UPSERT' && externalId) { - jobInfoNode.ele('externalIdFieldName').txt(externalId).up(); - } - - // job fails if these come before externalIdFieldName - // prettier-ignore - jobInfoNode.ele('concurrencyMode').txt(serialMode ? 'Serial' : 'Parallel').up(); - - if (hasZipAttachment) { - jobInfoNode.ele('contentType').txt('ZIP_CSV').up(); - } else { - jobInfoNode.ele('contentType').txt('CSV').up(); - } - - // If this does not come last, Salesforce explodes - if (isString(assignmentRuleId) && assignmentRuleId) { - jobInfoNode.ele('assignmentRuleId').txt(assignmentRuleId).up(); - } - - const xml = jobInfoNode.end({ prettyPrint: true }); - - const requestOptions: jsforce.RequestInfo = { - method: 'POST', - url: `/services/async/${conn.version}/job`, - body: xml, - headers: { [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.CSV, Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }, - }; - - const results = await conn - .request(requestOptions, { responseType: 'text/xml' }) - .then(({ jobInfo }: { jobInfo: BulkJobUntyped }) => bulkApiEnsureTyped(jobInfo)); - - return results; -} - -export async function sfBulkGetJobInfo(conn: jsforce.Connection, jobId: string): Promise { - const requestOptions: jsforce.RequestInfo = { - method: 'GET', - url: `/services/async/${conn.version}/job/${jobId}`, - headers: { Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }, - }; - const requestOptionsBatch: jsforce.RequestInfo = { - ...requestOptions, - url: `/services/async/${conn.version}/job/${jobId}/batch`, - }; - - const [jobResults, batchesResults] = await Promise.all([ - conn - .request(requestOptions, { responseType: 'text/xml' }) - .then(({ jobInfo }: { jobInfo: BulkJobUntyped }) => bulkApiEnsureTyped(jobInfo)), - conn - .request(requestOptionsBatch, { responseType: 'text/xml' }) - .then(({ batchInfoList }: { batchInfoList: { batchInfo: BulkJobBatchInfoUntyped[] } }) => ensureArray(batchInfoList.batchInfo)) - .then((batchInfoItems) => batchInfoItems.map((batchInfo) => bulkApiEnsureTyped(batchInfo))), - ]); - - return { ...jobResults, batches: batchesResults || [] }; -} - -export async function sfBulkCloseOrAbortJob( - conn: jsforce.Connection, - jobId: string, - state: 'Closed' | 'Aborted' = 'Closed' -): Promise { - // prettier-ignore - const xml = xmlBuilder({ version: '1.0', encoding: 'UTF-8' }) - .ele('jobInfo', { xmlns: 'http://www.force.com/2009/06/asyncapi/dataload' }) - .ele('state').txt(state).up() - .end({ prettyPrint: true }); - - const requestOptions: jsforce.RequestInfo = { - method: 'POST', - url: `/services/async/${conn.version}/job/${jobId}`, - body: xml, - headers: { [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.CSV, Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }, - }; - - const results = await conn - .request(requestOptions, { responseType: 'text/xml' }) - .then(({ jobInfo }: { jobInfo: BulkJobUntyped }) => bulkApiEnsureTyped(jobInfo)); - - return results; -} - -export async function sfBulkAddBatchToJob( - conn: jsforce.Connection, - csv: string | Buffer | ArrayBuffer, - jobId: string, - closeJob = false -): Promise { - const results = await request - .post(`${conn.instanceUrl}/services/async/${conn.version}/job/${jobId}/batch`) - .set({ [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.CSV, Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }) - .send(csv) - .then((res) => { - const resultXml = xmlConverter((res.body as Buffer).toString(), { format: 'object', wellFormed: true }) as any; - const bulkJob = bulkApiEnsureTyped(resultXml.batchInfo); - return bulkJob; - }); - - if (closeJob) { - await sfBulkCloseOrAbortJob(conn, jobId, 'Closed'); - } - return results; -} - -export async function sfBulkGetQueryResultsJobIds(conn: jsforce.Connection, jobId: string, batchId: string): Promise { - const results = await request - .get(`${conn.instanceUrl}/services/async/${conn.version}/job/${jobId}/batch/${batchId}/result`) - .set({ Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }) - .then((res) => { - // https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_code_curl_walkthrough.htm - // {result-list: Object {@xmlns: "http://www.force.com/2009/06/asyncapi/dataload", result: "752x00000004CJE"}} - const resultXml = xmlConverter((res.body as Buffer).toString(), { format: 'object', wellFormed: true }) as any; - let resultIds = resultXml['result-list'].result; - // FIXME: there could potentially be multiple results - if (!Array.isArray(resultIds) && resultIds) { - resultIds = [resultIds]; - } - return resultIds || []; - }); - return results; -} - -export async function sfBulkAddBatchWithZipAttachmentToJob( - conn: jsforce.Connection, - zip: Buffer | ArrayBuffer, - jobId: string, - closeJob = false -): Promise { - const results = await request - .post(`${conn.instanceUrl}/services/async/${conn.version}/job/${jobId}/batch`) - .set({ [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.ZIP_CSV, Accept: CONTENT_TYPE.XML, [HEADERS.X_SFDC_Session]: conn.accessToken }) - .send(zip) - .then((res) => { - const resultXml = xmlConverter((res.body as Buffer).toString(), { format: 'object', wellFormed: true }) as any; - const bulkJob = bulkApiEnsureTyped(resultXml.batchInfo); - return bulkJob; - }); - - if (closeJob) { - await sfBulkCloseOrAbortJob(conn, jobId, 'Closed'); - } - return results; -} - -export function sfBulkDownloadRecords( - conn: jsforce.Connection, - jobId: string, - batchId: string, - type: BulkApiDownloadType, - /** For query jobs, an id is required */ - resultId?: string -): request.SuperAgentRequest { - return request - .get(`${conn.instanceUrl}/services/async/${conn.version}/job/${jobId}/batch/${batchId}/${type}${resultId ? `/${resultId}` : ''}`) - .set({ [HEADERS.CONTENT_TYPE]: CONTENT_TYPE.XML_UTF8, Accept: CONTENT_TYPE.CSV, [HEADERS.X_SFDC_Session]: conn.accessToken }); -} diff --git a/apps/api/src/app/services/sf-misc.ts b/apps/api/src/app/services/sf-misc.ts deleted file mode 100644 index 595212ae6..000000000 --- a/apps/api/src/app/services/sf-misc.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { ENV } from '@jetstream/api-config'; -import { HTTP, ORG_VERSION_PLACEHOLDER } from '@jetstream/shared/constants'; -import { ensureArray, getFullNameFromListMetadata, orderObjectsBy } from '@jetstream/shared/utils'; -import { ListMetadataResult, ManualRequestPayload, ManualRequestResponse, MapOf } from '@jetstream/types'; -import axios, { AxiosError, AxiosRequestConfig } from 'axios'; -import type { PackageTypeMembers, RetrieveRequest } from 'jsforce'; -import * as jsforce from 'jsforce'; -import { isObjectLike, isString, get as lodashGet } from 'lodash'; -import { create as xmlBuilder } from 'xmlbuilder2'; -import { UserFacingError } from '../utils/error-handler'; - -const SESSION_ID_RGX = /\{sessionId\}/i; -const VALID_PACKAGE_VERSION = /^[0-9]+\.[0-9]+$/; - -export interface SalesforceRequestViaAxiosOptions extends ManualRequestPayload { - conn: jsforce.Connection; - /** - * If true, the function will throw an error if the request fails - * @default false - */ - throwIfError?: boolean; - /** - * If true, the function will attempt to refresh the token and retry the request - * @default true - */ - retryOnAuthFailure?: boolean; -} - -/** - * Make API call to Salesforce without using JSForce - */ -export async function salesforceRequestViaAxios(options: SalesforceRequestViaAxiosOptions): Promise { - const { conn, method, headers = {}, throwIfError = false, retryOnAuthFailure = true } = options; - let { body, url } = options; - try { - url = url.replace(ORG_VERSION_PLACEHOLDER, conn.version); - - const config: AxiosRequestConfig = { - url, - method, - baseURL: conn.instanceUrl, - // X-SFDC-Session is used for some SOAP APIs, such as the bulk api - headers: { - [HTTP.HEADERS.CONTENT_TYPE]: HTTP.CONTENT_TYPE.JSON, - [HTTP.HEADERS.ACCEPT]: HTTP.CONTENT_TYPE.JSON, - ...headers, - ['Authorization']: `Bearer ${conn.accessToken}`, - ['X-SFDC-Session']: conn.accessToken, - }, - responseType: 'text', - // validateStatus: false, - timeout: 120000, - transformResponse: [], // required to avoid automatic json parsing - }; - - if (isString(body) && SESSION_ID_RGX.test(body)) { - body = body.replace(SESSION_ID_RGX, conn.accessToken); - } - - if (method !== 'GET' && body) { - config.data = body; - } - - const response = await axios.request(config); - - return { - error: false, - status: response.status, - statusText: response.statusText, - headers: JSON.stringify(response.headers || {}, null, 2), - body: response.data, - }; - } catch (ex) { - if (retryOnAuthFailure && ex instanceof AxiosError) { - const response = ex.response; - if (response.status === 401 || (isString(response.data) && response.data.includes('INVALID_SESSION_ID'))) { - // attempt another API call which should auto-refresh and try again - try { - await refreshAccessToken(conn); - } catch (ex) { - console.error('Failed to refresh token', ex); - } - return await salesforceRequestViaAxios({ ...options, retryOnAuthFailure: false }); - } - } - if (throwIfError) { - throw ex; - } - if (ex instanceof AxiosError) { - const response = ex.response; - if (response) { - return { - error: response.status < 200 || response.status > 300, - status: response.status, - statusText: response.statusText, - headers: JSON.stringify(response.headers || {}, null, 2), - body: response.data, - }; - } else if (ex.request) { - return { - error: true, - errorMessage: ex.message || 'An unknown error has occurred.', - status: null, - statusText: null, - headers: null, - body: null, - }; - } - } else if (ex instanceof Error) { - return { - error: true, - errorMessage: ex.message || 'An unknown error has occurred.', - status: null, - statusText: null, - headers: null, - body: null, - }; - } - return { - error: true, - errorMessage: ex?.message || 'An unknown error has occurred, the request was not made.', - status: null, - statusText: null, - headers: null, - body: null, - }; - } -} - -export async function refreshAccessToken(conn: jsforce.Connection): Promise { - try { - const response = await axios.request<{ access_token: string }>({ - url: '/services/oauth2/token', - method: 'POST', - baseURL: conn.instanceUrl, - // X-SFDC-Session is used for some SOAP APIs, such as the bulk api - headers: { - [HTTP.HEADERS.CONTENT_TYPE]: HTTP.CONTENT_TYPE.FORM_URL, - [HTTP.HEADERS.ACCEPT]: HTTP.CONTENT_TYPE.JSON, - }, - responseType: 'json', - timeout: 20000, - data: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: ENV.SFDC_CONSUMER_KEY, - client_secret: ENV.SFDC_CONSUMER_SECRET, - refresh_token: conn.refreshToken, - }).toString(), - }); - - conn.accessToken = response.data.access_token; - try { - conn.emit('refresh', conn.accessToken, conn.refreshToken); - } catch (ex) { - console.error('Failed to emit refresh event', ex); - } - } catch (ex) { - console.error('Failed to refresh token', ex); - throw ex; - } -} - -export function buildPackageXml(types: MapOf, version: string, otherFields: MapOf = {}, prettyPrint = true) { - // prettier-ignore - const packageNode = xmlBuilder({ version: '1.0', encoding: 'UTF-8' }) - .ele('Package', { xmlns: 'http://soap.sforce.com/2006/04/metadata' }); - - Object.keys(types).forEach((metadataType) => { - const typesNode = packageNode.ele('types'); - if (types[metadataType].length) { - orderObjectsBy(types[metadataType], 'fullName').forEach(({ fullName, namespacePrefix }) => { - typesNode.ele('members').txt( - getFullNameFromListMetadata({ - fullName, - metadataType, - namespace: namespacePrefix, - }) - ); - }); - typesNode.ele('name').txt(metadataType); - } - }); - - if (otherFields) { - Object.keys(otherFields).forEach((key) => { - packageNode.ele(key).txt(otherFields[key]); - }); - } - - packageNode.ele('version').txt(version); - - return packageNode.end({ prettyPrint }); -} - -export function getRetrieveRequestFromListMetadata(types: MapOf, version: string) { - // https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_retrieve_request.htm - const retrieveRequest: RetrieveRequest = { - apiVersion: version, - singlePackage: true, - unpackaged: { - types: Object.keys(types).map((metadataName) => { - const members = types[metadataName]; - return { - members: members.map(({ fullName, namespacePrefix }) => { - return getFullNameFromListMetadata({ - fullName, - metadataType: metadataName, - namespace: namespacePrefix, - }); - }), - name: metadataName, - }; - }), - version: version, - }, - }; - return retrieveRequest; -} - -/** - * TODO: should we handle other packages fields? - * - * @param packageManifest - */ -export function getRetrieveRequestFromManifest(packageManifest: string) { - let manifestXml; - try { - manifestXml = xmlBuilder(packageManifest).toObject({ wellFormed: true }) as any; - } catch (ex) { - throw new UserFacingError('The package manifest format is invalid'); - } - // validate parsed package manifest - if (!manifestXml || Array.isArray(manifestXml)) { - throw new UserFacingError('The package manifest format is invalid'); - } else { - const version: string = lodashGet(manifestXml, 'Package.version'); - let types: PackageTypeMembers[] = lodashGet(manifestXml, 'Package.types'); - if (isObjectLike(types)) { - types = ensureArray(types); - } - if (!isString(version) || !VALID_PACKAGE_VERSION.test(version)) { - throw new UserFacingError('The package manifest version is invalid or is missing'); - } else if (!Array.isArray(types) || !types.length) { - throw new UserFacingError('The package manifest is missing types'); - } - - const retrieveRequest: RetrieveRequest = { - apiVersion: version, - unpackaged: { - types, - version: version, - }, - }; - return retrieveRequest; - } -} diff --git a/apps/api/src/app/services/worker-jobs.ts b/apps/api/src/app/services/worker-jobs.ts index 50165b250..13ca4b6bd 100644 --- a/apps/api/src/app/services/worker-jobs.ts +++ b/apps/api/src/app/services/worker-jobs.ts @@ -1,4 +1,4 @@ -import { ENV, logger } from '@jetstream/api-config'; +import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; import { User } from '@prisma/client'; import axios, { AxiosError } from 'axios'; @@ -21,9 +21,9 @@ export async function sendWelcomeEmail(user: User) { if (ex.isAxiosError) { if (ex.response) { const errorResponse = (ex as AxiosError).response; - logger.error('[WORKER-SERVICE][WELCOME EMAIL][ERROR] %s %o', errorResponse?.status, errorResponse?.data); + logger.error(getExceptionLog(ex), '[WORKER-SERVICE][WELCOME EMAIL][ERROR] %s %o', errorResponse?.status, errorResponse?.data); } else { - logger.error('[WORKER-SERVICE][WELCOME EMAIL][ERROR] Unknown error occurred', ex.message); + logger.error(getExceptionLog(ex), '[WORKER-SERVICE][WELCOME EMAIL][ERROR] Unknown error occurred'); } } } diff --git a/apps/api/src/app/types/types.ts b/apps/api/src/app/types/types.ts index 9860c351a..f99ffe26e 100644 --- a/apps/api/src/app/types/types.ts +++ b/apps/api/src/app/types/types.ts @@ -1,22 +1,31 @@ -export interface QueryColumnsSfdc { - columnMetadata: QueryColumnMetadata[]; - entityName: string; - groupBy: boolean; - idSelected: boolean; - keyPrefix: string; -} +import { ApiConnection } from '@jetstream/salesforce-api'; +import { SalesforceOrg } from '@prisma/client'; +import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; +import type pino from 'pino'; -export interface QueryColumnMetadata { - aggregate: boolean; - apexType: string; - booleanType: boolean; - columnName: string; - custom: boolean; - displayName: string; - foreignKeyName?: any; - insertable: boolean; - joinColumns: QueryColumnMetadata[]; - numberType: boolean; - textType: boolean; - updatable: boolean; -} +export type Request< + Params extends Record | unknown = Record, + ReqBody = any, + Query extends Record | unknown = Record +> = ExpressRequest< + Params, + unknown, + ReqBody, + Query, + { + requestId: string; + jetstreamConn: ApiConnection; + targetJetstreamConn?: ApiConnection; + } +> & { log: pino.Logger }; + +export type Response = ExpressResponse< + ResBody, + { + requestId: string; + jetstreamConn: ApiConnection; + org: SalesforceOrg; + targetJetstreamConn?: ApiConnection; + targetOrg?: SalesforceOrg; + } +> & { log: pino.Logger }; diff --git a/apps/api/src/app/utils/auth-utils.ts b/apps/api/src/app/utils/auth-utils.ts index 6aaf6f621..1f0ae00fc 100644 --- a/apps/api/src/app/utils/auth-utils.ts +++ b/apps/api/src/app/utils/auth-utils.ts @@ -1,5 +1,3 @@ -import { ENV } from '@jetstream/api-config'; -import * as jsforce from 'jsforce'; import * as Auth0Strategy from 'passport-auth0'; interface AuthorizationParamsOptions { @@ -14,15 +12,6 @@ interface AuthorizationParamsOptions { nonce?: string; } -export function getJsforceOauth2(loginUrl: string) { - return new jsforce.OAuth2({ - loginUrl, - clientId: ENV.SFDC_CONSUMER_KEY, - clientSecret: ENV.SFDC_CONSUMER_SECRET, - redirectUri: ENV.SFDC_CALLBACK_URL, - }); -} - // Monkey Patch Auth0Strategy to allow directing a user to the login page // :sob: - https://github.com/auth0/passport-auth0/issues/53 // https://auth0.com/docs/universal-login/new-experience#signup diff --git a/apps/api/src/app/utils/error-handler.ts b/apps/api/src/app/utils/error-handler.ts index a135ff033..16bc01592 100644 --- a/apps/api/src/app/utils/error-handler.ts +++ b/apps/api/src/app/utils/error-handler.ts @@ -1,25 +1,45 @@ import { logger } from '@jetstream/api-config'; +import { ZodError } from 'zod'; /* eslint-disable @typescript-eslint/no-explicit-any */ export class UserFacingError extends Error { additionalData?: any; - constructor(message: string | Error, additionalData?: any) { - if (message instanceof Error) { + constructor(message: string | Error | ZodError, additionalData?: any) { + if (message instanceof ZodError) { + const errorDetails = Object.values( + message.flatten((issue) => ({ + message: `Invalid request: '${issue.path.join('.')}' is ${issue.message}`, + errorCode: issue.code, + })).fieldErrors + ); + + const formattedMessage = errorDetails + .flatMap((item) => item) + .map((item) => item?.message) + .filter(Boolean) + .join(', '); + + super(formattedMessage); + this.additionalData = message.errors; + this.name = 'Validation Error'; + this.stack = message.stack; + } else if (message instanceof Error) { if (message.message.startsWith('(res: express.Response, content?: ResponseType, status = 200) { if (res.headersSent) { - logger.warn('Response headers already sent', { requestId: res.locals.requestId }); + res.log.warn('Response headers already sent'); try { rollbarServer.warn('Response not handled by sendJson, headers already sent', new Error('headers already sent'), { requestId: res.locals.requestId, }); } catch (ex) { - logger.error('Error sending to Rollbar', ex, { requestId: res.locals.requestId }); + res.log.error(getExceptionLog(ex), 'Error sending to Rollbar'); } return; } @@ -39,42 +39,22 @@ export function sendJson(res: express.Response, content?: Re } export function blockBotHandler(req: express.Request, res: express.Response) { - logger.debug('[BLOCKED REQUEST] %s %s', req.method, req.originalUrl, { - blocked: true, - method: req.method, - url: req.originalUrl, - requestId: res.locals.requestId, - agent: req.header('User-Agent'), - referrer: req.get('Referrer'), - ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, - country: req.headers[HTTP.HEADERS.CF_IPCountry], - }); + res.log.debug('[BLOCKED REQUEST] %s %s'); res.status(403).send('Forbidden'); } -// TODO: implement user facing errors and system facing errors and separate them -// TODO: this should handle ALL errors, and controllers need to throw proper errors! // eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function uncaughtErrorHandler(err: any, req: express.Request, res: express.Response, next: express.NextFunction) { const userInfo = req.user ? { username: (req.user as any)?.displayName, userId: (req.user as any)?.user_id } : undefined; - logger.warn('[RESPONSE][ERROR] %s', err.message, { - error: err.message || err, - method: req.method, - url: req.originalUrl, - requestId: res.locals.requestId, - agent: req.header('User-Agent'), - ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, - country: req.headers[HTTP.HEADERS.CF_IPCountry], - ...userInfo, - }); + res.log.warn(getExceptionLog(err), '[RESPONSE][ERROR]'); if (res.headersSent) { - logger.warn('Response headers already sent', { requestId: res.locals.requestId }); + res.log.warn('Response headers already sent'); try { rollbarServer.warn('Error not handled by error handler, headers already sent', req, userInfo, err, new Error('headers already sent')); } catch (ex) { - logger.error('Error sending to Rollbar', ex, { requestId: res.locals.requestId }); + res.log.error(getExceptionLog(ex), 'Error sending to Rollbar'); } return; } @@ -94,11 +74,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: const org = res.locals.org as SalesforceOrg; await salesforceOrgsDb.updateOrg_UNSAFE(org, { connectionError: ERROR_MESSAGES.SFDC_EXPIRED_TOKEN }); } catch (ex) { - logger.warn('[RESPONSE][ERROR UPDATING INVALID ORG] %s', ex.message, { - error: ex.message, - userInfo, - requestId: res.locals.requestId, - }); + res.log.warn(getExceptionLog(ex), '[RESPONSE][ERROR UPDATING INVALID ORG'); } } @@ -142,15 +118,10 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: } } - // TODO: clean up everything below this - - logger.error(err.message, { userInfo, requestId: res.locals.requestId }); - logger.error(err.stack, { userInfo, requestId: res.locals.requestId }); - try { rollbarServer.warn('Error not handled by error handler', req, userInfo, err); } catch (ex) { - logger.error('Error sending to Rollbar', ex, { requestId: res.locals.requestId }); + res.log.error(getExceptionLog(ex), 'Error sending to Rollbar'); } const errorMessage = 'There was an error processing the request'; diff --git a/apps/api/src/app/utils/route.utils.ts b/apps/api/src/app/utils/route.utils.ts new file mode 100644 index 000000000..fedf18f24 --- /dev/null +++ b/apps/api/src/app/utils/route.utils.ts @@ -0,0 +1,83 @@ +import { getExceptionLog } from '@jetstream/api-config'; +import { ApiConnection } from '@jetstream/salesforce-api'; +import { UserProfileServer } from '@jetstream/types'; +import { NextFunction } from 'express'; +import { z } from 'zod'; +import { findByUniqueId_UNSAFE } from '../db/salesforce-org.db'; +import { Request, Response } from '../types/types'; +import { UserFacingError } from './error-handler'; + +// FIXME: when these were used, createRoute did not properly infer types +// export type RouteValidator = Parameters[0]; +// export type RouteDefinition = { +// controllerFn: () => ReturnType; +// validators: RouteValidator; +// }; +// export type RouteDefinitions = Record; + +export type ControllerFunction = ( + data: { + params: z.infer; + body: z.infer; + query: z.infer; + jetstreamConn: ApiConnection; + targetJetstreamConn: ApiConnection; + user: UserProfileServer; + requestId: string; + org: NonNullable>>; + targetOrg: NonNullable>>; + }, + req: Request, + res: Response, + next: NextFunction +) => Promise | void; + +export function createRoute( + { + params, + body, + query, + hasSourceOrg = true, + hasTargetOrg = false, + }: { + params?: TParamsSchema; + body?: TBodySchema; + query?: TQuerySchema; + /** + * Set to false to skip validating that an org exists on the request + * @default true + */ + hasSourceOrg?: boolean; + hasTargetOrg?: boolean; + }, + controllerFn: ControllerFunction +) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const data = { + params: params ? params.parse(req.params) : undefined, + body: body ? body.parse(req.body) : undefined, + query: query ? query.parse(req.query) : undefined, + jetstreamConn: res.locals.jetstreamConn, + targetJetstreamConn: res.locals.targetJetstreamConn!, + org: res.locals.org as NonNullable>>, + // this will exist if targetJetstreamConn exists, otherwise will throw + targetOrg: res.locals.targetOrg as NonNullable>>, + user: req.user as UserProfileServer, + requestId: res.locals.requestId, + }; + if (hasSourceOrg && !data.jetstreamConn) { + req.log.info('[INIT-ORG][ERROR] A source org did not exist on locals'); + return next(new UserFacingError('An org is required for this action')); + } + if (hasTargetOrg && !data.targetJetstreamConn) { + req.log.info('[INIT-ORG][ERROR] A target org did not exist on locals'); + return next(new UserFacingError('A source and target org are required for this action')); + } + await controllerFn(data, req, res, next); + } catch (ex) { + req.log.error(getExceptionLog(ex), '[ROUTE][VALIDATION ERROR]'); + next(new UserFacingError(ex)); + } + }; +} diff --git a/apps/api/src/app/utils/socket-utils.ts b/apps/api/src/app/utils/socket-utils.ts index 11cd9a78c..36be04625 100644 --- a/apps/api/src/app/utils/socket-utils.ts +++ b/apps/api/src/app/utils/socket-utils.ts @@ -33,10 +33,14 @@ export function disconnectCometD( cometd.clearSubscriptions(); if (!cometd.isDisconnected()) { cometd.disconnect((message) => { - logger.debug('[COMTED][DISCONNECT] Disconnected', message, { - socketId: socket?.id || 'unknown', - userId: user?.id || 'unknown', - }); + logger.debug( + { + socketId: socket?.id || 'unknown', + userId: user?.id || 'unknown', + message, + }, + '[COMTED][DISCONNECT] Disconnected' + ); }); } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 19e80d68a..5cff65d75 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,5 @@ import '@jetstream/api-config'; // this gets imported first to ensure as some items require early initialization -import { ENV, logger, pgPool } from '@jetstream/api-config'; +import { ENV, getExceptionLog, httpLogger, logger, pgPool } from '@jetstream/api-config'; import { HTTP, SESSION_EXP_DAYS } from '@jetstream/shared/constants'; import { json, raw, urlencoded } from 'body-parser'; import cluster from 'cluster'; @@ -19,7 +19,6 @@ import { apiRoutes, oauthRoutes, platformEventRoutes, staticAuthenticatedRoutes, import { addContextMiddleware, blockBotByUserAgentMiddleware, - logRoute, notFoundMiddleware, setApplicationCookieMiddleware, } from './app/routes/route.middleware'; @@ -29,6 +28,7 @@ import { environment } from './environments/environment'; declare module 'express-session' { interface SessionData { activityExp: number; + orgAuth?: { code_verifier: string; nonce: string; state: string; loginUrl: string }; } } @@ -44,7 +44,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { } cluster.on('exit', (worker, code, signal) => { - logger.info(`worker ${worker.process.pid} died, restarting`, { code, signal }); + logger.info({ code, signal }, `worker ${worker.process.pid} died, restarting`); cluster.fork(); }); } else { @@ -92,6 +92,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { } app.use(addContextMiddleware); + app.use(httpLogger); // Setup session app.use(sessionMiddleware); @@ -250,7 +251,6 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { }); app.options( '*', - logRoute, (req: express.Request, res: express.Response, next: express.NextFunction) => { res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -275,9 +275,9 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { ], }) ); - app.use('/platform-event', logRoute, cors({ origin: /http:\/\/localhost:[0-9]+$/ }), platformEventRoutes); + app.use('/platform-event', cors({ origin: /http:\/\/localhost:[0-9]+$/ }), platformEventRoutes); } else { - app.use('/platform-event', logRoute, platformEventRoutes); + app.use('/platform-event', platformEventRoutes); } app.use(raw({ limit: '30mb', type: ['text/csv'] })); @@ -286,12 +286,12 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use(urlencoded({ extended: true })); app.use('/healthz', healthCheck); - app.use('/api', logRoute, apiRoutes); - app.use('/static', logRoute, staticAuthenticatedRoutes); // these are routes that return files or redirect (e.x. NOT JSON) - app.use('/oauth', logRoute, oauthRoutes); // NOTE: there are also static files with same path + app.use('/api', apiRoutes); + app.use('/static', staticAuthenticatedRoutes); // these are routes that return files or redirect (e.x. NOT JSON) + app.use('/oauth', oauthRoutes); // NOTE: there are also static files with same path if (ENV.ENVIRONMENT !== 'production' || ENV.IS_CI) { - app.use('/test', logRoute, testRoutes); + app.use('/test', testRoutes); } // const server = app.listen(Number(ENV.PORT), () => { @@ -356,7 +356,6 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use(uncaughtErrorHandler); server.on('error', (error: Error) => { - logger.error('[SERVER][ERROR]', error.message); - logger.error(error.stack); + logger.error(getExceptionLog(error), '[SERVER][ERROR]'); }); } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index c1e2dd4e8..9f3d4de92 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -11,6 +11,7 @@ } ], "compilerOptions": { - "esModuleInterop": true + "esModuleInterop": true, + "strictNullChecks": true, } } diff --git a/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts b/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts index 0690f867f..ecaa3e6ae 100644 --- a/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts +++ b/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts @@ -14,13 +14,25 @@ export class ApiRequestUtils { this.request = request; } - async makeRequest(method: HttpMethod, path: string, data?: unknown): Promise { + async makeRequest(method: HttpMethod, path: string, data?: unknown, headers?: Record): Promise { + const response = await this.makeRequestRaw(method, path, data, headers); + const results = await response.json(); + if (!response.ok()) { + console.warn('\n\nREQUEST ERROR'); + console.log(results); + throw new Error('Request failed\n\n'); + } + return results.data; + } + + async makeRequestRaw(method: HttpMethod, path: string, data?: unknown, headers?: Record): Promise { const url = `${this.BASE_URL}${path}`; const options = { data, headers: { [HTTP.HEADERS.ACCEPT]: HTTP.CONTENT_TYPE.JSON, [HTTP.HEADERS.X_SFDC_ID]: this.selectedOrgId, + ...headers, }, }; let response: APIResponse; @@ -43,12 +55,6 @@ export class ApiRequestUtils { default: throw new Error('Invalid method'); } - const results = await response.json(); - if (!response.ok()) { - console.warn('\n\nREQUEST ERROR'); - console.log(results); - throw new Error('Request failed\n\n'); - } - return results.data; + return response; } } diff --git a/apps/jetstream-e2e/src/fixtures/fixtures.ts b/apps/jetstream-e2e/src/fixtures/fixtures.ts index dfc8dee96..0d38b2506 100644 --- a/apps/jetstream-e2e/src/fixtures/fixtures.ts +++ b/apps/jetstream-e2e/src/fixtures/fixtures.ts @@ -1,8 +1,8 @@ import { test as base } from '@playwright/test'; -import { QueryPage } from '../pageObjectModels/QueryPage.model'; -import { ApiRequestUtils } from './ApiRequestUtils'; import * as dotenv from 'dotenv'; import { LoadSingleObjectPage } from '../pageObjectModels/LoadSingleObjectPage.model'; +import { QueryPage } from '../pageObjectModels/QueryPage.model'; +import { ApiRequestUtils } from './ApiRequestUtils'; // Ensure tests run via VSCode debugger are run from the root of the repo if (process.cwd().endsWith('/apps/jetstream-e2e')) { @@ -17,7 +17,6 @@ type MyFixtures = { loadSingleObjectPage: LoadSingleObjectPage; }; -// Extend basic test by providing a "todoPage" fixture. export const test = base.extend({ apiRequestUtils: async ({ page, request }, use) => { await page.goto('/app'); diff --git a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts index c23b06fae..05bb45cd1 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts @@ -1,8 +1,7 @@ -import { QueryResults } from '@jetstream/api-interfaces'; import { formatNumber } from '@jetstream/shared/ui-utils'; import { isRecordWithId } from '@jetstream/shared/utils'; -import { QueryFilterOperator } from '@jetstream/types'; -import { APIRequestContext, expect, Locator, Page } from '@playwright/test'; +import { QueryFilterOperator, QueryResults } from '@jetstream/types'; +import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; import isNumber from 'lodash/isNumber'; import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; diff --git a/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts new file mode 100644 index 000000000..55d30c209 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts @@ -0,0 +1,43 @@ +import { CreateQueryJobRequest } from '@jetstream/api-types'; +import { BulkQuery20Job, BulkQuery20JobResults, BulkQuery20Response } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Bulk Query 2.0', () => { + test('Create and get job', async ({ apiRequestUtils }) => { + const createJobResponse = await apiRequestUtils.makeRequest('POST', `/api/bulk-query`, { + query: `SELECT Id, Name, AnnualRevenue, City, Company, Country, CreatedDate, Description, Email, IsConverted, IsDeleted FROM Lead`, + queryAll: true, + } as CreateQueryJobRequest); + + expect(createJobResponse.id).toBeTruthy(); + expect(createJobResponse.apiVersion).toBeTruthy(); + expect(createJobResponse.object).toEqual('Lead'); + expect(createJobResponse.contentType).toEqual('CSV'); + expect(createJobResponse.operation).toEqual('queryAll'); + expect(['UploadComplete', 'InProgress', 'JobComplete'].includes(createJobResponse.state)).toBeTruthy(); + + const getJobResponse = await apiRequestUtils.makeRequest('GET', `/api/bulk-query/${createJobResponse.id}`); + + expect(getJobResponse.id).toBeTruthy(); + expect(getJobResponse.numberRecordsProcessed).toEqual(0); + expect(['UploadComplete', 'InProgress', 'JobComplete'].includes(getJobResponse.state)).toBeTruthy(); + + const getJobResponse2 = await apiRequestUtils.makeRequest('POST', `/api/bulk-query/${createJobResponse.id}/abort`); + expect(getJobResponse2.id).toBeTruthy(); + expect(getJobResponse2.state).toEqual('Aborted'); + + const getJobResponse3 = await apiRequestUtils.makeRequestRaw('DELETE', `/api/bulk-query/${createJobResponse.id}`); + expect(getJobResponse3.status()).toEqual(204); + }); + + test('Get all jobs', async ({ apiRequestUtils }) => { + const allJobs = await apiRequestUtils.makeRequest('GET', `/api/bulk-query`); + + expect(typeof allJobs.done === 'boolean').toBeTruthy(); + expect(Array.isArray(allJobs.records)).toBeTruthy(); + }); + + // TODO: there isn't a great way of testing query results because it takes a long time to run +}); diff --git a/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts new file mode 100644 index 000000000..9f1f6d42d --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts @@ -0,0 +1,118 @@ +import { CreateJobRequest } from '@jetstream/api-types'; +import { BulkJob, BulkJobBatchInfo, BulkJobWithBatches } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Bulk', () => { + test('Bulk Job - Create,Get,Add Batch', async ({ apiRequestUtils }) => { + const createJobResponse = await apiRequestUtils.makeRequest('POST', `/api/bulk`, { + externalId: null, + serialMode: false, + sObject: 'LEAD', + type: 'INSERT', + } as CreateJobRequest); + + expect(createJobResponse.id).toBeTruthy(); + expect(createJobResponse.apiVersion).toBeTruthy(); + expect(createJobResponse.object).toEqual('Lead'); + expect(createJobResponse.contentType).toEqual('CSV'); + expect(createJobResponse.operation).toEqual('insert'); + expect(createJobResponse.state).toEqual('Open'); + + const getJobResponse = await apiRequestUtils.makeRequest('GET', `/api/bulk/${createJobResponse.id}`); + + expect(getJobResponse.id).toBeTruthy(); + expect(getJobResponse.numberRecordsProcessed).toEqual(0); + expect(getJobResponse.state).toEqual('Open'); + expect(Array.isArray(getJobResponse.batches)).toBeTruthy(); + expect(getJobResponse.batches.length).toEqual(0); + + const timestamp = new Date().getTime(); + const addBatchToJobResponse = await apiRequestUtils.makeRequest( + 'POST', + `/api/bulk/${createJobResponse.id}?${new URLSearchParams({ + closeJob: 'true', + })}`, + [ + `"LastName";"FirstName";"Title";"Company";"State";"Country";"Email";"LeadSource";"Status"`, + `"Snyder-${timestamp}";"Kathy";"Regional General Manager ${timestamp}";"TNR Corp. ${timestamp}";"CT";"USA";"ksynder@tnr.${timestamp}.net";"Purchased List";"Working - Contacted"`, + ].join('\n') + ); + + expect(addBatchToJobResponse.id).toBeTruthy(); + expect(['Queued', 'InProgress', 'Completed'].includes(addBatchToJobResponse.state)).toBeTruthy(); + + const getJobResponse2 = await apiRequestUtils.makeRequest('GET', `/api/bulk/${createJobResponse.id}`); + + expect(getJobResponse2.id).toBeTruthy(); + expect(getJobResponse2.state).toEqual('Closed'); + }); + + test('Bulk Job - Errors', async ({ apiRequestUtils }) => { + const createJobResponse = await apiRequestUtils.makeRequestRaw('POST', `/api/bulk`, { + externalId: null, + serialMode: false, + sObject: 'LEADS', + type: 'INSERT', + } as CreateJobRequest); + + expect(createJobResponse.ok()).toBeFalsy(); + const createJobResponseBody = await createJobResponse.json(); + expect(createJobResponseBody.message).toEqual('Unable to find object: LEADS'); + + const getJobResponse = await apiRequestUtils.makeRequestRaw('GET', `/api/bulk/invalidJobId000`); + + expect(getJobResponse.ok()).toBeFalsy(); + const getJobResponseBody = await getJobResponse.json(); + expect(getJobResponseBody.message).toEqual('Invalid job id: invalidJobId000'); + + const timestamp = new Date().getTime(); + const addBatchToJobResponse = await apiRequestUtils.makeRequestRaw( + 'POST', + `/api/bulk/invalidJobId000?${new URLSearchParams({ + closeJob: 'true', + })}`, + [ + `"LastName";"FirstName";"Title";"Company";"State";"Country";"Email";"LeadSource";"Status"`, + `"Snyder-${timestamp}";"Kathy";"Regional General Manager ${timestamp}";"TNR Corp. ${timestamp}";"CT";"USA";"ksynder@tnr.${timestamp}.net";"Purchased List";"Working - Contacted"`, + ].join('\n') + ); + + expect(addBatchToJobResponse.ok()).toBeFalsy(); + const addBatchToJobResponseBody = await addBatchToJobResponse.json(); + expect(addBatchToJobResponseBody.message).toEqual('Invalid job id: invalidJobId000'); + }); + + test('Query Job', async ({ apiRequestUtils }) => { + const createJobResponse = await apiRequestUtils.makeRequest('POST', `/api/bulk`, { + externalId: null, + serialMode: false, + sObject: 'LEAD', + type: 'QUERY_ALL', + } as CreateJobRequest); + + expect(createJobResponse.id).toBeTruthy(); + expect(createJobResponse.apiVersion).toBeTruthy(); + expect(createJobResponse.object).toEqual('Lead'); + expect(createJobResponse.contentType).toEqual('CSV'); + expect(createJobResponse.operation).toEqual('queryAll'); + expect(createJobResponse.state).toEqual('Open'); + + const addBatchToJobResponse = await apiRequestUtils.makeRequest( + 'POST', + `/api/bulk/${createJobResponse.id}?${new URLSearchParams({ + closeJob: 'true', + })}`, + `SELECT Id, Name, AnnualRevenue, City, Company, Country, CreatedDate, Description, Email, IsConverted, IsDeleted FROM Lead` + ); + + expect(addBatchToJobResponse.id).toBeTruthy(); + expect(['Queued', 'InProgress', 'Completed'].includes(addBatchToJobResponse.state)).toBeTruthy(); + + const getJobResponse2 = await apiRequestUtils.makeRequest('GET', `/api/bulk/${createJobResponse.id}`); + + expect(getJobResponse2.id).toBeTruthy(); + expect(getJobResponse2.state).toEqual('Closed'); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts new file mode 100644 index 000000000..b2a73beeb --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts @@ -0,0 +1,86 @@ +import { AnonymousApexResponse } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Apex', () => { + test('anonymousApex', async ({ apiRequestUtils }) => { + const [validWithLogLevel, validWithoutLogLevel, validApexWithXmlChars, invalidApex, runtimeError, missingApex, invalidLogLevel] = + await Promise.all([ + apiRequestUtils.makeRequest('POST', `/api/apex/anonymous`, { + apex: `System.debug('Hello World');`, + logLevel: 'DEBUG', + }), + apiRequestUtils.makeRequest('POST', `/api/apex/anonymous`, { + apex: `System.debug('Hello World');`, + }), + apiRequestUtils.makeRequest('POST', `/api/apex/anonymous`, { + apex: `List accounts = [SELECT Id from Account LIMIT 0];`, + }), + apiRequestUtils.makeRequest('POST', `/api/apex/anonymous`, { + apex: `System.debug('Hello World')`, + }), + apiRequestUtils.makeRequest('POST', `/api/apex/anonymous`, { + apex: `String name = [SELECT Id from Account LIMIT 0][1].Name;`, + }), + apiRequestUtils.makeRequestRaw('POST', `/api/apex/anonymous`, { + apex1: `System.debug('Hello World');`, + logLevel: 'DEBUG', + }), + apiRequestUtils.makeRequestRaw('POST', `/api/apex/anonymous`, { + apex: `System.debug('Hello World');`, + logLevel: 'SUPERFINE', + }), + ]); + + expect(validWithLogLevel).toBeTruthy(); + expect(typeof validWithLogLevel.debugLog === 'string').toBeTruthy(); + expect(validWithLogLevel.result.success).toEqual(true); + expect(validWithLogLevel.debugLog).toContain(`System.debug('Hello World');`); + + expect(validWithoutLogLevel).toBeTruthy(); + expect(typeof validWithoutLogLevel.debugLog === 'string').toBeTruthy(); + expect(validWithoutLogLevel.result.success).toEqual(true); + expect(validWithLogLevel.debugLog).toContain(`System.debug('Hello World');`); + + expect(validApexWithXmlChars).toBeTruthy(); + expect(typeof validApexWithXmlChars.debugLog === 'string').toBeTruthy(); + expect(validApexWithXmlChars.debugLog).toContain('List accounts = [SELECT Id from Account LIMIT 0];'); + + expect(typeof invalidApex.debugLog === 'string').toBeTruthy(); + expect(invalidApex.result.compiled).toEqual(false); + expect(invalidApex.result.success).toEqual(false); + expect(invalidApex.result.compileProblem).toEqual(`Unexpected token '('.`); + + expect(typeof runtimeError.debugLog === 'string').toBeTruthy(); + expect(runtimeError.result.compiled).toEqual(true); + expect(runtimeError.result.success).toEqual(false); + expect(runtimeError.result.exceptionMessage).toEqual(`System.ListException: List index out of bounds: 1`); + expect(runtimeError.result.exceptionStackTrace).toEqual(`AnonymousBlock: line 1, column 1`); + + expect(missingApex.ok()).toBeFalsy(); + const missingApexBody = await missingApex.json(); + expect(missingApexBody.message).toEqual(`Invalid request: 'apex' is Required`); + + expect(invalidLogLevel.ok()).toBeFalsy(); + expect((await invalidLogLevel.text()).includes(`logLevel`)).toBeTruthy(); + }); + + // TODO: this one takes a really long time to run + // test('apexCompletions', async ({ apiRequestUtils }) => { + // const [apexCompletions, vfCompletions, invalid] = await Promise.all([ + // apiRequestUtils.makeRequest('GET', `/api/apex/completions/apex`), + // apiRequestUtils.makeRequest('GET', `/api/apex/completions/visualforce`), + // apiRequestUtils.makeRequestRaw('GET', `/api/apex/completions/invalid`), + // ]); + + // expect(apexCompletions).toBeTruthy(); + // expect(apexCompletions.publicDeclarations).toBeTruthy(); + + // expect(vfCompletions).toBeTruthy(); + // expect(vfCompletions.publicDeclarations).toBeTruthy(); + + // expect(invalid.ok()).toBeFalsy(); + // expect((await invalid.text()).includes(`type`)).toBeTruthy(); + // }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts new file mode 100644 index 000000000..4408329d0 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts @@ -0,0 +1,247 @@ +import { AsyncResult, DeployResult, DescribeMetadataResult, FileProperties, MetadataInfo, RetrieveResult } from '@jetstream/types'; +import { readFileSync } from 'fs-extra'; +import { join } from 'path'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Metadata', () => { + test('describe metadata', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('GET', `/api/metadata/describe`); + + expect(results).toBeTruthy(); + expect(Array.isArray(results.metadataObjects)).toBeTruthy(); + expect(results.organizationNamespace).toBeFalsy(); + expect(results.partialSaveAllowed).toBeTruthy(); + expect(results.testRequired).toBeFalsy(); + }); + + test('list metadata', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/list`, { types: [{ type: 'ApexClass' }] }); + + expect(results).toBeTruthy(); + expect(Array.isArray(results)).toBeTruthy(); + + results.forEach((result) => { + expect(result).toBeTruthy(); + expect(result.type).toBeTruthy(); + expect(result.createdById).toBeTruthy(); + expect(result.createdByName).toBeTruthy(); + expect(result.createdDate).toBeTruthy(); + expect(result.fileName).toBeTruthy(); + expect(result.fullName).toBeTruthy(); + expect(result.id).toBeTruthy(); + expect(result.lastModifiedById).toBeTruthy(); + }); + }); + + test('list metadata - invalid type', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequestRaw('POST', `/api/metadata/list`, { + types: [{ type: 'ApexClasses' }], + }); + + expect(results.ok()).toBeFalsy(); + const errorBody = await results.json(); + expect(errorBody.message).toEqual(`INVALID_TYPE: Unknown type:ApexClasses`); + }); + + test('read metadata', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/read/CustomObject`, { + fullNames: ['Account'], + }); + + expect(results).toBeTruthy(); + expect(Array.isArray(results)).toBeTruthy(); + expect(results.length).toEqual(1); + + results.forEach((result) => { + expect(result).toBeTruthy(); + expect(result.fullName).toBeTruthy(); + }); + }); + + test('read metadata - Item does not exist', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/read/PermissionSet`, { + fullNames: ['ApexUtils'], + }); + + expect(results).toBeTruthy(); + expect(Array.isArray(results)).toBeTruthy(); + expect(results.length).toEqual(0); + }); + + test('deploy metadata', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/deploy`, { + files: [ + { + fullFilename: 'classes/AddPrimaryContact.cls', + content: ` +public class AddPrimaryContact implements Queueable { + + String stateAbbr; + Contact contact; + + public AddPrimaryContact(Contact contact, String stateAbbr) { + this.contact = contact; + this.stateAbbr = stateAbbr; + } + + public void execute(QueueableContext context) { + List accounts = [Select ID FROM Account WHERE BillingState = :stateAbbr LIMIT 200]; + List contacts = new List(); + for(Account a : accounts) { + Contact c = contact.clone(); + c.accountId = a.Id; + contacts.add(c); + } + insert contacts; + } +}`, + }, + ], + options: { + allowMissingFiles: false, + autoUpdatePackage: false, + checkOnly: false, + ignoreWarnings: false, + purgeOnDelete: false, + rollbackOnError: true, + singlePackage: true, + runTests: [], + }, + }); + + expect(results).toBeTruthy(); + expect(results.id).toBeTruthy(); + expect(results.state).toBeTruthy(); + expect(typeof results.done === 'boolean').toBeTruthy(); + }); + + test('deploy zip And Check Results', async ({ apiRequestUtils }) => { + const file = readFileSync(join(__dirname, 'test.metadata-package.zip')); + const results = await apiRequestUtils.makeRequest( + 'POST', + `/api/metadata/deploy-zip?${new URLSearchParams({ + options: JSON.stringify({ + allowMissingFiles: false, + autoUpdatePackage: false, + checkOnly: false, + ignoreWarnings: false, + purgeOnDelete: false, + rollbackOnError: true, + singlePackage: true, + runTests: [], + }), + })}`, + file, + { + 'Content-Type': 'application/zip', + } + ); + + expect(results).toBeTruthy(); + expect(results.id).toBeTruthy(); + expect(results.state).toBeTruthy(); + expect(typeof results.done === 'boolean').toBeTruthy(); + + const retrieveResults = await apiRequestUtils.makeRequest('GET', `/api/metadata/deploy/${results.id}`); + + expect(retrieveResults).toBeTruthy(); + expect(retrieveResults.id).toBeTruthy(); + expect(retrieveResults.status).toBeTruthy(); + expect(typeof retrieveResults.done === 'boolean').toBeTruthy(); + expect(typeof retrieveResults.success === 'boolean').toBeTruthy(); + }); + + test('deploy zip Invalid Job', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequestRaw('GET', `/api/metadata/deploy/invalidId000000`); + + expect(results).toBeTruthy(); + expect(results.ok()).toBeFalsy(); + const errorBody = await results.json(); + expect(errorBody.message).toEqual(`MALFORMED_ID: bad id invalidId000000`); + }); + + test('retrieve package from list metadata', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/retrieve/list-metadata`, { + ApexClass: [ + { + createdById: '0056g000004tCpaAAE', + createdByName: 'Barbara Walters', + createdDate: '2021-02-01T03:16:19.000Z', + fileName: 'classes/ApexUtils.cls', + fullName: 'ApexUtils', + id: '01p6g00000RikTCAAZ', + lastModifiedById: '0056g000004tCpaAAE', + lastModifiedByName: 'Barbara Walters', + lastModifiedDate: '2022-03-02T02:52:38.000Z', + manageableState: 'unmanaged', + type: 'ApexClass', + }, + ], + }); + + expect(results).toBeTruthy(); + expect(results.id).toBeTruthy(); + expect(results.state).toBeTruthy(); + expect(typeof results.done === 'boolean').toBeTruthy(); + + const retrieveResults = await apiRequestUtils.makeRequest( + 'GET', + `/api/metadata/retrieve/check-results?id=${results.id}` + ); + + expect(retrieveResults).toBeTruthy(); + expect(retrieveResults.id).toBeTruthy(); + expect(retrieveResults.status).toBeTruthy(); + expect(typeof retrieveResults.done === 'boolean').toBeTruthy(); + expect(typeof retrieveResults.success === 'boolean').toBeTruthy(); + }); + + test('retrieve package from package names', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/retrieve/package-names`, { + packageNames: ['MyPackage'], + }); + + expect(results).toBeTruthy(); + expect(results.id).toBeTruthy(); + expect(results.state).toBeTruthy(); + expect(typeof results.done === 'boolean').toBeTruthy(); + }); + + test('retrieve package from manifest', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/retrieve/manifest`, { + packageManifest: ` + + + ApexUtils + ApexClass + + 60.0 + `, + }); + + expect(results).toBeTruthy(); + expect(results.id).toBeTruthy(); + expect(results.state).toBeTruthy(); + expect(typeof results.done === 'boolean').toBeTruthy(); + }); + + // TODO: checkRetrieveStatusAndRedeploy (this one will be hard to test) + + test('getPackageXml', async ({ apiRequestUtils }) => { + const results = await apiRequestUtils.makeRequest('POST', `/api/metadata/package-xml`, { + metadata: { + ApexClass: [ + { + fullName: 'ApexUtils', + }, + ], + }, + otherFields: {}, + }); + + expect(results).toBeTruthy(); + expect(typeof results === 'string').toBeTruthy(); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts new file mode 100644 index 000000000..f22a0aee6 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts @@ -0,0 +1,104 @@ +import { CompositeResponse, ManualRequestResponse } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Misc', () => { + test('Stream file download', async ({ apiRequestUtils }) => { + const file = await apiRequestUtils.makeRequestRaw( + 'GET', + `/api/file/stream-download?${new URLSearchParams({ + url: '/services/data/v60.0/sobjects/ContentVersion/068Dn00000BSXTsIAP/VersionData&X-SFDC-ID=00DDn000006Cfm2MAC-005Dn000003DuJgIAK', + }).toString()}` + ); + + expect(file).toBeTruthy(); + file; + }); + + test('Request - POST', async ({ apiRequestUtils }) => { + const response = await apiRequestUtils.makeRequest('POST', '/api/request', { + isTooling: true, + method: 'POST', + url: '/services/data/v60.0/tooling/composite', + body: { + allOrNone: false, + compositeRequest: [ + { + method: 'PATCH', + url: '/services/data/v60.0/tooling/sobjects/CustomField/00NDn00001454Pe', + body: { + FullName: 'Account.CustomField__c', + Metadata: { type: 'Text', label: 'CustomField', length: '255', required: false, unique: false, externalId: false }, + }, + referenceId: 'Account_CustomField_c', + }, + ], + }, + }); + + expect(response.compositeResponse.length).toEqual(1); + expect(response.compositeResponse[0].httpStatusCode).toEqual(204); + expect(response.compositeResponse[0].referenceId).toEqual('Account_CustomField_c'); + }); + + test('Request - Error', async ({ apiRequestUtils }) => { + const response = await apiRequestUtils.makeRequest('POST', '/api/request-manual', { + method: 'GET', + url: '/services/data/v60.0/fake', + }); + + expect(response.status === 500).toBeTruthy(); + expect(response.headers).toBeTruthy(); + expect(response.error).toBeTruthy(); + expect(response.statusText).toEqual('Server Error'); + + const body = JSON.parse(response.body as string) as { + message: "This is a fake resource, you shouldn't even be seeing this"; + errorCode: 'UNKNOWN_EXCEPTION'; + }[]; + + expect(Array.isArray(body)).toBeTruthy(); + expect(body[0].errorCode).toEqual('UNKNOWN_EXCEPTION'); + }); + + test('Request Manual - GET', async ({ apiRequestUtils }) => { + const response = await apiRequestUtils.makeRequest('POST', '/api/request-manual', { + method: 'GET', + url: '/services/data', + }); + + expect(response.status === 200).toBeTruthy(); + expect(response.headers).toBeTruthy(); + expect(response.error).toBeFalsy(); + expect(response.statusText).toEqual('OK'); + + const body = JSON.parse(response.body as string) as { + label: string; + url: string; + version: string; + }[]; + + expect(Array.isArray(body)).toBeTruthy(); + }); + + test('Request Manual - Error', async ({ apiRequestUtils }) => { + const response = await apiRequestUtils.makeRequest('POST', '/api/request-manual', { + method: 'GET', + url: '/services/data/v60.0/fake', + }); + + expect(response.status === 500).toBeTruthy(); + expect(response.headers).toBeTruthy(); + expect(response.error).toBeTruthy(); + expect(response.statusText).toEqual('Server Error'); + + const body = JSON.parse(response.body as string) as { + message: "This is a fake resource, you shouldn't even be seeing this"; + errorCode: 'UNKNOWN_EXCEPTION'; + }[]; + + expect(Array.isArray(body)).toBeTruthy(); + expect(body[0].errorCode).toEqual('UNKNOWN_EXCEPTION'); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/query.api.spec.ts b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts new file mode 100644 index 000000000..704857561 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts @@ -0,0 +1,135 @@ +import { DescribeGlobalResult, DescribeSObjectResult, QueryResults } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Query', () => { + test('describe global', async ({ apiRequestUtils }) => { + const [describeSuccess, describeToolingSuccess, invalidParam] = await Promise.all([ + apiRequestUtils.makeRequest('GET', `/api/describe`), + apiRequestUtils.makeRequest('GET', `/api/describe?isTooling=true`), + apiRequestUtils.makeRequestRaw('GET', `/api/describe?isTooling=invalid`), + ]); + + expect(describeSuccess).toBeTruthy(); + expect(Array.isArray(describeSuccess.sobjects)).toBeTruthy(); + + expect(describeToolingSuccess).toBeTruthy(); + expect(Array.isArray(describeToolingSuccess.sobjects)).toBeTruthy(); + + // confirm that isTooling param is working + expect(describeSuccess.sobjects.length).not.toEqual(describeToolingSuccess.sobjects.length); + + expect(invalidParam.ok()).toBeFalsy(); + const errorBody = await invalidParam.json(); + expect('error' in errorBody).toBeTruthy(); + expect('message' in errorBody).toBeTruthy(); + expect(errorBody.message.includes(`'isTooling' is Invalid enum value.`)).toBeTruthy(); + }); + + test('describe sobject', async ({ apiRequestUtils }) => { + const [describeSuccess, describeToolingSuccess, invalidObj, invalidParam] = await Promise.all([ + apiRequestUtils.makeRequest('GET', `/api/describe/Account`), + apiRequestUtils.makeRequest('GET', `/api/describe/ApexClass?isTooling=true`), + apiRequestUtils.makeRequestRaw('GET', `/api/describe/InvalidObj`), + apiRequestUtils.makeRequestRaw('GET', `/api/describe/Account?isTooling=invalid`), + ]); + + expect(describeSuccess).toBeTruthy(); + expect(describeSuccess.name).toEqual('Account'); + expect(Array.isArray(describeSuccess.fields)).toBeTruthy(); + + expect(describeToolingSuccess).toBeTruthy(); + expect(describeToolingSuccess.name).toEqual('ApexClass'); + expect(Array.isArray(describeToolingSuccess.fields)).toBeTruthy(); + + expect(invalidObj.ok()).toBeFalsy(); + const errorBody = await invalidObj.json(); + expect('error' in errorBody).toBeTruthy(); + expect('message' in errorBody).toBeTruthy(); + expect(errorBody.message).toEqual('The requested resource does not exist'); + + expect(invalidParam.ok()).toBeFalsy(); + }); + + test('query records', async ({ apiRequestUtils }) => { + const [querySuccess, queryToolingSuccess, invalidObj, invalidParam] = await Promise.all([ + apiRequestUtils.makeRequest( + 'POST', + `/api/query?${new URLSearchParams({ + isTooling: 'false', + }).toString()}`, + { query: `SELECT Id, Name, AccountNumber, Description, Fax FROM Account WHERE (NOT Name LIKE '%abc%') LIMIT 1` } + ), + apiRequestUtils.makeRequest( + 'POST', + `/api/query?${new URLSearchParams({ + isTooling: 'true', + }).toString()}`, + { query: `SELECT Id, Name, FirstName, LastName, Username FROM User LIMIT 1` } + ), + apiRequestUtils.makeRequestRaw( + 'POST', + `/api/query?${new URLSearchParams({ + isTooling: 'true', + }).toString()}`, + { query: `SELECT Id, Name, FirstName, LastName, Username FROM no_exist LIMIT 1` } + ), + apiRequestUtils.makeRequestRaw( + 'POST', + `/api/query?${new URLSearchParams({ + isTooling: 'invalid', + }).toString()}`, + { query: `SELECT Id, Name, FirstName, LastName, Username FROM User LIMIT 1` } + ), + ]); + + console.log(querySuccess); + + expect(querySuccess).toBeTruthy(); + expect(querySuccess.parsedQuery).toBeTruthy(); + expect(querySuccess.parsedQuery?.fields?.length).toEqual(5); + expect(querySuccess.parsedQuery?.where).toBeTruthy(); + expect(querySuccess.parsedQuery?.limit).toEqual(1); + expect(Array.isArray(querySuccess.columns?.columns)).toBeTruthy(); + + expect(queryToolingSuccess).toBeTruthy(); + expect(queryToolingSuccess.parsedQuery).toBeTruthy(); + expect(queryToolingSuccess.parsedQuery?.fields?.length).toEqual(5); + expect(queryToolingSuccess.parsedQuery?.where).toBeFalsy(); + expect(queryToolingSuccess.parsedQuery?.limit).toEqual(1); + expect(Array.isArray(queryToolingSuccess.columns?.columns)).toBeTruthy(); + + expect(invalidObj.ok()).toBeFalsy(); + const response = await invalidObj.json(); + expect(response.error).toBeTruthy(); + expect(typeof response.message === 'string').toBeTruthy(); + expect(response.message.startsWith('\nFirstName, LastName')).toBeTruthy(); + expect(response.message.includes(`sObject type 'no_exist' is not supported.`)).toBeTruthy(); + + expect(invalidParam.ok()).toBeFalsy(); + }); + + test('query more records', async ({ apiRequestUtils }) => { + const initialQuery = await apiRequestUtils.makeRequest( + 'POST', + `/api/query?${new URLSearchParams({ + isTooling: 'false', + }).toString()}`, + { query: `SELECT Id FROM Product2 LIMIT 2500` } + ); + + expect(initialQuery.queryResults.done).toBeFalsy(); + expect(initialQuery.queryResults.nextRecordsUrl).toBeTruthy(); + + const queryMore = await apiRequestUtils.makeRequest( + 'GET', + `/api/query-more?${new URLSearchParams({ + isTooling: 'false', + nextRecordsUrl: initialQuery.queryResults.nextRecordsUrl!, + }).toString()}` + ); + + expect(queryMore.queryResults.done).toBeTruthy(); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/record.api.spec.ts b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts new file mode 100644 index 000000000..db6fd96c8 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts @@ -0,0 +1,121 @@ +import { ErrorResult, RecordResult, SalesforceRecord, SuccessResult } from '@jetstream/types'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('API - Record Controller', () => { + test('Record Operation', async ({ apiRequestUtils }) => { + const [lead, leadInvalid] = await apiRequestUtils.makeRequest<[SuccessResult, ErrorResult]>( + 'POST', + `/api/record/create/Lead?${new URLSearchParams({ + allOrNone: 'false', + }).toString()}`, + { + records: [ + { + LastName: `Luce`, + FirstName: `Eugena - ${Math.random() * 100}`, + Title: 'CEO', + Company: `Pacific Retail Group - ${Math.random() * 100}`, + State: 'MA', + Country: 'USA', + Email: `eluce@pacificretail.${Math.random() * 100}.com`, + LeadSource: 'Purchased List', + Status: 'Open - Not Contacted', + }, + { + InvalidField: 'true', + }, + ], + } + ); + const [leadFullRecord, leadFullRecordInvalid] = await apiRequestUtils.makeRequest<[SalesforceRecord, ErrorResult]>( + 'POST', + `/api/record/retrieve/Lead?${new URLSearchParams({ + allOrNone: 'false', + }).toString()}`, + { ids: [lead.id, 'invalid'] } + ); + const allInvalid = await apiRequestUtils.makeRequest( + 'POST', + `/api/record/retrieve/Lead?${new URLSearchParams({ + allOrNone: 'true', + }).toString()}`, + { ids: [lead.id, 'invalid'] } + ); + const [leadUpdated] = await apiRequestUtils.makeRequest( + 'POST', + `/api/record/update/Lead?${new URLSearchParams({ + allOrNone: 'false', + }).toString()}`, + { + records: [{ AnnualRevenue: 12323, attributes: { type: 'Lead' }, Id: lead.id }], + } + ); + const [leadUpserted, leadUpsertedInvalid] = await apiRequestUtils.makeRequest<[SuccessResult, ErrorResult]>( + 'POST', + `/api/record/upsert/Lead?${new URLSearchParams({ + externalId: 'Id', + allOrNone: 'false', + }).toString()}`, + { + records: [ + { attributes: { type: 'Lead' }, Id: lead.id }, + { attributes: { type: 'Lead' }, Id: '001Dn000003QlaaMAZ' }, + ], + } + ); + const [deletedRecord, deletedRecordInvalid] = await apiRequestUtils.makeRequest<[SuccessResult, ErrorResult]>( + 'POST', + `/api/record/delete/Lead?${new URLSearchParams({ + allOrNone: 'false', + }).toString()}`, + { + ids: [lead.id, 'invalid'], + } + ); + + expect(lead).toBeTruthy(); + expect(lead.success).toBeTruthy(); + expect(lead.id).toBeTruthy(); + + expect(leadFullRecord.Id).toEqual(lead.id); + + expect(leadFullRecordInvalid).toBeTruthy(); + expect(Array.isArray(leadFullRecordInvalid)).toBeFalsy(); + expect(leadFullRecordInvalid.success).toBeFalsy(); + expect(Array.isArray(leadFullRecordInvalid.errors)).toBeTruthy(); + expect(leadFullRecordInvalid.errors.length).toEqual(1); + + expect(Array.isArray(allInvalid)).toBeTruthy(); + expect(allInvalid.length).toEqual(2); + expect(Array.isArray(allInvalid[0].errors)).toBeTruthy(); + expect(allInvalid[0].errors).toBeTruthy(); + expect(Array.isArray(allInvalid[1].errors)).toBeTruthy(); + expect(allInvalid[1].errors).toBeTruthy(); + + expect(leadUpdated.success).toBeTruthy(); + + expect(leadInvalid.success).toBeFalsy(); + + expect(leadUpserted.success).toBeTruthy(); + expect(leadUpserted.id).toEqual(leadFullRecord.Id); + expect(leadUpsertedInvalid.success).toBeFalsy(); + + expect(deletedRecord.success).toBeTruthy(); + expect(deletedRecord.id).toEqual(leadFullRecord.Id); + expect(deletedRecordInvalid.success).toBeFalsy(); + }); + + test('Record Operation - Invalid URL', async ({ apiRequestUtils }) => { + const [errorResponse] = await apiRequestUtils.makeRequest<[ErrorResult]>('POST', `/api/record/retrieve/Lead`, { + ids: ['0038c00003RGJvSAAX'], + }); + + expect(errorResponse).toBeTruthy(); + expect(Array.isArray(errorResponse)).toBeFalsy(); + expect(errorResponse.success).toBeFalsy(); + expect(Array.isArray(errorResponse.errors)).toBeTruthy(); + expect(errorResponse.errors.length).toEqual(1); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/api/test.metadata-package.zip b/apps/jetstream-e2e/src/tests/api/test.metadata-package.zip new file mode 100644 index 000000000..aaac40411 Binary files /dev/null and b/apps/jetstream-e2e/src/tests/api/test.metadata-package.zip differ diff --git a/apps/jetstream/src/app/app-state.ts b/apps/jetstream/src/app/app-state.ts index 601b37197..c78d66b96 100644 --- a/apps/jetstream/src/app/app-state.ts +++ b/apps/jetstream/src/app/app-state.ts @@ -31,14 +31,14 @@ function getAppCookie(): ApplicationCookie { : { serverUrl: 'http://localhost:3333', environment: 'development', - defaultApiVersion: 'v54.0', + defaultApiVersion: 'v60.0', google_appId: '1071580433137', google_apiKey: 'AIzaSyDaqv3SafGq6NmVVwUWqENrf2iEFiDSMoA', google_clientId: '1094188928456-fp5d5om6ar9prdl7ak03fjkqm4fgagoj.apps.googleusercontent.com', }; appState.serverUrl = appState.serverUrl || 'https://getjetstream.app/'; appState.environment = appState.environment || 'production'; - appState.defaultApiVersion = appState.defaultApiVersion || 'v54.0'; + appState.defaultApiVersion = appState.defaultApiVersion || 'v60.0'; appState.google_appId = appState.google_appId || '1071580433137'; appState.google_apiKey = appState.google_apiKey || 'AIzaSyDaqv3SafGq6NmVVwUWqENrf2iEFiDSMoA'; appState.google_clientId = appState.google_clientId || '1094188928456-fp5d5om6ar9prdl7ak03fjkqm4fgagoj.apps.googleusercontent.com'; diff --git a/apps/jetstream/src/app/components/automation-control/AutomationControlEditor.tsx b/apps/jetstream/src/app/components/automation-control/AutomationControlEditor.tsx index 9b2d52f52..50fce98f8 100644 --- a/apps/jetstream/src/app/components/automation-control/AutomationControlEditor.tsx +++ b/apps/jetstream/src/app/components/automation-control/AutomationControlEditor.tsx @@ -177,6 +177,23 @@ export const AutomationControlEditor: FunctionComponent): string { +function getRowId(row: SalesforceRecord): string { return `${row.Id}-${row._idx}`; } const groupedRows = ['_groupByLabel'] as const; -function getRows(childRelationships: ChildRelationship[], record: Record) { +function getRows(childRelationships: ChildRelationship[], record: SalesforceRecord) { return (childRelationships || []) - .flatMap((childRelationship): Record[] => { + .flatMap((childRelationship): SalesforceRecord[] => { if (childRelationship.relationshipName && record[childRelationship.relationshipName]) { const childQueryResults: QueryResult = record[childRelationship.relationshipName]; return childQueryResults.records.map((record, i) => ({ @@ -70,10 +69,10 @@ export interface ViewChildRecordsProps { selectedOrg: SalesforceOrgUi; sobjectName: string; parentRecordId: string; - initialData?: Record; + initialData?: SalesforceRecord; childRelationships: ChildRelationship[]; modalRef?: MutableRefObject>; - onChildrenData?: (parentRecordId: string, record: Record) => void; + onChildrenData?: (parentRecordId: string, record: SalesforceRecord) => void; } export const ViewChildRecords: FunctionComponent = ({ @@ -90,14 +89,14 @@ export const ViewChildRecords: FunctionComponent = ({ const { serverUrl } = useRecoilValue(applicationCookieState); const skipFrontDoorAuth = useRecoilValue(selectSkipFrontdoorAuth); const [loading, setLoading] = useState(true); - const [rows, setRows] = useState[]>([]); + const [rows, setRows] = useState[]>([]); const [expandedGroupIds, setExpandedGroupIds] = useState(new Set()); const [fetchErrors, setHasFetchErrors] = useState([]); const columns = useMemo( - (): ColumnWithFilter>[] => [ + (): ColumnWithFilter>[] => [ { - ...setColumnFromType>('_groupByLabel', 'text'), + ...setColumnFromType>('_groupByLabel', 'text'), key: '_groupByLabel', name: '', width: 40, diff --git a/apps/jetstream/src/app/components/core/ViewEditCloneRecord.tsx b/apps/jetstream/src/app/components/core/ViewEditCloneRecord.tsx index a2ab11399..13f54bc2d 100644 --- a/apps/jetstream/src/app/components/core/ViewEditCloneRecord.tsx +++ b/apps/jetstream/src/app/components/core/ViewEditCloneRecord.tsx @@ -8,16 +8,18 @@ import { copyRecordsToClipboard, isErrorResponse, useNonInitialEffect } from '@j import { AsyncJobNew, BulkDownloadJob, + ChildRelationship, CloneEditView, + ErrorResult, + Field, FileExtCsvXLSXJsonGSheet, MapOf, Maybe, PicklistFieldValues, PicklistFieldValuesResponse, - Record, RecordResult, SalesforceOrgUi, - SobjectCollectionResponse, + SalesforceRecord, } from '@jetstream/types'; import { Breadcrumbs, @@ -34,7 +36,6 @@ import { Tabs, } from '@jetstream/ui'; import Editor from '@monaco-editor/react'; -import type { ChildRelationship, Field } from 'jsforce'; import isNumber from 'lodash/isNumber'; import isObject from 'lodash/isObject'; import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; @@ -78,7 +79,7 @@ function getTagline( selectedOrg: SalesforceOrgUi, serverUrl: string, sobjectName: string, - initialRecord?: Record, + initialRecord?: SalesforceRecord, recordId?: string | null ) { if (initialRecord && recordId) { @@ -152,9 +153,9 @@ export const ViewEditCloneRecord: FunctionComponent = const [childRelationships, setChildRelationships] = useState(); const [sobjectFields, setSobjectFields] = useState(); const [picklistValues, setPicklistValues] = useState(); - const [initialRecord, setInitialRecord] = useState(); - const [recordWithChildrenQueries, setRecordWithChildrenQueries] = useState>>({}); - const [modifiedRecord, setModifiedRecord] = useState({}); + const [initialRecord, setInitialRecord] = useState(); + const [recordWithChildrenQueries, setRecordWithChildrenQueries] = useState>>({}); + const [modifiedRecord, setModifiedRecord] = useState({}); const [formIsDirty, setIsFormDirty] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -163,7 +164,7 @@ export const ViewEditCloneRecord: FunctionComponent = const [isViewAsJson, setIsViewAsJson] = useState(false); const [downloadModalData, setDownloadModalData] = useState< - { open: false } | { open: true; data: Record; fields: string[]; subqueryFields?: MapOf } + { open: false } | { open: true; data: SalesforceRecord; fields: string[]; subqueryFields?: MapOf } >({ open: false, }); @@ -187,7 +188,7 @@ export const ViewEditCloneRecord: FunctionComponent = const fetchMetadata = useCallback(async () => { try { let picklistValues: PicklistFieldValues = {}; - let record: Record = {}; + let record: SalesforceRecord = {}; const sobjectMetadata = await describeSObject(selectedOrg, sobjectName); setChildRelationships( @@ -197,7 +198,15 @@ export const ViewEditCloneRecord: FunctionComponent = ); if (action !== 'create' && recordId) { - record = await sobjectOperation(selectedOrg, sobjectName, 'retrieve', { ids: recordId }); + const response: SalesforceRecord | ErrorResult = ( + await sobjectOperation(selectedOrg, sobjectName, 'retrieve', { ids: [recordId] }) + )[0]; + if ('success' in response && !response.success) { + setFormErrors(handleEditFormErrorResponse(response)); + setLoading(false); + return; + } + record = response; } let recordTypeId = record?.RecordTypeId; @@ -301,7 +310,7 @@ export const ViewEditCloneRecord: FunctionComponent = trackEvent(ANALYTICS_KEYS.record_modal_action_change, { action, isViewAsJson }); }, [action, isViewAsJson, trackEvent]); - async function handleRecordChange(record: Record) { + async function handleRecordChange(record: SalesforceRecord) { setModifiedRecord(record); } @@ -324,11 +333,11 @@ export const ViewEditCloneRecord: FunctionComponent = if (action === 'edit') { record.attributes = { type: sobjectName }; record.Id = recordId; - recordResponse = (await sobjectOperation(selectedOrg, sobjectName, 'update', { records: [record] }))[0]; + recordResponse = (await sobjectOperation(selectedOrg, sobjectName, 'update', { records: [record] }))[0]; } else { // include all creatable fields from original record record = combineRecordsForClone(sobjectFields || [], initialRecord, record); - recordResponse = (await sobjectOperation(selectedOrg, sobjectName, 'create', { records: [record] }))[0]; + recordResponse = (await sobjectOperation(selectedOrg, sobjectName, 'create', { records: [record] }))[0]; } if (isMounted.current) { diff --git a/apps/jetstream/src/app/components/core/config.ts b/apps/jetstream/src/app/components/core/config.ts index 566289229..6de3acd86 100644 --- a/apps/jetstream/src/app/components/core/config.ts +++ b/apps/jetstream/src/app/components/core/config.ts @@ -1,21 +1,22 @@ -import { logger } from '@jetstream/shared/client-logger'; -import { initForElectron } from '@jetstream/shared/data'; -import { BrowserRouter, HashRouter } from 'react-router-dom'; -import { environment } from '../../../environments/environment'; -import { axiosElectronAdapter } from './electron-axios-adapter'; -import * as jetstreamElectron from './electron-utils'; +import { BrowserRouter } from 'react-router-dom'; +// import { logger } from '@jetstream/shared/client-logger'; +// import { initForElectron } from '@jetstream/shared/data'; +// import { environment } from '../../../environments/environment'; +// import { axiosElectronAdapter } from './electron-axios-adapter'; +// import * as jetstreamElectron from './electron-utils'; +// import { BrowserRouter, HashRouter } from 'react-router-dom'; export const CONFIG = { Router: BrowserRouter, baseName: '/app', }; -if (environment.isElectron || window.electron?.isElectron) { - CONFIG.Router = HashRouter; - CONFIG.baseName = '/'; - initForElectron(axiosElectronAdapter); - (async () => { - logger.log('Loaded electron'); - jetstreamElectron.init(); - })(); -} +// if (environment.isElectron || window.electron?.isElectron) { +// CONFIG.Router = HashRouter; +// CONFIG.baseName = '/'; +// initForElectron(axiosElectronAdapter); +// (async () => { +// logger.log('Loaded electron'); +// jetstreamElectron.init(); +// })(); +// } diff --git a/apps/jetstream/src/app/components/core/electron-axios-adapter.ts b/apps/jetstream/src/app/components/core/electron-axios-adapter.ts index 488caaf0f..2de8b8263 100644 --- a/apps/jetstream/src/app/components/core/electron-axios-adapter.ts +++ b/apps/jetstream/src/app/components/core/electron-axios-adapter.ts @@ -1,5 +1,5 @@ // import {electron, shell} from 'electron'; -import { AxiosAdapter, AxiosRequestConfig } from 'axios'; +import { AxiosAdapter, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; import { nanoid } from 'nanoid'; let port: MessagePort | undefined = undefined; @@ -63,7 +63,7 @@ function send(path: string, data: any): Promise<{ id: string; error: boolean; da }); } -export const axiosElectronAdapter: AxiosAdapter = async (config: AxiosRequestConfig) => { +export const axiosElectronAdapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => { /** * what do I need? * I need to map method+endpoint to something so that I know how to handle diff --git a/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx b/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx index 468dffef8..00596b41d 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx +++ b/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx @@ -82,6 +82,8 @@ export const CreateFields: FunctionComponent = () => { const selectedPermissionSets = useRecoilValue(fromCreateFieldsState.selectedPermissionSetsState); const selectedSObjects = useRecoilValue(fromCreateFieldsState.selectedSObjectsState); + const profilesAndPermSetsById = useRecoilValue(fromCreateFieldsState.profilesAndPermSetsByIdSelector); + const resetProfilesState = useResetRecoilState(fromCreateFieldsState.profilesState); const resetSelectedProfilesPermSetState = useResetRecoilState(fromCreateFieldsState.selectedProfilesPermSetState); const resetPermissionSetsState = useResetRecoilState(fromCreateFieldsState.permissionSetsState); @@ -137,6 +139,7 @@ export const CreateFields: FunctionComponent = () => { selectedOrg={selectedOrg} profiles={selectedProfiles} permissionSets={selectedPermissionSets} + profilesAndPermSetsById={profilesAndPermSetsById} sObjects={selectedSObjects} rows={rows} onClose={handleCloseModal} diff --git a/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsDeployModal.tsx b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsDeployModal.tsx index 27f6374b0..24c7513ce 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsDeployModal.tsx +++ b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsDeployModal.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { useFetchPageLayouts } from '@jetstream/shared/ui-utils'; -import { MapOf, SalesforceOrgUi } from '@jetstream/types'; +import { MapOf, PermissionSetNoProfileRecord, PermissionSetWithProfileRecord, SalesforceOrgUi } from '@jetstream/types'; import { Checkbox, ConfirmationModalPromise, FileDownloadModal, Grid, Icon, Modal, ScopedNotification, Spinner } from '@jetstream/ui'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; @@ -18,6 +18,7 @@ export interface CreateFieldsDeployModalProps { selectedOrg: SalesforceOrgUi; profiles: string[]; permissionSets: string[]; + profilesAndPermSetsById: MapOf; sObjects: string[]; rows: FieldValues[]; onClose: () => void; @@ -27,6 +28,7 @@ export const CreateFieldsDeployModal: FunctionComponent>({ - key: 'create-fields.profilesByIdSelector', +export const profilesAndPermSetsByIdSelector = selector>({ + key: 'create-fields.profilesAndPermSetsByIdSelector', get: ({ get }) => { const profiles = get(profilesState); + const permSets = get(permissionSetsState); + const output: MapOf = {}; if (profiles) { - return profiles.reduce((output, profile) => { - output[profile.id] = profile.meta; + profiles.reduce((output, profile) => { + if (profile.meta) { + output[profile.id] = profile.meta; + } return output; - }, {}); + }, output); } - return {}; - }, -}); - -export const permissionSetsByIdSelector = selector>({ - key: 'create-fields.permissionSetsByIdSelector', - get: ({ get }) => { - const permSets = get(permissionSetsState); if (permSets) { - return permSets.reduce((output, permSet) => { - output[permSet.id] = permSet.meta; + permSets.reduce((output, permSet) => { + if (permSet.meta) { + output[permSet.id] = permSet.meta; + } return output; - }, {}); + }, output); } - return {}; + return output; }, }); diff --git a/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/create-object-utils.ts b/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/create-object-utils.ts index 5fa5bb185..429295455 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/create-object-utils.ts +++ b/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/create-object-utils.ts @@ -144,12 +144,18 @@ export async function savePermissionRecords( const recordInsertResults: RecordResult[] = ( await Promise.all([ - ...splitArrayToMaxSize(objectPermissions, 200).map((records) => - sobjectOperation(org, 'ObjectPermissions', 'create', { records }, { allOrNone: false }) - ), - ...splitArrayToMaxSize(tabPermissions, 200).map((records) => - sobjectOperation(org, 'PermissionSetTabSetting', 'create', { records }, { allOrNone: false }) - ), + ...splitArrayToMaxSize(objectPermissions, 200).map((records) => { + if (records.length === 0) { + return []; + } + return sobjectOperation(org, 'ObjectPermissions', 'create', { records }, { allOrNone: false }); + }), + ...splitArrayToMaxSize(tabPermissions, 200).map((records) => { + if (records.length === 0) { + return []; + } + return sobjectOperation(org, 'PermissionSetTabSetting', 'create', { records }, { allOrNone: false }); + }), ]) ).flat(); diff --git a/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/useCreateObject.ts b/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/useCreateObject.ts index e83c944a5..843487845 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/useCreateObject.ts +++ b/apps/jetstream/src/app/components/create-object-and-fields/create-new-object/useCreateObject.ts @@ -13,9 +13,9 @@ export function getFriendlyStatus(status: CreateObjectResultsStatus) { case 'NOT_STARTED': return ''; case 'LOADING_METADATA': - return '(Loading Metadata)'; + return '(Creating Object)'; case 'LOADING_PERMISSIONS': - return '(Loading Permissions)'; + return '(Setting Permissions)'; case 'SUCCESS': return '(Success)'; case 'FAILED': diff --git a/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangeset.tsx b/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangeset.tsx index dfda6c80b..d56a9dff3 100644 --- a/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangeset.tsx +++ b/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangeset.tsx @@ -1,5 +1,5 @@ import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; -import { ChangeSet, DeployResult, ListMetadataResult, MapOf, SalesforceOrgUi } from '@jetstream/types'; +import { ChangeSet, DeployResult, ListMetadataResult, MapOf, Maybe, SalesforceOrgUi } from '@jetstream/types'; import { FileDownloadModal, Icon } from '@jetstream/ui'; import classNames from 'classnames'; import { Fragment, FunctionComponent, useState } from 'react'; @@ -42,7 +42,7 @@ export const AddToChangeset: FunctionComponent = ({ classNa setSelectedMetadata(convertRowsToMapOfListMetadataResults(Array.from(selectedRows))); } - function handleDeployToChangeset(packageName: string, changesetDescription: string, changeset?: ChangeSet) { + function handleDeployToChangeset(packageName: string, changesetDescription: string, changeset?: Maybe) { setChangesetPackageName(packageName); setChangesetPackageDescription(changesetDescription); setSelectedChangeset(changeset); diff --git a/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangesetConfigModal.tsx b/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangesetConfigModal.tsx index 4434db7a0..a459b2baa 100644 --- a/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangesetConfigModal.tsx +++ b/apps/jetstream/src/app/components/deploy/add-to-changeset/AddToChangesetConfigModal.tsx @@ -17,7 +17,7 @@ export interface AddToChangesetConfigModalProps { onChangesetPackages: (changesetPackages: ListItem[]) => void; onSelection: (changesetPackage: string) => void; onClose: () => void; - onDeploy: (changesetPackage: string, changesetDescription: string, changeset?: ChangeSet) => void; + onDeploy: (changesetPackage: string, changesetDescription: string, changeset?: Maybe) => void; } export const AddToChangesetConfigModal: FunctionComponent = ({ @@ -85,7 +85,7 @@ export const AddToChangesetConfigModal: FunctionComponent