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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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`.
3 changes: 1 addition & 2 deletions apps/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -21,7 +21,6 @@
"devDependencies": {
"@types/node": "^22.13.4",
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^5.7.3"
}
}
59 changes: 50 additions & 9 deletions apps/database/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Run: bun run db:seed (from apps/database)
*/

import { eq } from "drizzle-orm";
import { db } from "./db";
import {
events,
Expand Down Expand Up @@ -33,6 +34,12 @@ function pickN<T>(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();

Expand Down Expand Up @@ -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:<slug>`), 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<string, string[]>();
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) {
Expand All @@ -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()];
Expand Down
64 changes: 49 additions & 15 deletions apps/web/src/actions/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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 }> {
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/app/(app)/events/my-events-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -83,7 +84,7 @@ function EventListCard({ event }: { event: FeedEvent }) {
export function MyEventsClient({ created, rsvped, saved }: MyEventsClientProps) {
const [activeTab, setActiveTab] = useState<TabId>("created");

const eventMap: Record<TabId, FeedEvent[]> = { created, saved };
const eventMap: Record<TabId, FeedEvent[]> = { created, rsvped, saved };
const events = eventMap[activeTab];

return (
Expand Down Expand Up @@ -131,12 +132,18 @@ export function MyEventsClient({ created, rsvped, saved }: MyEventsClientProps)
) : (
<div className="flex flex-col items-center justify-center py-[60px]">
<p className="font-serif text-[24px] text-forum-dark-gray mb-[8px]">
{activeTab === "created" ? "No events created yet" : "No saved events"}
{activeTab === "created"
? "No events created yet"
: activeTab === "rsvped"
? "No RSVP'd events"
: "No saved events"}
</p>
<p className="text-[14px] font-dm-sans text-forum-light-gray mb-[24px]">
{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."}
</p>
</div>
)}
Expand Down
7 changes: 0 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"db:studio": {
"cache": false,
"persistent": true
},
"db:seed": {
"cache": false
}
}
}