improvement(waitlist): added batch send requests, fixed UI (#305)

* improvement(waitlist): added batch send requests, fixed UI

* updated package lock

* added protection against rate limts for resend with a delay + exponential backoff
This commit is contained in:
Waleed Latif
2025-04-27 18:06:50 -07:00
committed by GitHub
parent 08c4dc9141
commit 22036b0b02
15 changed files with 1923 additions and 1009 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Metadata } from 'next'
import PasswordAuth from '../password-auth'
import { WaitlistTable } from './waitlist-table'
import { WaitlistTable } from './waitlist'
export const metadata: Metadata = {
title: 'Waitlist Management | Sim Studio',
@@ -10,15 +10,15 @@ export const metadata: Metadata = {
export default function WaitlistPage() {
return (
<PasswordAuth>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 md:px-8 py-10">
<div className="mb-8 px-1">
<h1 className="text-3xl font-bold tracking-tight">Waitlist Management</h1>
<p className="text-muted-foreground mt-2">
<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-none shadow-md bg-white dark:bg-gray-950 rounded-md">
<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>

View File

@@ -1,812 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import {
AlertCircleIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
InfoIcon,
MailIcon,
RotateCcwIcon,
SearchIcon,
UserCheckIcon,
UserIcon,
UserXIcon,
XIcon,
} from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Logger } from '@/lib/logs/console-logger'
import { useWaitlistStore } from './stores/store'
const logger = new Logger('WaitlistTable')
interface FilterButtonProps {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
className?: string
}
// Alert types for more specific error display
type AlertType = 'error' | 'email-error' | 'rate-limit' | null
// Filter button component
const FilterButton = ({ active, onClick, icon, label, className }: FilterButtonProps) => (
<Button
variant={active ? 'default' : 'ghost'}
size="sm"
onClick={onClick}
className={`flex items-center gap-2 ${className || ''}`}
>
{icon}
<span>{label}</span>
</Button>
)
export function WaitlistTable() {
const router = useRouter()
const searchParams = useSearchParams()
// Get all values from the store
const {
entries,
filteredEntries,
status,
searchTerm,
page,
totalEntries,
loading,
error,
actionLoading,
setStatus,
setSearchTerm,
setPage,
setActionLoading,
setError,
fetchEntries,
} = useWaitlistStore()
// Local state for search input with debounce
const [searchInputValue, setSearchInputValue] = useState(searchTerm)
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 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 search input change with debounce
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(() => {
setSearchTerm(value)
}, 500) // 500ms debounce
}
// 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',
})
}
// 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-6 w-full">
{/* Filter bar - similar to logs.tsx */}
<div className="border-b px-6">
<div className="flex flex-wrap items-center gap-2 py-3">
<div className="flex justify-between items-center">
{/* Filter buttons */}
<div className="flex items-center gap-2 overflow-x-auto">
<FilterButton
active={status === 'all'}
onClick={() => handleStatusChange('all')}
icon={<UserIcon className="h-4 w-4" />}
label="All"
className={
status === 'all'
? 'bg-blue-100 text-blue-900 hover:bg-blue-200 hover:text-blue-900'
: ''
}
/>
<FilterButton
active={status === 'pending'}
onClick={() => handleStatusChange('pending')}
icon={<UserIcon className="h-4 w-4" />}
label="Pending"
className={
status === 'pending'
? 'bg-amber-100 text-amber-900 hover:bg-amber-200 hover:text-amber-900'
: ''
}
/>
<FilterButton
active={status === 'approved'}
onClick={() => handleStatusChange('approved')}
icon={<UserCheckIcon className="h-4 w-4" />}
label="Approved"
className={
status === 'approved'
? 'bg-green-100 text-green-900 hover:bg-green-200 hover:text-green-900'
: ''
}
/>
<FilterButton
active={status === 'rejected'}
onClick={() => handleStatusChange('rejected')}
icon={<UserXIcon className="h-4 w-4" />}
label="Rejected"
className={
status === 'rejected'
? 'bg-red-100 text-red-900 hover:bg-red-200 hover:text-red-900'
: ''
}
/>
<FilterButton
active={status === 'signed_up'}
onClick={() => handleStatusChange('signed_up')}
icon={<CheckIcon className="h-4 w-4" />}
label="Signed Up"
className={
status === 'signed_up'
? 'bg-purple-100 text-purple-900 hover:bg-purple-200 hover:text-purple-900'
: ''
}
/>
</div>
</div>
</div>
</div>
{/* Search and refresh bar */}
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-4 md:items-center px-6">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search by email..."
value={searchInputValue}
onChange={handleSearchChange}
className="w-full pl-10"
disabled={loading}
/>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
className="h-9 w-9"
>
<RotateCcwIcon className={`h-4 w-4 ${loading ? 'animate-spin text-blue-500' : ''}`} />
</Button>
</div>
</div>
{/* Enhanced Alert system */}
{alertInfo.type && (
<Alert
variant={
alertInfo.type === 'error'
? 'destructive'
: alertInfo.type === 'email-error'
? 'destructive'
: alertInfo.type === 'rate-limit'
? 'default'
: 'default'
}
className="mx-6 w-auto"
>
<AlertCircleIcon className="h-4 w-4" />
<AlertTitle className="ml-2">
{alertInfo.type === 'email-error'
? 'Email Delivery Failed'
: alertInfo.type === 'rate-limit'
? 'Rate Limit Exceeded'
: 'Error'}
</AlertTitle>
<AlertDescription className="ml-2 flex items-center justify-between">
<span>{alertInfo.message}</span>
<Button
onClick={() => setAlertInfo({ type: null, message: '' })}
variant="outline"
size="sm"
className="ml-4"
>
Dismiss
</Button>
</AlertDescription>
</Alert>
)}
{/* Original error alert - kept for backward compatibility */}
{error && !alertInfo.type && (
<Alert variant="destructive" className="mx-6 w-auto">
<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>
)}
{/* Loading skeleton */}
{loading ? (
<div className="space-y-4 px-6">
<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 mx-6 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 */}
<div className="rounded-md border mx-6 overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[180px]">Email</TableHead>
<TableHead className="min-w-[150px]">Joined</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEntries.map((entry) => (
<TableRow
key={entry.id}
className={`hover:bg-muted/30 ${
alertInfo.entryId === entry.id && alertInfo.type
? alertInfo.type === 'error' || alertInfo.type === 'email-error'
? 'bg-red-50'
: 'bg-amber-50'
: ''
}`}
>
<TableCell className="font-medium">{entry.email}</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{formatDate(entry.createdAt)}</span>
</TooltipTrigger>
<TooltipContent>{getDetailedTimeTooltip(entry.createdAt)}</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>
{/* Status badge */}
<div className="py-2.5 px-4">
{entry.status === 'pending' && (
<Badge
variant="outline"
className="bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200"
>
<InfoIcon className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
{entry.status === 'approved' && (
<Badge
variant="outline"
className="bg-green-100 text-green-800 border border-green-200 hover:bg-green-200"
>
<UserCheckIcon className="mr-1 h-3 w-3" />
Approved
</Badge>
)}
{entry.status === 'rejected' && (
<Badge
variant="outline"
className="bg-red-100 text-red-800 border border-red-200 hover:bg-red-200"
>
<UserXIcon className="mr-1 h-3 w-3" />
Rejected
</Badge>
)}
{entry.status === 'signed_up' && (
<Badge
variant="outline"
className="bg-purple-100 text-purple-800 border border-purple-200 hover:bg-purple-200"
>
<CheckIcon className="mr-1 h-3 w-3" />
Signed Up
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
{entry.status !== 'approved' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={() => handleApprove(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-green-500 hover:text-green-600"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-4 w-4 animate-spin" />
) : (
<CheckIcon className="h-4 w-4 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={() => handleResendApproval(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-blue-500 hover:text-blue-600"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-4 w-4 animate-spin" />
) : (
<>
<MailIcon className="h-4 w-4 mr-1" />
Resend Approval
</>
)}
</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={() => handleReject(entry.email, entry.id)}
disabled={actionLoading === entry.id}
className="hover:border-red-500 hover:text-red-600"
>
{actionLoading === entry.id ? (
<RotateCcwIcon className="h-4 w-4 animate-spin" />
) : (
<XIcon className="h-4 w-4 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"
>
<MailIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Email user in Gmail</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!searchTerm && (
<div className="flex items-center justify-center gap-2 mx-6 my-4 pb-2">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={handleFirstPage}
disabled={page === 1 || loading}
title="First Page"
>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={page === 1 || loading}
>
<ChevronLeftIcon className="h-4 w-4" />
<span className="ml-1">Prev</span>
</Button>
</div>
<span className="text-sm text-muted-foreground mx-2">
Page {page} of {Math.ceil(totalEntries / 50) || 1}
&nbsp;&nbsp;
{totalEntries} total entries
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page >= Math.ceil(totalEntries / 50) || loading}
>
<span className="mr-1">Next</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleLastPage}
disabled={page >= Math.ceil(totalEntries / 50) || loading}
title="Last Page"
>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}

View File

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

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { Logger } from '@/lib/logs/console-logger'
import {
approveWaitlistUser,
approveBatchWaitlistUsers,
getWaitlistEntries,
rejectWaitlistUser,
resendApprovalEmail
@@ -24,6 +25,12 @@ const actionSchema = z.object({
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 || ''
@@ -149,265 +156,321 @@ export async function POST(request: NextRequest) {
// Parse request body
const body = await request.json()
// Validate request
const validatedData = actionSchema.safeParse(body)
// 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 request',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
if (!validatedData.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid batch request',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
const { email, action } = validatedData.data
let result: any
// Perform the requested action
if (action === 'approve') {
const { emails } = validatedData.data
logger.info(`Processing batch approval for ${emails.length} emails`)
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 }
)
}
const result = await approveBatchWaitlistUsers(emails)
// Check for rate limiting
if (!result.success && result?.rateLimited) {
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
message: 'Rate limit exceeded for email sending. Users were NOT approved.',
rateLimited: true,
results: result.results
},
{ status: 429 }
)
}
// General failure
if (!result.success) {
return NextResponse.json(
{
success: false,
message: result.message || 'Failed to approve user'
},
{ status: 400 }
)
}
// Return the result, even if partially successful
return NextResponse.json({
success: result.success,
message: result.message,
results: result.results
})
} 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'))) {
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)
// Handle rate limiting specifically
if (detectResendRateLimitError(error)) {
// 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,
emailError: true
rateLimited: 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)) {
// General failure
if (!result.success) {
return NextResponse.json(
{
success: false,
message: 'Rate limit exceeded for email sending.',
rateLimited: true,
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: 429 }
{ status: 500 }
)
}
return NextResponse.json(
{
success: false,
message: `Email delivery failed: ${result.message || 'Unknown email error'}`,
emailError: true
message: error instanceof Error ? error.message : 'Failed to approve user',
},
{ status: 500 }
)
}
// Check for rate limiting
if (!result.success && result?.rateLimited) {
logger.warn('Rate limit reached for email sending')
} else if (action === 'reject') {
try {
result = await rejectWaitlistUser(email)
} catch (error) {
logger.error('Error rejecting waitlist user:', error)
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.',
message: error instanceof Error ? error.message : 'Failed to reject user',
},
{ status: 500 }
)
}
// Handle Resend API errors specifically
if (error instanceof Error &&
(error.message.includes('email') ||
error.message.includes('resend'))) {
} else if (action === 'resend') {
try {
result = await resendApprovalEmail(email)
// Handle rate limiting specifically
if (detectResendRateLimitError(error)) {
// 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,
emailError: true
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: `Email delivery failed: ${error.message}`,
emailError: true
message: error instanceof Error ? error.message : 'Failed to resend approval email',
},
{ status: 500 }
)
}
}
if (!result || !result.success) {
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : 'Failed to resend approval email',
message: result?.message || 'Failed to perform action',
},
{ status: 500 }
{ status: 400 }
)
}
}
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,
})
}
return NextResponse.json({
success: true,
message: result.message,
})
} catch (error) {
logger.error('Admin waitlist API error:', error)

View File

@@ -8,12 +8,23 @@ interface EmailOptions {
from?: string
}
interface BatchEmailOptions {
emails: EmailOptions[]
}
interface SendEmailResult {
success: boolean
message: string
data?: any
}
interface BatchSendEmailResult {
success: boolean
message: string
results: SendEmailResult[]
data?: any
}
const logger = createLogger('Mailer')
const resendApiKey = process.env.RESEND_API_KEY
@@ -72,3 +83,188 @@ export async function sendEmail({
}
}
}
export async function sendBatchEmails({
emails,
}: BatchEmailOptions): Promise<BatchSendEmailResult> {
try {
const senderEmail = 'noreply@simstudio.ai'
const results: SendEmailResult[] = []
if (!resend) {
logger.info('Batch emails not sent (Resend not configured):', {
emailCount: emails.length,
})
// Create mock results for each email
emails.forEach(() => {
results.push({
success: true,
message: 'Email logging successful (Resend not configured)',
data: { id: 'mock-email-id' },
})
})
return {
success: true,
message: 'Batch email logging successful (Resend not configured)',
results,
data: { ids: Array(emails.length).fill('mock-email-id') },
}
}
// Prepare emails for batch sending
const batchEmails = emails.map(email => ({
from: `Sim Studio <${email.from || senderEmail}>`,
to: email.to,
subject: email.subject,
html: email.html,
}))
// Send batch emails (maximum 100 per batch as per Resend API limits)
// Process in chunks of 50 to be safe
const BATCH_SIZE = 50
let allSuccessful = true
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
let rateDelay = 500
for (let i = 0; i < batchEmails.length; i += BATCH_SIZE) {
if (i > 0) {
logger.info(`Rate limit protection: Waiting ${rateDelay}ms before sending next batch`)
await delay(rateDelay)
}
const batch = batchEmails.slice(i, i + BATCH_SIZE)
try {
logger.info(`Sending batch ${Math.floor(i/BATCH_SIZE) + 1} of ${Math.ceil(batchEmails.length/BATCH_SIZE)} (${batch.length} emails)`)
const response = await resend.batch.send(batch)
if (response.error) {
logger.error('Resend batch API error:', response.error)
// Add failure results for this batch
batch.forEach(() => {
results.push({
success: false,
message: response.error?.message || 'Failed to send batch email',
})
})
allSuccessful = false
} else if (response.data) {
if (Array.isArray(response.data)) {
response.data.forEach((item: { id: string }) => {
results.push({
success: true,
message: 'Email sent successfully',
data: item,
})
})
} else {
logger.info('Resend batch API returned unexpected format, assuming success')
batch.forEach((_, index) => {
results.push({
success: true,
message: 'Email sent successfully',
data: { id: `batch-${i}-item-${index}` },
})
})
}
}
} catch (error) {
logger.error('Error sending batch emails:', error)
// Check if it's a rate limit error
if (error instanceof Error &&
(error.message.toLowerCase().includes('rate') ||
error.message.toLowerCase().includes('too many') ||
error.message.toLowerCase().includes('429'))) {
logger.warn('Rate limit exceeded, increasing delay and retrying...')
// Wait a bit longer and try again with this batch
await delay(rateDelay * 5)
try {
logger.info(`Retrying batch ${Math.floor(i/BATCH_SIZE) + 1} with longer delay`)
const retryResponse = await resend.batch.send(batch)
if (retryResponse.error) {
logger.error('Retry failed with error:', retryResponse.error)
batch.forEach(() => {
results.push({
success: false,
message: retryResponse.error?.message || 'Failed to send batch email after retry',
})
})
allSuccessful = false
} else if (retryResponse.data) {
if (Array.isArray(retryResponse.data)) {
retryResponse.data.forEach((item: { id: string }) => {
results.push({
success: true,
message: 'Email sent successfully on retry',
data: item,
})
})
} else {
batch.forEach((_, index) => {
results.push({
success: true,
message: 'Email sent successfully on retry',
data: { id: `retry-batch-${i}-item-${index}` },
})
})
}
// Increase the standard delay since we hit a rate limit
logger.info('Increasing delay between batches after rate limit hit')
rateDelay = rateDelay * 2
}
} catch (retryError) {
logger.error('Retry also failed:', retryError)
batch.forEach(() => {
results.push({
success: false,
message: retryError instanceof Error ? retryError.message : 'Failed to send email even after retry',
})
})
allSuccessful = false
}
} else {
// Non-rate limit error
batch.forEach(() => {
results.push({
success: false,
message: error instanceof Error ? error.message : 'Failed to send batch email',
})
})
allSuccessful = false
}
}
}
return {
success: allSuccessful,
message: allSuccessful
? 'All batch emails sent successfully'
: 'Some batch emails failed to send',
results,
data: { count: results.filter(r => r.success).length },
}
} catch (error) {
logger.error('Error in batch email sending:', error)
return {
success: false,
message: 'Failed to send batch emails',
results: [],
}
}
}

