improvement(admin-waitlist): removed bulk, added reliability and ease of approval

This commit is contained in:
Emir Karabeg
2025-04-17 20:14:29 -07:00
parent df68c6b512
commit 4e08768a6e
5 changed files with 552 additions and 474 deletions

View File

@@ -26,26 +26,18 @@ interface WaitlistState {
page: number
totalEntries: number
// Selection
selectedIds: Set<string>
// Loading states
actionLoading: string | null
bulkActionLoading: boolean
// Actions
setStatus: (status: string) => void
setSearchTerm: (searchTerm: string) => void
setPage: (page: number) => void
toggleSelectEntry: (id: string) => void
selectAll: () => void
deselectAll: () => void
fetchEntries: () => Promise<void>
setEntries: (entries: WaitlistEntry[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setActionLoading: (id: string | null) => void
setBulkActionLoading: (loading: boolean) => void
}
export const useWaitlistStore = create<WaitlistState>((set, get) => ({
@@ -63,12 +55,8 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
page: 1,
totalEntries: 0,
// Selection
selectedIds: new Set<string>(),
// Loading states
actionLoading: null,
bulkActionLoading: false,
// Filter actions
setStatus: (status) => {
@@ -77,7 +65,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
status,
page: 1,
searchTerm: '',
selectedIds: new Set(),
loading: true,
})
get().fetchEntries()
@@ -93,26 +80,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
get().fetchEntries()
},
// Selection actions
toggleSelectEntry: (id) => {
const newSelectedIds = new Set(get().selectedIds)
if (newSelectedIds.has(id)) {
newSelectedIds.delete(id)
} else {
newSelectedIds.add(id)
}
set({ selectedIds: newSelectedIds })
},
selectAll: () => {
const allIds = get().filteredEntries.map((entry) => entry.id)
set({ selectedIds: new Set(allIds) })
},
deselectAll: () => {
set({ selectedIds: new Set() })
},
// Data actions
setEntries: (entries) => {
set({
@@ -126,7 +93,6 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setActionLoading: (id) => set({ actionLoading: id }),
setBulkActionLoading: (loading) => set({ bulkActionLoading: loading }),
// Fetch data
fetchEntries: async () => {
@@ -152,7 +118,19 @@ export const useWaitlistStore = create<WaitlistState>((set, get) => ({
})
if (!response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`)
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()

View File

@@ -5,18 +5,20 @@ import { useRouter, useSearchParams } from 'next/navigation'
import {
AlertCircleIcon,
CheckIcon,
CheckSquareIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
InfoIcon,
MailIcon,
RotateCcwIcon,
SearchIcon,
SquareIcon,
UserCheckIcon,
UserIcon,
UserXIcon,
XIcon,
} from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
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'
@@ -43,6 +45,9 @@ interface FilterButtonProps {
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
@@ -70,17 +75,11 @@ export function WaitlistTable() {
totalEntries,
loading,
error,
selectedIds,
actionLoading,
bulkActionLoading,
setStatus,
setSearchTerm,
setPage,
toggleSelectEntry,
selectAll,
deselectAll,
setActionLoading,
setBulkActionLoading,
setError,
fetchEntries,
} = useWaitlistStore()
@@ -89,6 +88,23 @@ export function WaitlistTable() {
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)
@@ -151,20 +167,12 @@ export function WaitlistTable() {
}, 500) // 500ms debounce
}
// Toggle selection of all entries
const handleToggleSelectAll = () => {
if (selectedIds.size === filteredEntries.length) {
deselectAll()
} else {
selectAll()
}
}
// 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',
@@ -177,14 +185,58 @@ export function WaitlistTable() {
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to approve user')
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
}
}
// Refresh the data
fetchEntries()
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) {
setError(error instanceof Error ? error.message : 'Failed to approve user')
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)
@@ -196,6 +248,7 @@ export function WaitlistTable() {
try {
setActionLoading(id)
setError(null)
setAlertInfo({ type: null, message: '' })
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
@@ -209,13 +262,22 @@ export function WaitlistTable() {
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to reject user')
setAlertInfo({
type: 'error',
message: data.message || 'Failed to reject user',
entryId: id,
})
return
}
// Refresh the data
fetchEntries()
// Success - don't refresh the table
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to reject user')
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)
@@ -227,6 +289,7 @@ export function WaitlistTable() {
try {
setActionLoading(id)
setError(null)
setAlertInfo({ type: null, message: '' })
const response = await fetch('/api/admin/waitlist', {
method: 'POST',
@@ -239,150 +302,77 @@ export function WaitlistTable() {
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to resend approval email')
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
}
}
// Refresh the data
fetchEntries()
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) {
setError(error instanceof Error ? error.message : 'Failed to resend approval email')
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)
}
}
// Handle bulk approval
const handleBulkApprove = async () => {
if (selectedIds.size === 0) return
setBulkActionLoading(true)
setError(null)
try {
const selectedEmails = filteredEntries
.filter((entry) => selectedIds.has(entry.id))
.map((entry) => entry.email)
const response = await fetch('/api/admin/waitlist/bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({
emails: selectedEmails,
action: 'approve',
}),
})
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to approve selected users')
}
// Refresh data
fetchEntries()
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to approve selected users')
logger.error('Error approving users:', error)
} finally {
setBulkActionLoading(false)
}
}
// Handle bulk rejection
const handleBulkReject = async () => {
if (selectedIds.size === 0) return
setBulkActionLoading(true)
setError(null)
try {
const selectedEmails = filteredEntries
.filter((entry) => selectedIds.has(entry.id))
.map((entry) => entry.email)
const response = await fetch('/api/admin/waitlist/bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({
emails: selectedEmails,
action: 'reject',
}),
})
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to reject selected users')
}
// Refresh data
fetchEntries()
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to reject selected users')
logger.error('Error rejecting users:', error)
} finally {
setBulkActionLoading(false)
}
}
// Handle bulk resend approval
const handleBulkResend = async () => {
if (selectedIds.size === 0) return
setBulkActionLoading(true)
setError(null)
try {
const selectedEmails = filteredEntries
.filter((entry) => selectedIds.has(entry.id) && entry.status === 'approved')
.map((entry) => entry.email)
if (selectedEmails.length === 0) {
setError('No approved users selected')
setBulkActionLoading(false)
return
}
const response = await fetch('/api/admin/waitlist/bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiToken}`,
},
body: JSON.stringify({
emails: selectedEmails,
action: 'resend',
}),
})
const data = await response.json()
if (!response.ok || !data.success) {
throw new Error(data.message || 'Failed to resend approval emails')
}
// Refresh data
fetchEntries()
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to resend approval emails')
logger.error('Error resending approval emails:', error)
} finally {
setBulkActionLoading(false)
}
}
// Navigation
const handleNextPage = () => setPage(page + 1)
const handlePrevPage = () => setPage(Math.max(page - 1, 1))
const handleRefresh = () => fetchEntries()
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) => {
@@ -486,7 +476,7 @@ export function WaitlistTable() {
</div>
</div>
{/* Search and bulk actions bar */}
{/* 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" />
@@ -510,86 +500,47 @@ export function WaitlistTable() {
>
<RotateCcwIcon className={`h-4 w-4 ${loading ? 'animate-spin text-blue-500' : ''}`} />
</Button>
{selectedIds.size > 0 && (
<>
<span className="text-sm text-muted-foreground whitespace-nowrap ml-2">
{selectedIds.size} selected
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleBulkApprove}
disabled={bulkActionLoading || status === 'approved' || loading}
size="sm"
className="bg-green-500 hover:bg-green-600 text-white"
>
{bulkActionLoading ? (
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
) : (
<CheckIcon className="h-4 w-4 mr-1" />
)}
Approve All
</Button>
</TooltipTrigger>
<TooltipContent>
Approve all selected users and send them access emails
</TooltipContent>
</Tooltip>
</TooltipProvider>
{status === 'approved' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleBulkResend}
disabled={bulkActionLoading || loading}
size="sm"
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{bulkActionLoading ? (
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
) : (
<MailIcon className="h-4 w-4 mr-1" />
)}
Resend All
</Button>
</TooltipTrigger>
<TooltipContent>Resend approval emails to all selected users</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleBulkReject}
disabled={bulkActionLoading || status === 'rejected' || loading}
size="sm"
variant="destructive"
>
{bulkActionLoading ? (
<RotateCcwIcon className="h-4 w-4 mr-1 animate-spin" />
) : (
<XIcon className="h-4 w-4 mr-1" />
)}
Reject All
</Button>
</TooltipTrigger>
<TooltipContent>Reject all selected users</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</div>
{/* Error alert */}
{error && (
{/* 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">
@@ -629,17 +580,6 @@ export function WaitlistTable() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<div className="flex items-center">
<button onClick={handleToggleSelectAll} className="focus:outline-none">
{selectedIds.size === filteredEntries.length ? (
<CheckSquareIcon className="h-4 w-4 text-primary" />
) : (
<SquareIcon className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</TableHead>
<TableHead className="min-w-[180px]">Email</TableHead>
<TableHead className="min-w-[150px]">Joined</TableHead>
<TableHead>Status</TableHead>
@@ -648,19 +588,16 @@ export function WaitlistTable() {
</TableHeader>
<TableBody>
{filteredEntries.map((entry) => (
<TableRow key={entry.id} className="hover:bg-muted/30">
<TableCell>
<button
onClick={() => toggleSelectEntry(entry.id)}
className="focus:outline-none"
>
{selectedIds.has(entry.id) ? (
<CheckSquareIcon className="h-4 w-4 text-primary" />
) : (
<SquareIcon className="h-4 w-4 text-muted-foreground" />
)}
</button>
</TableCell>
<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>
@@ -818,23 +755,54 @@ export function WaitlistTable() {
{/* Pagination */}
{!searchTerm && (
<div className="flex items-center justify-between mx-6 my-4 pb-2">
<Button variant="outline" size="sm" onClick={handlePrevPage} disabled={page === 1}>
Previous
</Button>
<span className="text-sm text-muted-foreground">
<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>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page >= Math.ceil(totalEntries / 50)}
>
Next
</Button>
<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>
)}
</>

View File

@@ -1,138 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { Logger } from '@/lib/logs/console-logger'
import { approveWaitlistUser, rejectWaitlistUser, resendApprovalEmail } from '@/lib/waitlist/service'
const logger = new Logger('WaitlistBulkAPI')
// Schema for POST request body
const bulkActionSchema = z.object({
emails: z.array(z.string().email()),
action: z.enum(['approve', 'reject', 'resend']),
})
// 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
}
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()
// Validate request
const validatedData = bulkActionSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid request',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
const { emails, action } = validatedData.data
if (emails.length === 0) {
return NextResponse.json({ success: false, message: 'No emails provided' }, { status: 400 })
}
// Process each email
let results
try {
results = await Promise.allSettled(
emails.map((email) => {
if (action === 'approve') {
return approveWaitlistUser(email)
} else if (action === 'reject') {
return rejectWaitlistUser(email)
} else if (action === 'resend') {
return resendApprovalEmail(email)
}
throw new Error('Invalid action')
})
)
// Check if there's a JWT_SECRET error
const jwtError = results.find(
(r) =>
r.status === 'rejected' &&
r.reason instanceof Error &&
r.reason.message.includes('JWT_SECRET')
)
if (jwtError) {
return NextResponse.json(
{
success: false,
message:
'Configuration error: JWT_SECRET environment variable is missing. Please contact the administrator.',
},
{ status: 500 }
)
}
// Count successful and failed operations
const successful = results.filter(
(r) => r.status === 'fulfilled' && (r.value as any).success
).length
const failed = emails.length - successful
return NextResponse.json({
success: true,
message: `Processed ${emails.length} entries: ${successful} successful, ${failed} failed`,
details: {
successful,
failed,
total: emails.length,
},
})
} catch (error) {
logger.error('Error in bulk processing:', error)
return NextResponse.json(
{
success: false,
message:
error instanceof Error
? error.message
: 'An error occurred while processing your request',
},
{ status: 500 }
)
}
} catch (error) {
logger.error('Admin waitlist bulk API error:', error)
return NextResponse.json(
{
success: false,
message: 'An error occurred while processing your request',
},
{ status: 500 }
)
}
}

View File

@@ -43,6 +43,37 @@ function isAuthorized(request: NextRequest) {
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
@@ -134,14 +165,67 @@ export async function POST(request: NextRequest) {
const { email, action } = validatedData.data
let result
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(
@@ -153,6 +237,35 @@ export async function POST(request: NextRequest) {
{ 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,
@@ -177,8 +290,60 @@ export async function POST(request: NextRequest) {
} 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(
@@ -190,6 +355,35 @@ export async function POST(request: NextRequest) {
{ 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,

View File

@@ -170,7 +170,7 @@ export async function getWaitlistEntries(
// Approve a user from the waitlist and send approval email
export async function approveWaitlistUser(
email: string
): Promise<{ success: boolean; message: string }> {
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
@@ -188,15 +188,6 @@ export async function approveWaitlistUser(
}
}
// Update status to approved
await db
.update(waitlist)
.set({
status: 'approved',
updatedAt: new Date(),
})
.where(eq(waitlist.email, normalizedEmail))
// Create a special signup token
const token = await createToken({
email: normalizedEmail,
@@ -207,30 +198,79 @@ export async function approveWaitlistUser(
// Generate signup link with token
const signupLink = `${process.env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
// Send approval email
// IMPORTANT: Send approval email BEFORE updating the status
// This ensures we don't mark users as approved if email fails
try {
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
const subject = getEmailSubject('waitlist-approval')
await sendEmail({
const emailResult = await sendEmail({
to: normalizedEmail,
subject,
html: emailHtml,
})
// If email sending failed, don't update the user status
if (!emailResult.success) {
console.error('Error sending approval email:', emailResult.message)
// Check if it's a rate limit error
if (emailResult.message?.toLowerCase().includes('rate') ||
emailResult.message?.toLowerCase().includes('too many') ||
emailResult.message?.toLowerCase().includes('limit')) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true
}
}
return {
success: false,
message: emailResult.message || 'Failed to send approval email',
emailError: emailResult
}
}
// Email sent successfully, now update status to approved
await db
.update(waitlist)
.set({
status: 'approved',
updatedAt: new Date(),
})
.where(eq(waitlist.email, normalizedEmail))
return {
success: true,
message: 'User approved and email sent'
}
} catch (emailError) {
console.error('Error sending approval email:', emailError)
// Continue even if email fails - user is still approved in db
}
return {
success: true,
message: 'User approved and email sent',
// Check if it's a rate limit error
if (emailError instanceof Error &&
(emailError.message.toLowerCase().includes('rate') ||
emailError.message.toLowerCase().includes('too many') ||
emailError.message.toLowerCase().includes('limit'))) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true
}
}
return {
success: false,
message: 'Failed to send approval email',
emailError
}
}
} catch (error) {
console.error('Error approving waitlist user:', error)
return {
success: false,
message: 'An error occurred while approving user',
message: 'An error occurred while approving user'
}
}
}
@@ -357,7 +397,7 @@ export async function markWaitlistUserAsSignedUp(
// Resend approval email to an already approved user
export async function resendApprovalEmail(
email: string
): Promise<{ success: boolean; message: string }> {
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
@@ -390,23 +430,59 @@ export async function resendApprovalEmail(
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
const subject = getEmailSubject('waitlist-approval')
await sendEmail({
const emailResult = await sendEmail({
to: normalizedEmail,
subject,
html: emailHtml,
})
// Check for email sending failures
if (!emailResult.success) {
console.error('Error sending approval email:', emailResult.message)
// Check if it's a rate limit error
if (emailResult.message?.toLowerCase().includes('rate') ||
emailResult.message?.toLowerCase().includes('too many') ||
emailResult.message?.toLowerCase().includes('limit')) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true
}
}
return {
success: false,
message: emailResult.message || 'Failed to send approval email',
emailError: emailResult
}
}
return {
success: true,
message: 'Approval email resent successfully',
}
} catch (emailError) {
console.error('Error sending approval email:', emailError)
// Check if it's a rate limit error
if (emailError instanceof Error &&
(emailError.message.toLowerCase().includes('rate') ||
emailError.message.toLowerCase().includes('too many') ||
emailError.message.toLowerCase().includes('limit'))) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true
}
}
return {
success: false,
message: 'Failed to send approval email',
emailError
}
}
return {
success: true,
message: 'Approval email resent successfully',
}
} catch (error) {
console.error('Error resending approval email:', error)
return {