Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/repo-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,4 @@ jobs:
tests/integration/profile.test.ts
tests/integration/ballotQuestions.test.ts
tests/integration/backfillTestimonyScope.test.ts
tests/integration/backfillTestimonyFirstPublishedAt.test.ts
14 changes: 0 additions & 14 deletions components/api/delete-testimony.ts

This file was deleted.

2 changes: 0 additions & 2 deletions components/moderation/EditReports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ export const ReportContent = ({
<Card>
<CardHeader title="User Report Content" />
<CardContent>
<TextField label="archive id" source="resolution.archiveTestimonyId" />

<label>Reason:</label>
<Typography>{report.reason}</Typography>
<br />
Expand Down
4 changes: 0 additions & 4 deletions components/moderation/ListReports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ export function ListReports() {
<Datagrid rowClick={"edit"} bulkActionButtons={false}>
<TextField source="id" label={t("reportId")} />
<TextField source="testimonyId" />
<TextField
source="resolution.archiveTestimonyId"
label={t("archivedId")}
/>
<TextField source="reason" />
<StatusField label={t("status")} />
<TextField source="resolution.resolution" label={t("resolution")} />
Expand Down
27 changes: 2 additions & 25 deletions components/moderation/RemoveTestimony.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import { Card, CardContent, CardHeader, Stack } from "@mui/material"
import { deleteTestimony } from "components/api/delete-testimony"
import { resolveReport } from "components/db"
import { getAuth } from "firebase/auth"
import { doc, getDoc } from "firebase/firestore"
import { Timestamp } from "functions/src/firebase"
import { FormEventHandler, useState } from "react"
import { useRedirect, useNotify, useRefresh } from "react-admin"
import { useRedirect, useRefresh } from "react-admin"
import { Report, Resolution } from "."
import { firestore } from "components/firebase"
import { refreshToken } from "firebase-admin/app"

export type ReportResponseValues = {
reportId: string
resolution: Resolution
reason: string
}

export const onSubmitReport = async (
reportId: string,
resolution: Resolution,
reason: string,
authorUid: string,
testimonyId: string,
refresh: () => void
) => {
const r = await resolveReport({
Expand All @@ -34,10 +22,6 @@ export const onSubmitReport = async (
alert(r.data.status)
}

if (resolution === "remove-testimony") {
// If removing testimony, call deleteTestimony to move testimony from 'published' to 'archived'
const res = await deleteTestimony(authorUid, testimonyId)
}
refresh()
}

Expand All @@ -57,14 +41,7 @@ export function RemoveTestimonyForm({ report }: { report: Report }) {
const onSubmit: FormEventHandler<HTMLFormElement> = async e => {
e.preventDefault()
if (resolution && reason && report.id) {
await onSubmitReport(
report.id,
resolution,
reason,
report.authorUid,
report.testimonyId,
refresh
)
await onSubmitReport(report.id, resolution, reason, refresh)
redirect("list", "reports")
} else {
console.log("one of these not defined", resolution, reason, report.id)
Expand Down
1 change: 0 additions & 1 deletion components/moderation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export type ReportResolution = {
moderatorUid: string
resolvedAt: Timestamp
authorUid?: string
archivedTestimonyId: string
}

export const modifyAccount = httpsCallable<{ uid: string; role: Role }, void>(
Expand Down
80 changes: 80 additions & 0 deletions docs/cosign-feature-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Cosign feature requirements

## Context

MAPLE provides users with the ability to submit testimony on bills.

We want to add a feature that allows a user to "cosign" someone else's
testimony, adding a +1 to what the other is saying, and an optional note
explaining why they are cosigning. The total number of cosigns is to appear
next to the testimony. This will be initially rolled out to conference
committee testimonies only.

## 1. Purpose

The user must be able to press a button to cosign testimony for a conference committee, and see that cosignature reflected.

## 2. Triggers

What triggers the feature?

| ID | Trigger Type | Source | Resulting action |
| ---- | ---------------------------- | --------------- | ---------------- |
| TR-1 | Pressing the "cosign" button | User on Web UI | FR-1, FR-5 |
| TR-2 | Pressing the edit button | User on Web UI | FR-3 |
| TR-3 | Deleting parent testimony | Admin on Web UI | FR-4 |

## 3. Functional Requirements

| ID | Requirement | Trigger | Acceptance criterion | Priority |
| ---- | ------------------------------------------------------------------------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| FR-1 | The cosign panel pops up and allows the user to cosign testimony | TR-1 | The testimony card that's been cosigned gains a "Cosigned" count, a number of districts, a last cosigned date, and the new testimony grouped under it (noteless cosigns appear only if "show cosigns without content" is ticked) | Must |
| FR-2 | Cosigned testimony appears in newsletters and the newsfeed | FR-1 | A new piece of cosigning testimony is included in the email update to followers of the conference or the cosigner | Must |
| FR-3 | An "edit cosign" view appears allowing the user to change the statement (and nothing else) | TR-2 | A piece of cosigned testimony has its justification sentence edited; it's not possible to change its position or to turn it into a plain testimony | Must |
| FR-4 | Deleting the parent testimony cascades to deleting cosigns | TR-3 | Deleting parent testimony deletes cosigns of it | Must |
| FR-5 | A logged out user that presses cosign is taken to the login flow | TR-1 | It's possible to initiate login by pressing cosign | Must |
| FR-6 | Cosigned testimony copies the parent's position, and the position is not editable | FR-1 | Editing a parent's position does not edit the cosign's, and the tally reflects this | Must |
| FR-7 | Cosigning a cosign or your own testimony is an error | FR-1 | There is no cosign button on co-signatures, or on the logged in user's testimony, and calling the server-side to do an invalid cosign fails | Must |
| FR-8 | The conference shows a bar with its position totals, cosigns included | FR-1 | On submitting a cosign, the conference's position tallies change | Must |

## 4. Platform Constraints

| ID | Constraint area | Requirement |
| ---- | --------------- | --------------------------------------------------------------------------------------------------------------------- |
| PC-1 | Infrastructure | Firestore data must be denormalized and querying can only happen through a set of indexes that may need to be updated |
| PC-2 | Infrastructure | Any server-side actions must be implemented as a cloud-function with clear triggers |

## 5. Non-Functional Requirements

| ID | Category | Requirement | Target |
| ----- | -------------- | ------------------------------------------------------------------------------------------------------------------------- | ------ |
| NFR-1 | Data integrity | No migration of existing records | N/A |
| NFR-2 | Consistency | Cosigns behave like other testimony vis-a-vis sharing and restrictions for posting (e.g. "only while conference is open") | N/A |

## 6. Failure Behaviour

| ID | Failure condition | Expected behaviour | Retry/backoff | Alert? |
| ---- | ---------------------------- | ------------------------------ | ------------- | ------ |
| FB-1 | Parent deleted mid-cosign | Cosign fails, user is told | N/A | No |
| FB-2 | Conference closes mid-cosign | Cosign fails, user is told | N/A | No |
| FB-3 | Double submission | Exactly one cosign is recorded | N/A | No |

## 7. Open Questions

| ID | Question | Status | Resolution |
| --- | -------- | ------ | ---------- |

## 8. Out of Scope

| ID | Category | Anti-Requirement | Target |
| ----- | -------------- | --------------------------------------------------------------------- | ------ |
| OOS-1 | Functional | No cosigns in non-conference bills | N/A |
| OOS-2 | Functional | No sending cosigns as a digest to the org whose testimony is cosigned | N/A |
| OOS-3 | Functional | No private notes for orgs | N/A |
| OOS-4 | Infrastructure | No ATProto scope whatsoever | N/A |

## Change Log

| Version | Date | Change |
| ------- | ---------- | ------------- |
| 0.1 | 2026-10-22 | Initial draft |
11 changes: 11 additions & 0 deletions firestore.indexes.json
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,17 @@
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "publishedTestimony",
"fieldPath": "cosignOfId",
"ttl": false,
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
}
]
}
1 change: 1 addition & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { checkSearchIndexVersion, searchHealthCheck } from "./search"
export {
deleteTestimonyV2,
publishTestimonyV2,
updateCosignTally,
runTestimonyBackfillChunk,
syncTestimonyToSearchIndex,
upgradeTestimonySearchIndex,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { populateTestimonySubmissionNotificationEventsHandler } from "./populateTestimonySubmissionNotificationEvents"

jest.mock("firebase-functions/v2/firestore", () => ({
onDocumentWritten: jest.fn()
}))

jest.mock("../firebase", () => ({
db: {
collection: jest.fn()
},
Timestamp: {
now: jest.fn(() => ({ seconds: 1000 }))
}
}))

const mockDb = require("../firebase").db

const mockAdd = jest.fn().mockResolvedValue({})
const mockUpdate = jest.fn().mockResolvedValue({})
const mockDoc = jest.fn().mockReturnValue({ update: mockUpdate })

const setupCollection = (empty: boolean, docs: { id: string }[] = []) => {
const query = {
where: jest.fn(),
get: jest.fn().mockResolvedValue({ empty, docs }),
add: mockAdd,
doc: mockDoc
}
query.where.mockReturnValue(query)
mockDb.collection.mockReturnValue(query)
}

const makeTestimony = (overrides: object = {}) => ({
court: 194,
billId: "H1234",
billTitle: "An Act relative to testing",
authorUid: "author-uid",
authorRole: "user",
fullName: "Test Author",
position: "endorse",
content: "Testimony content",
version: 1,
...overrides
})

const makeSnapshot = (before: object | null, after: object | null) => ({
before: { exists: before !== null, data: () => before },
after: { exists: after !== null, data: () => after }
})

const makeEvent = (before: object | null, after: object | null) => ({
data: makeSnapshot(before, after),
params: { userId: "author-uid", testimonyId: "testimony-id" }
})

beforeEach(() => {
jest.clearAllMocks()
})

describe("populateTestimonySubmissionNotificationEventsHandler", () => {
it("creates a notificationEvent for new testimony", async () => {
setupCollection(true)

const event = makeEvent(null, makeTestimony())
await populateTestimonySubmissionNotificationEventsHandler(event as any)

expect(mockAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: "testimony",
billCourt: "194",
billId: "H1234",
billName: "An Act relative to testing",
userId: "author-uid",
testimonyId: "testimony-id",
testimonyVersion: 1
})
)
})

it.each(["billTitle", "court", "authorUid"])(
"skips new testimony missing %s",
async field => {
setupCollection(true)

const event = makeEvent(null, makeTestimony({ [field]: undefined }))
await populateTestimonySubmissionNotificationEventsHandler(event as any)

expect(mockAdd).not.toHaveBeenCalled()
}
)

it("does nothing when the version is unchanged", async () => {
const event = makeEvent(makeTestimony(), makeTestimony({ cosignCount: 1 }))
await populateTestimonySubmissionNotificationEventsHandler(event as any)

expect(mockDb.collection).not.toHaveBeenCalled()
})

it("updates an existing notificationEvent when the version changes", async () => {
const existingDocId = "existing-event-id"
setupCollection(false, [{ id: existingDocId }])

const event = makeEvent(
makeTestimony(),
makeTestimony({ version: 2, content: "Edited content" })
)
await populateTestimonySubmissionNotificationEventsHandler(event as any)

expect(mockDoc).toHaveBeenCalledWith(existingDocId)
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
testimonyContent: "Edited content",
testimonyVersion: 2
})
)
})
})
Loading
Loading