From 5da32715faac3ec83f7821323bba81befc359d0d Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 9 Feb 2026 18:51:25 -0800 Subject: [PATCH] Wire events page to persistent data with RSVP tracking --- data/events.json | 117 +++++++++--------- src/app/api/events/[id]/route.ts | 152 ++++++++++++++++++++++++ src/app/api/events/route.ts | 32 +++-- src/app/events/events-client.tsx | 197 +++++++++++++++++++++++++------ src/lib/eventsStore.ts | 55 ++++++++- 5 files changed, 449 insertions(+), 104 deletions(-) create mode 100644 src/app/api/events/[id]/route.ts diff --git a/data/events.json b/data/events.json index 1cbfed06..7a635a5e 100644 --- a/data/events.json +++ b/data/events.json @@ -1,82 +1,91 @@ [ { - "id": "evt_20260212_workshop_memory", - "title": "Agent Memory Patterns in Practice", - "description": "A hands-on workshop on building resilient short-term and long-term memory layers for agents.", + "id": "evt_20260118_workshop_memory_loops", + "title": "Memory Loops Workshop", + "description": "A practical workshop for building durable memory layers and retrieval flows in agent systems.", "type": "workshop", - "date": "2026-02-12T18:00:00.000Z", - "url": "https://foragents.dev/events/memory-patterns", + "date": "2026-01-18T18:00:00.000Z", + "url": "https://foragents.dev/events/memory-loops-workshop", "location": "Online", - "createdAt": "2026-02-01T18:00:00.000Z" + "attendeeCount": 36, + "maxAttendees": 60, + "rsvps": ["kai_ops", "nova_builds", "echo_lab"], + "createdAt": "2026-01-05T16:00:00.000Z" }, { - "id": "evt_20260214_meetup_vancouver", + "id": "evt_20260130_meetup_vancouver_agents", "title": "Vancouver Agent Builders Meetup", - "description": "Community meetup for local builders sharing active agent projects and deployment lessons.", + "description": "Local meetup for demos, architecture swaps, and lessons from shipping autonomous workflows.", "type": "meetup", - "date": "2026-02-14T02:00:00.000Z", - "url": "https://foragents.dev/events/vancouver-meetup", + "date": "2026-01-30T02:00:00.000Z", "location": "Vancouver, BC", - "createdAt": "2026-02-01T18:05:00.000Z" + "attendeeCount": 44, + "maxAttendees": 80, + "rsvps": ["northstar", "agent_rye", "raven_core"], + "createdAt": "2026-01-08T10:20:00.000Z" }, { - "id": "evt_20260218_workshop_observability", - "title": "Agent Observability Workshop", - "description": "Learn logging, traces, and replay strategies for debugging production agent workflows.", - "type": "workshop", - "date": "2026-02-18T19:00:00.000Z", - "url": "https://foragents.dev/events/observability-workshop", + "id": "evt_20260213_webinar_guardrails", + "title": "Webinar: Guardrails for Tool-Using Agents", + "description": "Live webinar covering policy checks, action constraints, and safety telemetry for production agents.", + "type": "webinar", + "date": "2026-02-13T19:00:00.000Z", + "url": "https://foragents.dev/events/guardrails-webinar", "location": "Online", - "createdAt": "2026-02-01T18:10:00.000Z" + "attendeeCount": 112, + "maxAttendees": 150, + "rsvps": ["delta_ops", "signal_agent", "aperture-ai"], + "createdAt": "2026-01-20T12:40:00.000Z" }, { - "id": "evt_20260222_hackathon_weekend", - "title": "Weekend Agent Build Hackathon", - "description": "A 2-day hackathon to ship agent tools, workflows, and integrations with community mentors.", + "id": "evt_20260222_hackathon_weekender", + "title": "Agent Weekender Hackathon", + "description": "A two-day hackathon to ship tools, MCP integrations, and workflow automations with mentor support.", "type": "hackathon", "date": "2026-02-22T17:00:00.000Z", - "url": "https://foragents.dev/events/weekend-hackathon", + "url": "https://foragents.dev/events/weekender-hackathon", "location": "Online", - "createdAt": "2026-02-01T18:15:00.000Z" - }, - { - "id": "evt_20260301_meetup_nyc", - "title": "NYC Agents + MCP Meetup", - "description": "Meetup focused on MCP integrations, local demos, and networking with agent engineers.", - "type": "meetup", - "date": "2026-03-01T23:30:00.000Z", - "url": "https://foragents.dev/events/nyc-mcp-meetup", - "location": "New York, NY", - "createdAt": "2026-02-01T18:20:00.000Z" + "attendeeCount": 71, + "maxAttendees": 120, + "rsvps": ["patchbot", "luna_stack", "vectorforge"], + "createdAt": "2026-01-22T09:15:00.000Z" }, { - "id": "evt_20260305_workshop_security", - "title": "Secure Agent Actions Workshop", - "description": "Practical controls for prompt-injection defense, tool allowlists, and safe action boundaries.", - "type": "workshop", - "date": "2026-03-05T20:00:00.000Z", - "url": "https://foragents.dev/events/security-workshop", + "id": "evt_20260302_launch_registry2", + "title": "forAgents Registry v2 Launch", + "description": "Launch event for Registry v2 with live demos, migration walkthroughs, and Q&A.", + "type": "launch", + "date": "2026-03-02T20:00:00.000Z", + "url": "https://foragents.dev/events/registry-v2-launch", "location": "Online", - "createdAt": "2026-02-01T18:25:00.000Z" + "attendeeCount": 58, + "maxAttendees": 200, + "rsvps": ["lattice", "orbit_agent", "stackpilot"], + "createdAt": "2026-01-25T14:00:00.000Z" }, { - "id": "evt_20260310_meetup_sf", - "title": "San Francisco Agent Operators Meetup", - "description": "In-person meetup for teams operating autonomous systems in production.", + "id": "evt_20260312_meetup_nyc_runtime", + "title": "NYC Runtime Operators Meetup", + "description": "In-person meetup focused on observability and ops patterns for autonomous systems.", "type": "meetup", - "date": "2026-03-10T01:00:00.000Z", - "url": "https://foragents.dev/events/sf-operators-meetup", - "location": "San Francisco, CA", - "createdAt": "2026-02-01T18:30:00.000Z" + "date": "2026-03-12T23:30:00.000Z", + "location": "New York, NY", + "attendeeCount": 39, + "maxAttendees": 75, + "rsvps": ["zenflow", "agent_atlas", "dockyard"], + "createdAt": "2026-01-30T11:00:00.000Z" }, { - "id": "evt_20260320_hackathon_global", - "title": "Global Agent Hackathon Spring 2026", - "description": "Global hackathon for agent-native products with mentors, tracks, and showcase judging.", - "type": "hackathon", - "date": "2026-03-20T16:00:00.000Z", - "url": "https://foragents.dev/events/global-hackathon-spring", + "id": "evt_20260328_workshop_eval_studio", + "title": "Workshop: Eval Studio for Agent Quality", + "description": "Hands-on lab for creating benchmark suites, failure replay loops, and release confidence checks.", + "type": "workshop", + "date": "2026-03-28T18:30:00.000Z", + "url": "https://foragents.dev/events/eval-studio-workshop", "location": "Online", - "createdAt": "2026-02-01T18:35:00.000Z" + "attendeeCount": 24, + "maxAttendees": 60, + "rsvps": ["sparrow", "matrix_ops", "linebreaker"], + "createdAt": "2026-02-02T08:05:00.000Z" } ] diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts new file mode 100644 index 00000000..983b3273 --- /dev/null +++ b/src/app/api/events/[id]/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + checkRateLimit, + getClientIp, + rateLimitResponse, + readJsonWithLimit, +} from "@/lib/requestLimits"; +import { readEventsFile, toPublicEvent, writeEventsFile } from "@/lib/eventsStore"; + +export const runtime = "nodejs"; + +const MAX_BODY_BYTES = 8_000; + +type RsvpInput = { + agentHandle: string; +}; + +function validateRsvpPayload( + body: Record +): { ok: true; value: RsvpInput } | { ok: false; errors: string[] } { + const errors: string[] = []; + + const agentHandleRaw = + typeof body.agentHandle === "string" ? body.agentHandle.trim().toLowerCase() : ""; + + if (!agentHandleRaw) { + errors.push("agentHandle is required"); + } + + if (agentHandleRaw.length > 50) { + errors.push("agentHandle must be 50 characters or fewer"); + } + + if (!/^[a-z0-9._-]+$/.test(agentHandleRaw)) { + errors.push("agentHandle may only contain lowercase letters, numbers, dots, underscores, and hyphens"); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + value: { + agentHandle: agentHandleRaw, + }, + }; +} + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const ip = getClientIp(request); + const rl = checkRateLimit(`events-id:get:${ip}`, { windowMs: 60_000, max: 120 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const { id } = await context.params; + const events = await readEventsFile(); + const event = events.find((entry) => entry.id === id); + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + return NextResponse.json(toPublicEvent(event), { + headers: { + "Cache-Control": "no-store", + }, + }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const ip = getClientIp(request); + const rl = checkRateLimit(`events-id:post:${ip}`, { windowMs: 60_000, max: 50 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const { id } = await context.params; + + try { + const body = await readJsonWithLimit(request, MAX_BODY_BYTES); + const validation = validateRsvpPayload(body); + + if (!validation.ok) { + return NextResponse.json( + { error: "Validation failed", details: validation.errors }, + { status: 400 } + ); + } + + const events = await readEventsFile(); + const eventIndex = events.findIndex((entry) => entry.id === id); + + if (eventIndex === -1) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }); + } + + const event = events[eventIndex]; + const handle = validation.value.agentHandle; + + if (event.rsvps.includes(handle)) { + return NextResponse.json( + { + event: toPublicEvent(event), + status: "already_rsvped", + }, + { status: 200 } + ); + } + + if (event.attendeeCount >= event.maxAttendees) { + return NextResponse.json({ error: "Event is at capacity" }, { status: 409 }); + } + + const updatedEvent = { + ...event, + rsvps: [...event.rsvps, handle], + attendeeCount: Math.min(event.maxAttendees, event.attendeeCount + 1), + }; + + events[eventIndex] = updatedEvent; + await writeEventsFile(events); + + return NextResponse.json( + { + event: toPublicEvent(updatedEvent), + status: "rsvped", + }, + { status: 200 } + ); + } catch (err: unknown) { + const status = + typeof err === "object" && + err !== null && + "status" in err && + typeof (err as { status?: unknown }).status === "number" + ? ((err as { status: number }).status ?? 400) + : 400; + + if (status === 413) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } + + return NextResponse.json( + { error: "Invalid request body. Expected JSON." }, + { status: 400 } + ); + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index bf2096d8..6177fc97 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -12,6 +12,7 @@ import { makeEventId, readEventsFile, sortEventsByDate, + toPublicEvent, writeEventsFile, } from "@/lib/eventsStore"; @@ -28,9 +29,9 @@ type CreateEventInput = { location?: string; }; -function validateEventPayload(body: Record): - | { ok: true; value: CreateEventInput } - | { ok: false; errors: string[] } { +function validateEventPayload( + body: Record +): { ok: true; value: CreateEventInput } | { ok: false; errors: string[] } { const errors: string[] = []; const title = typeof body.title === "string" ? body.title.trim() : ""; @@ -47,13 +48,17 @@ function validateEventPayload(body: Record): if (description.length > 2_000) errors.push("description must be under 2,000 characters"); if (!isEventType(typeRaw)) { - errors.push("type must be one of: workshop, meetup, hackathon"); + errors.push("type must be one of: workshop, meetup, hackathon, webinar, launch"); } if (!dateRaw || Number.isNaN(Date.parse(dateRaw))) { errors.push("date must be a valid ISO date"); } + if (!url && !location) { + errors.push("either location or url is required"); + } + if (url) { if (url.length > 500) { errors.push("url must be under 500 characters"); @@ -114,7 +119,7 @@ export async function GET(request: NextRequest) { return NextResponse.json( { - events: sorted, + events: sorted.map(toPublicEvent), total: sorted.length, }, { @@ -145,16 +150,25 @@ export async function POST(request: NextRequest) { const newEvent: CommunityEvent = { id: makeEventId(), + attendeeCount: 0, + maxAttendees: 100, + rsvps: [], createdAt: new Date().toISOString(), ...validation.value, }; await writeEventsFile([...current, newEvent]); - return NextResponse.json(newEvent, { status: 201 }); - } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const status = typeof (err as any)?.status === "number" ? (err as any).status : 400; + return NextResponse.json(toPublicEvent(newEvent), { status: 201 }); + } catch (err: unknown) { + const status = + typeof err === "object" && + err !== null && + "status" in err && + typeof (err as { status?: unknown }).status === "number" + ? ((err as { status: number }).status ?? 400) + : 400; + if (status === 413) { return NextResponse.json({ error: "Request body too large" }, { status: 413 }); } diff --git a/src/app/events/events-client.tsx b/src/app/events/events-client.tsx index 68d93237..4bc83e05 100644 --- a/src/app/events/events-client.tsx +++ b/src/app/events/events-client.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; @@ -11,7 +12,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -type EventType = "workshop" | "meetup" | "hackathon"; +type EventType = "workshop" | "meetup" | "hackathon" | "webinar" | "launch"; type CommunityEvent = { id: string; @@ -21,6 +22,8 @@ type CommunityEvent = { date: string; url?: string; location?: string; + attendeeCount: number; + maxAttendees: number; createdAt: string; }; @@ -31,12 +34,21 @@ type EventsResponse = { type Notice = { type: "success" | "error"; message: string }; -const TYPE_OPTIONS: Array = ["all", "workshop", "meetup", "hackathon"]; +const TYPE_OPTIONS: Array = [ + "all", + "workshop", + "meetup", + "hackathon", + "webinar", + "launch", +]; const typeStyles: Record = { workshop: "bg-purple/10 text-purple border-purple/20", meetup: "bg-green/10 text-green border-green/20", hackathon: "bg-orange/10 text-orange border-orange/20", + webinar: "bg-blue-400/10 text-blue-300 border-blue-400/20", + launch: "bg-amber-400/10 text-amber-300 border-amber-400/20", }; function formatDate(dateString: string) { @@ -56,10 +68,16 @@ function toTitleCase(value: string) { return value.charAt(0).toUpperCase() + value.slice(1); } +function capacityPercent(event: CommunityEvent) { + if (event.maxAttendees <= 0) return 0; + return Math.min(100, Math.round((event.attendeeCount / event.maxAttendees) * 100)); +} + export function EventsClient() { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); + const [rsvpingEventId, setRsvpingEventId] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -73,6 +91,8 @@ export function EventsClient() { const [url, setUrl] = useState(""); const [location, setLocation] = useState(""); + const [agentHandle, setAgentHandle] = useState(""); + const loadEvents = useCallback(async () => { setLoading(true); setError(null); @@ -125,6 +145,10 @@ export function EventsClient() { throw new Error("Please provide a valid date and time."); } + if (!url.trim() && !location.trim()) { + throw new Error("Provide at least a URL or location."); + } + const res = await fetch("/api/events", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -166,6 +190,52 @@ export function EventsClient() { } } + async function rsvpToEvent(eventId: string) { + setNotice(null); + setError(null); + + const normalizedHandle = agentHandle.trim().toLowerCase(); + if (!normalizedHandle) { + setNotice({ type: "error", message: "Enter your agent handle before RSVPing." }); + return; + } + + setRsvpingEventId(eventId); + + try { + const res = await fetch(`/api/events/${eventId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agentHandle: normalizedHandle }), + }); + + const body = (await res.json().catch(() => ({}))) as { + error?: string; + status?: "rsvped" | "already_rsvped"; + event?: CommunityEvent; + }; + + if (!res.ok) { + throw new Error(body.error || `RSVP failed (${res.status})`); + } + + if (body.event) { + setEvents((prev) => prev.map((event) => (event.id === body.event?.id ? body.event : event))); + } + + if (body.status === "already_rsvped") { + setNotice({ type: "success", message: "You're already RSVP'd for this event." }); + } else { + setNotice({ type: "success", message: "RSVP saved. See you there." }); + } + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to RSVP"; + setNotice({ type: "error", message }); + } finally { + setRsvpingEventId(null); + } + } + const noticeClass = notice?.type === "success" ? "text-emerald-300 border-emerald-500/20 bg-emerald-500/5" @@ -184,8 +254,7 @@ export function EventsClient() { Events & Community

- Browse upcoming community events and submit your own workshop, - meetup, or hackathon. + You're invited to browse upcoming community events and RSVP with your agent handle.

@@ -199,6 +268,7 @@ export function EventsClient() { {TYPE_OPTIONS.map((option) => ( + + + ); + })} )} @@ -319,6 +436,8 @@ export function EventsClient() { + + @@ -357,6 +476,8 @@ export function EventsClient() { /> +

Provide at least one of URL or location.

+