Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ jobs:
run: bun run lint
- name: Format check
run: bun run format:check
# NOTE: When adding a production build step for the extension, use
# `bun run build` (apps/extension). Never use `build:test` in CI/release —
# that variant injects http://localhost/* into the manifest via PLAYWRIGHT=true.
13 changes: 13 additions & 0 deletions apps/dashboard/convex/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ async function findBookmark(
.unique();
}

function isPublished(event: Doc<"events">): boolean {
return event.visibility !== "draft" && event.visibility !== "hidden";
}

export const bookmark = mutation({
args: { eventId: v.id("events") },
handler: async (ctx, args) => {
Expand All @@ -64,6 +68,14 @@ export const bookmark = mutation({
});
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing it but is this handler fully completed? It looks like the body is missing.


const event = await ctx.db.get(args.eventId);
if (event === null || !isPublished(event)) {
throw new ConvexError({
code: "EVENT_NOT_FOUND",
message: "This event is not available to bookmark.",
});
}

const existing = await findBookmark(ctx, userId, args.eventId);
if (existing !== null) {
// Idempotent: already bookmarked.
Expand Down Expand Up @@ -132,6 +144,7 @@ export const myBookmarks = query({
for (const row of result.page) {
const event = await ctx.db.get(row.eventId);
if (event === null) continue;
if (!isPublished(event)) continue;
const orgs = await loadOrgsForEvent(ctx, event._id);
hydrated.push({ bookmark: row, event, orgs });
}
Expand Down
134 changes: 101 additions & 33 deletions apps/dashboard/convex/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type HydratedEvent = {
orgs: Doc<"orgs">[];
isBookmarked: boolean;
source: FeedSource;
/** Epoch-ms of the parent email. Used by the extension for 14-day windowing. */
sentAt?: number;
};

type FeedPage = {
Expand All @@ -26,8 +28,7 @@ const FEED_MIN_ITEMS = 20;
// Bounded pool size when pulling recommended events to dedupe + backfill from.
const RECOMMENDED_POOL_SIZE = 50;
// Bounded scan sizes for the interest-personalised recommendation pass. Kept
// small so the feed query stays cheap; the full ranking model lives on a
// separate branch and will replace this stub.
// small so the feed query stays cheap while still providing relevant backfill.
const RECOMMENDED_ORG_SCAN = 100;
const RECOMMENDED_EVENTS_PER_ORG = 10;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on keeping this small so we can keep the query cheap!


Expand Down Expand Up @@ -67,6 +68,31 @@ async function isEventBookmarked(
return row !== null;
}

/**
* Resolves the epoch-ms timestamp of the parent listserv email for an event.
* Primary: sourceMessageId → listservMessages.receivedAt (ingestion pipeline).
* Fallback: listservEmailId → listservEmails.sentAt (legacy path).
*/
async function loadSentAt(
ctx: QueryCtx,
event: Doc<"events">,
): Promise<number | undefined> {
if (event.sourceMessageId !== undefined) {
const msg = await ctx.db.get(event.sourceMessageId);
if (msg !== null) return msg.receivedAt;
}
if (event.listservEmailId !== undefined) {
const email = await ctx.db.get(event.listservEmailId);
if (email !== null) return email.sentAt;
}
return undefined;
}

/** Returns true for events that should be visible to students. */
function isPublished(event: Doc<"events">): boolean {
return event.visibility !== "draft" && event.visibility !== "hidden";
}

async function hydrateEvent(
ctx: QueryCtx,
event: Doc<"events">,
Expand All @@ -75,7 +101,8 @@ async function hydrateEvent(
): Promise<HydratedEvent> {
const orgs = await loadOrgsForEvent(ctx, event._id);
const isBookmarked = await isEventBookmarked(ctx, userId, event._id);
return { event, orgs, isBookmarked, source };
const sentAt = await loadSentAt(ctx, event);
return { event, orgs, isBookmarked, source, sentAt };
}

export const feed = query({
Expand All @@ -96,15 +123,20 @@ export const feed = query({
// that never feels empty on the home page.
const targetItems = Math.max(numItems, FEED_MIN_ITEMS);

// Helper: recency-ordered pool, used as a fallback when we have no signal
// to personalise recommendations (signed-out, no profile, no matching
// interests). Bounded by RECOMMENDED_POOL_SIZE.
// Helper: recency-ordered pool of published events, used as a fallback
// when we have no signal to personalise recommendations.
const fetchRecencyPool = async (
excluded: ReadonlySet<Id<"events">>,
limit: number,
): Promise<Doc<"events">[]> => {
const pool = await ctx.db
.query("events")
.filter((q) =>
q.and(
q.neq(q.field("visibility"), "draft"),
q.neq(q.field("visibility"), "hidden"),
),
)
.order("desc")
.take(RECOMMENDED_POOL_SIZE);
const out: Doc<"events">[] = [];
Expand All @@ -116,10 +148,6 @@ export const feed = query({
return out;
};

// Pull the user's profile so we can rank recommendations by interest
// overlap with org and event tags. Major / gradYear are not used as ranking
// signals yet — the production algo lives on a separate branch and will
// factor in major + cohort + collaborative filtering.
const loadInterests = async (
uid: Id<"users"> | null,
): Promise<ReadonlySet<string>> => {
Expand All @@ -132,9 +160,6 @@ export const feed = query({
return new Set<string>(profile.interests);
};

// Helper: build a pool of recommended events ranked by interest tag overlap
// with the user's profile. Falls back to recency when the profile is empty
// or no orgs match.
const fetchRecommendedPool = async (
excluded: ReadonlySet<Id<"events">>,
limit: number,
Expand All @@ -144,7 +169,6 @@ export const feed = query({
return await fetchRecencyPool(excluded, limit);
}

// Bounded scan of orgs; score each by tag overlap with interests.
const orgs = await ctx.db.query("orgs").take(RECOMMENDED_ORG_SCAN);
const scoredOrgs: { org: Doc<"orgs">; score: number }[] = [];
for (const org of orgs) {
Expand Down Expand Up @@ -175,6 +199,7 @@ export const feed = query({
if (excluded.has(join.eventId)) continue;
const event = await ctx.db.get(join.eventId);
if (event === null) continue;
if (!isPublished(event)) continue;
let eventTagScore = 0;
for (const tag of event.tags) {
if (interests.has(tag)) eventTagScore += 1;
Expand All @@ -193,7 +218,6 @@ export const feed = query({
});
const personalised = ranked.slice(0, limit).map((r) => r.event);

// Backfill with recency if we still came up short.
if (personalised.length >= limit) return personalised;
const seen = new Set<Id<"events">>(excluded);
for (const e of personalised) seen.add(e._id);
Expand All @@ -202,13 +226,15 @@ export const feed = query({
return [...personalised, ...recent];
};

// "all" scope or signed-out: paginate over all events with creation-time
// ordering, then opportunistically backfill the first page with
// recommended events if the page comes up short. We tag every event with
// source = "recommended" because the user has no follow context.
if (scope === "all" || userId === null) {
const result = await ctx.db
.query("events")
.filter((q) =>
q.and(
q.neq(q.field("visibility"), "draft"),
q.neq(q.field("visibility"), "hidden"),
),
)
.order("desc")
.paginate(args.paginationOpts);

Expand All @@ -223,8 +249,7 @@ export const feed = query({
};
}

// scope === "followed": gather events from followed orgs first, then
// backfill with recommended events so the home feed is never empty.
// scope === "followed"
const follows = await ctx.db
.query("follows")
.withIndex("by_user", (q) => q.eq("userId", userId))
Expand All @@ -244,19 +269,15 @@ export const feed = query({
if (seenEventIds.has(join.eventId)) continue;
seenEventIds.add(join.eventId);
const event = await ctx.db.get(join.eventId);
if (event !== null) {
subscribedEvents.push(event);
}
if (event === null) continue;
if (!isPublished(event)) continue;
subscribedEvents.push(event);
}
}

// Recency-rank subscribed events and trim to the page target.
subscribedEvents.sort((a, b) => b._creationTime - a._creationTime);
const subscribedSlice = subscribedEvents.slice(0, targetItems);

// Backfill: if the subscribed slice is below the floor, pull recommended
// events from the global recency pool, dedupe against subscribed ids, and
// pad up to targetItems. Subscribed events render first.
const remaining = targetItems - subscribedSlice.length;
const recommendedSlice =
remaining > 0 ? await fetchRecommendedPool(seenEventIds, remaining) : [];
Expand All @@ -269,9 +290,6 @@ export const feed = query({
hydrated.push(await hydrateEvent(ctx, event, userId, "recommended"));
}

// Followed-scope is a single-page snapshot: ranking + cursor pagination
// for the subscribed/recommended union lands with the recommendation algo
// branch. Mark the page as done so the client doesn't try to advance.
return {
page: hydrated,
isDone: true,
Expand All @@ -284,7 +302,7 @@ export const getById = query({
args: { eventId: v.id("events") },
handler: async (ctx, args): Promise<HydratedEvent | null> => {
const event = await ctx.db.get(args.eventId);
if (event === null) {
if (event === null || !isPublished(event)) {
return null;
}
const userId = await getAuthUserId(ctx);
Expand Down Expand Up @@ -315,6 +333,7 @@ export const searchEvents = query({
const merged: Doc<"events">[] = [];
for (const event of [...byTitle, ...byDesc]) {
if (seen.has(event._id)) continue;
if (!isPublished(event)) continue;
seen.add(event._id);
merged.push(event);
if (merged.length >= 25) break;
Expand Down Expand Up @@ -357,7 +376,7 @@ export const byOrg = query({
for (const join of joinPage.page) {
const event = await ctx.db.get(join.eventId);
if (event === null) continue;
// Org page: every event is "subscribed" from the org's perspective.
if (!isPublished(event)) continue;
hydrated.push(await hydrateEvent(ctx, event, userId, "subscribed"));
}

Expand All @@ -368,3 +387,52 @@ export const byOrg = query({
};
},
});

function splitIntoParagraphs(text: string): string[] {
return text
.split(/\n{2,}/)
.map((p) => p.trim())
.filter((p) => p.length > 0);
}

/**
* Returns the raw email content for OriginalEmailView.
* Prefers the ingestion pipeline path (listservMessages); falls back to the
* legacy listservEmails table. Returns null when no email body is available.
*/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would returning an error when no email body is available be better here?

export const getEmailContent = query({
args: { eventId: v.id("events") },
handler: async (
ctx,
args,
): Promise<{ subject: string; paragraphs: string[] } | null> => {
const userId = await getAuthUserId(ctx);
if (userId === null) return null;

const event = await ctx.db.get(args.eventId);
if (event === null || !isPublished(event)) return null;

if (event.sourceMessageId !== undefined) {
const msg = await ctx.db.get(event.sourceMessageId);
if (msg !== null) {
return {
subject: msg.subject,
paragraphs: splitIntoParagraphs(msg.bodyText),
};
}
}

if (event.listservEmailId !== undefined) {
const email = await ctx.db.get(event.listservEmailId);
if (email !== null) {
const raw = email.rawText ?? email.rawHtml ?? "";
return {
subject: event.title,
paragraphs: splitIntoParagraphs(raw),
};
}
}

return null;
},
});
5 changes: 5 additions & 0 deletions apps/dashboard/convex/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export const seedAll = internalMutation({
isSeed: true,
});
summary.listservEmails += 1;
} else {
await ctx.db.patch(listservId, {
sentAt: now,
rawText: "Seed listserv parent row.",
});
}

// 2. Orgs (skip if a row with this slug already exists).
Expand Down
23 changes: 23 additions & 0 deletions apps/extension/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";

export default defineConfig([
globalIgnores(["dist", "node_modules"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: { ...globals.browser, ...globals.node },
},
},
]);
10 changes: 7 additions & 3 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"build:test": "PLAYWRIGHT=true vite build",
"lint": "eslint .",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@app/ui": "workspace:*",
Expand All @@ -19,10 +22,13 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.2.2",
"@types/chrome": "^0.0.320",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/webextension-polyfill": "^0.10.0",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
Expand All @@ -31,8 +37,6 @@
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"@types/chrome": "^0.0.320",
"@types/webextension-polyfill": "^0.10.0",
"vite": "^7.3.1",
"vite-plugin-svgr": "^5.2.0",
"vite-plugin-web-extension": "^4.1.1"
Expand Down
Loading
Loading