improvement(subscriptions): added log retention cron, enterprise plan & refactored subscriptions logic (#357)

* added cron to cleanup logs for free users

* added log retention cron, admin dashboard, & enterprise plan. refactored subscriptions logic & ui

* added more type safety & added total chat executions to user stats table

* removed waitlist + admin

* added tests, fixed edge cases

* removed migrations

* acknowledged PR comments

* added heplers, removed duplicate logic

* removed extraneous comments
This commit is contained in:
Waleed Latif
2025-05-14 18:25:48 -07:00
committed by GitHub
parent 38b169cc31
commit 43cb4cbf15
44 changed files with 3743 additions and 2995 deletions

View File

@@ -1,28 +0,0 @@
import PasswordAuth from './password-auth'
export default function AdminPage() {
return (
<PasswordAuth>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-6">
<div className="mb-6 px-1">
<h1 className="text-2xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage Sim Studio platform settings and users.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href="/admin/waitlist"
className="border border-gray-200 dark:border-gray-800 rounded-md p-4 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
>
<h2 className="text-lg font-medium">Waitlist Management</h2>
<p className="text-sm text-muted-foreground mt-1">
Review and manage users on the waitlist
</p>
</a>
</div>
</div>
</PasswordAuth>
)
}

View File

@@ -1,99 +0,0 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import { LockIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
// The admin password for client components should use NEXT_PUBLIC prefix for accessibility
// In production, setup appropriate env vars and secure access patterns
const ADMIN_PASSWORD = process.env.NEXT_PUBLIC_ADMIN_PASSWORD || ''
export default function PasswordAuth({ children }: { children: React.ReactNode }) {
const [password, setPassword] = useState('')
const [isAuthorized, setIsAuthorized] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Check if already authorized in session storage - client-side only
useEffect(() => {
try {
const auth = sessionStorage.getItem('admin-auth')
if (auth === 'true') {
setIsAuthorized(true)
}
} catch (error) {
console.error('Error accessing sessionStorage:', error)
} finally {
setIsLoading(false)
}
}, [])
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (password === ADMIN_PASSWORD) {
setIsAuthorized(true)
try {
sessionStorage.setItem('admin-auth', 'true')
sessionStorage.setItem('admin-auth-token', ADMIN_PASSWORD)
} catch (error) {
console.error('Error setting sessionStorage:', error)
}
setError(null)
} else {
setError('Incorrect password')
}
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">Checking authentication...</p>
</div>
)
}
if (isAuthorized) {
return <>{children}</>
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="max-w-md w-full shadow-lg">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<LockIcon className="h-6 w-6 text-primary" />
</div>
</div>
<CardTitle className="text-2xl text-center">Admin Access</CardTitle>
<CardDescription className="text-center">
Enter your admin password to continue
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter admin password"
autoComplete="off"
className="w-full"
autoFocus
/>
{error && <p className="text-destructive text-sm">{error}</p>}
</div>
<Button type="submit" className="w-full">
Access Admin
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,70 +0,0 @@
import { CheckSquareIcon, SquareIcon, UserCheckIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface BatchActionsProps {
hasSelectedEmails: boolean
selectedCount: number
loading: boolean
onToggleSelectAll: () => void
onClearSelections: () => void
onBatchApprove: () => void
entriesExist: boolean
someSelected: boolean
}
export function BatchActions({
hasSelectedEmails,
selectedCount,
loading,
onToggleSelectAll,
onClearSelections,
onBatchApprove,
entriesExist,
someSelected,
}: BatchActionsProps) {
if (!entriesExist) return null
return (
<div className="flex flex-wrap items-center gap-2 mb-2">
<Button
size="sm"
variant={hasSelectedEmails ? 'default' : 'outline'}
onClick={onToggleSelectAll}
disabled={loading || !entriesExist}
className="whitespace-nowrap h-8 px-2.5 text-xs"
>
{someSelected ? (
<CheckSquareIcon className="h-3.5 w-3.5 mr-1.5" />
) : (
<SquareIcon className="h-3.5 w-3.5 mr-1.5" />
)}
{someSelected ? 'Deselect All' : 'Select All'}
</Button>
{hasSelectedEmails && (
<>
<Button
size="sm"
variant="outline"
onClick={onClearSelections}
className="whitespace-nowrap h-8 px-2.5 text-xs"
>
<XIcon className="h-3.5 w-3.5 mr-1.5" />
Clear Selection
</Button>
<Button
size="sm"
variant="default"
onClick={onBatchApprove}
disabled={!hasSelectedEmails || loading}
className="whitespace-nowrap h-8 px-2.5 text-xs"
>
<UserCheckIcon className="h-3.5 w-3.5 mr-1.5" />
{loading ? 'Processing...' : `Approve Selected (${selectedCount})`}
</Button>
</>
)}
</div>
)
}

View File

@@ -1,79 +0,0 @@
import { CheckIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
type BatchResult = {
email: string
success: boolean
message: string
}
interface BatchResultsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
results: Array<BatchResult> | null
onClose: () => void
}
export function BatchResultsModal({
open,
onOpenChange,
results,
onClose,
}: BatchResultsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Batch Approval Results</DialogTitle>
<DialogDescription>Results of the batch approval operation.</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{results && results.length > 0 ? (
<div className="space-y-2 pt-2">
<div className="flex justify-between mb-2">
<span>Total: {results.length}</span>
<span>
Success: {results.filter((r) => r.success).length} / Failed:{' '}
{results.filter((r) => !r.success).length}
</span>
</div>
{results.map((result, idx) => (
<div
key={idx}
className={`p-2 rounded text-sm ${
result.success
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-red-50 border border-red-200 text-red-800'
}`}
>
<div className="flex items-center gap-2">
{result.success ? (
<CheckIcon className="h-4 w-4" />
) : (
<XIcon className="h-4 w-4" />
)}
<span className="font-medium">{result.email}</span>
</div>
<div className="ml-6 text-xs mt-1">{result.message}</div>
</div>
))}
</div>
) : (
<div className="py-4 text-center text-gray-500">No results to display</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,24 +0,0 @@
import { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
interface FilterButtonProps {
active: boolean
onClick: () => void
icon: ReactNode
label: string
className?: string
}
export function FilterButton({ active, onClick, icon, label, className }: FilterButtonProps) {
return (
<Button
variant={active ? 'default' : 'ghost'}
size="sm"
onClick={onClick}
className={`flex items-center gap-1.5 h-9 px-3 ${className || ''}`}
>
{icon}
<span>{label}</span>
</Button>
)
}

View File

@@ -1,69 +0,0 @@
import { CheckIcon, UserCheckIcon, UserIcon, UserXIcon } from 'lucide-react'
import { FilterButton } from './components/filter-button'
interface FilterBarProps {
currentStatus: string
onStatusChange: (status: string) => void
}
export function FilterBar({ currentStatus, onStatusChange }: FilterBarProps) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<FilterButton
active={currentStatus === 'all'}
onClick={() => onStatusChange('all')}
icon={<UserIcon className="h-3.5 w-3.5" />}
label="All"
className={
currentStatus === 'all'
? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900'
: ''
}
/>
<FilterButton
active={currentStatus === 'pending'}
onClick={() => onStatusChange('pending')}
icon={<UserIcon className="h-3.5 w-3.5" />}
label="Pending"
className={
currentStatus === 'pending'
? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900'
: ''
}
/>
<FilterButton
active={currentStatus === 'approved'}
onClick={() => onStatusChange('approved')}
icon={<UserCheckIcon className="h-3.5 w-3.5" />}
label="Approved"
className={
currentStatus === 'approved'
? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900'
: ''
}
/>
<FilterButton
active={currentStatus === 'rejected'}
onClick={() => onStatusChange('rejected')}
icon={<UserXIcon className="h-3.5 w-3.5" />}
label="Rejected"
className={
currentStatus === 'rejected'
? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900'
: ''
}
/>
<FilterButton
active={currentStatus === 'signed_up'}
onClick={() => onStatusChange('signed_up')}
icon={<CheckIcon className="h-3.5 w-3.5" />}
label="Signed Up"
className={
currentStatus === 'signed_up'
? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900'
: ''
}
/>
</div>
)
}

View File

@@ -1,87 +0,0 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
interface PaginationProps {
page: number
totalItems: number
itemsPerPage: number
loading: boolean
onFirstPage: () => void
onPrevPage: () => void
onNextPage: () => void
onLastPage: () => void
}
export function Pagination({
page,
totalItems,
itemsPerPage,
loading,
onFirstPage,
onPrevPage,
onNextPage,
onLastPage,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
return (
<div className="flex items-center justify-center gap-1.5 my-3 pb-1">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={onFirstPage}
disabled={page === 1 || loading}
title="First Page"
className="h-8 w-8 p-0"
>
<ChevronsLeftIcon className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
onClick={onPrevPage}
disabled={page === 1 || loading}
className="h-8 px-2 text-xs"
>
<ChevronLeftIcon className="h-3.5 w-3.5 mr-1" />
Prev
</Button>
</div>
<span className="text-xs text-muted-foreground mx-2">
Page {page} of {totalPages}
&nbsp;&nbsp;
{totalItems} total entries
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={onNextPage}
disabled={page >= totalPages || loading}
className="h-8 px-2 text-xs"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5 ml-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={onLastPage}
disabled={page >= totalPages || loading}
title="Last Page"
className="h-8 w-8 p-0"
>
<ChevronsRightIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
}

View File

@@ -1,49 +0,0 @@
import { useRef, useState } from 'react'
import { SearchIcon } from 'lucide-react'
import { Input } from '@/components/ui/input'
interface SearchBarProps {
initialValue: string
onSearch: (value: string) => void
disabled?: boolean
placeholder?: string
}
export function SearchBar({
initialValue = '',
onSearch,
disabled = false,
placeholder = 'Search by email...',
}: SearchBarProps) {
const [searchInputValue, setSearchInputValue] = useState(initialValue)
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setSearchInputValue(value)
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set a new timeout for debounce
searchTimeoutRef.current = setTimeout(() => {
onSearch(value)
}, 500) // 500ms debounce
}
return (
<div className="relative flex-grow sm:flex-grow-0 w-full sm:w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={placeholder}
value={searchInputValue}
onChange={handleSearchChange}
className="pl-8 h-9 text-sm"
disabled={disabled}
/>
</div>
)
}

View File

@@ -1,45 +0,0 @@
import { AlertCircleIcon } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
interface WaitlistAlertProps {
type: AlertType
message: string
onDismiss: () => void
onRefresh?: () => void
}
export function WaitlistAlert({ type, message, onDismiss, onRefresh }: WaitlistAlertProps) {
if (!type) return null
return (
<Alert
variant={type === 'error' || type === 'email-error' ? 'destructive' : 'default'}
className="mb-4"
>
<AlertCircleIcon className="h-4 w-4" />
<AlertTitle className="ml-2">
{type === 'email-error'
? 'Email Delivery Failed'
: type === 'rate-limit'
? 'Rate Limit Exceeded'
: 'Error'}
</AlertTitle>
<AlertDescription className="ml-2 flex items-center justify-between">
<span>{message}</span>
<div className="flex gap-2">
{onRefresh && (
<Button onClick={onRefresh} variant="outline" size="sm" className="ml-4">
Try Again
</Button>
)}
<Button onClick={onDismiss} variant="outline" size="sm" className="ml-4">
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
)
}

View File

@@ -1,222 +0,0 @@
import {
CheckIcon,
CheckSquareIcon,
InfoIcon,
MailIcon,
RotateCcwIcon,
SquareIcon,
UserCheckIcon,
UserXIcon,
XIcon,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface WaitlistEntry {
id: string
email: string
status: string
createdAt: Date
}
interface WaitlistTableProps {
entries: WaitlistEntry[]
status: string
actionLoading: string | null
selectedEmails: Record<string, boolean>
onToggleSelection: (email: string) => void
onApprove: (email: string, id: string) => void
onReject: (email: string, id: string) => void
onResendApproval: (email: string, id: string) => void
formatDate: (date: Date) => string
getDetailedTimeTooltip: (date: Date) => string
}
export function WaitlistTable({
entries,
status,
actionLoading,
selectedEmails,
onToggleSelection,
onApprove,
onReject,
onResendApproval,
formatDate,
getDetailedTimeTooltip,
}: WaitlistTableProps) {
return (
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
{/* Add selection checkbox column */}
{status !== 'approved' && (
<TableHead className="w-[60px] text-center">Select</TableHead>
)}
<TableHead className="w-[250px]">Email</TableHead>
<TableHead className="w-[120px]">Status</TableHead>
<TableHead className="w-[120px]">Date Added</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id} className="hover:bg-muted/30">
{/* Add selection checkbox */}
{status !== 'approved' && (
<TableCell className="text-center py-2">
<Button
size="sm"
variant="ghost"
onClick={() => onToggleSelection(entry.email)}
className="p-0 h-8 w-8"
>
{selectedEmails[entry.email] ? (
<CheckSquareIcon className="h-5 w-5" />
) : (
<SquareIcon className="h-5 w-5" />
)}
</Button>
</TableCell>
)}
<TableCell className="font-medium">{entry.email}</TableCell>
<TableCell>
{/* Status badge */}
<Badge
variant="outline"
className={`
${entry.status === 'pending' ? 'bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200' : ''}
${entry.status === 'approved' ? 'bg-green-100 text-green-800 border border-green-200 hover:bg-green-200' : ''}
${entry.status === 'rejected' ? 'bg-red-100 text-red-800 border border-red-200 hover:bg-red-200' : ''}
${entry.status === 'signed_up' ? 'bg-purple-100 text-purple-800 border border-purple-200 hover:bg-purple-200' : ''}
`}
>
{entry.status === 'pending' && <InfoIcon className="mr-1 h-3 w-3" />}
{entry.status === 'approved' && <UserCheckIcon className="mr-1 h-3 w-3" />}
{entry.status === 'rejected' && <UserXIcon className="mr-1 h-3 w-3" />}
{entry.status === 'signed_up' && <CheckIcon className="mr-1 h-3 w-3" />}
{entry.status.charAt(0).toUpperCase() + entry.status.slice(1).replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{formatDate(entry.createdAt)}</span>
</TooltipTrigger>
<TooltipContent>{getDetailedTimeTooltip(entry.createdAt)}</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-1.5">
{entry.status !== 'approved' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => onApprove(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-green-500 hover:text-green-600 h-8 w-8"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
) : (
<CheckIcon className="h-3.5 w-3.5 text-green-500" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Approve user and send access email</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{entry.status === 'approved' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => onResendApproval(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-blue-500 hover:text-blue-600 h-8 text-xs px-2"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<MailIcon className="h-3.5 w-3.5 mr-1" />
Resend
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>Resend approval email with sign-up link</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{entry.status !== 'rejected' && entry.status !== 'approved' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => onReject(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-red-500 hover:text-red-600 h-8 w-8"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-3.5 w-3.5 animate-spin" />
) : (
<XIcon className="h-3.5 w-3.5 text-red-500" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Reject user</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() =>
window.open(
`https://mail.google.com/mail/?view=cm&fs=1&to=${entry.email}`
)
}
className="hover:border-blue-500 hover:text-blue-600 h-8 w-8"
>
<MailIcon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Email user in Gmail</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -1,27 +0,0 @@
import { Metadata } from 'next'
import PasswordAuth from '../password-auth'
import { WaitlistTable } from './waitlist'
export const metadata: Metadata = {
title: 'Waitlist Management | Sim Studio',
description: 'Manage the waitlist for Sim Studio',
}
export default function WaitlistPage() {
return (
<PasswordAuth>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-6">
<div className="mb-6 px-1">
<h1 className="text-2xl font-bold tracking-tight">Waitlist Management</h1>
<p className="text-muted-foreground mt-1 text-sm">
Review and manage users who have signed up for the waitlist.
</p>
</div>
<div className="w-full border border-gray-200 dark:border-gray-800 shadow-sm bg-white dark:bg-gray-950 rounded-md overflow-hidden">
<WaitlistTable />
</div>
</div>
</PasswordAuth>
)
}

View File

@@ -1,165 +0,0 @@
import { create } from 'zustand'
// Define types inline since types.ts was deleted
export type WaitlistStatus = 'pending' | 'approved' | 'rejected' | 'signed_up'
export interface WaitlistEntry {
id: string
email: string
status: WaitlistStatus
createdAt: Date
updatedAt: Date
}
interface WaitlistState {
// Core data
entries: WaitlistEntry[]
filteredEntries: WaitlistEntry[]
loading: boolean
error: string | null
// Filters
status: string
searchTerm: string
// Pagination
page: number
totalEntries: number
// Loading states
actionLoading: string | null
// Actions
setStatus: (status: string) => void
setSearchTerm: (searchTerm: string) => void
setPage: (page: number) => void
fetchEntries: () => Promise<void>
setEntries: (entries: WaitlistEntry[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setActionLoading: (id: string | null) => void
}
export const useWaitlistStore = create<WaitlistState>((set, get) => ({
// Core data
entries: [],
filteredEntries: [],
loading: true,
error: null,
// Filters
status: 'all',
searchTerm: '',
// Pagination
page: 1,
totalEntries: 0,
// Loading states
actionLoading: null,
// Filter actions
setStatus: (status) => {
console.log('Store: Setting status to', status)
set({
status,
page: 1,
searchTerm: '',
loading: true,
})
get().fetchEntries()
},
setSearchTerm: (searchTerm) => {
set({ searchTerm, page: 1, loading: true })
get().fetchEntries()
},
setPage: (page) => {
set({ page, loading: true })
get().fetchEntries()
},
// Data actions
setEntries: (entries) => {
set({
entries,
filteredEntries: entries,
loading: false,
error: null,
})
},
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setActionLoading: (id) => set({ actionLoading: id }),
// Fetch data
fetchEntries: async () => {
const { status, page, searchTerm } = get()
try {
set({ loading: true, error: null })
// Prevent caching with timestamp
const timestamp = Date.now()
const searchParam = searchTerm ? `&search=${encodeURIComponent(searchTerm)}` : ''
const url = `/api/admin/waitlist?page=${page}&limit=50&status=${status}&t=${timestamp}${searchParam}`
// Get the auth token
const token = sessionStorage.getItem('admin-auth-token') || ''
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
'Cache-Control': 'no-cache, must-revalidate',
},
cache: 'no-store',
})
if (!response.ok) {
let errorMessage = `Error ${response.status}: ${response.statusText}`
// Try to parse the error message from the response body
try {
const errorData = await response.json()
if (errorData.message) {
errorMessage = errorData.message
}
} catch (parseError) {
console.error('Failed to parse error response:', parseError)
}
throw new Error(errorMessage)
}
const data = await response.json()
if (!data.success) {
throw new Error(data.message || 'Failed to load waitlist entries')
}
// Process entries
const entries = data.data.entries.map((entry: any) => ({
...entry,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
}))
// Update state
set({
entries,
filteredEntries: entries,
totalEntries: data.data.total,
loading: false,
error: null,
})
} catch (error) {
console.error('Error fetching waitlist entries:', error)
set({
error: error instanceof Error ? error.message : 'An unknown error occurred',
loading: false,
})
}
},
}))

View File

@@ -1,618 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { AlertCircleIcon, InfoIcon, RotateCcwIcon } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Logger } from '@/lib/logs/console-logger'
import { BatchActions } from './components/batch-actions/batch-actions'
import { BatchResultsModal } from './components/batch-results-modal/batch-results-modal'
import { FilterBar } from './components/filter-bar/filter-bar'
import { Pagination } from './components/pagination/pagination'
import { SearchBar } from './components/search-bar/search-bar'
import { WaitlistAlert } from './components/waitlist-alert/waitlist-alert'
import { WaitlistTable as WaitlistDataTable } from './components/waitlist-table/waitlist-table'
import { useWaitlistStore } from './stores/store'
const logger = new Logger('WaitlistTable')
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
export function WaitlistTable() {
const router = useRouter()
const searchParams = useSearchParams()
const {
entries,
filteredEntries,
status,
searchTerm,
page,
totalEntries,
loading,
error,
actionLoading,
setStatus,
setSearchTerm,
setPage,
setActionLoading,
setError,
fetchEntries,
} = useWaitlistStore()
// Enhanced error state
const [alertInfo, setAlertInfo] = useState<{
type: AlertType
message: string
entryId?: string
}>({ type: null, message: '' })
// Auto-dismiss alert after 7 seconds
useEffect(() => {
if (alertInfo.type) {
const timer = setTimeout(() => {
setAlertInfo({ type: null, message: '' })
}, 7000)
return () => clearTimeout(timer)
}
}, [alertInfo])
// Auth token for API calls
const [apiToken, setApiToken] = useState('')
const [authChecked, setAuthChecked] = useState(false)
// Check authentication and redirect if needed
useEffect(() => {
// Check if user is authenticated
const token = sessionStorage.getItem('admin-auth-token') || ''
const isAuth = sessionStorage.getItem('admin-auth') === 'true'
setApiToken(token)
// If not authenticated, redirect to admin home page to show the login form
if (!isAuth || !token) {
logger.warn('Not authenticated, redirecting to admin page')
router.push('/admin')
return
}
setAuthChecked(true)
}, [router])
// Get status from URL on initial load - only if authenticated
useEffect(() => {
if (!authChecked) return
const urlStatus = searchParams.get('status') || 'all'
// Make sure it's a valid status
const validStatus = ['all', 'pending', 'approved', 'rejected'].includes(urlStatus)
? urlStatus
: 'all'
setStatus(validStatus)
}, [searchParams, setStatus, authChecked])
// Handle status filter change
const handleStatusChange = useCallback(
(newStatus: string) => {
if (newStatus !== status) {
setStatus(newStatus)
router.push(`?status=${newStatus}`)
}
},
[status, setStatus, router]
)
// Handle individual approval
const handleApprove = async (email: string, id: string) => {
try {
setActionLoading(id)
setError(null)
setAlertInfo({ type: null, message: '' })
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({ email, action: 'approve' }),
})
const data = await response.json()
if (!response.ok) {
// Handle specific error types
if (response.status === 429) {
setAlertInfo({
type: 'rate-limit',
message: 'Rate limit exceeded. Please try again later.',
entryId: id,
})
return
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
setAlertInfo({
type: 'email-error',
message: `Email delivery failed: ${data.message}`,
entryId: id,
})
return
} else {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to approve user',
entryId: id,
})
return
}
}
if (!data.success) {
if (data.message?.includes('email') || data.message?.includes('resend')) {
setAlertInfo({
type: 'email-error',
message: `Email delivery failed: ${data.message}`,
entryId: id,
})
return
} else {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to approve user',
entryId: id,
})
return
}
}
// Success - don't refresh the table, just clear any errors
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to approve user'
setAlertInfo({
type: 'error',
message: errorMessage,
entryId: id,
})
logger.error('Error approving user:', error)
} finally {
setActionLoading(null)
}
}
// Handle individual rejection
const handleReject = async (email: string, id: string) => {
try {
setActionLoading(id)
setError(null)
setAlertInfo({ type: null, message: '' })
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({ email, action: 'reject' }),
})
const data = await response.json()
if (!response.ok || !data.success) {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to reject user',
entryId: id,
})
return
}
// Success - don't refresh the table
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to reject user'
setAlertInfo({
type: 'error',
message: errorMessage,
entryId: id,
})
logger.error('Error rejecting user:', error)
} finally {
setActionLoading(null)
}
}
// Handle resending approval email
const handleResendApproval = async (email: string, id: string) => {
try {
setActionLoading(id)
setError(null)
setAlertInfo({ type: null, message: '' })
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({ email, action: 'resend' }),
})
const data = await response.json()
if (!response.ok) {
// Handle specific error types
if (response.status === 429) {
setAlertInfo({
type: 'rate-limit',
message: 'Rate limit exceeded. Please try again later.',
entryId: id,
})
return
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
setAlertInfo({
type: 'email-error',
message: `Email delivery failed: ${data.message}`,
entryId: id,
})
return
} else {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to resend approval email',
entryId: id,
})
return
}
}
if (!data.success) {
if (data.message?.includes('email') || data.message?.includes('resend')) {
setAlertInfo({
type: 'email-error',
message: `Email delivery failed: ${data.message}`,
entryId: id,
})
return
} else {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to resend approval email',
entryId: id,
})
return
}
}
// No UI update needed on success, just clear error state
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to resend approval email'
setAlertInfo({
type: 'email-error',
message: errorMessage,
entryId: id,
})
logger.error('Error resending approval email:', error)
} finally {
setActionLoading(null)
}
}
// Navigation
const handleNextPage = () => setPage(page + 1)
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
const handleFirstPage = () => setPage(1)
const handleLastPage = () => {
const lastPage = Math.max(1, Math.ceil(totalEntries / 50))
setPage(lastPage)
}
const handleRefresh = () => {
fetchEntries()
setAlertInfo({ type: null, message: '' })
}
// Format date helper
const formatDate = (date: Date) => {
const now = new Date()
const diffInMs = now.getTime() - date.getTime()
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24))
if (diffInDays < 1) return 'today'
if (diffInDays === 1) return 'yesterday'
if (diffInDays < 30) return `${diffInDays} days ago`
return date.toLocaleDateString()
}
// Get formatted timestamp for tooltips
const getDetailedTimeTooltip = (date: Date) => {
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// State for selected emails (for batch operations)
const [selectedEmails, setSelectedEmails] = useState<Record<string, boolean>>({})
const [showBatchDialog, setShowBatchDialog] = useState(false)
const [batchActionLoading, setBatchActionLoading] = useState(false)
const [batchResults, setBatchResults] = useState<Array<{
email: string
success: boolean
message: string
}> | null>(null)
// Helper to check if any emails are selected
const hasSelectedEmails = Object.values(selectedEmails).some(Boolean)
// Count of selected emails
const selectedEmailsCount = Object.values(selectedEmails).filter(Boolean).length
// Toggle selection of a single email
const toggleEmailSelection = (email: string) => {
setSelectedEmails((prev) => ({
...prev,
[email]: !prev[email],
}))
}
// Clear all selections
const clearSelections = () => {
setSelectedEmails({})
}
// Select/deselect all visible emails
const toggleSelectAll = () => {
if (filteredEntries.some((entry) => selectedEmails[entry.email])) {
// If any are selected, deselect all
const newSelection = { ...selectedEmails }
filteredEntries.forEach((entry) => {
newSelection[entry.email] = false
})
setSelectedEmails(newSelection)
} else {
// Select all visible entries
const newSelection = { ...selectedEmails }
filteredEntries.forEach((entry) => {
newSelection[entry.email] = true
})
setSelectedEmails(newSelection)
}
}
// Handle batch approval
const handleBatchApprove = async () => {
try {
setBatchActionLoading(true)
setBatchResults(null)
setAlertInfo({ type: null, message: '' })
// Get list of selected emails
const emails = Object.entries(selectedEmails)
.filter(([_, isSelected]) => isSelected)
.map(([email]) => email)
if (emails.length === 0) {
setAlertInfo({
type: 'error',
message: 'No emails selected for batch approval',
})
return
}
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({ emails, action: 'batchApprove' }),
})
const data = await response.json()
if (!response.ok) {
// Handle specific error types
if (response.status === 429) {
setAlertInfo({
type: 'rate-limit',
message: 'Rate limit exceeded. Please try again later with fewer emails.',
})
setBatchResults(data.results || null)
return
} else if (data.message?.includes('email') || data.message?.includes('resend')) {
setAlertInfo({
type: 'email-error',
message: `Email delivery failed: ${data.message}`,
})
setBatchResults(data.results || null)
return
} else {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to approve users',
})
setBatchResults(data.results || null)
return
}
}
if (!data.success) {
setAlertInfo({
type: 'error',
message: data.message || 'Failed to approve some or all users',
})
setBatchResults(data.results || null)
return
}
// Success
setShowBatchDialog(true)
setBatchResults(data.results || [])
// Clear selections for successfully approved emails
if (data.results && Array.isArray(data.results)) {
const successfulEmails = data.results
.filter((result: { success: boolean }) => result.success)
.map((result: { email: string }) => result.email)
if (successfulEmails.length > 0) {
const newSelection = { ...selectedEmails }
successfulEmails.forEach((email: string) => {
newSelection[email] = false
})
setSelectedEmails(newSelection)
// Refresh the entries to show updated statuses
fetchEntries()
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to approve users'
setAlertInfo({
type: 'error',
message: errorMessage,
})
logger.error('Error batch approving users:', error)
} finally {
setBatchActionLoading(false)
}
}
// If not authenticated yet, show loading state
if (!authChecked) {
return (
<div className="flex justify-center items-center py-20">
<Skeleton className="h-16 w-16 rounded-full" />
</div>
)
}
return (
<div className="space-y-3 w-full p-4">
{/* Top bar with filters, search and refresh */}
<div className="flex flex-col sm:flex-row justify-between items-start gap-3 mb-2">
{/* Filter buttons in a single row */}
<FilterBar currentStatus={status} onStatusChange={handleStatusChange} />
{/* Search and refresh aligned to the right */}
<div className="flex items-center gap-2 w-full sm:w-auto">
<SearchBar initialValue={searchTerm} onSearch={setSearchTerm} disabled={loading} />
<Button
size="sm"
variant="outline"
onClick={handleRefresh}
disabled={loading}
className="flex-shrink-0 h-9 w-9 p-0"
>
<RotateCcwIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Enhanced Alert system */}
<WaitlistAlert
type={alertInfo.type}
message={alertInfo.message}
onDismiss={() => setAlertInfo({ type: null, message: '' })}
onRefresh={alertInfo.type === 'error' ? handleRefresh : undefined}
/>
{/* Original error alert - kept for backward compatibility */}
{error && !alertInfo.type && (
<Alert variant="destructive" className="mb-4">
<AlertCircleIcon className="h-4 w-4" />
<AlertDescription className="ml-2">
{error}
<Button onClick={handleRefresh} variant="outline" size="sm" className="ml-4">
Try Again
</Button>
</AlertDescription>
</Alert>
)}
{/* Select All row - only shown when not in approved view and entries exist */}
{status !== 'approved' && filteredEntries.length > 0 && !loading && (
<BatchActions
hasSelectedEmails={hasSelectedEmails}
selectedCount={selectedEmailsCount}
loading={batchActionLoading}
onToggleSelectAll={toggleSelectAll}
onClearSelections={clearSelections}
onBatchApprove={handleBatchApprove}
entriesExist={filteredEntries.length > 0}
someSelected={filteredEntries.some((entry) => selectedEmails[entry.email])}
/>
)}
{/* Loading skeleton */}
{loading ? (
<div className="space-y-4">
<div className="space-y-2 w-full">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
) : filteredEntries.length === 0 ? (
<div className="rounded-md border p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<InfoIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-semibold">No entries found</h3>
<p className="mt-2 text-sm text-muted-foreground">
{searchTerm
? 'No matching entries found with the current search term'
: `No ${status === 'all' ? '' : status} entries found in the waitlist.`}
</p>
</div>
) : (
<>
{/* Table */}
<WaitlistDataTable
entries={filteredEntries}
status={status}
actionLoading={actionLoading}
selectedEmails={selectedEmails}
onToggleSelection={toggleEmailSelection}
onApprove={handleApprove}
onReject={handleReject}
onResendApproval={handleResendApproval}
formatDate={formatDate}
getDetailedTimeTooltip={getDetailedTimeTooltip}
/>
{/* Pagination */}
{!searchTerm && (
<Pagination
page={page}
totalItems={totalEntries}
itemsPerPage={50}
loading={loading}
onFirstPage={handleFirstPage}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
onLastPage={handleLastPage}
/>
)}
</>
)}
{/* Batch results dialog */}
<BatchResultsModal
open={showBatchDialog}
onOpenChange={setShowBatchDialog}
results={batchResults}
onClose={() => {
setShowBatchDialog(false)
setBatchResults(null)
}}
/>
</div>
)
}

