From 8b2147242e7716ae1f6124be3458faa5b684c235 Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 21:28:24 -0400 Subject: [PATCH 01/69] Explore ranking: widen candidate pool, smooth time decay, org diversity cap, seeded daily nudge, popularity/org-history signals, ranking docs --- apps/web/src/actions/events.ts | 177 +++++++++++++++++++++++++++------ docs/ranking.md | 146 +++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 docs/ranking.md diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 241c098..51428fb 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -12,6 +12,7 @@ import { gt, ilike, inArray, + interactions, lt, ne, notifications, @@ -45,6 +46,61 @@ export interface FeedEvent { isSaved: boolean; } +// Upcoming events scored before slicing out the requested page — must be +// well above any realistic page size so personalization has real candidates +// to work with. See docs/ranking.md. +const CANDIDATE_POOL_SIZE = 100; + +// An event is "soon" if it's within this many days. The first page always +// includes at least SOON_QUOTA such events, even if their score is weak. +const SOON_WINDOW_DAYS = 1; +const SOON_QUOTA = 3; + +// Max events from one org before the rest get pushed later in the ranking. +const ORG_DIVERSITY_CAP = 3; + +// Org affinity when you've RSVP'd to the org before but don't follow/belong +// to it (full affinity is 1.0). +const ORG_PAST_INTERACTION_AFFINITY = 0.5; + +// View count treated as "maximally popular" (log-scaled, caps at 1.0). +const POPULARITY_VIEW_CAP = 50; +const POPULARITY_WEIGHT = 0.5; + +// Small per-event nudge, seeded per user-per-day (not per request) so it +// varies the feed over time without ever reshuffling on refresh. +const RANDOM_WEIGHT = 0.5; + +// Deterministic pseudo-random value in [0, 1) for a seed string. +function seededRandom(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash << 5) - hash + seed.charCodeAt(i); + hash |= 0; + } + return (hash >>> 0) / 0xffffffff; +} + +function diversifyByOrg(list: T[], cap: number): T[] { + const counts = new Map(); + const primary: T[] = []; + const deferred: T[] = []; + for (const item of list) { + if (!item.orgId) { + primary.push(item); + continue; + } + const count = counts.get(item.orgId) ?? 0; + if (count < cap) { + counts.set(item.orgId, count + 1); + primary.push(item); + } else { + deferred.push(item); + } + } + return [...primary, ...deferred]; +} + export async function getFeedEvents(params?: { search?: string; tags?: string[]; @@ -96,6 +152,20 @@ export async function getFeedEvents(params?: { ...memberOrgRows.map((o) => o.orgId), ]); + // Orgs the user has RSVP'd to before but doesn't follow/belong to — a + // weaker org-affinity signal than myOrgIds. + const interactedOrgRows = await db + .select({ orgId: events.orgId }) + .from(rsvps) + .innerJoin(events, eq(rsvps.eventId, events.id)) + .where(eq(rsvps.userId, userId)); + const interactedOrgIds = new Set( + interactedOrgRows.map((r) => r.orgId).filter((id): id is string => id !== null), + ); + + // Day string (UTC) that seeds the random nudge — flips once a day. + const today = new Date().toISOString().slice(0, 10); + // Build base query conditions — only show published events in the feed const conditions = [gt(events.datetime, new Date()), eq(events.status, "published")]; @@ -157,7 +227,9 @@ export async function getFeedEvents(params?: { .where(and(...conditions)); const total = countResult?.count ?? 0; - // Fetch events with scoring + // Score a bounded pool of upcoming events, not just the requested page — + // otherwise personalization could never surface anything past the + // soonest `limit` events. See docs/ranking.md. const rawEvents = await db .select({ id: events.id, @@ -175,17 +247,13 @@ export async function getFeedEvents(params?: { .leftJoin(organizations, eq(events.orgId, organizations.id)) .where(and(...conditions)) .orderBy(events.datetime) - .limit(limit) - .offset(offset); - - // Enrich each event with tags, rsvp counts, friend attendance, user state - // - // Ranking, in plain English: an event scores higher if (1) its tags match - // your interests, (2) it's happening soon, (3) friends of yours are - // attending, (4) it belongs to an org you follow or belong to, or (5) it - // was posted recently. Scores are deterministic — no randomness — so - // refreshing Explore without new data (RSVPs, new events, etc.) never - // reorders the feed. Ties break by soonest event first. + .limit(CANDIDATE_POOL_SIZE); + + // Enrich each event with tags, rsvp counts, friend attendance, user state, + // and a weighted relevance score (interests, timing, friends, org + // affinity, recency, popularity, a small daily nudge). Full breakdown, + // including the org-diversity cap and soon-event guarantee applied below: + // docs/ranking.md. const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = await Promise.all( rawEvents.map(async (event) => { // Get tags @@ -200,6 +268,18 @@ export async function getFeedEvents(params?: { .from(rsvps) .where(eq(rsvps.eventId, event.id)); + // Get view count for the popularity signal + const [viewCount] = await db + .select({ count: sql`count(*)::int` }) + .from(interactions) + .where( + and( + eq(interactions.itemId, event.id), + eq(interactions.itemType, "event"), + eq(interactions.interactionType, "view"), + ), + ); + // Get friends attending let friendsAttending: { id: string; displayName: string; avatarUrl: string | null }[] = []; if (friendIds.length > 0) { @@ -230,8 +310,7 @@ export async function getFeedEvents(params?: { // Score for sorting const tagNames = tags.map((t) => t.tag); - // Fraction of this event's tags that match the user's interests — how - // relevant is this event to you, not how much of your profile it covers. + // Fraction of this event's tags that match your interests. const matchedTags = tagNames.filter((t) => myInterestTags.includes(t)).length; const interestRelevance = myInterestTags.length === 0 @@ -243,30 +322,41 @@ export async function getFeedEvents(params?: { const now = Date.now(); const eventTime = event.datetime.getTime(); const daysUntil = (eventTime - now) / (1000 * 60 * 60 * 24); - const timeProximity = - daysUntil <= 1 - ? 1.0 - : daysUntil <= 3 - ? 0.8 - : daysUntil <= 7 - ? 0.6 - : daysUntil <= 14 - ? 0.3 - : 0.1; + // Half-life decay: 1.0 right now, halving every 4 days out. + const timeProximity = 2 ** (-daysUntil / 4); const friendRsvpScore = Math.min(1.0, friendsAttending.length / 3.0); - const orgAffinity = event.orgId && myOrgIds.has(event.orgId) ? 1.0 : 0.0; + // Full affinity if you follow/belong to the org, weaker if you've + // just RSVP'd to it before. + const orgAffinity = !event.orgId + ? 0 + : myOrgIds.has(event.orgId) + ? 1.0 + : interactedOrgIds.has(event.orgId) + ? ORG_PAST_INTERACTION_AFFINITY + : 0; const hoursSinceCreated = (now - event.createdAt.getTime()) / (1000 * 60 * 60); const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; + // Log-scaled view count, capped at 1.0 around POPULARITY_VIEW_CAP. + const popularityScore = Math.min( + 1.0, + Math.log((viewCount?.count ?? 0) + 1) / Math.log(POPULARITY_VIEW_CAP + 1), + ); + + // Deterministic per user-per-day-per-event nudge — see RANDOM_WEIGHT. + const randomNudge = seededRandom(`${userId}:${today}:${event.id}`); + const score = 3.0 * interestRelevance + 2.0 * timeProximity + 4.0 * friendRsvpScore + 1.0 * orgAffinity + - 1.0 * recencyBoost; + 1.0 * recencyBoost + + POPULARITY_WEIGHT * popularityScore + + RANDOM_WEIGHT * randomNudge; return { id: event.id, @@ -288,15 +378,44 @@ export async function getFeedEvents(params?: { }), ); - // Sort by score descending; ties break by soonest event first, so - // refreshing Explore with no new data never reorders the feed. + // Sort by score descending; ties break by soonest first, so refreshing + // with no new data never reorders the feed. enriched.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; return a._rawDatetime.getTime() - b._rawDatetime.getTime(); }); + // Cap events per org — anything past the cap keeps its score order, just later. + const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); + + // Guarantee SOON_QUOTA imminent events on the first page by merging them + // in rather than overriding score order outright — this only backfills + // what score order left out. + let page = ranked.slice(offset, offset + limit); + if (offset === 0) { + const isSoon = (e: (typeof ranked)[number]) => + (e._rawDatetime.getTime() - Date.now()) / (1000 * 60 * 60 * 24) <= SOON_WINDOW_DAYS; + + const soonInPage = page.filter(isSoon).length; + if (soonInPage < SOON_QUOTA) { + const pageIds = new Set(page.map((e) => e.id)); + const missingSoon = ranked + .filter((e) => isSoon(e) && !pageIds.has(e.id)) + .slice(0, SOON_QUOTA - soonInPage); + + if (missingSoon.length > 0) { + const merged = [...page]; + const stride = Math.max(1, Math.floor(merged.length / (missingSoon.length + 1))); + missingSoon.forEach((event, i) => { + merged.splice(Math.min(merged.length, stride * (i + 1)), 0, event); + }); + page = merged.slice(0, limit); + } + } + } + return { - events: enriched.map(({ score: _score, _rawDatetime, ...event }) => event), + events: page.map(({ score: _score, _rawDatetime, ...event }) => event), total, }; } diff --git a/docs/ranking.md b/docs/ranking.md new file mode 100644 index 0000000..d906a0e --- /dev/null +++ b/docs/ranking.md @@ -0,0 +1,146 @@ +# Explore feed ranking + +This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`) orders the Explore +feed. It's a plain SQL + in-memory weighted score — no ML, no external service. + +## The formula, in plain English + +Every upcoming event gets a score built from seven signals, each roughly between 0 and 1, +multiplied by a weight: + +| Signal | Weight | What it measures | +|---|---|---| +| Interest relevance | 3.0 | What fraction of this event's tags match your onboarding interests | +| Time proximity | 2.0 | How soon the event is — decays smoothly the further out it is | +| Friend RSVPs | 4.0 | How many of your friends are going (caps out at 3+) | +| Org affinity | 1.0 | Whether you follow/belong to the hosting org, or have RSVP'd to it before | +| Recency | 1.0 | Whether the event was posted in the last 1–3 days | +| Popularity | 0.5 | How many views the event has gotten, log-scaled | +| Random nudge | 0.5 | A small per-user-per-day nudge so the feed varies over time | + +``` +score = 3.0 × interest_relevance + + 2.0 × time_proximity + + 4.0 × friend_rsvp_score + + 1.0 × org_affinity + + 1.0 × recency_boost + + 0.5 × popularity_score + + 0.5 × random_nudge +``` + +Events are sorted by this score, highest first. Friend RSVPs carry the most weight — "people +you know are going" is the strongest signal on a campus app. Popularity and the random nudge +are deliberately small: they nudge the feed, they don't dominate it. + +### Interest relevance + +`matched tags / total tags on the event`. No interests set yet? Everyone gets a neutral 0.5 +instead of 0, so a user with no interests still sees a normal feed, not everything at the +bottom. + +### Time proximity — smooth decay + +``` +time_proximity = 2 ^ (-days_until / 4) +``` + +An event right now scores 1.0, halving every 4 days out (day 4 ≈ 0.5, day 14 ≈ 0.09, day 30 +≈ 0.004). This replaced an earlier bucketed version (`≤1 day = 1.0, ≤3 days = 0.8, …`) whose +score could visibly jump as an event crossed a bucket boundary. The smooth curve keeps the +same intuition — sooner is better, distant events fade but never hit zero — without the jump. + +### Friend RSVPs + +`min(1.0, friends attending / 3)`. Three or more friends going is treated as maximally +compelling; it doesn't climb further past that. + +### Org affinity — tiered + +- **1.0** if you follow or belong to the event's org. +- **0.5** if you don't, but you've RSVP'd to that org's events before + (`ORG_PAST_INTERACTION_AFFINITY` in `events.ts`) — a weaker signal of interest. +- **0** otherwise, or if the event has no org. + +### Recency boost + +1.0 if posted in the last 24 hours, 0.5 if posted in the last 3 days, 0 otherwise — surfaces +newly-posted events before other signals catch up. + +### Popularity + +``` +popularity_score = min(1.0, log(view_count + 1) / log(POPULARITY_VIEW_CAP + 1)) +``` + +Grows logarithmically with view count (logged via `interactions`, see +`apps/web/src/actions/interactions.ts`), capping at 1.0 around `POPULARITY_VIEW_CAP` (50) +views. It's a live per-request count, not a batch job, so it stays cheap. + +### Random nudge — seeded per user, per day + +``` +random_nudge = seededRandom(`${userId}:${today}:${event.id}`) +``` + +A deterministic hash of the user, the current UTC date, and the event ID, normalized to +`[0, 1)`. Not `Math.random()` — the same user looking at the same event on the same day +always gets the same nudge, so refreshing Explore never reshuffles it. The nudge changes +once a day, so events that would otherwise tie get some variety over time. Ties still break +by soonest event first. + +## Candidate pool: why scoring needs more than one page + +The DB query first pulls a bounded pool of the ~100 soonest upcoming events +(`CANDIDATE_POOL_SIZE`), scores all of them, sorts by score, and only then slices out the +requested page (`limit`/`offset`). If scoring only ever ran against the 20 events the caller +asked for, personalization would have nothing to work with — the soonest 20 would always be +exactly what's returned, just reshuffled. Widening the pool first lets a highly relevant +event further down the calendar outrank a less relevant one that merely happens sooner. The +size is a bound to keep the query cheap, not a hard limit on how far ranking can see — 100 +events is comfortably more than a demo dataset needs. + +## Org diversity cap + +After sorting by score, results are capped at `ORG_DIVERSITY_CAP` (3) events per org — once +an org hits 3, its remaining events are pushed later (not dropped), so one heavily-posting +org can't dominate the top of the feed. Events without an org are never capped. + +## Guaranteeing imminent events aren't buried + +Friend RSVPs (weight 4.0) can outweigh time proximity (weight 2.0), so an event with strong +social signal three weeks out could in principle outscore one happening tomorrow with no +friends attending yet. To keep "what's happening soon" reliably visible, the first page +(`offset === 0`) always includes at least `SOON_QUOTA` (3) events within `SOON_WINDOW_DAYS` +(1) day, even if their score wouldn't naturally place them there. + +This is a **merge**, not a score override: if the sorted first page already has enough soon +events, nothing changes. Otherwise the highest-scoring soon events missing from the page are +interleaved into it at evenly-spaced positions — everything else keeps its normal score +order. It only backfills what score order left out, the way feeds inject a freshness quota +without letting it take over the whole ranking. + +All the tunable constants above (`SOON_WINDOW_DAYS`, `SOON_QUOTA`, `ORG_DIVERSITY_CAP`, +`POPULARITY_VIEW_CAP`, `POPULARITY_WEIGHT`, `RANDOM_WEIGHT`, `ORG_PAST_INTERACTION_AFFINITY`) +live at the top of `events.ts`. + +## Edge cases + +- **No interests**: `interest_relevance` defaults to 0.5 for every event. +- **No friends**: `friend_rsvp_score` is 0; the friends-attending lookup is skipped entirely. +- **No org follows/memberships/past RSVPs**: `org_affinity` is 0. + +None of these throw or produce an empty feed — a brand-new user with zero signals still gets +a full feed, ranked by time proximity, recency, popularity, and the random nudge alone. + +## Not implemented (deferred) + +- **Cursor-based pagination** — the API still uses offset/limit; Explore doesn't paginate + past the first page today, so this hasn't been needed yet. +- **Behavioral tag-weight blending & nightly aggregation job** — `user_preference_vectors` + exists in the schema but is never read or written. Blending onboarding interests with + interaction history needs a batch job — real added infrastructure, deliberately out of + scope for this MVP pass. Popularity above is the lightweight, no-batch-job alternative. +- **Similar Events / co-RSVP item-item similarity** — a separate feature, not part of + `getFeedEvents()`. +- **Position-bias correction** (downweighting previously-seen items) — would need + per-request interaction-log reads; not implemented. From f61da831be646adb40db0b06e5fdf66778d9b7bd Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 22:36:20 -0400 Subject: [PATCH 02/69] minor comment fixes --- apps/web/src/actions/events.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 51428fb..e7afa14 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -385,12 +385,10 @@ export async function getFeedEvents(params?: { return a._rawDatetime.getTime() - b._rawDatetime.getTime(); }); - // Cap events per org — anything past the cap keeps its score order, just later. + // Cap events per org const ranked = diversifyByOrg(enriched, ORG_DIVERSITY_CAP); - // Guarantee SOON_QUOTA imminent events on the first page by merging them - // in rather than overriding score order outright — this only backfills - // what score order left out. + // Guarantee SOON_QUOTA imminent events on the first page let page = ranked.slice(offset, offset + limit); if (offset === 0) { const isSoon = (e: (typeof ranked)[number]) => From 7430f6c16e94237416766fb9ba1be009445753db Mon Sep 17 00:00:00 2001 From: Rohan Matta Date: Thu, 20 Aug 2026 22:37:36 -0400 Subject: [PATCH 03/69] ranking description edits --- docs/ranking.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/ranking.md b/docs/ranking.md index d906a0e..be723cf 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -131,16 +131,3 @@ live at the top of `events.ts`. None of these throw or produce an empty feed — a brand-new user with zero signals still gets a full feed, ranked by time proximity, recency, popularity, and the random nudge alone. - -## Not implemented (deferred) - -- **Cursor-based pagination** — the API still uses offset/limit; Explore doesn't paginate - past the first page today, so this hasn't been needed yet. -- **Behavioral tag-weight blending & nightly aggregation job** — `user_preference_vectors` - exists in the schema but is never read or written. Blending onboarding interests with - interaction history needs a batch job — real added infrastructure, deliberately out of - scope for this MVP pass. Popularity above is the lightweight, no-batch-job alternative. -- **Similar Events / co-RSVP item-item similarity** — a separate feature, not part of - `getFeedEvents()`. -- **Position-bias correction** (downweighting previously-seen items) — would need - per-request interaction-log reads; not implemented. From 7e6cdcfcb045a8ed46939443daa6b4acc2210221 Mon Sep 17 00:00:00 2001 From: prishaakapasi Date: Mon, 24 Aug 2026 18:07:01 -0600 Subject: [PATCH 04/69] Error states, consistent search, and handle sparse ingested events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIG-247. ErrorState was sitting in the shared states file unused by anything, so every server action failure was invisible. Wired it into Explore (retry button instead of an empty feed, which reads as "no events" and is a totally different message) and into the notification dropdown, which had an actual `// silently fail` in it. Worse than the missing UI: RSVP and save were updating optimistically and never rolling back. If the server threw, the button stayed flipped while the DB disagreed. Both revert now and toast. Didn't toast on the notification failure — it polls every 60s so that would just be spam. It shows the error inside the dropdown when you open it instead. Search: Explore needed you to press Enter, Orgs debounced as you typed. No reason for them to differ, so Explore debounces now too, plus a result count when a search or filter is active. Ingested events: the server swaps in the literal string "TBD" when an event has no location, and the card was rendering that next to a map pin, which just looks broken. Treats it as absent now. Org, flyer, description and tags were already conditional so a sparse event degrades fine. Settings on a phone: TOP_BAR_CLEARANCE reserved 140px on the right to clear the floating bell/avatar, which is over a third of a 375px screen. Drops below the bar on phones instead, only insets from the right at sm+. Fixes the Events page heading too since it uses the same constant. --- .../src/app/(app)/explore/explore-client.tsx | 96 ++++++++++++++----- apps/web/src/components/events/event-card.tsx | 11 ++- .../layout/notification-dropdown.tsx | 18 +++- apps/web/src/components/layout/page-shell.tsx | 11 ++- 4 files changed, 105 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx index e779ae0..0277081 100644 --- a/apps/web/src/app/(app)/explore/explore-client.tsx +++ b/apps/web/src/app/(app)/explore/explore-client.tsx @@ -2,7 +2,7 @@ import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { useCallback, useMemo, useState, useTransition } from "react"; +import { useCallback, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { type FeedEvent, @@ -12,7 +12,7 @@ import { toggleSave, } from "~/actions/events"; import { SearchInput } from "~/components/common/search-input"; -import { EmptyState, EventCardSkeletonList } from "~/components/common/states"; +import { EmptyState, ErrorState, EventCardSkeletonList } from "~/components/common/states"; import { EventCard } from "~/components/events/event-card"; import { EventFilters } from "~/components/events/event-filters"; import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; @@ -80,16 +80,26 @@ export function ExploreClient({ const [hiddenIds, setHiddenIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(initialSearch); const [isPending, startTransition] = useTransition(); + /** Set when a feed fetch fails, so the list can offer a retry. */ + const [loadError, setLoadError] = useState(false); + const searchTimeout = useRef>(null); const firstName = useMemo(() => userName.split(" ")[0] || "there", [userName]); const refreshEvents = useCallback((filters: string[], search: string) => { startTransition(async () => { - const result = await getFeedEvents({ - tags: filters.length > 0 ? filters : undefined, - search: search || undefined, - }); - setEvents(result.events); - setTotal(result.total); + try { + const result = await getFeedEvents({ + tags: filters.length > 0 ? filters : undefined, + search: search || undefined, + }); + setEvents(result.events); + setTotal(result.total); + setLoadError(false); + } catch { + // Surfaced as an ErrorState with a retry rather than an empty feed, + // which reads as "no events" and is a very different thing. + setLoadError(true); + } }); }, []); @@ -104,22 +114,48 @@ export function ExploreClient({ [activeFilters, searchQuery, refreshEvents], ); - const handleSearch = useCallback(() => { - if (searchQuery.trim()) refreshEvents(activeFilters, searchQuery.trim()); - }, [searchQuery, activeFilters, refreshEvents]); + /* + * Debounced as you type, matching Orgs. Explore used to require Enter, so + * the two search fields behaved differently for no reason. + */ + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value); + if (searchTimeout.current) clearTimeout(searchTimeout.current); + searchTimeout.current = setTimeout(() => { + refreshEvents(activeFilters, value.trim()); + }, 300); + }, + [activeFilters, refreshEvents], + ); + /** Roll the optimistic update back if the server rejects it. */ const handleSaveToggle = useCallback(async (eventId: string) => { - const result = await toggleSave(eventId); - setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: result.saved } : e))); + try { + const result = await toggleSave(eventId); + setEvents((prev) => + prev.map((e) => (e.id === eventId ? { ...e, isSaved: result.saved } : e)), + ); + } catch { + toast.error("Couldn't update saved events. Please try again."); + setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: !e.isSaved } : e))); + } }, []); const handleRsvpToggle = useCallback(async (eventId: string) => { - const result = await toggleRsvp(eventId); - setEvents((prev) => - prev.map((e) => - e.id === eventId ? { ...e, isRsvped: result.rsvped, rsvpCount: result.count } : e, - ), - ); + try { + const result = await toggleRsvp(eventId); + setEvents((prev) => + prev.map((e) => + e.id === eventId ? { ...e, isRsvped: result.rsvped, rsvpCount: result.count } : e, + ), + ); + } catch { + toast.error("Couldn't update your RSVP. Please try again."); + setEvents((prev) => + prev.map((e) => (e.id === eventId ? { ...e, isRsvped: !e.isRsvped } : e)), + ); + } }, []); const upcomingList = useMemo( @@ -158,17 +194,29 @@ export function ExploreClient({ label="Search events" placeholder="Search for events or people" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSearch(); - }} + onChange={(e) => handleSearchChange(e.target.value)} /> + {/* Result count, so a filtered feed says how filtered it is */} + {(searchQuery.trim() || activeFilters.length > 0) && !loadError && ( +

+ {isPending + ? "Searching…" + : `${events.length} ${events.length === 1 ? "event" : "events"} match`} +

+ )} + {/* Feed */}
- {isPending && events.length === 0 ? ( + {loadError ? ( + refreshEvents(activeFilters, searchQuery.trim())} + /> + ) : isPending && events.length === 0 ? ( ) : events.length === 0 ? ( friend.displayName); const remainingFriends = friendsAttending.length - displayedFriendNames.length; @@ -305,7 +312,7 @@ export function EventCard({
- {location && ( + {hasLocation && ( {location} @@ -542,7 +549,7 @@ export function EventCard({ {/* Location & Time */}
- {location && ( + {hasLocation && (
diff --git a/apps/web/src/components/layout/notification-dropdown.tsx b/apps/web/src/components/layout/notification-dropdown.tsx index ca76cff..83d860d 100644 --- a/apps/web/src/components/layout/notification-dropdown.tsx +++ b/apps/web/src/components/layout/notification-dropdown.tsx @@ -10,6 +10,7 @@ import { markAllNotificationsRead, markNotificationRead, } from "~/actions/notifications"; +import { ErrorState } from "~/components/common/states"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; @@ -31,13 +32,21 @@ export function NotificationDropdown() { const [open, setOpen] = useState(false); const [limit, setLimit] = useState(20); + const [loadFailed, setLoadFailed] = useState(false); + const fetchNotifications = useCallback(async () => { try { const data = await getNotifications(); setItems(data.items); setUnreadCount(data.unreadCount); + setLoadFailed(false); } catch { - // silently fail + /* + * Recorded rather than swallowed. This polls on a 60s interval, so a + * toast per failure would be spam — the dropdown says so instead, and + * only when you open it. + */ + setLoadFailed(true); } }, []); @@ -114,6 +123,13 @@ export function NotificationDropdown() { }} /> )) + ) : loadFailed ? ( + /* An empty list and a failed fetch mean very different things. */ + ) : (
diff --git a/apps/web/src/components/layout/page-shell.tsx b/apps/web/src/components/layout/page-shell.tsx index 71dc261..a118013 100644 --- a/apps/web/src/components/layout/page-shell.tsx +++ b/apps/web/src/components/layout/page-shell.tsx @@ -57,15 +57,18 @@ export function PageShell({ } /** - * Right padding that keeps a page's top-right control clear of the TopBar. + * Keeps a page's top-right control clear of the floating TopBar. * * The TopBar (notification bell + avatar) is absolutely positioned over the * content area on every route, so anything sharing that band — a page heading's * trailing action — collides with it once the viewport is narrower than the - * shell's max width. Roughly 24px page padding + 36px bell + 12px gap + 40px - * avatar + 24px padding, rounded up. + * shell's max width. + * + * From `sm` up it reserves ~140px on the right: 24px page padding + 36px bell + + * 12px gap + 40px avatar + 24px padding, rounded up. On phones that would eat + * over a third of the screen, so the content drops *below* the bar instead. */ -export const TOP_BAR_CLEARANCE = "pr-[140px]"; +export const TOP_BAR_CLEARANCE = "pt-14 sm:pt-0 sm:pr-[140px]"; /** * Page-level `

`. One ramp for every page — previously these ranged from From a7559d8bfae28828ace031a4bc20b622b4b65f5d Mon Sep 17 00:00:00 2001 From: prishaakapasi Date: Tue, 25 Aug 2026 11:50:42 -0600 Subject: [PATCH 05/69] Two-column feed, card polish, and search race fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home feed now matches the Figma: event cards in a two-column grid with the highlights rail pinned to the right, so only the feed shifts when the nav rail expands. The shell runs full width instead of max-w-7xl. With a capped shell the whole row re-centred and the rail travelled with it. Card, on Explore and the map's expanded view alike: - utility icons (save, share, hide, open) go coral, hovering to a coral wash rather than the ghost variant's full-strength turquoise --accent - tags alternate yellow/turquoise, friends line reads "… added this event to their calendar!", description gains a See Details link - footer wraps as two units with the redundant Users glyph dropped, so "4 attending" stops breaking across two lines in a narrow column - the map modal picks up the shared date formatter and the "+ Calendar" action; buildGCalUrl was extracted for that call site and never wired up Fixes from review: - feed requests carry a monotonic id and a queued search is cancelled when filters change, so a debounce armed with the previous filters can no longer land last and overwrite the feed - save/RSVP flip optimistically and genuinely revert on failure; the catch used to invert a value that was still correct. Handlers rethrow so the card announces success only once the request resolves, instead of showing a success toast beside the error one - the result count uses the returned total, not the 20-row page length - getFeedEvents carries rawDatetime through; it was declared on FeedEvent for calendar links but dropped with the sort key Rail backdrop goes near-opaque over the map, where 50% let street names read straight through the nav labels. --- apps/web/src/actions/events.ts | 3 + .../src/app/(app)/explore/explore-client.tsx | 166 +++++++++---- .../map/_components/event-detail-modal.tsx | 26 +- apps/web/src/components/common/states.tsx | 5 +- apps/web/src/components/events/event-card.tsx | 231 ++++++++++++------ apps/web/src/components/layout/sidebar.tsx | 18 +- 6 files changed, 320 insertions(+), 129 deletions(-) diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index ca169b2..974440b 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -440,6 +440,9 @@ export async function getFeedEvents(params?: { return { events: enriched.map(({ score: _score, _rawDatetime, ...event }) => ({ ...event, + // Carried through so the feed card can build its "+ Calendar" link; the + // sort key was being dropped here and the button never rendered. + rawDatetime: _rawDatetime.toISOString(), attendees: attendeesByEvent.get(event.id) ?? [], })), total, diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx index 0277081..0af7406 100644 --- a/apps/web/src/app/(app)/explore/explore-client.tsx +++ b/apps/web/src/app/(app)/explore/explore-client.tsx @@ -2,7 +2,7 @@ import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { useCallback, useMemo, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { type FeedEvent, @@ -17,6 +17,7 @@ import { EventCard } from "~/components/events/event-card"; import { EventFilters } from "~/components/events/event-filters"; import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; +import { buildGCalUrl } from "~/lib/calendar"; import { formatEventDateTime, formatRelativeDay } from "~/lib/date-format"; interface ExploreClientProps { @@ -71,7 +72,12 @@ export function ExploreClient({ }: ExploreClientProps) { const fallbackEvents = initialEvents.length > 0 ? initialEvents : [demoEvent]; const [events, setEvents] = useState(fallbackEvents); - const [_total, setTotal] = useState(initialEvents.length > 0 ? initialTotal : 1); + /* + * The full match count, not the page size. `getFeedEvents` pages at 20 while + * returning a separate count over every match, so reporting `events.length` + * capped the message at "20 events match" no matter how many there were. + */ + const [total, setTotal] = useState(initialEvents.length > 0 ? initialTotal : 1); const [activeFilters, setActiveFilters] = useState([]); /* * Hidden events stay in the list as collapsed stubs rather than being @@ -83,19 +89,36 @@ export function ExploreClient({ /** Set when a feed fetch fails, so the list can offer a retry. */ const [loadError, setLoadError] = useState(false); const searchTimeout = useRef>(null); + /* + * Monotonic id for feed requests. Only the most recently issued one may write + * to state: two fetches can be in flight at once (type, then toggle a filter), + * and without this the slower-but-older response lands last and wins. + */ + const latestRequest = useRef(0); const firstName = useMemo(() => userName.split(" ")[0] || "there", [userName]); + /** Drop a queued debounced search — it carries whatever filters were active when it was armed. */ + const cancelPendingSearch = useCallback(() => { + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + searchTimeout.current = null; + } + }, []); + const refreshEvents = useCallback((filters: string[], search: string) => { + const requestId = ++latestRequest.current; startTransition(async () => { try { const result = await getFeedEvents({ tags: filters.length > 0 ? filters : undefined, search: search || undefined, }); + if (requestId !== latestRequest.current) return; setEvents(result.events); setTotal(result.total); setLoadError(false); } catch { + if (requestId !== latestRequest.current) return; // Surfaced as an ErrorState with a retry rather than an empty feed, // which reads as "no events" and is a very different thing. setLoadError(true); @@ -103,15 +126,26 @@ export function ExploreClient({ }); }, []); + // A queued search outliving the component would fetch for a dead screen. + useEffect(() => cancelPendingSearch, [cancelPendingSearch]); + const handleFilterToggle = useCallback( (filterId: string) => { const next = activeFilters.includes(filterId) ? activeFilters.filter((f) => f !== filterId) : [...activeFilters, filterId]; setActiveFilters(next); - refreshEvents(next, searchQuery); + /* + * Cancel first. A search queued moments ago captured the *previous* + * filters, so letting it fire would re-fetch without the chip the user + * just clicked and overwrite this result — the feed and the active + * filters would disagree until the next interaction. The fetch below + * already carries the current query, so nothing is lost by dropping it. + */ + cancelPendingSearch(); + refreshEvents(next, searchQuery.trim()); }, - [activeFilters, searchQuery, refreshEvents], + [activeFilters, searchQuery, refreshEvents, cancelPendingSearch], ); /* @@ -129,20 +163,38 @@ export function ExploreClient({ [activeFilters, refreshEvents], ); - /** Roll the optimistic update back if the server rejects it. */ + /* + * Flip the card first so the bookmark reacts on click, then reconcile with + * whatever the server actually stored. Without the leading flip there was + * nothing to roll back, and the `catch` inverted a value that was still + * correct — leaving the UI disagreeing with the database. + * + * Rethrown so the card knows not to announce success; the error toast here + * is the only feedback the failure gets. + */ const handleSaveToggle = useCallback(async (eventId: string) => { + setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: !e.isSaved } : e))); try { const result = await toggleSave(eventId); setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: result.saved } : e)), ); - } catch { + } catch (error) { toast.error("Couldn't update saved events. Please try again."); setEvents((prev) => prev.map((e) => (e.id === eventId ? { ...e, isSaved: !e.isSaved } : e))); + throw error; } }, []); + /** Same optimistic-then-reconcile shape as `handleSaveToggle`, plus the count. */ const handleRsvpToggle = useCallback(async (eventId: string) => { + const flip = (e: FeedEvent) => ({ + ...e, + isRsvped: !e.isRsvped, + rsvpCount: Math.max(0, e.rsvpCount + (e.isRsvped ? -1 : 1)), + }); + + setEvents((prev) => prev.map((e) => (e.id === eventId ? flip(e) : e))); try { const result = await toggleRsvp(eventId); setEvents((prev) => @@ -150,11 +202,10 @@ export function ExploreClient({ e.id === eventId ? { ...e, isRsvped: result.rsvped, rsvpCount: result.count } : e, ), ); - } catch { + } catch (error) { toast.error("Couldn't update your RSVP. Please try again."); - setEvents((prev) => - prev.map((e) => (e.id === eventId ? { ...e, isRsvped: !e.isRsvped } : e)), - ); + setEvents((prev) => prev.map((e) => (e.id === eventId ? flip(e) : e))); + throw error; } }, []); @@ -168,11 +219,18 @@ export function ExploreClient({ * with its own internal scroll only kicks in at xl, where the right rail * appears. Nesting a scroll container inside the page scroller on a phone * made the feed feel stuck. + * + * The shell runs full width rather than `wide` (max-w-7xl) so the row's right + * edge is the content area's right edge. That is what keeps the highlights + * rail still while the nav rail expands: only the shell's *left* edge moves, + * so the greeting, search field and cards slide right and the feed narrows, + * while the rail — pinned to the right by `ml-auto` — does not budge. With a + * capped shell the whole row re-centred and the rail travelled with it. */ return ( - - {/* CENTER — Feed */} -
+ + {/* CENTER — Feed. Capped so cards stay card-sized on very wide displays. */} +
@@ -186,13 +244,13 @@ export function ExploreClient({ } > - Hi + Hello {firstName}, handleSearchChange(e.target.value)} /> @@ -204,7 +262,7 @@ export function ExploreClient({

{isPending ? "Searching…" - : `${events.length} ${events.length === 1 ? "event" : "events"} match`} + : `${total} ${total === 1 ? "event matches" : "events match"}`}

)} @@ -214,7 +272,10 @@ export function ExploreClient({ refreshEvents(activeFilters, searchQuery.trim())} + onRetry={() => { + cancelPendingSearch(); + refreshEvents(activeFilters, searchQuery.trim()); + }} /> ) : isPending && events.length === 0 ? ( @@ -228,31 +289,50 @@ export function ExploreClient({ } /> ) : ( - events.map((event, index) => ( - handleSaveToggle(event.id)} - onRsvpToggle={() => handleRsvpToggle(event.id)} - onShare={() => { - navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); - toast.success("Link copied to clipboard"); - }} - isHidden={hiddenIds.has(event.id)} - onHide={() => { - setHiddenIds((prev) => new Set(prev).add(event.id)); - }} - onUnhide={() => { - setHiddenIds((prev) => { - const next = new Set(prev); - next.delete(event.id); - return next; - }); - }} - /> - )) + /* + * Two columns from `sm` up. Cards stretch to the row height so a + * short description doesn't leave its neighbour's RSVP row floating + * at a different height. + */ +
+ {events.map((event, index) => ( + handleSaveToggle(event.id)} + onRsvpToggle={() => handleRsvpToggle(event.id)} + onShare={() => { + navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); + toast.success("Link copied to clipboard"); + }} + isHidden={hiddenIds.has(event.id)} + onHide={() => { + setHiddenIds((prev) => new Set(prev).add(event.id)); + }} + onUnhide={() => { + setHiddenIds((prev) => { + const next = new Set(prev); + next.delete(event.id); + return next; + }); + }} + /> + ))} +
)}
@@ -265,7 +345,7 @@ export function ExploreClient({ */}

-
@@ -581,7 +627,7 @@ export function EventCard({ key={tag} className={cn( "rounded-[10px] px-2 py-px font-dm-sans text-[12px] text-black", - compact && i === 1 ? "bg-forum-turquoise-50" : "bg-forum-yellow-50", + (compact ? i === 1 : i > 0) ? "bg-forum-turquoise-50" : "bg-forum-yellow-50", )} > {tag} @@ -598,31 +644,43 @@ export function EventCard({
) : (
- +

- {displayedFriendNames.join( - displayedFriendNames.length === 2 && remainingFriends === 0 ? " and " : ", ", - )} - - {remainingFriends > 0 && + {remainingFriends} more} - {friendsAttending.length === 1 ? "is" : "are"} also going. + {displayedFriendNames.join(", ")} + {remainingFriends > 0 && ` + ${remainingFriends} other`} + {" "} + added this event to their calendar!

))} - {/* Description — full card only */} + {/* Description — full card only. Clamped to the mock's three lines, with + "See Details" carrying the rest. */} {!compact && description && ( -

- {description} -

+ <> +

+ {description} +

+ + See Details + + )} {/* Footer actions — right gutter keeps clear of the corner avatar cluster */} - {(onRsvpToggle || onLocate) && ( + {(onRsvpToggle || onLocate || calendarUrl) && (
0 && "pr-20", )} > @@ -637,53 +695,78 @@ export function EventCard({ ) : ( /* Avatar stack + "N attending", clickable to see the full list. */ -
- {attendees.length > 0 && } - {rsvpCount ? ( -
- + (attendees.length > 0 || Boolean(rsvpCount)) && ( +
+ {attendees.length > 0 && } + {rsvpCount ? ( f.id))} + /* Sized to the card's own metadata scale — 14px bold black + shouted over the title's own details — and kept on one + line, which is what wrapped to "4 / attending". */ + className="whitespace-nowrap text-[12px] font-medium text-forum-dark-gray" /> -
- ) : null} -
+ ) : null} +
+ ) )} - {onRsvpToggle && ( - - )} + {/* Calendar + RSVP, gathered at the card's bottom-right as in the mock. */} +
+ {calendarUrl && ( + + )} + {onRsvpToggle && ( + + )} +
)}
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 3edad80..8bf9aad 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -84,8 +84,22 @@ export function Sidebar({ floating = false }: { floating?: boolean }) { "w-[64px] group-hover/rail:w-[200px] group-focus-within/rail:w-[200px]", )} > - {/* Rail backdrop — #ECFCFC at 50%. */} - + {/* + Rail backdrop — #ECFCFC. + + Half opacity reads fine over the app's near-white pages, but the map + is a dense, high-contrast canvas: at 50% the streets and building + labels ran straight through the nav labels. Over the map the panel + goes nearly solid and blurs what's behind it, so "Home" is read + against a flat tint rather than Nassau Street. + */} +
diff --git a/apps/web/src/app/(app)/explore/page.tsx b/apps/web/src/app/(app)/explore/page.tsx index 82c711f..40cb922 100644 --- a/apps/web/src/app/(app)/explore/page.tsx +++ b/apps/web/src/app/(app)/explore/page.tsx @@ -20,6 +20,7 @@ export default async function ExplorePage({ >> 0) / 0x100000000; +} + +export interface ScoringContext { + userId: string; + /** The instant the ranking is computed for (see `asOf` in docs/ranking.md). */ + now: number; + interestTags: ReadonlySet; + /** Orgs the viewer follows or belongs to. */ + myOrgIds: ReadonlySet; + /** Orgs whose events the viewer has RSVP'd to before. */ + interactedOrgIds: ReadonlySet; +} + +export interface ScoringInput { + id: string; + orgId: string | null; + startsAt: number; + createdAt: number; + tags: readonly string[]; + friendsAttendingCount: number; + viewCount: number; +} + +export function scoreEvent(event: ScoringInput, ctx: ScoringContext): number { + // Fraction of this event's tags that match your interests. No interests set + // yet → neutral 0.5 so a brand-new user still gets a normal feed. + const matched = event.tags.filter((t) => ctx.interestTags.has(t)).length; + const interestRelevance = + ctx.interestTags.size === 0 ? 0.5 : event.tags.length === 0 ? 0 : matched / event.tags.length; + + // Half-life decay: 1.0 right now, halving every TIME_HALF_LIFE_DAYS days out. + const daysUntil = Math.max(0, (event.startsAt - ctx.now) / DAY_MS); + const timeProximity = 2 ** (-daysUntil / TIME_HALF_LIFE_DAYS); + + const friendRsvpScore = Math.min(1.0, event.friendsAttendingCount / 3.0); + + const orgAffinity = !event.orgId + ? 0 + : ctx.myOrgIds.has(event.orgId) + ? 1.0 + : ctx.interactedOrgIds.has(event.orgId) + ? ORG_PAST_INTERACTION_AFFINITY + : 0; + + const hoursSinceCreated = (ctx.now - event.createdAt) / HOUR_MS; + const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; + + const popularityScore = Math.min( + 1.0, + Math.log(event.viewCount + 1) / Math.log(POPULARITY_VIEW_CAP + 1), + ); + + // Seeded per user, per (UTC) day, per event — varies over time but never on refresh. + const day = new Date(ctx.now).toISOString().slice(0, 10); + const randomNudge = seededRandom(`${ctx.userId}:${day}:${event.id}`); + + return ( + WEIGHTS.interest * interestRelevance + + WEIGHTS.time * timeProximity + + WEIGHTS.friends * friendRsvpScore + + WEIGHTS.org * orgAffinity + + WEIGHTS.recency * recencyBoost + + WEIGHTS.popularity * popularityScore + + WEIGHTS.random * randomNudge + ); +} + +/** Score desc, then soonest first, then id — a total order, so sorting is fully deterministic. */ +export function compareScored( + a: { id: string; score: number; startsAt: number }, + b: { id: string; score: number; startsAt: number }, +): number { + if (b.score !== a.score) return b.score - a.score; + if (a.startsAt !== b.startsAt) return a.startsAt - b.startsAt; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +export interface OrderingOptions { + now: number; + orgCap?: number; + orgWindow?: number; + soonQuota?: number; + soonWindowDays?: number; + soonInjectionWindow?: number; +} + +/** + * Turn a score-sorted list into the final feed order, over the WHOLE candidate + * list, before any pagination. Every page is then a plain slice of this one + * array, so nothing can be duplicated or skipped across pages. + * + * Greedy, one position at a time: + * - An item is *allowed* at a position if its org has fewer than `orgCap` + * items in the previous `orgWindow - 1` positions (events without an org + * are never capped). + * - Within the first `soonInjectionWindow` positions, the soon quota is + * spread evenly (for 3 in 20: by positions 5, 10 and 15). When the quota is + * behind schedule, the highest-scoring *allowed* soon event is placed. + * Soon injection never breaks the org cap — if no allowed soon event + * exists, the quota is simply left unmet. + * - Otherwise the highest-scoring allowed item is placed. + * - If no remaining item is allowed (the rest of the feed is all from orgs + * already at the cap in this window), the highest-scoring remaining item is + * placed anyway. That only happens at the tail, where there is nothing else + * left to show. + * + * The result is a permutation of the input: same length, nothing dropped. + */ +export function finalizeFeedOrder( + sorted: readonly T[], + opts: OrderingOptions, +): T[] { + const orgCap = opts.orgCap ?? ORG_DIVERSITY_CAP; + const orgWindow = opts.orgWindow ?? ORG_DIVERSITY_WINDOW; + const soonQuota = opts.soonQuota ?? SOON_QUOTA; + const soonCutoff = opts.now + (opts.soonWindowDays ?? SOON_WINDOW_DAYS) * DAY_MS; + const front = Math.min(opts.soonInjectionWindow ?? SOON_INJECTION_WINDOW, sorted.length); + + const isSoon = (item: T) => item.startsAt <= soonCutoff; + + // Target positions for the soon quota, spread evenly through the front window. + const soonTargets: number[] = []; + for (let k = 0; k < soonQuota; k++) { + soonTargets.push(Math.floor(((k + 1) * front) / (soonQuota + 1))); + } + + const used = new Array(sorted.length).fill(false); + const windowCounts = new Map(); + const result: T[] = []; + let firstUnused = 0; + let soonPlaced = 0; + + const allowed = (item: T) => !item.orgId || (windowCounts.get(item.orgId) ?? 0) < orgCap; + + const findFirst = (predicate: (item: T) => boolean): number => { + for (let i = firstUnused; i < sorted.length; i++) { + const item = sorted[i] as T; + if (!used[i] && predicate(item)) return i; + } + return -1; + }; + + for (let position = 0; position < sorted.length; position++) { + // Slide the org window: drop the item that just fell out of it. + if (position >= orgWindow) { + const leaving = result[position - orgWindow] as T; + if (leaving.orgId) { + windowCounts.set(leaving.orgId, (windowCounts.get(leaving.orgId) ?? 1) - 1); + } + } + + let pick = -1; + if (position < front) { + const requiredByNow = soonTargets.filter((t) => t <= position).length; + if (soonPlaced < requiredByNow) { + pick = findFirst((item) => isSoon(item) && allowed(item)); + } + } + if (pick === -1) pick = findFirst(allowed); + if (pick === -1) pick = findFirst(() => true); + + const item = sorted[pick] as T; + used[pick] = true; + while (firstUnused < sorted.length && used[firstUnused]) firstUnused++; + + result.push(item); + if (position < front && isSoon(item)) soonPlaced++; + if (item.orgId) windowCounts.set(item.orgId, (windowCounts.get(item.orgId) ?? 0) + 1); + } + + return result; +} diff --git a/apps/web/src/lib/feed.ts b/apps/web/src/lib/feed.ts new file mode 100644 index 0000000..17b196f --- /dev/null +++ b/apps/web/src/lib/feed.ts @@ -0,0 +1,397 @@ +import { + events, + and, + campusLocations, + db, + eq, + eventTags, + gt, + gte, + ilike, + inArray, + interactions, + lt, + or, + orgFollowers, + orgMembers, + organizations, + rsvps, + savedEvents, + sql, + userInterests, + users, +} from "@the-forum/database"; +import type { FeedEvent } from "~/actions/events"; +import { formatEventDateTime } from "~/lib/date-format"; +import { + CANDIDATE_HORIZON_DAYS, + CANDIDATE_POOL_CAP, + PERSONAL_CANDIDATE_CAP, + type ScoringContext, + compareScored, + finalizeFeedOrder, + scoreEvent, +} from "~/lib/feed-ranking"; +import { loadFriendIds } from "~/lib/social-graph"; +import { containsPattern } from "~/lib/sql-helpers"; + +type EventTag = typeof eventTags.$inferSelect.tag; +type OrgCategory = typeof organizations.$inferSelect.category; + +export interface FeedQuery { + search?: string; + tags?: EventTag[]; + orgCategory?: OrgCategory; + locationId?: string; + dateRange?: "today" | "week" | "month"; + limit: number; + offset: number; + /** + * The instant page 1 was ranked at, echoed back by the client for later + * pages so every page is a slice of the same ranking. Ignored if missing or + * older than ASOF_MAX_AGE_MS. + */ + asOf?: Date; +} + +export interface FeedPage { + events: FeedEvent[]; + /** Size of the ranked candidate list — exactly what offset/limit can reach. */ + total: number; + /** ISO timestamp the ranking was computed for; pass it back as `asOf` for the next page. */ + asOf: string; +} + +/** A stale `asOf` is ignored, so a tab left open overnight gets a fresh feed. */ +const ASOF_MAX_AGE_MS = 30 * 60 * 1000; + +function resolveNow(asOf: Date | undefined): number { + const realNow = Date.now(); + if (!asOf) return realNow; + const t = asOf.getTime(); + if (Number.isNaN(t) || t > realNow + 60_000 || realNow - t > ASOF_MAX_AGE_MS) return realNow; + return t; +} + +function dateRangeEnd(now: number, dateRange: FeedQuery["dateRange"]): Date { + if (dateRange === "today") { + const end = new Date(now); + end.setHours(23, 59, 59, 999); + return end; + } + if (dateRange === "week") return new Date(now + 7 * 24 * 60 * 60 * 1000); + if (dateRange === "month") return new Date(now + 30 * 24 * 60 * 60 * 1000); + const end = new Date(now); + end.setDate(end.getDate() + CANDIDATE_HORIZON_DAYS); + end.setHours(23, 59, 59, 999); + return end; +} + +/** + * Rank the Explore feed for `userId` and return one page of it. + * + * 1. Candidate generation (docs/ranking.md, "Candidate pool"). + * 2. Batched enrichment of every candidate — one `inArray` query per signal. + * 3. Score, sort, then finalize ONE order over the whole list (org cap + + * soon-event quota) before slicing out the requested page. + * 4. Attendee rosters are loaded for the returned page only. + */ +export async function loadRankedFeed(userId: string, query: FeedQuery): Promise { + const now = resolveNow(query.asOf); + const nowDate = new Date(now); + const asOf = nowDate.toISOString(); + + // ── Viewer context ────────────────────────────────────── + const [interestRows, friendIds, followedOrgRows, memberOrgRows, interactedOrgRows] = + await Promise.all([ + db + .select({ tag: userInterests.tag }) + .from(userInterests) + .where(eq(userInterests.userId, userId)), + loadFriendIds(userId), + db + .select({ orgId: orgFollowers.orgId }) + .from(orgFollowers) + .where(eq(orgFollowers.userId, userId)), + db.select({ orgId: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId)), + // Orgs the user has RSVP'd to before — a weaker org-affinity signal. + db + .selectDistinct({ orgId: events.orgId }) + .from(rsvps) + .innerJoin(events, eq(rsvps.eventId, events.id)) + .where(eq(rsvps.userId, userId)), + ]); + + const interestTags = interestRows.map((r) => r.tag); + const myOrgIds = new Set([ + ...followedOrgRows.map((o) => o.orgId), + ...memberOrgRows.map((o) => o.orgId), + ]); + const interactedOrgIds = new Set( + interactedOrgRows.map((r) => r.orgId).filter((id): id is string => id !== null), + ); + + // ── Candidate generation ──────────────────────────────── + const conditions = [ + gt(events.datetime, nowDate), + lt(events.datetime, dateRangeEnd(now, query.dateRange)), + eq(events.status, "published"), + ]; + + if (query.search) { + const pattern = containsPattern(query.search); + const searchCondition = or(ilike(events.title, pattern), ilike(events.description, pattern)); + if (searchCondition) conditions.push(searchCondition); + } + if (query.tags && query.tags.length > 0) { + conditions.push( + inArray( + events.id, + db + .select({ eventId: eventTags.eventId }) + .from(eventTags) + .where(inArray(eventTags.tag, query.tags)), + ), + ); + } + if (query.orgCategory) { + conditions.push( + inArray( + events.orgId, + db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.category, query.orgCategory)), + ), + ); + } + if (query.locationId) { + conditions.push(eq(events.locationId, query.locationId)); + } + + const candidateColumns = { + id: events.id, + title: events.title, + description: events.description, + datetime: events.datetime, + flyerUrl: events.flyerUrl, + locationName: campusLocations.name, + orgId: events.orgId, + orgName: organizations.name, + createdAt: events.createdAt, + }; + + // Stage 1: everything within the horizon, soonest first, up to the cap. + const timeOrdered = await db + .select(candidateColumns) + .from(events) + .leftJoin(campusLocations, eq(events.locationId, campusLocations.id)) + .leftJoin(organizations, eq(events.orgId, organizations.id)) + .where(and(...conditions)) + .orderBy(events.datetime, events.id) + .limit(CANDIDATE_POOL_CAP); + + let candidates = timeOrdered; + + // Stage 2 (only if stage 1 saturated): events past the stage-1 cutoff that + // carry a personal signal, so a dense calendar can't wall them off. + const lastTimeOrdered = timeOrdered[timeOrdered.length - 1]; + if (timeOrdered.length === CANDIDATE_POOL_CAP && lastTimeOrdered) { + const personalSignals = [ + myOrgIds.size > 0 ? inArray(events.orgId, [...myOrgIds]) : undefined, + friendIds.length > 0 + ? inArray( + events.id, + db + .select({ eventId: rsvps.eventId }) + .from(rsvps) + .where(inArray(rsvps.userId, friendIds)), + ) + : undefined, + interestTags.length > 0 + ? inArray( + events.id, + db + .select({ eventId: eventTags.eventId }) + .from(eventTags) + .where(inArray(eventTags.tag, interestTags)), + ) + : undefined, + ].filter((c) => c !== undefined); + + if (personalSignals.length > 0) { + const personal = await db + .select(candidateColumns) + .from(events) + .leftJoin(campusLocations, eq(events.locationId, campusLocations.id)) + .leftJoin(organizations, eq(events.orgId, organizations.id)) + .where( + and( + ...conditions, + gte(events.datetime, lastTimeOrdered.datetime), + or(...personalSignals), + ), + ) + .orderBy(events.datetime, events.id) + .limit(PERSONAL_CANDIDATE_CAP); + + const seen = new Set(timeOrdered.map((e) => e.id)); + candidates = [...timeOrdered, ...personal.filter((e) => !seen.has(e.id))]; + } + + console.warn( + `loadRankedFeed: time-ordered candidate pool hit CANDIDATE_POOL_CAP (${CANDIDATE_POOL_CAP}); ` + + `ranked ${candidates.length} candidates after personalised expansion.`, + ); + } + + if (candidates.length === 0) return { events: [], total: 0, asOf }; + + // ── Batched enrichment (one query per signal, whole candidate set) ── + const candidateIds = candidates.map((e) => e.id); + const [tagRows, rsvpCountRows, viewCountRows, friendRsvpRows, myRsvpRows, mySaveRows] = + await Promise.all([ + db + .select({ eventId: eventTags.eventId, tag: eventTags.tag }) + .from(eventTags) + .where(inArray(eventTags.eventId, candidateIds)), + db + .select({ eventId: rsvps.eventId, count: sql`count(*)::int` }) + .from(rsvps) + .where(inArray(rsvps.eventId, candidateIds)) + .groupBy(rsvps.eventId), + db + .select({ eventId: interactions.itemId, count: sql`count(*)::int` }) + .from(interactions) + .where( + and( + inArray(interactions.itemId, candidateIds), + eq(interactions.itemType, "event"), + eq(interactions.interactionType, "view"), + ), + ) + .groupBy(interactions.itemId), + friendIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(and(inArray(rsvps.eventId, candidateIds), inArray(rsvps.userId, friendIds))), + db + .select({ eventId: rsvps.eventId }) + .from(rsvps) + .where(and(eq(rsvps.userId, userId), inArray(rsvps.eventId, candidateIds))), + db + .select({ eventId: savedEvents.eventId }) + .from(savedEvents) + .where(and(eq(savedEvents.userId, userId), inArray(savedEvents.eventId, candidateIds))), + ]); + + const tagsByEvent = groupBy( + tagRows, + (r) => r.eventId, + (r) => r.tag, + ); + const friendsByEvent = groupBy( + friendRsvpRows, + (r) => r.eventId, + (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), + ); + const rsvpCountByEvent = new Map(rsvpCountRows.map((r) => [r.eventId, r.count])); + const viewCountByEvent = new Map(viewCountRows.map((r) => [r.eventId, r.count])); + const myRsvps = new Set(myRsvpRows.map((r) => r.eventId)); + const mySaves = new Set(mySaveRows.map((r) => r.eventId)); + + // ── Score, sort, finalize ─────────────────────────────── + const ctx: ScoringContext = { + userId, + now, + interestTags: new Set(interestTags), + myOrgIds, + interactedOrgIds, + }; + + const scored = candidates.map((event) => { + const tags = tagsByEvent.get(event.id) ?? []; + const friendsAttending = friendsByEvent.get(event.id) ?? []; + const startsAt = event.datetime.getTime(); + const score = scoreEvent( + { + id: event.id, + orgId: event.orgId, + startsAt, + createdAt: event.createdAt.getTime(), + tags, + friendsAttendingCount: friendsAttending.length, + viewCount: viewCountByEvent.get(event.id) ?? 0, + }, + ctx, + ); + return { event, tags, friendsAttending, startsAt, score, id: event.id, orgId: event.orgId }; + }); + scored.sort(compareScored); + + const ordered = finalizeFeedOrder(scored, { now }); + const page = ordered.slice(query.offset, query.offset + query.limit); + + // ── Attendee rosters: final page only ─────────────────── + const pageIds = page.map((p) => p.id); + const attendeeRows = + pageIds.length === 0 + ? [] + : await db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(inArray(rsvps.eventId, pageIds)) + .orderBy(rsvps.createdAt); + const attendeesByEvent = groupBy( + attendeeRows, + (r) => r.eventId, + (r) => ({ id: r.id, displayName: r.displayName, avatarUrl: r.avatarUrl }), + ); + + return { + events: page.map(({ event, tags, friendsAttending }) => ({ + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + datetime: formatEventDateTime(event.datetime), + rawDatetime: event.datetime.toISOString(), + location: event.locationName ?? "TBD", + tags, + flyerUrl: event.flyerUrl, + rsvpCount: rsvpCountByEvent.get(event.id) ?? 0, + friendsAttending, + attendees: attendeesByEvent.get(event.id) ?? [], + isRsvped: myRsvps.has(event.id), + isSaved: mySaves.has(event.id), + })), + total: ordered.length, + asOf, + }; +} + +function groupBy(rows: readonly R[], key: (row: R) => string, value: (row: R) => V) { + const map = new Map(); + for (const row of rows) { + const k = key(row); + const list = map.get(k); + if (list) list.push(value(row)); + else map.set(k, [value(row)]); + } + return map; +} diff --git a/apps/web/src/lib/social-graph.ts b/apps/web/src/lib/social-graph.ts new file mode 100644 index 0000000..87d434d --- /dev/null +++ b/apps/web/src/lib/social-graph.ts @@ -0,0 +1,21 @@ +import { and, db, eq, friendships } from "@the-forum/database"; + +/** + * Accepted friend ids for a user. Friendships are stored one-directional + * (requester → recipient), so both columns are read. + * + * Server-only: imported by server actions and `~/lib/feed.ts`. + */ +export async function loadFriendIds(userId: string): Promise { + const [outgoing, incoming] = await Promise.all([ + db + .select({ friendId: friendships.friendId }) + .from(friendships) + .where(and(eq(friendships.userId, userId), eq(friendships.status, "accepted"))), + db + .select({ friendId: friendships.userId }) + .from(friendships) + .where(and(eq(friendships.friendId, userId), eq(friendships.status, "accepted"))), + ]); + return [...new Set([...outgoing, ...incoming].map((r) => r.friendId))]; +} diff --git a/apps/web/src/lib/sql-helpers.ts b/apps/web/src/lib/sql-helpers.ts new file mode 100644 index 0000000..0148a96 --- /dev/null +++ b/apps/web/src/lib/sql-helpers.ts @@ -0,0 +1,12 @@ +/** + * Escape `%`, `_` and `\` so user input is matched literally inside an + * ILIKE pattern (Postgres' default LIKE escape character is `\`). + */ +export function escapeLike(input: string): string { + return input.replace(/[\\%_]/g, (ch) => `\\${ch}`); +} + +/** `%input%` with wildcards in the input escaped. */ +export function containsPattern(input: string): string { + return `%${escapeLike(input)}%`; +} diff --git a/apps/web/src/lib/validation.ts b/apps/web/src/lib/validation.ts new file mode 100644 index 0000000..dee7c06 --- /dev/null +++ b/apps/web/src/lib/validation.ts @@ -0,0 +1,25 @@ +import { eventTagEnum, orgCategoryEnum } from "@the-forum/database"; +import { z } from "zod"; + +/** + * Parse server-action input, throwing a readable Error on failure. + * + * Server actions are public HTTP endpoints — TypeScript types on their + * parameters are not enforced at runtime, so every action runs its input + * through a schema before touching the database. + */ +export function parseInput(schema: S, input: unknown): z.output { + const result = schema.safeParse(input); + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue?.path.length ? `${issue.path.join(".")}: ` : ""; + throw new Error(`Invalid input — ${path}${issue?.message ?? "validation failed"}`); + } + return result.data; +} + +/** Any 8-4-4-4-12 hex id (Postgres `uuid`). */ +export const idSchema = z.guid(); + +export const eventTagSchema = z.enum(eventTagEnum.enumValues); +export const orgCategorySchema = z.enum(orgCategoryEnum.enumValues); diff --git a/docs/ranking.md b/docs/ranking.md index 58dbb2d..e9bb89d 100644 --- a/docs/ranking.md +++ b/docs/ranking.md @@ -1,6 +1,6 @@ # Explore feed ranking -This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`) orders the Explore +This describes how `getFeedEvents()` (`apps/web/src/actions/events.ts`, implemented in `apps/web/src/lib/feed.ts`) orders the Explore feed. It's a plain SQL + in-memory weighted score — no ML, no external service. ## The formula, in plain English @@ -58,7 +58,7 @@ compelling; it doesn't climb further past that. - **1.0** if you follow or belong to the event's org. - **0.5** if you don't, but you've RSVP'd to that org's events before - (`ORG_PAST_INTERACTION_AFFINITY` in `events.ts`) — a weaker signal of interest. + (`ORG_PAST_INTERACTION_AFFINITY` in `lib/feed-ranking.ts`) — a weaker signal of interest. - **0** otherwise, or if the event has no org. ### Recency boost @@ -88,77 +88,99 @@ always gets the same nudge, so refreshing Explore never reshuffles it. The nudge once a day, so events that would otherwise tie get some variety over time. Ties still break by soonest event first. -## Candidate pool: why scoring needs more than one page - -The DB query pulls every upcoming, published event matching the feed's filters — not just the -requested page (`limit`/`offset`) — scores all of them, sorts by score, and only then slices -out the requested page. If scoring only ever ran against the 20 events the caller asked for, -personalization would have nothing to work with — the soonest 20 would always be exactly -what's returned, just reshuffled. Scoring the full candidate set lets a highly relevant event -further down the calendar outrank a less relevant one that merely happens sooner, no matter -how many other events are scheduled in between. - -`total` is simply the size of that candidate set, so it always agrees with what `limit`/`offset` -can actually reach — there's no separate count that could promise more than pagination can -deliver. Enrichment (tags, rsvp/view counts, friend attendance, user state) is batched via -`inArray(...)` across the whole candidate set rather than queried per event, so a wider pool -doesn't multiply query count — it stays at a fixed handful of queries regardless of how many -candidates are scored. - -### Bounded by calendar distance, not row count - -When no explicit `dateRange` filter is applied, candidates are bounded to the next -`CANDIDATE_HORIZON_DAYS` (14) days, rounded up to the end of that day. This replaced an earlier -version that capped the candidate pool at a fixed row count (the soonest 100 events) — that -approach meant a highly relevant event could be excluded from ranking entirely just because 100 -*other* events happened to be scheduled sooner, regardless of how strong its interest/friend/org -signal was. A calendar-distance bound doesn't have that failure mode: every event within the -next two weeks is always a candidate, no matter how many other events fall before it. An -explicit `dateRange` param (`today`/`week`/`month`) overrides the default horizon with its own -narrower or wider window. - -Events further out than the horizon never enter ranking by default — this is an intentional -product choice (Explore surfaces what's happening soon, not the whole semester's calendar), not -a scale workaround. `CANDIDATE_POOL_SAFETY_VALVE` (5000) is a separate, purely defensive row -limit on top of the horizon, in case an unusually dense window ever produced a pathological -result size — it isn't expected to bind at realistic campus-event scale. +## Where the code lives + +| File | What | +|---|---| +| `apps/web/src/actions/events.ts` → `getFeedEvents()` | Server action: auth + zod validation of params, then delegates | +| `apps/web/src/lib/feed.ts` → `loadRankedFeed()` | Candidate generation, batched enrichment, pagination | +| `apps/web/src/lib/feed-ranking.ts` | Pure scoring + ordering (`scoreEvent`, `finalizeFeedOrder`) and every tunable constant | + +## Pipeline + +1. **Candidate generation** — which events get ranked at all (below). +2. **Batched enrichment** — tags, RSVP counts, view counts, friends attending, and the viewer's + own RSVP/save state for *every* candidate, one `inArray(...)` query per signal (six queries + total, independent of pool size). No per-event queries. +3. **Score + sort** — score desc, then soonest first, then event id. A total order, so the sort is + fully deterministic. +4. **Finalize one order over the whole list** — org-diversity cap and soon-event quota + (below) are applied to the complete ranked list, *before* pagination. +5. **Paginate** — the requested page is `finalOrder.slice(offset, offset + limit)`. +6. **Attendee rosters** — the full "N attending" list is loaded for the returned page only. + +`total` is the length of the finalized list, so it always agrees with what `offset`/`limit` can +reach. + +## Candidate pool + +Scoring has to see more than one page — if only the 20 soonest events were scored, a highly +relevant event further out could never outrank a weak one that merely happens sooner. + +**Stage 1 — calendar horizon.** Every published, visible, upcoming event matching the filters +within the next `CANDIDATE_HORIZON_DAYS` (45) days, rounded up to end of day, soonest first, up +to `CANDIDATE_POOL_CAP` (1000) rows. An explicit `dateRange` filter (`today`/`week`/`month`) +replaces the default horizon. At the expected scale (hundreds of events per semester) the cap +never binds and stage 1 *is* the whole pool. Scoring 1000 candidates in memory is a few +milliseconds; the batched enrichment queries stay well below Postgres' bind-parameter limit. + +**Stage 2 — personalised expansion (only if stage 1 saturates).** If stage 1 returns exactly +`CANDIDATE_POOL_CAP` rows, a second query over the same filters and horizon fetches up to +`PERSONAL_CANDIDATE_CAP` (500) events *at or after* the stage-1 cutoff that carry a personal +signal: hosted by an org the viewer follows/belongs to, RSVP'd by a friend, or tagged with one +of the viewer's interests. These are merged (de-duplicated) into the pool. So a dense calendar +can crowd out generic far-future events, but not the ones this particular viewer is likely to +care about — unlike the old "100 soonest" hard wall. A warning is logged when this path runs, +as the signal to revisit the constants. + +Events beyond the horizon never enter ranking by default — a product choice (Explore is about +the next several weeks), not a scale workaround. ## Org diversity cap -After sorting by score, results are capped at `ORG_DIVERSITY_CAP` (3) events per org — once -an org hits 3, its remaining events are pushed later (not dropped), so one heavily-posting -org can't dominate the top of the feed. Events without an org are never capped. +At most `ORG_DIVERSITY_CAP` (3) events from one org in any `ORG_DIVERSITY_WINDOW` (20) +consecutive feed positions — i.e. no page is dominated by one heavy-posting org. Events without +an org are never capped. Over-cap events are *deferred*, not dropped: they re-enter as soon as +the window slides past the org's earlier events. (The original PR #38 version deferred every +over-cap event to the very end of the feed; the sliding window keeps a followed org's 4th event +reachable a page later instead of behind hundreds of others.) + +The only time the cap is exceeded is at the tail, when *every* remaining event is from an org +already at the cap in the current window — there is nothing else left to show, so the +highest-scoring remaining event is placed. ## Guaranteeing imminent events aren't buried -Friend RSVPs (weight 4.0) can outweigh time proximity (weight 2.0), so an event with strong -social signal three weeks out could in principle outscore one happening tomorrow with no -friends attending yet. To keep "what's happening soon" reliably visible, the first -`SOON_INJECTION_WINDOW` (20) positions of the ranked, diversified feed always include at least -`SOON_QUOTA` (3) events within `SOON_WINDOW_DAYS` (1) day, even if their score wouldn't -naturally place them there. - -This window is a fixed size, independent of the `limit`/`offset` a given request happens to -use — the whole feed is reordered into one final, stable sequence first, and *then* sliced into -pages. That means two requests for the same feed with different page sizes see the same -underlying order, and no event can appear on two different pages or be silently dropped by the -guarantee. - -This is a **merge**, not a score override: if the front of the feed already has enough soon -events, nothing changes. Otherwise the highest-scoring soon events missing from that window are -interleaved into it at evenly-spaced positions, removed from their original later position — -everything else keeps its normal score order. It only backfills what score order left out, the -way feeds inject a freshness quota without letting it take over the whole ranking. - -The injection never breaks the org diversity cap above: a soon event whose org already has -`ORG_DIVERSITY_CAP` events in the front window is skipped, even if that means fewer than -`SOON_QUOTA` soon events end up there. "Max 3 per org near the top" holds regardless of the -soon-event guarantee — the two rules compose rather than one silently overriding the other. - -All the tunable constants above (`SOON_WINDOW_DAYS`, `SOON_QUOTA`, `SOON_INJECTION_WINDOW`, -`ORG_DIVERSITY_CAP`, `CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_SAFETY_VALVE`, -`POPULARITY_VIEW_CAP`, `POPULARITY_WEIGHT`, `RANDOM_WEIGHT`, `ORG_PAST_INTERACTION_AFFINITY`) -live at the top of `events.ts`. +Friend RSVPs (weight 4.0) can outweigh time proximity (weight 2.0), so an event with strong social +signal three weeks out could outscore one happening tomorrow. To keep "what's happening soon" +visible, the first `SOON_INJECTION_WINDOW` (20) positions include at least `SOON_QUOTA` (3) +events starting within `SOON_WINDOW_DAYS` (1) day, spread evenly (by positions 5, 10 and 15). +Soon events that already rank there naturally count toward the quota; only a shortfall causes +the highest-scoring *missing* soon event to be pulled forward. + +**The quota never breaks the org cap.** A soon event is only pulled forward if its org is under +the cap in the current window; if no such soon event exists, the quota is left unmet. "Max 3 +per org per page" wins over "3 soon events near the top". + +The window is fixed, independent of `limit`/`offset`, so page size never changes the order. + +## Pagination and `asOf` + +Both rules above are part of one greedy pass (`finalizeFeedOrder`) over the full ranked list, +producing a permutation of it (nothing added or dropped). Pages are plain slices of that array, +so within one ranking no event can appear on two pages or be skipped. + +Scores depend on "now" (time proximity, recency, which events count as soon, the daily nudge +seed, and which events have already started). To keep page 2 a slice of the *same* ranking as +page 1, every response includes `asOf` — the instant it was ranked for — and the client sends it +back with the next `offset`. The server reuses it as "now" if it is at most 30 minutes old +(otherwise it ranks fresh). Data that changes between requests (a new RSVP, a new event) can +still shift the order slightly; the client de-duplicates by id when appending as a safety net. + +All tunable constants (`CANDIDATE_HORIZON_DAYS`, `CANDIDATE_POOL_CAP`, `PERSONAL_CANDIDATE_CAP`, +`WEIGHTS`, `TIME_HALF_LIFE_DAYS`, `ORG_PAST_INTERACTION_AFFINITY`, `POPULARITY_VIEW_CAP`, +`ORG_DIVERSITY_CAP`, `ORG_DIVERSITY_WINDOW`, `SOON_WINDOW_DAYS`, `SOON_QUOTA`, +`SOON_INJECTION_WINDOW`) live at the top of `apps/web/src/lib/feed-ranking.ts`. ## Edge cases From 321645e3d41e778cb4248ec8838a8cd5aab75df9 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:42:09 -0400 Subject: [PATCH 17/69] Add shared profile options with dynamic class years Class years are computed from the current Princeton academic year (rolling over after Commencement) plus Grad/Other, instead of a hardcoded list that had already gone stale. Interest and campus-region options are shared between onboarding and settings, keyed by the existing event_tag / campus_region enum values. Friends list no longer renders 'Grad' as "'ad". --- .../src/app/(app)/friends/friends-client.tsx | 6 +- apps/web/src/lib/profile-options.ts | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/profile-options.ts diff --git a/apps/web/src/app/(app)/friends/friends-client.tsx b/apps/web/src/app/(app)/friends/friends-client.tsx index b441bc2..a54c1cd 100644 --- a/apps/web/src/app/(app)/friends/friends-client.tsx +++ b/apps/web/src/app/(app)/friends/friends-client.tsx @@ -16,6 +16,7 @@ import { SearchInput } from "~/components/common/search-input"; import { EmptyState, LoadingState } from "~/components/common/states"; import { Button } from "~/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { classYearShort } from "~/lib/profile-options"; function Avatar({ name, @@ -197,7 +198,7 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP

@{user.netId} - {user.classYear && ` · '${user.classYear.slice(-2)}`} + {classYearShort(user.classYear) && ` · ${classYearShort(user.classYear)}`}

{isFriend ? ( @@ -249,7 +250,8 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP

@{friend.netId} - {friend.classYear && ` · '${friend.classYear.slice(-2)}`} + {classYearShort(friend.classYear) && + ` · ${classYearShort(friend.classYear)}`}

{/* diff --git a/apps/web/src/lib/profile-options.ts b/apps/web/src/lib/profile-options.ts new file mode 100644 index 0000000..c009d11 --- /dev/null +++ b/apps/web/src/lib/profile-options.ts @@ -0,0 +1,93 @@ +import { getZonedParts } from "~/lib/date-format"; + +/** + * Option lists shared by onboarding and settings. + * + * Values must match the `event_tag` / `campus_region` pgEnums in + * apps/database/src/schema. They're inlined because these are client + * components and can't import the server-only database package. + */ + +export const INTEREST_OPTIONS = [ + { value: "free food", label: "Free Food" }, + { value: "career", label: "Career & Recruiting" }, + { value: "research", label: "Research" }, + { value: "academics", label: "Academics" }, + { value: "tech", label: "Tech & Coding" }, + { value: "stem", label: "Science & Engineering" }, + { value: "entrepreneurship", label: "Entrepreneurship" }, + { value: "politics", label: "Politics & Advocacy" }, + { value: "visual arts", label: "Art & Design" }, + { value: "performing arts", label: "Theater & Performance" }, + { value: "music", label: "Music" }, + { value: "literature", label: "Literature & Writing" }, + { value: "culture", label: "Culture & Identity" }, + { value: "religion", label: "Faith & Religion" }, + { value: "community service", label: "Community Service" }, + { value: "sustainability", label: "Sustainability" }, + { value: "wellness", label: "Wellness & Mental Health" }, + { value: "athletics", label: "Fitness & Sports" }, + { value: "outdoors", label: "Outdoor & Adventure" }, + { value: "gaming", label: "Gaming & Esports" }, + { value: "social event", label: "Social Events" }, + { value: "speaker event", label: "Speakers & Talks" }, +] as const; + +const INTEREST_VALUES = new Set(INTEREST_OPTIONS.map((o) => o.value)); + +export function isInterestValue(value: string) { + return INTEREST_VALUES.has(value); +} + +export function interestLabel(value: string) { + return INTEREST_OPTIONS.find((o) => o.value === value)?.label ?? value; +} + +export const CAMPUS_REGION_OPTIONS = [ + { value: "central", label: "Central Campus", desc: "Nassau Hall, Frist, 1879" }, + { value: "east", label: "Science Area", desc: "Jadwin, Friend, EQuad" }, + { value: "south", label: "Prospect Ave", desc: "Eating clubs, Terrace" }, + { value: "west", label: "Residential Colleges", desc: "Butler, Whitman, Yeh …" }, + { value: "north", label: "Arts Corridor", desc: "McCarter, Lewis Center" }, + { value: "off-campus", label: "Athletics Area", desc: "Lenz, DeNunzio, Dillon" }, +] as const; + +const REGION_VALUES = new Set(CAMPUS_REGION_OPTIONS.map((o) => o.value)); + +export function isRegionValue(value: string) { + return REGION_VALUES.has(value); +} + +/** + * Undergraduate class years currently on campus, plus graduate/other. + * + * Princeton's year turns over at Commencement (late May), so from June on the + * rising seniors are next year's class. In September 2026 this yields + * 2027–2030; hardcoding the list meant it silently went stale every year. + */ +export function getClassYearOptions(now = new Date()): string[] { + const { year, month } = getZonedParts(now); + const seniorClass = month >= 6 ? year + 1 : year; + const undergrad = Array.from({ length: 4 }, (_, i) => String(seniorClass + i)); + return [...undergrad, "Grad", "Other"]; +} + +/** Keeps a previously saved value selectable after the list has rolled over. */ +export function withCurrentOption(options: string[], current: string | null | undefined) { + if (!current || options.includes(current)) return options; + return [current, ...options]; +} + +/** "'27" for a class year, "Grad" for grad students, nothing otherwise. */ +export function classYearShort(value: string | null | undefined) { + if (!value) return null; + if (/^\d{4}$/.test(value)) return `'${value.slice(-2)}`; + if (value === "Grad") return "Grad"; + return null; +} + +export function classYearLabel(value: string) { + if (value === "Grad") return "Graduate student"; + if (value === "Other") return "Other / not a student"; + return `Class of ${value}`; +} From 3022e6f0c2cad9d046cc489c54c602d8b44188f0 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:42:09 -0400 Subject: [PATCH 18/69] Settings: real data only, persist interests and regions - Remove the hardcoded 'Princeton TigerApps / Design Lead' rows, dead Edit Role buttons, the duplicate Friends/Organizations block with its non-working search, and the fake 'Suggested tags' (Jane Street etc.) - Interests: add/remove from the real event_tag list with a filter; saving stays on the page, toasts success/failure, and refreshes from the server so the saved state survives a reload (ideas from PR #44, without its enum rename) - Send the user's actual regions instead of regions: [] (which wiped them) and add a campus-region picker - Editable name (updateProfile accepts displayName), read-only NetID, dynamic class years, major picker, org-leader toggle - Organizations panel lists orgs the user actually manages - Avatar upload checks the S3 response and surfaces errors via toast - Links to Privacy, Terms and contact --- apps/web/src/actions/users.ts | 4 + apps/web/src/app/(app)/settings/page.tsx | 12 +- .../app/(app)/settings/settings-client.tsx | 637 ++++++++++-------- apps/web/src/lib/upload-image.ts | 51 ++ 4 files changed, 422 insertions(+), 282 deletions(-) create mode 100644 apps/web/src/lib/upload-image.ts diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts index 51b04e2..4bf9a67 100644 --- a/apps/web/src/actions/users.ts +++ b/apps/web/src/actions/users.ts @@ -22,6 +22,7 @@ export async function completeOnboarding(data: { major: string; regions: string[]; isOrgLeader: boolean; + displayName?: string; }) { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); @@ -32,6 +33,7 @@ export async function completeOnboarding(data: { await db .update(users) .set({ + displayName: data.displayName?.trim().slice(0, 255) || undefined, classYear: data.classYear, major: data.major, isOrgLeader: data.isOrgLeader, @@ -154,6 +156,7 @@ export async function updateProfile(data: { isOrgLeader?: boolean; interests?: string[]; regions?: string[]; + displayName?: string; }): Promise { const user = await getCurrentUser(); const userId = user.id; @@ -161,6 +164,7 @@ export async function updateProfile(data: { await db .update(users) .set({ + displayName: data.displayName?.trim().slice(0, 255) || undefined, classYear: data.classYear, major: data.major, isOrgLeader: data.isOrgLeader, diff --git a/apps/web/src/app/(app)/settings/page.tsx b/apps/web/src/app/(app)/settings/page.tsx index ebc3418..2f9f69b 100644 --- a/apps/web/src/app/(app)/settings/page.tsx +++ b/apps/web/src/app/(app)/settings/page.tsx @@ -1,9 +1,17 @@ +import type { Metadata } from "next"; import { getFriends } from "~/actions/friends"; +import { getUserOrgs } from "~/actions/orgs"; import { getUserProfile } from "~/actions/users"; import { SettingsClient } from "./settings-client"; +export const metadata: Metadata = { title: "Settings" }; + export default async function SettingsPage() { - const [profile, friends] = await Promise.all([getUserProfile(), getFriends()]); + const [profile, friends, managedOrgs] = await Promise.all([ + getUserProfile(), + getFriends(), + getUserOrgs(), + ]); - return ; + return ; } diff --git a/apps/web/src/app/(app)/settings/settings-client.tsx b/apps/web/src/app/(app)/settings/settings-client.tsx index e3bbff2..eadb559 100644 --- a/apps/web/src/app/(app)/settings/settings-client.tsx +++ b/apps/web/src/app/(app)/settings/settings-client.tsx @@ -1,11 +1,11 @@ "use client"; -import { ArrowLeft, ExternalLink, Pencil, Search, X } from "lucide-react"; +import { ArrowLeft, ExternalLink, Pencil, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useRef, useState, useTransition } from "react"; +import { useMemo, useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; import type { FriendProfile } from "~/actions/friends"; -import { getPresignedUploadUrl } from "~/actions/upload"; import { type UserProfile, updateAvatar, updateProfile } from "~/actions/users"; import { Field } from "~/components/common/field"; import { FilterChip } from "~/components/common/filter-chip"; @@ -18,53 +18,39 @@ import { TOP_BAR_CLEARANCE, } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; +import { PRINCETON_MAJORS } from "~/lib/princeton-departments"; +import { + CAMPUS_REGION_OPTIONS, + INTEREST_OPTIONS, + classYearLabel, + classYearShort, + getClassYearOptions, + interestLabel, + isInterestValue, + isRegionValue, + withCurrentOption, +} from "~/lib/profile-options"; +import { IMAGE_ACCEPT, uploadImage } from "~/lib/upload-image"; import { cn } from "~/lib/utils"; -const INTEREST_TAGS = [ - { id: "free food", label: "free food" }, - { id: "tech", label: "technology" }, - { id: "stem", label: "science and engineering" }, - { id: "visual arts", label: "visual arts" }, - { id: "wellness", label: "fitness & health" }, - { id: "academics", label: "academics" }, - { id: "research", label: "research" }, - { id: "career", label: "career" }, - { id: "entrepreneurship", label: "entrepreneurship" }, - { id: "music", label: "music" }, - { id: "social event", label: "social" }, - { id: "athletics", label: "sports" }, - { id: "performing arts", label: "performing arts" }, - { id: "culture", label: "culture" }, - { id: "literature", label: "literature" }, - { id: "community service", label: "service" }, - { id: "religion", label: "religion" }, - { id: "politics", label: "politics" }, - { id: "gaming", label: "gaming" }, - { id: "outdoors", label: "outdoors" }, - { id: "sustainability", label: "sustainability" }, - { id: "speaker event", label: "speaker" }, -]; - -const SUGGESTION_TAGS = [ - "tech talk", - "Jane Street", - "consulting", - "internship", - "Citadel", - "Lockheed Martin", - "free merch", - "Bain & Company", -]; - -const CLASS_YEARS = ["2025", "2026", "2027", "2028", "2029", "Grad"]; - interface SettingsClientProps { profile: UserProfile; friends: FriendProfile[]; + /** Organizations the user owns or is an officer of. */ + managedOrgs: { id: string; name: string }[]; +} + +const MAJOR_OPTIONS = PRINCETON_MAJORS.map((d) => (d.degree ? `${d.name} (${d.degree})` : d.name)); + +const UNDERLINE_CONTROL = + "w-full border-b border-forum-medium-gray bg-transparent pb-1.5 font-dm-sans text-[15px] text-black outline-none transition-colors focus:border-forum-cerulean"; + +function sameSet(a: string[], b: string[]) { + return a.length === b.length && a.every((v) => b.includes(v)); } -/** Single friend row — was duplicated verbatim in both Friends sections. */ function FriendRow({ friend }: { friend: FriendProfile }) { + const year = classYearShort(friend.classYear); return (
@@ -85,65 +71,120 @@ function FriendRow({ friend }: { friend: FriendProfile }) { @{friend.netId}
- {friend.classYear && ( - - '{friend.classYear.slice(-2)} - - )} + {year && {year}}
); } -export function SettingsClient({ profile, friends }: SettingsClientProps) { +export function SettingsClient({ profile, friends, managedOrgs }: SettingsClientProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [classYear, setClassYear] = useState(profile.classYear ?? ""); - const [major, setMajor] = useState(profile.major ?? ""); - const [isOrgLeader, setIsOrgLeader] = useState(profile.isOrgLeader); - const [interests, setInterests] = useState(profile.interests); + /* + * Saved values, derived from props so they refresh after `router.refresh()` + * re-renders the server page with what's actually in the database. + */ + const saved = useMemo( + () => ({ + displayName: profile.displayName, + classYear: profile.classYear ?? "", + major: profile.major ?? "", + isOrgLeader: profile.isOrgLeader, + interests: profile.interests.filter(isInterestValue), + regions: profile.regions.filter(isRegionValue), + }), + [profile], + ); + + const [displayName, setDisplayName] = useState(saved.displayName); + const [classYear, setClassYear] = useState(saved.classYear); + const [major, setMajor] = useState(saved.major); + const [isOrgLeader, setIsOrgLeader] = useState(saved.isOrgLeader); + const [interests, setInterests] = useState(saved.interests); + const [regions, setRegions] = useState(saved.regions); const [friendSearch, setFriendSearch] = useState(""); - const [orgSearch, setOrgSearch] = useState(""); const [tagSearch, setTagSearch] = useState(""); - const toggleInterest = (id: string) => { - setInterests((prev) => (prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id])); + const classYearOptions = withCurrentOption(getClassYearOptions(), saved.classYear); + const majorOptions = withCurrentOption(MAJOR_OPTIONS, saved.major); + + const nameError = displayName.trim() ? undefined : "Name can't be empty"; + + const hasChanges = + displayName.trim() !== saved.displayName || + classYear !== saved.classYear || + major !== saved.major || + isOrgLeader !== saved.isOrgLeader || + !sameSet(interests, saved.interests) || + !sameSet(regions, saved.regions); + + const toggleInterest = (value: string) => { + setInterests((prev) => + prev.includes(value) ? prev.filter((i) => i !== value) : [...prev, value], + ); + }; + + const toggleRegion = (value: string) => { + setRegions((prev) => + prev.includes(value) ? prev.filter((r) => r !== value) : [...prev, value], + ); }; const handleSave = () => { + if (nameError) { + toast.error(nameError); + return; + } startTransition(async () => { - await updateProfile({ - classYear, - major, - isOrgLeader, - interests, - regions: [], - }); - router.push("/explore"); + try { + await updateProfile({ + displayName: displayName.trim(), + classYear, + major, + isOrgLeader, + interests: interests.filter(isInterestValue), + regions: regions.filter(isRegionValue), + }); + toast.success("Settings saved"); + router.refresh(); + } catch { + toast.error("Couldn't save your settings. Please try again."); + } }); }; + const handleDiscard = () => { + setDisplayName(saved.displayName); + setClassYear(saved.classYear); + setMajor(saved.major); + setIsOrgLeader(saved.isOrgLeader); + setInterests(saved.interests); + setRegions(saved.regions); + setTagSearch(""); + }; + const avatarInputRef = useRef(null); const [avatarPreview, setAvatarPreview] = useState(profile.avatarUrl); + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const handleAvatarUpload = async (file: File) => { - // Preview immediately - const reader = new FileReader(); - reader.onload = (e) => setAvatarPreview(e.target?.result as string); - reader.readAsDataURL(file); - + const previous = avatarPreview; + const localPreview = URL.createObjectURL(file); + setAvatarPreview(localPreview); + setIsUploadingAvatar(true); try { - const { uploadUrl, publicUrl } = await getPresignedUploadUrl({ - filename: file.name, - contentType: file.type, - size: file.size, - folder: "avatars", - }); - await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); + const publicUrl = await uploadImage(file, "avatars"); await updateAvatar(publicUrl); + setAvatarPreview(publicUrl); + toast.success("Profile photo updated"); + router.refresh(); } catch (err) { - console.error("Avatar upload failed:", err); - setAvatarPreview(profile.avatarUrl); + setAvatarPreview(previous); + toast.error(err instanceof Error ? err.message : "Couldn't update your photo."); + } finally { + URL.revokeObjectURL(localPreview); + setIsUploadingAvatar(false); + if (avatarInputRef.current) avatarInputRef.current.value = ""; } }; @@ -154,6 +195,13 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { f.netId.toLowerCase().includes(friendSearch.toLowerCase()), ); + const tagQuery = tagSearch.trim().toLowerCase(); + const availableInterests = INTEREST_OPTIONS.filter( + (o) => + !interests.includes(o.value) && + (!tagQuery || o.label.toLowerCase().includes(tagQuery) || o.value.includes(tagQuery)), + ); + return ( {/* Reserves space so these buttons don't collide with the floating TopBar. */} @@ -163,10 +211,20 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { Back
- -
@@ -180,9 +238,13 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { {/* Avatar */}
-
+
{avatarPreview ? ( - + {profile.displayName} ) : (
)} + {isUploadingAvatar && ( + + Uploading… + + )}
- {/* Name + Class Year inline */} -
- -
- - {profile.displayName} - - -
+
+ + setDisplayName(e.target.value)} + aria-invalid={nameError ? true : undefined} + aria-describedby={nameError ? "display-name-error" : undefined} + className={UNDERLINE_CONTROL} + /> - + +
+ NetID +

+ {profile.netId} + + from your Princeton login + +

+
+ + + + + + + +
+ +
- {/* ═══ Friends + Organizations — two columns ═══ */} + {/* ═══ Friends + Organizations ═══ */}
- {/* Friends column */}
Friends - setFriendSearch(e.target.value)} - /> - -
    - {filteredFriends.map((friend) => ( -
  • - -
  • - ))} -
+ {friends.length > 0 ? ( + <> + setFriendSearch(e.target.value)} + /> +
    + {filteredFriends.map((friend) => ( +
  • + +
  • + ))} + {filteredFriends.length === 0 && ( +
  • + No friends match “{friendSearch}”. +
  • + )} +
+ + ) : ( +

+ You haven't added any friends yet. +

+ )}
- {/* Organizations column */}
Organizations - setOrgSearch(e.target.value)} - /> - -

- No organizations yet. Follow orgs from the Orgs page. -

+ {managedOrgs.length > 0 ? ( +
    + {managedOrgs.map((org) => ( +
  • + + + {org.name[0]?.toUpperCase()} + + {org.name} + +
  • + ))} +
+ ) : ( +

+ You don't manage any organizations yet. +

+ )} @@ -297,26 +438,27 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
- {/* ═══ Interest Tags ═══ */} + {/* ═══ Interests ═══ */}
Interest Tags +

+ Your Explore feed is ranked around these. +

- {/* Selected topics */}
- Topics + Your topics
- {interests.map((tagId) => { - const tag = INTEREST_TAGS.find((t) => t.id === tagId); - const label = tag?.label ?? tagId; + {interests.map((value) => { + const label = interestLabel(value); return ( toggleInterest(tagId)} + onClick={() => toggleInterest(value)} > {label} @@ -325,174 +467,109 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { })} {interests.length === 0 && (

- No topics selected yet. + No topics selected yet — add some from the list.

)}
- {/* Search for new tags */}
- - - Suggested tags + + Add topics + setTagSearch(e.target.value)} + className="mb-3 h-10" + />
- {SUGGESTION_TAGS.map((tag) => ( - - {tag} + {availableInterests.map((option) => ( + toggleInterest(option.value)} + > + {option.label} ))} + {availableInterests.length === 0 && ( +

+ {tagQuery ? "No matching topics." : "You've added every topic."} +

+ )}
- - {/* Organizations — link to orgs page */} -
- - Organizations - -

- Manage your organization memberships from the{" "} - - Orgs page - - . -

-
- {/* ═══ Friends + Organizations (bottom expanded view) ═══ */} -
- {/* Friends expanded */} -
-
-
-

Friends

-
- - {/* Avatar large */} -
- {profile.avatarUrl ? ( - {profile.displayName} - ) : ( -
- {profile.displayName[0]?.toUpperCase()} -
- )} -
- -
- - -
- -
- {friends.map((friend) => ( -
-
- {friend.avatarUrl ? ( - {friend.displayName} + Campus Regions + +
+ + Where you usually are on campus. We use this to surface nearby events. + +
+ {CAMPUS_REGION_OPTIONS.map(({ value, label, desc }) => { + const selected = regions.includes(value); + return ( +
-
- - {friend.displayName} - - - @{friend.netId} - -
- {friend.classYear && ( - - '{friend.classYear.slice(-2)} - - )} -
- ))} -
+ + ); + })} +
+ + + - - ADD / EDIT MY FRIENDS LIST - + {/* ═══ About ═══ */} +
+ About + + + Privacy -
- - {/* Organizations expanded */} -
-
-
-

Organizations

-
- -
- - -
- -
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
- - Princeton TigerApps - - - Design Lead - -
- -
- ))} -
- - - ADD / EDIT MY ORGANIZATIONS - + + Terms of Use -
-
+ + Contact: it.admin@tigerapps.org + + + ); } diff --git a/apps/web/src/lib/upload-image.ts b/apps/web/src/lib/upload-image.ts new file mode 100644 index 0000000..38a7b18 --- /dev/null +++ b/apps/web/src/lib/upload-image.ts @@ -0,0 +1,51 @@ +import { getPresignedUploadUrl } from "~/actions/upload"; + +/** Mirrors the limits enforced server-side in actions/upload.ts. */ +export const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp"; +const ALLOWED_TYPES = IMAGE_ACCEPT.split(","); +const MAX_BYTES = 5 * 1024 * 1024; + +/** + * Upload an image straight to S3 via a presigned PUT and return its public URL. + * + * Throws an Error whose message is safe to show in a toast. The S3 response is + * checked — `fetch` resolves on 4xx/5xx, so without `res.ok` a rejected upload + * used to be saved as if it had worked, leaving a broken image URL behind. + */ +export async function uploadImage(file: File, folder: "avatars" | "event-flyers") { + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error("Please choose a JPEG, PNG, or WebP image."); + } + if (file.size > MAX_BYTES) { + throw new Error("That image is over 5 MB. Please choose a smaller one."); + } + + let uploadUrl: string; + let publicUrl: string; + try { + ({ uploadUrl, publicUrl } = await getPresignedUploadUrl({ + filename: file.name, + contentType: file.type, + size: file.size, + folder, + })); + } catch { + throw new Error("Image uploads aren't available right now. Please try again later."); + } + + let res: Response; + try { + res = await fetch(uploadUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }); + } catch { + throw new Error("Upload failed — check your connection and try again."); + } + if (!res.ok) { + throw new Error("Upload failed. Please try again."); + } + + return publicUrl; +} From 98e097e0d6da93d6ec85ebce9814fcdf16adfd15 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:42:10 -0400 Subject: [PATCH 19/69] Redirect /profile to /settings It rendered a duplicate of Settings and nothing linked to it. --- apps/web/src/app/(app)/profile/page.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index 0bd9f09..bbf6737 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -1,9 +1,6 @@ -import { getFriends } from "~/actions/friends"; -import { getUserProfile } from "~/actions/users"; -import { SettingsClient } from "../settings/settings-client"; +import { redirect } from "next/navigation"; -export default async function ProfilePage() { - const [profile, friends] = await Promise.all([getUserProfile(), getFriends()]); - - return ; +/** `/profile` used to render a second copy of Settings. One page now. */ +export default function ProfilePage() { + redirect("/settings"); } From 899a49a14548bd531d0b690acfc167ec13121b48 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:42:49 -0400 Subject: [PATCH 20/69] feat(web): add Princeton CAS protocol helpers with bun tests apps/web/src/lib/cas.ts: state generation + timing-safe compare, return-path sanitizing, service/login/serviceValidate/logout URL builders, service-URL pinning, and serviceValidate XML parsing (fast-xml-parser, removeNSPrefix, processEntities:false) with NetID validation. Adds fast-xml-parser, a 'test' script, and drops the unused @auth/drizzle-adapter dependency. --- apps/web/package.json | 3 +- apps/web/src/lib/cas.test.ts | 288 +++++++++++++++++++++++++++++++++++ apps/web/src/lib/cas.ts | 238 +++++++++++++++++++++++++++++ bun.lock | 8 +- 4 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/cas.test.ts create mode 100644 apps/web/src/lib/cas.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5a9e26b..c152d92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,10 +7,10 @@ "build": "next build", "start": "next start", "lint": "biome check .", + "test": "bun test", "check-types": "tsc --noEmit" }, "dependencies": { - "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1004.0", "@aws-sdk/s3-request-presigner": "^3.1004.0", "@t3-oss/env-nextjs": "^0.13.10", @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "fast-xml-parser": "^5.4.1", "lucide-react": "^0.575.0", "mapbox-gl": "^3.20.0", "next": "^16.1.6", diff --git a/apps/web/src/lib/cas.test.ts b/apps/web/src/lib/cas.test.ts new file mode 100644 index 0000000..4625475 --- /dev/null +++ b/apps/web/src/lib/cas.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, test } from "bun:test"; +import { + buildLoginUrl, + buildLogoutUrl, + buildServiceUrl, + buildServiceValidateUrl, + generateState, + isExpectedServiceUrl, + isValidState, + isValidTicket, + normalizeCasBaseUrl, + normalizeNetId, + parseServiceValidateResponse, + safeCompare, + sanitizeReturnPath, + validateServiceTicket, +} from "./cas"; + +const BASE = "https://fed.princeton.edu/cas/"; +const ORIGIN = "https://forum.example.edu"; + +const success = (user: string, extra = "") => ` + + + ${user} + ${extra} + +`; + +const failure = ` + Ticket ST-1 not recognized +`; + +describe("parseServiceValidateResponse", () => { + test("extracts and lowercases the NetID on success", () => { + expect(parseServiceValidateResponse(success("IAmin"))).toEqual({ ok: true, netId: "iamin" }); + }); + + test("keeps numeric-looking NetIDs as strings", () => { + expect(parseServiceValidateResponse(success("01234"))).toEqual({ ok: true, netId: "01234" }); + }); + + test("ignores attributes block", () => { + const attrs = + "someone@else.eduX"; + expect(parseServiceValidateResponse(success("abc123", attrs))).toEqual({ + ok: true, + netId: "abc123", + }); + }); + + test("reports authenticationFailure with its code", () => { + expect(parseServiceValidateResponse(failure)).toEqual({ + ok: false, + reason: "failure", + code: "INVALID_TICKET", + }); + }); + + test("rejects users that aren't valid NetIDs", () => { + for (const bad of [ + "iamin@princeton.edu", + "evil user", + "", + "a".repeat(41), + "../etc", + "<x>", + ]) { + expect(parseServiceValidateResponse(success(bad))).toEqual({ + ok: false, + reason: "invalid_user", + }); + } + }); + + test("does not expand entities", () => { + const xml = ` +]> + + &x; +`; + const result = parseServiceValidateResponse(xml); + expect(result.ok).toBe(false); + }); + + test("rejects malformed / unrelated documents", () => { + expect(parseServiceValidateResponse("not xml at all").ok).toBe(false); + expect(parseServiceValidateResponse("hi")).toEqual({ + ok: false, + reason: "malformed", + }); + expect(parseServiceValidateResponse("").ok).toBe(false); + }); +}); + +describe("normalizeNetId", () => { + test("accepts letters, digits, underscore, hyphen", () => { + expect(normalizeNetId("Ab_c-9")).toBe("ab_c-9"); + }); + test("rejects non-strings", () => { + expect(normalizeNetId(123)).toBeNull(); + expect(normalizeNetId(undefined)).toBeNull(); + expect(normalizeNetId({ "#text": "x" })).toBeNull(); + }); +}); + +describe("sanitizeReturnPath", () => { + test("keeps local paths with query and hash", () => { + expect(sanitizeReturnPath("/events/create")).toBe("/events/create"); + expect(sanitizeReturnPath("/explore?search=a%20b#top")).toBe("/explore?search=a%20b#top"); + }); + + test("falls back for anything that could leave the origin", () => { + for (const bad of [ + null, + undefined, + "", + "explore", + "https://evil.com", + "//evil.com", + "/\\evil.com", + "/foo\\bar", + "/\t/evil.com", + "javascript:alert(1)", + "/api/auth/cas/login", + "/api/auth", + `/${"a".repeat(600)}`, + ]) { + expect(sanitizeReturnPath(bad)).toBe("/explore"); + } + }); + + test("uses the supplied fallback", () => { + expect(sanitizeReturnPath("https://evil.com", "/")).toBe("/"); + }); +}); + +describe("state + ticket helpers", () => { + test("generateState is 64 hex chars and unique", () => { + const a = generateState(); + const b = generateState(); + expect(isValidState(a)).toBe(true); + expect(a).not.toBe(b); + }); + + test("isValidState rejects wrong shapes", () => { + expect(isValidState("abc")).toBe(false); + expect(isValidState("G".repeat(64))).toBe(false); + expect(isValidState(null)).toBe(false); + }); + + test("safeCompare", () => { + expect(safeCompare("abc", "abc")).toBe(true); + expect(safeCompare("abc", "abd")).toBe(false); + expect(safeCompare("abc", "abcd")).toBe(false); + }); + + test("isValidTicket", () => { + expect(isValidTicket("ST-12345-abcDEF_ghi-cas01.princeton.edu")).toBe(true); + expect(isValidTicket("")).toBe(false); + expect(isValidTicket("ST-1 2")).toBe(false); + expect(isValidTicket("ST-1&service=x")).toBe(false); + expect(isValidTicket("x".repeat(513))).toBe(false); + expect(isValidTicket(undefined)).toBe(false); + }); +}); + +describe("URL builders", () => { + const state = "a".repeat(64); + const service = buildServiceUrl(ORIGIN, state); + + test("service URL", () => { + expect(service).toBe(`${ORIGIN}/api/auth/cas/callback?state=${state}`); + }); + + test("login URL encodes the service", () => { + expect(buildLoginUrl(BASE, service)).toBe( + `${BASE}login?service=${encodeURIComponent(service)}`, + ); + expect(buildLoginUrl("https://fed.princeton.edu/cas", service)).toBe( + `${BASE}login?service=${encodeURIComponent(service)}`, + ); + }); + + test("serviceValidate URL encodes service and ticket", () => { + expect(buildServiceValidateUrl(BASE, service, "ST-1")).toBe( + `${BASE}serviceValidate?service=${encodeURIComponent(service)}&ticket=ST-1`, + ); + }); + + test("logout URL", () => { + expect(buildLogoutUrl(BASE)).toBe(`${BASE}logout`); + expect(buildLogoutUrl(BASE, `${ORIGIN}/`)).toBe( + `${BASE}logout?service=${encodeURIComponent(`${ORIGIN}/`)}`, + ); + }); + + test("normalizeCasBaseUrl", () => { + expect(normalizeCasBaseUrl("https://x/cas")).toBe("https://x/cas/"); + expect(normalizeCasBaseUrl("https://x/cas///")).toBe("https://x/cas/"); + }); +}); + +describe("isExpectedServiceUrl", () => { + const state = "b".repeat(64); + const good = buildServiceUrl(ORIGIN, state); + + test("accepts our own callback URL", () => { + expect(isExpectedServiceUrl(good, ORIGIN)).toBe(true); + expect(isExpectedServiceUrl(good, `${ORIGIN}/api/auth`)).toBe(true); + }); + + test("rejects other origins, paths, and extra params", () => { + for (const bad of [ + buildServiceUrl("https://evil.com", state), + buildServiceUrl("http://forum.example.edu", state), + `${ORIGIN}/api/auth/cas/other?state=${state}`, + `${good}&ticket=ST-1`, + `${good}&x=1`, + `${good}#frag`, + `${ORIGIN}/api/auth/cas/callback?state=short`, + `${ORIGIN}/api/auth/cas/callback`, + "not a url", + undefined, + 42, + ]) { + expect(isExpectedServiceUrl(bad, ORIGIN)).toBe(false); + } + }); +}); + +describe("validateServiceTicket", () => { + const service = buildServiceUrl(ORIGIN, "c".repeat(64)); + + const fakeFetch = (body: string, status = 200, seen?: { url?: string }) => + (async (input: RequestInfo | URL) => { + if (seen) seen.url = String(input); + return new Response(body, { status }); + }) as unknown as typeof fetch; + + test("calls serviceValidate with the same service and returns the NetID", async () => { + const seen: { url?: string } = {}; + const result = await validateServiceTicket({ + casBaseUrl: BASE, + service, + ticket: "ST-42", + fetchImpl: fakeFetch(success("TigerUser"), 200, seen), + }); + expect(result).toEqual({ ok: true, netId: "tigeruser" }); + expect(seen.url).toBe(buildServiceValidateUrl(BASE, service, "ST-42")); + }); + + test("returns failure for CAS authenticationFailure", async () => { + const result = await validateServiceTicket({ + casBaseUrl: BASE, + service, + ticket: "ST-42", + fetchImpl: fakeFetch(failure), + }); + expect(result.ok).toBe(false); + }); + + test("rejects malformed tickets without calling CAS", async () => { + let called = false; + const result = await validateServiceTicket({ + casBaseUrl: BASE, + service, + ticket: "bad ticket", + fetchImpl: (async () => { + called = true; + return new Response(""); + }) as unknown as typeof fetch, + }); + expect(result.ok).toBe(false); + expect(called).toBe(false); + }); + + test("throws on non-2xx so callers treat CAS as unavailable", async () => { + await expect( + validateServiceTicket({ + casBaseUrl: BASE, + service, + ticket: "ST-42", + fetchImpl: fakeFetch("oops", 503), + }), + ).rejects.toThrow("HTTP 503"); + }); +}); diff --git a/apps/web/src/lib/cas.ts b/apps/web/src/lib/cas.ts new file mode 100644 index 0000000..06f2116 --- /dev/null +++ b/apps/web/src/lib/cas.ts @@ -0,0 +1,238 @@ +/** + * Princeton CAS (Central Authentication Service) protocol helpers. + * + * Pure, framework-agnostic functions so they can be unit-tested with `bun test` + * without booting Next.js or validating env vars. Anything that needs env or + * request context lives in `~/lib/cas-server.ts` / the route handlers. + * + * Flow (CAS protocol v2): + * 1. /api/auth/cas/login → redirect to `${base}login?service=` + * 2. CAS redirects back to `&ticket=ST-…` + * 3. Server calls `${base}serviceValidate?service=&ticket=` + * and reads `serviceResponse.authenticationSuccess.user` (the NetID). + */ + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { XMLParser } from "fast-xml-parser"; + +export const DEFAULT_CAS_BASE_URL = "https://fed.princeton.edu/cas/"; + +/** Path of our CAS callback route (the CAS "service"). */ +export const CAS_CALLBACK_PATH = "/api/auth/cas/callback"; + +/** Cookie holding the anti-CSRF `state` for an in-flight login. */ +export const CAS_STATE_COOKIE = "forum_cas_state"; +/** Cookie holding the sanitized local path to return to after login. */ +export const CAS_RETURN_COOKIE = "forum_cas_return"; +/** Both cookies are only needed by the CAS login/callback routes. */ +export const CAS_COOKIE_PATH = "/api/auth/cas"; +/** Lifetime of the state + return cookies (10 minutes). */ +export const CAS_COOKIE_MAX_AGE_SECONDS = 10 * 60; + +export const CAS_VALIDATE_TIMEOUT_MS = 15_000; + +export const DEFAULT_RETURN_PATH = "/explore"; + +const NETID_RE = /^[a-z0-9_-]{1,40}$/i; +const STATE_RE = /^[a-f0-9]{64}$/; +// CAS service tickets look like `ST-12345-abcDEF…-cas01`. Be permissive about the +// charset but bounded, and reject anything with whitespace/control characters. +const TICKET_RE = /^[A-Za-z0-9._:-]{1,512}$/; + +/** Ensure the CAS base URL ends in exactly one `/` so path joins are safe. */ +export function normalizeCasBaseUrl(base: string): string { + return `${base.replace(/\/+$/, "")}/`; +} + +/** 32 random bytes, hex-encoded (64 chars). */ +export function generateState(): string { + return randomBytes(32).toString("hex"); +} + +export function isValidState(state: unknown): state is string { + return typeof state === "string" && STATE_RE.test(state); +} + +export function isValidTicket(ticket: unknown): ticket is string { + return typeof ticket === "string" && TICKET_RE.test(ticket); +} + +/** Constant-time string comparison (length mismatch → false, still no early exit on content). */ +export function safeCompare(a: string, b: string): boolean { + const aBuf = Buffer.from(a, "utf8"); + const bBuf = Buffer.from(b, "utf8"); + if (aBuf.length !== bBuf.length) { + // Compare against itself so timing doesn't depend on where strings differ. + timingSafeEqual(aBuf, aBuf); + return false; + } + return timingSafeEqual(aBuf, bBuf); +} + +/** Validate a CAS username and normalize it to a lowercase NetID. */ +export function normalizeNetId(user: unknown): string | null { + if (typeof user !== "string") return null; + const trimmed = user.trim(); + if (!NETID_RE.test(trimmed)) return null; + return trimmed.toLowerCase(); +} + +/** + * Sanitize a user-supplied "return to" value into a same-origin path. + * Rejects absolute URLs, protocol-relative URLs (`//evil.com`), backslash + * tricks (`/\evil.com`), and control characters. Falls back to `fallback`. + */ +export function sanitizeReturnPath( + value: string | null | undefined, + fallback: string = DEFAULT_RETURN_PATH, +): string { + if (!value || typeof value !== "string") return fallback; + if (value.length > 512) return fallback; + if (!value.startsWith("/")) return fallback; + if (value.startsWith("//") || value.startsWith("/\\")) return fallback; + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally rejecting control chars + if (/[\u0000-\u001f\u007f\\]/.test(value)) return fallback; + // Never bounce back into the auth flow itself. + if (value === "/api/auth" || value.startsWith("/api/auth/")) return fallback; + try { + // Resolve against a dummy origin; if the origin changes, it wasn't a local path. + const url = new URL(value, "http://local.invalid"); + if (url.origin !== "http://local.invalid") return fallback; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return fallback; + } +} + +/** Build the CAS `service` URL for a login attempt. */ +export function buildServiceUrl(origin: string, state: string): string { + const url = new URL(CAS_CALLBACK_PATH, origin); + url.search = new URLSearchParams({ state }).toString(); + return url.toString(); +} + +/** + * Check that a `service` URL is exactly one we would have generated for + * `expectedOrigin`. This prevents redeeming a ticket that CAS issued to a + * *different* service (e.g. a malicious site the victim logged into). + */ +export function isExpectedServiceUrl(service: unknown, expectedOrigin: string): boolean { + if (typeof service !== "string" || service.length > 2048) return false; + let url: URL; + let origin: string; + try { + url = new URL(service); + origin = new URL(expectedOrigin).origin; + } catch { + return false; + } + if (url.origin !== origin) return false; + if (url.pathname !== CAS_CALLBACK_PATH) return false; + if (url.hash) return false; + const keys = [...url.searchParams.keys()]; + if (keys.length !== 1 || keys[0] !== "state") return false; + const state = url.searchParams.get("state"); + if (!isValidState(state)) return false; + // Must round-trip to the exact canonical form we generate. + return buildServiceUrl(origin, state) === service; +} + +export function buildLoginUrl(casBaseUrl: string, service: string): string { + return `${normalizeCasBaseUrl(casBaseUrl)}login?service=${encodeURIComponent(service)}`; +} + +export function buildServiceValidateUrl( + casBaseUrl: string, + service: string, + ticket: string, +): string { + return `${normalizeCasBaseUrl(casBaseUrl)}serviceValidate?service=${encodeURIComponent( + service, + )}&ticket=${encodeURIComponent(ticket)}`; +} + +export function buildLogoutUrl(casBaseUrl: string, service?: string): string { + const base = `${normalizeCasBaseUrl(casBaseUrl)}logout`; + return service ? `${base}?service=${encodeURIComponent(service)}` : base; +} + +export type CasValidationResult = + | { ok: true; netId: string } + | { ok: false; reason: "failure" | "invalid_user" | "malformed"; code?: string }; + +const parser = new XMLParser({ + removeNSPrefix: true, + // Don't expand entities (no XXE / billion-laughs style surprises). + processEntities: false, + // Keep values as strings — a numeric NetID must not become a number. + parseTagValue: false, + ignoreAttributes: false, + attributeNamePrefix: "@_", + trimValues: true, +}); + +function textOf(node: unknown): unknown { + if (node && typeof node === "object" && "#text" in node) { + return (node as { "#text": unknown })["#text"]; + } + return node; +} + +/** + * Parse a CAS v2 `serviceValidate` XML response. + * CAS is the authority on identity: on success we return its `user` as the NetID. + */ +export function parseServiceValidateResponse(xml: string): CasValidationResult { + let doc: unknown; + try { + doc = parser.parse(xml); + } catch { + return { ok: false, reason: "malformed" }; + } + const response = (doc as { serviceResponse?: Record } | null)?.serviceResponse; + if (!response || typeof response !== "object") return { ok: false, reason: "malformed" }; + + if ("authenticationFailure" in response) { + const failure = response.authenticationFailure; + const code = + failure && typeof failure === "object" && "@_code" in failure + ? String((failure as { "@_code": unknown })["@_code"]) + : undefined; + return { ok: false, reason: "failure", code }; + } + + const success = response.authenticationSuccess; + if (!success || typeof success !== "object") return { ok: false, reason: "malformed" }; + const user = textOf((success as { user?: unknown }).user); + const netId = normalizeNetId(user); + if (!netId) return { ok: false, reason: "invalid_user" }; + return { ok: true, netId }; +} + +/** + * Validate a service ticket against CAS (server-side). Never trust a NetID from + * the client — only what CAS returns here. + */ +export async function validateServiceTicket(opts: { + casBaseUrl: string; + service: string; + ticket: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +}): Promise { + const { casBaseUrl, service, ticket, timeoutMs = CAS_VALIDATE_TIMEOUT_MS } = opts; + const fetchImpl = opts.fetchImpl ?? fetch; + if (!isValidTicket(ticket)) return { ok: false, reason: "failure", code: "INVALID_TICKET" }; + + const res = await fetchImpl(buildServiceValidateUrl(casBaseUrl, service, ticket), { + method: "GET", + headers: { Accept: "application/xml, text/xml" }, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + throw new Error(`CAS serviceValidate returned HTTP ${res.status}`); + } + return parseServiceValidateResponse(await res.text()); +} diff --git a/bun.lock b/bun.lock index 69399bc..ff9f376 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,6 @@ "name": "@the-forum/web", "version": "0.1.0", "dependencies": { - "@auth/drizzle-adapter": "^1.11.1", "@aws-sdk/client-s3": "^3.1004.0", "@aws-sdk/s3-request-presigner": "^3.1004.0", "@t3-oss/env-nextjs": "^0.13.10", @@ -63,6 +62,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "fast-xml-parser": "^5.4.1", "lucide-react": "^0.575.0", "mapbox-gl": "^3.20.0", "next": "^16.1.6", @@ -97,9 +97,7 @@ "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="], - - "@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.11.1", "", { "dependencies": { "@auth/core": "0.41.1" } }, "sha512-cQTvDZqsyF7RPhDm/B6SvqdVP9EzQhy3oM4Muu7fjjmSYFLbSR203E6dH631ZHSKDn2b4WZkfMnjPDzRsPSAeA=="], + "@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -1657,8 +1655,6 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "next-auth/@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], From 8697421b658f5a5aca44ff61b751caf2257260d9 Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:43:02 -0400 Subject: [PATCH 21/69] feat(web)!: replace Microsoft Entra ID with Princeton CAS login Entra derived netId from the UPN/preferred_username with no domain check, allowing account takeover. Login now goes through Princeton CAS (fed.princeton.edu/cas): - /api/auth/cas/login stores a 32-byte state + sanitized return path in short-lived httpOnly SameSite=Lax cookies and redirects to CAS. - /api/auth/cas/callback timing-safe checks state, then calls Auth.js signIn('cas') in-process. - The 'cas' Credentials provider's authorize() re-validates the ticket with CAS serviceValidate (15s timeout), pins the service URL to our own callback/origin, and upserts the user by NetID without clobbering edited displayName/email. CAS is the sole identity authority. - Direct POSTs to /api/auth/{signin,callback}/cas are rejected. - Sign-out clears the Auth.js session, then ends the CAS SSO session via /api/auth/cas/logout. - New shadcn auth error page (pages.error = /auth/error), public in middleware. - jwt callback tolerates DB errors, keeping last known token values. - Env: drop AUTH_AZURE_*; add optional AUTH_URL and CAS_BASE_URL; document AUTH_TRUST_HOST. --- .env.example | 13 +- apps/web/.env.local.example | 15 +- .../src/app/api/auth/[...nextauth]/route.ts | 26 ++- .../src/app/api/auth/cas/callback/route.ts | 71 ++++++++ apps/web/src/app/api/auth/cas/login/route.ts | 44 +++++ apps/web/src/app/api/auth/cas/logout/route.ts | 22 +++ apps/web/src/app/auth/error/page.tsx | 97 ++++++++++ apps/web/src/app/page.tsx | 34 +--- apps/web/src/auth.ts | 171 +++++++++++------- apps/web/src/components/layout/mobile-nav.tsx | 2 +- apps/web/src/components/layout/sidebar.tsx | 2 +- apps/web/src/env.ts | 16 +- apps/web/src/lib/cas-server.ts | 38 ++++ apps/web/src/middleware.ts | 5 +- apps/web/src/types/next-auth.d.ts | 6 + 15 files changed, 451 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/app/api/auth/cas/callback/route.ts create mode 100644 apps/web/src/app/api/auth/cas/login/route.ts create mode 100644 apps/web/src/app/api/auth/cas/logout/route.ts create mode 100644 apps/web/src/app/auth/error/page.tsx create mode 100644 apps/web/src/lib/cas-server.ts diff --git a/.env.example b/.env.example index f6f89ed..ecb2261 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,16 @@ POSTGRES_PORT=5434 # ----- Database URL (used by Drizzle & Next.js) ----- DATABASE_URL=postgresql://forum:forum_password@localhost:5434/the_forum -# ----- Auth.js (Microsoft Entra ID) ----- +# ----- Auth.js + Princeton CAS ----- +# Generate with: openssl rand -base64 32 AUTH_SECRET=your-auth-secret-here -AUTH_AZURE_AD_CLIENT_ID=your-client-id -AUTH_AZURE_AD_CLIENT_SECRET=your-client-secret -AUTH_AZURE_AD_TENANT_ID=YOUR_TENANT_ID_HERE +# Canonical public URL of the app. Required in production (pins the CAS service +# URL and Auth.js callbacks to this origin). Optional locally. +# AUTH_URL=https://forum.example.edu +# Trust X-Forwarded-Host/Proto from your reverse proxy (not needed on Vercel or when AUTH_URL is set). +# AUTH_TRUST_HOST=true +# Princeton CAS server (default shown). +# CAS_BASE_URL=https://fed.princeton.edu/cas/ # ----- AWS S3 (image uploads) ----- # AWS_S3_BUCKET=the-forum-uploads diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index b4438dc..d316400 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -5,14 +5,17 @@ # ----- Database (Docker default — see `bun run db:up`) ----- DATABASE_URL=postgresql://forum:forum_password@localhost:5434/the_forum -# ----- Auth.js (Microsoft Entra ID / Princeton login) ----- +# ----- Auth.js + Princeton CAS login ----- # Generate AUTH_SECRET yourself: openssl rand -base64 32 AUTH_SECRET=YOUR_AUTH_SECRET_HERE -# Ask Ibraheem for the client ID + secret -AUTH_AZURE_AD_CLIENT_ID=YOUR_CLIENT_ID_HERE -AUTH_AZURE_AD_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE -# Princeton tenant ID (not secret) -AUTH_AZURE_AD_TENANT_ID=2ff60116-7431-425d-b5af-077d7791bda4 +# Login uses Princeton CAS (no client ID/secret needed). CAS redirects back to +# http://localhost:3000/api/auth/cas/callback in dev. +# Canonical public URL — set this in production (e.g. https://forum.example.edu). +# AUTH_URL=http://localhost:3000 +# Only needed behind a reverse proxy without AUTH_URL (Vercel sets this for you). +# AUTH_TRUST_HOST=true +# CAS server (default shown) +# CAS_BASE_URL=https://fed.princeton.edu/cas/ # ----- Mapbox (ask Ibraheem for these) ----- NEXT_PUBLIC_MAPBOX_TOKEN=YOUR_MAPBOX_TOKEN_HERE diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts index 16a2015..cf548c6 100644 --- a/apps/web/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,25 @@ -import { handlers } from "~/auth"; +import type { NextRequest } from "next/server"; +import { CAS_PROVIDER_ID, handlers } from "~/auth"; -export const { GET, POST } = handlers; +export const { GET } = handlers; + +/** + * The CAS Credentials provider must only be driven by our own + * `/api/auth/cas/callback` route (which verifies the `state` cookie and calls + * `signIn()` in-process). Refuse direct HTTP POSTs to its Auth.js endpoints. + * + * Defense in depth only: `authorize()` independently validates the ticket with + * CAS and pins the service URL to our origin. + */ +const blockedPaths = new Set([ + `/api/auth/callback/${CAS_PROVIDER_ID}`, + `/api/auth/signin/${CAS_PROVIDER_ID}`, +]); + +export function POST(req: NextRequest) { + const pathname = req.nextUrl.pathname.replace(/\/+$/, ""); + if (blockedPaths.has(pathname)) { + return new Response("Not found", { status: 404 }); + } + return handlers.POST(req); +} diff --git a/apps/web/src/app/api/auth/cas/callback/route.ts b/apps/web/src/app/api/auth/cas/callback/route.ts new file mode 100644 index 0000000..2e19075 --- /dev/null +++ b/apps/web/src/app/api/auth/cas/callback/route.ts @@ -0,0 +1,71 @@ +import { AuthError } from "next-auth"; +import { cookies } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { CAS_PROVIDER_ID, signIn } from "~/auth"; +import { + CAS_COOKIE_PATH, + CAS_RETURN_COOKIE, + CAS_STATE_COOKIE, + buildServiceUrl, + isValidState, + isValidTicket, + safeCompare, + sanitizeReturnPath, +} from "~/lib/cas"; +import { getAppOrigin } from "~/lib/cas-server"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/auth/cas/callback?state=…&ticket=ST-… + * + * CAS redirects here after login. We verify `state` against the cookie set by + * /api/auth/cas/login, then hand the ticket to the Auth.js CAS provider, whose + * `authorize()` validates it with CAS (serviceValidate) and upserts the user. + * On success `signIn()` sets the session cookie and throws a Next redirect to + * the saved return path, which Next turns into the response. + */ +export async function GET(req: NextRequest) { + const origin = getAppOrigin(req.headers, req.nextUrl.protocol); + const fail = (error: string) => { + const url = new URL("/auth/error", origin); + url.searchParams.set("error", error); + const res = NextResponse.redirect(url, 302); + res.headers.set("Cache-Control", "no-store"); + return res; + }; + + const jar = await cookies(); + const expectedState = jar.get(CAS_STATE_COOKIE)?.value; + const returnPath = sanitizeReturnPath(jar.get(CAS_RETURN_COOKIE)?.value); + // One-shot: the state/return cookies are consumed whatever happens next. + jar.delete({ name: CAS_STATE_COOKIE, path: CAS_COOKIE_PATH }); + jar.delete({ name: CAS_RETURN_COOKIE, path: CAS_COOKIE_PATH }); + + const state = req.nextUrl.searchParams.get("state"); + const ticket = req.nextUrl.searchParams.get("ticket"); + + if (!expectedState || !isValidState(state) || !safeCompare(state, expectedState)) { + return fail("CasState"); + } + if (!isValidTicket(ticket)) { + return fail("CasTicket"); + } + + // Must be byte-for-byte the service we sent to CAS at login. + const service = buildServiceUrl(origin, state); + + try { + await signIn(CAS_PROVIDER_ID, { ticket, service, redirectTo: returnPath }); + } catch (error) { + // signIn() signals success by throwing Next's redirect — let it propagate. + if (error instanceof AuthError) { + console.warn(`[auth] CAS sign-in failed: ${error.type}`); + return fail(error.type === "CredentialsSignin" ? "CasValidation" : "CasUnavailable"); + } + throw error; + } + + // Unreachable in practice: signIn() always redirects. + return fail("Default"); +} diff --git a/apps/web/src/app/api/auth/cas/login/route.ts b/apps/web/src/app/api/auth/cas/login/route.ts new file mode 100644 index 0000000..a7eaf00 --- /dev/null +++ b/apps/web/src/app/api/auth/cas/login/route.ts @@ -0,0 +1,44 @@ +import { cookies } from "next/headers"; +import { type NextRequest, NextResponse } from "next/server"; +import { + CAS_COOKIE_MAX_AGE_SECONDS, + CAS_COOKIE_PATH, + CAS_RETURN_COOKIE, + CAS_STATE_COOKIE, + buildLoginUrl, + buildServiceUrl, + generateState, + sanitizeReturnPath, +} from "~/lib/cas"; +import { casBaseUrl, getAppOrigin } from "~/lib/cas-server"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/auth/cas/login?callbackUrl=/some/path + * + * Starts a Princeton CAS login: stores a random `state` (anti login-CSRF) and a + * sanitized local return path in short-lived httpOnly cookies, then redirects + * to CAS with `service` pointing at our callback. + */ +export async function GET(req: NextRequest) { + const origin = getAppOrigin(req.headers, req.nextUrl.protocol); + const state = generateState(); + const returnPath = sanitizeReturnPath(req.nextUrl.searchParams.get("callbackUrl")); + const service = buildServiceUrl(origin, state); + + const cookieOptions = { + httpOnly: true, + sameSite: "lax" as const, + secure: origin.startsWith("https:"), + path: CAS_COOKIE_PATH, + maxAge: CAS_COOKIE_MAX_AGE_SECONDS, + }; + const jar = await cookies(); + jar.set(CAS_STATE_COOKIE, state, cookieOptions); + jar.set(CAS_RETURN_COOKIE, returnPath, cookieOptions); + + const res = NextResponse.redirect(buildLoginUrl(casBaseUrl, service), 302); + res.headers.set("Cache-Control", "no-store"); + return res; +} diff --git a/apps/web/src/app/api/auth/cas/logout/route.ts b/apps/web/src/app/api/auth/cas/logout/route.ts new file mode 100644 index 0000000..e361fed --- /dev/null +++ b/apps/web/src/app/api/auth/cas/logout/route.ts @@ -0,0 +1,22 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { buildLogoutUrl } from "~/lib/cas"; +import { casBaseUrl, getAppOrigin } from "~/lib/cas-server"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/auth/cas/logout + * + * Final hop of sign-out: the client first calls Auth.js `signOut()` (which + * clears our session cookie via a CSRF-protected POST) with this route as the + * redirect target, and we then end the Princeton CAS SSO session too, so the + * next person on a shared computer isn't silently signed back in. + * + * This route itself changes no state on our side. + */ +export function GET(req: NextRequest) { + const origin = getAppOrigin(req.headers, req.nextUrl.protocol); + const res = NextResponse.redirect(buildLogoutUrl(casBaseUrl, `${origin}/`), 302); + res.headers.set("Cache-Control", "no-store"); + return res; +} diff --git a/apps/web/src/app/auth/error/page.tsx b/apps/web/src/app/auth/error/page.tsx new file mode 100644 index 0000000..64a02c8 --- /dev/null +++ b/apps/web/src/app/auth/error/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from "next"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "~/components/ui/card"; + +export const metadata: Metadata = { + title: "Sign-in problem — Forum", + robots: { index: false }, +}; + +type ErrorCopy = { title: string; description: string }; + +/** + * Only known codes are rendered; the raw `error` query param is never echoed. + * Covers Auth.js' own codes (Configuration, AccessDenied, Verification, …) + * plus the ones our CAS routes emit. + */ +const ERRORS: Record = { + CasState: { + title: "Your sign-in session expired", + description: + "The sign-in link was opened in a different browser, took too long, or was already used. Please try again.", + }, + CasTicket: { + title: "Princeton sign-in didn't complete", + description: "We didn't receive a valid response from Princeton CAS. Please try again.", + }, + CasValidation: { + title: "We couldn't verify your Princeton login", + description: + "Princeton CAS didn't confirm this sign-in. This can happen if you refreshed or reused an old link. Please try again.", + }, + CasUnavailable: { + title: "Sign-in is temporarily unavailable", + description: + "We couldn't reach Princeton CAS or finish setting up your account. Please try again in a moment.", + }, + AccessDenied: { + title: "Access denied", + description: "You don't have permission to sign in to Forum.", + }, + Configuration: { + title: "Sign-in is misconfigured", + description: + "Something is wrong on our end. Please try again later, and let the TigerApps team know if it keeps happening.", + }, +}; + +const DEFAULT_ERROR: ErrorCopy = { + title: "Something went wrong signing you in", + description: "Please try again. If the problem persists, let the TigerApps team know.", +}; + +export default async function AuthErrorPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string | string[] }>; +}) { + const { error } = await searchParams; + const code = typeof error === "string" ? error : undefined; + const known = code !== undefined && Object.hasOwn(ERRORS, code); + const copy = known ? ERRORS[code] : DEFAULT_ERROR; + + return ( +
+ + +

The Forum

+ {copy.title} + {copy.description} +
+ {code ? ( + +

+ Error code: {known ? code : "Unknown"} +

+
+ ) : null} + + {/* Plain : these are route handlers / full navigations, not RSC pages. */} + + + +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index bc9a232..a2db67c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,3 @@ -import { signIn } from "~/auth"; - export default function LandingPage() { return (
@@ -25,12 +23,8 @@ export default function LandingPage() { EVENT ORGANIZERS
-
{ - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/explore" }); - }} - > + + @@ -83,12 +77,8 @@ export default function LandingPage() { one beautifully curated feed, personalized around you, your friends, and the things you actually love.

- { - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/explore" }); - }} - > + + @@ -190,12 +180,8 @@ export default function LandingPage() { reach students whose interests actually align with what you're building.

- { - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/events/create" }); - }} - > + + diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index fe5c913..7bd7f2a 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -1,19 +1,96 @@ import { db, eq, users } from "@the-forum/database"; import NextAuth from "next-auth"; -import type { MicrosoftEntraIDProfile } from "next-auth/providers/microsoft-entra-id"; -import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; -import { env } from "~/env"; +import Credentials from "next-auth/providers/credentials"; +import { isExpectedServiceUrl, isValidTicket, validateServiceTicket } from "~/lib/cas"; +import { casBaseUrl } from "~/lib/cas-server"; + +/** Auth.js provider id for Princeton CAS. */ +export const CAS_PROVIDER_ID = "cas"; + +const userColumns = { + id: users.id, + netId: users.netId, + email: users.email, + displayName: users.displayName, + onboarded: users.onboarded, +}; + +/** + * Find-or-create the user for a CAS-verified NetID. + * On first login we seed email/displayName from the NetID; on later logins we + * leave them alone so profile edits aren't clobbered. + */ +async function upsertUserByNetId(netId: string) { + const [inserted] = await db + .insert(users) + .values({ netId, email: `${netId}@princeton.edu`, displayName: netId }) + .onConflictDoNothing({ target: users.netId }) + .returning(userColumns); + if (inserted) return inserted; + + const [existing] = await db + .select(userColumns) + .from(users) + .where(eq(users.netId, netId)) + .limit(1); + return existing ?? null; +} export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ - MicrosoftEntraID({ - clientId: env.AUTH_AZURE_AD_CLIENT_ID, - clientSecret: env.AUTH_AZURE_AD_CLIENT_SECRET, - issuer: `https://login.microsoftonline.com/${env.AUTH_AZURE_AD_TENANT_ID}/v2.0`, - authorization: { - params: { - scope: "openid profile email User.Read", - }, + /** + * Princeton CAS, modelled as a Credentials provider so we keep Auth.js' JWT + * session (`auth()`, `signOut`, `session.user`). The only "credentials" are + * a CAS service ticket and the service URL it was issued for; `authorize` + * re-validates both against CAS server-side, so nothing the client sends + * is trusted as identity. + * + * The only caller is `/api/auth/cas/callback` (which checks the `state` + * cookie first); direct POSTs to `/api/auth/callback/cas` are rejected in + * `app/api/auth/[...nextauth]/route.ts`. + */ + Credentials({ + id: CAS_PROVIDER_ID, + name: "Princeton CAS", + credentials: { + ticket: {}, + service: {}, + }, + async authorize(credentials, request) { + const { ticket, service } = credentials ?? {}; + if (!isValidTicket(ticket)) return null; + + // The service must be *our* callback URL on the origin Auth.js resolved + // for this request. Otherwise a ticket CAS issued to another site could + // be replayed here to impersonate the user who obtained it. + const expectedOrigin = new URL(request.url).origin; + if (!isExpectedServiceUrl(service, expectedOrigin)) { + console.warn("[auth] rejected CAS login: unexpected service URL"); + return null; + } + + const result = await validateServiceTicket({ + casBaseUrl, + service: service as string, + ticket, + }); + if (!result.ok) { + console.warn( + `[auth] CAS ticket validation failed: ${result.reason}${result.code ? ` (${result.code})` : ""}`, + ); + return null; + } + + const user = await upsertUserByNetId(result.netId); + if (!user) return null; + + return { + id: user.id, + email: user.email, + name: user.displayName, + netId: user.netId, + onboarded: user.onboarded, + }; }, }), ], @@ -22,62 +99,30 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, pages: { signIn: "/", + error: "/auth/error", }, callbacks: { - async signIn({ profile }) { - if (!profile?.email) return false; - - const entraProfile = profile as unknown as MicrosoftEntraIDProfile; - - // Extract NetID from UPN (e.g., "iamin@princeton.edu" → "iamin") - const upn = entraProfile.upn ?? entraProfile.preferred_username ?? profile.email; - const netId = upn.split("@")[0]?.toLowerCase(); - - // Upsert user - const existing = await db.select().from(users).where(eq(users.netId, netId)).limit(1); - - if (existing.length === 0) { - await db.insert(users).values({ - netId, - email: profile.email, - displayName: profile.name ?? netId, - }); - } else { - await db - .update(users) - .set({ - displayName: profile.name ?? existing[0]?.displayName, - email: profile.email, - updatedAt: new Date(), - }) - .where(eq(users.netId, netId)); + async jwt({ token, user }) { + // Initial sign-in: `user` is what `authorize` returned. + if (user) { + token.userId = user.id; + token.netId = user.netId; + token.onboarded = user.onboarded ?? false; + return token; } - return true; - }, - - async jwt({ token, profile }) { - if (profile?.email) { - const entraProfile = profile as unknown as MicrosoftEntraIDProfile; - const upn = entraProfile.upn ?? entraProfile.preferred_username ?? profile.email; - const netId = upn.split("@")[0]?.toLowerCase(); - - const [user] = await db.select().from(users).where(eq(users.netId, netId)).limit(1); - - if (user) { - token.userId = user.id; - token.netId = user.netId; - token.onboarded = user.onboarded; - } - } else if (token.userId) { - // Re-check onboarded status from DB on subsequent requests - const [user] = await db - .select({ onboarded: users.onboarded }) - .from(users) - .where(eq(users.id, token.userId as string)) - .limit(1); - if (user) { - token.onboarded = user.onboarded; + // Subsequent requests: refresh onboarded status. A DB hiccup must not + // 500 every request, so on failure keep the last known token values. + if (token.userId) { + try { + const [row] = await db + .select({ onboarded: users.onboarded }) + .from(users) + .where(eq(users.id, token.userId as string)) + .limit(1); + if (row) token.onboarded = row.onboarded; + } catch (err) { + console.error("[auth] failed to refresh session from DB; using cached token", err); } } return token; diff --git a/apps/web/src/components/layout/mobile-nav.tsx b/apps/web/src/components/layout/mobile-nav.tsx index f39535a..7e0cb7c 100644 --- a/apps/web/src/components/layout/mobile-nav.tsx +++ b/apps/web/src/components/layout/mobile-nav.tsx @@ -54,7 +54,7 @@ export function MobileNav() {
  • +
  • +
    + )} + + {/* Step 2: Academic Profile */} + {step === 1 && ( +
    +

    + Step 2 of {totalSteps - 1} +

    +

    + Your academic profile +

    +

    + This helps us surface events that are right for where you are in your Princeton journey +

    + +
    +
    + + +
    + + + +
    + + Campus Regions You Are Frequently At + +

    + Select all that apply. We'll prioritize nearby events +

    +
    + {CAMPUS_REGION_OPTIONS.map(({ value, label, desc }) => { + const selected = regions.includes(value); + return ( + + ); + })} +
    +
    +
    + +
    + + +
    +
    + )} + + {/* Step 3: Interests */} + {step === 2 && ( +
    +

    + Step 3 of {totalSteps - 1} +

    +

    + What do you like? +

    +

    + Pick as many as you like. Your feed is built around these. The more you choose, the + better it gets. +

    + +
    + {INTEREST_OPTIONS.map(({ value, label }) => { + const selected = interests.includes(value); + return ( + + ); + })} +
    + +

    + {interests.length} {interests.length === 1 ? "interest" : "interests"} selected +

    + +
    + + +
    +
    + )} + + {/* Step 4: Org Leader */} + {step === 3 && ( +
    +

    + Step 4 of {totalSteps - 1} +

    +

    + Are you a club/org. leader? +

    +

    + If you run or manage a student organization, we'll unlock tools to create and + publish events on Forum. +

    + +
    + {ORG_ROLES.map(({ value, label, desc }) => { + const selected = orgRole === value; + return ( + + ); + })} +
    + +
    + + +
    +
    + )} + + {/* Step 5: Completion — only reached after the save succeeded */} + {step === 4 && ( +
    +

    + You are all set +
    + {firstName.trim() || "there"}! +

    +

    + Your personalized Forum feed is ready. +

    + +
    + )} +
    + ); +} + +/** Searchable major/department selector with A.B./B.S.E. distinction */ +function MajorSelector({ + major, + setMajor, +}: { + major: string; + setMajor: (v: string) => void; +}) { + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + + const filtered = useMemo(() => { + if (!search) return PRINCETON_MAJORS; + const q = search.toLowerCase(); + return PRINCETON_MAJORS.filter( + (d) => + d.name.toLowerCase().includes(q) || + d.code.toLowerCase().includes(q) || + (d.degree?.toLowerCase().includes(q) ?? false), + ); + }, [search]); + + const selected = PRINCETON_MAJORS.find((d) => { + const label = d.degree ? `${d.name} (${d.degree})` : d.name; + return label === major || d.code === major || d.name === major; + }); + + const displayLabel = selected + ? selected.degree + ? `${selected.name} (${selected.degree})` + : selected.name + : major || null; + + return ( +
    + + Major / Department + + + {open && ( +
    +
    + setSearch(e.target.value)} + placeholder="Search majors... (try 'COS', 'BSE', 'economics')" + className="w-full h-[36px] px-[12px] text-[13px] font-dm-sans bg-gray-50 rounded-[8px] outline-none" + /> +
    +
    + {filtered.length > 0 ? ( + filtered.map((dept) => { + const label = dept.degree ? `${dept.name} (${dept.degree})` : dept.name; + return ( + + ); + }) + ) : ( +

    + No majors found +

    + )} +
    +
    + )} +
    + ); +} diff --git a/apps/web/src/app/(onboarding)/onboarding/page.tsx b/apps/web/src/app/(onboarding)/onboarding/page.tsx index 1706dc9..774780b 100644 --- a/apps/web/src/app/(onboarding)/onboarding/page.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/page.tsx @@ -1,666 +1,22 @@ -"use client"; +import type { Metadata } from "next"; +import { getUserProfile } from "~/actions/users"; +import { OnboardingClient } from "./onboarding-client"; -import { useSession } from "next-auth/react"; -import { useEffect, useMemo, useState, useTransition } from "react"; -import { completeOnboarding } from "~/actions/users"; -import { PRINCETON_MAJORS } from "~/lib/princeton-departments"; -import { cn } from "~/lib/utils"; +export const metadata: Metadata = { title: "Welcome" }; -const INTEREST_TAGS = [ - "Career & Recruiting", - "Research", - "Academics", - "Entrepreneurship", - "Art & Design", - "Theater & Performance", - "Cultural & Identity", - "Music", - "Community Service", - "Gaming & Esports", - "Politics & Advocacy", - "Sustainability", - "Free Food", - "Wellness & Mental Health", - "Fitness & Sports", - "Cooking & Food", - "Tech & Coding", - "Science & Engineering", - "AI & Machine Learning", - "Social Events", - "Outdoor & Adventure", - "Photography", - "Film", -]; - -// Maps display labels to DB event_tag enum values -const INTEREST_TO_TAG: Record = { - "Career & Recruiting": "career", - Research: "research", - Academics: "academics", - Entrepreneurship: "entrepreneurship", - "Art & Design": "visual arts", - "Theater & Performance": "performing arts", - "Cultural & Identity": "culture", - Music: "music", - "Community Service": "community service", - "Gaming & Esports": "gaming", - "Politics & Advocacy": "politics", - Sustainability: "sustainability", - "Free Food": "free food", - "Wellness & Mental Health": "wellness", - "Fitness & Sports": "athletics", - "Cooking & Food": "free food", - "Tech & Coding": "tech", - "Science & Engineering": "stem", - "AI & Machine Learning": "tech", - "Social Events": "social event", - "Outdoor & Adventure": "outdoors", - Photography: "visual arts", - Film: "visual arts", -}; - -const CAMPUS_REGIONS = [ - { id: "central", label: "Central Campus", desc: "Nassau Hall, Frist, 1879" }, - { id: "east", label: "Science Area", desc: "Jadwin, Friend, EQuad" }, - { id: "south", label: "Prospect Ave", desc: "Eating clubs, Terrace" }, - { id: "west", label: "Residential Colleges", desc: "Butler, Whitman, Yeh ..." }, - { id: "north", label: "Arts Corridor", desc: "McCarter, Lewis Center" }, - { id: "off-campus", label: "Athletics Area", desc: "Lenz, Denunzio, Dillon" }, -]; - -const CLASS_YEARS = ["2025", "2026", "2027", "2028", "2029", "Grad"]; - -const ORG_ROLES = [ - { - value: "leader", - label: "Yes, I lead or manage an organization/club", - desc: "I can create events, manage an org page, and post announcements to members.", - }, - { - value: "member", - label: "I'm a member but not a leader", - desc: "I'll follow organizations and get notified when they post new events.", - }, - { - value: "explorer", - label: "I'm just here to discover events", - desc: "Browse the feed, RSVP, and coordinate with friends.", - }, -]; - -export default function OnboardingPage() { - const { data: session } = useSession(); - const [isPending, startTransition] = useTransition(); - const [step, setStep] = useState(0); - - // Step 1: Personal info - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [netId, setNetId] = useState(""); - const [email, setEmail] = useState(""); - - // Step 2: Academic profile - const [classYear, setClassYear] = useState(""); - const [residentialCollege, setResidentialCollege] = useState(""); - const [major, setMajor] = useState(""); - const [regions, setRegions] = useState([]); - - // Step 3: Interests - const [interests, setInterests] = useState([]); - - // Step 4: Org role - const [orgRole, setOrgRole] = useState(""); - - const totalSteps = 5; - - // Pre-fill from session - const displayName = session?.user?.name ?? ""; - const userEmail = session?.user?.email ?? ""; - - // biome-ignore lint/correctness/useExhaustiveDependencies: only run when session changes - useEffect(() => { - if (!session?.user) return; - if (!firstName && displayName) { - setFirstName(displayName.split(" ")[0] || ""); - setLastName(displayName.split(" ").slice(1).join(" ") || ""); - } - if (!email && userEmail) setEmail(userEmail); - const sessionNetId = (session.user as { netId?: string })?.netId; - if (!netId && sessionNetId) setNetId(sessionNetId); - }, [session?.user]); - - const toggleInterest = (tag: string) => { - setInterests((prev) => (prev.includes(tag) ? prev.filter((i) => i !== tag) : [...prev, tag])); - }; - - const toggleRegion = (id: string) => { - setRegions((prev) => (prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id])); - }; - - const canProceed = () => { - switch (step) { - case 0: - return (firstName || displayName) && netId; - case 1: - return classYear !== ""; - case 2: - return interests.length >= 1; - case 3: - return orgRole !== ""; - default: - return true; - } - }; - - const handleSubmit = () => { - startTransition(async () => { - const uniqueTags = [ - ...new Set(interests.map((label) => INTEREST_TO_TAG[label]).filter(Boolean)), - ]; - await completeOnboarding({ - interests: uniqueTags, - classYear, - major, - regions, - isOrgLeader: orgRole === "leader", - }); - window.location.href = "/explore"; - }); - }; - - const handleNext = () => { - if (step < totalSteps - 1) setStep(step + 1); - }; - - const handleBack = () => { - if (step > 0) setStep(step - 1); - }; - - return ( -
    - {/* Step 1: Personal Info */} - {step === 0 && ( -
    -

    - Step 1 of {totalSteps - 1} -

    -

    - Let's get you set up -

    -

    - Tell us a bit about yourself so we can personalize your experience from day one -

    - -
    -
    -
    - - setFirstName(e.target.value)} - className="w-full h-[49px] border border-black rounded-[14px] px-[20px] text-[15px] font-dm-sans placeholder:text-forum-placeholder placeholder:font-bold outline-none focus:border-forum-orange transition-colors" - /> -
    -
    - - setLastName(e.target.value)} - className="w-full h-[49px] border border-black rounded-[14px] px-[20px] text-[15px] font-dm-sans placeholder:text-forum-placeholder placeholder:font-bold outline-none focus:border-forum-orange transition-colors" - /> -
    -
    - -
    - - setNetId(e.target.value)} - className="w-full h-[49px] border border-black rounded-[14px] px-[20px] text-[15px] font-dm-sans placeholder:text-forum-placeholder placeholder:font-bold outline-none focus:border-forum-orange transition-colors" - /> -
    - -
    - - setEmail(e.target.value)} - className="w-full h-[49px] border border-black rounded-[14px] px-[20px] text-[15px] font-dm-sans placeholder:text-forum-placeholder placeholder:font-bold outline-none focus:border-forum-orange transition-colors" - /> -
    -
    - -
    - -
    -
    - )} - - {/* Step 2: Academic Profile */} - {step === 1 && ( -
    -

    - Step 2 of {totalSteps - 1} -

    -

    - Your academic profile -

    -

    - This helps us surface events that are right for where you are in your Princeton journey -

    - -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    - - Campus Regions You Are Frequently At - -

    - Select all that apply. We'll prioritize nearby events -

    -
    - {CAMPUS_REGIONS.map(({ id, label, desc }) => { - const selected = regions.includes(id); - return ( - - ); - })} -
    -
    -
    - -
    - - -
    -
    - )} - - {/* Step 3: Interests */} - {step === 2 && ( -
    -

    - Step 3 of {totalSteps - 1} -

    -

    - What do you like? -

    -

    - Pick as many as you like. Your feed is built around these. The more you choose, the - better it gets. -

    - -
    - {INTEREST_TAGS.map((tag) => { - const selected = interests.includes(tag); - return ( - - ); - })} -
    - -

    - {interests.length} interests selected -

    - -
    - - -
    -
    - )} - - {/* Step 4: Org Leader */} - {step === 3 && ( -
    -

    - Step 4 of {totalSteps - 1} -

    -

    - Are you a club/org. leader? -

    -

    - If you run or manage a student organization, we'll unlock tools to create and - publish events on Forum. -

    - -
    - {ORG_ROLES.map(({ value, label, desc }) => { - const selected = orgRole === value; - return ( - - ); - })} -
    - -
    - - -
    -
    - )} - - {/* Step 5: Completion */} - {step === 4 && ( -
    -

    - You are all set -
    - - {firstName || displayName.split(" ")[0] || "there"} - - ! -

    -

    - Your personalized Forum feed is ready. -

    - -
    - )} -
    - ); -} - -/** Searchable major/department selector with A.B./B.S.E. distinction */ -function MajorSelector({ - major, - setMajor, -}: { - major: string; - setMajor: (v: string) => void; -}) { - const [search, setSearch] = useState(""); - const [open, setOpen] = useState(false); - - const filtered = useMemo(() => { - if (!search) return PRINCETON_MAJORS; - const q = search.toLowerCase(); - return PRINCETON_MAJORS.filter( - (d) => - d.name.toLowerCase().includes(q) || - d.code.toLowerCase().includes(q) || - (d.degree?.toLowerCase().includes(q) ?? false), - ); - }, [search]); - - const selected = PRINCETON_MAJORS.find((d) => { - const label = d.degree ? `${d.name} (${d.degree})` : d.name; - return label === major || d.code === major || d.name === major; - }); - - const displayLabel = selected - ? selected.degree - ? `${selected.name} (${selected.degree})` - : selected.name - : major || null; +/** + * Identity fields are read from the database (populated at sign-in) rather + * than the client session, so they're correct regardless of which fields the + * login provider puts in the token. + */ +export default async function OnboardingPage() { + const profile = await getUserProfile(); return ( -
    - - Major / Department - - - {open && ( -
    -
    - setSearch(e.target.value)} - placeholder="Search majors... (try 'COS', 'BSE', 'economics')" - className="w-full h-[36px] px-[12px] text-[13px] font-dm-sans bg-gray-50 rounded-[8px] outline-none" - /> -
    -
    - {filtered.length > 0 ? ( - filtered.map((dept) => { - const label = dept.degree ? `${dept.name} (${dept.degree})` : dept.name; - return ( - - ); - }) - ) : ( -

    - No majors found -

    - )} -
    -
    - )} -
    + ); } From a0545f3ed2450557d56e3279220f27959587967c Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:44:36 -0400 Subject: [PATCH 26/69] ci: add CI workflow (biome, tsc, bun test, web build) without secrets turbo.json: declare build env (NEXT_PUBLIC_* hashed; SKIP_ENV_VALIDATION, DATABASE_URL, AUTH_*, CAS_BASE_URL, AWS_* passed through). Turbo 2 strict env mode otherwise strips them, so 'turbo build' could not validate or skip env validation. --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ turbo.json | 13 +++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..930105b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + push: + branches: [staging, main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + web: + name: lint / typecheck / test / build + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # No secrets needed: env validation is skipped and every value is a dummy. + SKIP_ENV_VALIDATION: "1" + AUTH_SECRET: ci-dummy-auth-secret + DATABASE_URL: postgresql://ci:ci@localhost:5432/ci + NEXT_PUBLIC_MAPBOX_TOKEN: pk.ci-dummy + NEXT_PUBLIC_CAMPUS_MAP_TOKEN: pk.ci-dummy + NEXT_PUBLIC_CAMPUS_MAP_STYLE: mapbox://styles/ci/dummy + NEXT_TELEMETRY_DISABLED: "1" + TURBO_TELEMETRY_DISABLED: "1" + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + # bun.lock uses configVersion (Bun >= 1.3). + bun-version: 1.3.5 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint (Biome) + run: bun run lint + + - name: Typecheck (apps/web) + run: bunx tsc --noEmit -p apps/web + + - name: Unit tests (apps/web) + working-directory: apps/web + run: bun test + + - name: Build (apps/web) + run: bun run build --filter=@the-forum/web diff --git a/turbo.json b/turbo.json index cbf008a..a3905f4 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,18 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".next/**", "!.next/cache/**", "dist/**"] + "outputs": [".next/**", "!.next/cache/**", "dist/**"], + "env": ["NEXT_PUBLIC_*"], + "passThroughEnv": [ + "SKIP_ENV_VALIDATION", + "DATABASE_URL", + "AUTH_SECRET", + "AUTH_URL", + "AUTH_TRUST_HOST", + "CAS_BASE_URL", + "AWS_S3_BUCKET", + "AWS_REGION" + ] }, "dev": { "cache": false, From 1c5b828e9ec799055cb75246be68ea56c0c8ca2b Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 28 Sep 2026 02:48:13 -0400 Subject: [PATCH 27/69] Event forms: real date/time inputs, explicit location, working preview Create and Edit now share components/events/event-form-fields.tsx: - shadcn Calendar date + Input type=time start/end; combined in America/New_York and sent as datetime/endDatetime; end must be after start; past start times are rejected on create - Location is required; the seeded 'other' row is offered as an explicit 'Other / off-campus / TBA' choice instead of silently defaulting to Frist. The picker no longer closes itself on click. - Tags are chosen from the real event_tag set (the free-text box silently dropped anything else on save); dead Keyword/Organization buttons removed - Remove the non-functional 'Upload Files' section, the 'Select Location on map' placeholder and the dead 'Add External Links' button; external links are validated and get https:// if missing - Remove 'Save as draft': drafts could never be published again - Flyer upload goes through lib/upload-image (checks the S3 PUT's res.ok, toasts errors, no console.error) - Edit form's Preview button opens EventPreviewModal, which now uses the shadcn Dialog (focus trap, Escape, labelled close button) - Save/publish failures toast instead of failing silently; Back to top scrolls the app's scroll container (window.scrollTo was a no-op) --- .../events/[id]/edit/edit-event-form.tsx | 489 ++++++------- .../(app)/events/create/create-event-form.tsx | 647 ++++++------------ .../components/events/event-form-fields.tsx | 388 +++++++++++ .../components/events/event-preview-modal.tsx | 106 ++- 4 files changed, 816 insertions(+), 814 deletions(-) create mode 100644 apps/web/src/components/events/event-form-fields.tsx diff --git a/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx b/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx index 73f88b4..d2d9054 100644 --- a/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx +++ b/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx @@ -1,49 +1,48 @@ "use client"; -import { format } from "date-fns"; -import { - ArrowUp, - CalendarIcon, - ExternalLink, - Eye, - Globe, - ImagePlus, - Link2, - Lock, - MapPin, - Pencil, - Plus, - Search, - Upload, - X, -} from "lucide-react"; +import { ArrowUp, Eye, Globe, Link2, Lock, Pencil, Upload, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; import { type EventDetail, updateEvent } from "~/actions/events"; -import { getPresignedUploadUrl } from "~/actions/upload"; -import { PageShell } from "~/components/layout/page-shell"; +import { + type CampusLocation, + type EventWhen, + EventWhenFields, + FORM_ERROR, + FORM_LABEL, + LocationPicker, + OTHER_LOCATION_ID, + TagPicker, + VisibilityToggle, + type WhenErrors, + describeEventWhen, + normalizeExternalLink, + resolveEventWhen, +} from "~/components/events/event-form-fields"; +import { EventPreviewModal } from "~/components/events/event-preview-modal"; +import { PageShell, TOP_BAR_CLEARANCE } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; -import { Calendar } from "~/components/ui/calendar"; -import { Input } from "~/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { Textarea } from "~/components/ui/textarea"; +import { toZonedDateKey, toZonedTimeValue } from "~/lib/date-format"; +import { isInterestValue } from "~/lib/profile-options"; +import { IMAGE_ACCEPT, uploadImage } from "~/lib/upload-image"; import { cn } from "~/lib/utils"; const TIMELINE_SECTIONS = [ { id: "cover", label: "Cover & Title", color: "bg-forum-cerulean" }, { id: "details", label: "Details & Description", color: "bg-forum-coral" }, { id: "when-where", label: "When & Where", color: "bg-forum-coral" }, - { id: "uploads", label: "Uploads & Links", color: "bg-forum-coral" }, + { id: "tags-links", label: "Tags & Links", color: "bg-forum-coral" }, ]; interface EditEventFormProps { event: EventDetail; - locations: { id: string; name: string; category: string }[]; + locations: CampusLocation[]; } -function pad(n: number) { - return n.toString().padStart(2, "0"); -} +type FormErrors = WhenErrors & + Partial>; export function EditEventForm({ event, locations }: EditEventFormProps) { const router = useRouter(); @@ -53,126 +52,111 @@ export function EditEventForm({ event, locations }: EditEventFormProps) { const [title, setTitle] = useState(event.title); const [description, setDescription] = useState(event.description); - const [date, setDate] = useState(event.datetime); - const [startTime, setStartTime] = useState( - `${pad(event.datetime.getHours())}:${pad(event.datetime.getMinutes())}`, - ); - const [endTime, setEndTime] = useState( - event.endDatetime - ? `${pad(event.endDatetime.getHours())}:${pad(event.endDatetime.getMinutes())}` - : "", - ); + // Seeded from the stored instant, read back as Princeton wall-clock time. + const [when, setWhen] = useState(() => ({ + dateKey: toZonedDateKey(event.datetime), + startTime: toZonedTimeValue(event.datetime), + endTime: event.endDatetime ? toZonedTimeValue(event.endDatetime) : "", + })); const [locationId, setLocationId] = useState(event.locationId); - const [locationSearch, setLocationSearch] = useState(""); - const [locationOpen, setLocationOpen] = useState(false); - const [tags, setTags] = useState(event.tags); - const [tagSearch, setTagSearch] = useState(""); + const [tags, setTags] = useState(event.tags.filter(isInterestValue)); const [flyerUrl, setFlyerUrl] = useState(event.flyerUrl); const [flyerPreview, setFlyerPreview] = useState(event.flyerUrl); const [isUploading, setIsUploading] = useState(false); const [externalLink, setExternalLink] = useState(event.externalLink ?? ""); const [isPublic, setIsPublic] = useState(event.isPublic); - const [errors, setErrors] = useState>({}); - - const filteredLocations = locations.filter( - (loc) => !locationSearch || loc.name.toLowerCase().includes(locationSearch.toLowerCase()), - ); - const selectedLocationName = locations.find((l) => l.id === locationId)?.name ?? ""; + const [showPreview, setShowPreview] = useState(false); + const [errors, setErrors] = useState({}); - const addTag = (tag: string) => { - if (tag && !tags.includes(tag)) setTags((prev) => [...prev, tag]); - setTagSearch(""); - }; - const removeTag = (tag: string) => setTags((prev) => prev.filter((t) => t !== tag)); + const clearError = (key: keyof FormErrors) => + setErrors((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev)); const handleImageUpload = useCallback( async (file: File) => { if (isUploading) return; setIsUploading(true); + const previousUrl = flyerUrl; + const localPreview = URL.createObjectURL(file); + setFlyerPreview(localPreview); try { - const reader = new FileReader(); - reader.onload = (e) => setFlyerPreview(e.target?.result as string); - reader.readAsDataURL(file); - const { uploadUrl, publicUrl } = await getPresignedUploadUrl({ - filename: file.name, - contentType: file.type, - size: file.size, - folder: "event-flyers", - }); - await fetch(uploadUrl, { - method: "PUT", - body: file, - headers: { "Content-Type": file.type }, - }); + const publicUrl = await uploadImage(file, "event-flyers"); setFlyerUrl(publicUrl); + setFlyerPreview(publicUrl); } catch (err) { - console.error("Upload failed:", err); - setFlyerPreview(event.flyerUrl); + setFlyerPreview(previousUrl); + toast.error(err instanceof Error ? err.message : "Couldn't upload that image."); } finally { + URL.revokeObjectURL(localPreview); setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; } }, - [isUploading, event.flyerUrl], + [isUploading, flyerUrl], ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; - if (file?.type.startsWith("image/")) handleImageUpload(file); + if (file) handleImageUpload(file); }, [handleImageUpload], ); - const validate = (): boolean => { - const newErrors: Record = {}; - if (!title.trim()) newErrors.title = "Title is required"; - if (!description.trim()) newErrors.description = "Description is required"; - if (!date) newErrors.date = "Date is required"; - if (!locationId) newErrors.location = "Location is required"; - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - const handleSubmit = () => { - if (!validate()) return; - startTransition(async () => { - const datetime = new Date(date as Date); - const [h, m] = startTime.split(":").map(Number); - datetime.setHours(h ?? 0, m ?? 0); + const nextErrors: FormErrors = {}; + if (!title.trim()) nextErrors.title = "Title is required"; + if (!description.trim()) nextErrors.description = "Description is required"; + if (!locationId) nextErrors.location = "Choose a location, or “Other / off-campus / TBA”"; + const link = normalizeExternalLink(externalLink); + if (link === null) nextErrors.link = "Enter a valid web address, e.g. https://example.com"; + const resolved = resolveEventWhen(when); + if (!resolved.ok) Object.assign(nextErrors, resolved.errors); - let endDatetime: string | undefined; - if (endTime) { - const end = new Date(date as Date); - const [eh, em] = endTime.split(":").map(Number); - end.setHours(eh ?? 0, em ?? 0); - endDatetime = end.toISOString(); - } + setErrors(nextErrors); + if (Object.values(nextErrors).some(Boolean) || !resolved.ok) { + toast.error("Please fix the highlighted fields."); + return; + } + if (isUploading) { + toast.error("Please wait for the cover image to finish uploading."); + return; + } - await updateEvent(event.id, { - title: title.trim(), - description: description.trim(), - datetime: datetime.toISOString(), - endDatetime, - locationId, - tags, - flyerUrl: flyerUrl ?? undefined, - externalLink: externalLink.trim() || undefined, - isPublic, - }); - router.push(`/events/${event.id}`); + startTransition(async () => { + try { + await updateEvent(event.id, { + title: title.trim(), + description: description.trim(), + datetime: resolved.datetime.toISOString(), + endDatetime: resolved.endDatetime?.toISOString(), + locationId, + tags, + flyerUrl: flyerUrl ?? undefined, + externalLink: link || undefined, + isPublic, + }); + toast.success("Changes saved"); + router.push(`/events/${event.id}`); + } catch { + toast.error("Couldn't save your changes. Please try again."); + } }); }; + const previewWhen = describeEventWhen(when); + return ( {/* Top buttons */} -
    +
    - - @@ -184,7 +168,7 @@ export function EditEventForm({ event, locations }: EditEventFormProps) {
    {/* Timeline sidebar */} -
    +
    + {/* Form body */}
    @@ -223,23 +207,26 @@ export function EditEventForm({ event, locations }: EditEventFormProps) {
    Cover preview {isUploading && ( -
    - -
    + + + Uploading… + )}
    ) : ( @@ -248,18 +235,22 @@ export function EditEventForm({ event, locations }: EditEventFormProps) { onClick={() => fileInputRef.current?.click()} onDragOver={(e) => e.preventDefault()} onDrop={handleDrop} - className="w-full h-[280px] rounded-[10px] bg-forum-turquoise/15 border-2 border-dashed border-forum-turquoise/40 flex items-end justify-end p-[20px] cursor-pointer hover:bg-forum-turquoise/20 transition-colors mb-[20px]" + className="w-full h-[220px] sm:h-[280px] rounded-[10px] bg-forum-turquoise/15 border-2 border-dashed border-forum-turquoise/40 flex flex-col items-end justify-end gap-1 p-[20px] cursor-pointer hover:bg-forum-turquoise/20 transition-colors mb-[20px]" > - Add Cover Image + Add Cover Image + + + JPEG, PNG or WebP, up to 5 MB )} { const file = e.target.files?.[0]; if (file) handleImageUpload(file); @@ -269,38 +260,43 @@ export function EditEventForm({ event, locations }: EditEventFormProps) { {/* Event Title */}
    - + +
    { setTitle(e.target.value); - if (errors.title) setErrors((prev) => ({ ...prev, title: "" })); + clearError("title"); }} placeholder="Super Interesting Event Title" className={cn( - "flex-1 text-[32px] font-serif font-bold text-black placeholder:text-forum-placeholder/40 outline-none", + "flex-1 min-w-0 text-[26px] sm:text-[32px] font-serif font-bold text-black placeholder:text-forum-placeholder/40 outline-none", errors.title && "placeholder:text-forum-coral/60", )} /> - +
    - {errors.title &&

    {errors.title}

    } + {errors.title &&

    {errors.title}

    }
    {/* Event Description */}
    - + +