Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/api/src/app/db/feature-flags.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,15 @@ export async function checkFeatureFlag({ userId, key }: { userId: string; key: F
const flags = await resolveFeatureFlagsForUser({ userId, teamId });
return flags[key];
}

/**
* Force a flag on for a single user by upserting a user-scoped override. Intended for test setup so
* flag-gated features can be exercised as if rolled out; production rollout is managed via overrides.
*/
export async function enableFeatureFlagForUser({ userId, key }: { userId: string; key: FeatureFlagKey }): Promise<void> {
await prisma.featureFlagOverride.upsert({
where: { uniqueFeatureFlagUser: { key, userId } },
create: { key, userId, enabled: true },
update: { enabled: true },
});
}
27 changes: 27 additions & 0 deletions apps/api/src/app/db/subscription.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { prisma } from '@jetstream/api-config';
import { Prisma } from '@jetstream/prisma';
import { EntitlementsAccess, EntitlementsAccessSchema, TeamBillingStatus } from '@jetstream/types';
import Stripe from 'stripe';
import { resolveActiveTeamIdForUser } from './feature-flags.db';

const SELECT = {
id: true,
Expand Down Expand Up @@ -49,6 +50,32 @@ export const updateUserEntitlements = async (customerId: string, entitlementAcce
});
};

/**
* Force-grant only the Analysis Tools entitlement to a user, leaving the other entitlement flags
* untouched. Intended for test setup so the flag-gated, paid-only Analysis Tools render as usable
* (unlocked) without provisioning a full paid subscription.
*
* When the user belongs to an active team, the user profile reads TEAM entitlements (see
* `getUserProfileUi`), so a user-level grant alone is ignored for team members. Grant it on the active
* team too so the tools render unlocked regardless of whether the user is on a team.
*/
export const grantAnalysisToolsEntitlementForUser = async (userId: string): Promise<void> => {
await prisma.entitlement.upsert({
create: { userId, analysisTools: true },
update: { analysisTools: true },
where: { userId },
});

const activeTeamId = await resolveActiveTeamIdForUser(userId);
if (activeTeamId) {
await prisma.teamEntitlement.upsert({
create: { teamId: activeTeamId, analysisTools: true },
update: { analysisTools: true },
where: { teamId: activeTeamId },
});
}
};

export const updateTeamEntitlements = async (customerId: string, entitlementAccessUntrusted: EntitlementsAccess) => {
const entitlementAccess = EntitlementsAccessSchema.parse(entitlementAccessUntrusted);
const team = await prisma.team.findFirstOrThrow({
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/db/user.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const UserFacingProfileSelect = {
},
entitlements: {
select: {
analysisTools: true,
chromeExtension: true,
desktop: true,
googleDrive: true,
Expand All @@ -113,6 +114,7 @@ const UserFacingProfileSelect = {
billingStatus: true,
entitlements: {
select: {
analysisTools: true,
chromeExtension: true,
desktop: true,
googleDrive: true,
Expand Down Expand Up @@ -149,6 +151,7 @@ export const findByIdWithSubscriptions = (id: string) => {
name: true,
entitlements: {
select: {
analysisTools: true,
chromeExtension: true,
desktop: true,
googleDrive: true,
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/app/routes/test.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api
import { salesforceLoginJwtBearer } from '@jetstream/salesforce-oauth';
import express, { Router } from 'express';
import { initConnectionFromOAuthResponse } from '../controllers/oauth.controller';
import { enableFeatureFlagForUser } from '../db/feature-flags.db';
import { grantAnalysisToolsEntitlementForUser } from '../db/subscription.db';
import { NotAllowedError } from '../utils/error-handler';
import { sendJson } from '../utils/response.handlers';

Expand Down Expand Up @@ -65,6 +67,12 @@ routes.post('/e2e-integration-org', async (_: express.Request, res: express.Resp
userId: ENV.EXAMPLE_USER!.id,
});

// Exercise the Analysis Tools in E2E as if fully rolled out + entitled: enable the rollout flag (off by
// default in code) so the cards/nav render, and grant the paid-only entitlement so they render unlocked
// (a locked card renders its links as plain text, which the routing nav tests cannot click).
await enableFeatureFlagForUser({ userId: ENV.EXAMPLE_USER!.id, key: 'analysis-tools' });
await grantAnalysisToolsEntitlementForUser(ENV.EXAMPLE_USER!.id);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendJson(res as any, salesforceOrg);
});
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/services/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export async function updateEntitlements(customerId: string, entitlements: Strip
chromeExtension: false,
recordSync: false,
desktop: false,
analysisTools: false,
},
);

Expand Down
43 changes: 43 additions & 0 deletions apps/jetstream-canvas/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ const ManagePermissionsSelection = lazy(() =>
const ManagePermissionsEditor = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })),
);
const PermissionAnalysis = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })),
);
const PermissionAnalysisSelection = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })),
);
const PermissionAnalysisView = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })),
);

