diff --git a/README.md b/README.md index 67bb2b4..61baefe 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The database URL is `postgresql://forum:forum_password@localhost:5434/the_forum` Optionally fill the database with realistic demo data: ```bash -cd apps/database && bun run db:seed +bun run db:seed ``` ### 4. Run the app @@ -124,6 +124,7 @@ bun run build # build all packages bun run db:up # start Postgres db:down stop it (data persists) bun run db:push # push schema (dev) db:generate generate SQL migrations bun run db:migrate # apply migrations db:studio visual DB browser +bun run db:seed # seed demo data (safe to re-run any time) ``` Pre-commit hooks (Husky + lint-staged) automatically run Biome on staged files — @@ -186,4 +187,4 @@ Re-run `bun install` from the repo root (the `prepare` script reinstalls hooks). Expected unless you've run `uv sync` in `backends/fastapi` and `uv` is on your PATH. **Wipe the database and start fresh** -`docker compose down -v` (deletes the data volume), then `bun run db:up && bun run db:push`. +`docker compose down -v` (deletes the data volume), then `bun run db:up && bun run db:push && bun run db:seed`. diff --git a/apps/database/package.json b/apps/database/package.json index 01b9253..fe5e459 100644 --- a/apps/database/package.json +++ b/apps/database/package.json @@ -10,7 +10,7 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", - "db:seed": "tsx src/seed.ts", + "db:seed": "bun run src/seed.ts", "lint": "biome check .", "check-types": "tsc --noEmit" }, @@ -21,7 +21,6 @@ "devDependencies": { "@types/node": "^22.13.4", "drizzle-kit": "^0.31.10", - "tsx": "^4.21.0", "typescript": "^5.7.3" } } diff --git a/apps/database/src/seed.ts b/apps/database/src/seed.ts index b15cd16..e00ac8a 100644 --- a/apps/database/src/seed.ts +++ b/apps/database/src/seed.ts @@ -4,6 +4,7 @@ * Run: bun run db:seed (from apps/database) */ +import { eq } from "drizzle-orm"; import { db } from "./db"; import { events, @@ -33,6 +34,12 @@ function pickN(arr: T[], n: number): T[] { const shuffled = [...arr].sort(() => Math.random() - 0.5); return shuffled.slice(0, n); } +function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} const now = Date.now(); @@ -755,17 +762,53 @@ async function seed() { }, ]; - // Events have no unique constraint on title, so skip ones that already - // exist to keep re-runs of the seed from inserting duplicates. - const existingTitles = new Set( - (await db.select({ title: events.title }).from(events)).map((e) => e.title), + // Titles aren't a safe identity — they're not unique and could collide with a + // real user-created event. Each seed event instead gets a stable key derived + // from its title (`seed:`), stored in `sourceMessageId` with + // `source = "seed"`. Re-runs only ever match rows tagged `source = "seed"`, + // so a reseed can never touch a real user's or listserv-imported event, and + // the DB's existing unique constraint on `sourceMessageId` guarantees no two + // seed rows can silently collide. + const seedKeyCounts = new Map(); + for (const e of eventList) { + const key = `seed:${slugify(e.title)}`; + seedKeyCounts.set(key, [...(seedKeyCounts.get(key) ?? []), e.title]); + } + for (const [key, titles] of seedKeyCounts) { + if (titles.length > 1) { + throw new Error( + `Duplicate seed event key "${key}" from titles: ${titles.join(", ")}. Seed event titles must be distinct.`, + ); + } + } + + const existingSeedEvents = new Map( + ( + await db + .select({ id: events.id, sourceMessageId: events.sourceMessageId }) + .from(events) + .where(eq(events.source, "seed")) + ).map((e) => [e.sourceMessageId, e.id]), ); const insertedEvents: { id: string; tags: Tag[] }[] = []; + let refreshedCount = 0; for (const e of eventList) { - if (existingTitles.has(e.title)) continue; const { tags: tagList, ...vals } = e; - const [inserted] = await db.insert(events).values(vals).returning({ id: events.id }); + const seedKey = `seed:${slugify(e.title)}`; + const existingId = existingSeedEvents.get(seedKey); + if (existingId) { + await db + .update(events) + .set({ datetime: vals.datetime, endDatetime: vals.endDatetime }) + .where(eq(events.id, existingId)); + refreshedCount++; + continue; + } + const [inserted] = await db + .insert(events) + .values({ ...vals, source: "seed", sourceMessageId: seedKey }) + .returning({ id: events.id }); if (inserted) { insertedEvents.push({ id: inserted.id, tags: tagList }); if (tagList.length > 0) { @@ -776,9 +819,7 @@ async function seed() { } } } - console.log( - ` Events: ${insertedEvents.length} inserted, ${existingTitles.size} already present`, - ); + console.log(` Events: ${insertedEvents.length} inserted, ${refreshedCount} dates refreshed`); /* ═══ 9. RSVPs ═══ */ const allUserIds = [...userMap.values()]; diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index ea1d726..241c098 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -82,6 +82,20 @@ export async function getFeedEvents(params?: { ...reverseFriendRows.map((f) => f.friendId), ]; + // Get orgs the user follows or belongs to, for the org-affinity signal + const followedOrgRows = await db + .select({ orgId: orgFollowers.orgId }) + .from(orgFollowers) + .where(eq(orgFollowers.userId, userId)); + const memberOrgRows = await db + .select({ orgId: orgMembers.orgId }) + .from(orgMembers) + .where(eq(orgMembers.userId, userId)); + const myOrgIds = new Set([ + ...followedOrgRows.map((o) => o.orgId), + ...memberOrgRows.map((o) => o.orgId), + ]); + // Build base query conditions — only show published events in the feed const conditions = [gt(events.datetime, new Date()), eq(events.status, "published")]; @@ -165,7 +179,14 @@ export async function getFeedEvents(params?: { .offset(offset); // Enrich each event with tags, rsvp counts, friend attendance, user state - const enriched: FeedEvent[] = await Promise.all( + // + // 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. + const enriched: (FeedEvent & { score: number; _rawDatetime: Date })[] = await Promise.all( rawEvents.map(async (event) => { // Get tags const tags = await db @@ -208,10 +229,16 @@ export async function getFeedEvents(params?: { // Score for sorting const tagNames = tags.map((t) => t.tag); - const interestOverlap = - myInterestTags.length > 0 - ? tagNames.filter((t) => myInterestTags.includes(t)).length / myInterestTags.length - : 0.5; + + // 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. + const matchedTags = tagNames.filter((t) => myInterestTags.includes(t)).length; + const interestRelevance = + myInterestTags.length === 0 + ? 0.5 + : tagNames.length === 0 + ? 0 + : matchedTags / tagNames.length; const now = Date.now(); const eventTime = event.datetime.getTime(); @@ -229,15 +256,17 @@ export async function getFeedEvents(params?: { const friendRsvpScore = Math.min(1.0, friendsAttending.length / 3.0); + const orgAffinity = event.orgId && myOrgIds.has(event.orgId) ? 1.0 : 0.0; + const hoursSinceCreated = (now - event.createdAt.getTime()) / (1000 * 60 * 60); const recencyBoost = hoursSinceCreated <= 24 ? 1.0 : hoursSinceCreated <= 72 ? 0.5 : 0.0; const score = - 3.0 * interestOverlap + + 3.0 * interestRelevance + 2.0 * timeProximity + 4.0 * friendRsvpScore + - 1.0 * recencyBoost + - 0.5 * Math.random(); + 1.0 * orgAffinity + + 1.0 * recencyBoost; return { id: event.id, @@ -253,18 +282,23 @@ export async function getFeedEvents(params?: { friendsAttending, isRsvped: !!userRsvp, isSaved: !!userSave, - _score: score, + score, + _rawDatetime: event.datetime, }; }), ); - // Sort by score descending - enriched.sort( - (a, b) => - (b as unknown as { _score: number })._score - (a as unknown as { _score: number })._score, - ); + // Sort by score descending; ties break by soonest event first, so + // refreshing Explore 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(); + }); - return { events: enriched, total }; + return { + events: enriched.map(({ score: _score, _rawDatetime, ...event }) => event), + total, + }; } export async function toggleRsvp(eventId: string): Promise<{ rsvped: boolean; count: number }> { diff --git a/apps/web/src/app/(app)/events/my-events-client.tsx b/apps/web/src/app/(app)/events/my-events-client.tsx index 99b30af..14560df 100644 --- a/apps/web/src/app/(app)/events/my-events-client.tsx +++ b/apps/web/src/app/(app)/events/my-events-client.tsx @@ -10,6 +10,7 @@ import { cn } from "~/lib/utils"; const TABS = [ { id: "created", label: "Events Created" }, + { id: "rsvped", label: "Events RSVP'd" }, { id: "saved", label: "Events Saved" }, ] as const; @@ -83,7 +84,7 @@ function EventListCard({ event }: { event: FeedEvent }) { export function MyEventsClient({ created, rsvped, saved }: MyEventsClientProps) { const [activeTab, setActiveTab] = useState("created"); - const eventMap: Record = { created, saved }; + const eventMap: Record = { created, rsvped, saved }; const events = eventMap[activeTab]; return ( @@ -131,12 +132,18 @@ export function MyEventsClient({ created, rsvped, saved }: MyEventsClientProps) ) : (

