@@ -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
1618type 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+
7097async 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
81109export 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