View File

@@ -1,11 +1,11 @@
import { and, count, desc, eq, like, or, SQL } from 'drizzle-orm'
import { and, count, desc, eq, like, or, SQL, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import {
getEmailSubject,
renderWaitlistApprovalEmail,
renderWaitlistConfirmationEmail,
} from '@/components/emails/render-email'
import { sendEmail } from '@/lib/mailer'
import { sendEmail, sendBatchEmails } from '@/lib/mailer'
import { createToken, verifyToken } from '@/lib/waitlist/token'
import { db } from '@/db'
import { waitlist } from '@/db/schema'
@@ -491,3 +491,159 @@ export async function resendApprovalEmail(
}
}
}
// Approve multiple users from the waitlist and send approval emails in batches
export async function approveBatchWaitlistUsers(
emails: string[]
): Promise<{
success: boolean
message: string
results: Array<{ email: string, success: boolean, message: string }>
emailErrors?: any
rateLimited?: boolean
}> {
try {
if (!emails || emails.length === 0) {
return {
success: false,
message: 'No emails provided for batch approval',
results: [],
}
}
// Fetch all users from the waitlist that match the emails
const normalizedEmails = emails.map(email => email.trim().toLowerCase())
const users = await db
.select()
.from(waitlist)
.where(
and(
inArray(waitlist.email, normalizedEmails),
// Only select users who aren't already approved
or(
eq(waitlist.status, 'pending'),
eq(waitlist.status, 'rejected')
)
)
)
if (users.length === 0) {
return {
success: false,
message: 'No valid users found for approval',
results: emails.map(email => ({
email,
success: false,
message: 'User not found or already approved',
})),
}
}
// Create email options for each user
const emailOptions = await Promise.all(
users.map(async user => {
// Create a special signup token
const token = await createToken({
email: user.email,
type: 'waitlist-approval',
expiresIn: '7d',
})
// Generate signup link with token
const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
// Generate email HTML
const emailHtml = await renderWaitlistApprovalEmail(user.email, signupLink)
const subject = getEmailSubject('waitlist-approval')
return {
to: user.email,
subject,
html: emailHtml,
}
})
)
// Send batch emails
const emailResults = await sendBatchEmails({ emails: emailOptions })
// Process results and update database
const results = users.map((user, index) => {
const emailResult = emailResults.results[index]
if (emailResult?.success) {
// Update user status to approved in database
return {
email: user.email,
success: true,
message: 'User approved and email sent successfully',
data: emailResult.data,
}
} else {
return {
email: user.email,
success: false,
message: emailResult?.message || 'Failed to send approval email',
error: emailResult,
}
}
})
// Update approved users in the database
const successfulEmails = results
.filter(result => result.success)
.map(result => result.email)
if (successfulEmails.length > 0) {
await db
.update(waitlist)
.set({
status: 'approved',
updatedAt: new Date(),
})
.where(
and(
inArray(waitlist.email, successfulEmails),
// Only update users who aren't already approved
or(
eq(waitlist.status, 'pending'),
eq(waitlist.status, 'rejected')
)
)
)
}
// Check if any rate limit errors occurred
const rateLimitError = emailResults.results.some(
(result: { message?: string }) =>
result.message?.toLowerCase().includes('rate') ||
result.message?.toLowerCase().includes('too many') ||
result.message?.toLowerCase().includes('limit')
)
return {
success: successfulEmails.length > 0,
message: successfulEmails.length === users.length
? 'All users approved successfully'
: successfulEmails.length > 0
? 'Some users approved successfully'
: 'Failed to approve any users',
results: results.map(({ email, success, message }: { email: string; success: boolean; message: string }) =>
({ email, success, message })),
emailErrors: emailResults.results.some((r: { success: boolean }) => !r.success),
rateLimited: rateLimitError,
}
} catch (error) {
console.error('Error approving batch waitlist users:', error)
return {
success: false,
message: 'An error occurred while approving users',
results: emails.map(email => ({
email,
success: false,
message: 'Operation failed due to server error',
})),
}
}
}