- {activeTab === "created" ? "No events created yet" : "No saved events"} + {activeTab === "created" + ? "No events created yet" + : activeTab === "rsvped" + ? "No RSVP'd events" + : "No saved events"}

{activeTab === "created" ? "Share something with campus — create your first event." - : "Bookmark events you're interested in."} + : activeTab === "rsvped" + ? "Events you've RSVP'd to will show up here." + : "Bookmark events you're interested in."}

)} diff --git a/bun.lock b/bun.lock index 1250d06..783828f 100644 --- a/bun.lock +++ b/bun.lock @@ -39,14 +39,9 @@ "devDependencies": { "@types/node": "^22.13.4", "drizzle-kit": "^0.31.10", - "tsx": "^4.21.0", "typescript": "^5.7.3", }, }, - "apps/listserv-mcp-experimental": { - "name": "@the-forum/listserv-mcp-experimental", - "version": "0.0.1", - }, "apps/listserv-scraper": { "name": "@the-forum/listserv-scraper", "version": "0.0.1", @@ -793,8 +788,6 @@ "@the-forum/fastapi": ["@the-forum/fastapi@workspace:backends/fastapi"], - "@the-forum/listserv-mcp-experimental": ["@the-forum/listserv-mcp-experimental@workspace:apps/listserv-mcp-experimental"], - "@the-forum/listserv-scraper": ["@the-forum/listserv-scraper@workspace:apps/listserv-scraper"], "@the-forum/mpu-scraper": ["@the-forum/mpu-scraper@workspace:apps/mpu-scraper"], diff --git a/package.json b/package.json index 4096968..42f46a4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:migrate": "turbo db:migrate", "db:push": "turbo db:push", "db:studio": "turbo db:studio", + "db:seed": "turbo db:seed", "prepare": "husky" }, "devDependencies": { diff --git a/turbo.json b/turbo.json index d9de495..cbf008a 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,9 @@ "db:studio": { "cache": false, "persistent": true + }, + "db:seed": { + "cache": false } } }