diff --git a/src/client/index.ts b/src/client/index.ts index c09360e..cb07c6d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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; diff --git a/src/queries.ts b/src/queries.ts index e6481bf..04c9194 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -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: @@ -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); diff --git a/src/sources/mpu-events.ts b/src/sources/mpu-events.ts index 60c7d7f..fd8c4b2 100644 --- a/src/sources/mpu-events.ts +++ b/src/sources/mpu-events.ts @@ -203,7 +203,7 @@ export async function markDuplicates(sql: Sql): Promise { 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` @@ -225,6 +225,10 @@ export async function markDuplicates(sql: Sql): Promise { 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; } diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index 9b65bad..eb1c3c2 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -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(); });