Skip to content

Commit 3fc16dd

Browse files
committed
shared events.ts edits
1 parent 7b023ff commit 3fc16dd

1 file changed

Lines changed: 96 additions & 32 deletions

File tree

apps/dashboard/convex/events.ts

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ type HydratedEvent = {
1111
orgs: Doc<"orgs">[];
1212
isBookmarked: boolean;
1313
source: FeedSource;
14+
/** Epoch-ms of the parent email. Used by the extension for 14-day windowing. */
15+
sentAt?: number;
1416
};
1517

1618
type FeedPage = {
@@ -67,6 +69,31 @@ async function isEventBookmarked(
6769
return row !== null;
6870
}
6971

72+
/**
73+
* Resolves the epoch-ms timestamp of the parent listserv email for an event.
74+
* Primary: sourceMessageId → listservMessages.receivedAt (ingestion pipeline).
75+
* Fallback: listservEmailId → listservEmails.sentAt (legacy path).
76+
*/
77+
async function loadSentAt(
78+
ctx: QueryCtx,
79+
event: Doc<"events">,
80+
): Promise<number | undefined> {
81+
if (event.sourceMessageId !== undefined) {
82+
const msg = await ctx.db.get(event.sourceMessageId);
83+
if (msg !== null) return msg.receivedAt;
84+
}
85+
if (event.listservEmailId !== undefined) {
86+
const email = await ctx.db.get(event.listservEmailId);
87+
if (email !== null) return email.sentAt;
88+
}
89+
return undefined;
90+
}
91+
92+
/** Returns true for events that should be visible to students. */
93+
function isPublished(event: Doc<"events">): boolean {
94+
return event.visibility !== "draft" && event.visibility !== "hidden";
95+
}
96+
7097
async function hydrateEvent(
7198
ctx: QueryCtx,
7299
event: Doc<"events">,
@@ -75,7 +102,8 @@ async function hydrateEvent(
75102
): Promise<HydratedEvent> {
76103
const orgs = await loadOrgsForEvent(ctx, event._id);
77104
const isBookmarked = await isEventBookmarked(ctx, userId, event._id);
78-
return { event, orgs, isBookmarked, source };
105+
const sentAt = await loadSentAt(ctx, event);
106+
return { event, orgs, isBookmarked, source, sentAt };
79107
}
80108

81109
export const feed = query({
@@ -96,15 +124,20 @@ export const feed = query({
96124
// that never feels empty on the home page.
97125
const targetItems = Math.max(numItems, FEED_MIN_ITEMS);
98126

99-
// Helper: recency-ordered pool, used as a fallback when we have no signal
100-
// to personalise recommendations (signed-out, no profile, no matching
101-
// interests). Bounded by RECOMMENDED_POOL_SIZE.
127+
// Helper: recency-ordered pool of published events, used as a fallback
128+
// when we have no signal to personalise recommendations.
102129
const fetchRecencyPool = async (
103130
excluded: ReadonlySet<Id<"events">>,
104131
limit: number,
105132
): Promise<Doc<"events">[]> => {
106133
const pool = await ctx.db
107134
.query("events")
135+
.filter((q) =>
136+
q.and(
137+
q.neq(q.field("visibility"), "draft"),
138+
q.neq(q.field("visibility"), "hidden"),
139+
),
140+
)
108141
.order("desc")
109142
.take(RECOMMENDED_POOL_SIZE);
110143
const out: Doc<"events">[] = [];
@@ -116,10 +149,6 @@ export const feed = query({
116149
return out;
117150
};
118151

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

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

147-
// Bounded scan of orgs; score each by tag overlap with interests.
148173
const orgs = await ctx.db.query("orgs").take(RECOMMENDED_ORG_SCAN);
149174
const scoredOrgs: { org: Doc<"orgs">; score: number }[] = [];
150175
for (const org of orgs) {
@@ -159,8 +184,6 @@ export const feed = query({
159184
}
160185
scoredOrgs.sort((a, b) => b.score - a.score);
161186

162-
// Pull recent events for each scoring org and combine scores
163-
// (org-tag overlap + event-tag overlap with interests).
164187
const candidateById = new Map<
165188
Id<"events">,
166189
{ event: Doc<"events">; score: number }
@@ -175,6 +198,7 @@ export const feed = query({
175198
if (excluded.has(join.eventId)) continue;
176199
const event = await ctx.db.get(join.eventId);
177200
if (event === null) continue;
201+
if (!isPublished(event)) continue;
178202
let eventTagScore = 0;
179203
for (const tag of event.tags) {
180204
if (interests.has(tag)) eventTagScore += 1;
@@ -193,7 +217,6 @@ export const feed = query({
193217
});
194218
const personalised = ranked.slice(0, limit).map((r) => r.event);
195219

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

205-
// "all" scope or signed-out: paginate over all events with creation-time
206-
// ordering, then opportunistically backfill the first page with
207-
// recommended events if the page comes up short. We tag every event with
208-
// source = "recommended" because the user has no follow context.
209228
if (scope === "all" || userId === null) {
210229
const result = await ctx.db
211230
.query("events")
231+
.filter((q) =>
232+
q.and(
233+
q.neq(q.field("visibility"), "draft"),
234+
q.neq(q.field("visibility"), "hidden"),
235+
),
236+
)
212237
.order("desc")
213238
.paginate(args.paginationOpts);
214239

@@ -223,8 +248,7 @@ export const feed = query({
223248
};
224249
}
225250

226-
// scope === "followed": gather events from followed orgs first, then
227-
// backfill with recommended events so the home feed is never empty.
251+
// scope === "followed"
228252
const follows = await ctx.db
229253
.query("follows")
230254
.withIndex("by_user", (q) => q.eq("userId", userId))
@@ -244,19 +268,15 @@ export const feed = query({
244268
if (seenEventIds.has(join.eventId)) continue;
245269
seenEventIds.add(join.eventId);
246270
const event = await ctx.db.get(join.eventId);
247-
if (event !== null) {
248-
subscribedEvents.push(event);
249-
}
271+
if (event === null) continue;
272+
if (!isPublished(event)) continue;
273+
subscribedEvents.push(event);
250274
}
251275
}
252276

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

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

272-
// Followed-scope is a single-page snapshot: ranking + cursor pagination
273-
// for the subscribed/recommended union lands with the recommendation algo
274-
// branch. Mark the page as done so the client doesn't try to advance.
275292
return {
276293
page: hydrated,
277294
isDone: true,
@@ -315,6 +332,7 @@ export const searchEvents = query({
315332
const merged: Doc<"events">[] = [];
316333
for (const event of [...byTitle, ...byDesc]) {
317334
if (seen.has(event._id)) continue;
335+
if (!isPublished(event)) continue;
318336
seen.add(event._id);
319337
merged.push(event);
320338
if (merged.length >= 25) break;
@@ -357,7 +375,7 @@ export const byOrg = query({
357375
for (const join of joinPage.page) {
358376
const event = await ctx.db.get(join.eventId);
359377
if (event === null) continue;
360-
// Org page: every event is "subscribed" from the org's perspective.
378+
if (!isPublished(event)) continue;
361379
hydrated.push(await hydrateEvent(ctx, event, userId, "subscribed"));
362380
}
363381

@@ -368,3 +386,49 @@ export const byOrg = query({
368386
};
369387
},
370388
});
389+
390+
function splitIntoParagraphs(text: string): string[] {
391+
return text
392+
.split(/\n{2,}/)
393+
.map((p) => p.trim())
394+
.filter((p) => p.length > 0);
395+
}
396+
397+
/**
398+
* Returns the raw email content for OriginalEmailView.
399+
* Prefers the ingestion pipeline path (listservMessages); falls back to the
400+
* legacy listservEmails table. Returns null when no email body is available.
401+
*/
402+
export const getEmailContent = query({
403+
args: { eventId: v.id("events") },
404+
handler: async (
405+
ctx,
406+
args,
407+
): Promise<{ subject: string; paragraphs: string[] } | null> => {
408+
const event = await ctx.db.get(args.eventId);
409+
if (event === null) return null;
410+
411+
if (event.sourceMessageId !== undefined) {
412+
const msg = await ctx.db.get(event.sourceMessageId);
413+
if (msg !== null) {
414+
return {
415+
subject: msg.subject,
416+
paragraphs: splitIntoParagraphs(msg.bodyText),
417+
};
418+
}
419+
}
420+
421+
if (event.listservEmailId !== undefined) {
422+
const email = await ctx.db.get(event.listservEmailId);
423+
if (email !== null) {
424+
const raw = email.rawText ?? email.rawHtml ?? "";
425+
return {
426+
subject: event.title,
427+
paragraphs: splitIntoParagraphs(raw),
428+
};
429+
}
430+
}
431+
432+
return null;
433+
},
434+
});

0 commit comments

Comments
 (0)