diff --git a/functions/src/bills/updateBillReferences.test.ts b/functions/src/bills/updateBillReferences.test.ts new file mode 100644 index 000000000..04e7bb762 --- /dev/null +++ b/functions/src/bills/updateBillReferences.test.ts @@ -0,0 +1,169 @@ +import { Timestamp } from "../firebase" +import { Hearing } from "../events/types" +import { computeEventUpdates, EventMatchBill } from "./updateBillReferences" + +/** Helper to create a minimal Hearing object for testing */ +function createHearing( + id: string, + startsAt: Timestamp, + documents: Array<{ billNumber: string; courtNumber: number }> +): Hearing { + return { + id, + type: "hearing", + startsAt, + fetchedAt: Timestamp.fromMillis(Date.now()), + content: { + EventId: 1, + EventDate: "2026-02-01T10:00:00", + StartTime: "2026-02-01T10:00:00", + Description: "Test hearing", + Name: "Test Hearing", + Status: "Scheduled", + HearingHost: { + CommitteeCode: "ABC", + GeneralCourtNumber: 194 + }, + Location: { + AddressLine1: null, + AddressLine2: null, + City: null, + LocationName: "Room 1", + State: null, + ZipCode: null + }, + HearingAgendas: [ + { + DocumentsInAgenda: documents.map(doc => ({ + BillNumber: doc.billNumber, + GeneralCourtNumber: doc.courtNumber, + PrimarySponsor: null, + Title: "Test Bill" + })), + StartTime: "2026-02-01T10:00:00", + EndTime: "2026-02-01T11:00:00", + Topic: "Test Topic" + } + ], + RescheduledHearing: null + } + } +} + +describe("computeEventUpdates", () => { + const futureTime = Timestamp.fromMillis(Date.now() + 86400000) // 1 day in future + const now = Timestamp.fromMillis(Date.now()) + + describe("court matching", () => { + it("links a bill with an associated hearing when both are in the same court", () => { + const bills: EventMatchBill[] = [{ id: "H100", court: 194 }] + + const hearings: Hearing[] = [ + createHearing("hearing-1", futureTime, [ + { billNumber: "H100", courtNumber: 194 } + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + expect(updates.get("H100")).toBeDefined() + expect(updates.get("H100")?.hearingIds).toContain("hearing-1") + expect(updates.get("H100")?.nextHearingId).toBe("hearing-1") + }) + + it("does not link a bill with an associated hearing when they are in different courts", () => { + const bills: EventMatchBill[] = [{ id: "H100", court: 194 }] + + const hearings: Hearing[] = [ + createHearing("hearing-1", futureTime, [ + { billNumber: "H100", courtNumber: 193 } // Different court + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + // Bill should not have any hearing updates since courts don't match + expect(updates.get("H100")).toBeUndefined() + }) + + it("does not link a bill with a hearing if the bill id is not found in the hearing's agenda", () => { + const bills: EventMatchBill[] = [{ id: "H100", court: 194 }] + + const hearings: Hearing[] = [ + createHearing("hearing-1", futureTime, [ + { billNumber: "H200", courtNumber: 194 } // Different bill + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + // H100 should not have any hearing updates + expect(updates.get("H100")).toBeUndefined() + // H200 is in the hearing but not in our bills list, so no court match possible + expect(updates.get("H200")).toBeUndefined() + }) + }) + + describe("multiple hearings and bills", () => { + it("correctly matches multiple bills with different courts to their respective hearings", () => { + const bills: EventMatchBill[] = [ + { id: "H100", court: 194 }, + { id: "H101", court: 193 } + ] + + const hearings: Hearing[] = [ + createHearing("hearing-2", futureTime, [ + { billNumber: "H100", courtNumber: 194 } + ]), + createHearing("hearing-1", futureTime, [ + { billNumber: "H101", courtNumber: 193 } + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + expect(updates.get("H100")?.hearingIds).toContain("hearing-2") + expect(updates.get("H100")?.hearingIds).not.toContain("hearing-1") + + expect(updates.get("H101")?.hearingIds).toContain("hearing-1") + expect(updates.get("H101")?.hearingIds).not.toContain("hearing-2") + }) + + it("only matches bills to hearings with matching court, even when bill appears in multiple hearings", () => { + const bills: EventMatchBill[] = [{ id: "H100", court: 194 }] + + const hearings: Hearing[] = [ + createHearing("hearing-correct-court", futureTime, [ + { billNumber: "H100", courtNumber: 194 } + ]), + createHearing("hearing-wrong-court", futureTime, [ + { billNumber: "H100", courtNumber: 193 } + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + expect(updates.get("H100")?.hearingIds).toContain("hearing-correct-court") + expect(updates.get("H100")?.hearingIds).not.toContain( + "hearing-wrong-court" + ) + expect(updates.get("H100")?.hearingIds).toHaveLength(1) + }) + }) + + describe("bills without court field", () => { + it("does not link a bill without a court field to any hearing", () => { + const bills: EventMatchBill[] = [{ id: "H100" }] // No court field + + const hearings: Hearing[] = [ + createHearing("hearing-1", futureTime, [ + { billNumber: "H100", courtNumber: 194 } + ]) + ] + + const updates = computeEventUpdates(bills, hearings, now) + + expect(updates.get("H100")).toBeUndefined() + }) + }) +}) diff --git a/functions/src/bills/updateBillReferences.ts b/functions/src/bills/updateBillReferences.ts index 567961a28..4d0594fed 100644 --- a/functions/src/bills/updateBillReferences.ts +++ b/functions/src/bills/updateBillReferences.ts @@ -4,6 +4,103 @@ import { db, FieldValue, Timestamp } from "../firebase" import { Member, MemberReference } from "../members/types" import BillProcessor, { BillUpdates } from "./BillProcessor" +/** Input bill for event matching */ +export type EventMatchBill = { + id: string + court?: number + nextHearingId?: string +} + +/** Computes event updates for bills based on hearing data */ +export function computeEventUpdates( + bills: EventMatchBill[], + hearings: Hearing[], + now: Timestamp +): BillUpdates { + const updates: BillUpdates = new Map() + + // Build a map of billId -> court for matching + const billCourtMap = new Map() + bills.forEach(bill => { + if (bill.id && bill.court !== undefined) { + billCourtMap.set(bill.id, bill.court) + } + }) + + // Build mapping from billId -> hearingIds and compute earliest upcoming hearing + const hearingIdsByBill = new Map>() + + hearings.forEach(hearing => { + const hearingId = hearing.id + const startsAt = hearing.startsAt + + hearing.content.HearingAgendas.forEach(agenda => { + agenda.DocumentsInAgenda.forEach(doc => { + const billId = doc.BillNumber + const docCourtNumber = doc.GeneralCourtNumber + + // Only match hearings with bills from the same general court + const billCourt = billCourtMap.get(billId) + if (billCourt === undefined || billCourt !== docCourtNumber) { + return + } + + if (!hearingIdsByBill.has(billId)) + hearingIdsByBill.set(billId, new Set()) + hearingIdsByBill.get(billId)!.add(hearingId) + + // Track next upcoming hearing per bill (startsAt in the future) + if (startsAt.toMillis() >= now.toMillis()) { + const existing = updates.get(billId) + if ( + !existing || + (existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! > + startsAt.toMillis() + ) { + updates.set(billId, { + nextHearingAt: startsAt, + nextHearingId: hearingId + }) + } + } + }) + }) + }) + + hearingIdsByBill.forEach((ids, billId) => { + const existing = updates.get(billId) ?? {} + updates.set(billId, { + ...existing, + hearingIds: Array.from(ids) + }) + }) + + // Remove the next hearing on any bills that previously had an upcoming hearing + // but are no longer on any upcoming hearing agendas. + const upcomingHearingBillIds = new Set() + updates.forEach((u, id) => { + if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.()) + upcomingHearingBillIds.add(id) + }) + const existingBillsWithEvents = bills + .filter(b => !!b.nextHearingId) + .map(b => b.id as string) + const billsWithRemovedEvents = difference( + existingBillsWithEvents, + Array.from(upcomingHearingBillIds) + ) + billsWithRemovedEvents.forEach(id => { + const existing = updates.get(id) ?? {} + updates.set(id, { + ...existing, + nextHearingAt: FieldValue.delete(), + nextHearingId: FieldValue.delete() + }) + }) + + return updates +} + /** * Updates references to other entities for each bill. * @@ -32,7 +129,7 @@ class UpdateBillReferences extends BillProcessor { } override get billFields() { - return ["id", "nextHearingId"] + return ["id", "court", "nextHearingId"] } getCityUpdates(): BillUpdates { @@ -94,75 +191,8 @@ class UpdateBillReferences extends BillProcessor { .where("type", "==", "hearing") .get() .then(this.load(Hearing)) - const updates: BillUpdates = new Map() - - // Build mapping from billId -> hearingIds and compute earliest upcoming hearing - const hearingIdsByBill = new Map>() - const now = Timestamp.fromMillis(Date.now()) - - hearings.forEach(hearing => { - const hearingId = hearing.id - const startsAt = hearing.startsAt - - hearing.content.HearingAgendas.forEach(agenda => { - agenda.DocumentsInAgenda.forEach(doc => { - const billId = doc.BillNumber - - if (!hearingIdsByBill.has(billId)) - hearingIdsByBill.set(billId, new Set()) - hearingIdsByBill.get(billId)!.add(hearingId) - - // Track next upcoming hearing per bill (startsAt in the future) - if (startsAt.toMillis() >= now.toMillis()) { - const existing = updates.get(billId) - if ( - !existing || - (existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! > - startsAt.toMillis() - ) { - updates.set(billId, { - nextHearingAt: startsAt, - nextHearingId: hearingId - }) - } - } - }) - }) - }) - - hearingIdsByBill.forEach((ids, billId) => { - const existing = updates.get(billId) ?? {} - updates.set(billId, { - ...existing, - hearingIds: Array.from(ids) - }) - }) - - // Remove the next hearing on any bills that previously had an upcoming hearing - // but are no longer on any upcoming hearing agendas. - const upcomingHearingBillIds = new Set() - updates.forEach((u, id) => { - if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.()) - upcomingHearingBillIds.add(id) - }) - const existingBillsWithEvents = this.bills - .filter(b => !!b.nextHearingId) - .map(b => b.id as string) - const billsWithRemovedEvents = difference( - existingBillsWithEvents, - Array.from(upcomingHearingBillIds) - ) - billsWithRemovedEvents.forEach(id => { - const existing = updates.get(id) ?? {} - updates.set(id, { - ...existing, - nextHearingAt: FieldValue.delete(), - nextHearingId: FieldValue.delete() - }) - }) - - return updates + return computeEventUpdates(this.bills, hearings, now) } formatChair(m: MemberReference | null) {