@@ -67,7 +71,9 @@
{{!-- Item 3 --}}
- {{#if this.bills.2.billId}}
+ {{#if this.bills.2.conferenceId}}
+ {{> conferenceItem this.bills.[2]}}
+ {{else if this.bills.2.billId}}
|
|
{{!-- Item 5 --}}
- {{#if this.bills.4.billId}}
+ {{#if this.bills.4.conferenceId}}
+ {{> conferenceItem this.bills.[4]}}
+ {{else if this.bills.4.billId}}
|
+ {{else if this.bills.5.conferenceId}}
+ {{> conferenceItem this.bills.[5]}}
{{else}}
diff --git a/functions/src/email/userPartial.test.ts b/functions/src/email/userPartial.test.ts
new file mode 100644
index 000000000..092313752
--- /dev/null
+++ b/functions/src/email/userPartial.test.ts
@@ -0,0 +1,66 @@
+import * as handlebars from "handlebars"
+import * as path from "path"
+import { registerHelpers, registerPartials } from "./handlebarsHelpers"
+
+const render = (bills: object[]) => {
+ registerHelpers()
+ registerPartials(path.join(__dirname, "partials"))
+ return handlebars.compile("{{> user}}")({
+ userId: "author1",
+ userName: "Alice",
+ newTestimonyCount: bills.length,
+ bills
+ })
+}
+
+describe("user digest partial", () => {
+ it("links conference testimony to its conference and words the stance", () => {
+ const html = render([
+ {
+ billId: "S2581",
+ court: "194",
+ conferenceId: "student-learning-and-mental-health",
+ stance: "Pass the Senate version"
+ }
+ ])
+
+ expect(html).toContain(
+ 'href="https://mapletestimony.org/conferences/194/student-learning-and-mental-health"'
+ )
+ expect(html).toContain("Pass the Senate version")
+ expect(html).not.toContain("/bills/194/S2581")
+ expect(html).not.toContain("images_no-svgs")
+ })
+
+ it("keeps a bill testimony's link and position image", () => {
+ const html = render([
+ { billId: "H1234", court: "194", position: "endorse" }
+ ])
+
+ expect(html).toContain('href="https://mapletestimony.org/bills/194/H1234"')
+ expect(html).toContain(
+ 'src="https://mapletestimony.org/email/images_no-svgs/endorse.jpg"'
+ )
+ })
+
+ it("handles conference testimony in the sixth slot", () => {
+ const bill = { billId: "H1", court: "194", position: "oppose" }
+ const html = render([
+ bill,
+ bill,
+ bill,
+ bill,
+ bill,
+ {
+ billId: "S2581",
+ court: "194",
+ conferenceId: "student-learning-and-mental-health",
+ stance: "Pass the Senate version"
+ }
+ ])
+
+ expect(html).toContain(
+ "/conferences/194/student-learning-and-mental-health"
+ )
+ })
+})
diff --git a/functions/src/notifications/deliverNotifications.test.ts b/functions/src/notifications/deliverNotifications.test.ts
index 79f9b5f7e..9213de33c 100644
--- a/functions/src/notifications/deliverNotifications.test.ts
+++ b/functions/src/notifications/deliverNotifications.test.ts
@@ -1,4 +1,4 @@
-import { buildDigestData } from "./deliverNotifications"
+import { buildDigestData, CONFERENCE_STANCES } from "./deliverNotifications"
import { Timestamp } from "../firebase"
// Mock firebase module
@@ -302,3 +302,60 @@ describe("buildDigestData — users", () => {
expect(result.numUsersWithNewTestimony).toBe(1)
})
})
+
+describe("buildDigestData — conference testimony", () => {
+ it("links the conference and words the stance for a followed author", async () => {
+ const base = {
+ type: "testimony",
+ isBillMatch: false,
+ isUserMatch: true,
+ isBallotQuestionMatch: false,
+ header: "A Bill",
+ court: "194",
+ timestamp: now,
+ authorUid: "author1",
+ subheader: "Alice",
+ bodyText: "",
+ userRole: "user",
+ delivered: false,
+ ballotQuestionId: null,
+ ballotQuestionCourt: null
+ }
+ setupCollection([
+ {
+ ...base,
+ billId: "S2581",
+ position: "passSenateVersion",
+ testimonyId: "t1",
+ conferenceId: "student-learning-and-mental-health"
+ },
+ {
+ ...base,
+ billId: "H1234",
+ position: "endorse",
+ testimonyId: "t2",
+ conferenceId: null
+ }
+ ])
+
+ const result = await buildDigestData("uid1", now as any, "Weekly")
+
+ expect(result.users[0].bills).toEqual([
+ {
+ billId: "S2581",
+ court: "194",
+ conferenceId: "student-learning-and-mental-health",
+ stance: "Pass the Senate version"
+ },
+ { billId: "H1234", court: "194", position: "endorse" }
+ ])
+ })
+})
+
+describe("CONFERENCE_STANCES", () => {
+ it("says what the site says", () => {
+ const { input } = require("../../../public/locales/en/conferences.json")
+ for (const [position, stance] of Object.entries(CONFERENCE_STANCES))
+ expect(stance).toBe(input.position[position].long)
+ })
+})
diff --git a/functions/src/notifications/deliverNotifications.ts b/functions/src/notifications/deliverNotifications.ts
index c45d645f7..f7c159c0b 100644
--- a/functions/src/notifications/deliverNotifications.ts
+++ b/functions/src/notifications/deliverNotifications.ts
@@ -12,12 +12,14 @@ import { TestimonySubmissionNotificationFields, Profile } from "./types"
import {
BallotQuestionDigest,
BillDigest,
+ BillResult,
NotificationEmailDigest,
Position,
UserDigest
} from "./emailTypes"
import { prepareHandlebars } from "../email/handlebarsHelpers"
import { Frequency } from "../auth/types"
+import { ConferencePosition } from "../testimony/types"
const PROFILE_BATCH_SIZE = 50
const NUM_BILLS_TO_DISPLAY = 4
@@ -26,6 +28,14 @@ const NUM_TESTIMONIES_TO_DISPLAY = 6
const NUM_BALLOT_QUESTIONS_TO_DISPLAY = 4
const EMAIL_TEMPLATE_PATH = "../email/digestEmail.handlebars"
+// The site's wording, conferences input.position.*.long; a test holds them equal.
+export const CONFERENCE_STANCES: Record = {
+ passSomething: "Please pass something",
+ passSenateVersion: "Pass the Senate version",
+ passHouseVersion: "Pass the House version",
+ passNothing: "Don't pass anything"
+}
+
const path = require("path")
const getVerifiedUserEmail = async (uid: string) => {
@@ -207,10 +217,16 @@ export const buildDigestData = async (
}
if (notification.isUserMatch) {
- const billResult = {
+ const billResult: BillResult = {
billId: notification.billId,
court: notification.court,
- position: notification.position as Position
+ ...(notification.conferenceId
+ ? {
+ conferenceId: notification.conferenceId,
+ stance:
+ CONFERENCE_STANCES[notification.position as ConferencePosition]
+ }
+ : { position: notification.position as Position })
}
if (usersById[notification.authorUid]) {
const user = usersById[notification.authorUid]
diff --git a/functions/src/notifications/emailTypes.ts b/functions/src/notifications/emailTypes.ts
index 3ddf6fc99..ae6fe0bd8 100644
--- a/functions/src/notifications/emailTypes.ts
+++ b/functions/src/notifications/emailTypes.ts
@@ -12,8 +12,12 @@ export type Position = "endorse" | "neutral" | "oppose"
export type BillResult = {
billId: string
court: string
- position: Position
-}
+} & (
+ | { position: Position }
+ // Testimony on a committee of conference links the conference and says its
+ // stance in words, since the position images are bill stances.
+ | { conferenceId: string; stance: string }
+)
export type UserDigest = {
userId: string
userName: string
diff --git a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
index 0c9594308..3f4cec732 100644
--- a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
+++ b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts
@@ -53,6 +53,7 @@ export const populateTestimonySubmissionNotificationEvents = onDocumentWritten(
ballotQuestionId: bqId,
ballotQuestionCourt: bqCourt,
+ conferenceId: newData?.conferenceId ?? null,
updateTime: Timestamp.now()
}
diff --git a/functions/src/notifications/publishNotifications.ts b/functions/src/notifications/publishNotifications.ts
index fd84ea8d4..9b86f34db 100644
--- a/functions/src/notifications/publishNotifications.ts
+++ b/functions/src/notifications/publishNotifications.ts
@@ -74,7 +74,8 @@ const createNotificationFields = (
testimonyId: entity.testimonyId,
userRole: entity.userRole,
ballotQuestionId: entity.ballotQuestionId ?? null,
- ballotQuestionCourt: entity.ballotQuestionCourt ?? null
+ ballotQuestionCourt: entity.ballotQuestionCourt ?? null,
+ conferenceId: entity.conferenceId ?? null
},
createdAt: Timestamp.now()
}
@@ -140,7 +141,11 @@ export const publishNotifications = onDocumentWritten(
]
: [
`testimony-${topic.userId}`,
- `bill-${topic.billCourt}-${topic.billId}`,
+ // Conference testimony is kept off its bill, so the bill's
+ // followers don't hear of it.
+ ...(topic.conferenceId
+ ? []
+ : [`bill-${topic.billCourt}-${topic.billId}`]),
...(topic.ballotQuestionId && topic.ballotQuestionCourt != null
? [
`ballot-question-${topic.ballotQuestionCourt}-${topic.ballotQuestionId}`
diff --git a/functions/src/notifications/types.ts b/functions/src/notifications/types.ts
index e748f24de..33c4aa5e3 100644
--- a/functions/src/notifications/types.ts
+++ b/functions/src/notifications/types.ts
@@ -26,6 +26,7 @@ export type TestimonySubmissionNotification = Notification & {
testimonyVersion: number
ballotQuestionId?: string | null
ballotQuestionCourt?: number | null
+ conferenceId?: string | null
}
export interface TestimonySubmissionNotificationFields {
@@ -48,6 +49,7 @@ export interface TestimonySubmissionNotificationFields {
authorUid: string
ballotQuestionId?: string | null
ballotQuestionCourt?: number | null
+ conferenceId?: string | null
}
createdAt: FirebaseFirestore.Timestamp
}
diff --git a/functions/src/shared/conferences.ts b/functions/src/shared/conferences.ts
new file mode 100644
index 000000000..40c9ba74b
--- /dev/null
+++ b/functions/src/shared/conferences.ts
@@ -0,0 +1,6 @@
+import type { ConferenceStatus } from "../conferences/types"
+
+/** Testimony is taken while the committee is still deciding which version to
+ * report. */
+export const takesTestimony = (status: ConferenceStatus) =>
+ status === "appointed"
diff --git a/functions/src/shared/index.ts b/functions/src/shared/index.ts
index c651f6893..2958296c6 100644
--- a/functions/src/shared/index.ts
+++ b/functions/src/shared/index.ts
@@ -1,2 +1,3 @@
+export * from "./conferences"
export * from "./constants"
export * from "./names"
diff --git a/functions/src/testimony/deleteTestimony.ts b/functions/src/testimony/deleteTestimony.ts
index fad333205..58c3e5791 100644
--- a/functions/src/testimony/deleteTestimony.ts
+++ b/functions/src/testimony/deleteTestimony.ts
@@ -3,8 +3,6 @@ import { onCall } from "firebase-functions/v2/https"
import type { CallableRequest } from "firebase-functions/v2/https"
import * as logger from "firebase-functions/logger"
import { Record } from "runtypes"
-import { BallotQuestion } from "../ballotQuestions/types"
-import { Bill } from "../bills/types"
import {
checkAuth,
checkRequest,
@@ -15,8 +13,8 @@ import {
} from "../common"
import { db, FieldValue } from "../firebase"
import { Attachments } from "./attachments"
+import { targetCountsUpdate, targetOf } from "./target"
import { DraftTestimony, Testimony } from "./types"
-import { updateTestimonyCounts } from "./updateTestimonyCounts"
const DeleteTestimonyRequest = Record({
uid: Id,
@@ -67,10 +65,6 @@ class DeleteTestimonyTransaction {
private publicationSnap!: DocumentSnapshot
private publication!: Testimony
- private billSnap!: DocumentSnapshot
- private bill!: Bill
- private ballotQuestion?: BallotQuestion
- private ballotQuestionRef?: FirebaseFirestore.DocumentReference
private draftSnap?: DocumentSnapshot
constructor(
@@ -86,27 +80,28 @@ class DeleteTestimonyTransaction {
async run(): Promise {
await this.loadPublication()
if (!this.publicationSnap.exists) return { deleted: false }
- await this.loadBill()
- await this.loadBallotQuestion()
- await this.loadDraft()
-
- const billUpdate: DocUpdate = {
- ...(await this.resolveNewLatestTestimony()),
- ...updateTestimonyCounts(this.bill, this.publication, undefined)
- }
+ const target = targetOf(this.publication)
+ const [targetSnap, latest] = await Promise.all([
+ this.t.get(target.ref),
+ target.kind === "bill" ? this.resolveNewLatestTestimony() : {},
+ this.loadDraft()
+ ])
const draftUpdate: DocUpdate = {
publishedVersion: FieldValue.delete()
}
- if (!this.publication.ballotQuestionId) {
- this.t.update(this.billSnap.ref, billUpdate)
- }
- if (this.ballotQuestion && this.ballotQuestionRef) {
- this.t.update(
- this.ballotQuestionRef,
- updateTestimonyCounts(this.ballotQuestion, this.publication, undefined)
- )
+ // A target that is gone (a removed ballot question, say) has no counts.
+ if (targetSnap.exists) {
+ this.t.update(target.ref, {
+ ...latest,
+ ...targetCountsUpdate(
+ target,
+ targetSnap.data(),
+ this.publication,
+ undefined
+ )
+ })
}
this.t.delete(this.publicationSnap.ref)
if (this.draftSnap) this.t.update(this.draftSnap.ref, draftUpdate)
@@ -128,20 +123,17 @@ class DeleteTestimonyTransaction {
}
}
- private async loadBill() {
- this.billSnap = await this.t.get(
- db.doc(
- `/generalCourts/${this.publication.court}/bills/${this.publication.billId}`
- )
- )
- this.bill = Bill.checkWithDefaults(this.billSnap.data())
- }
-
private async loadDraft() {
const result = await this.t.get(
db
.collection(`users/${this.uid}/draftTestimony`)
.where("billId", "==", this.publication.billId)
+ .where(
+ "ballotQuestionId",
+ "==",
+ this.publication.ballotQuestionId ?? null
+ )
+ .where("conferenceId", "==", this.publication.conferenceId ?? null)
)
if (result.docs.length === 1) {
@@ -149,17 +141,6 @@ class DeleteTestimonyTransaction {
}
}
- private async loadBallotQuestion() {
- const ballotQuestionId = this.publication.ballotQuestionId ?? null
- if (!ballotQuestionId) return
-
- this.ballotQuestionRef = db.doc(`/ballotQuestions/${ballotQuestionId}`)
- const snap = await this.t.get(this.ballotQuestionRef)
- if (!snap.exists) return
-
- this.ballotQuestion = BallotQuestion.checkWithDefaults(snap.data())
- }
-
private async resolveNewLatestTestimony() {
const result = await this.t.get(
db
@@ -167,6 +148,7 @@ class DeleteTestimonyTransaction {
.where("billId", "==", this.publication.billId)
.where("court", "==", this.publication.court)
.where("ballotQuestionId", "==", null)
+ .where("conferenceId", "==", null)
.orderBy("publishedAt", "desc")
.limit(2)
)
diff --git a/functions/src/testimony/positions.test.ts b/functions/src/testimony/positions.test.ts
new file mode 100644
index 000000000..43cdd912f
--- /dev/null
+++ b/functions/src/testimony/positions.test.ts
@@ -0,0 +1,42 @@
+import { DraftTestimony } from "./types"
+
+const draft = {
+ billId: "S2581",
+ court: 194,
+ content: "text",
+ attachmentId: null,
+ editReason: null,
+ ballotQuestionId: null
+}
+
+describe("DraftTestimony position", () => {
+ it("takes a bill position on bill testimony", () => {
+ expect(DraftTestimony.guard({ ...draft, position: "endorse" })).toBe(true)
+ expect(DraftTestimony.guard({ ...draft, position: "passNothing" })).toBe(
+ false
+ )
+ })
+
+ it("takes a conference position on conference testimony", () => {
+ const conference = { ...draft, conferenceId: "data-privacy" }
+ expect(
+ DraftTestimony.guard({ ...conference, position: "passSenateVersion" })
+ ).toBe(true)
+ expect(DraftTestimony.guard({ ...conference, position: "oppose" })).toBe(
+ false
+ )
+ })
+})
+
+describe("DraftTestimony target", () => {
+ it("names a ballot question or a conference, not both", () => {
+ expect(
+ DraftTestimony.guard({
+ ...draft,
+ position: "endorse",
+ ballotQuestionId: "bq-1",
+ conferenceId: "data-privacy"
+ })
+ ).toBe(false)
+ })
+})
diff --git a/functions/src/testimony/publishTestimony.ts b/functions/src/testimony/publishTestimony.ts
index 844ac504a..76638b679 100644
--- a/functions/src/testimony/publishTestimony.ts
+++ b/functions/src/testimony/publishTestimony.ts
@@ -1,17 +1,21 @@
-import { DocumentReference, DocumentSnapshot } from "@google-cloud/firestore"
+import {
+ DocumentData,
+ DocumentReference,
+ DocumentSnapshot
+} from "@google-cloud/firestore"
import { onCall } from "firebase-functions/v2/https"
import type { CallableRequest } from "firebase-functions/v2/https"
import * as logger from "firebase-functions/logger"
import { nanoid } from "nanoid"
import { Record } from "runtypes"
-import { BallotQuestion } from "../ballotQuestions/types"
import { Bill } from "../bills/types"
+import { Conference } from "../conferences/types"
import { checkAuth, checkRequest, DocUpdate, fail, Id } from "../common"
import { db, FieldValue, Timestamp } from "../firebase"
-import { supportedGeneralCourts } from "../shared"
+import { supportedGeneralCourts, takesTestimony } from "../shared"
import { Attachments, PublishedAttachmentState } from "./attachments"
+import { targetCountsUpdate, targetOf, TestimonyTarget } from "./target"
import { DraftTestimony, Testimony } from "./types"
-import { updateTestimonyCounts } from "./updateTestimonyCounts"
const PublishTestimonyRequest = Record({
draftId: Id
@@ -62,9 +66,10 @@ class PublishTestimonyTransaction {
private draftSnap!: DocumentSnapshot
private draft!: DraftTestimony
private bqId!: string | null
- private billSnap!: DocumentSnapshot
private bill!: Bill
- private ballotQuestion?: BallotQuestion
+ private conferenceId!: string | null
+ private target!: TestimonyTarget
+ private targetData?: DocumentData
private publicationRef!: DocumentReference
private currentPublication?: Testimony
private profile?: any
@@ -99,7 +104,8 @@ class PublishTestimonyTransaction {
draftAttachmentId: this.attachments.draftId,
fullName: this.profile?.fullName ?? "Anonymous",
public: this.profile?.public ?? true,
- ballotQuestionId: this.bqId
+ ballotQuestionId: this.bqId,
+ conferenceId: this.conferenceId
}
if (this.profile?.representative?.id) {
newPublication.representativeId = this.profile.representative.id
@@ -118,8 +124,7 @@ class PublishTestimonyTransaction {
this.setPublication(newPublication)
this.createArchive(newPublication)
this.updateDraft(newPublication)
- this.updateBill(newPublication)
- this.updateBallotQuestion(newPublication)
+ this.updateTarget(newPublication)
return {
publicationId: this.publicationRef.id,
@@ -147,32 +152,23 @@ class PublishTestimonyTransaction {
this.t.update(this.draftSnap.ref, update)
}
- private updateBill(newPublication: Testimony) {
- if (this.bqId) return
-
- const billTestimonyFields: DocUpdate = {
- latestTestimonyAt: newPublication.publishedAt,
- latestTestimonyId: this.publicationRef.id,
- ...updateTestimonyCounts(
- this.bill,
- this.currentPublication,
- newPublication
- )
- }
- this.t.update(this.billSnap.ref, billTestimonyFields)
- }
-
- private updateBallotQuestion(newPublication: Testimony) {
- if (!this.bqId || !this.ballotQuestion) return
-
- const ballotQuestionFields: DocUpdate =
- updateTestimonyCounts(
- this.ballotQuestion,
+ private updateTarget(newPublication: Testimony) {
+ const latest: DocUpdate =
+ this.target.kind === "bill"
+ ? {
+ latestTestimonyAt: newPublication.publishedAt,
+ latestTestimonyId: this.publicationRef.id
+ }
+ : {}
+ this.t.update(this.target.ref, {
+ ...latest,
+ ...targetCountsUpdate(
+ this.target,
+ this.targetData,
this.currentPublication,
newPublication
)
-
- this.t.update(db.doc(`/ballotQuestions/${this.bqId}`), ballotQuestionFields)
+ })
}
private async resolveDraft() {
@@ -195,13 +191,14 @@ class PublishTestimonyTransaction {
await this.checkValidCourt(draft.court)
const bqId = draft.ballotQuestionId ?? null
+ const conferenceId = draft.conferenceId ?? null
+ const target = targetOf(draft)
const billRef = db.doc(
`/generalCourts/${draft.court}/bills/${draft.billId}`
)
- const bqRef = bqId !== null ? db.doc(`/ballotQuestions/${bqId}`) : null
- const [billSnap, bqSnap] = await Promise.all([
+ const [billSnap, targetSnap] = await Promise.all([
this.t.get(billRef),
- bqRef ? this.t.get(bqRef) : Promise.resolve(null)
+ target.kind === "bill" ? null : this.t.get(target.ref)
])
if (!billSnap.exists) {
@@ -210,22 +207,38 @@ class PublishTestimonyTransaction {
`Draft testimony has invalid bill ID ${draft.billId}`
)
}
- if (bqSnap !== null && !bqSnap.exists) {
+ if (targetSnap && !targetSnap.exists) {
throw fail(
"failed-precondition",
- `Draft testimony has invalid ballotQuestionId ${bqId}`
+ target.kind === "ballotQuestion"
+ ? `Draft testimony has invalid ballotQuestionId ${bqId}`
+ : `Draft testimony has invalid conferenceId ${conferenceId}`
)
}
+ if (target.kind === "conference") {
+ const conference = Conference.checkWithDefaults(targetSnap!.data())
+ if (conference.canonicalBillId !== draft.billId) {
+ throw fail(
+ "failed-precondition",
+ `Conference ${conferenceId} takes testimony on ${conference.canonicalBillId}, not ${draft.billId}`
+ )
+ }
+ if (!takesTestimony(conference.status)) {
+ throw fail(
+ "failed-precondition",
+ `Conference ${conferenceId} is not accepting testimony (status ${conference.status})`
+ )
+ }
+ }
+
this.draft = draft
this.bqId = bqId
+ this.conferenceId = conferenceId
+ this.target = target
+ this.targetData = (targetSnap ?? billSnap).data()
this.draftSnap = draftSnap
- this.billSnap = billSnap
this.bill = Bill.checkWithDefaults(billSnap.data())
- this.ballotQuestion =
- bqSnap && bqSnap.exists
- ? BallotQuestion.checkWithDefaults(bqSnap.data())
- : undefined
}
private async resolveProfile() {
@@ -281,14 +294,15 @@ class PublishTestimonyTransaction {
.where("billId", "==", this.draft.billId)
.where("court", "==", this.draft.court)
.where("ballotQuestionId", "==", this.bqId)
+ .where("conferenceId", "==", this.conferenceId)
.orderBy("version", "desc")
.limit(1)
)
+ const latest = archived.docs[0]
- if (archived.size === 0) return INITIAL_VERSION
+ if (!latest) return INITIAL_VERSION
- const data = archived.docs[0].data(),
- nextVersion = Testimony.checkWithDefaults(data).version + 1
+ const nextVersion = Testimony.checkWithDefaults(latest.data()).version + 1
if (nextVersion > MAX_VERSION)
throw new Error(
@@ -305,17 +319,18 @@ class PublishTestimonyTransaction {
.where("billId", "==", this.draft.billId)
.where("court", "==", this.draft.court)
.where("ballotQuestionId", "==", this.bqId)
+ .where("conferenceId", "==", this.conferenceId)
.limit(1)
)
+ const publication = publications.docs[0]
- if (publications.size === 0) {
+ if (!publication) {
this.publicationRef = db.doc(
`/users/${this.uid}/publishedTestimony/${nanoid()}`
)
} else {
- const data = publications.docs[0].data()
- this.currentPublication = Testimony.checkWithDefaults(data)
- this.publicationRef = publications.docs[0].ref
+ this.currentPublication = Testimony.checkWithDefaults(publication.data())
+ this.publicationRef = publication.ref
}
}
diff --git a/functions/src/testimony/search.ts b/functions/src/testimony/search.ts
index 0e3fc027d..4675d3346 100644
--- a/functions/src/testimony/search.ts
+++ b/functions/src/testimony/search.ts
@@ -42,7 +42,7 @@ export const {
],
default_sorting_field: "publishedAt"
},
- filter: data => !data.ballotQuestionId,
+ filter: data => !data.ballotQuestionId && !data.conferenceId,
convert: data => {
const validation = Testimony.validateWithDefaults(data)
if (!validation.success) {
diff --git a/functions/src/testimony/target.test.ts b/functions/src/testimony/target.test.ts
new file mode 100644
index 000000000..bf898121e
--- /dev/null
+++ b/functions/src/testimony/target.test.ts
@@ -0,0 +1,29 @@
+import { targetOf } from "./target"
+
+const ids = { court: 194, billId: "S2581" }
+
+describe("targetOf", () => {
+ it("targets the bill when no other target is named", () => {
+ const target = targetOf({ ...ids, ballotQuestionId: null })
+ expect(target.kind).toBe("bill")
+ expect(target.ref.path).toBe("generalCourts/194/bills/S2581")
+ })
+
+ it("targets a ballot question", () => {
+ const target = targetOf({ ...ids, ballotQuestionId: "bq-1" })
+ expect(target.kind).toBe("ballotQuestion")
+ expect(target.ref.path).toBe("ballotQuestions/bq-1")
+ })
+
+ it("targets a conference", () => {
+ const target = targetOf({ ...ids, conferenceId: "data-privacy" })
+ expect(target.kind).toBe("conference")
+ expect(target.ref.path).toBe("generalCourts/194/conferences/data-privacy")
+ })
+
+ it("throws on a testimony naming two targets", () => {
+ expect(() =>
+ targetOf({ ...ids, ballotQuestionId: "bq-1", conferenceId: "x" })
+ ).toThrow("Testimony targets a ballot question or a conference, not both")
+ })
+})
diff --git a/functions/src/testimony/target.ts b/functions/src/testimony/target.ts
new file mode 100644
index 000000000..d755ca652
--- /dev/null
+++ b/functions/src/testimony/target.ts
@@ -0,0 +1,61 @@
+import { DocumentData, DocumentReference } from "@google-cloud/firestore"
+import { conferenceCountFields } from "../conferences/types"
+import { db } from "../firebase"
+import { billCountFields, oneTarget, Testimony } from "./types"
+import { updateTestimonyCounts } from "./updateTestimonyCounts"
+
+/** What a testimony counts toward: its bill, a ballot question on it, or a
+ * committee of conference on it. */
+export type TestimonyTarget = {
+ kind: "bill" | "ballotQuestion" | "conference"
+ ref: DocumentReference
+}
+
+type TargetIds = Pick<
+ Testimony,
+ "court" | "billId" | "ballotQuestionId" | "conferenceId"
+>
+
+export function targetOf({
+ court,
+ billId,
+ ballotQuestionId,
+ conferenceId
+}: TargetIds): TestimonyTarget {
+ const one = oneTarget({ ballotQuestionId, conferenceId })
+ if (one !== true) throw new Error(one)
+ if (ballotQuestionId)
+ return {
+ kind: "ballotQuestion",
+ ref: db.doc(`/ballotQuestions/${ballotQuestionId}`)
+ }
+ if (conferenceId)
+ return {
+ kind: "conference",
+ ref: db.doc(`/generalCourts/${court}/conferences/${conferenceId}`)
+ }
+ return {
+ kind: "bill",
+ ref: db.doc(`/generalCourts/${court}/bills/${billId}`)
+ }
+}
+
+const countFieldsFor = {
+ bill: billCountFields,
+ ballotQuestion: billCountFields,
+ conference: conferenceCountFields
+}
+
+/** The count fields to write on the target, from its current document. */
+export const targetCountsUpdate = (
+ target: TestimonyTarget,
+ data: DocumentData | undefined,
+ currentPublication?: Pick,
+ newPublication?: Pick
+) =>
+ updateTestimonyCounts(
+ countFieldsFor[target.kind],
+ data ?? {},
+ currentPublication,
+ newPublication
+ )
diff --git a/functions/src/testimony/types.ts b/functions/src/testimony/types.ts
index cbd4f5875..b2905c2c5 100644
--- a/functions/src/testimony/types.ts
+++ b/functions/src/testimony/types.ts
@@ -16,10 +16,24 @@ import { Timestamp } from "../firebase"
const maxTestimonyLength = 10_000
+/** Whether a bill should pass. */
+export type BillPosition = Static
+export const BillPosition = Union(L("endorse"), L("oppose"), L("neutral"))
+/** Which version a committee of conference should report, if any. */
+export type ConferencePosition = Static
+export const ConferencePosition = Union(
+ L("passNothing"),
+ L("passSomething"),
+ L("passSenateVersion"),
+ L("passHouseVersion")
+)
+export type Position = Static
+export const Position = Union(BillPosition, ConferencePosition)
+
const BaseTestimony = R({
billId: Id,
court: Number,
- position: Union(L("endorse"), L("oppose"), L("neutral")),
+ position: Position,
content: RtString.withConstraint(
s => s.length > 0 && s.length < maxTestimonyLength
),
@@ -64,17 +78,60 @@ export const Testimony = withDefaults(
}
)
+/** The positions a testimony may take: conference testimony takes a
+ * conference position, all other testimony a bill position. */
+export const positionsFor = (conferenceId?: string | null) =>
+ conferenceId ? ConferencePosition : BillPosition
+
+export const positionMatchesTarget = ({
+ position,
+ conferenceId
+}: {
+ position: Position
+ conferenceId?: string | null
+}) =>
+ positionsFor(conferenceId).guard(position) ||
+ (conferenceId
+ ? "Conference testimony takes a conference position"
+ : "Bill testimony takes a bill position")
+
+/** A testimony is about one thing: its bill, a ballot question on it, or a
+ * conference on it. */
+export const oneTarget = ({
+ ballotQuestionId,
+ conferenceId
+}: {
+ ballotQuestionId?: string | null
+ conferenceId?: string | null
+}) =>
+ !(ballotQuestionId && conferenceId) ||
+ "Testimony targets a ballot question or a conference, not both"
+
export type DraftTestimony = Static
export const DraftTestimony = BaseTestimony.extend({
publishedVersion: Optional(Number)
})
+ .withConstraint(positionMatchesTarget)
+ .withConstraint(oneTarget)
export const countsByPositions = {
endorse: "endorseCount",
neutral: "neutralCount",
- oppose: "opposeCount"
+ oppose: "opposeCount",
+ passNothing: "passNothingCount",
+ passSomething: "passSomethingCount",
+ passSenateVersion: "passSenateVersionCount",
+ passHouseVersion: "passHouseVersionCount"
} as const
+/** The count fields the testimony triggers own on bills and ballot questions. */
+export const billCountFields = [
+ "testimonyCount",
+ "endorseCount",
+ "neutralCount",
+ "opposeCount"
+] as const
+
export const TestimonySearchRecord = R({
id: RtString,
billId: RtString,
@@ -82,7 +139,7 @@ export const TestimonySearchRecord = R({
* functions/src/bills/numberVariants.ts. */
billIdVariants: Array(RtString),
court: Number,
- position: Union(L("endorse"), L("oppose"), L("neutral")),
+ position: Position,
content: RtString,
authorUid: RtString,
authorRole: RtString,
diff --git a/functions/src/testimony/updateTestimonyCounts.test.ts b/functions/src/testimony/updateTestimonyCounts.test.ts
index 68c76210d..d44ec1803 100644
--- a/functions/src/testimony/updateTestimonyCounts.test.ts
+++ b/functions/src/testimony/updateTestimonyCounts.test.ts
@@ -1,9 +1,12 @@
+import { conferenceCountFields } from "../conferences/types"
+import { billCountFields } from "./types"
import { updateTestimonyCounts } from "./updateTestimonyCounts"
describe("updateTestimonyCounts", () => {
it("increments counts for a new publication", () => {
expect(
updateTestimonyCounts(
+ billCountFields,
{
testimonyCount: 0,
endorseCount: 0,
@@ -24,6 +27,7 @@ describe("updateTestimonyCounts", () => {
it("swaps counts correctly when a testimony changes position", () => {
expect(
updateTestimonyCounts(
+ billCountFields,
{
testimonyCount: 3,
endorseCount: 1,
@@ -44,6 +48,7 @@ describe("updateTestimonyCounts", () => {
it("decrements counts for a deletion without going below zero", () => {
expect(
updateTestimonyCounts(
+ billCountFields,
{
testimonyCount: 1,
endorseCount: 1,
@@ -60,4 +65,71 @@ describe("updateTestimonyCounts", () => {
opposeCount: 0
})
})
+
+ it("ignores fields outside the given set", () => {
+ expect(
+ updateTestimonyCounts(
+ billCountFields,
+ {
+ testimonyCount: 2,
+ endorseCount: 2,
+ neutralCount: 0,
+ opposeCount: 0,
+ latestTestimonyId: "x"
+ } as any,
+ { position: "endorse" },
+ { position: "oppose" }
+ )
+ ).toEqual({
+ testimonyCount: 2,
+ endorseCount: 1,
+ neutralCount: 0,
+ opposeCount: 1
+ })
+ })
+
+ it("moves conference counts between the four stances", () => {
+ expect(
+ updateTestimonyCounts(
+ conferenceCountFields,
+ {
+ testimonyCount: 1,
+ passNothingCount: 0,
+ passSomethingCount: 0,
+ passSenateVersionCount: 1,
+ passHouseVersionCount: 0
+ },
+ { position: "passSenateVersion" },
+ { position: "passHouseVersion" }
+ )
+ ).toEqual({
+ testimonyCount: 1,
+ passNothingCount: 0,
+ passSomethingCount: 0,
+ passSenateVersionCount: 0,
+ passHouseVersionCount: 1
+ })
+ })
+
+ it("throws on a position without a field in the set", () => {
+ expect(() =>
+ updateTestimonyCounts(
+ billCountFields,
+ { testimonyCount: 0, endorseCount: 0, neutralCount: 0, opposeCount: 0 },
+ undefined,
+ { position: "passNothing" }
+ )
+ ).toThrow("No count field for position passNothing")
+ })
+
+ it("throws on an unknown position", () => {
+ expect(() =>
+ updateTestimonyCounts(
+ billCountFields,
+ { testimonyCount: 1, endorseCount: 0, neutralCount: 0, opposeCount: 0 },
+ { position: "bogus" as any },
+ undefined
+ )
+ ).toThrow("No count field for position bogus")
+ })
})
diff --git a/functions/src/testimony/updateTestimonyCounts.ts b/functions/src/testimony/updateTestimonyCounts.ts
index 2970c13c0..0ce4f5e9f 100644
--- a/functions/src/testimony/updateTestimonyCounts.ts
+++ b/functions/src/testimony/updateTestimonyCounts.ts
@@ -1,38 +1,30 @@
import { countsByPositions, Testimony } from "./types"
-export type TestimonyCountFields = {
- testimonyCount: number
- endorseCount: number
- neutralCount: number
- opposeCount: number
-}
+type CountField =
+ | "testimonyCount"
+ | (typeof countsByPositions)[keyof typeof countsByPositions]
-/** Updates testimony-count fields based on changes to the user's testimony. */
-export function updateTestimonyCounts(
- {
- testimonyCount,
- endorseCount,
- neutralCount,
- opposeCount
- }: TestimonyCountFields,
+/** Updates the given testimony-count fields based on changes to the user's
+ * testimony. Missing counts start at zero. Throws on a position whose count
+ * field is not among `fields`: draft validation keeps those out, so one here
+ * is a broken invariant. */
+export function updateTestimonyCounts(
+ fields: readonly F[],
+ counts: Partial>,
currentPublication?: Pick,
newPublication?: Pick
-): TestimonyCountFields {
- const update: TestimonyCountFields = {
- testimonyCount,
- endorseCount,
- neutralCount,
- opposeCount
- }
- if (currentPublication) {
- const oldPosition = countsByPositions[currentPublication.position]
- update[oldPosition] = Math.max(update[oldPosition] - 1, 0)
- update.testimonyCount = Math.max(update.testimonyCount - 1, 0)
- }
- if (newPublication) {
- const newPosition = countsByPositions[newPublication.position]
- update[newPosition] += 1
- update.testimonyCount += 1
+): Record {
+ const update = Object.fromEntries(
+ fields.map(field => [field, counts[field] ?? 0])
+ ) as Record
+ const adjust = (position: Testimony["position"], delta: number) => {
+ const field = countsByPositions[position]
+ if (!(field in update))
+ throw new Error(`No count field for position ${position} on this target`)
+ for (const f of [field, "testimonyCount"])
+ update[f] = Math.max(update[f] + delta, 0)
}
- return update
+ if (currentPublication) adjust(currentPublication.position, -1)
+ if (newPublication) adjust(newPublication.position, 1)
+ return update as Record
}
diff --git a/pages/conferences/[court]/[id].tsx b/pages/conferences/[court]/[id].tsx
index 2b4275eaf..18a561bb8 100644
--- a/pages/conferences/[court]/[id].tsx
+++ b/pages/conferences/[court]/[id].tsx
@@ -28,7 +28,13 @@ export const getServerSideProps: GetServerSideProps = async ctx => {
const [page, translations] = await Promise.all([
loadConferencePage(query.data.court, query.data.id),
- serverSideTranslations(locale, ["auth", "common", "footer", "conferences"])
+ serverSideTranslations(locale, [
+ "auth",
+ "common",
+ "footer",
+ "conferences",
+ "testimony"
+ ])
])
if (!page) return { notFound: true }
diff --git a/pages/newsfeed.tsx b/pages/newsfeed.tsx
index 38f6bda7a..8574e8df2 100644
--- a/pages/newsfeed.tsx
+++ b/pages/newsfeed.tsx
@@ -19,6 +19,7 @@ export async function getStaticProps({ locale }: any) {
...(await serverSideTranslations(locale, [
"auth",
"common",
+ "conferences",
"editProfile",
"footer",
"profile"
diff --git a/pages/testimony/[...testimonyDetail].tsx b/pages/testimony/[...testimonyDetail].tsx
index 894e00e52..d1e66802f 100644
--- a/pages/testimony/[...testimonyDetail].tsx
+++ b/pages/testimony/[...testimonyDetail].tsx
@@ -1,5 +1,6 @@
import { Testimony } from "components/db"
import { dbService } from "components/db/api"
+import { getConference } from "components/db/conferences"
import { wrapper } from "components/store"
import {
pageDataLoaded,
@@ -127,7 +128,8 @@ const fetchDocs = async (q: NonNullable>) => {
authorUid,
billId,
court,
- ballotQuestionId
+ ballotQuestionId,
+ conferenceId: testimony.conferenceId ?? undefined
})
} else if (q.authorUid) {
;({ authorUid, billId, court, ballotQuestionId } = q)
@@ -149,11 +151,14 @@ const fetchDocs = async (q: NonNullable>) => {
author = await db.getProfile({ uid: authorUid }),
ballotQuestion = ballotQuestionId
? await db.getBallotQuestion({ id: ballotQuestionId })
+ : null,
+ conference = testimony.conferenceId
+ ? await getConference(court, testimony.conferenceId)
: null
if (!bill) return
- return { bill, author, archive, testimony, ballotQuestion }
+ return { bill, author, archive, testimony, ballotQuestion, conference }
}
export const getServerSideProps = wrapper.getServerSideProps(
@@ -194,6 +199,7 @@ export const getServerSideProps = wrapper.getServerSideProps(
testimony: docs.testimony,
bill: docs.bill,
ballotQuestion: docs.ballotQuestion ?? null,
+ conference: docs.conference ?? null,
author: docs.author ?? null,
archive: docs.archive,
version: q.version
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index fc9786495..cabd121f0 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -156,12 +156,15 @@
"actionUpdate": "has an action update from the ",
"and": " and ",
"notFollowEither": "You do not follow either",
+ "conferenceInput": " gave input to the conference committee on ",
+ "conferenceInputPost": "Input Posted ",
"endorsed": " endorsed ",
"endorsementPost": "Endorsement Posted ",
"follow": "You follow ",
"notFollow": "You do not follow ",
"or": " or ",
- "viewFullTestimony": "View Full Testimony"
+ "viewFullTestimony": "View Full Testimony",
+ "viewConferenceInput": "View on the Conference Page"
},
"notInCommittee": "Bill not currently in committee",
"noResults": "No Results",
diff --git a/public/locales/en/conferences.json b/public/locales/en/conferences.json
index 762c09b0f..c3d06c9ed 100644
--- a/public/locales/en/conferences.json
+++ b/public/locales/en/conferences.json
@@ -78,5 +78,96 @@
"text_pdf": "Read the PDF",
"tabs_label": "Sections of this conference committee",
"tab_committee": "Committee",
- "tab_text": "Bill Text"
+ "tab_input": "Public Input",
+ "tab_text": "Bill Text",
+ "input": {
+ "share": "Share your input",
+ "title": "Public Input",
+ "all_users": "All users",
+ "all_accounts": "All Accounts",
+ "filter_by_account_type": "Filter by account type",
+ "account_individual": "Individuals",
+ "account_organization": "Organizations",
+ "account_government": "Gov Officials",
+ "type_individual": "Individual",
+ "type_organization": "Organization",
+ "type_government": "Government office",
+ "all_positions": "All positions",
+ "filter_by_position": "Filter by position",
+ "position_filtered": "Position: {{position}}. Change",
+ "clear_position": "Clear the {{position}} filter",
+ "position": {
+ "passSomething": {
+ "long": "Please pass something",
+ "short": "Pass something"
+ },
+ "passSenateVersion": {
+ "long": "Pass the Senate version",
+ "short": "Senate version"
+ },
+ "passHouseVersion": {
+ "long": "Pass the House version",
+ "short": "House version"
+ },
+ "passNothing": {
+ "long": "Don't pass anything",
+ "short": "Pass nothing"
+ }
+ },
+ "show_more": "Show more",
+ "show_less": "Show less",
+ "empty_title": "No public input yet",
+ "empty_body": "No public input has been submitted yet.",
+ "no_match_title": "Nothing matches these filters",
+ "no_match_body": "Try widening your selection to see more input on this topic.",
+ "clear_filters": "Clear Filters",
+ "previous_page": "Previous page",
+ "next_page": "Next page",
+ "page_of": "{{page}} of {{count}}",
+ "panel_title": "Add Your Input",
+ "close_panel": "Close Add Your Input",
+ "position_label": "Your position",
+ "body_label": "Your input",
+ "prompt": "What do you want lawmakers and other voters to know about this question?",
+ "saving": "Saving…",
+ "saved": "Saved",
+ "delete_draft": "Delete Draft",
+ "cancel": "Cancel",
+ "review": "Review and Post",
+ "review_title": "Review and Post",
+ "preview_label": "Preview",
+ "preview_on": "on {{subject}}",
+ "rules_label": "Before You Post",
+ "rule_public": "Posting is public and stays attached to your account.",
+ "rule_revise": "You can revise it later; earlier versions stay on the record.",
+ "conduct": "All posts are governed by our <1>Code of Conduct1>.",
+ "back": "Back to editing",
+ "post": "Post",
+ "empty_hint": "Write something first, then post it.",
+ "error": "Your input could not be saved. Try again in a moment.",
+ "yours_title": "Your Input",
+ "yours_on_record": "Your input is on the record.",
+ "yours_private": "This section is only visible to you. You can also find your post below in the public record.",
+ "view": "View submission",
+ "edit": "Edit",
+ "follow_user": "Follow user",
+ "unfollow_user": "Unfollow user",
+ "report": "Report",
+ "report_title": "Report Input",
+ "delete_wrong": "Submitted to the wrong conference committee",
+ "rescind": "You may request that your input be deleted by completing this form. Since MAPLE is an archive, deletion requests will only be granted if the input was submitted to the wrong conference committee, if it contained sensitive personal information, or if the user is below 18 years old.",
+ "more_actions": "More actions for {{name}}",
+ "modal_title": "Input for Conference Committee on {{subject}}",
+ "actions": "Actions",
+ "follow_account": "Follow This Account",
+ "unfollow_account": "Unfollow This Account",
+ "close": "Close",
+ "filters": "Filters",
+ "following": "Following",
+ "follow_all": "Follow all",
+ "anonymous": "Anonymous",
+ "descriptor_organization": "Organization account, verified",
+ "descriptor_individual": "Individual account",
+ "descriptor_government": "Legislator account"
+ }
}
diff --git a/scripts/README.md b/scripts/README.md
index 32d54274b..178215621 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -28,20 +28,19 @@ yarn firebase-admin console --env local
### Scripts
-#### `backfillTestimonyBallotQuestionId`
+#### `backfillTestimonyScope`
-Stamps `ballotQuestionId: null` onto every `publishedTestimony` and `archivedTestimony` document that is missing the field.
+Stamps `ballotQuestionId: null` and `conferenceId: null` (replacing `backfillTestimonyBallotQuestionId`, which skipped drafts) onto every `publishedTestimony`, `archivedTestimony` and `draftTestimony` document missing either field.
-**Why it exists:** The composite Firestore index used by `resolvePublication()` (which queries by `billId + court + ballotQuestionId`) excludes documents where an indexed field is absent. Without this backfill, re-publishing legacy testimony creates a duplicate record instead of updating the existing one.
+**Why it exists:** Bill testimony is read by querying both fields `== null`, and Firestore cannot select on a missing field. Without this backfill, legacy testimony drops out of its bill's listing, and re-publishing it creates a duplicate instead of updating it.
-**Idempotent:** Documents that already have the field set (to any value) are skipped, so it is safe to re-run.
+**Idempotent:** Fields already set (to any value) are left alone. `--dry-run` prints the counts and writes nothing. Each write to a published testimony runs its triggers once (notification events, search sync, embeddings); the version and text are unchanged, so none of them notify or re-embed.
-**When to run:** Once against each environment after deploying the ballot-questions feature. Can be deleted from the codebase once confirmed executed everywhere.
+**When to run:** Per environment, dev then prod: after the indexes are built and before the functions and hosting deploy, then once more right after it, to catch what the old code wrote in between. Can be deleted once run everywhere.
```sh
-yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env local
-yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env dev
-yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env prod
+yarn firebase-admin run-script backfillTestimonyScope --env dev --dry-run
+yarn firebase-admin run-script backfillTestimonyScope --env dev
```
---
@@ -346,4 +345,4 @@ yarn setRole --env [args]
## CI test
-The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyBallotQuestionId` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes.
+The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyScope` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes.
diff --git a/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts b/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts
deleted file mode 100644
index 7090bc0d8..000000000
--- a/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- Backfills ballotQuestionId: null onto all existing publishedTestimony and
- archivedTestimony documents that do not have the field set.
-
- Required: Firestore composite indexes exclude documents where indexed fields
- are absent. resolvePublication() queries by billId + court + ballotQuestionId,
- so legacy docs without the field would be duplicated on re-publish rather than
- updated in place.
-
- Safe to re-run: documents already having the field (any value) are skipped.
- Can be removed after running against all environments.
-*/
-import { Script } from "./types"
-
-export const script: Script = async ({ db }) => {
- const writer = db.bulkWriter()
- let updated = 0,
- skipped = 0
-
- const [publishedSnap, archivedSnap] = await Promise.all([
- db.collectionGroup("publishedTestimony").select("ballotQuestionId").get(),
- db.collectionGroup("archivedTestimony").select("ballotQuestionId").get()
- ])
-
- for (const [collName, snap] of [
- ["publishedTestimony", publishedSnap],
- ["archivedTestimony", archivedSnap]
- ] as const) {
- console.log(`${collName}: ${snap.size} documents`)
- snap.forEach(doc => {
- if (!("ballotQuestionId" in doc.data())) {
- writer.update(doc.ref, { ballotQuestionId: null })
- updated++
- } else {
- skipped++
- }
- })
- }
-
- await writer.close()
- console.log(`Done. Updated: ${updated}, Skipped (already set): ${skipped}`)
-}
diff --git a/scripts/firebase-admin/backfillTestimonyScope.ts b/scripts/firebase-admin/backfillTestimonyScope.ts
new file mode 100644
index 000000000..405f8cf0d
--- /dev/null
+++ b/scripts/firebase-admin/backfillTestimonyScope.ts
@@ -0,0 +1,60 @@
+/*
+ Backfills ballotQuestionId: null and conferenceId: null onto every
+ publishedTestimony, archivedTestimony and draftTestimony document missing
+ either field.
+
+ Required: bill testimony is read by querying both fields == null, and
+ Firestore cannot select on a missing field, so legacy docs would drop out of
+ the bill's listing, and re-publishing one would duplicate it rather than
+ update it in place. Replaces backfillTestimonyBallotQuestionId, which
+ skipped drafts.
+
+ Safe to re-run: fields already set (to any value) are left alone. Pass
+ --dry-run to count without writing. Can be removed after running against all
+ environments.
+*/
+import { Boolean, Optional, Record } from "runtypes"
+import { Script } from "./types"
+
+const Args = Record({ dryRun: Optional(Boolean) })
+
+const fields = ["ballotQuestionId", "conferenceId"] as const
+
+export const script: Script = async ({ db, args }) => {
+ const { dryRun = false } = Args.check(args)
+ const writer = db.bulkWriter()
+ const would = dryRun ? "would update" : "updated"
+
+ const collNames = [
+ "publishedTestimony",
+ "archivedTestimony",
+ "draftTestimony"
+ ]
+ const snaps = await Promise.all(
+ collNames.map(c =>
+ db
+ .collectionGroup(c)
+ .select(...fields)
+ .get()
+ )
+ )
+
+ collNames.forEach((collName, i) => {
+ let updated = 0
+ snaps[i].forEach(doc => {
+ const data = doc.data()
+ const missing = fields.filter(f => !(f in data))
+ if (!missing.length) return
+ if (!dryRun)
+ writer.update(doc.ref, Object.fromEntries(missing.map(f => [f, null])))
+ updated++
+ })
+ console.log(
+ `${collName}: ${
+ snaps[i].size
+ } documents, ${would} ${updated}, already set ${snaps[i].size - updated}`
+ )
+ })
+
+ await writer.close()
+}
diff --git a/scripts/firebase-admin/seedLocal.ts b/scripts/firebase-admin/seedLocal.ts
new file mode 100644
index 000000000..714c0e775
--- /dev/null
+++ b/scripts/firebase-admin/seedLocal.ts
@@ -0,0 +1,142 @@
+import * as fs from "fs"
+import * as yaml from "js-yaml"
+import * as path from "path"
+import { Timestamp } from "../../functions/src/firebase"
+import { createMemberSearchIndex } from "../../functions/src/members/createMemberSearchIndex"
+import { script as syncConferences } from "./syncConferences"
+import { Script } from "./types"
+
+/**
+ * Brings a fresh local emulator up with the conference data: the real bills and
+ * members every conference file names (fetched by the functions emulator's
+ * scrapers, as a scrape would), the member search index built from them, the
+ * conferences themselves, and some sample testimony on one conference. The
+ * verified test@example.com comes from the emulator's auth export. Local only.
+ *
+ * yarn emulators:start # in another terminal, from a clean start
+ * yarn firebase-admin run-script seedLocal --env local
+ */
+const COURT = 194
+const SAMPLE = "student-learning-and-mental-health"
+const WAIT_MS = 180_000
+
+const sample: [string, string, string, string][] = [
+ [
+ "Sam Okafor",
+ "user",
+ "passSenateVersion",
+ "The Senate version gives districts a year longer. That matters for small districts like mine."
+ ],
+ [
+ "Rep. Ellis Park",
+ "legislator",
+ "passHouseVersion",
+ "As a member of the House I ask the committee to hold to the House text on enforcement dates."
+ ],
+ [
+ "Dana Whitlock",
+ "user",
+ "passNothing",
+ "Neither bill does what its sponsors say. I would rather the conference let both lapse."
+ ],
+ [
+ "Example Statewide Coalition",
+ "organization",
+ "passSomething",
+ "An organization account files under its own name. This one asks the conference to reach agreement without saying which chamber's text should win."
+ ],
+ [
+ "Jordan Marsh",
+ "user",
+ "passHouseVersion",
+ "An individual writes a few sentences about why the outcome matters to them, and it appears here with their name and the date."
+ ],
+ [
+ "Fight for the Future",
+ "organization",
+ "passSenateVersion",
+ "Our coalition continues to oppose any legislation that requires online age checks.\n\nHowever, the Senate bill, as amended, is the better of the two texts."
+ ],
+ [
+ "Massachusetts Medical Society",
+ "organization",
+ "passSomething",
+ "Dear Members of the Conference Committee:\n\nOn behalf of the over 22,000 members of the Massachusetts Medical Society, I write to convey our support of legislation to establish reasonable limits on student smartphone use during the school day. We urge you to report a bill that includes clear exceptions for medical need."
+ ]
+]
+
+export const script: Script = async ctx => {
+ const { env, db } = ctx
+ if (env !== "local") throw Error("only local supported")
+
+ const dir = path.resolve(process.cwd(), "conferences", `${COURT}`)
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".yaml"))
+ const bills = new Set()
+ const members = new Set()
+ for (const f of files) {
+ const c: any = yaml.load(fs.readFileSync(path.join(dir, f), "utf8"))
+ bills.add(c.senateBillId).add(c.houseBillId)
+ c.conferees.forEach((m: any) => members.add(m.memberCode))
+ }
+
+ // The scrapers run in the functions emulator and write the real documents.
+ for (const [name, ids] of [
+ ["bills", [...bills]],
+ ["members", [...members]]
+ ] as const)
+ await db.collection(`/scrapers/${name}/batches`).add({ court: COURT, ids })
+ console.log(`queued ${bills.size} bills and ${members.size} members`)
+
+ const want = [
+ ...[...bills].map(id => `generalCourts/${COURT}/bills/${id}`),
+ ...[...members].map(id => `generalCourts/${COURT}/members/${id}`)
+ ]
+ const started = Date.now()
+ for (;;) {
+ const snaps = await db.getAll(...want.map(p => db.doc(p)), {
+ fieldMask: []
+ })
+ const missing = snaps.filter(s => !s.exists).length
+ if (!missing) break
+ if (Date.now() - started > WAIT_MS)
+ throw Error(`${missing} bills or members still missing after waiting`)
+ process.stdout.write(`\rwaiting on the scrapers: ${missing} left `)
+ await new Promise(r => setTimeout(r, 3000))
+ }
+ console.log("\nbills and members are in")
+
+ // Scheduled jobs never fire in the emulator, and triggerPubsubFunction does
+ // not reach them, so run the index job's handler here.
+ await createMemberSearchIndex.run({ scheduleTime: new Date().toISOString() })
+
+ await syncConferences(ctx)
+
+ const conference = `generalCourts/${COURT}/conferences/${SAMPLE}`
+ const { canonicalBillId, title } = (await db.doc(conference).get()).data()!
+ const day = 86_400_000
+ let at = Date.now() - sample.length * day
+ for (const [i, [name, role, position, content]] of sample.entries()) {
+ const uid = `seed-conference-${i}`
+ const id = `seed-conference-testimony-${i}`
+ at += day
+ await db.doc(`users/${uid}/publishedTestimony/${id}`).set({
+ id,
+ authorUid: uid,
+ authorDisplayName: name,
+ fullName: name,
+ authorRole: role,
+ billId: canonicalBillId,
+ court: COURT,
+ billTitle: title,
+ position,
+ content,
+ version: 1,
+ public: true,
+ conferenceId: SAMPLE,
+ ballotQuestionId: null,
+ publishedAt: Timestamp.fromMillis(at),
+ updatedAt: Timestamp.fromMillis(at)
+ })
+ }
+ console.log(`seeded ${sample.length} sample testimonies on ${SAMPLE}`)
+}
diff --git a/scripts/firebase-admin/sendTestEmail.ts b/scripts/firebase-admin/sendTestEmail.ts
index ce9f3cde8..1c6dfc1c7 100644
--- a/scripts/firebase-admin/sendTestEmail.ts
+++ b/scripts/firebase-admin/sendTestEmail.ts
@@ -126,6 +126,12 @@ const billResults: BillResult[] = [
court: "194",
position: "neutral"
},
+ {
+ billId: "S2581",
+ court: "194",
+ conferenceId: "student-learning-and-mental-health",
+ stance: "Pass the Senate version"
+ },
{
billId: "H533",
court: "194",
diff --git a/stories/emailTemplates/NotificationDigestEmail.stories.tsx b/stories/emailTemplates/NotificationDigestEmail.stories.tsx
index 1a8321b6b..dc3d1d5c5 100644
--- a/stories/emailTemplates/NotificationDigestEmail.stories.tsx
+++ b/stories/emailTemplates/NotificationDigestEmail.stories.tsx
@@ -87,6 +87,12 @@ const billResults: BillResult[] = [
court: "194",
position: "neutral"
},
+ {
+ billId: "S2581",
+ court: "194",
+ conferenceId: "student-learning-and-mental-health",
+ stance: "Pass the Senate version"
+ },
{
billId: "H533",
court: "194",
diff --git a/tests/integration/backfillTestimonyBallotQuestionId.test.ts b/tests/integration/backfillTestimonyBallotQuestionId.test.ts
deleted file mode 100644
index 4feb74e41..000000000
--- a/tests/integration/backfillTestimonyBallotQuestionId.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { testDb, terminateFirebase } from "../testUtils"
-import { script } from "../../scripts/firebase-admin/backfillTestimonyBallotQuestionId"
-
-afterAll(async () => {
- await Promise.all([
- testDb
- .doc("users/backfill-test-user/publishedTestimony/legacy-pub")
- .delete(),
- testDb
- .doc("users/backfill-test-user/archivedTestimony/legacy-arch")
- .delete(),
- testDb
- .doc("users/backfill-test-user2/publishedTestimony/has-null")
- .delete(),
- testDb
- .doc("users/backfill-test-user2/publishedTestimony/has-value")
- .delete()
- ])
- await terminateFirebase()
-})
-
-it("sets ballotQuestionId: null on docs that are missing the field", async () => {
- const pub = testDb
- .collection("users")
- .doc("backfill-test-user")
- .collection("publishedTestimony")
- .doc("legacy-pub")
- const arch = testDb
- .collection("users")
- .doc("backfill-test-user")
- .collection("archivedTestimony")
- .doc("legacy-arch")
-
- await pub.set({ billId: "H1234", court: 193, content: "test" })
- await arch.set({ billId: "H1234", court: 193, version: 1 })
-
- await script({ db: testDb, args: { env: "local", argv: [] } } as any)
-
- expect((await pub.get()).data()?.ballotQuestionId).toBeNull()
- expect((await arch.get()).data()?.ballotQuestionId).toBeNull()
-})
-
-it("skips docs that already have ballotQuestionId set", async () => {
- const pub1 = testDb
- .collection("users")
- .doc("backfill-test-user2")
- .collection("publishedTestimony")
- .doc("has-null")
- const pub2 = testDb
- .collection("users")
- .doc("backfill-test-user2")
- .collection("publishedTestimony")
- .doc("has-value")
-
- await pub1.set({ ballotQuestionId: null, billId: "H1234", court: 193 })
- await pub2.set({ ballotQuestionId: "bq-123", billId: "H5678", court: 193 })
-
- await script({ db: testDb, args: { env: "local", argv: [] } } as any)
-
- expect((await pub1.get()).data()?.ballotQuestionId).toBeNull()
- expect((await pub2.get()).data()?.ballotQuestionId).toBe("bq-123")
-})
diff --git a/tests/integration/backfillTestimonyCounts.test.ts b/tests/integration/backfillTestimonyCounts.test.ts
index fb5ac68ef..e58da4d49 100644
--- a/tests/integration/backfillTestimonyCounts.test.ts
+++ b/tests/integration/backfillTestimonyCounts.test.ts
@@ -15,7 +15,7 @@ beforeEach(async () => {
afterAll(terminateFirebase)
-describe.skip("backfillTestimonyCounts", () => {
+describe("backfillTestimonyCounts", () => {
it("Calculates testimony counts", async () => {
await setPublication("user-1", "t-1", {
position: "endorse",
@@ -37,6 +37,13 @@ describe.skip("backfillTestimonyCounts", () => {
billId,
court: currentGeneralCourt
})
+ // Conference testimony shares the bill id but not its counts.
+ await setPublication("user-5", "t-5", {
+ position: "passSenateVersion",
+ billId,
+ court: currentGeneralCourt,
+ conferenceId: "some-conference"
+ })
await testDb
.doc(`/generalCourts/${currentGeneralCourt}/bills/${billId}`)
@@ -76,7 +83,9 @@ describe.skip("backfillTestimonyCounts", () => {
async function setPublication(
uid: string,
id: string,
- testimony: PartialTestimony
+ testimony: PartialTestimony & { conferenceId?: string }
): Promise {
- await testDb.doc(`/users/${uid}/publishedTestimony/${id}`).set(testimony)
+ await testDb
+ .doc(`/users/${uid}/publishedTestimony/${id}`)
+ .set({ ballotQuestionId: null, conferenceId: null, ...testimony })
}
diff --git a/tests/integration/backfillTestimonyScope.test.ts b/tests/integration/backfillTestimonyScope.test.ts
new file mode 100644
index 000000000..072504bcc
--- /dev/null
+++ b/tests/integration/backfillTestimonyScope.test.ts
@@ -0,0 +1,60 @@
+import { testDb, terminateFirebase } from "../testUtils"
+import { script } from "../../scripts/firebase-admin/backfillTestimonyScope"
+
+const user = testDb.collection("users").doc("backfill-scope-test-user")
+const docs = {
+ pub: user.collection("publishedTestimony").doc("legacy-pub"),
+ arch: user.collection("archivedTestimony").doc("legacy-arch"),
+ draft: user.collection("draftTestimony").doc("legacy-draft"),
+ ballot: user.collection("publishedTestimony").doc("ballot-pub"),
+ conference: user.collection("draftTestimony").doc("conference-draft")
+}
+
+const run = (args = {}) =>
+ script({ db: testDb, args: { env: "local", argv: [], ...args } } as any)
+
+beforeEach(async () => {
+ await Promise.all([
+ docs.pub.set({ billId: "H1234", court: 193 }),
+ docs.arch.set({ billId: "H1234", court: 193, version: 1 }),
+ docs.draft.set({ billId: "H1234", court: 193 }),
+ docs.ballot.set({ billId: "H1234", court: 193, ballotQuestionId: "bq-1" }),
+ docs.conference.set({ billId: "H1234", court: 193, conferenceId: "c-1" })
+ ])
+})
+
+afterAll(async () => {
+ await Promise.all(Object.values(docs).map(d => d.delete()))
+ await terminateFirebase()
+})
+
+it("sets both scope fields to null on docs missing them", async () => {
+ await run()
+
+ for (const d of [docs.pub, docs.arch, docs.draft]) {
+ const data = (await d.get()).data()
+ expect(data).toHaveProperty("ballotQuestionId", null)
+ expect(data).toHaveProperty("conferenceId", null)
+ }
+})
+
+it("leaves fields that are already set", async () => {
+ await run()
+
+ const ballot = (await docs.ballot.get()).data()
+ expect(ballot).toHaveProperty("ballotQuestionId", "bq-1")
+ expect(ballot).toHaveProperty("conferenceId", null)
+ const conference = (await docs.conference.get()).data()
+ expect(conference).toHaveProperty("ballotQuestionId", null)
+ expect(conference).toHaveProperty("conferenceId", "c-1")
+})
+
+it("writes nothing on a dry run", async () => {
+ await run({ dryRun: true })
+
+ for (const d of [docs.pub, docs.arch, docs.draft]) {
+ const data = (await d.get()).data()
+ expect(data).not.toHaveProperty("ballotQuestionId")
+ expect(data).not.toHaveProperty("conferenceId")
+ }
+})
diff --git a/tests/integration/common.ts b/tests/integration/common.ts
index 6ea279184..0a9c59489 100644
--- a/tests/integration/common.ts
+++ b/tests/integration/common.ts
@@ -181,7 +181,9 @@ export const createNewTestimony = async (uid: string, billId: string) => {
court: 192,
position: "oppose",
content: "testimony content",
- public: true
+ public: true,
+ ballotQuestionId: null,
+ conferenceId: null
}
try {
diff --git a/tests/integration/exportedTestData/auth_export/accounts.json b/tests/integration/exportedTestData/auth_export/accounts.json
index f932a8f3b..915e4f481 100644
--- a/tests/integration/exportedTestData/auth_export/accounts.json
+++ b/tests/integration/exportedTestData/auth_export/accounts.json
@@ -1 +1 @@
-{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"05lcy6YQrPvPRvVPlZFbCA0O81TF","lastLoginAt":"1682192783216","emailVerified":true,"email":"testadmin@example.com","salt":"fakeSaltQ0CeYd5UyEmOq5MWLcnP","passwordHash":"fakeHash:salt=fakeSaltQ0CeYd5UyEmOq5MWLcnP:password=password","passwordUpdatedAt":1682192769498,"validSince":"1682192769","createdAt":"1682192575545","providerUserInfo":[{"providerId":"password","email":"testadmin@example.com","federatedId":"testadmin@example.com","rawId":"testadmin@example.com","displayName":"","photoUrl":""}],"lastRefreshAt":"2023-04-22T19:46:23.216Z","customAttributes":"{\"role\":\"admin\"}","displayName":"","photoUrl":""},{"localId":"PbB7tQy18F9bQXfI3AonVmFfQU1B","createdAt":"1647962671353","lastLoginAt":"1649530941158","displayName":"test1","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltuknPVwAkolkrDbQLJkUa:password=password","salt":"fakeSaltuknPVwAkolkrDbQLJkUa","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test@example.com","federatedId":"test@example.com","rawId":"test@example.com","displayName":"test1","photoUrl":""}],"validSince":"1682192348","email":"test@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"VBN6dg70PGsupNvayJ2TPHPgydHz","createdAt":"1647962690530","lastLoginAt":"1647962870753","displayName":"test2","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt2x7l7erotVwSgZtSrvE4:password=password","salt":"fakeSalt2x7l7erotVwSgZtSrvE4","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test3@example.com","federatedId":"test3@example.com","rawId":"test3@example.com","displayName":"test2","photoUrl":""}],"validSince":"1682192348","email":"test3@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"d6ZFKTVH8i4hglyv42wz8QDaKKxw","createdAt":"1647962684429","lastLoginAt":"1647962684429","displayName":"test3","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt9WTUIwt7hSmUtHEWukrL:password=password","salt":"fakeSalt9WTUIwt7hSmUtHEWukrL","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test2@example.com","federatedId":"test2@example.com","rawId":"test2@example.com","displayName":"test3","photoUrl":""}],"validSince":"1682192348","email":"test2@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"jLPye8OfwlzAEGO6h10hs5mmVIDE","createdAt":"1647962700251","lastLoginAt":"1647962870743","displayName":"test4","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltExpNl9RTLBTTOfrDMtdM:password=password","salt":"fakeSaltExpNl9RTLBTTOfrDMtdM","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test4@example.com","federatedId":"test4@example.com","rawId":"test4@example.com","displayName":"test4","photoUrl":""}],"validSince":"1682192348","email":"test4@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"}]}
\ No newline at end of file
+{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"05lcy6YQrPvPRvVPlZFbCA0O81TF","lastLoginAt":"1682192783216","emailVerified":true,"email":"testadmin@example.com","salt":"fakeSaltQ0CeYd5UyEmOq5MWLcnP","passwordHash":"fakeHash:salt=fakeSaltQ0CeYd5UyEmOq5MWLcnP:password=password","passwordUpdatedAt":1682192769498,"validSince":"1682192769","createdAt":"1682192575545","providerUserInfo":[{"providerId":"password","email":"testadmin@example.com","federatedId":"testadmin@example.com","rawId":"testadmin@example.com","displayName":"","photoUrl":""}],"lastRefreshAt":"2023-04-22T19:46:23.216Z","customAttributes":"{\"role\":\"admin\"}","displayName":"","photoUrl":""},{"localId":"PbB7tQy18F9bQXfI3AonVmFfQU1B","createdAt":"1647962671353","lastLoginAt":"1649530941158","displayName":"test1","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltuknPVwAkolkrDbQLJkUa:password=password","salt":"fakeSaltuknPVwAkolkrDbQLJkUa","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test@example.com","federatedId":"test@example.com","rawId":"test@example.com","displayName":"test1","photoUrl":""}],"validSince":"1682192348","email":"test@example.com","emailVerified":true,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"VBN6dg70PGsupNvayJ2TPHPgydHz","createdAt":"1647962690530","lastLoginAt":"1647962870753","displayName":"test2","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt2x7l7erotVwSgZtSrvE4:password=password","salt":"fakeSalt2x7l7erotVwSgZtSrvE4","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test3@example.com","federatedId":"test3@example.com","rawId":"test3@example.com","displayName":"test2","photoUrl":""}],"validSince":"1682192348","email":"test3@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"d6ZFKTVH8i4hglyv42wz8QDaKKxw","createdAt":"1647962684429","lastLoginAt":"1647962684429","displayName":"test3","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt9WTUIwt7hSmUtHEWukrL:password=password","salt":"fakeSalt9WTUIwt7hSmUtHEWukrL","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test2@example.com","federatedId":"test2@example.com","rawId":"test2@example.com","displayName":"test3","photoUrl":""}],"validSince":"1682192348","email":"test2@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"jLPye8OfwlzAEGO6h10hs5mmVIDE","createdAt":"1647962700251","lastLoginAt":"1647962870743","displayName":"test4","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltExpNl9RTLBTTOfrDMtdM:password=password","salt":"fakeSaltExpNl9RTLBTTOfrDMtdM","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test4@example.com","federatedId":"test4@example.com","rawId":"test4@example.com","displayName":"test4","photoUrl":""}],"validSince":"1682192348","email":"test4@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"}]}
\ No newline at end of file
diff --git a/tests/integration/exportedTestData/firebase-export-metadata.json b/tests/integration/exportedTestData/firebase-export-metadata.json
index 913a9ae45..5955c99c1 100644
--- a/tests/integration/exportedTestData/firebase-export-metadata.json
+++ b/tests/integration/exportedTestData/firebase-export-metadata.json
@@ -1,7 +1,7 @@
{
"version": "11.16.0",
"firestore": {
- "version": "1.15.1",
+ "version": "1.22.0",
"path": "firestore_export",
"metadata_file": "firestore_export/firestore_export.overall_export_metadata"
},
diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata
index 7c1c7a624..eac59636d 100644
Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ
diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0
index 73ae8ca18..1eb8980f8 100644
Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 differ
diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1
index a9cf44192..d4427f614 100644
Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 differ
diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2
index a7c4d72c7..46c6893da 100644
Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 differ
diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3
index 9d7dd6842..ed975374b 100644
Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 differ
diff --git a/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata b/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata
index 9d09faa0b..a23ed5390 100644
Binary files a/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata and b/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata differ
diff --git a/tests/integration/testimony.test.ts b/tests/integration/testimony.test.ts
index dd6f6393b..0e39e836f 100644
--- a/tests/integration/testimony.test.ts
+++ b/tests/integration/testimony.test.ts
@@ -1,4 +1,5 @@
import { currentGeneralCourt } from "functions/src/shared"
+import { FieldValue } from "firebase-admin/firestore"
import { signOut, User } from "firebase/auth"
import { doc, getDoc, setDoc, Timestamp, updateDoc } from "firebase/firestore"
import { httpsCallable } from "firebase/functions"
@@ -6,6 +7,7 @@ import { ref, uploadBytes } from "firebase/storage"
import { nanoid } from "nanoid"
import { auth, firestore, functions, storage } from "../../components/firebase"
import { terminateFirebase, testDb, testStorage } from "../testUtils"
+import { script as backfillTestimonyScope } from "../../scripts/firebase-admin/backfillTestimonyScope"
import {
createFakeBill,
expectPermissionDenied,
@@ -20,11 +22,19 @@ import {
type BaseTestimony = {
billId: string
court: number
- position: "endorse" | "oppose" | "neutral"
+ position:
+ | "endorse"
+ | "oppose"
+ | "neutral"
+ | "passNothing"
+ | "passSomething"
+ | "passSenateVersion"
+ | "passHouseVersion"
content: string
attachmentId: string | null | undefined
editReason?: string
ballotQuestionId?: string | null
+ conferenceId?: string | null
}
type DraftTestimony = BaseTestimony & {
@@ -446,6 +456,17 @@ describe("publishTestimony", () => {
expect(pubB.ballotQuestionId ?? null).toBeNull()
})
+ it("Refuses testimony on a ballot question and a conference", async () => {
+ const { draftId } = await createDraft(uid, billId, undefined, "bq-test-1")
+ await updateDoc(refs.draftTestimony(uid, draftId), {
+ conferenceId: "data-privacy",
+ position: "passNothing"
+ })
+ await expect(publishTestimony({ draftId })).rejects.toThrow(
+ "Testimony targets a ballot question or a conference, not both"
+ )
+ })
+
it("Fails to publish testimony with a non-existent ballotQuestionId", async () => {
const { draftId: bqDraftId } = await createDraft(
uid,
@@ -479,6 +500,236 @@ describe("publishTestimony", () => {
expect(publication.ballotQuestionId).toBe("bq-test-1")
})
})
+
+ describe("conferenceId", () => {
+ let conferenceId: string
+ beforeEach(async () => {
+ conferenceId = await createConference(billId)
+ })
+
+ it("Counts conference testimony on the conference, not the bill", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSenateVersion"
+ )
+ const res = await publishTestimony({ draftId: conferenceDraftId })
+
+ const publication = await getPublication(uid, res.data.publicationId)
+ expect(publication.conferenceId).toBe(conferenceId)
+ expect(publication.position).toBe("passSenateVersion")
+
+ const conference = await getConference(conferenceId)
+ expect(conference.testimonyCount).toBe(1)
+ expect(conference.passSenateVersionCount).toBe(1)
+
+ const bill = await getBill(billId)
+ expect(bill.testimonyCount).toBe(0)
+ expect(bill.latestTestimonyId).toBeUndefined()
+ })
+
+ it("Keeps bill and conference testimony on the same bill apart", async () => {
+ const resA = await publishTestimony({ draftId })
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passNothing"
+ )
+ const resB = await publishTestimony({ draftId: conferenceDraftId })
+ // Republishing the bill testimony finds its own publication again.
+ await updateDoc(refs.draftTestimony(uid, draftId), {
+ content: "updated content",
+ editReason: "edit reason"
+ })
+ const resC = await publishTestimony({ draftId })
+
+ expect(resA.data.publicationId).not.toBe(resB.data.publicationId)
+ expect(resC.data.publicationId).toBe(resA.data.publicationId)
+ expect((await getPublication(uid, resC.data.publicationId)).version).toBe(
+ 2
+ )
+ expect((await getPublication(uid, resB.data.publicationId)).version).toBe(
+ 1
+ )
+
+ const bill = await getBill(billId)
+ expect(bill.testimonyCount).toBe(1)
+ expect(bill.endorseCount).toBe(1)
+ expect(bill.latestTestimonyId).toBe(resA.data.publicationId)
+ })
+
+ it("Republishes backfilled legacy bill testimony in place", async () => {
+ const resA = await publishTestimony({ draftId })
+ // Strip the field from everything the bill testimony wrote, as it was
+ // before conference testimony existed, then backfill it.
+ const legacy = [
+ testDb.doc(paths.draftTestimony(uid, draftId)),
+ testDb.doc(
+ `/users/${uid}/publishedTestimony/${resA.data.publicationId}`
+ ),
+ ...(
+ await testDb
+ .collection(`/users/${uid}/archivedTestimony`)
+ .where("billId", "==", billId)
+ .get()
+ ).docs.map(d => d.ref)
+ ]
+ for (const ref of legacy)
+ await ref.update({ conferenceId: FieldValue.delete() })
+ await backfillTestimonyScope({
+ db: testDb,
+ args: { env: "local", argv: [] }
+ } as any)
+
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passNothing"
+ )
+ await publishTestimony({ draftId: conferenceDraftId })
+ await updateDoc(refs.draftTestimony(uid, draftId), {
+ content: "updated content",
+ editReason: "edit reason"
+ })
+ const resB = await publishTestimony({ draftId })
+
+ expect(resB.data.publicationId).toBe(resA.data.publicationId)
+ const publication = await getPublication(uid, resB.data.publicationId)
+ expect(publication.version).toBe(2)
+ expect(publication.conferenceId).toBeNull()
+ })
+
+ it("Moves the conference counts when the stance changes", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSenateVersion"
+ )
+ const res1 = await publishTestimony({ draftId: conferenceDraftId })
+ await updateDoc(refs.draftTestimony(uid, conferenceDraftId), {
+ position: "passHouseVersion",
+ editReason: "changed my mind"
+ })
+ const res2 = await publishTestimony({ draftId: conferenceDraftId })
+
+ expect(res2.data.publicationId).toBe(res1.data.publicationId)
+ const conference = await getConference(conferenceId)
+ expect(conference.testimonyCount).toBe(1)
+ expect(conference.passSenateVersionCount).toBe(0)
+ expect(conference.passHouseVersionCount).toBe(1)
+ })
+
+ it("Fails the delete of a publication whose position does not fit its conference", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSenateVersion"
+ )
+ const res = await publishTestimony({ draftId: conferenceDraftId })
+ const publicationPath = `/users/${uid}/publishedTestimony/${res.data.publicationId}`
+ await testDb.doc(publicationPath).update({ position: "endorse" })
+
+ await signInTestAdmin()
+ await expect(
+ deleteTestimony({ uid, publicationId: res.data.publicationId })
+ ).rejects.toThrow()
+ await signOut(auth)
+
+ expect((await testDb.doc(publicationPath).get()).exists).toBe(true)
+ const conference = await getConference(conferenceId)
+ expect(conference.testimonyCount).toBe(1)
+ expect(conference.passSenateVersionCount).toBe(1)
+ })
+
+ it("Refuses a bill position on conference testimony", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "endorse"
+ )
+ await expect(
+ publishTestimony({ draftId: conferenceDraftId })
+ ).rejects.toThrow("Conference testimony takes a conference position")
+ })
+
+ it("Refuses a conference position on bill testimony", async () => {
+ await updateDoc(refs.draftTestimony(uid, draftId), {
+ position: "passNothing"
+ })
+ await expect(publishTestimony({ draftId })).rejects.toThrow(
+ "Bill testimony takes a bill position"
+ )
+ })
+
+ it("Refuses a conference that does not exist", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ "nonexistent-conference",
+ "passNothing"
+ )
+ await expect(
+ publishTestimony({ draftId: conferenceDraftId })
+ ).rejects.toThrow("invalid conferenceId")
+ })
+
+ it.each(["reportFiled", "enacted", "dissolved"])(
+ "Refuses testimony on a conference that is %s",
+ async status => {
+ const closedId = await createConference(billId, status)
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ closedId,
+ "passNothing"
+ )
+ await expect(
+ publishTestimony({ draftId: conferenceDraftId })
+ ).rejects.toThrow("not accepting testimony")
+ expect((await getConference(closedId)).testimonyCount).toBe(0)
+ }
+ )
+
+ it("Refuses to republish once the conference has closed", async () => {
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passNothing"
+ )
+ await publishTestimony({ draftId: conferenceDraftId })
+ await testDb
+ .doc(
+ `/generalCourts/${currentGeneralCourt}/conferences/${conferenceId}`
+ )
+ .update({ status: "reportFiled" })
+ await updateDoc(refs.draftTestimony(uid, conferenceDraftId), {
+ content: "updated content"
+ })
+ await expect(
+ publishTestimony({ draftId: conferenceDraftId })
+ ).rejects.toThrow("not accepting testimony")
+ })
+
+ it("Refuses testimony on a bill that is not the conference's", async () => {
+ const otherBillId = await createFakeBill()
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ otherBillId,
+ conferenceId,
+ "passNothing"
+ )
+ await expect(
+ publishTestimony({ draftId: conferenceDraftId })
+ ).rejects.toThrow(`takes testimony on ${billId}`)
+ })
+ })
})
describe("deleteTestimony", () => {
@@ -547,6 +798,90 @@ describe("deleteTestimony", () => {
expect(archived.size).toBe(2)
})
+ it("Takes conference testimony off the conference's counts", async () => {
+ const conferenceId = await createConference(billId)
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSomething"
+ )
+ const res = await publishTestimony({ draftId: conferenceDraftId })
+
+ await getSignedInAdmin()
+ await deleteTestimony({ uid, publicationId: res.data.publicationId })
+
+ const conference = await getConference(conferenceId)
+ expect(conference.testimonyCount).toBe(0)
+ expect(conference.passSomethingCount).toBe(0)
+ const draft = await testDb
+ .doc(paths.draftTestimony(uid, conferenceDraftId))
+ .get()
+ expect(draft.data()!.publishedVersion).toBeUndefined()
+ })
+
+ it("Lets conference testimony be deleted after the conference closes", async () => {
+ const conferenceId = await createConference(billId)
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSomething"
+ )
+ const res = await publishTestimony({ draftId: conferenceDraftId })
+ await testDb
+ .doc(`/generalCourts/${currentGeneralCourt}/conferences/${conferenceId}`)
+ .update({ status: "dissolved" })
+
+ await getSignedInAdmin()
+ await deleteTestimony({ uid, publicationId: res.data.publicationId })
+
+ expect((await getConference(conferenceId)).testimonyCount).toBe(0)
+ })
+
+ it("Never makes conference testimony the bill's latest", async () => {
+ const conferenceId = await createConference(billId)
+ const resBill = await publishTestimony({ draftId })
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ conferenceId,
+ "passSomething"
+ )
+ await publishTestimony({ draftId: conferenceDraftId })
+
+ await getSignedInAdmin()
+ await deleteTestimony({ uid, publicationId: resBill.data.publicationId })
+
+ const bill = await getBill(billId)
+ expect(bill.testimonyCount).toBe(0)
+ expect(bill.latestTestimonyId).toBeUndefined()
+ expect((await getConference(conferenceId)).testimonyCount).toBe(1)
+ })
+
+ it("Skips newer conference testimony for the bill's latest", async () => {
+ const resA = await publishTestimony({ draftId })
+ const { draftId: conferenceDraftId } = await createConferenceDraft(
+ uid,
+ billId,
+ await createConference(billId),
+ "passSomething"
+ )
+ await publishTestimony({ draftId: conferenceDraftId })
+ const { uid: uid2 } = await getSignedInAdmin()
+ const { draftId: draftId2 } = await createDraft(uid2, billId)
+ const resB = await publishTestimony({ draftId: draftId2 })
+ expect((await getBill(billId)).latestTestimonyId).toBe(
+ resB.data.publicationId
+ )
+
+ await deleteTestimony({ uid: uid2, publicationId: resB.data.publicationId })
+
+ const bill = await getBill(billId)
+ expect(bill.testimonyCount).toBe(1)
+ expect(bill.latestTestimonyId).toBe(resA.data.publicationId)
+ })
+
describe("attachments", () => {
it("deletes attachments", async () => {
let attachmentId = nanoid()
@@ -615,6 +950,54 @@ async function createDraftAttachment(uid: string, id: string, content: string) {
})
}
+/** A conference whose canonical bill is `billId`, with its counts zeroed. It
+ * takes testimony only while `status` is appointed. */
+async function createConference(billId: string, status = "appointed") {
+ const id = `conference-${nanoid()}`.toLowerCase()
+ await testDb
+ .doc(`/generalCourts/${currentGeneralCourt}/conferences/${id}`)
+ .set({
+ id,
+ court: currentGeneralCourt,
+ title: "Test conference",
+ senateBillId: billId,
+ houseBillId: "H1",
+ canonicalBillId: billId,
+ conferees: [],
+ status,
+ hearingIds: [],
+ differences: [],
+ similarities: [],
+ testimonyCount: 0,
+ passNothingCount: 0,
+ passSomethingCount: 0,
+ passSenateVersionCount: 0,
+ passHouseVersionCount: 0
+ })
+ return id
+}
+
+async function getConference(id: string) {
+ const doc = await testDb
+ .doc(`/generalCourts/${currentGeneralCourt}/conferences/${id}`)
+ .get()
+ return doc.data() as any
+}
+
+async function createConferenceDraft(
+ uid: string,
+ billId: string,
+ conferenceId: string,
+ position: BaseTestimony["position"]
+) {
+ const { draftId } = await createDraft(uid, billId)
+ await updateDoc(refs.draftTestimony(uid, draftId), {
+ conferenceId,
+ position
+ })
+ return { draftId }
+}
+
async function createDraft(
uid: string,
billId: string,
@@ -628,7 +1011,8 @@ async function createDraft(
court,
position: "endorse",
attachmentId: null,
- ballotQuestionId: ballotQuestionId ?? null
+ ballotQuestionId: ballotQuestionId ?? null,
+ conferenceId: null
}
await setDoc(refs.draftTestimony(uid, draftId), draft)
diff --git a/tests/unit/conferenceLinks.test.tsx b/tests/unit/conferenceLinks.test.tsx
index 6818d94b1..47e9dd051 100644
--- a/tests/unit/conferenceLinks.test.tsx
+++ b/tests/unit/conferenceLinks.test.tsx
@@ -23,6 +23,7 @@ const conference: ConferenceSummary = {
title: "An Act relative to testing",
senateBillId: "S2500",
houseBillId: "H4000",
+ canonicalBillId: "S2500",
status: "appointed"
}
|