const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis })));
const DataAnalysisSelection = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })),
);
const FieldUsageAnalysisView = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })),
);

const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata })));
const DeployMetadataSelection = lazy(() =>
Expand Down Expand Up @@ -141,6 +158,8 @@ export const AppRoutes = () => {
AutomationControlEditor.preload();
} else if (location.pathname.includes('/permissions-manager')) {
ManagePermissionsEditor.preload();
} else if (location.pathname.includes('/data-analysis')) {
FieldUsageAnalysisView.preload();
} else if (location.pathname.includes('/deploy-metadata')) {
DeployMetadataDeployment.preload();
} else if (location.pathname.includes('/create-fields')) {
Expand Down Expand Up @@ -214,6 +233,30 @@ export const AppRoutes = () => {
<Route path="editor" element={<ManagePermissionsEditor />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.PERMISSION_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<PermissionAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<PermissionAnalysisSelection />} />
<Route path="analysis" element={<PermissionAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DATA_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<DataAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<DataAnalysisSelection />} />
<Route path="analysis" element={<FieldUsageAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DEPLOY_METADATA.ROUTE}
element={
Expand Down
43 changes: 43 additions & 0 deletions apps/jetstream-desktop-client/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ const ManagePermissionsSelection = lazy(() =>
const ManagePermissionsEditor = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })),
);
const PermissionAnalysis = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })),
);
const PermissionAnalysisSelection = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })),
);
const PermissionAnalysisView = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })),
);

const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis })));
const DataAnalysisSelection = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })),
);
const FieldUsageAnalysisView = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })),
);

const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata })));
const DeployMetadataSelection = lazy(() =>
Expand Down Expand Up @@ -121,6 +138,8 @@ export const AppRoutes = () => {
AutomationControlEditor.preload();
} else if (location.pathname.includes('/permissions-manager')) {
ManagePermissionsEditor.preload();
} else if (location.pathname.includes('/data-analysis')) {
FieldUsageAnalysisView.preload();
} else if (location.pathname.includes('/deploy-metadata')) {
DeployMetadataDeployment.preload();
} else if (location.pathname.includes('/create-fields')) {
Expand Down Expand Up @@ -197,6 +216,30 @@ export const AppRoutes = () => {
<Route path="editor" element={<ManagePermissionsEditor />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.PERMISSION_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<PermissionAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<PermissionAnalysisSelection />} />
<Route path="analysis" element={<PermissionAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DATA_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<DataAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<DataAnalysisSelection />} />
<Route path="analysis" element={<FieldUsageAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DEPLOY_METADATA.ROUTE}
element={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Announcement, JetstreamEventSaveSoqlQueryFormatOptionsPayload, Salesfor
import { fireToast } from '@jetstream/ui';
import { fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core';
import { fromAppState } from '@jetstream/ui/app-state';
import { initDexieDb } from '@jetstream/ui/db';
import { initDexieDb, pruneAnalysisJobHistory } from '@jetstream/ui/db';
import { AxiosResponse } from 'axios';
import { useAtom, useAtomValue } from 'jotai';
import localforage from 'localforage';
Expand Down Expand Up @@ -85,9 +85,11 @@ APP VERSION ${version}
} else {
disconnectSocket();
}
initDexieDb({ recordSyncEnabled }).catch((ex) => {
logger.error('[DB] Error initializing db', ex);
});
initDexieDb({ recordSyncEnabled })
.then(() => pruneAnalysisJobHistory())
.catch((ex) => {
logger.error('[DB] Error initializing db', ex);
});
}, [appInfo.serverUrl, authInfo.accessToken, authInfo.deviceId, recordSyncEnabled]);

useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/jetstream-desktop/src/services/persistence.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export function getFullUserProfile() {
desktop: false,
chromeExtension: false,
recordSync: true,
analysisTools: true,
},
teamMembership: appData.userProfile.teamMembership,
subscriptions: [],
Expand Down
8 changes: 8 additions & 0 deletions apps/jetstream-e2e/src/tests/app/routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const testCases = [
},
{ cardTitle: 'AUTOMATION', menu: 'Automation Control', items: [{ link: 'Automation Control', path: '/automation-control' }] },
{ cardTitle: 'PERMISSIONS', menu: 'Manage Permissions', items: [{ link: 'Manage Permissions', path: '/permissions-manager' }] },
{
cardTitle: 'Analysis',
menu: 'Analysis Tools',
items: [
{ link: 'Permission Analysis', path: '/permission-analysis' },
{ link: 'Data Analysis', path: '/data-analysis' },
],
},
{
cardTitle: 'DEPLOY',
menu: 'Deploy Metadata',
Expand Down
43 changes: 43 additions & 0 deletions apps/jetstream/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ const ManagePermissionsSelection = lazy(() =>
const ManagePermissionsEditor = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })),
);
const PermissionAnalysis = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })),
);
const PermissionAnalysisSelection = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })),
);
const PermissionAnalysisView = lazy(() =>
import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })),
);

