From ae69428650e4932370e4470e42dea18076761987 Mon Sep 17 00:00:00 2001 From: Vincent Date: Mon, 13 Jul 2026 16:02:07 +0200 Subject: [PATCH] fix: camp --- src/app/admin/page.tsx | 71 ++++++++- .../admin/attendees/[id]/check-in/route.ts | 11 ++ src/app/api/admin/attendees/[id]/route.ts | 52 +++++++ src/app/api/admin/attendees/route.ts | 11 +- src/app/api/redeem/route.ts | 40 +++++- src/app/camp/layout.tsx | 2 +- src/app/camp/page.tsx | 136 +----------------- 7 files changed, 185 insertions(+), 138 deletions(-) create mode 100644 src/app/api/admin/attendees/[id]/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b6e8e2f..8d22eb8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 @@ -24,10 +25,12 @@ 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(null) + const [toggling, setToggling] = useState(null) const fetchAttendees = useCallback(async () => { setLoading(true) @@ -35,6 +38,7 @@ export default function AdminPage() { 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}`) @@ -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') @@ -68,6 +72,7 @@ export default function AdminPage() { a.id === id ? { ...a, + confirmed: true, checked_in: true, checked_in_at: new Date().toISOString(), } @@ -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) @@ -148,6 +170,18 @@ export default function AdminPage() { + + {a.checked_in ? ( @@ -205,9 +262,17 @@ export default function AdminPage() { )} diff --git a/src/app/api/admin/attendees/[id]/check-in/route.ts b/src/app/api/admin/attendees/[id]/check-in/route.ts index a584e9c..de97773 100644 --- a/src/app/api/admin/attendees/[id]/check-in/route.ts +++ b/src/app/api/admin/attendees/[id]/check-in/route.ts @@ -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, }) diff --git a/src/app/api/admin/attendees/[id]/route.ts b/src/app/api/admin/attendees/[id]/route.ts new file mode 100644 index 0000000..e2cebbe --- /dev/null +++ b/src/app/api/admin/attendees/[id]/route.ts @@ -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 }) +} diff --git a/src/app/api/admin/attendees/route.ts b/src/app/api/admin/attendees/route.ts index 3a07ad1..e4d4180 100644 --- a/src/app/api/admin/attendees/route.ts +++ b/src/app/api/admin/attendees/route.ts @@ -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 @@ -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) @@ -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) @@ -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, diff --git a/src/app/api/redeem/route.ts b/src/app/api/redeem/route.ts index 526c215..bac34bc 100644 --- a/src/app/api/redeem/route.ts +++ b/src/app/api/redeem/route.ts @@ -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() + +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') || '' @@ -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, @@ -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 diff --git a/src/app/camp/layout.tsx b/src/app/camp/layout.tsx index 82f58f4..fd2727e 100644 --- a/src/app/camp/layout.tsx +++ b/src/app/camp/layout.tsx @@ -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({ diff --git a/src/app/camp/page.tsx b/src/app/camp/page.tsx index ab3c76e..51d45fe 100644 --- a/src/app/camp/page.tsx +++ b/src/app/camp/page.tsx @@ -324,14 +324,7 @@ export default function CampPage() {
About Eisbach Callin Camp -

- Join us{' '} - - Jul 17-18, 2026 - {' '} - for a private festival with 20+ DJs from 3 crews, clean camping, - and good atmosphere. -

+

Invited to CAMP?

{renderAuthStatus()} @@ -340,16 +333,6 @@ export default function CampPage() { } rightSide={
-
-
-
-

- One Festival. Two Nights of Bass. Three Crews. -

-
-
-
-
@@ -469,126 +452,15 @@ export default function CampPage() {
-
-
-
-
    -
  • - - Jul 17-18, 2026 - -
  • -
  • - - Jim Camp Grounds - {' '} - in{' '} - - Schattenhofen - -
  • -
  • - - 20+ DJs from 3 Crews - -
  • -
  • - - A Massive Soundsystem - -
  • -
  • - - Invitation Only - -
  • -
-
-
-
- -
-
-
-

- Expect rolling beats, deep frequencies, and laid-back vibes on - the former{' '} - - Jim Camp grounds in Schattenhofen - - . We’ve got clean camping, showers, coffee, snacks, and all - the essentials for a stress-free festival. -

-
-
-
- -
+
-

- - This private event requires an invitation. - {' '} - There are no tickets and no on-site ticket sales. We’re all - about safe, respectful spaces with zero tolerance for bad - vibes. You take care of your trash, keep noise down at - campsites, and bring nothing hazardous. Got questions?{' '} - - Drop us a message - - . +

+ This private event requires an invitation.

- -
-
-
- -
-
-
} />