Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/docs/public/humans.txt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Jean-Paul Argudo
Jeezy
Jeff Smick
Jenny Kibiri
Jeremias Menichelli
Jess Shears
Jim Brodeur
Jim Chanco Jr
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/data/config/project-settings-v2-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] &

export async function getProjectSettings(
{ projectRef }: ProjectSettingsVariables,
signal?: AbortSignal
signal?: AbortSignal,
headers?: Record<string, string>
) {
if (!projectRef) throw new Error('projectRef is required')

const { data, error } = await get('/platform/projects/{ref}/settings', {
params: { path: { ref: projectRef } },
signal,
headers,
})

if (error) handleError(error)
Expand Down
4 changes: 3 additions & 1 deletion apps/studio/data/subscriptions/org-subscription-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export type OrgSubscriptionVariables = {

export async function getOrgSubscription(
{ orgSlug }: OrgSubscriptionVariables,
signal?: AbortSignal
signal?: AbortSignal,
headers?: Record<string, string>
) {
if (!orgSlug) throw new Error('orgSlug is required')

const { error, data } = await get('/platform/organizations/{slug}/billing/subscription', {
params: { path: { slug: orgSlug } },
signal,
headers,
})

if (error) handleError(error)
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/lib/ai/generate-assistant-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export async function generateAssistantResponse({
getSchemas?: () => Promise<string>
projectRef?: string
chatName?: string
// TODO(mattrossman): use for excluding HIPAA projects from assistant tracing
isHipaaEnabled?: boolean
promptProviderOptions?: Record<string, any>
providerOptions?: Record<string, any>
abortSignal?: AbortSignal
Expand Down
136 changes: 136 additions & 0 deletions apps/studio/lib/ai/org-ai-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,53 @@ vi.mock('data/projects/project-detail-query', () => ({
getProjectDetail: vi.fn(),
}))

vi.mock('data/subscriptions/org-subscription-query', () => ({
getOrgSubscription: vi.fn(),
}))

vi.mock('data/config/project-settings-v2-query', () => ({
getProjectSettings: vi.fn(),
}))

vi.mock('hooks/misc/useOrgOptedIntoAi', () => ({
getAiOptInLevel: vi.fn(),
}))

vi.mock('components/interfaces/Billing/Subscription/Subscription.utils', () => ({
subscriptionHasHipaaAddon: vi.fn(),
}))

describe('ai/org-ai-details', () => {
let mockGetOrganizations: ReturnType<typeof vi.fn>
let mockGetProjectDetail: ReturnType<typeof vi.fn>
let mockGetOrgSubscription: ReturnType<typeof vi.fn>
let mockGetProjectSettings: ReturnType<typeof vi.fn>
let mockGetAiOptInLevel: ReturnType<typeof vi.fn>
let mockSubscriptionHasHipaaAddon: ReturnType<typeof vi.fn>

beforeEach(async () => {
vi.clearAllMocks()

const orgsQuery = await import('data/organizations/organizations-query')
const projectQuery = await import('data/projects/project-detail-query')
const subscriptionQuery = await import('data/subscriptions/org-subscription-query')
const settingsQuery = await import('data/config/project-settings-v2-query')
const aiHook = await import('hooks/misc/useOrgOptedIntoAi')
const subscriptionUtils = await import(
'components/interfaces/Billing/Subscription/Subscription.utils'
)

mockGetOrganizations = vi.mocked(orgsQuery.getOrganizations)
mockGetProjectDetail = vi.mocked(projectQuery.getProjectDetail)
mockGetOrgSubscription = vi.mocked(subscriptionQuery.getOrgSubscription)
mockGetProjectSettings = vi.mocked(settingsQuery.getProjectSettings)
mockGetAiOptInLevel = vi.mocked(aiHook.getAiOptInLevel)
mockSubscriptionHasHipaaAddon = vi.mocked(subscriptionUtils.subscriptionHasHipaaAddon)

// Default mocks for subscription/settings (no HIPAA)
mockGetOrgSubscription.mockResolvedValue({ addons: [] })
mockGetProjectSettings.mockResolvedValue({ is_sensitive: false })
mockSubscriptionHasHipaaAddon.mockReturnValue(false)
})

describe('getOrgAIDetails', () => {
Expand Down Expand Up @@ -92,6 +120,7 @@ describe('ai/org-ai-details', () => {
expect(result).toEqual({
aiOptInLevel: 'schema_only',
isLimited: true,
isHipaaEnabled: false,
})
})

Expand Down Expand Up @@ -239,5 +268,112 @@ describe('ai/org-ai-details', () => {

expect(result.isLimited).toBe(false) // Pro plan
})

it('should return isHipaaEnabled true when subscription has HIPAA addon and project is sensitive', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
plan: { id: 'enterprise' },
opt_in_tags: [],
}
const mockProject = { organization_id: 1 }

mockGetOrganizations.mockResolvedValue([mockOrg])
mockGetProjectDetail.mockResolvedValue(mockProject)
mockGetAiOptInLevel.mockReturnValue('schema')
mockSubscriptionHasHipaaAddon.mockReturnValue(true)
mockGetProjectSettings.mockResolvedValue({ is_sensitive: true })

const result = await getOrgAIDetails({
orgSlug: 'test-org',
authorization: 'Bearer token',
projectRef: 'test-project',
})

expect(result.isHipaaEnabled).toBe(true)
})

it('should return isHipaaEnabled false when subscription has HIPAA addon but project is not sensitive', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
plan: { id: 'enterprise' },
opt_in_tags: [],
}
const mockProject = { organization_id: 1 }

mockGetOrganizations.mockResolvedValue([mockOrg])
mockGetProjectDetail.mockResolvedValue(mockProject)
mockGetAiOptInLevel.mockReturnValue('schema')
mockSubscriptionHasHipaaAddon.mockReturnValue(true)
mockGetProjectSettings.mockResolvedValue({ is_sensitive: false })

const result = await getOrgAIDetails({
orgSlug: 'test-org',
authorization: 'Bearer token',
projectRef: 'test-project',
})

expect(result.isHipaaEnabled).toBe(false)
})

it('should return isHipaaEnabled false when project is sensitive but no HIPAA addon', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
plan: { id: 'pro' },
opt_in_tags: [],
}
const mockProject = { organization_id: 1 }

mockGetOrganizations.mockResolvedValue([mockOrg])
mockGetProjectDetail.mockResolvedValue(mockProject)
mockGetAiOptInLevel.mockReturnValue('schema')
mockSubscriptionHasHipaaAddon.mockReturnValue(false)
mockGetProjectSettings.mockResolvedValue({ is_sensitive: true })

const result = await getOrgAIDetails({
orgSlug: 'test-org',
authorization: 'Bearer token',
projectRef: 'test-project',
})

expect(result.isHipaaEnabled).toBe(false)
})

it('should fetch subscription and project settings with authorization headers', async () => {
const mockOrg = {
id: 1,
slug: 'test-org',
plan: { id: 'pro' },
}
const mockProject = { organization_id: 1 }

mockGetOrganizations.mockResolvedValue([mockOrg])
mockGetProjectDetail.mockResolvedValue(mockProject)
mockGetAiOptInLevel.mockReturnValue('schema')

await getOrgAIDetails({
orgSlug: 'test-org',
authorization: 'Bearer token',
projectRef: 'test-project',
})

const expectedHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer token',
}

expect(mockGetOrgSubscription).toHaveBeenCalledWith(
{ orgSlug: 'test-org' },
undefined,
expectedHeaders
)
expect(mockGetProjectSettings).toHaveBeenCalledWith(
{ projectRef: 'test-project' },
undefined,
expectedHeaders
)
})
})
})
9 changes: 8 additions & 1 deletion apps/studio/lib/ai/org-ai-details.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { getProjectSettings } from 'data/config/project-settings-v2-query'
import { getOrganizations } from 'data/organizations/organizations-query'
import { getProjectDetail } from 'data/projects/project-detail-query'
import { getOrgSubscription } from 'data/subscriptions/org-subscription-query'
import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'

