From c90d41dd78b3af70258bf6f5d0d3cdbfa59e1000 Mon Sep 17 00:00:00 2001 From: astordg Date: Thu, 19 Feb 2026 10:25:40 -0500 Subject: [PATCH 1/2] v2 versions of CreateFakeTestimony, createFakeOrg, modifyAccount, adminResolveReport Created v2 versions of all of these functions that are firebase v2 versions. I also created v2 versions of checkAuth and checkAdmin and the fail function they use for throwing errors that use the v2 error type. --- components/db/testimony/types.ts | 4 + components/moderation/ListProfiles.tsx | 3 +- components/moderation/RemoveTestimony.tsx | 3 +- .../moderation/setUp/CreateMockReport.tsx | 3 +- components/moderation/types.ts | 13 +++ functions/src/auth/createFakeOrg.ts | 31 +++++++- functions/src/auth/createFakeTestimony.ts | 53 ++++++++++++- functions/src/auth/modifyAccount.ts | 20 ++++- functions/src/common.ts | 46 ++++++++++- functions/src/index.ts | 12 ++- functions/src/testimony/resolveReport.ts | 79 ++++++++++++++++++- tests/integration/moderation.test.ts | 12 ++- 12 files changed, 263 insertions(+), 16 deletions(-) diff --git a/components/db/testimony/types.ts b/components/db/testimony/types.ts index bcec3fb5d..1c9f464b6 100644 --- a/components/db/testimony/types.ts +++ b/components/db/testimony/types.ts @@ -97,3 +97,7 @@ export const resolveReport = httpsCallable( functions, "adminResolveReport" ) +export const resolveReportv2 = httpsCallable( + functions, + "adminResolveReportv2" +) diff --git a/components/moderation/ListProfiles.tsx b/components/moderation/ListProfiles.tsx index f8cfc1ffc..8eefbca8e 100644 --- a/components/moderation/ListProfiles.tsx +++ b/components/moderation/ListProfiles.tsx @@ -22,6 +22,7 @@ import { Internal } from "components/links" import { ButtonGroup } from "@mui/material" import { Role } from "components/auth" import { createFakeOrg } from "components/moderation" +import { createFakeOrgv2 } from "components/moderation" import { loremIpsum } from "lorem-ipsum" import { nanoid } from "nanoid" @@ -46,7 +47,7 @@ const UserRoleToolBar = () => { const fullName = loremIpsum({ count: 2, units: "words" }) const email = `${uid}@example.com` - await createFakeOrg({ uid, fullName, email }) + await createFakeOrgv2({ uid, fullName, email }) if (filterValues["role"] === "organization") setFilters({ role: "pendingUpgrade" }, []) diff --git a/components/moderation/RemoveTestimony.tsx b/components/moderation/RemoveTestimony.tsx index 70bc93bc6..d815d8372 100644 --- a/components/moderation/RemoveTestimony.tsx +++ b/components/moderation/RemoveTestimony.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardHeader, Stack } from "@mui/material" import { deleteTestimony } from "components/api/delete-testimony" import { resolveReport } from "components/db" +import { resolveReportv2 } from "components/db" import { getAuth } from "firebase/auth" import { doc, getDoc } from "firebase/firestore" import { Timestamp } from "functions/src/firebase" @@ -24,7 +25,7 @@ export const onSubmitReport = async ( testimonyId: string, refresh: () => void ) => { - const r = await resolveReport({ + const r = await resolveReportv2({ reportId, resolution, reason diff --git a/components/moderation/setUp/CreateMockReport.tsx b/components/moderation/setUp/CreateMockReport.tsx index 154c256ca..ae4fa8f29 100644 --- a/components/moderation/setUp/CreateMockReport.tsx +++ b/components/moderation/setUp/CreateMockReport.tsx @@ -3,6 +3,7 @@ import { useReportTestimony } from "components/api/report" import { Testimony } from "components/db" import { auth, firestore } from "components/firebase" import { createFakeTestimony } from "components/moderation" +import { createFakeTestimonyv2 } from "components/moderation" import { doc, getDoc } from "firebase/firestore" import { loremIpsum } from "lorem-ipsum" import { nanoid } from "nanoid" @@ -21,7 +22,7 @@ export const CreateMockReport = () => { const fullName = loremIpsum({ count: 2, units: "words" }) const email = `${uid}@example.com` - const result = await createFakeTestimony({ + const result = await createFakeTestimonyv2({ uid, fullName, email diff --git a/components/moderation/types.ts b/components/moderation/types.ts index 39c3217ab..40269f853 100644 --- a/components/moderation/types.ts +++ b/components/moderation/types.ts @@ -32,6 +32,10 @@ export const modifyAccount = httpsCallable<{ uid: string; role: Role }, void>( functions, "modifyAccount" ) +export const modifyAccountv2 = httpsCallable<{ uid: string; role: Role }, void>( + functions, + "modifyAccountv2" +) type Request = { uid: string; fullName: string; email: string } type Response = { uid: string; tid: string } @@ -41,7 +45,16 @@ export const createFakeOrg = httpsCallable( "createFakeOrg" ) +export const createFakeOrgv2 = httpsCallable( + functions, + "createFakeOrg" +) + export const createFakeTestimony = httpsCallable( functions, "createFakeTestimony" ) +export const createFakeTestimonyv2 = httpsCallable( + functions, + "createFakeTestimonyv2" +) diff --git a/functions/src/auth/createFakeOrg.ts b/functions/src/auth/createFakeOrg.ts index af0b9c301..5a6014f64 100644 --- a/functions/src/auth/createFakeOrg.ts +++ b/functions/src/auth/createFakeOrg.ts @@ -1,6 +1,7 @@ import * as functions from "firebase-functions" -import { checkAdmin, checkAuth } from "../common" +import { checkAdmin, checkAdminv2, checkAuth, checkAuthv2 } from "../common" import { auth, db } from "../firebase" +import { onCall, CallableRequest } from "firebase-functions/v2/https" // for populating admin module for testing & demonstration //@TODO: remove @@ -32,3 +33,31 @@ export const createFakeOrg = functions.https.onCall(async (data, context) => { return { ...authUser, uid: userRecord.uid } }) + +export const createFakeOrgv2 = onCall(async (request: CallableRequest) => { + checkAuthv2(request, false) + checkAdminv2(request) + + const { uid, fullName, email } = request.data + + const newUser = { + uid, + fullName, + email, + password: "password", + public: true, + role: "pendingUpgrade" + } + + const role = "pendingUpgrade" + const userRecord = await auth.createUser(newUser) + + await auth.setCustomUserClaims(newUser.uid, { role }) + await db.doc(`/profiles/${newUser.uid}`).set(newUser) + + const authUser = (await db.doc(`/profiles/${newUser.uid}`).get()).data() + + console.log(authUser) + + return { ...authUser, uid: userRecord.uid } +}) diff --git a/functions/src/auth/createFakeTestimony.ts b/functions/src/auth/createFakeTestimony.ts index d13740333..a793b937c 100644 --- a/functions/src/auth/createFakeTestimony.ts +++ b/functions/src/auth/createFakeTestimony.ts @@ -1,8 +1,9 @@ -import * as functions from "firebase-functions" -import { checkAdmin, checkAuth } from "../common" +import * as functions from "firebase-functions/v1" +import { checkAdmin, checkAdminv2, checkAuth, checkAuthv2 } from "../common" import { auth, db } from "../firebase" import { Testimony } from "../testimony/types" import { Timestamp } from "../firebase" +import { onCall, CallableRequest } from "firebase-functions/v2/https" // for populating admin module for testing & demonstration--alert--no auth checked here. //@TODO: remove @@ -54,3 +55,51 @@ export const createFakeTestimony = functions.https.onCall( return { uid: uid, tid: id } } ) + +export const createFakeTestimonyv2 = onCall( + async (request: CallableRequest) => { + console.log("running fake testimony") + checkAuthv2(request, false) + checkAdminv2(request) + + const { uid, fullName, email } = request.data + + const author = { + uid, + fullName, + email, + password: "password", + public: true, + role: "user" + } + + await auth.createUser({ uid }) + + await db.doc(`profiles/${uid}`).set(author) + + const id = `${uid}ttmny` + + const testimony: Testimony = { + id, + authorUid: author.uid, + authorDisplayName: "none", + authorRole: "user", + billTitle: "An act", + version: 2, + billId: "H1002", + publishedAt: Timestamp.now(), + court: 192, + position: "oppose", + fullName: fullName, + content: fullName + " " + fullName + " " + fullName + " " + fullName, + public: true, + updatedAt: Timestamp.now() + } + + const testRef = db.doc(`users/${uid}/publishedTestimony/${id}`) + + await testRef.set(testimony) + + return { uid: uid, tid: id } + } +) diff --git a/functions/src/auth/modifyAccount.ts b/functions/src/auth/modifyAccount.ts index 93b65409e..c844796db 100644 --- a/functions/src/auth/modifyAccount.ts +++ b/functions/src/auth/modifyAccount.ts @@ -1,8 +1,15 @@ import * as functions from "firebase-functions" import { db, auth } from "../firebase" import { z } from "zod" -import { checkRequestZod, checkAuth, checkAdmin } from "../common" +import { + checkRequestZod, + checkAuth, + checkAdmin, + checkAuthv2, + checkAdminv2 +} from "../common" import { setRole } from "." +import { onCall, CallableRequest } from "firebase-functions/v2/https" import { ZRole } from "./types" @@ -21,3 +28,14 @@ export const modifyAccount = functions.https.onCall(async (data, context) => { await setRole({ role, auth, db, uid }) }) + +export const modifyAccountv2 = onCall(async (request: CallableRequest) => { + checkAuthv2(request, false) + checkAdminv2(request) + + const { uid, role } = checkRequestZod(Request, request.data) + + console.log(`Setting role for ${uid} to ${role}`) + + await setRole({ role, auth, db, uid }) +}) diff --git a/functions/src/common.ts b/functions/src/common.ts index 7838ada3f..47bf9acaf 100644 --- a/functions/src/common.ts +++ b/functions/src/common.ts @@ -1,7 +1,10 @@ import { FieldValue } from "@google-cloud/firestore" import axios from "axios" import { https, logger } from "firebase-functions" -import { CallableRequest } from "firebase-functions/v2/https" +import { + CallableRequest, + HttpsError as HttpsErrorV2 +} from "firebase-functions/v2/https" import { Null, Nullish, @@ -39,7 +42,7 @@ export function checkRequestZod( /** Return the authenticated user's id or fail if they are not authenticated. */ export function checkAuth( - context: https.CallableContext | CallableRequest, + context: https.CallableContext, checkEmailVerification = false ) { const uid = context.auth?.uid @@ -59,21 +62,58 @@ export function checkAuth( return uid } +/** Return the authenticated user's id or fail if they are not authenticated. (Firebase v2 compatible) */ +export function checkAuthv2( + request: CallableRequest, + checkEmailVerification = false +) { + const uid = request.auth?.uid + + if (!uid) { + throw failv2("unauthenticated", "Caller must be signed in") + } + + if (checkEmailVerification && process.env.FUNCTIONS_EMULATOR !== "true") { + const email_verified = request.auth?.token?.email_verified + + if (!email_verified) { + throw failv2("permission-denied", "You must verify an account first") + } + } + + return uid +} + /** * Checks that the caller is an admin. */ -export function checkAdmin(context: https.CallableContext | CallableRequest) { +export function checkAdmin(context: https.CallableContext) { const callerRole = context.auth?.token.role if (callerRole !== "admin") { throw fail("permission-denied", "You must be an admin") } } +/** + * Checks that the caller is an admin. (Firebase v2 compatible) + */ +export function checkAdminv2(request: CallableRequest) { + const callerRole = request.auth?.token.role + if (callerRole !== "admin") { + throw failv2("permission-denied", "You must be an admin") + } +} + /** Constructs a new HTTPS error */ export function fail(code: https.FunctionsErrorCode, message: string) { return new https.HttpsError(code, message) } +/** Constructs a new HTTPS error (Firebase v2 compatible) */ +export function failv2(code: string, message: string) { + return new HttpsErrorV2(code as any, message) +} + /** Catch handler to log axios errors and return undefined. */ export const logFetchError = (label: string, id?: string) => (e: any) => { if (axios.isAxiosError(e)) { diff --git a/functions/src/index.ts b/functions/src/index.ts index d3effd4be..55a7dac7a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,4 +1,11 @@ -export { modifyAccount, createFakeOrg, createFakeTestimony } from "./auth" +export { + modifyAccount, + modifyAccountv2, + createFakeOrg, + createFakeOrgv2, + createFakeTestimony, + createFakeTestimonyv2 +} from "./auth" export { backfillTestimonyCounts, fetchBillBatch, @@ -38,7 +45,8 @@ export { publishTestimony, syncTestimonyToSearchIndex, upgradeTestimonySearchIndex, - resolveReport as adminResolveReport + resolveReport as adminResolveReport, + resolveReportv2 as adminResolveReportv2 } from "./testimony" export { publishNotifications, diff --git a/functions/src/testimony/resolveReport.ts b/functions/src/testimony/resolveReport.ts index b3170c5dc..03dd0618a 100644 --- a/functions/src/testimony/resolveReport.ts +++ b/functions/src/testimony/resolveReport.ts @@ -1,11 +1,19 @@ import * as functions from "firebase-functions" import { db } from "../firebase" import { z } from "zod" -import { fail, checkRequestZod, checkAuth, checkAdmin } from "../common" +import { + fail, + checkRequestZod, + checkAuth, + checkAdmin, + checkAuthv2, + checkAdminv2 +} from "../common" // import { performDeleteTestimony } from "./deleteTestimony" import { first } from "lodash" import { Testimony } from "./types" import { Profile } from "../profile/types" +import { onCall, CallableRequest } from "firebase-functions/v2/https" export type Request = z.infer const Request = z.object({ @@ -84,3 +92,72 @@ export const resolveReport = functions.https.onCall( return { status: "success" } } ) +export const resolveReportv2 = onCall( + async (request: CallableRequest): Promise => { + checkAuthv2(request, false) + checkAdminv2(request) + + const { reportId, resolution, reason } = checkRequestZod( + Request, + request.data + ) + + // 1. Get the report document + const reportRef = db.collection("reports").doc(reportId) + const report = await reportRef.get() + if (!report.exists) throw fail("not-found", "Report not found") + if (report.data()?.resolution) return { status: "report-already-resolved" } + + // 2. Get the testimony document + const { testimonyId } = report.data() ?? {} + const res = await db + .collectionGroup("publishedTestimony") + .where("id", "==", testimonyId) + .get() + + const rawTestimony = first(res.docs)?.data() + console.log("res", testimonyId, rawTestimony, res.docs.length) + if (!rawTestimony) return { status: "testimony-already-removed" } + const testimony = Testimony.check(rawTestimony) + + // 3. Get the moderator's profile document + const moderatorUid = request.auth!.uid + const moderator = Profile.check( + await db + .doc(`profiles/${moderatorUid}`) + .get() + .then(d => d.data()) + ) + + // ***archived testiomny Id === published testimony Id*** + + // 4 Get the archived testimony document + // const archivedTestimonyId = await db + // .collection(`/users/${testimony.authorUid}/archivedTestimony`) + // .where("billId", "==", testimony.billId) + // .where("court", "==", testimony.court) + // .where("version", "==", testimony.version) + // .limit(1) + // .get() + // .then(snap => { + // if (snap.empty) return testimony.id // throw fail("not-found", "Archived testimony not found") + // return snap.docs[0].id + // }) + + // 5. Update the report + const resolutionObj: any = { + resolution, + moderatorUid, + resolvedAt: new Date(), + authorUid: testimony.authorUid, + archivedTestimonyId: testimonyId + } + if (reason) resolutionObj.reason = reason + if (moderator.fullName) resolutionObj.moderatorName = moderator.fullName + + await reportRef.update({ + resolution: resolutionObj + }) + return { status: "success" } + } +) diff --git a/tests/integration/moderation.test.ts b/tests/integration/moderation.test.ts index 372e1f561..cc20e1655 100644 --- a/tests/integration/moderation.test.ts +++ b/tests/integration/moderation.test.ts @@ -1,6 +1,7 @@ import { waitFor } from "@testing-library/react" import { Role } from "components/auth" import { resolveReport } from "components/db" +import { resolveReportv2 } from "components/db" import { doc, setDoc } from "firebase/firestore" import { httpsCallable } from "firebase/functions" import { @@ -82,7 +83,7 @@ describe("moderate testimony", () => { expect((await pubRef.get()).exists).toBeTruthy() - const result = await resolveReport({ + const result = await resolveReportv2({ reportId, resolution: "remove-testimony", reason: "important reason" @@ -167,6 +168,11 @@ const modifyAccount = httpsCallable<{ uid: string; role: Role }, void>( "modifyAccount" ) +const modifyAccountv2 = httpsCallable<{ uid: string; role: Role }, void>( + functions, + "modifyAccountv2" +) + describe("admins can modify user accounts", () => { it("allows admins to modify user roles ", async () => { const userInfo = genUserInfo() @@ -174,7 +180,7 @@ describe("admins can modify user accounts", () => { testDb.doc(`profiles/${user.uid}`).set({ role: "user" }, { merge: true }) await signInTestAdmin() - await modifyAccount({ uid: user.uid, role: "admin" }) + await modifyAccountv2({ uid: user.uid, role: "admin" }) expect((await testAuth.getUser(user.uid)).customClaims?.role).toEqual( "admin" @@ -189,7 +195,7 @@ describe("admins can modify user accounts", () => { // tries to run modifyAccount as a regular "user" role await signInUser(userInfo.email) await expectPermissionDenied( - modifyAccount({ uid: user.uid, role: "legislator" }) + modifyAccountv2({ uid: user.uid, role: "legislator" }) ) }) }) From 0a2f02621f900a94a2757d9c904be23e604b25d6 Mon Sep 17 00:00:00 2001 From: astordg Date: Thu, 19 Feb 2026 10:51:01 -0500 Subject: [PATCH 2/2] Updated Scrape single hearing to use checkAuthv2 and checkAdminv2 --- functions/src/events/scrapeEvents.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/functions/src/events/scrapeEvents.ts b/functions/src/events/scrapeEvents.ts index 4c989f16c..aceecebe2 100644 --- a/functions/src/events/scrapeEvents.ts +++ b/functions/src/events/scrapeEvents.ts @@ -4,7 +4,13 @@ import { onCall, CallableRequest } from "firebase-functions/v2/https" import { DateTime } from "luxon" import { JSDOM } from "jsdom" import { AssemblyAI } from "assemblyai" -import { checkAuth, checkAdmin, logFetchError } from "../common" +import { + checkAuth, + checkAdmin, + logFetchError, + checkAdminv2, + checkAuthv2 +} from "../common" import { db, storage, Timestamp } from "../firebase" import * as api from "../malegislature" import { @@ -482,8 +488,8 @@ export const scrapeSingleHearingv2 = onCall( async (request: CallableRequest) => { // Require admin authentication // Check how to integrate the new object with these helper functions - checkAuth(request, false) - checkAdmin(request) + checkAuthv2(request, false) + checkAdminv2(request) const { eventId } = request.data