View File

@@ -1,491 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { Logger } from '@/lib/logs/console-logger'
import {
approveBatchWaitlistUsers,
approveWaitlistUser,
getWaitlistEntries,
rejectWaitlistUser,
resendApprovalEmail,
} from '@/lib/waitlist/service'
const logger = new Logger('WaitlistAPI')
// Schema for GET request query parameters
const getQuerySchema = z.object({
page: z.coerce.number().optional().default(1),
limit: z.coerce.number().optional().default(20),
status: z.enum(['all', 'pending', 'approved', 'rejected', 'signed_up']).optional(),
search: z.string().optional(),
})
// Schema for POST request body
const actionSchema = z.object({
email: z.string().email(),
action: z.enum(['approve', 'reject', 'resend']),
})
// Schema for batch approval request
const batchActionSchema = z.object({
emails: z.array(z.string().email()).min(1).max(100),
action: z.literal('batchApprove'),
})
// Admin password from environment variables
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
// Check if the request has valid admin password
function isAuthorized(request: NextRequest) {
// Get authorization header (Bearer token)
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false
}
// Extract token
const token = authHeader.split(' ')[1]
// Compare with expected token
return token === ADMIN_PASSWORD
}
// Catch and handle Resend API errors
function detectResendRateLimitError(error: any): boolean {
if (!error) return false
// Check for structured error from Resend
if (
error.statusCode === 429 ||
(error.name && error.name === 'rate_limit_exceeded') ||
(error.message && error.message.toLowerCase().includes('rate'))
) {
return true
}
// Check string error message
if (
typeof error === 'string' &&
(error.toLowerCase().includes('rate') ||
error.toLowerCase().includes('too many') ||
error.toLowerCase().includes('limit'))
) {
return true
}
// If the error is an object, check common properties
if (typeof error === 'object') {
const errorStr = JSON.stringify(error).toLowerCase()
return (
errorStr.includes('rate') ||
errorStr.includes('too many') ||
errorStr.includes('limit') ||
errorStr.includes('429')
)
}
return false
}
export async function GET(request: NextRequest) {
try {
// Check authorization
if (!isAuthorized(request)) {
return NextResponse.json({ success: false, message: 'Unauthorized access' }, { status: 401 })
}
// Parse query parameters
const { searchParams } = request.nextUrl
const page = searchParams.get('page') ? Number(searchParams.get('page')) : 1
const limit = searchParams.get('limit') ? Number(searchParams.get('limit')) : 20
const status = searchParams.get('status') || 'all'
const search = searchParams.get('search') || undefined
logger.info(
`API route: Received request with status: "${status}", search: "${search || 'none'}", page: ${page}, limit: ${limit}`
)
// Validate params
const validatedParams = getQuerySchema.safeParse({ page, limit, status, search })
if (!validatedParams.success) {
logger.error('Invalid parameters:', validatedParams.error.format())
return NextResponse.json(
{
success: false,
message: 'Invalid parameters',
errors: validatedParams.error.format(),
},
{ status: 400 }
)
}
// Get waitlist entries with search parameter
const entries = await getWaitlistEntries(
validatedParams.data.page,
validatedParams.data.limit,
validatedParams.data.status,
validatedParams.data.search
)
logger.info(
`API route: Returning ${entries.entries.length} entries for status: "${status}", total: ${entries.total}`
)
// Return response with cache control header to prevent caching
return new NextResponse(JSON.stringify({ success: true, data: entries }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
},
})
} catch (error) {
logger.error('Admin waitlist API error:', error)
return NextResponse.json(
{
success: false,
message: 'An error occurred while processing your request',
},
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
// Check authorization
if (!isAuthorized(request)) {
return NextResponse.json({ success: false, message: 'Unauthorized access' }, { status: 401 })
}
// Parse request body
const body = await request.json()
// Check if it's a batch action
if (body.action === 'batchApprove' && Array.isArray(body.emails)) {
// Validate batch request
const validatedData = batchActionSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid batch request',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
const { emails } = validatedData.data
logger.info(`Processing batch approval for ${emails.length} emails`)
try {
const result = await approveBatchWaitlistUsers(emails)
// Check for rate limiting
if (!result.success && result.rateLimited) {
logger.warn('Rate limit reached for email sending')
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending. Users were NOT approved.',
rateLimited: true,
results: result.results,
},
{ status: 429 }
)
}
// Return the result, even if partially successful
return NextResponse.json({
success: result.success,
message: result.message,
results: result.results,
})
} catch (error) {
logger.error('Error in batch approval:', error)
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Failed to process batch approval',
},
{ status: 500 }
)
}
} else {
// Handle individual actions
// Validate request
const validatedData = actionSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid request',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
const { email, action } = validatedData.data
let result: any
// Perform the requested action
if (action === 'approve') {
try {
// Need to handle email errors specially to prevent approving users when email fails
result = await approveWaitlistUser(email)
// First check for email delivery errors from Resend
if (!result.success && result?.emailError) {
logger.error('Email delivery error:', result.emailError)
// Check if it's a rate limit error
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending. User was NOT approved.',
rateLimited: true,
emailError: true,
},
{ status: 429 }
)
}
return NextResponse.json(
{
success: false,
message: `Email delivery failed: ${result.message || 'Unknown email error'}. User was NOT approved.`,
emailError: true,
},
{ status: 500 }
)
}
// Check for rate limiting
if (!result.success && result?.rateLimited) {
logger.warn('Rate limit reached for email sending')
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending. User was NOT approved.',
rateLimited: true,
},
{ status: 429 }
)
}
// General failure
if (!result.success) {
return NextResponse.json(
{
success: false,
message: result.message || 'Failed to approve user',
},
{ status: 400 }
)
}
} catch (error) {
logger.error('Error approving waitlist user:', error)
// Check if it's the JWT_SECRET missing error
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
return NextResponse.json(
{
success: false,
message:
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
},
{ status: 500 }
)
}
// Handle Resend API errors specifically
if (
error instanceof Error &&
(error.message.includes('email') || error.message.includes('resend'))
) {
// Handle rate limiting specifically
if (detectResendRateLimitError(error)) {
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending. User was NOT approved.',
rateLimited: true,
emailError: true,
},
{ status: 429 }
)
}
return NextResponse.json(
{
success: false,
message: `Email delivery failed: ${error.message}. User was NOT approved.`,
emailError: true,
},
{ status: 500 }
)
}
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Failed to approve user',
},
{ status: 500 }
)
}
} else if (action === 'reject') {
try {
result = await rejectWaitlistUser(email)
} catch (error) {
logger.error('Error rejecting waitlist user:', error)
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Failed to reject user',
},
{ status: 500 }
)
}
} else if (action === 'resend') {
try {
result = await resendApprovalEmail(email)
// First check for email delivery errors from Resend
if (!result.success && result?.emailError) {
logger.error('Email delivery error:', result.emailError)
// Check if it's a rate limit error
if (result.rateLimited || detectResendRateLimitError(result.emailError)) {
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending.',
rateLimited: true,
emailError: true,
},
{ status: 429 }
)
}
return NextResponse.json(
{
success: false,
message: `Email delivery failed: ${result.message || 'Unknown email error'}`,
emailError: true,
},
{ status: 500 }
)
}
// Check for rate limiting
if (!result.success && result?.rateLimited) {
logger.warn('Rate limit reached for email sending')
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
},
{ status: 429 }
)
}
// General failure
if (!result.success) {
return NextResponse.json(
{
success: false,
message: result.message || 'Failed to resend approval email',
},
{ status: 400 }
)
}
} catch (error) {
logger.error('Error resending approval email:', error)
// Check if it's the JWT_SECRET missing error
if (error instanceof Error && error.message.includes('JWT_SECRET')) {
return NextResponse.json(
{
success: false,
message:
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
},
{ status: 500 }
)
}
// Handle Resend API errors specifically
if (
error instanceof Error &&
(error.message.includes('email') || error.message.includes('resend'))
) {
// Handle rate limiting specifically
if (detectResendRateLimitError(error)) {
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
emailError: true,
},
{ status: 429 }
)
}
return NextResponse.json(
{
success: false,
message: `Email delivery failed: ${error.message}`,
emailError: true,
},
{ status: 500 }
)
}
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Failed to resend approval email',
},
{ status: 500 }
)
}
}
if (!result || !result.success) {
return NextResponse.json(
{
success: false,
message: result?.message || 'Failed to perform action',
},
{ status: 400 }
)
}
return NextResponse.json({
success: true,
message: result.message,
})
}
} catch (error) {
logger.error('Admin waitlist API error:', error)
return NextResponse.json(
{
success: false,
message: 'An error occurred while processing your request',
},
{ status: 500 }
)
}
}