const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis })));
const DataAnalysisSelection = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })),
);
const FieldUsageAnalysisView = lazy(() =>
import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })),
);

const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata })));
const DeployMetadataSelection = lazy(() =>
Expand Down Expand Up @@ -109,6 +126,8 @@ export const AppRoutes = () => {
AutomationControlEditor.preload();
} else if (location.pathname.includes('/permissions-manager')) {
ManagePermissionsEditor.preload();
} else if (location.pathname.includes('/permission-analysis')) {
PermissionAnalysisView.preload();
} else if (location.pathname.includes('/deploy-metadata')) {
DeployMetadataDeployment.preload();
} else if (location.pathname.includes('/create-fields')) {
Expand Down Expand Up @@ -189,6 +208,30 @@ export const AppRoutes = () => {
<Route path="editor" element={<ManagePermissionsEditor />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.PERMISSION_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<PermissionAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<PermissionAnalysisSelection />} />
<Route path="analysis" element={<PermissionAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DATA_ANALYSIS.ROUTE}
element={
<OrgSelectionRequired>
<DataAnalysis />
</OrgSelectionRequired>
}
>
<Route index element={<DataAnalysisSelection />} />
<Route path="analysis" element={<FieldUsageAnalysisView />} />
<Route path="*" element={<Navigate to=".." />} />
</Route>
<Route
path={APP_ROUTES.DEPLOY_METADATA.ROUTE}
element={
Expand Down
10 changes: 6 additions & 4 deletions apps/jetstream/src/app/components/core/AppInitializer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { fireToast } from '@jetstream/ui';
import { fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core';
import { fromAppState } from '@jetstream/ui/app-state';
import { CookieConsentBanner, useConditionalGoogleAnalytics } from '@jetstream/ui/cookie-consent-banner';
import { initDexieDb } from '@jetstream/ui/db';
import { initDexieDb, pruneAnalysisJobHistory } from '@jetstream/ui/db';
import { AxiosResponse } from 'axios';
import { useAtom, useAtomValue } from 'jotai';
import localforage from 'localforage';
Expand Down Expand Up @@ -90,9 +90,11 @@ APP VERSION ${version}
} else {
disconnectSocket();
}
initDexieDb({ recordSyncEnabled }).catch((ex) => {
logger.error('[DB] Error initializing db', ex);
});
initDexieDb({ recordSyncEnabled })
.then(() => pruneAnalysisJobHistory())
.catch((ex) => {
logger.error('[DB] Error initializing db', ex);
});
}, [appInfo.serverUrl, recordSyncEnabled]);

useEffect(() => {
Expand Down
6 changes: 5 additions & 1 deletion libs/auth/acl/src/lib/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type Actions = 'read' | 'update';
type Subjects = 'Billing' | 'CoreFunctionality' | 'Profile' | 'Settings';

type EntitlementActions = 'access';
type EntitlementSubjects = 'GoogleDrive' | 'ChromeExtension' | 'Desktop' | 'RecordSync';
type EntitlementSubjects = 'GoogleDrive' | 'ChromeExtension' | 'Desktop' | 'RecordSync' | 'AnalysisTools';

type TeamActions = 'read' | 'update';
type TeamSubjects = 'Team' | 'TeamMember' | { type: 'TeamMember'; role: TeamMemberRole };
Expand Down Expand Up @@ -132,6 +132,10 @@ function getAbilityRules({ isBrowserExtension, isDesktop, isCanvasApp, user }: G
if (user.entitlements.recordSync || isBrowserExtension || isDesktop) {
can('access', 'RecordSync');
}
// Analysis Tools (Field Usage + Permission Analysis) are paid-only; desktop/extension/canvas are paid tiers.
if (user.entitlements.analysisTools || isBrowserExtension || isDesktop || isCanvasApp) {
can('access', 'AnalysisTools');
}

return rules;
}
Expand Down
Loading
Loading