Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 68 additions & 3 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Attendee = {
last_name: string
email: string
crew_name: string
confirmed: boolean
checked_in: boolean
checked_in_at: string | null
created_at: string
Expand All @@ -24,17 +25,20 @@ export default function AdminPage() {
const [search, setSearch] = useState('')
const [crewFilter, setCrewFilter] = useState('')
const [checkedInFilter, setCheckedInFilter] = useState('')
const [confirmedFilter, setConfirmedFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const [checkingIn, setCheckingIn] = useState<string | null>(null)
const [toggling, setToggling] = useState<string | null>(null)

const fetchAttendees = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (crewFilter) params.set('crew_id', crewFilter)
if (checkedInFilter) params.set('checked_in', checkedInFilter)
if (confirmedFilter) params.set('confirmed', confirmedFilter)
params.set('page', page.toString())

const res = await fetch(`/api/admin/attendees?${params}`)
Expand All @@ -45,7 +49,7 @@ export default function AdminPage() {
setTotal(data.total)
}
setLoading(false)
}, [search, crewFilter, checkedInFilter, page])
}, [search, crewFilter, checkedInFilter, confirmedFilter, page])

useEffect(() => {
fetch('/api/crews')
Expand All @@ -68,6 +72,7 @@ export default function AdminPage() {
a.id === id
? {
...a,
confirmed: true,
checked_in: true,
checked_in_at: new Date().toISOString(),
}
Expand All @@ -85,6 +90,23 @@ export default function AdminPage() {
setCheckingIn(null)
}

const handleToggleConfirmed = async (id: string, next: boolean) => {
setToggling(id)
const res = await fetch(`/api/admin/attendees/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmed: next }),
})
if (res.ok) {
setAttendees((prev) =>
prev.map((a) => (a.id === id ? { ...a, confirmed: next } : a))
)
} else {
alert('Update failed')
}
setToggling(null)
}

const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
Expand Down Expand Up @@ -148,6 +170,18 @@ export default function AdminPage() {
<option value='true'>Checked in</option>
<option value='false'>Not checked in</option>
</select>
<select
value={confirmedFilter}
onChange={(e) => {
setConfirmedFilter(e.target.value)
setPage(1)
}}
className='border-accent bg-default px-3 py-2 font-sans text-base shadow-sm focus:border-default focus:ring-1 focus:ring-blue-500'
>
<option value=''>All</option>
<option value='true'>Confirmed</option>
<option value='false'>Not confirmed</option>
</select>
<button
type='submit'
className='border-transparent bg-invert px-6 py-2 font-sans text-sm text-invert hover:border-default hover:bg-accent hover:text-onaccent'
Expand All @@ -170,6 +204,7 @@ export default function AdminPage() {
<th className='px-4 py-2'>Name</th>
<th className='px-4 py-2'>Email</th>
<th className='px-4 py-2'>Crew</th>
<th className='px-4 py-2'>Confirmed</th>
<th className='px-4 py-2'>Status</th>
<th className='px-4 py-2'>Action</th>
</tr>
Expand All @@ -182,6 +217,28 @@ export default function AdminPage() {
</td>
<td className='px-4 py-2'>{a.email}</td>
<td className='px-4 py-2'>{a.crew_name}</td>
<td className='px-4 py-2'>
<button
onClick={() => handleToggleConfirmed(a.id, !a.confirmed)}
disabled={toggling === a.id}
title={
a.confirmed
? 'Click to mark as not confirmed'
: 'Click to mark as confirmed'
}
className={
a.confirmed
? 'bg-green-600 p-[0.05rem] text-sm text-white hover:bg-green-700 disabled:opacity-50'
: 'bg-amber-500 p-[0.05rem] text-sm text-white hover:bg-amber-600 disabled:opacity-50'
}
>
{toggling === a.id
? '...'
: a.confirmed
? 'Confirmed'
: 'Not confirmed'}
</button>
</td>
<td className='px-4 py-2'>
{a.checked_in ? (
<span className='bg-green-600 p-[0.05rem] text-sm text-white'>
Expand All @@ -205,9 +262,17 @@ export default function AdminPage() {
<button
onClick={() => handleCheckIn(a.id)}
disabled={checkingIn === a.id}
className='border-transparent bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50'
className={
a.confirmed
? 'border-transparent bg-green-600 px-3 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50'
: 'border-transparent bg-amber-500 px-3 py-1 text-xs text-white hover:bg-amber-600 disabled:opacity-50'
}
>
{checkingIn === a.id ? '...' : 'Check In'}
{checkingIn === a.id
? '...'
: a.confirmed
? 'Check In'
: 'Confirm & Check In'}
</button>
)}
</td>
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/admin/attendees/[id]/check-in/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export async function POST(
return NextResponse.json({ error: 'invalid_id' }, { status: 400 })
}

// Checking in implies confirmation ("Confirm & Check In" is one request)
const { error: confirmError } = await supabase
.from('attendees')
.update({ confirmed: true })
.eq('id', id)

if (confirmError) {
console.error('Confirm error:', confirmError)
return NextResponse.json({ error: 'server_error' }, { status: 500 })
}

const { data, error } = await supabase.rpc('check_in_attendee', {
p_attendee_id: id,
})
Expand Down
52 changes: 52 additions & 0 deletions src/app/api/admin/attendees/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth-server'
import { supabase } from '@/lib/supabase'

export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const cookie = req.headers.get('cookie') || ''

try {
await requireAdmin(cookie)
} catch {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}

const { id } = await params

const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(id)) {
return NextResponse.json({ error: 'invalid_id' }, { status: 400 })
}

let body: { confirmed?: unknown }
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
}

if (typeof body.confirmed !== 'boolean') {
return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
}

const { data, error } = await supabase
.from('attendees')
.update({ confirmed: body.confirmed })
.eq('id', id)
.select('id, confirmed')
.single()

if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
}
console.error('Failed to update attendee:', error)
return NextResponse.json({ error: 'server_error' }, { status: 500 })
}

return NextResponse.json({ success: true, confirmed: data.confirmed })
}
11 changes: 10 additions & 1 deletion src/app/api/admin/attendees/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ export async function GET(req: NextRequest) {
}

const { searchParams } = new URL(req.url)
const search = searchParams.get('search') || ''
const search = (searchParams.get('search') || '').slice(0, 100)
const crewId = searchParams.get('crew_id')
const checkedIn = searchParams.get('checked_in')
const confirmed = searchParams.get('confirmed')
const page = parseInt(searchParams.get('page') || '1', 10)
const limit = 50
const offset = (page - 1) * limit
Expand All @@ -26,6 +27,7 @@ export async function GET(req: NextRequest) {
last_name,
email,
crew_id,
confirmed,
created_at,
crews(name),
tickets(checked_in, checked_in_at)
Expand All @@ -49,6 +51,12 @@ export async function GET(req: NextRequest) {
query = query.eq('tickets.checked_in', false)
}

if (confirmed === 'true') {
query = query.eq('confirmed', true)
} else if (confirmed === 'false') {
query = query.eq('confirmed', false)
}

query = query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
Expand All @@ -67,6 +75,7 @@ export async function GET(req: NextRequest) {
email: a.email,
crew_id: a.crew_id,
crew_name: a.crews?.name,
confirmed: a.confirmed,
checked_in: a.tickets?.checked_in || false,
checked_in_at: a.tickets?.checked_in_at,
created_at: a.created_at,
Expand Down
40 changes: 39 additions & 1 deletion src/app/api/redeem/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ const ory = new FrontendApi(
new Configuration({ basePath: process.env.NEXT_PUBLIC_ORY_URL })
)

// In-memory rate limiter: max 5 redemption attempts per identity per hour
const RATE_LIMIT = 5
const RATE_WINDOW_MS = 60 * 60 * 1000
const redeemAttempts = new Map<string, { count: number; resetAt: number }>()

function checkRateLimit(identityId: string): boolean {
const now = Date.now()
const record = redeemAttempts.get(identityId)
if (!record || now > record.resetAt) {
redeemAttempts.set(identityId, { count: 1, resetAt: now + RATE_WINDOW_MS })
return true
}
if (record.count >= RATE_LIMIT) return false
record.count++
return true
}

// Safe error codes the RPC may return that can be forwarded to the client
const SAFE_RPC_ERRORS = new Set([
'ticket_not_found',
'ticket_already_redeemed',
'already_registered',
'crew_not_found',
'crew_inactive',
])

function safeRedeemError(raw: string): string {
return SAFE_RPC_ERRORS.has(raw) ? raw : 'redemption_failed'
}

export async function POST(req: NextRequest) {
const cookie = req.headers.get('cookie') || ''

Expand Down Expand Up @@ -46,6 +76,14 @@ export async function POST(req: NextRequest) {
const identityId = session.identity?.id
const email = session.identity?.traits?.email

if (!identityId) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}

if (!checkRateLimit(identityId)) {
return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 })
}

// Call Supabase function
const { data, error } = await supabase.rpc('redeem_ticket', {
p_uuid: uuid,
Expand All @@ -63,7 +101,7 @@ export async function POST(req: NextRequest) {

// data is the JSONB result from the function
if (data.error) {
return NextResponse.json({ error: data.error }, { status: 400 })
return NextResponse.json({ error: safeRedeemError(data.error) }, { status: 400 })
}

// Resolve crew name for the confirmation email
Expand Down
2 changes: 1 addition & 1 deletion src/app/camp/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Metadata } from 'next'

export const metadata: Metadata = {
title: 'Eisbach Callin Camp',
description: 'Underground Rave since 2010',
description: 'Private, invitation-only event.',
}

export default function RootLayout({
Expand Down
Loading