View File

@@ -9,14 +9,12 @@ import { chat } from '@/db/schema'
const logger = createLogger('SubdomainValidateAPI')
export async function GET(request: Request) {
// Check if the user is authenticated
const session = await getSession()
if (!session || !session.user) {
return createErrorResponse('Unauthorized', 401)
}
try {
// Get subdomain from query parameters
const { searchParams } = new URL(request.url)
const subdomain = searchParams.get('subdomain')
@@ -24,7 +22,6 @@ export async function GET(request: Request) {
return createErrorResponse('Missing subdomain parameter', 400)
}
// Check if subdomain follows allowed pattern (only lowercase letters, numbers, and hyphens)
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return NextResponse.json(
{
@@ -35,7 +32,6 @@ export async function GET(request: Request) {
)
}
// Protect reserved subdomains
const reservedSubdomains = [
'telemetry',
'docs',
@@ -47,6 +43,7 @@ export async function GET(request: Request) {
'blog',
'help',
'support',
'admin',
]
if (reservedSubdomains.includes(subdomain)) {
return NextResponse.json(
@@ -58,14 +55,12 @@ export async function GET(request: Request) {
)
}
// Query database to see if subdomain already exists
const existingDeployment = await db
.select()
.from(chat)
.where(eq(chat.subdomain, subdomain))
.limit(1)
// Return availability status
return createSuccessResponse({
available: existingDeployment.length === 0,
subdomain,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionLogs } from '@/lib/logs/execution-logger'
@@ -8,7 +8,7 @@ import { decryptSecret } from '@/lib/utils'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { WorkflowState } from '@/stores/workflows/workflow/types'
import { db } from '@/db'
import { chat, environment as envTable, workflow } from '@/db/schema'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import { BlockLog } from '@/executor/types'
import { Serializer } from '@/serializer'
@@ -495,6 +495,38 @@ export async function executeWorkflowForChat(chatId: string, message: string) {
`[${requestId}] Persisted execution logs for streaming chat with ID: ${executionId}`
)
// Update user stats for successful streaming chat execution
if (executionData.success) {
try {
// Find the workflow to get the user ID
const workflowData = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowData.length > 0) {
const userId = workflowData[0].userId
// Update the user stats
await db
.update(userStats)
.set({
totalChatExecutions: sql`total_chat_executions + 1`,
lastActive: new Date(),
})
.where(eq(userStats.userId, userId))
logger.debug(
`[${requestId}] Updated user stats: incremented totalChatExecutions for streaming chat`
)
}
} catch (error) {
// Don't fail if stats update fails
logger.error(`[${requestId}] Failed to update streaming chat execution stats:`, error)
}
}
return { success: true }
} catch (error) {
logger.error(`[${requestId}] Failed to persist streaming chat execution logs:`, error)
@@ -531,6 +563,36 @@ export async function executeWorkflowForChat(chatId: string, message: string) {
}
}
// Update user stats to increment totalChatExecutions if the execution was successful
if (result.success) {
try {
// Find the workflow to get the user ID
const workflowData = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowData.length > 0) {
const userId = workflowData[0].userId
// Update the user stats
await db
.update(userStats)
.set({
totalChatExecutions: sql`total_chat_executions + 1`,
lastActive: new Date(),
})
.where(eq(userStats.userId, userId))
logger.debug(`[${requestId}] Updated user stats: incremented totalChatExecutions`)
}
} catch (error) {
// Don't fail the chat response if stats update fails
logger.error(`[${requestId}] Failed to update chat execution stats:`, error)
}
}
// Persist execution logs using the 'chat' trigger type for non-streaming results
try {
// Build trace spans to enrich the logs (same as in use-workflow-execution.ts)

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server'
import { sql } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { subscription, user, workflowLogs } from '@/db/schema'
const logger = createLogger('LogsCleanup')
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('authorization')
if (!process.env.CRON_SECRET) {
return new NextResponse('Configuration error: Cron secret is not set', { status: 500 })
}
if (!authHeader || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
logger.warn(`Unauthorized access attempt to logs cleanup endpoint`)
return new NextResponse('Unauthorized', { status: 401 })
}
const retentionDate = new Date()
retentionDate.setDate(
retentionDate.getDate() - Number(process.env.FREE_PLAN_LOG_RETENTION_DAYS)
)
const freeUsers = await db
.select({ userId: user.id })
.from(user)
.leftJoin(
subscription,
sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')`
)
.where(sql`${subscription.id} IS NULL`)
if (freeUsers.length === 0) {
logger.info('No free users found for log cleanup')
return NextResponse.json({ message: 'No free users found for cleanup' })
}
const freeUserIds = freeUsers.map((u) => u.userId)
logger.info(`Found ${freeUserIds.length} free users for log cleanup`)
const freeUserWorkflows = await db
.select({ workflowId: workflowLogs.workflowId })
.from(workflowLogs)
.innerJoin(
sql`workflow`,
sql`${workflowLogs.workflowId} = workflow.id AND workflow.user_id IN (${sql.join(freeUserIds)})`
)
.groupBy(workflowLogs.workflowId)
if (freeUserWorkflows.length === 0) {
logger.info('No free user workflows found for log cleanup')
return NextResponse.json({ message: 'No logs to clean up' })
}
const workflowIds = freeUserWorkflows.map((w) => w.workflowId)
const result = await db
.delete(workflowLogs)
.where(
sql`${workflowLogs.workflowId} IN (${sql.join(workflowIds)}) AND ${workflowLogs.createdAt} < ${retentionDate}`
)
.returning({ id: workflowLogs.id })
logger.info(`Successfully cleaned up ${result.length} logs for free users`)
return NextResponse.json({
message: `Successfully cleaned up ${result.length} logs for free users`,
deletedCount: result.length,
})
} catch (error) {
logger.error('Error cleaning up logs:', { error })
return NextResponse.json({ error: 'Failed to clean up logs' }, { status: 500 })
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { eq, sql } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
@@ -41,6 +42,7 @@ export async function GET(request: NextRequest) {
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: new Date(),

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { checkEnterprisePlan } from '@/lib/subscription/utils'
import { db } from '@/db'
import { member, subscription } from '@/db/schema'
const logger = createLogger('EnterpriseSubscriptionAPI')
export async function GET() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const userId = session.user.id
const userSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
.limit(1)
if (userSubscriptions.length > 0 && checkEnterprisePlan(userSubscriptions[0])) {
const enterpriseSub = userSubscriptions[0]
logger.info('Found direct enterprise subscription', { userId, subId: enterpriseSub.id })
return NextResponse.json({
success: true,
subscription: enterpriseSub,
})
}
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
for (const { organizationId } of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
if (orgSubscriptions.length > 0 && checkEnterprisePlan(orgSubscriptions[0])) {
const enterpriseSub = orgSubscriptions[0]
logger.info('Found organization enterprise subscription', {
userId,
orgId: organizationId,
subId: enterpriseSub.id,
})
return NextResponse.json({
success: true,
subscription: enterpriseSub,
})
}
}
return NextResponse.json({
success: false,
subscription: null,
})
} catch (error) {
logger.error('Error fetching enterprise subscription:', error)
return NextResponse.json(
{
success: false,
error: 'Failed to fetch enterprise subscription data',
},
{ status: 500 }
)
}
}

View File

@@ -1,29 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { isProPlan, isTeamPlan } from '@/lib/subscription'
import { getHighestPrioritySubscription } from '@/lib/subscription/subscription'
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/subscription/utils'
const logger = createLogger('UserSubscriptionAPI')
export async function GET(request: NextRequest) {
export async function GET() {
try {
// Get the authenticated user
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized subscription access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Check if the user is on the Pro plan
const isPro = await isProPlan(session.user.id)
const activeSub = await getHighestPrioritySubscription(session.user.id)
// Check if the user is on the Team plan
const isTeam = await isTeamPlan(session.user.id)
const isPaid =
activeSub?.status === 'active' &&
['pro', 'team', 'enterprise'].includes(activeSub?.plan ?? '')
return NextResponse.json({ isPro, isTeam })
const isPro = isPaid
const isTeam = checkTeamPlan(activeSub)
const isEnterprise = checkEnterprisePlan(activeSub)
return NextResponse.json({
isPaid,
isPro,
isTeam,
isEnterprise,
plan: activeSub?.plan || 'free',
status: activeSub?.status || null,
seats: activeSub?.seats || null,
metadata: activeSub?.metadata || null,
})
} catch (error) {
logger.error('Error checking subscription status:', error)
return NextResponse.json({ error: 'Failed to check subscription status' }, { status: 500 })
logger.error('Error fetching subscription:', error)
return NextResponse.json({ error: 'Failed to fetch subscription data' }, { status: 500 })
}
}

View File

@@ -0,0 +1,161 @@
import { NextResponse } from 'next/server'
import { and, eq, or } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { checkEnterprisePlan } from '@/lib/subscription/utils'
import { db } from '@/db'
import { member, subscription } from '@/db/schema'
const logger = createLogger('UpdateSubscriptionSeatsAPI')
const updateSeatsSchema = z.object({
subscriptionId: z.string().uuid(),
seats: z.number().int().positive(),
})
const subscriptionMetadataSchema = z
.object({
perSeatAllowance: z.number().positive().optional(),
totalAllowance: z.number().positive().optional(),
updatedAt: z.string().optional(),
})
.catchall(z.any())
interface SubscriptionMetadata {
perSeatAllowance?: number
totalAllowance?: number
updatedAt?: string
[key: string]: any
}
export async function POST(req: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const rawBody = await req.json()
const validationResult = updateSeatsSchema.safeParse(rawBody)
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Invalid request parameters',
details: validationResult.error.format(),
},
{ status: 400 }
)
}
const { subscriptionId, seats } = validationResult.data
const subscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
if (subscriptions.length === 0) {
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
}
const sub = subscriptions[0]
if (!checkEnterprisePlan(sub)) {
return NextResponse.json(
{
error: 'Only enterprise subscriptions can be updated through this endpoint',
},
{ status: 400 }
)
}
let hasPermission = sub.referenceId === session.user.id
if (!hasPermission) {
const memberships = await db
.select()
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, sub.referenceId),
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
)
)
.limit(1)
hasPermission = memberships.length > 0
if (!hasPermission) {
logger.warn('Unauthorized subscription update attempt', {
userId: session.user.id,
subscriptionId,
referenceId: sub.referenceId,
})
return NextResponse.json(
{ error: 'You must be an admin or owner to update subscription settings' },
{ status: 403 }
)
}
}
let validatedMetadata: SubscriptionMetadata
try {
validatedMetadata = subscriptionMetadataSchema.parse(sub.metadata || {})
} catch (error) {
logger.error('Invalid subscription metadata format', {
error,
subscriptionId,
metadata: sub.metadata,
})
return NextResponse.json(
{ error: 'Subscription metadata has invalid format' },
{ status: 400 }
)
}
if (validatedMetadata.perSeatAllowance && validatedMetadata.perSeatAllowance > 0) {
validatedMetadata.totalAllowance = seats * validatedMetadata.perSeatAllowance
validatedMetadata.updatedAt = new Date().toISOString()
}
await db
.update(subscription)
.set({
seats,
metadata: validatedMetadata,
})
.where(eq(subscription.id, subscriptionId))
logger.info('Updated subscription seats', {
subscriptionId,
previousSeats: sub.seats,
newSeats: seats,
userId: session.user.id,
})
return NextResponse.json({
success: true,
message: 'Subscription seats updated',
data: {
subscriptionId,
seats,
plan: sub.plan,
metadata: validatedMetadata,
},
})
} catch (error) {
logger.error('Error updating subscription seats:', error)
return NextResponse.json(
{
error: 'Failed to update subscription seats',
},
{ status: 500 }
)
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
@@ -7,9 +8,13 @@ import * as schema from '@/db/schema'
const logger = createLogger('TransferSubscriptionAPI')
const transferSubscriptionSchema = z.object({
subscriptionId: z.string().uuid(),
organizationId: z.string().uuid(),
})
export async function POST(request: NextRequest) {
try {
// Get the authenticated user
const session = await getSession()
if (!session?.user?.id) {
@@ -17,24 +22,27 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse the request body
const body = await request.json()
const { subscriptionId, organizationId } = body
const validationResult = transferSubscriptionSchema.safeParse(body)
if (!subscriptionId || !organizationId) {
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Missing required fields: subscriptionId and organizationId' },
{
error: 'Invalid request parameters',
details: validationResult.error.format(),
},
{ status: 400 }
)
}
const { subscriptionId, organizationId } = validationResult.data
logger.info('Transferring subscription to organization', {
userId: session.user.id,
subscriptionId,
organizationId,
})
// Verify the user has access to both the subscription and organization
const subscription = await db
.select()
.from(schema.subscription)
@@ -46,7 +54,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
}
// Verify the subscription belongs to the user
if (subscription.referenceId !== session.user.id) {
logger.warn('Unauthorized subscription transfer - subscription does not belong to user', {
userId: session.user.id,
@@ -58,7 +65,6 @@ export async function POST(request: NextRequest) {
)
}
// Verify the organization exists
const organization = await db
.select()
.from(schema.organization)
@@ -70,7 +76,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
// Verify the user has admin access to the organization (is owner or admin)
const member = await db
.select()
.from(schema.member)
@@ -92,7 +97,6 @@ export async function POST(request: NextRequest) {
)
}
// Update the subscription to point to the organization instead of the user
await db
.update(schema.subscription)
.set({ referenceId: organizationId })

View File

@@ -58,6 +58,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: new Date(),

View File

@@ -25,6 +25,7 @@ interface SettingsNavigationProps {
| 'privacy'
) => void
isTeam?: boolean
isEnterprise?: boolean
}
type NavigationItem = {
@@ -93,15 +94,15 @@ export function SettingsNavigation({
activeSection,
onSectionChange,
isTeam = false,
isEnterprise = false,
}: SettingsNavigationProps) {
const navigationItems = allNavigationItems.filter((item) => {
// Hide items based on development environment
if (item.hideInDev && isDev) {
return false
}
// Hide team tab if user doesn't have team subscription
if (item.requiresTeam && !isTeam) {
// Hide team tab if user doesn't have team or enterprisesubscription
if (item.requiresTeam && !isTeam && !isEnterprise) {
return false
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
@@ -20,7 +20,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { client, useActiveOrganization, useSession, useSubscription } from '@/lib/auth-client'
import { useActiveOrganization, useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('Subscription')
@@ -29,6 +29,7 @@ interface SubscriptionProps {
onOpenChange: (open: boolean) => void
cachedIsPro?: boolean
cachedIsTeam?: boolean
cachedIsEnterprise?: boolean
cachedUsageData?: any
cachedSubscriptionData?: any
isLoading?: boolean
@@ -39,12 +40,14 @@ const useSubscriptionData = (
activeOrgId: string | null | undefined,
cachedIsPro?: boolean,
cachedIsTeam?: boolean,
cachedIsEnterprise?: boolean,
cachedUsageData?: any,
cachedSubscriptionData?: any,
isParentLoading?: boolean
) => {
const [isPro, setIsPro] = useState<boolean>(cachedIsPro || false)
const [isTeam, setIsTeam] = useState<boolean>(cachedIsTeam || false)
const [isEnterprise, setIsEnterprise] = useState<boolean>(cachedIsEnterprise || false)
const [usageData, setUsageData] = useState<{
percentUsed: number
isWarning: boolean
@@ -72,11 +75,13 @@ const useSubscriptionData = (
isParentLoading !== undefined ||
(cachedIsPro !== undefined &&
cachedIsTeam !== undefined &&
cachedIsEnterprise !== undefined &&
cachedUsageData &&
cachedSubscriptionData)
) {
if (cachedIsPro !== undefined) setIsPro(cachedIsPro)
if (cachedIsTeam !== undefined) setIsTeam(cachedIsTeam)
if (cachedIsEnterprise !== undefined) setIsEnterprise(cachedIsEnterprise)
if (cachedUsageData) setUsageData(cachedUsageData)
if (cachedSubscriptionData) setSubscriptionData(cachedSubscriptionData)
if (isParentLoading !== undefined) setLoading(isParentLoading)
@@ -107,6 +112,7 @@ const useSubscriptionData = (
const proStatusData = await proStatusResponse.json()
setIsPro(proStatusData.isPro)
setIsTeam(proStatusData.isTeam)
setIsEnterprise(proStatusData.isEnterprise)
const usageDataResponse = await usageResponse.json()
setUsageData(usageDataResponse)
@@ -114,13 +120,14 @@ const useSubscriptionData = (
logger.info('Subscription status and usage data retrieved', {
isPro: proStatusData.isPro,
isTeam: proStatusData.isTeam,
isEnterprise: proStatusData.isEnterprise,
usage: usageDataResponse,
})
// Main subscription logic - prioritize organization team subscription
// Main subscription logic - prioritize organization team/enterprise subscription
let activeSubscription = null
// First check if user has an active organization with a team subscription
// First check if user has an active organization with a team/enterprise subscription
if (activeOrgId) {
logger.info('Checking organization subscription first', { orgId: activeOrgId })
@@ -135,13 +142,13 @@ const useSubscriptionData = (
if (orgSubError) {
logger.error('Error fetching organization subscription details', orgSubError)
} else if (orgSubscriptions) {
// Find active team subscription for the organization
// Find active team/enterprise subscription for the organization
activeSubscription = orgSubscriptions.find(
(sub) => sub.status === 'active' && sub.plan === 'team'
(sub) => sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'enterprise')
)
if (activeSubscription) {
logger.info('Using organization team subscription as primary', {
logger.info(`Using organization ${activeSubscription.plan} subscription as primary`, {
id: activeSubscription.id,
seats: activeSubscription.seats,
})
@@ -149,7 +156,7 @@ const useSubscriptionData = (
}
}
// If no org team subscription was found, check for personal subscription
// If no org subscription was found, check for personal subscription
if (!activeSubscription) {
// Fetch detailed subscription data for the user
const result = await subscription.list()
@@ -165,6 +172,27 @@ const useSubscriptionData = (
}
}
// If no subscription found via client.subscription but we know they have enterprise,
// try fetching from the enterprise endpoint
if (!activeSubscription && proStatusData.isEnterprise) {
try {
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
if (enterpriseResponse.ok) {
const enterpriseData = await enterpriseResponse.json()
if (enterpriseData.subscription) {
activeSubscription = enterpriseData.subscription
logger.info('Found enterprise subscription', {
id: activeSubscription.id,
plan: 'enterprise',
seats: activeSubscription.seats,
})
}
}
} catch (error) {
logger.error('Error fetching enterprise subscription details', error)
}
}
if (activeSubscription) {
logger.info('Using active subscription', {
id: activeSubscription.id,
@@ -191,18 +219,20 @@ const useSubscriptionData = (
subscription,
cachedIsPro,
cachedIsTeam,
cachedIsEnterprise,
cachedUsageData,
cachedSubscriptionData,
isParentLoading,
])
return { isPro, isTeam, usageData, subscriptionData, loading, error }
return { isPro, isTeam, isEnterprise, usageData, subscriptionData, loading, error }
}
export function Subscription({
onOpenChange,
cachedIsPro,
cachedIsTeam,
cachedIsEnterprise,
cachedUsageData,
cachedSubscriptionData,
isLoading,
@@ -214,6 +244,7 @@ export function Subscription({
const {
isPro,
isTeam,
isEnterprise,
usageData,
subscriptionData,
loading,
@@ -223,6 +254,7 @@ export function Subscription({
activeOrg?.id,
cachedIsPro,
cachedIsTeam,
cachedIsEnterprise,
cachedUsageData,
cachedSubscriptionData,
isLoading
@@ -363,188 +395,398 @@ export function Subscription({
<>
<div className="grid gap-6 md:grid-cols-2">
{/* Free Tier */}
<div className={`border rounded-lg p-4 ${!isPro ? 'border-primary' : ''}`}>
<h4 className="text-md font-semibold">Free Tier</h4>
<p className="text-sm text-muted-foreground mt-1">
For individual users and small projects
</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• ${!isPro ? 5 : usageData.limit} of inference credits</li>
<li>• Basic features</li>
<li>• No sharing capabilities</li>
</ul>
<div
className={`relative border rounded-lg transition-all ${
!isPro
? 'border-primary/50 bg-primary/5 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/20'
}`}
>
{!isPro && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: ''
}`}
/>
<div className="absolute -top-2.5 left-4 px-2 py-0.5 bg-primary text-primary-foreground text-xs rounded-sm font-medium">
Current Plan
</div>
)}
<div className="p-5">
<h4 className="text-base font-semibold flex items-center">Free Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For individual users</p>
<div className="mt-4">
{!isPro ? (
<div className="text-sm bg-secondary/50 text-secondary-foreground py-1 px-2 rounded inline-block">
Current Plan
<div className="my-4 py-2 border-y">
<div className="flex items-baseline justify-between">
<span className="text-3xl font-bold">$0</span>
<span className="text-muted-foreground text-sm">/month</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
${!isPro ? 5 : usageData.limit} inference credits included
</div>
</div>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Basic features</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>No sharing capabilities</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>7 day log retention</span>
</li>
</ul>
{!isPro && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: '[&>*]:bg-primary'
}`}
/>
</div>
) : (
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isCanceling}>
{isCanceling ? <ButtonSkeleton /> : <span>Downgrade</span>}
</Button>
)}
<div className="mt-5">
{!isPro ? (
<div className="w-full bg-primary/10 text-primary py-2 px-3 rounded text-center text-xs font-medium">
Current Plan
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleCancel}
disabled={isCanceling}
>
{isCanceling ? <ButtonSkeleton /> : <span>Downgrade</span>}
</Button>
)}
</div>
</div>
</div>
{/* Pro Tier */}
<div className={`border rounded-lg p-4 ${isPro && !isTeam ? 'border-primary' : ''}`}>
<h4 className="text-md font-semibold">Pro Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For professional users and teams</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• ${isPro && !isTeam ? usageData.limit : 20} of inference credits</li>
<li>• All features included</li>
<li>• Workflow sharing capabilities</li>
</ul>
{isPro && !isTeam && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: ''
}`}
/>
<div
className={`relative border rounded-lg transition-all ${
isPro && !isTeam && !isEnterprise
? 'border-primary/50 bg-primary/5 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/20'
}`}
>
{isPro && !isTeam && !isEnterprise && (
<div className="absolute -top-2.5 left-4 px-2 py-0.5 bg-primary text-primary-foreground text-xs rounded-sm font-medium">
Current Plan
</div>
)}
<div className="p-5">
<h4 className="text-base font-semibold flex items-center">Pro Tier</h4>
<p className="text-sm text-muted-foreground mt-1">
For professional users and teams
</p>
<div className="mt-4">
{isPro && !isTeam ? (
<div className="text-sm bg-secondary/50 text-secondary-foreground py-1 px-2 rounded inline-block">
Current Plan
<div className="my-4 py-2 border-y">
<div className="flex items-baseline justify-between">
<span className="text-3xl font-bold">$20</span>
<span className="text-muted-foreground text-sm">/month</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
${isPro && !isTeam && !isEnterprise ? usageData.limit : 20} inference credits
included
</div>
</div>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>All features included</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Workflow sharing capabilities</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Extended log retention</span>
</li>
</ul>
{isPro && !isTeam && !isEnterprise && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: '[&>*]:bg-primary'
}`}
/>
</div>
) : (
<Button
variant={!isPro ? 'default' : 'outline'}
size="sm"
onClick={() => handleUpgrade('pro')}
disabled={isUpgrading}
>
{isUpgrading ? (
<ButtonSkeleton />
) : (
<span>{!isPro ? 'Upgrade' : 'Switch'}</span>
)}
</Button>
)}
<div className="mt-5">
{isPro && !isTeam && !isEnterprise ? (
<div className="w-full bg-primary/10 text-primary py-2 px-3 rounded text-center text-xs font-medium">
Current Plan
</div>
) : (
<Button
variant={!isPro ? 'default' : 'outline'}
size="sm"
className="w-full"
onClick={() => handleUpgrade('pro')}
disabled={isUpgrading || isEnterprise}
>
{isUpgrading ? (
<ButtonSkeleton />
) : (
<span>{!isPro ? 'Upgrade' : 'Switch'}</span>
)}
</Button>
)}
</div>
</div>
</div>
{/* Team Tier */}
<div className={`border rounded-lg p-4 ${isTeam ? 'border-primary' : ''}`}>
<h4 className="text-md font-semibold">Team Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For collaborative teams</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• $40 of inference credits per seat</li>
<li>• All Pro features included</li>
<li>• Real-time multiplayer collaboration</li>
<li>• Shared workspace for team members</li>
</ul>
<div
className={`relative border rounded-lg transition-all ${
isTeam
? 'border-primary/50 bg-primary/5 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/20'
}`}
>
{isTeam && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {(subscriptionData?.seats || 1) * 40}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: ''
}`}
/>
<div className="flex justify-between text-xs mt-2">
<span>Team Size</span>
<span>
{subscriptionData?.seats || 1}{' '}
{subscriptionData?.seats === 1 ? 'seat' : 'seats'}
</span>
</div>
<div className="absolute -top-2.5 left-4 px-2 py-0.5 bg-primary text-primary-foreground text-xs rounded-sm font-medium">
Current Plan
</div>
)}
<div className="p-5">
<h4 className="text-base font-semibold flex items-center">Team Tier</h4>
<p className="text-sm text-muted-foreground mt-1">For collaborative teams</p>
<div className="mt-4">
{isTeam ? (
<div className="text-sm bg-secondary/50 text-secondary-foreground py-1 px-2 rounded inline-block">
Current Plan
<div className="my-4 py-2 border-y">
<div className="flex items-baseline justify-between">
<span className="text-3xl font-bold">$40</span>
<span className="text-muted-foreground text-sm">/seat/month</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
$40 inference credits per seat
</div>
</div>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>All Pro features included</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Real-time multiplayer collaboration</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Shared workspace for team members</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Unlimited log retention</span>
</li>
</ul>
{isTeam && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {(subscriptionData?.seats || 1) * 40}
$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: '[&>*]:bg-primary'
}`}
/>
<div className="flex justify-between text-xs mt-2">
<span>Team Size</span>
<span>
{subscriptionData?.seats || 1}{' '}
{subscriptionData?.seats === 1 ? 'seat' : 'seats'}
</span>
</div>
</div>
) : (
<Button variant="outline" size="sm" onClick={handleTeamUpgrade}>
Upgrade to Team
</Button>
)}
<div className="mt-5">
{isTeam ? (
<div className="w-full bg-primary/10 text-primary py-2 px-3 rounded text-center text-xs font-medium">
Current Plan
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleTeamUpgrade}
>
Upgrade to Team
</Button>
)}
</div>
</div>
</div>
{/* Enterprise Tier */}
<div className="border rounded-lg p-4 col-span-full">
<h4 className="text-md font-semibold">Enterprise</h4>
<p className="text-sm text-muted-foreground mt-1">
For larger teams and organizations
</p>
<div
className={`relative border rounded-lg md:col-span-2 transition-all ${
isEnterprise
? 'border-primary/50 bg-primary/5 shadow-sm'
: 'border-border hover:border-border/80 hover:bg-accent/20'
}`}
>
{isEnterprise && (
<div className="absolute -top-2.5 left-4 px-2 py-0.5 bg-primary text-primary-foreground text-xs rounded-sm font-medium">
Current Plan
</div>
)}
<div className="p-5">
<div className="md:flex md:justify-between md:items-start">
<div className="md:flex-1">
<h4 className="text-base font-semibold">Enterprise</h4>
<p className="text-sm text-muted-foreground mt-1">
For larger teams and organizations
</p>
<ul className="mt-3 space-y-2 text-sm">
<li>• Custom cost limits</li>
<li>• Priority support</li>
<li>• Custom integrations</li>
<li>• Dedicated account manager</li>
</ul>
<div className="my-4 py-2 border-y md:mr-6">
<div className="text-3xl font-bold">Custom</div>
<div className="text-xs text-muted-foreground mt-1">
Contact us for custom pricing
</div>
</div>
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
window.open(
'https://calendly.com/emir-simstudio/15min',
'_blank',
'noopener,noreferrer'
)
}}
>
Contact Us
</Button>
<div className="md:flex-1">
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Custom cost limits</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Priority support</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Custom integrations</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Dedicated account manager</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>Unlimited log retention</span>
</li>
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>24/7 slack support</span>
</li>
{isEnterprise && subscriptionData?.metadata?.perSeatAllowance && (
<li className="flex items-start">
<span className="text-primary mr-2">•</span>
<span>
${subscriptionData.metadata.perSeatAllowance} inference credits per seat
</span>
</li>
)}
</ul>
</div>
</div>
{isEnterprise && (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-xs">
<span>Usage</span>
<span>
{usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$
</span>
</div>
<Progress
value={usageData.percentUsed}
className={`h-2 ${
usageData.isExceeded
? 'bg-muted [&>*]:bg-destructive'
: usageData.isWarning
? 'bg-muted [&>*]:bg-amber-500'
: '[&>*]:bg-primary'
}`}
/>
<div className="flex justify-between text-xs mt-2">
<span>Team Size</span>
<span>
{subscriptionData?.seats || 1}{' '}
{subscriptionData?.seats === 1 ? 'seat' : 'seats'}
</span>
</div>
{subscriptionData?.metadata?.totalAllowance && (
<div className="flex justify-between text-xs mt-2">
<span>Total Allowance</span>
<span>${subscriptionData.metadata.totalAllowance}</span>
</div>
)}
</div>
)}
<div className="mt-5">
{isEnterprise ? (
<div className="w-full bg-primary/10 text-primary py-2 px-3 rounded text-center text-xs font-medium">
Current Plan
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
window.open(
'https://calendly.com/emir-simstudio/15min',
'_blank',
'noopener,noreferrer'
)
}}
>
Contact Us
</Button>
)}
</div>
</div>
</div>
</div>

View File

@@ -16,6 +16,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { client, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { checkEnterprisePlan } from '@/lib/subscription/utils'
const logger = createLogger('TeamManagement')
@@ -44,6 +45,7 @@ export function TeamManagement() {
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false)
const [hasTeamPlan, setHasTeamPlan] = useState(false)
const [hasEnterprisePlan, setHasEnterprisePlan] = useState(false)
const [userRole, setUserRole] = useState<string>('member')
const [isAdminOrOwner, setIsAdminOrOwner] = useState(false)
@@ -58,16 +60,17 @@ export function TeamManagement() {
const orgsResponse = await client.organization.list()
setOrganizations(orgsResponse.data || [])
// Check if user has a team subscription
// Check if user has a team or enterprise subscription
const response = await fetch('/api/user/subscription')
const data = await response.json()
setHasTeamPlan(data.isTeam)
setHasEnterprisePlan(data.isEnterprise)
// If user has team plan but no organizations, prompt to create one
if (data.isTeam && (!orgsResponse.data || orgsResponse.data.length === 0)) {
// Set default organization name and slug for organization creation
// but no longer automatically showing the dialog
if (data.isTeam || data.isEnterprise) {
setOrgName(`${session.user.name || 'My'}'s Team`)
setOrgSlug(generateSlug(`${session.user.name || 'My'}'s Team`))
setCreateOrgDialogOpen(true)
}
} catch (err: any) {
setError(err.message || 'Failed to load data')
@@ -129,20 +132,43 @@ export function TeamManagement() {
})),
})
// Filter to only active team subscription
// Find active team or enterprise subscription
const teamSubscription = data?.find((sub) => sub.status === 'active' && sub.plan === 'team')
const enterpriseSubscription = data?.find((sub) => checkEnterprisePlan(sub))
if (teamSubscription) {
logger.info('Found active team subscription', {
id: teamSubscription.id,
seats: teamSubscription.seats,
// Use enterprise plan if available, otherwise team plan
const activeSubscription = enterpriseSubscription || teamSubscription
if (activeSubscription) {
logger.info('Found active subscription', {
id: activeSubscription.id,
plan: activeSubscription.plan,
seats: activeSubscription.seats,
})
setSubscriptionData([teamSubscription])
setSubscriptionData(activeSubscription)
} else {
logger.warn('No active team subscription found for organization', {
orgId,
})
setSubscriptionData([])
// If no subscription found through client API, check for enterprise subscriptions
if (hasEnterprisePlan) {
try {
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
if (enterpriseResponse.ok) {
const enterpriseData = await enterpriseResponse.json()
if (enterpriseData.subscription) {
logger.info('Found enterprise subscription', {
id: enterpriseData.subscription.id,
seats: enterpriseData.subscription.seats,
})
setSubscriptionData(enterpriseData.subscription)
return
}
}
} catch (err) {
logger.error('Error fetching enterprise subscription', err)
}
}
logger.warn('No active subscription found for organization', { orgId })
setSubscriptionData(null)
}
}
} catch (err: any) {
@@ -177,10 +203,9 @@ export function TeamManagement() {
// Handle seat reduction - remove members when seats are reduced
const handleReduceSeats = async () => {
if (!session?.user || !activeOrganization || !subscriptionData || subscriptionData.length === 0)
return
if (!session?.user || !activeOrganization || !subscriptionData) return
const currentSeats = subscriptionData[0]?.seats || 0
const currentSeats = subscriptionData.seats || 0
if (currentSeats <= 1) {
setError('Cannot reduce seats below 1')
return
@@ -207,20 +232,40 @@ export function TeamManagement() {
// Reduce the seats by 1
const newSeatCount = currentSeats - 1
// Upgrade with reduced seat count
const { error } = await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: newSeatCount,
})
// If it's an enterprise plan, handle through custom endpoint
if (checkEnterprisePlan(subscriptionData)) {
// For enterprise plans, update via admin endpoint with credentials
const response = await fetch('/api/user/subscription/update-seats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionId: subscriptionData.id,
seats: newSeatCount,
}),
})
if (error) {
setError(error.message || 'Failed to update seat count')
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to update seat count')
}
} else {
await refreshOrganization()
// For team plans, use the client API
const { error } = await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: newSeatCount,
})
if (error) {
throw new Error(error.message || 'Failed to update seat count')
}
}
await refreshOrganization()
} catch (err: any) {
setError(err.message || 'Failed to reduce seats')
} finally {
@@ -269,17 +314,18 @@ export function TeamManagement() {
organizationId: orgId,
})
// If the user has a team subscription, update the subscription reference
// If the user has a team or enterprise subscription, update the subscription reference
// directly through a custom API endpoint instead of using upgrade
if (hasTeamPlan) {
if (hasTeamPlan || hasEnterprisePlan) {
const userSubResponse = await client.subscription.list()
const teamSubscription = userSubResponse.data?.find(
(sub) => sub.plan === 'team' && sub.status === 'active'
(sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active'
)
if (teamSubscription) {
logger.info('Found user team subscription to transfer', {
logger.info('Found subscription to transfer', {
subscriptionId: teamSubscription.id,
plan: teamSubscription.plan,
seats: teamSubscription.seats,
targetOrgId: orgId,
})
@@ -383,15 +429,14 @@ export function TeamManagement() {
const totalCount = currentMemberCount + pendingInvitationCount
// Get the number of seats from subscription data
const teamSubscription = subscriptionData?.[0]
const seatLimit = teamSubscription?.seats || 0
const seatLimit = subscriptionData?.seats || 0
logger.info('Checking seat availability for invitation', {
currentMembers: currentMemberCount,
pendingInvites: pendingInvitationCount,
totalUsed: totalCount,
seatLimit: seatLimit,
subscriptionId: teamSubscription?.id,
subscriptionId: subscriptionData?.id,
})
if (totalCount >= seatLimit) {
@@ -469,18 +514,38 @@ export function TeamManagement() {
})
// If the user opted to reduce seats as well
if (shouldReduceSeats && subscriptionData && subscriptionData.length > 0) {
const currentSeats = subscriptionData[0]?.seats || 0
if (shouldReduceSeats && subscriptionData) {
const currentSeats = subscriptionData.seats || 0
if (currentSeats > 1) {
// Reduce the seat count by 1
await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: currentSeats - 1,
})
// Determine if we're dealing with enterprise or team plan
if (checkEnterprisePlan(subscriptionData)) {
// Handle enterprise plan seat reduction
const response = await fetch('/api/user/subscription/update-seats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionId: subscriptionData.id,
seats: currentSeats - 1,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to reduce seats')
}
} else {
// Handle team plan seat reduction
await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
successUrl: window.location.href,
cancelUrl: window.location.href,
seats: currentSeats - 1,
})
}
}
}
@@ -517,7 +582,23 @@ export function TeamManagement() {
}
}
if (isLoading && !activeOrganization && !hasTeamPlan) {
// Get the effective plan name for display
const getEffectivePlanName = () => {
if (!subscriptionData) return 'No Plan'
if (checkEnterprisePlan(subscriptionData)) {
return 'Enterprise'
} else if (subscriptionData.plan === 'team') {
return 'Team'
} else {
return (
subscriptionData.plan?.charAt(0).toUpperCase() + subscriptionData.plan?.slice(1) ||
'Unknown'
)
}
}
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
return <TeamManagementSkeleton />
}
@@ -553,20 +634,76 @@ export function TeamManagement() {
if (!activeOrganization) {
return (
<div className="p-6 space-y-6">
<div className="text-center space-y-4">
<div className="space-y-6">
<h3 className="text-lg font-medium">
{hasTeamPlan ? 'Create Your Team Workspace' : 'No Team Workspace'}
{hasTeamPlan || hasEnterprisePlan ? 'Create Your Team Workspace' : 'No Team Workspace'}
</h3>
<p className="text-sm text-muted-foreground">
{hasTeamPlan
? "You're subscribed to a team plan. Create your workspace to start collaborating with your team."
: "You don't have a team workspace yet. Create one to start collaborating with your team."}
</p>
<Button onClick={() => setCreateOrgDialogOpen(true)}>
<Building className="w-4 h-4 mr-2" />
Create Team Workspace
</Button>
{hasTeamPlan || hasEnterprisePlan ? (
<div className="border rounded-lg p-6 space-y-6">
<p className="text-sm text-muted-foreground">
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
workspace to start collaborating with your team.
</p>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Team Name</label>
<Input value={orgName} onChange={handleOrgNameChange} placeholder="My Team" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Team URL</label>
<div className="flex items-center space-x-2">
<div className="bg-muted px-3 py-2 rounded-l-md text-sm text-muted-foreground">
simstudio.ai/team/
</div>
<Input
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
className="rounded-l-none"
/>
</div>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end space-x-2">
<Button
onClick={handleCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
>
{isCreatingOrg && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Create Team Workspace
</Button>
</div>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
You don't have a team workspace yet. To collaborate with others, first upgrade to a
team or enterprise plan.
</p>
<Button
onClick={() => {
// Open the subscription tab
const event = new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
window.dispatchEvent(event)
}}
>
Upgrade to Team Plan
</Button>
</>
)}
</div>
<Dialog open={createOrgDialogOpen} onOpenChange={setCreateOrgDialogOpen}>
@@ -694,7 +831,7 @@ export function TeamManagement() {
{isLoadingSubscription ? (
<TeamSeatsSkeleton />
) : subscriptionData && subscriptionData.length > 0 ? (
) : subscriptionData ? (
<>
<div className="flex justify-between text-sm mb-2">
<span>Used</span>
@@ -703,7 +840,7 @@ export function TeamManagement() {
(activeOrganization.invitations?.filter(
(inv: any) => inv.status === 'pending'
).length || 0)}
/{subscriptionData[0]?.seats || 0}
/{subscriptionData.seats || 0}
</span>
</div>
<Progress
@@ -712,7 +849,7 @@ export function TeamManagement() {
(activeOrganization.invitations?.filter(
(inv: any) => inv.status === 'pending'
).length || 0)) /
(subscriptionData[0]?.seats || 1)) *
(subscriptionData.seats || 1)) *
100
}
className="h-2"
@@ -723,16 +860,46 @@ export function TeamManagement() {
variant="outline"
size="sm"
onClick={handleReduceSeats}
disabled={(subscriptionData[0]?.seats || 0) <= 1 || isLoading}
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
>
Remove Seat
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const currentSeats = subscriptionData[0]?.seats || 1
confirmTeamUpgrade(currentSeats + 1)
onClick={async () => {
const currentSeats = subscriptionData.seats || 1
// For enterprise plans, we need a custom endpoint
if (checkEnterprisePlan(subscriptionData)) {
try {
const response = await fetch('/api/user/subscription/update-seats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionId: subscriptionData.id,
seats: currentSeats + 1,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to update seats')
}
await refreshOrganization()
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to update seats'
setError(errorMessage)
logger.error('Error updating enterprise seats', { error })
}
} else {
// For team plans, use the normal upgrade flow
await confirmTeamUpgrade(currentSeats + 1)
}
}}
disabled={isLoading}
>
@@ -742,7 +909,7 @@ export function TeamManagement() {
</>
) : (
<div className="text-sm text-muted-foreground space-y-2">
<p>No active team subscription found for this organization.</p>
<p>No active subscription found for this organization.</p>
<p>
This might happen if your subscription was created for your personal account but
hasn't been properly transferred to the organization.
@@ -875,12 +1042,24 @@ export function TeamManagement() {
}`}
></div>
<span className="capitalize font-medium">
{subscriptionData.status}
{getEffectivePlanName()} {subscriptionData.status}
{subscriptionData.cancelAtPeriodEnd ? ' (Cancels at period end)' : ''}
</span>
</div>
<div className="text-sm text-muted-foreground">
<div>Team seats: {subscriptionData.seats}</div>
{checkEnterprisePlan(subscriptionData) && subscriptionData.metadata && (
<div>
{subscriptionData.metadata.perSeatAllowance && (
<div>
Per-seat allowance: ${subscriptionData.metadata.perSeatAllowance}
</div>
)}
{subscriptionData.metadata.totalAllowance && (
<div>Total allowance: ${subscriptionData.metadata.totalAllowance}</div>
)}
</div>
)}
{subscriptionData.periodEnd && (
<div>
Next billing date:{' '}

View File

@@ -39,6 +39,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const [isPro, setIsPro] = useState(false)
const [isTeam, setIsTeam] = useState(false)
const [isEnterprise, setIsEnterprise] = useState(false)
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [usageData, setUsageData] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -63,10 +64,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const subData = await proStatusResponse.json()
setIsPro(subData.isPro)
setIsTeam(subData.isTeam)
if (!subData.isTeam && activeSection === 'team') {
setActiveSection('general')
}
setIsEnterprise(subData.isEnterprise)
}
const usageResponse = await fetch('/api/user/usage')
@@ -78,9 +76,26 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
try {
const result = await subscription.list()
if (isEnterprise) {
try {
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
if (enterpriseResponse.ok) {
const enterpriseData = await enterpriseResponse.json()
if (enterpriseData.subscription) {
setSubscriptionData(enterpriseData.subscription)
return
}
}
} catch (error) {
logger.error('Error fetching enterprise subscription', error)
}
}
if (result.data && result.data.length > 0) {
const activeSubscription = result.data.find(
(sub) => sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'pro')
(sub) =>
sub.status === 'active' &&
(sub.plan === 'team' || sub.plan === 'pro' || sub.plan === 'enterprise')
)
if (activeSubscription) {
@@ -104,7 +119,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
} else {
hasLoadedInitialData.current = false
}
}, [open, loadSettings, subscription, activeSection])
}, [open, loadSettings, subscription, activeSection, isEnterprise])
useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
@@ -121,6 +136,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const isSubscriptionEnabled = !!client.subscription
const showTeamManagement = isTeam || isEnterprise
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[70vh] flex flex-col p-0 gap-0" hideCloseButton>
@@ -146,6 +163,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
activeSection={activeSection}
onSectionChange={setActiveSection}
isTeam={isTeam}
isEnterprise={isEnterprise}
/>
</div>
@@ -172,13 +190,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
onOpenChange={onOpenChange}
cachedIsPro={isPro}
cachedIsTeam={isTeam}
cachedIsEnterprise={isEnterprise}
cachedUsageData={usageData}
cachedSubscriptionData={subscriptionData}
isLoading={isLoading}
/>
</div>
)}
{isTeam && (
{showTeamManagement && (
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>

View File

@@ -1,5 +1,9 @@
'use client'
import { TimerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { isProd } from '@/lib/environment'
import { useUserSubscription } from '@/hooks/use-user-subscription'
import FilterSection from './components/filter-section'
import Level from './components/level'
import Timeline from './components/timeline'
@@ -9,8 +13,43 @@ import Workflow from './components/workflow'
* Filters component for logs page - includes timeline and other filter options
*/
export function Filters() {
const { isPaid, isLoading } = useUserSubscription()
const handleUpgradeClick = (e: React.MouseEvent) => {
e.preventDefault()
const event = new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
window.dispatchEvent(event)
}
return (
<div className="p-4 w-60 border-r h-full overflow-auto">
{/* Show retention policy for free users in production only */}
{!isLoading && !isPaid && isProd && (
<div className="mb-4 border border-border rounded-md overflow-hidden">
<div className="bg-background border-b p-3 flex items-center gap-2">
<TimerOff className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Log Retention Policy</span>
</div>
<div className="p-3">
<p className="text-xs text-muted-foreground">
Logs are automatically deleted after 7 days.
</p>
<div className="mt-2.5">
<Button
size="sm"
variant="secondary"
className="px-3 py-1.5 h-8 text-xs w-full"
onClick={handleUpgradeClick}
>
Upgrade Plan
</Button>
</div>
</div>
</div>
)}
<h2 className="text-sm font-medium mb-4 pl-2">Filters</h2>
{/* Timeline Filter */}

View File

@@ -0,0 +1,3 @@
ALTER TABLE "organization" ALTER COLUMN "metadata" SET DATA TYPE json;--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "metadata" json;--> statement-breakpoint
ALTER TABLE "user_stats" ADD COLUMN "total_chat_executions" integer DEFAULT 0 NOT NULL;

View File

@@ -93,12 +93,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -170,12 +166,8 @@
"name": "api_key_user_id_user_id_fk",
"tableFrom": "api_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -185,9 +177,7 @@
"api_key_key_unique": {
"name": "api_key_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
"columns": ["key"]
}
},
"policies": {},
@@ -312,12 +302,8 @@
"name": "chat_workflow_id_workflow_id_fk",
"tableFrom": "chat",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -325,12 +311,8 @@
"name": "chat_user_id_user_id_fk",
"tableFrom": "chat",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -396,12 +378,8 @@
"name": "custom_tools_user_id_user_id_fk",
"tableFrom": "custom_tools",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -448,12 +426,8 @@
"name": "environment_user_id_user_id_fk",
"tableFrom": "environment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -463,9 +437,7 @@
"environment_user_id_unique": {
"name": "environment_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -532,12 +504,8 @@
"name": "invitation_inviter_id_user_id_fk",
"tableFrom": "invitation",
"tableTo": "user",
"columnsFrom": [
"inviter_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["inviter_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -545,12 +513,8 @@
"name": "invitation_organization_id_organization_id_fk",
"tableFrom": "invitation",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -641,12 +605,8 @@
"name": "marketplace_workflow_id_workflow_id_fk",
"tableFrom": "marketplace",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -654,12 +614,8 @@
"name": "marketplace_author_id_user_id_fk",
"tableFrom": "marketplace",
"tableTo": "user",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["author_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -712,12 +668,8 @@
"name": "member_user_id_user_id_fk",
"tableFrom": "member",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -725,12 +677,8 @@
"name": "member_organization_id_organization_id_fk",
"tableFrom": "member",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["organization_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -863,12 +811,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -876,12 +820,8 @@
"name": "session_active_organization_id_organization_id_fk",
"tableFrom": "session",
"tableTo": "organization",
"columnsFrom": [
"active_organization_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["active_organization_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -891,9 +831,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@@ -979,12 +917,8 @@
"name": "settings_user_id_user_id_fk",
"tableFrom": "settings",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -994,9 +928,7 @@
"settings_user_id_unique": {
"name": "settings_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -1148,9 +1080,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -1229,12 +1159,8 @@
"name": "user_stats_user_id_user_id_fk",
"tableFrom": "user_stats",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1244,9 +1170,7 @@
"user_stats_user_id_unique": {
"name": "user_stats_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -1347,9 +1271,7 @@
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -1434,12 +1356,8 @@
"name": "webhook_workflow_id_workflow_id_fk",
"tableFrom": "webhook",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1581,12 +1499,8 @@
"name": "workflow_user_id_user_id_fk",
"tableFrom": "workflow",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1594,12 +1508,8 @@
"name": "workflow_workspace_id_workspace_id_fk",
"tableFrom": "workflow",
"tableTo": "workspace",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workspace_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1676,12 +1586,8 @@
"name": "workflow_logs_workflow_id_workflow_id_fk",
"tableFrom": "workflow_logs",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1760,12 +1666,8 @@
"name": "workflow_schedule_workflow_id_workflow_id_fk",
"tableFrom": "workflow_schedule",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1775,9 +1677,7 @@
"workflow_schedule_workflow_id_unique": {
"name": "workflow_schedule_workflow_id_unique",
"nullsNotDistinct": false,
"columns": [
"workflow_id"
]
"columns": ["workflow_id"]
}
},
"policies": {},
@@ -1827,12 +1727,8 @@
"name": "workspace_owner_id_user_id_fk",
"tableFrom": "workspace",
"tableTo": "user",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1918,12 +1814,8 @@
"name": "workspace_invitation_workspace_id_workspace_id_fk",
"tableFrom": "workspace_invitation",
"tableTo": "workspace",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workspace_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1931,12 +1823,8 @@
"name": "workspace_invitation_inviter_id_user_id_fk",
"tableFrom": "workspace_invitation",
"tableTo": "user",
"columnsFrom": [
"inviter_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["inviter_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1946,9 +1834,7 @@
"workspace_invitation_token_unique": {
"name": "workspace_invitation_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@@ -2027,12 +1913,8 @@
"name": "workspace_member_workspace_id_workspace_id_fk",
"tableFrom": "workspace_member",
"tableTo": "workspace",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workspace_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -2040,12 +1922,8 @@
"name": "workspace_member_user_id_user_id_fk",
"tableFrom": "workspace_member",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2068,4 +1946,4 @@
"schemas": {},
"tables": {}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -253,6 +253,13 @@
"when": 1747041949354,
"tag": "0035_slim_energizer",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1747265680027,
"tag": "0036_married_skreet",
"breakpoints": true
}
]
}
}

View File

@@ -3,7 +3,6 @@ import {
decimal,
integer,
json,
jsonb,
pgTable,
text,
timestamp,
@@ -224,6 +223,7 @@ export const userStats = pgTable('user_stats', {
totalApiCalls: integer('total_api_calls').notNull().default(0),
totalWebhookTriggers: integer('total_webhook_triggers').notNull().default(0),
totalScheduledExecutions: integer('total_scheduled_executions').notNull().default(0),
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
totalTokensUsed: integer('total_tokens_used').notNull().default(0),
totalCost: decimal('total_cost').notNull().default('0'),
lastActive: timestamp('last_active').notNull().defaultNow(),
@@ -254,6 +254,7 @@ export const subscription = pgTable('subscription', {
seats: integer('seats'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end'),
metadata: json('metadata'),
})
export const chat = pgTable(
@@ -296,7 +297,7 @@ export const organization = pgTable('organization', {
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
metadata: jsonb('metadata'),
metadata: json('metadata'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
@@ -375,4 +376,4 @@ export const workspaceInvitation = pgTable('workspace_invitation', {
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
})

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
interface UserSubscription {
isPaid: boolean
isLoading: boolean
plan: string | null
error: Error | null
isEnterprise: boolean
}
export function useUserSubscription(): UserSubscription {
const [subscription, setSubscription] = useState<UserSubscription>({
isPaid: false,
isLoading: true,
plan: null,
error: null,
isEnterprise: false,
})
useEffect(() => {
let mounted = true
const fetchSubscription = async () => {
try {
const response = await fetch('/api/user/subscription')
if (!response.ok) {
throw new Error('Failed to fetch subscription data')
}
const data = await response.json()
if (mounted) {
setSubscription({
isPaid: data.isPaid,
isLoading: false,
plan: data.plan,
error: null,
isEnterprise: !!data.isEnterprise,
})
}
} catch (error) {
if (mounted) {
setSubscription({
isPaid: false,
isLoading: false,
plan: null,
error: error instanceof Error ? error : new Error('Unknown error'),
isEnterprise: false,
})
}
}
}
fetchSubscription()
return () => {
mounted = false
}
}, [])
return subscription
}

View File

@@ -79,18 +79,18 @@ export const auth = betterAuth({
.from(schema.member)
.where(eq(schema.member.userId, session.userId))
.limit(1)
if (members.length > 0) {
logger.info('Found organization for user', {
userId: session.userId,
organizationId: members[0].organizationId
logger.info('Found organization for user', {
userId: session.userId,
organizationId: members[0].organizationId,
})
return {
data: {
...session,
activeOrganizationId: members[0].organizationId
}
activeOrganizationId: members[0].organizationId,
},
}
} else {
logger.info('No organizations found for user', { userId: session.userId })
@@ -537,13 +537,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://discord.com/api/oauth2/authorize',
tokenUrl: 'https://discord.com/api/oauth2/token',
userInfoUrl: 'https://discord.com/api/users/@me',
scopes: [
'identify',
'bot',
'messages.read',
'guilds',
'guilds.members.read',
],
scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -572,7 +566,9 @@ export const auth = betterAuth({
id: profile.id,
name: profile.username || 'Discord User',
email: profile.email || `${profile.id}@discord.user`,
image: profile.avatar ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` : null,
image: profile.avatar
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
: null,
emailVerified: profile.verified || false,
createdAt: now,
updatedAt: now,

View File

@@ -682,6 +682,7 @@ export async function persistExecutionLogs(
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: totalTokens,
totalCost: costToStore.toString(),
lastActive: new Date(),

View File

@@ -1,347 +0,0 @@
import { eq } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { client } from './auth-client'
const logger = createLogger('Subscription')
/**
* Check if the user is on the Pro plan
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
// In development, enable Pro features for easier testing
if (!isProd) {
return true
}
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Pro or Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasProPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && (sub.plan === 'pro' || sub.plan === 'team')
)
if (orgHasProPlan) {
logger.info('User has pro plan via organization', {
userId,
orgId: membership.organizationId,
})
return true
}
}
// If no org subscriptions, check direct subscriptions
const directSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active pro subscription (either Pro or Team plan)
const hasDirectProPlan = directSubscriptions.some(
(sub) => sub.status === 'active' && (sub.plan === 'pro' || sub.plan === 'team')
)
if (hasDirectProPlan) {
logger.info('User has direct pro plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
/**
* Check if the user is on the Team plan
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
// In development, enable Team features for easier testing
if (!isProd) {
return true
}
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasTeamPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (orgHasTeamPlan) {
return true
}
}
// If no org subscriptions found, check direct subscriptions
const directSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active team subscription
const hasDirectTeamPlan = directSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (hasDirectTeamPlan) {
logger.info('User has direct team plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
/**
* Check if a user has exceeded their cost limit based on their subscription plan
*/
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
// In development, users never exceed their limit
if (!isProd) {
return false
}
// Get user's direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
// Find active direct subscription
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// Get organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
let highestCostLimit = 0
// Check cost limit from direct subscription
if (activeDirectSubscription && typeof activeDirectSubscription.limits?.cost === 'number') {
highestCostLimit = activeDirectSubscription.limits.cost
}
// Check cost limits from organization subscriptions
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (
activeOrgSubscription &&
typeof activeOrgSubscription.limits?.cost === 'number' &&
activeOrgSubscription.limits.cost > highestCostLimit
) {
highestCostLimit = activeOrgSubscription.limits.cost
}
}
// If no subscription found, use default free tier limit
if (highestCostLimit === 0) {
highestCostLimit = process.env.FREE_TIER_COST_LIMIT
? parseFloat(process.env.FREE_TIER_COST_LIMIT)
: 5
}
logger.info('User cost limit from subscription', { userId, costLimit: highestCostLimit })
// Get user's actual usage from the database
const statsRecords = await db
.select()
.from(schema.userStats)
.where(eq(schema.userStats.userId, userId))
if (statsRecords.length === 0) {
// No usage yet, so they haven't exceeded the limit
return false
}
// Get the current cost and compare with the limit
const currentCost = parseFloat(statsRecords[0].totalCost.toString())
return currentCost >= highestCostLimit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if a user is allowed to share workflows based on their subscription plan
*/
export async function isSharingEnabled(userId: string): Promise<boolean> {
try {
// In development, always allow sharing
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct pro/team subscription with sharing enabled
if (activeDirectSubscription && activeDirectSubscription.limits?.sharingEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with sharing enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.sharingEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking sharing permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if multiplayer collaboration is enabled for the user
*/
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable multiplayer
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct team subscription with multiplayer enabled
if (activeDirectSubscription && activeDirectSubscription.limits?.multiplayerEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with multiplayer enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.multiplayerEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking multiplayer permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if workspace collaboration is enabled for the user
*/
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable workspace collaboration
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct team subscription with workspace collaboration enabled
if (
activeDirectSubscription &&
activeDirectSubscription.limits?.workspaceCollaborationEnabled
) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with workspace collaboration enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.workspaceCollaborationEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking workspace collaboration permission', { error, userId })
return false // Be conservative in case of error
}
}

View File

@@ -0,0 +1,346 @@
import { and, eq, inArray } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { client } from '../auth-client'
import { calculateUsageLimit, checkEnterprisePlan, checkProPlan, checkTeamPlan } from './utils'
const logger = createLogger('Subscription')
export async function isProPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectProPlan = directSubscriptions.some(checkProPlan)
if (hasDirectProPlan) {
logger.info('User has direct pro plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, membership.organizationId))
const orgHasTeamPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (orgHasTeamPlan) {
return true
}
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectTeamPlan = directSubscriptions.some(checkTeamPlan)
if (hasDirectTeamPlan) {
logger.info('User has direct team plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
export async function isEnterprisePlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, membership.organizationId))
const orgHasEnterprisePlan = orgSubscriptions.some((sub) => checkEnterprisePlan(sub))
if (orgHasEnterprisePlan) {
logger.info('User has enterprise plan via organization', {
userId,
orgId: membership.organizationId,
})
return true
}
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectEnterprisePlan = directSubscriptions.some(checkEnterprisePlan)
if (hasDirectEnterprisePlan) {
logger.info('User has direct enterprise plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking enterprise plan status', { error, userId })
return false
}
}
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
if (!isProd) {
return false
}
let activeSubscription = null
const userSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
if (userSubscriptions.length > 0) {
const enterpriseSub = userSubscriptions.find(checkEnterprisePlan)
const teamSub = userSubscriptions.find(checkTeamPlan)
const proSub = userSubscriptions.find(checkProPlan)
activeSubscription = enterpriseSub || teamSub || proSub || null
}
if (!activeSubscription) {
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgId = membership.organizationId
const orgSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, orgId), eq(subscription.status, 'active')))
if (orgSubscriptions.length > 0) {
const orgEnterpriseSub = orgSubscriptions.find(checkEnterprisePlan)
const orgTeamSub = orgSubscriptions.find(checkTeamPlan)
const orgProSub = orgSubscriptions.find(checkProPlan)
activeSubscription = orgEnterpriseSub || orgTeamSub || orgProSub || null
if (activeSubscription) break
}
}
}
let limit = 0
if (activeSubscription) {
limit = calculateUsageLimit(activeSubscription)
logger.info('Using calculated subscription limit', {
userId,
plan: activeSubscription.plan,
seats: activeSubscription.seats || 1,
limit,
})
} else {
limit = process.env.FREE_TIER_COST_LIMIT ? parseFloat(process.env.FREE_TIER_COST_LIMIT) : 5
logger.info('Using free tier limit', { userId, limit })
}
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
if (statsRecords.length === 0) {
return false
}
const currentCost = parseFloat(statsRecords[0].totalCost.toString())
logger.info('Checking cost limit', { userId, currentCost, limit })
return currentCost >= limit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
}
}
export async function isSharingEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (activeDirectSubscription && activeDirectSubscription.limits?.sharingEnabled) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.sharingEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking sharing permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (activeDirectSubscription && activeDirectSubscription.limits?.multiplayerEnabled) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.multiplayerEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking multiplayer permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (
activeDirectSubscription &&
activeDirectSubscription.limits?.workspaceCollaborationEnabled
) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
// Check each organization for a subscription with workspace collaboration enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription && activeOrgSubscription.limits?.workspaceCollaborationEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking workspace collaboration permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function getHighestPrioritySubscription(userId: string) {
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
let orgSubs: any[] = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
}

View File

@@ -0,0 +1,78 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { calculateUsageLimit, checkEnterprisePlan } from './utils'
const ORIGINAL_ENV = { ...process.env }
beforeAll(() => {
process.env.FREE_TIER_COST_LIMIT = '5'
process.env.PRO_TIER_COST_LIMIT = '20'
process.env.TEAM_TIER_COST_LIMIT = '40'
process.env.ENTERPRISE_TIER_COST_LIMIT = '200'
})
afterAll(() => {
process.env = ORIGINAL_ENV
})
describe('Subscription Utilities', () => {
describe('checkEnterprisePlan', () => {
it('returns true for active enterprise subscription', () => {
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'active' })).toBeTruthy()
})
it('returns false for inactive enterprise subscription', () => {
expect(checkEnterprisePlan({ plan: 'enterprise', status: 'canceled' })).toBeFalsy()
})
it('returns false when plan is not enterprise', () => {
expect(checkEnterprisePlan({ plan: 'pro', status: 'active' })).toBeFalsy()
})
})
describe('calculateUsageLimit', () => {
it('returns free-tier limit when subscription is null', () => {
expect(calculateUsageLimit(null)).toBe(5)
})
it('returns free-tier limit when subscription is undefined', () => {
expect(calculateUsageLimit(undefined)).toBe(5)
})
it('returns free-tier limit when subscription is not active', () => {
expect(calculateUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(5)
})
it('returns pro limit for active pro plan', () => {
expect(calculateUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
})
it('returns team limit multiplied by seats', () => {
expect(calculateUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
})
it('returns enterprise limit using perSeatAllowance metadata', () => {
const sub = {
plan: 'enterprise',
status: 'active',
seats: 10,
metadata: { perSeatAllowance: '150' },
}
expect(calculateUsageLimit(sub)).toBe(10 * 150)
})
it('returns enterprise limit using totalAllowance metadata', () => {
const sub = {
plan: 'enterprise',
status: 'active',
seats: 8,
metadata: { totalAllowance: '5000' },
}
expect(calculateUsageLimit(sub)).toBe(5000)
})
it('falls back to default enterprise tier when metadata missing', () => {
const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} }
expect(calculateUsageLimit(sub)).toBe(2 * 200)
})
})
})

View File

@@ -0,0 +1,44 @@
export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
}
export function checkProPlan(subscription: any): boolean {
return subscription?.plan === 'pro' && subscription?.status === 'active'
}
export function checkTeamPlan(subscription: any): boolean {
return subscription?.plan === 'team' && subscription?.status === 'active'
}
/**
* Calculate usage limit for a subscription based on its type and metadata
* @param subscription The subscription object
* @returns The calculated usage limit in dollars
*/
export function calculateUsageLimit(subscription: any): number {
if (!subscription || subscription.status !== 'active') {
return parseFloat(process.env.FREE_TIER_COST_LIMIT!)
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return parseFloat(process.env.PRO_TIER_COST_LIMIT!)
} else if (subscription.plan === 'team') {
return seats * parseFloat(process.env.TEAM_TIER_COST_LIMIT!)
} else if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata || {}
if (metadata.perSeatAllowance) {
return seats * parseFloat(metadata.perSeatAllowance)
}
if (metadata.totalAllowance) {
return parseFloat(metadata.totalAllowance)
}
return seats * parseFloat(process.env.ENTERPRISE_TIER_COST_LIMIT!)
}
return parseFloat(process.env.FREE_TIER_COST_LIMIT!)
}

View File

@@ -1,9 +1,10 @@
import { eq } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { db } from '@/db'
import { member, organization as organizationTable, subscription, userStats } from '@/db/schema'
import { userStats } from '@/db/schema'
import { createLogger } from './logs/console-logger'
import { isProPlan, isTeamPlan } from './subscription'
import { getHighestPrioritySubscription } from './subscription/subscription'
import { calculateUsageLimit } from './subscription/utils'
const logger = createLogger('UsageMonitor')
@@ -18,65 +19,6 @@ interface UsageData {
limit: number
}
/**
* Gets the number of seats for a team subscription
* Used to calculate usage limits for team plans
*/
async function getTeamSeats(userId: string): Promise<number> {
try {
// First check if user is part of an organization with a team subscription
const memberships = await db.select().from(member).where(eq(member.userId, userId)).limit(1)
if (memberships.length > 0) {
const orgId = memberships[0].organizationId
// Check for organization's team subscription
const orgSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, orgId))
const teamSubscription = orgSubscriptions.find(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (teamSubscription?.seats) {
logger.info('Found organization team subscription with seats', {
userId,
orgId,
seats: teamSubscription.seats,
})
return teamSubscription.seats
}
}
// If no organization team subscription, check for personal team subscription
const userSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const teamSubscription = userSubscriptions.find(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (teamSubscription?.seats) {
logger.info('Found personal team subscription with seats', {
userId,
seats: teamSubscription.seats,
})
return teamSubscription.seats
}
// Default to 10 seats if we know they're on a team plan but couldn't get seats info
return 10
} catch (error) {
logger.error('Error getting team seats', { error, userId })
// Default to 10 seats on error
return 10
}
}
/**
* Checks a user's cost usage against their subscription plan limit
* and returns usage information including whether they're approaching the limit
@@ -99,41 +41,21 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
}
}
// Production environment - check real subscription limits
// Determine subscription (single source of truth)
let activeSubscription = await getHighestPrioritySubscription(userId)
let limit = 0
// Get user's subscription details
const isPro = await isProPlan(userId)
const isTeam = await isTeamPlan(userId)
logger.info('User subscription status', { userId, isPro, isTeam })
// Determine the limit based on subscription type
let limit: number
if (isTeam) {
// For team plans, get the number of seats and multiply by per-seat limit
const teamSeats = await getTeamSeats(userId)
const perSeatLimit = process.env.TEAM_TIER_COST_LIMIT
? parseFloat(process.env.TEAM_TIER_COST_LIMIT)
: 40
limit = perSeatLimit * teamSeats
logger.info('Using team plan limit', {
if (activeSubscription) {
limit = calculateUsageLimit(activeSubscription)
logger.info('Using calculated subscription limit', {
userId,
seats: teamSeats,
perSeatLimit,
totalLimit: limit,
plan: activeSubscription.plan,
seats: activeSubscription.seats || 1,
limit,
})
} else if (isPro) {
// Pro plan has a fixed limit
limit = process.env.PRO_TIER_COST_LIMIT ? parseFloat(process.env.PRO_TIER_COST_LIMIT) : 20
logger.info('Using pro plan limit', { userId, limit })
} else {
// Free tier limit
limit = process.env.FREE_TIER_COST_LIMIT ? parseFloat(process.env.FREE_TIER_COST_LIMIT) : 5
limit = parseFloat(process.env.FREE_TIER_COST_LIMIT!)
logger.info('Using free tier limit', { userId, limit })
}

View File

@@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray, like, or, SQL } from 'drizzle-orm'
import { and, count, desc, eq, inArray, like, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import {
getEmailSubject,
@@ -98,31 +98,23 @@ export async function getWaitlistEntries(
// First, determine if we need to apply status filter
const shouldFilterByStatus = status && status !== 'all'
console.log('Service: Filtering by status:', shouldFilterByStatus ? status : 'No status filter')
// Now build the conditions
if (shouldFilterByStatus && search && search.trim()) {
// Both status and search
console.log('Service: Applying status + search filter:', status)
whereCondition = and(
eq(waitlist.status, status as string),
like(waitlist.email, `%${search.trim()}%`)
)
} else if (shouldFilterByStatus) {
// Only status
console.log('Service: Applying status filter only:', status)
whereCondition = eq(waitlist.status, status as string)
} else if (search && search.trim()) {
// Only search
console.log('Service: Applying search filter only')
whereCondition = like(waitlist.email, `%${search.trim()}%`)
} else {
console.log('Service: No filters applied, showing all entries')
whereCondition = null
}
// Log what filter is being applied
console.log('Service: Where condition:', whereCondition ? 'applied' : 'none')
// Get entries with conditions
let entries = []
if (whereCondition) {
@@ -151,10 +143,6 @@ export async function getWaitlistEntries(
countResult = await db.select({ value: count() }).from(waitlist)
}
console.log(
`Service: Found ${entries.length} entries with ${status === 'all' ? 'all statuses' : `status=${status}`}, total: ${countResult[0]?.value || 0}`
)
return {
entries,
total: countResult[0]?.value || 0,

View File

@@ -65,6 +65,7 @@ export async function updateWorkflowRunCounts(workflowId: string, runs: number =
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: new Date(),

View File

@@ -7,6 +7,10 @@
{
"path": "/api/webhooks/poll/gmail",
"schedule": "*/1 * * * *"
},
{
"path": "/api/logs/cleanup",
"schedule": "0 0 * * *"
}
]
}