feat(usage-indicator): added ability to see current usage (#925)

* feat(usage-indicator): added ability to see current usage

* feat(billing): added billing ennabled flag for usage indicator, enforcement of billing usage

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-08-10 17:20:53 -07:00
committed by GitHub
parent 56ede1c980
commit 83f113984d
18 changed files with 631 additions and 174 deletions

View File

@@ -245,6 +245,8 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
const validData = {
@@ -287,6 +289,8 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
const validData = {

View File

@@ -30,6 +30,8 @@ vi.mock('@/lib/env', () => ({
env: {
OPENAI_API_KEY: 'test-api-key',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({

View File

@@ -15,7 +15,11 @@ vi.mock('drizzle-orm', () => ({
sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }),
}))
vi.mock('@/lib/env', () => ({ env: { OPENAI_API_KEY: 'test-key' } }))
vi.mock('@/lib/env', () => ({
env: { OPENAI_API_KEY: 'test-key' },
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(),

View File

@@ -89,6 +89,10 @@
/* Base Component Properties */
--base-muted-foreground: #737373;
/* Gradient Colors */
--gradient-primary: 263 85% 70%; /* More vibrant purple */
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
}
/* Dark Mode Theme */
@@ -145,6 +149,10 @@
/* Base Component Properties */
--base-muted-foreground: #a3a3a3;
/* Gradient Colors - Adjusted for dark mode */
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
}
}
@@ -325,6 +333,13 @@ input[type="search"]::-ms-clear {
background: transparent;
}
/* Gradient Text Utility - Use with Tailwind gradient directions */
.gradient-text {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation Classes */
.animate-pulse-ring {
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;

View File

@@ -3,7 +3,9 @@ export { FolderTree } from './folder-tree/folder-tree'
export { HelpModal } from './help-modal/help-modal'
export { LogsFilters } from './logs-filters/logs-filters'
export { SettingsModal } from './settings-modal/settings-modal'
export { SubscriptionModal } from './subscription-modal/subscription-modal'
export { Toolbar } from './toolbar/toolbar'
export { UsageIndicator } from './usage-indicator/usage-indicator'
export { WorkflowContextMenu } from './workflow-context-menu/workflow-context-menu'
export { WorkflowList } from './workflow-list/workflow-list'
export { WorkspaceHeader } from './workspace-header/workspace-header'

View File

@@ -8,7 +8,7 @@ import {
UserCircle,
Users,
} from 'lucide-react'
import { isDev } from '@/lib/environment'
import { isBillingEnabled } from '@/lib/environment'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -40,7 +40,7 @@ type NavigationItem = {
| 'privacy'
label: string
icon: React.ComponentType<{ className?: string }>
hideInDev?: boolean
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
}
@@ -79,13 +79,13 @@ const allNavigationItems: NavigationItem[] = [
id: 'subscription',
label: 'Subscription',
icon: CreditCard,
hideInDev: true,
hideWhenBillingDisabled: true,
},
{
id: 'team',
label: 'Team',
icon: Users,
hideInDev: true,
hideWhenBillingDisabled: true,
requiresTeam: true,
},
]
@@ -99,7 +99,7 @@ export function SettingsNavigation({
const subscription = getSubscriptionStatus()
const navigationItems = allNavigationItems.filter((item) => {
if (item.hideInDev && isDev) {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { client } from '@/lib/auth-client'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
@@ -82,7 +82,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [onOpenChange])
const isSubscriptionEnabled = !!client.subscription
// Redirect away from billing tabs if billing is disabled
useEffect(() => {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
setActiveSection('general')
}
}, [activeSection])
const isSubscriptionEnabled = isBillingEnabled
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -134,9 +141,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<Subscription onOpenChange={onOpenChange} />
</div>
)}
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
{isBillingEnabled && (
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
)}
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
<Privacy />
</div>

View File

@@ -0,0 +1,281 @@
'use client'
import { useCallback, useEffect } from 'react'
import {
Building2,
Check,
Clock,
Database,
DollarSign,
HeadphonesIcon,
Infinity as InfinityIcon,
MessageSquare,
Server,
Users,
Workflow,
Zap,
} from 'lucide-react'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('SubscriptionModal')
interface SubscriptionModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface PlanFeature {
text: string
included: boolean
icon?: any
}
export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps) {
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const { activeOrganization } = useOrganizationStore()
const { loadData, getSubscriptionStatus, isLoading } = useSubscriptionStore()
// Load subscription data when modal opens
useEffect(() => {
if (open) {
loadData()
}
}, [open, loadData])
const subscription = getSubscriptionStatus()
const handleUpgrade = useCallback(
async (targetPlan: 'pro' | 'team') => {
if (!session?.user?.id) return
const subscriptionData = useSubscriptionStore.getState().subscriptionData
const currentSubscriptionId = subscriptionData?.stripeSubscriptionId
let referenceId = session.user.id
if (subscription.isTeam && activeOrganization?.id) {
referenceId = activeOrganization.id
}
const currentUrl = window.location.origin + window.location.pathname
try {
const upgradeParams: any = {
plan: targetPlan,
referenceId,
successUrl: currentUrl,
cancelUrl: currentUrl,
seats: targetPlan === 'team' ? 1 : undefined,
}
if (currentSubscriptionId) {
upgradeParams.subscriptionId = currentSubscriptionId
}
await betterAuthSubscription.upgrade(upgradeParams)
} catch (error) {
logger.error('Failed to initiate subscription upgrade:', error)
alert('Failed to initiate upgrade. Please try again or contact support.')
}
},
[session?.user?.id, subscription.isTeam, activeOrganization?.id, betterAuthSubscription]
)
const handleContactUs = () => {
window.open('https://form.typeform.com/to/jqCO12pF', '_blank')
}
// Define all 4 plans
const plans = [
{
name: 'Free',
price: '$0',
description: '',
features: [
{ text: '$10 free inference credit', included: true, icon: DollarSign },
{ text: '10 runs per minute (sync)', included: true, icon: Zap },
{ text: '50 runs per minute (async)', included: true, icon: Clock },
{ text: '7-day log retention', included: true, icon: Database },
],
isActive: subscription.isFree,
action: null, // No action for free plan
},
{
name: 'Pro',
price: '$20',
description: '/month',
features: [
{ text: '25 runs per minute (sync)', included: true, icon: Zap },
{ text: '200 runs per minute (async)', included: true, icon: Clock },
{ text: 'Unlimited workspaces', included: true, icon: Building2 },
{ text: 'Unlimited workflows', included: true, icon: Workflow },
{ text: 'Unlimited invites', included: true, icon: Users },
{ text: 'Unlimited log retention', included: true, icon: Database },
],
isActive: subscription.isPro && !subscription.isTeam,
action: subscription.isFree ? () => handleUpgrade('pro') : null,
},
{
name: 'Team',
price: '$40',
description: '/month',
features: [
{ text: '75 runs per minute (sync)', included: true, icon: Zap },
{ text: '500 runs per minute (async)', included: true, icon: Clock },
{ text: 'Everything in Pro', included: true, icon: InfinityIcon },
{ text: 'Dedicated Slack channel', included: true, icon: MessageSquare },
],
isActive: subscription.isTeam,
action: !subscription.isTeam ? () => handleUpgrade('team') : null,
},
{
name: 'Enterprise',
price: '',
description: '',
features: [
{ text: 'Custom rate limits', included: true, icon: Zap },
{ text: 'Enterprise hosting license', included: true, icon: Server },
{ text: 'Custom enterprise support', included: true, icon: HeadphonesIcon },
],
isActive: subscription.isEnterprise,
action: handleContactUs,
},
]
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='!fixed !inset-0 !m-0 data-[state=open]:!translate-x-0 data-[state=open]:!translate-y-0 flex h-full max-h-full w-full max-w-full flex-col gap-0 rounded-none border-0 p-0'>
<AlertDialogHeader className='flex-shrink-0 px-6 py-5'>
<AlertDialogTitle className='font-medium text-lg'>Upgrade your plan</AlertDialogTitle>
</AlertDialogHeader>
<div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden px-8 pb-8'>
<div className='flex w-full max-w-4xl flex-col gap-6'>
{/* Main Plans Grid - Free, Pro, Team */}
<div className='grid grid-cols-1 gap-6 md:grid-cols-3'>
{plans.slice(0, 3).map((plan) => (
<div
key={plan.name}
className={cn('relative flex flex-col rounded-[10px] border p-6')}
>
{/* Plan Header */}
<div className='mb-6'>
<h3 className='mb-3 font-semibold text-lg'>{plan.name}</h3>
<div className='flex items-baseline'>
<span className='font-semibold text-3xl'>{plan.price}</span>
{plan.description && (
<span className='ml-1 text-muted-foreground text-sm'>
{plan.description}
</span>
)}
</div>
</div>
{/* Features */}
<ul className='mb-6 flex-1 space-y-3'>
{plan.features.map((feature, index) => (
<li key={index} className='flex items-start gap-2 text-sm'>
{feature.icon ? (
<feature.icon className='mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground' />
) : (
<Check className='mt-0.5 h-4 w-4 flex-shrink-0 text-green-500' />
)}
<span className='text-muted-foreground'>{feature.text}</span>
</li>
))}
</ul>
{/* Action Button */}
<div className='mt-auto'>
{plan.isActive ? (
<Button variant='secondary' className='w-full rounded-[8px]' disabled>
Current plan
</Button>
) : plan.action ? (
<Button
onClick={plan.action}
className='w-full rounded-[8px]'
variant='default'
>
Upgrade
</Button>
) : (
<Button variant='outline' className='w-full rounded-[8px]' disabled>
{plan.name === 'Free' ? 'Basic plan' : 'Upgrade'}
</Button>
)}
</div>
</div>
))}
</div>
{/* Enterprise Plan - Full Width */}
<div
className={cn(
'relative flex flex-col rounded-[10px] border p-6 md:flex-row md:items-center md:justify-between',
plans[3].isActive && 'border-gray-400'
)}
>
{/* Left Side - Plan Info */}
<div className='mb-4 md:mb-0'>
<h3 className='mb-2 font-semibold text-lg'>{plans[3].name}</h3>
<p className='mb-3 text-muted-foreground text-sm'>
Custom solutions tailored to your enterprise needs
</p>
<div className='flex items-center gap-4'>
{plans[3].features.map((feature, index) => (
<div key={index} className='flex items-center gap-4'>
<div className='flex items-center gap-2 text-sm'>
{feature.icon ? (
<feature.icon className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
) : (
<Check className='h-4 w-4 flex-shrink-0 text-green-500' />
)}
<span className='text-muted-foreground'>{feature.text}</span>
</div>
{index < plans[3].features.length - 1 && (
<div className='h-4 w-px bg-border' />
)}
</div>
))}
</div>
</div>
{/* Right Side - Button */}
<div className='md:ml-auto md:w-[200px]'>
{plans[3].isActive ? (
<Button variant='secondary' className='w-full rounded-[8px]' disabled>
Current plan
</Button>
) : plans[3].action ? (
<Button
onClick={plans[3].action}
className='w-full rounded-[8px]'
variant='default'
>
Contact us
</Button>
) : (
<Button className='w-full rounded-[8px]' variant='default' disabled>
Contact us
</Button>
)}
</div>
</div>
</div>
</div>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useEffect } from 'react'
import { Badge, Progress, Skeleton } from '@/components/ui'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
// Constants for reusable styles
const GRADIENT_BADGE_STYLES =
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs'
const GRADIENT_TEXT_STYLES =
'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
const CONTAINER_STYLES =
'pointer-events-auto flex-shrink-0 rounded-[10px] border bg-background px-3 py-2.5 shadow-xs cursor-pointer transition-colors hover:bg-muted/50'
// Plan name mapping
const PLAN_NAMES = {
enterprise: 'Enterprise',
team: 'Team',
pro: 'Pro',
free: 'Free',
} as const
interface UsageIndicatorProps {
onClick?: (badgeType: 'add' | 'upgrade') => void
}
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const { loadData, getUsage, getSubscriptionStatus, isLoading } = useSubscriptionStore()
// Load subscription data on mount
useEffect(() => {
loadData()
}, [loadData])
const usage = getUsage()
const subscription = getSubscriptionStatus()
// Show skeleton while loading
if (isLoading) {
return (
<div className={CONTAINER_STYLES} onClick={() => onClick?.('upgrade')}>
<div className='space-y-2'>
{/* Plan and usage info skeleton */}
<div className='flex items-center justify-between'>
<Skeleton className='h-5 w-12' />
<Skeleton className='h-4 w-20' />
</div>
{/* Progress Bar skeleton */}
<Skeleton className='h-2 w-full' />
</div>
</div>
)
}
// Calculate progress percentage (capped at 100)
const progressPercentage = Math.min(usage.percentUsed, 100)
// Determine plan type
const planType = subscription.isEnterprise
? 'enterprise'
: subscription.isTeam
? 'team'
: subscription.isPro
? 'pro'
: 'free'
// Determine badge to show
const showAddBadge = planType !== 'free' && usage.percentUsed >= 85
const badgeText = planType === 'free' ? 'Upgrade' : 'Add'
const badgeType = planType === 'free' ? 'upgrade' : 'add'
return (
<div className={CONTAINER_STYLES} onClick={() => onClick?.(badgeType)}>
<div className='space-y-2'>
{/* Plan and usage info */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span
className={cn(
'font-medium text-sm',
planType === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
)}
>
{PLAN_NAMES[planType]}
</span>
{(showAddBadge || planType === 'free') && (
<Badge className={GRADIENT_BADGE_STYLES}>{badgeText}</Badge>
)}
</div>
<span className='text-muted-foreground text-xs tabular-nums'>
${usage.current.toFixed(2)} / ${usage.limit}
</span>
</div>
{/* Progress Bar */}
<Progress value={progressPercentage} className='h-2' />
</div>
</div>
)
}

View File

@@ -171,11 +171,6 @@ const PermissionsTableSkeleton = React.memo(() => (
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'
const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string => {
// Use consistent gray styling for all statuses to align with modal design
return 'inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
const PermissionsTable = ({
userPermissions,
onPermissionChange,
@@ -314,12 +309,12 @@ const PermissionsTable = ({
<div className='flex items-center gap-2'>
<span className='font-medium text-card-foreground text-sm'>{user.email}</span>
{isPendingInvitation && (
<span className='inline-flex items-center rounded-[8px] bg-blue-100 px-2 py-1 font-medium text-blue-700 text-xs dark:bg-blue-900/30 dark:text-blue-400'>
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
Sent
</span>
)}
{hasChanges && (
<span className='inline-flex items-center rounded-[8px] bg-orange-100 px-2 py-1 font-medium text-orange-700 text-xs dark:bg-orange-900/30 dark:text-orange-400'>
<span className='inline-flex items-center rounded-[8px] bg-gray-100 px-2 py-1 font-medium text-gray-700 text-xs dark:bg-gray-800 dark:text-gray-300'>
Modified
</span>
)}

View File

@@ -5,6 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { cn } from '@/lib/utils'
@@ -16,7 +17,9 @@ import {
HelpModal,
LogsFilters,
SettingsModal,
SubscriptionModal,
Toolbar,
UsageIndicator,
WorkspaceHeader,
WorkspaceSelector,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
@@ -141,8 +144,9 @@ const SIDEBAR_HEIGHTS = {
WORKSPACE_HEADER: 48, // estimated height of workspace header
SEARCH: 48, // h-12
WORKFLOW_SELECTOR: 212, // h-[212px]
NAVIGATION: 48, // h-12 buttons
NAVIGATION: 42, // h-[42px] buttons
WORKSPACE_SELECTOR: 171, // optimized height: p-2(16) + h-[104px](104) + mt-2(8) + border-t(1) + pt-2(8) + h-8(32) = 169px
USAGE_INDICATOR: 58, // actual height: border(2) + py-2.5(20) + content(~36) = 58px
}
/**
@@ -688,6 +692,7 @@ export function Sidebar() {
const [showHelp, setShowHelp] = useState(false)
const [showInviteMembers, setShowInviteMembers] = useState(false)
const [showSearchModal, setShowSearchModal] = useState(false)
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false)
// Separate regular workflows from temporary marketplace workflows
const { regularWorkflows, tempWorkflows } = useMemo(() => {
@@ -1008,7 +1013,7 @@ export function Sidebar() {
}`}
style={{
top: `${toolbarTop}px`,
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP + (isBillingEnabled ? SIDEBAR_HEIGHTS.USAGE_INDICATOR + SIDEBAR_GAP : 0)}px`, // Navigation height + gap + UsageIndicator height + gap (if billing enabled)
}}
>
<Toolbar
@@ -1024,12 +1029,36 @@ export function Sidebar() {
}`}
style={{
top: `${toolbarTop}px`,
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP + (isBillingEnabled ? SIDEBAR_HEIGHTS.USAGE_INDICATOR + SIDEBAR_GAP : 0)}px`, // Navigation height + gap + UsageIndicator height + gap (if billing enabled)
}}
>
<LogsFilters />
</div>
{/* Floating Usage Indicator - Only shown when billing enabled */}
{isBillingEnabled && (
<div
className='pointer-events-auto fixed left-4 z-50 w-56'
style={{ bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP}px` }} // Navigation height + gap
>
<UsageIndicator
onClick={(badgeType) => {
if (badgeType === 'add') {
// Open settings modal on subscription tab
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('open-settings', { detail: { tab: 'subscription' } })
)
}
} else {
// Open subscription modal for upgrade
setShowSubscriptionModal(true)
}
}}
/>
</div>
)}
{/* Floating Navigation - Always visible */}
<div
className='pointer-events-auto fixed left-4 z-50 w-56'
@@ -1046,6 +1075,7 @@ export function Sidebar() {
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />
<SearchModal
open={showSearchModal}
onOpenChange={setShowSearchModal}

View File

@@ -23,7 +23,7 @@ import { getBaseURL } from '@/lib/auth-client'
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { quickValidateEmail } from '@/lib/email/validation'
import { env, isTruthy } from '@/lib/env'
import { isProd } from '@/lib/environment'
import { isBillingEnabled, isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { db } from '@/db'
@@ -1160,8 +1160,8 @@ export const auth = betterAuth({
},
],
}),
// Only include the Stripe plugin in production
...(isProd && stripeClient
// Only include the Stripe plugin when billing is enabled
...(isBillingEnabled && stripeClient
? [
stripe({
stripeClient,

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { getUserUsageLimit } from '@/lib/billing/core/usage'
import { isProd } from '@/lib/environment'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userStats } from '@/db/schema'
@@ -24,8 +24,8 @@ interface UsageData {
*/
export async function checkUsageStatus(userId: string): Promise<UsageData> {
try {
// In development, always return permissive limits
if (!isProd) {
// If billing is disabled, always return permissive limits
if (!isBillingEnabled) {
// Get actual usage from the database for display purposes
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const currentUsage =
@@ -115,8 +115,8 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
*/
export async function checkAndNotifyUsage(userId: string): Promise<void> {
try {
// Skip usage notifications in development
if (!isProd) {
// Skip usage notifications if billing is disabled
if (!isBillingEnabled) {
return
}
@@ -182,8 +182,8 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
message?: string
}> {
try {
// In development, always allow execution
if (!isProd) {
// If billing is disabled, always allow execution
if (!isBillingEnabled) {
return {
isExceeded: false,
currentUsage: 0,

View File

@@ -10,6 +10,8 @@ vi.mock('@/lib/env', () => ({
env: {
BETTER_AUTH_SECRET: 'test-secret-key',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
describe('unsubscribe utilities', () => {

View File

@@ -16,192 +16,193 @@ export const env = createEnv({
server: {
// Core Database & Authentication
DATABASE_URL: z.string().url(), // Primary database connection string
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
SIM_AGENT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
DATABASE_URL: z.string().url(), // Primary database connection string
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
SIM_AGENT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API
// Database & Storage
POSTGRES_URL: z.string().url().optional(), // Alternative PostgreSQL connection string
REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions
POSTGRES_URL: z.string().url().optional(), // Alternative PostgreSQL connection string
REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions
// Payment & Billing (Stripe)
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing
STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(), // Webhook secret for billing events
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier
FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users
STRIPE_PRO_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for pro tier
PRO_TIER_COST_LIMIT: z.number().optional(), // Cost limit for pro tier users
STRIPE_TEAM_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for team tier
TEAM_TIER_COST_LIMIT: z.number().optional(), // Cost limit for team tier users
STRIPE_ENTERPRISE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for enterprise tier
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
// Payment & Billing
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing
STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(), // Webhook secret for billing events
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier
FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users
STRIPE_PRO_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for pro tier
PRO_TIER_COST_LIMIT: z.number().optional(), // Cost limit for pro tier users
STRIPE_TEAM_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for team tier
TEAM_TIER_COST_LIMIT: z.number().optional(), // Cost limit for team tier users
STRIPE_ENTERPRISE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for enterprise tier
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
// Email & Communication
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails
// AI/LLM Provider API Keys
OPENAI_API_KEY: z.string().min(1).optional(), // Primary OpenAI API key
OPENAI_API_KEY_1: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_2: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_3: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
MISTRAL_API_KEY: z.string().min(1).optional(), // Mistral AI API key
ANTHROPIC_API_KEY_1: z.string().min(1).optional(), // Primary Anthropic Claude API key
ANTHROPIC_API_KEY_2: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
ANTHROPIC_API_KEY_3: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
FREESTYLE_API_KEY: z.string().min(1).optional(), // Freestyle AI API key
OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL
ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat
SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search
OPENAI_API_KEY: z.string().min(1).optional(), // Primary OpenAI API key
OPENAI_API_KEY_1: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_2: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_3: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
MISTRAL_API_KEY: z.string().min(1).optional(), // Mistral AI API key
ANTHROPIC_API_KEY_1: z.string().min(1).optional(), // Primary Anthropic Claude API key
ANTHROPIC_API_KEY_2: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
ANTHROPIC_API_KEY_3: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
FREESTYLE_API_KEY: z.string().min(1).optional(), // Freestyle AI API key
OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL
ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat
SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search
// Azure OpenAI Configuration
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Azure OpenAI service endpoint
AZURE_OPENAI_API_VERSION: z.string().optional(), // Azure OpenAI API version
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Azure OpenAI service endpoint
AZURE_OPENAI_API_VERSION: z.string().optional(), // Azure OpenAI API version
// Monitoring & Analytics
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
COPILOT_COST_MULTIPLIER: z.number().optional(), // Multiplier for copilot cost calculations
SENTRY_ORG: z.string().optional(), // Sentry organization for error tracking
SENTRY_PROJECT: z.string().optional(), // Sentry project for error tracking
SENTRY_AUTH_TOKEN: z.string().optional(), // Sentry authentication token
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
COPILOT_COST_MULTIPLIER: z.number().optional(), // Multiplier for copilot cost calculations
SENTRY_ORG: z.string().optional(), // Sentry organization for error tracking
SENTRY_PROJECT: z.string().optional(), // Sentry project for error tracking
SENTRY_AUTH_TOKEN: z.string().optional(), // Sentry authentication token
// External Services
JWT_SECRET: z.string().min(1).optional(), // JWT signing secret for custom tokens
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
BROWSERBASE_PROJECT_ID: z.string().min(1).optional(), // Browserbase project ID
GITHUB_TOKEN: z.string().optional(), // GitHub personal access token for API access
JWT_SECRET: z.string().min(1).optional(), // JWT signing secret for custom tokens
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
BROWSERBASE_PROJECT_ID: z.string().min(1).optional(), // Browserbase project ID
GITHUB_TOKEN: z.string().optional(), // GitHub personal access token for API access
// Infrastructure & Deployment
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
VERCEL_ENV: z.string().optional(), // Vercel deployment environment
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
VERCEL_ENV: z.string().optional(), // Vercel deployment environment
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment
// Background Jobs & Scheduling
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data
TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs
CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests
JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data
// Cloud Storage - AWS S3
AWS_REGION: z.string().optional(), // AWS region for S3 buckets
AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID
AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key
S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage
S3_LOGS_BUCKET_NAME: z.string().optional(), // S3 bucket for storing logs
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
AWS_REGION: z.string().optional(), // AWS region for S3 buckets
AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID
AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key
S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage
S3_LOGS_BUCKET_NAME: z.string().optional(), // S3 bucket for storing logs
S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files
S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files
S3_CHAT_BUCKET_NAME: z.string().optional(), // S3 bucket for chat logos
S3_COPILOT_BUCKET_NAME: z.string().optional(), // S3 bucket for copilot files
// Cloud Storage - Azure Blob
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key
AZURE_CONNECTION_STRING: z.string().optional(), // Azure storage connection string
AZURE_STORAGE_CONTAINER_NAME: z.string().optional(), // Azure container for general files
AZURE_STORAGE_KB_CONTAINER_NAME: z.string().optional(), // Azure container for knowledge base files
AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME: z.string().optional(), // Azure container for workflow execution files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
AZURE_ACCOUNT_NAME: z.string().optional(), // Azure storage account name
AZURE_ACCOUNT_KEY: z.string().optional(), // Azure storage account key
AZURE_CONNECTION_STRING: z.string().optional(), // Azure storage connection string
AZURE_STORAGE_CONTAINER_NAME: z.string().optional(), // Azure container for general files
AZURE_STORAGE_KB_CONTAINER_NAME: z.string().optional(), // Azure container for knowledge base files
AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME: z.string().optional(), // Azure container for workflow execution files
AZURE_STORAGE_CHAT_CONTAINER_NAME: z.string().optional(), // Azure container for chat logos
AZURE_STORAGE_COPILOT_CONTAINER_NAME: z.string().optional(), // Azure container for copilot files
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'), // Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
// Real-time Communication
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
SOCKET_PORT: z.number().optional(), // Port for WebSocket server
PORT: z.number().optional(), // Main application port
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
SOCKET_PORT: z.number().optional(), // Port for WebSocket server
PORT: z.number().optional(), // Main application port
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
// OAuth Integration Credentials - All optional, enables third-party integrations
GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID
CONFLUENCE_CLIENT_SECRET: z.string().optional(), // Atlassian Confluence OAuth client secret
JIRA_CLIENT_ID: z.string().optional(), // Atlassian Jira OAuth client ID
JIRA_CLIENT_SECRET: z.string().optional(), // Atlassian Jira OAuth client secret
AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID
AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret
SUPABASE_CLIENT_ID: z.string().optional(), // Supabase OAuth client ID
SUPABASE_CLIENT_SECRET: z.string().optional(), // Supabase OAuth client secret
NOTION_CLIENT_ID: z.string().optional(), // Notion OAuth client ID
NOTION_CLIENT_SECRET: z.string().optional(), // Notion OAuth client secret
DISCORD_CLIENT_ID: z.string().optional(), // Discord OAuth client ID
DISCORD_CLIENT_SECRET: z.string().optional(), // Discord OAuth client secret
MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams
MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret
HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID
HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID
CONFLUENCE_CLIENT_SECRET: z.string().optional(), // Atlassian Confluence OAuth client secret
JIRA_CLIENT_ID: z.string().optional(), // Atlassian Jira OAuth client ID
JIRA_CLIENT_SECRET: z.string().optional(), // Atlassian Jira OAuth client secret
AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID
AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret
SUPABASE_CLIENT_ID: z.string().optional(), // Supabase OAuth client ID
SUPABASE_CLIENT_SECRET: z.string().optional(), // Supabase OAuth client secret
NOTION_CLIENT_ID: z.string().optional(), // Notion OAuth client ID
NOTION_CLIENT_SECRET: z.string().optional(), // Notion OAuth client secret
DISCORD_CLIENT_ID: z.string().optional(), // Discord OAuth client ID
DISCORD_CLIENT_SECRET: z.string().optional(), // Discord OAuth client secret
MICROSOFT_CLIENT_ID: z.string().optional(), // Microsoft OAuth client ID for Office 365/Teams
MICROSOFT_CLIENT_SECRET: z.string().optional(), // Microsoft OAuth client secret
HUBSPOT_CLIENT_ID: z.string().optional(), // HubSpot OAuth client ID
HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
},
client: {
// Core Application URLs - Required for frontend functionality
NEXT_PUBLIC_APP_URL: z.string().url(), // Base URL of the application (e.g., https://app.sim.ai)
NEXT_PUBLIC_VERCEL_URL: z.string().optional(), // Vercel deployment URL for preview/production
NEXT_PUBLIC_APP_URL: z.string().url(), // Base URL of the application (e.g., https://app.sim.ai)
NEXT_PUBLIC_VERCEL_URL: z.string().optional(), // Vercel deployment URL for preview/production
// Client-side Services
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(), // Sentry DSN for client-side error tracking
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(), // Sentry DSN for client-side error tracking
NEXT_PUBLIC_SOCKET_URL: z.string().url().optional(), // WebSocket server URL for real-time features
// Asset Storage
NEXT_PUBLIC_BLOB_BASE_URL: z.string().url().optional(), // Base URL for Vercel Blob storage (CDN assets)
NEXT_PUBLIC_BLOB_BASE_URL: z.string().url().optional(), // Base URL for Vercel Blob storage (CDN assets)
// Google Services - For client-side Google integrations
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for browser auth
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for browser auth
// Analytics & Tracking
NEXT_PUBLIC_RB2B_KEY: z.string().optional(), // RB2B tracking key for B2B analytics
NEXT_PUBLIC_GOOGLE_API_KEY: z.string().optional(), // Google API key for client-side API calls
NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER: z.string().optional(), // Google project number for Drive picker
NEXT_PUBLIC_RB2B_KEY: z.string().optional(), // RB2B tracking key for B2B analytics
NEXT_PUBLIC_GOOGLE_API_KEY: z.string().optional(), // Google API key for client-side API calls
NEXT_PUBLIC_GOOGLE_PROJECT_NUMBER: z.string().optional(), // Google project number for Drive picker
// UI Branding & Whitelabeling
NEXT_PUBLIC_BRAND_NAME: z.string().optional(), // Custom brand name (defaults to "Sim")
NEXT_PUBLIC_BRAND_LOGO_URL: z.string().url().optional(), // Custom logo URL
NEXT_PUBLIC_BRAND_FAVICON_URL: z.string().url().optional(), // Custom favicon URL
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().optional(), // Primary brand color (hex)
NEXT_PUBLIC_BRAND_SECONDARY_COLOR: z.string().optional(), // Secondary brand color (hex)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().optional(), // Accent brand color (hex)
NEXT_PUBLIC_CUSTOM_CSS_URL: z.string().url().optional(), // Custom CSS stylesheet URL
NEXT_PUBLIC_HIDE_BRANDING: z.string().optional(), // Hide "Powered by" branding
NEXT_PUBLIC_CUSTOM_FOOTER_TEXT: z.string().optional(), // Custom footer text
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
NEXT_PUBLIC_SUPPORT_URL: z.string().url().optional(), // Custom support URL
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
NEXT_PUBLIC_BRAND_NAME: z.string().optional(), // Custom brand name (defaults to "Sim")
NEXT_PUBLIC_BRAND_LOGO_URL: z.string().url().optional(), // Custom logo URL
NEXT_PUBLIC_BRAND_FAVICON_URL: z.string().url().optional(), // Custom favicon URL
NEXT_PUBLIC_BRAND_PRIMARY_COLOR: z.string().optional(), // Primary brand color (hex)
NEXT_PUBLIC_BRAND_SECONDARY_COLOR: z.string().optional(), // Secondary brand color (hex)
NEXT_PUBLIC_BRAND_ACCENT_COLOR: z.string().optional(), // Accent brand color (hex)
NEXT_PUBLIC_CUSTOM_CSS_URL: z.string().url().optional(), // Custom CSS stylesheet URL
NEXT_PUBLIC_HIDE_BRANDING: z.string().optional(), // Hide "Powered by" branding
NEXT_PUBLIC_CUSTOM_FOOTER_TEXT: z.string().optional(), // Custom footer text
NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email
NEXT_PUBLIC_SUPPORT_URL: z.string().url().optional(), // Custom support URL
NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL
NEXT_PUBLIC_TERMS_URL: z.string().url().optional(), // Custom terms of service URL
NEXT_PUBLIC_PRIVACY_URL: z.string().url().optional(), // Custom privacy policy URL
},
// Variables available on both server and client

View File

@@ -1,7 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import { env } from './env'
import { env, isTruthy } from './env'
/**
* Is the application running in production mode
@@ -23,6 +23,11 @@ export const isTest = env.NODE_ENV === 'test'
*/
export const isHosted = env.NEXT_PUBLIC_APP_URL === 'https://www.sim.ai'
/**
* Is billing enforcement enabled
*/
export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
/**
* Get cost multiplier based on environment
*/

View File

@@ -52,6 +52,10 @@ export default {
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
gradient: {
primary: 'hsl(var(--gradient-primary))',
secondary: 'hsl(var(--gradient-secondary))',
},
},
fontWeight: {
medium: '460',

View File

@@ -50,6 +50,7 @@ vi.mock('@/blocks/registry', () => ({
})),
getAllBlocks: vi.fn(() => ({})),
}))
const originalConsoleError = console.error
const originalConsoleWarn = console.warn