Skip to content

Commit f016eb3

Browse files
committed
feat(knowledge): add Live sync option to KB connector modal for Max/Enterprise users
Adds a "Live" (every 5 min) sync frequency option gated to Max and Enterprise plan users. Includes client-side badge + disabled state, shared sync intervals constant, and server-side plan validation on both POST and PATCH connector routes.
1 parent d290e06 commit f016eb3

6 files changed

Lines changed: 99 additions & 18 deletions

File tree

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { z } from 'zod'
1313
import { decryptApiKey } from '@/lib/api-key/crypto'
1414
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
1515
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
16+
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
1617
import { generateRequestId } from '@/lib/core/utils/request'
1718
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
1819
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
@@ -116,6 +117,20 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
116117
)
117118
}
118119

120+
if (
121+
parsed.data.syncIntervalMinutes !== undefined &&
122+
parsed.data.syncIntervalMinutes > 0 &&
123+
parsed.data.syncIntervalMinutes < 60
124+
) {
125+
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
126+
if (!canUseLiveSync) {
127+
return NextResponse.json(
128+
{ error: 'Live sync requires a Max or Enterprise plan' },
129+
{ status: 403 }
130+
)
131+
}
132+
}
133+
119134
if (parsed.data.sourceConfig !== undefined) {
120135
const existingRows = await db
121136
.select()

apps/sim/app/api/knowledge/[id]/connectors/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { encryptApiKey } from '@/lib/api-key/crypto'
88
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
99
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
1011
import { generateRequestId } from '@/lib/core/utils/request'
1112
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
1213
import { allocateTagSlots } from '@/lib/knowledge/constants'
@@ -97,6 +98,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
9798

9899
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
99100

101+
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
102+
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
103+
if (!canUseLiveSync) {
104+
return NextResponse.json(
105+
{ error: 'Live sync requires a Max or Enterprise plan' },
106+
{ status: 403 }
107+
)
108+
}
109+
}
110+
100111
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
101112
if (!connectorConfig) {
102113
return NextResponse.json(

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,22 @@ import {
1919
ModalHeader,
2020
Tooltip,
2121
} from '@/components/emcn'
22+
import { getSubscriptionAccessState } from '@/lib/billing/client'
2223
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
2324
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
2425
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
2526
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
27+
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/sync-intervals'
28+
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
2629
import { getDependsOnFields } from '@/blocks/utils'
2730
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
2831
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
2932
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
3033
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
34+
import { useSubscriptionData } from '@/hooks/queries/subscription'
3135
import type { SelectorKey } from '@/hooks/selectors/types'
3236
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
3337

34-
const SYNC_INTERVALS = [
35-
{ label: 'Every hour', value: 60 },
36-
{ label: 'Every 6 hours', value: 360 },
37-
{ label: 'Daily', value: 1440 },
38-
{ label: 'Weekly', value: 10080 },
39-
{ label: 'Manual only', value: 0 },
40-
] as const
41-
4238
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
4339

4440
interface AddConnectorModalProps {
@@ -67,6 +63,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
6763
const { workspaceId } = useParams<{ workspaceId: string }>()
6864
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
6965

66+
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
67+
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
68+
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
69+
7070
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
7171
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
7272
const connectorProviderId = useMemo(
@@ -516,8 +516,17 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
516516
onValueChange={(val) => setSyncInterval(Number(val))}
517517
>
518518
{SYNC_INTERVALS.map((interval) => (
519-
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
519+
<ButtonGroupItem
520+
key={interval.value}
521+
value={String(interval.value)}
522+
disabled={interval.requiresMax && !hasMaxAccess}
523+
>
520524
{interval.label}
525+
{interval.requiresMax && !hasMaxAccess && (
526+
<span className='ml-1 shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
527+
Max
528+
</span>
529+
)}
521530
</ButtonGroupItem>
522531
))}
523532
</ButtonGroup>

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
ModalTabsTrigger,
2222
Skeleton,
2323
} from '@/components/emcn'
24+
import { getSubscriptionAccessState } from '@/lib/billing/client'
25+
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/sync-intervals'
26+
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
2427
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
2528
import type { ConnectorConfig } from '@/connectors/types'
2629
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
@@ -30,17 +33,10 @@ import {
3033
useRestoreConnectorDocument,
3134
useUpdateConnector,
3235
} from '@/hooks/queries/kb/connectors'
36+
import { useSubscriptionData } from '@/hooks/queries/subscription'
3337

3438
const logger = createLogger('EditConnectorModal')
3539

36-
const SYNC_INTERVALS = [
37-
{ label: 'Every hour', value: 60 },
38-
{ label: 'Every 6 hours', value: 360 },
39-
{ label: 'Daily', value: 1440 },
40-
{ label: 'Weekly', value: 10080 },
41-
{ label: 'Manual only', value: 0 },
42-
] as const
43-
4440
/** Keys injected by the sync engine — not user-editable */
4541
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
4642

@@ -76,6 +72,10 @@ export function EditConnectorModal({
7672

7773
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
7874

75+
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
76+
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
77+
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
78+
7979
const hasChanges = useMemo(() => {
8080
if (syncInterval !== connector.syncIntervalMinutes) return true
8181
for (const [key, value] of Object.entries(sourceConfig)) {
@@ -146,6 +146,7 @@ export function EditConnectorModal({
146146
setSourceConfig={setSourceConfig}
147147
syncInterval={syncInterval}
148148
setSyncInterval={setSyncInterval}
149+
hasMaxAccess={hasMaxAccess}
149150
error={error}
150151
/>
151152
</ModalTabsContent>
@@ -184,6 +185,7 @@ interface SettingsTabProps {
184185
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
185186
syncInterval: number
186187
setSyncInterval: (v: number) => void
188+
hasMaxAccess: boolean
187189
error: string | null
188190
}
189191

@@ -193,6 +195,7 @@ function SettingsTab({
193195
setSourceConfig,
194196
syncInterval,
195197
setSyncInterval,
198+
hasMaxAccess,
196199
error,
197200
}: SettingsTabProps) {
198201
return (
@@ -234,8 +237,17 @@ function SettingsTab({
234237
onValueChange={(val) => setSyncInterval(Number(val))}
235238
>
236239
{SYNC_INTERVALS.map((interval) => (
237-
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
240+
<ButtonGroupItem
241+
key={interval.value}
242+
value={String(interval.value)}
243+
disabled={interval.requiresMax && !hasMaxAccess}
244+
>
238245
{interval.label}
246+
{interval.requiresMax && !hasMaxAccess && (
247+
<span className='ml-1 shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
248+
Max
249+
</span>
250+
)}
239251
</ButtonGroupItem>
240252
))}
241253
</ButtonGroup>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const SYNC_INTERVALS = [
2+
{ label: 'Live', value: 5, requiresMax: true },
3+
{ label: 'Every hour', value: 60, requiresMax: false },
4+
{ label: 'Every 6 hours', value: 360, requiresMax: false },
5+
{ label: 'Daily', value: 1440, requiresMax: false },
6+
{ label: 'Weekly', value: 10080, requiresMax: false },
7+
{ label: 'Manual only', value: 0, requiresMax: false },
8+
] as const

apps/sim/lib/billing/core/subscription.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,32 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
459459
}
460460
}
461461

462+
/**
463+
* Check if user has access to live sync (every 5 minutes) for KB connectors
464+
* Returns true if:
465+
* - Self-hosted deployment, OR
466+
* - Non-production environment, OR
467+
* - User has a Max plan (credits >= 25000) or enterprise plan
468+
*/
469+
export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
470+
try {
471+
if (!isHosted) {
472+
return true
473+
}
474+
if (!isProd) {
475+
return true
476+
}
477+
const sub = await getHighestPrioritySubscription(userId)
478+
if (!sub) return false
479+
const billingStatus = await getEffectiveBillingStatus(userId)
480+
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
481+
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
482+
} catch (error) {
483+
logger.error('Error checking live sync access', { error, userId })
484+
return false
485+
}
486+
}
487+
462488
/**
463489
* Check if user has exceeded their cost limit based on current period usage
464490
*/

0 commit comments

Comments
 (0)