mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
•
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
77
apps/sim/app/api/logs/cleanup/route.ts
Normal file
77
apps/sim/app/api/logs/cleanup/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
78
apps/sim/app/api/user/subscription/enterprise/route.ts
Normal file
78
apps/sim/app/api/user/subscription/enterprise/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
161
apps/sim/app/api/user/subscription/update-seats/route.ts
Normal file
161
apps/sim/app/api/user/subscription/update-seats/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:{' '}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
3
apps/sim/db/migrations/0036_married_skreet.sql
Normal file
3
apps/sim/db/migrations/0036_married_skreet.sql
Normal 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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1962
apps/sim/db/migrations/meta/0036_snapshot.json
Normal file
1962
apps/sim/db/migrations/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1747041949354,
|
||||
"tag": "0035_slim_energizer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1747265680027,
|
||||
"tag": "0036_married_skreet",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
63
apps/sim/hooks/use-user-subscription.ts
Normal file
63
apps/sim/hooks/use-user-subscription.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -682,6 +682,7 @@ export async function persistExecutionLogs(
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
lastActive: new Date(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
346
apps/sim/lib/subscription/subscription.ts
Normal file
346
apps/sim/lib/subscription/subscription.ts
Normal 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
|
||||
}
|
||||
78
apps/sim/lib/subscription/utils.test.ts
Normal file
78
apps/sim/lib/subscription/utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
44
apps/sim/lib/subscription/utils.ts
Normal file
44
apps/sim/lib/subscription/utils.ts
Normal 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!)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
{
|
||||
"path": "/api/webhooks/poll/gmail",
|
||||
"schedule": "*/1 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/logs/cleanup",
|
||||
"schedule": "0 0 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user