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
2 changes: 2 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type EngineEvent = {
publishable: boolean;
extractionVersion: string;
imageUrl: string | null;
/** How many campus listserv emails announced this event, on which lists, and when first. */
announcements: { emails: number; listservs: string[]; firstAnnouncedAt: string | null };
/** Recurring official events share a series (same host + title); `size` counts occurrences in the feed. */
series: { id: string; size: number } | null;
duplicateOf: string | null;
Expand Down
17 changes: 16 additions & 1 deletion src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ function eventPayload(r: Row) {
publishable: !!r.publishable,
extractionVersion: String(r.extractionVersion),
imageUrl: (r.imageUrl as string) ?? null,
/**
* Listserv promotion: how many campus emails announced this event (its own email, if any, plus
* reminder/cross-list emails folded into it) and when it was first announced. Official
* MyPrincetonU events that nobody emailed have 0 and null.
*/
announcements: {
emails: (r.source === 'listserv' ? 1 : 0) + Number(r.repeatCount ?? 0),
listservs: [...new Set([...((r.lists as string[]) ?? []), ...((r.repeatLists as string[]) ?? [])])].sort(),
firstAnnouncedAt: r.firstAnnouncedAt ? new Date(r.firstAnnouncedAt as string).toISOString() : null
},
series: r.seriesId ? { id: String(r.seriesId), size: Number(r.seriesSize) } : null,
duplicateOf: (r.duplicateOf as string) ?? null,
source:
Expand Down Expand Up @@ -206,7 +216,12 @@ function eventPayload(r: Row) {

const EVENT_COLUMNS = (sql: Sql) => sql`
e.*, m.subject, m.sender_name, m.sent_at, m.source_url,
(SELECT array_agg(listserv ORDER BY listserv) FROM message_lists l WHERE l.message_id = e.message_id) AS lists`;
(SELECT array_agg(listserv ORDER BY listserv) FROM message_lists l WHERE l.message_id = e.message_id) AS lists,
(SELECT count(*)::int FROM events d WHERE d.duplicate_of = e.id) AS repeat_count,
(SELECT array_agg(DISTINCT l2.listserv) FROM events d JOIN message_lists l2 ON l2.message_id = d.message_id
WHERE d.duplicate_of = e.id) AS repeat_lists,
LEAST(m.sent_at, (SELECT min(m2.sent_at) FROM events d JOIN messages m2 ON m2.id = d.message_id
WHERE d.duplicate_of = e.id)) AS first_announced_at`;

export async function listEvents(sql: Sql, f: EventFilters) {
const limit = Math.min(Math.max(f.limit ?? 50, 1), 200);
Expand Down
8 changes: 6 additions & 2 deletions src/sources/mpu-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export async function markDuplicates(sql: Sql): Promise<number> {
l.host_org_id = o.host_org_id
OR similarity_words(l.title, o.title) >= 0.5
)
RETURNING l.id`;
RETURNING l.id, l.duplicate_of`;
// Reminder emails ("final call", "tomorrow!") re-announce the same event: keep the earliest
// extraction and mark later ones as its duplicates.
const reminders = await sql`
Expand All @@ -225,6 +225,10 @@ export async function markDuplicates(sql: Sql): Promise<number> {
AND abs(extract(epoch FROM p.starts_at - o.starts_at)) <= 1800
AND similarity_words(p.title, o.title) >= 0.4
)
RETURNING l.id`;
RETURNING l.id, l.duplicate_of`;
// The surviving event gained an announcement; bump it so consumers re-read its promotion data.
const targets = [...new Set([...official, ...reminders].map((r) => String(r.duplicateOf)))];
if (targets.length)
await sql`UPDATE events SET updated_at = now(), revision = nextval('revision_seq') WHERE id IN ${sql(targets)}`;
return official.length + reminders.length;
}
4 changes: 4 additions & 0 deletions test/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,9 @@ test('reminder emails for the same event collapse to the first announcement', {
const dupes = await sql`SELECT duplicate_of FROM events WHERE status = 'duplicate' AND title ILIKE '%MealMates%'`;
assert.equal(active.length, 1);
assert.equal(dupes.length, 1);
const { listEvents } = await import('../src/queries.ts');
const [kept] = (await listEvents(sql, { from: '2026-09-28T00:00:00Z', query: 'MealMates' })).results;
assert.equal(kept.announcements.emails, 2, 'the reminder counts as a second announcement');
assert.equal(kept.announcements.firstAnnouncedAt, '2026-09-28T13:00:00.000Z');
await closeDb();
});
Loading