export const getOrgAIDetails = async ({
Expand All @@ -16,9 +19,11 @@ export const getOrgAIDetails = async ({
...(authorization && { Authorization: authorization }),
}

const [organizations, selectedProject] = await Promise.all([
const [organizations, selectedProject, subscription, projectSettings] = await Promise.all([
getOrganizations({ headers }),
getProjectDetail({ ref: projectRef }, undefined, headers),
getOrgSubscription({ orgSlug }, undefined, headers),
getProjectSettings({ projectRef }, undefined, headers),
])

const selectedOrg = organizations.find((org) => org.slug === orgSlug)
Expand All @@ -30,9 +35,11 @@ export const getOrgAIDetails = async ({

const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags)
const isLimited = selectedOrg?.plan.id === 'free'
const isHipaaEnabled = subscriptionHasHipaaAddon(subscription) && !!projectSettings?.is_sensitive

return {
aiOptInLevel,
isLimited,
isHipaaEnabled,
}
}
9 changes: 8 additions & 1 deletion apps/studio/pages/api/ai/sql/generate-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {

let aiOptInLevel: AiOptInLevel = 'disabled'
let isLimited = false
let isHipaaEnabled = false

if (!IS_PLATFORM) {
aiOptInLevel = 'schema'
Expand All @@ -97,14 +98,19 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
if (IS_PLATFORM && orgSlug && authorization && projectRef) {
try {
// Get organizations and compute opt in level server-side
const { aiOptInLevel: orgAIOptInLevel, isLimited: orgAILimited } = await getOrgAIDetails({
const {
aiOptInLevel: orgAIOptInLevel,
isLimited: orgAILimited,
isHipaaEnabled: orgIsHipaaEnabled,
} = await getOrgAIDetails({
orgSlug,
authorization,
projectRef,
})

aiOptInLevel = orgAIOptInLevel
isLimited = orgAILimited
isHipaaEnabled = orgIsHipaaEnabled
} catch (error) {
return res.status(400).json({
error: 'There was an error fetching your organization details',
Expand Down Expand Up @@ -174,6 +180,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
getSchemas: aiOptInLevel !== 'disabled' ? getSchemas : undefined,
projectRef,
chatName,
isHipaaEnabled,
promptProviderOptions,
providerOptions,
abortSignal: abortController.signal,
Expand Down
13 changes: 7 additions & 6 deletions apps/studio/pages/project/[ref]/database/backups/pitr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'

const DatabasePhysicalBackups: NextPageWithLayout = () => {
return (
Expand Down Expand Up @@ -65,18 +66,18 @@ DatabasePhysicalBackups.getLayout = (page) => (
const PITR = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const { hasAccess: hasAccessToPitr, isLoading: isLoadingEntitlements } =
useCheckEntitlements('pitr.available_variants')
const isOrioleDbInAws = useIsOrioleDbInAws()
const {
data: backups,
error,
isPending: isLoading,
isPending: isLoadingBackups,
isError,
isSuccess,
} = useBackupsQuery({ projectRef })

const plan = organization?.plan?.id
const isFreePlan = plan === 'free'
const isLoading = isLoadingBackups || isLoadingEntitlements
const isEnabled = backups?.pitr_enabled
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY

Expand Down Expand Up @@ -109,12 +110,12 @@ const PITR = () => {
<>
{!isEnabled ? (
<UpgradeToPro
addon={isFreePlan ? undefined : 'pitr'}
addon={hasAccessToPitr ? 'pitr' : undefined}
source="pitr"
featureProposition="enable Point in Time Recovery"
primaryText="Point in Time Recovery is a Pro Plan add-on"
secondaryText={
isFreePlan
!hasAccessToPitr
? 'Roll back your database to a specific second. Starts at $100/month. Pro Plan already includes daily backups at no extra cost.'
: 'Please enable the add-on to enable point in time recovery for your project.'
}
Expand Down
2 changes: 1 addition & 1 deletion apps/www/lib/redirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -2823,7 +2823,7 @@ module.exports = [
{
permanent: true,
source: '/docs/guides/platform/oauth-apps/oauth-scopes',
destination: '/docs/guides/integrations/build-a-supabase-integration/oauth-scopes',
destination: '/docs/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes',
},
{
permanent: true,
Expand Down
Loading
Loading