diff --git a/components/api/delete-testimony.ts b/components/api/delete-testimony.ts index cec26956a..61e8292b1 100644 --- a/components/api/delete-testimony.ts +++ b/components/api/delete-testimony.ts @@ -9,6 +9,6 @@ import { mapleClient } from "./maple-client" * @param tid testimony id * @returns 204 response (no body) if successful, or 4XX if not. */ -export async function deleteTestimony(uid: string, tid: string) { +export async function deleteTestimonyv2(uid: string, tid: string) { return mapleClient.delete(`/api/users/${uid}/testimony/${tid}`) } diff --git a/components/auth/hooks.ts b/components/auth/hooks.ts index bfdb49415..723afe1c9 100644 --- a/components/auth/hooks.ts +++ b/components/auth/hooks.ts @@ -12,7 +12,7 @@ import { import { useAsyncCallback } from "react-async-hook" import { setProfile } from "../db" import { auth } from "../firebase" -import { finishSignup, OrgCategory } from "./types" +import { finishSignupv2, OrgCategory } from "./types" const errorMessages: Record = { "auth/email-already-exists": "You already have an account.", @@ -68,7 +68,7 @@ export function useCreateUserWithEmailAndPassword(isOrg: boolean) { password ) if (isOrg) { - await finishSignup({ + await finishSignupv2({ requestedRole: "organization", fullName, orgCategories: orgCategory ? [orgCategory] : "", @@ -76,7 +76,7 @@ export function useCreateUserWithEmailAndPassword(isOrg: boolean) { email: credentials.user.email }) } else { - await finishSignup({ + await finishSignupv2({ requestedRole: "user", fullName, notificationFrequency: "Weekly", @@ -125,7 +125,7 @@ export function useSignInWithPopUp() { const { claims } = await credentials.user.getIdTokenResult() if (!claims?.role) { // The user has not yet finished signing up - await finishSignup({ requestedRole: "user" }) + await finishSignupv2({ requestedRole: "user" }) await Promise.all([ setProfile(credentials.user.uid, { fullName: credentials.user.displayName ?? "New User", diff --git a/components/auth/types.tsx b/components/auth/types.tsx index c3170e281..1f75ea90a 100644 --- a/components/auth/types.tsx +++ b/components/auth/types.tsx @@ -9,3 +9,8 @@ export const finishSignup = httpsCallable< { requestedRole: Role } | Partial, void >(functions, "finishSignup") + +export const finishSignupv2 = httpsCallable< + { requestedRole: Role } | Partial, + void +>(functions, "finishSignupv2") diff --git a/components/db/testimony/types.ts b/components/db/testimony/types.ts index bcec3fb5d..2fb99545b 100644 --- a/components/db/testimony/types.ts +++ b/components/db/testimony/types.ts @@ -88,12 +88,26 @@ export const deleteTestimony = httpsCallable< { deleted: boolean } >(functions, "deleteTestimony") +export const deleteTestimonyv2 = httpsCallable< + { publicationId: string }, + { deleted: boolean } +>(functions, "deleteTestimonyv2") + export const publishTestimony = httpsCallable< { draftId: string }, { publicationId: string } >(functions, "publishTestimony") +export const publishTestimonyv2 = httpsCallable< + { draftId: string }, + { publicationId: string } +>(functions, "publishTestimonyv2") + export const resolveReport = httpsCallable( functions, "adminResolveReport" ) +export const resolveReportv2 = httpsCallable( + functions, + "adminResolveReportv2" +) diff --git a/components/db/testimony/useEditTestimony.ts b/components/db/testimony/useEditTestimony.ts index 136b8b610..a999cc81e 100644 --- a/components/db/testimony/useEditTestimony.ts +++ b/components/db/testimony/useEditTestimony.ts @@ -14,9 +14,11 @@ import { firestore } from "../../firebase" import { resolveBillTestimony } from "./resolveTestimony" import { deleteTestimony, + deleteTestimonyv2, DraftTestimony, hasDraftChanged, publishTestimony, + publishTestimonyv2, Testimony, WorkingDraft } from "./types" @@ -175,7 +177,7 @@ function usePublishTestimony( DraftTestimony.check(workingDraft) // TODO: don't publish again if draft.publishedVersion is defined if (draftRef) { - const result = await publishTestimony({ draftId: draftRef.id }) + const result = await publishTestimonyv2({ draftId: draftRef.id }) dispatch({ type: "resolvePublication", id: result.data.publicationId }) } }, [dispatch, draftRef, workingDraft]), @@ -190,7 +192,7 @@ function useDeleteTestimony( return useAsyncCallback( useCallback(async () => { if (publicationRef) { - const result = await deleteTestimony({ + const result = await deleteTestimonyv2({ publicationId: publicationRef.id }) if (result.data.deleted) dispatch({ type: "deletePublication" }) 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..bcfb00027 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 { deleteTestimonyv2 } 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 @@ -36,7 +37,7 @@ export const onSubmitReport = async ( if (resolution === "remove-testimony") { // If removing testimony, call deleteTestimony to move testimony from 'published' to 'archived' - const res = await deleteTestimony(authorUid, testimonyId) + const res = await deleteTestimonyv2(authorUid, testimonyId) } refresh() } 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..a333532e5 100644 --- a/functions/src/common.ts +++ b/functions/src/common.ts @@ -1,7 +1,11 @@ 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 +43,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 +63,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/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 diff --git a/functions/src/index.ts b/functions/src/index.ts index d3effd4be..7d4a412e0 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, @@ -31,14 +38,17 @@ export { fetchMemberBatch, startMemberBatches } from "./members" -export { finishSignup } from "./profile" +export { finishSignup, finishSignupv2 } from "./profile" export { checkSearchIndexVersion, searchHealthCheck } from "./search" export { deleteTestimony, + deleteTestimonyv2, publishTestimony, + publishTestimonyv2, syncTestimonyToSearchIndex, upgradeTestimonySearchIndex, - resolveReport as adminResolveReport + resolveReport as adminResolveReport, + resolveReportv2 as adminResolveReportv2 } from "./testimony" export { publishNotifications, @@ -46,7 +56,7 @@ export { populateTestimonySubmissionNotificationEvents, cleanupNotifications, deliverNotifications, - updateUserNotificationFrequency + updateUserNotificationFrequencyv2 } from "./notifications" export { diff --git a/functions/src/notifications/index.ts b/functions/src/notifications/index.ts index 04299a040..5a1a8867f 100644 --- a/functions/src/notifications/index.ts +++ b/functions/src/notifications/index.ts @@ -4,7 +4,7 @@ import { populateBillHistoryNotificationEvents } from "./populateBillHistoryNoti import { populateTestimonySubmissionNotificationEvents } from "./populateTestimonySubmissionNotificationEvents" import { cleanupNotifications } from "./cleanupNotifications" import { deliverNotifications } from "./deliverNotifications" -import { updateUserNotificationFrequency } from "./updateUserNotificationFrequency" +import { updateUserNotificationFrequencyv2 } from "./updateUserNotificationFrequency" // Export the functions export { @@ -13,5 +13,5 @@ export { populateTestimonySubmissionNotificationEvents, cleanupNotifications, deliverNotifications, - updateUserNotificationFrequency + updateUserNotificationFrequencyv2 } diff --git a/functions/src/notifications/updateUserNotificationFrequency.ts b/functions/src/notifications/updateUserNotificationFrequency.ts index bb4b32200..5e742118d 100644 --- a/functions/src/notifications/updateUserNotificationFrequency.ts +++ b/functions/src/notifications/updateUserNotificationFrequency.ts @@ -1,6 +1,7 @@ const functions = require("firebase-functions") import * as admin from "firebase-admin" import { getNextDigestAt } from "./helpers" +import { onDocumentWritten } from "firebase-functions/v2/firestore" export const updateUserNotificationFrequency = functions.firestore .document("profiles/{userId}") @@ -50,3 +51,46 @@ export const updateUserNotificationFrequency = functions.firestore return null } ) + +export const updateUserNotificationFrequencyv2 = onDocumentWritten( + "profiles/{userId}", + async event => { + const userId = event.params.userId + const docBeforeChange = event.data?.before?.data() + const docAfterChange = event.data?.after?.data() + + const isAnUpdate = + docBeforeChange && + docBeforeChange.notificationFrequency !== + docAfterChange?.notificationFrequency + + const isACreation = docBeforeChange === undefined + + if (!isAnUpdate && !isACreation) { + console.warn( + `Not an update or creation for userId: ${userId}, function will return without changes.` + ) + return null + } + + const notificationFrequency = docAfterChange?.notificationFrequency + + if (!notificationFrequency) { + console.log(`Notification frequency for user ${userId} is undefined.`) + return null + } + + await admin + .firestore() + .collection("profiles") + .doc(userId) + .set( + { + nextDigestAt: getNextDigestAt(notificationFrequency) + }, + { merge: true } + ) + + return null + } +) diff --git a/functions/src/profile/finishSignup.ts b/functions/src/profile/finishSignup.ts index 0f82728bc..1a0b80cfe 100644 --- a/functions/src/profile/finishSignup.ts +++ b/functions/src/profile/finishSignup.ts @@ -1,8 +1,9 @@ import * as functions from "firebase-functions" import { db, auth } from "../firebase" import { z } from "zod" -import { checkRequestZod, checkAuth } from "../common" +import { checkRequestZod, checkAuth, checkAuthv2 } from "../common" import { setRole } from "../auth" +import { onCall, CallableRequest } from "firebase-functions/v2/https" const CreateProfileRequest = z.object({ requestedRole: z.enum(["user", "organization", "pendingUpgrade"]) @@ -21,8 +22,38 @@ export const finishSignup = functions.https.onCall(async (data, context) => { public: isPublic } = data - // Only an admin can approve organizations, after they've signed up initially - // There's a nextjs api route: PATCH /users/ {"role": } + if (requestedRole === "organization") { + await setRole({ + role: "organization", + auth, + db, + uid, + newProfile: { fullName, email, orgCategories } + }) + } else { + await setRole({ + role: "user", + auth, + db, + uid, + newProfile: { fullName, notificationFrequency, email, public: isPublic } + }) + } +}) + +export const finishSignupv2 = onCall(async (request: CallableRequest) => { + const uid = checkAuthv2(request, false) + + const { requestedRole } = checkRequestZod(CreateProfileRequest, request.data) + + const { + fullName, + orgCategories, + notificationFrequency, + email, + public: isPublic + } = request.data + if (requestedRole === "organization") { await setRole({ role: "organization", diff --git a/functions/src/testimony/deleteTestimony.ts b/functions/src/testimony/deleteTestimony.ts index 5e6e464ae..e42d04bd6 100644 --- a/functions/src/testimony/deleteTestimony.ts +++ b/functions/src/testimony/deleteTestimony.ts @@ -8,12 +8,15 @@ import { DocUpdate, Id, Maybe, - checkAdmin + checkAdmin, + checkAuthv2, + checkAdminv2 } from "../common" import { db, FieldValue } from "../firebase" import { Attachments } from "./attachments" import { DraftTestimony, Testimony } from "./types" import { updateTestimonyCounts } from "./updateTestimonyCounts" +import { onCall, CallableRequest } from "firebase-functions/v2/https" const DeleteTestimonyRequest = Record({ uid: Id, @@ -23,9 +26,6 @@ const DeleteTestimonyRequest = Record({ export const deleteTestimony = https.onCall(async (data, context) => { checkAuth(context) - // Only admins can delete testimony. Previously we used the caller's UID to - // select the testimony to delete, but admins need to be able to delete other - // users testimony so we require the uid to be specified in the request. checkAdmin(context) const { uid, publicationId } = checkRequest(DeleteTestimonyRequest, data) @@ -33,6 +33,19 @@ export const deleteTestimony = https.onCall(async (data, context) => { return performDeleteTestimony(uid, publicationId) }) +export const deleteTestimonyv2 = onCall(async (request: CallableRequest) => { + checkAuthv2(request) + + checkAdminv2(request) + + const { uid, publicationId } = checkRequest( + DeleteTestimonyRequest, + request.data + ) + + return performDeleteTestimony(uid, publicationId) +}) + export const performDeleteTestimony = async ( authorUid: string, publicationId: string diff --git a/functions/src/testimony/publishTestimony.ts b/functions/src/testimony/publishTestimony.ts index 33f0364ec..3ad26e44a 100644 --- a/functions/src/testimony/publishTestimony.ts +++ b/functions/src/testimony/publishTestimony.ts @@ -1,9 +1,17 @@ import { DocumentReference, DocumentSnapshot } from "@google-cloud/firestore" import { https, logger } from "firebase-functions" +import { CallableRequest, onCall } from "firebase-functions/v2/https" import { nanoid } from "nanoid" import { Record } from "runtypes" import { Bill } from "../bills/types" -import { checkAuth, checkRequest, DocUpdate, fail, Id } from "../common" +import { + checkAuth, + checkAuthv2, + checkRequest, + DocUpdate, + fail, + Id +} from "../common" import { db, FieldValue, Timestamp } from "../firebase" import { supportedGeneralCourts } from "../shared" import { Attachments, PublishedAttachmentState } from "./attachments" @@ -39,6 +47,27 @@ export const publishTestimony = https.onCall(async (data, context) => { return { publicationId: output.publicationId } }) +export const publishTestimonyv2 = onCall(async (request: CallableRequest) => { + const checkEmailVerification = true + const uid = checkAuthv2(request, checkEmailVerification) + const { draftId } = checkRequest(PublishTestimonyRequest, request.data) + + let output: TransactionOutput + try { + output = await db.runTransaction(t => + new PublishTestimonyTransaction(t, draftId, uid).run() + ) + } catch (e) { + logger.info("Publication transaction failed.", e) + throw e + } + + let attachments = new Attachments() + await attachments.applyPublish(output.attachments) + + return { publicationId: output.publicationId } +}) + type TransactionOutput = { publicationId: string attachments: PublishedAttachmentState 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/common.ts b/tests/integration/common.ts index daf5955be..487b311ac 100644 --- a/tests/integration/common.ts +++ b/tests/integration/common.ts @@ -17,6 +17,7 @@ import { Bill, BillContent } from "../../functions/src/bills/types" import { testAuth, testDb, testTimestamp } from "../testUtils" import { Timestamp } from "functions/src/firebase" import { Timestamp as FirestoreTimestamp } from "@google-cloud/firestore" +import { finishSignupv2 } from "components/auth" export async function signInUser(email: string) { const { user } = await signInWithEmailAndPassword(auth, email, "password") @@ -309,7 +310,7 @@ export const testCreatePendingOrgWithEmailAndPassword = async ( expectCurrentUser(userCreds.user) - await finishSignup({ requestedRole: "organization" }) + await finishSignupv2({ requestedRole: "organization" }) expect( (await testAuth.getUser(userCreds.user.uid)).customClaims diff --git a/tests/integration/moderation.test.ts b/tests/integration/moderation.test.ts index 372e1f561..f59993832 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 { @@ -31,11 +32,21 @@ const deleteTestimony = httpsCallable< { deleted: boolean } >(functions, "deleteTestimony") +const deleteTestimonyv2 = httpsCallable< + { uid: string; publicationId: string }, + { deleted: boolean } +>(functions, "deleteTestimonyv2") + const publishTestimony = httpsCallable< { draftId: string }, { publicationId: string } >(functions, "publishTestimony") +const publishTestimonyv2 = httpsCallable< + { draftId: string }, + { publicationId: string } +>(functions, "publishTestimonyv2") + let adminUid: string let billId: string @@ -67,7 +78,7 @@ describe("moderate testimony", () => { await setDoc(draftRef, draft) - const pubId = (await publishTestimony({ draftId })).data.publicationId + const pubId = (await publishTestimonyv2({ draftId })).data.publicationId await signInTestAdmin() @@ -82,7 +93,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" @@ -113,7 +124,7 @@ describe("moderate testimony", () => { await setDoc(draftRef, draft) - const pubId = (await publishTestimony({ draftId })).data.publicationId + const pubId = (await publishTestimonyv2({ draftId })).data.publicationId await signInTestAdmin() const pubRef = testDb.collection(`/users/${authorUid}/publishedTestimony`) @@ -121,7 +132,7 @@ describe("moderate testimony", () => { expect(pubTest.size).toEqual(1) - await deleteTestimony({ uid: authorUid, publicationId: pubId }) + await deleteTestimonyv2({ uid: authorUid, publicationId: pubId }) pubTest = await pubRef.where("id", "==", pubId).get() @@ -145,12 +156,12 @@ describe("moderate testimony", () => { const archSize = (await archRef.get()).size await setDoc(draftRef, draft) - const r = await publishTestimony({ draftId }) + const r = await publishTestimonyv2({ draftId }) const pubId = r.data.publicationId await signInTestAdmin() - await deleteTestimony({ uid: authorUid, publicationId: pubId }) + await deleteTestimonyv2({ uid: authorUid, publicationId: pubId }) expect((await archRef.get()).size).toEqual(archSize + 1) await waitFor( @@ -167,6 +178,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 +190,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 +205,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" }) ) }) }) diff --git a/tests/integration/testimony.test.ts b/tests/integration/testimony.test.ts index 90f2dfc8e..a8c801e2e 100644 --- a/tests/integration/testimony.test.ts +++ b/tests/integration/testimony.test.ts @@ -63,11 +63,21 @@ const deleteTestimony = httpsCallable< { deleted: boolean } >(functions, "deleteTestimony") +const deleteTestimonyv2 = httpsCallable< + { uid: string; publicationId: string }, + { deleted: boolean } +>(functions, "deleteTestimonyv2") + const publishTestimony = httpsCallable< { draftId: string }, { publicationId: string } >(functions, "publishTestimony") +const publishTestimonyv2 = httpsCallable< + { draftId: string }, + { publicationId: string } +>(functions, "publishTestimonyv2") + let uid: string let user: User let fullName: string @@ -123,7 +133,7 @@ describe("draftTestimony", () => { describe("publishTestimony", () => { it("Fails if draft doesn't exist", async () => { await expect( - publishTestimony({ draftId: "nonexistant-id" }) + publishTestimonyv2({ draftId: "nonexistant-id" }) ).rejects.toThrow("No draft found with id") }) @@ -132,7 +142,7 @@ describe("publishTestimony", () => { content: "", position: "asdfasdf" }) - await expect(publishTestimony({ draftId })).rejects.toThrow( + await expect(publishTestimonyv2({ draftId })).rejects.toThrow( "failed validation" ) }) @@ -143,13 +153,13 @@ describe("publishTestimony", () => { billId = await createFakeBill() } const { draftId } = await createDraft(uid, billId, currentGeneralCourt) - const res = await publishTestimony({ draftId }) + const res = await publishTestimonyv2({ draftId }) const publication = await getPublication(uid, res.data.publicationId) expect(publication).toBeDefined() }) it("Publishes new testimony", async () => { - const res = await publishTestimony({ draftId }), + const res = await publishTestimonyv2({ draftId }), publicationId = res.data.publicationId expect(publicationId).toBeDefined() @@ -170,7 +180,7 @@ describe("publishTestimony", () => { }) it("Updates bill metadata on publish", async () => { - const res = await publishTestimony({ draftId }) + const res = await publishTestimonyv2({ draftId }) const bill = await getBill(billId) const published = await getPublication(uid, res.data.publicationId) @@ -181,7 +191,7 @@ describe("publishTestimony", () => { }) it("Archives testimony on publish", async () => { - const res = await publishTestimony({ draftId }) + const res = await publishTestimonyv2({ draftId }) const archived = await testDb .collection(`/users/${uid}/archivedTestimony`) @@ -194,7 +204,7 @@ describe("publishTestimony", () => { }) it("Updates existing testimony", async () => { - const res1 = await publishTestimony({ draftId }) + const res1 = await publishTestimonyv2({ draftId }) let bill = await getBill(billId) expect(bill.testimonyCount).toBe(1) @@ -207,7 +217,7 @@ describe("publishTestimony", () => { } await setDoc(refs.draftTestimony(uid, draftId), updatedDraft) - const res = await publishTestimony({ draftId }), + const res = await publishTestimonyv2({ draftId }), published = await getPublication(uid, res.data.publicationId), { publishedVersion: draftPublishedVersion } = await getDraft(uid, draftId) @@ -222,19 +232,22 @@ describe("publishTestimony", () => { }) it("Supports multiple users", async () => { - const res1 = await publishTestimony({ draftId }) + const res1 = await publishTestimonyv2({ draftId }) const { uid: uid2 } = await signInTestAdmin() await createDraft(uid2, billId) - const res2 = await publishTestimony({ draftId }) + const res2 = await publishTestimonyv2({ draftId }) let bill = await getBill(billId) expect(bill.testimonyCount).toBe(2) expect(bill.endorseCount).toBe(2) expect(bill.latestTestimonyId).toBe(res2.data.publicationId) - await deleteTestimony({ uid: uid2, publicationId: res2.data.publicationId }) + await deleteTestimonyv2({ + uid: uid2, + publicationId: res2.data.publicationId + }) await signOut(auth) @@ -281,7 +294,7 @@ describe("publishTestimony", () => { await createDraftAttachment(uid, attachmentId, "test-pdf") await updateDoc(refs.draftTestimony(uid, draftId), { attachmentId }) - const res = await publishTestimony({ draftId }), + const res = await publishTestimonyv2({ draftId }), { publication, attachments } = await getPublicationAndAttachments( uid, res.data.publicationId @@ -298,7 +311,7 @@ describe("publishTestimony", () => { let attachmentId = nanoid() await createDraftAttachment(uid, attachmentId, "test-pdf-1") await updateDoc(refs.draftTestimony(uid, draftId), { attachmentId }) - const r1 = await publishTestimony({ draftId }) + const r1 = await publishTestimonyv2({ draftId }) const p1 = await getPublication(uid, r1.data.publicationId) // Publish 2 @@ -309,7 +322,7 @@ describe("publishTestimony", () => { attachmentId, editReason: "changed attachment" }) - const r = await publishTestimony({ draftId }) + const r = await publishTestimonyv2({ draftId }) const { attachments } = await getPublicationAndAttachments( uid, @@ -329,14 +342,14 @@ describe("publishTestimony", () => { const expectedContent = "test-pdf-1" await createDraftAttachment(uid, attachmentId, expectedContent) await updateDoc(refs.draftTestimony(uid, draftId), { attachmentId }) - let r = await publishTestimony({ draftId }) + let r = await publishTestimonyv2({ draftId }) const publication = await getPublication(uid, r.data.publicationId) // Publish 2 await updateDoc(refs.draftTestimony(uid, draftId), { editReason: "changed" }) - r = await publishTestimony({ draftId }) + r = await publishTestimonyv2({ draftId }) const { attachments, publication: publication2 } = await getPublicationAndAttachments(uid, r.data.publicationId) @@ -351,7 +364,7 @@ describe("publishTestimony", () => { const expectedContent = "test-pdf-1" await createDraftAttachment(uid, attachmentId, expectedContent) await updateDoc(refs.draftTestimony(uid, draftId), { attachmentId }) - let r = await publishTestimony({ draftId }) + let r = await publishTestimonyv2({ draftId }) const publication = await getPublication(uid, r.data.publicationId) // Publish 2 @@ -359,7 +372,7 @@ describe("publishTestimony", () => { attachmentId: null, editReason: "removed attachment" }) - r = await publishTestimony({ draftId }) + r = await publishTestimonyv2({ draftId }) const { attachments, publication: publication2 } = await getPublicationAndAttachments(uid, r.data.publicationId) @@ -383,11 +396,11 @@ describe("deleteTestimony", () => { const normalUid = uid // Publish as user 1 - let res = await publishTestimony({ draftId }) + let res = await publishTestimonyv2({ draftId }) // Delete as admin await getSignedInAdmin() - const deleted = await deleteTestimony({ + const deleted = await deleteTestimonyv2({ uid: normalUid, publicationId: res.data.publicationId }) @@ -410,11 +423,11 @@ describe("deleteTestimony", () => { it("Retains archives", async () => { // Publish as user 1 - const res1 = await publishTestimony({ draftId }) + const res1 = await publishTestimonyv2({ draftId }) await getSignedInAdmin() // Delete as admin - await deleteTestimony({ uid, publicationId: res1.data.publicationId }) + await deleteTestimonyv2({ uid, publicationId: res1.data.publicationId }) // Publish again as user 1 await signInUser1() @@ -424,7 +437,7 @@ describe("deleteTestimony", () => { editReason: "edit reason" } await setDoc(refs.draftTestimony(uid, draftId), updatedDraft) - const res2 = await publishTestimony({ draftId }), + const res2 = await publishTestimonyv2({ draftId }), published = await getPublication(uid, res2.data.publicationId) expect(published.version).toBe(2) @@ -441,12 +454,12 @@ describe("deleteTestimony", () => { let attachmentId = nanoid() await createDraftAttachment(uid, attachmentId, "test-pdf-1") await updateDoc(refs.draftTestimony(uid, draftId), { attachmentId }) - const r = await publishTestimony({ draftId }) + const r = await publishTestimonyv2({ draftId }) const p = await getPublication(uid, r.data.publicationId) // Delete as admin await getSignedInAdmin() - await deleteTestimony({ uid, publicationId: r.data.publicationId }) + await deleteTestimonyv2({ uid, publicationId: r.data.publicationId }) expect(p.attachmentId).toBeDefined() await expect( diff --git a/tests/seed/seedTestimony.test.ts b/tests/seed/seedTestimony.test.ts index 2089aa06a..e27afcc02 100644 --- a/tests/seed/seedTestimony.test.ts +++ b/tests/seed/seedTestimony.test.ts @@ -1,5 +1,5 @@ import { testDb } from "../testUtils" -import { DraftTestimony, publishTestimony } from "../../components/db" +import { DraftTestimony, publishTestimonyv2 } from "../../components/db" import { currentGeneralCourt } from "functions/src/shared" import { loremIpsum } from "lorem-ipsum" import { signInUser3, signInUser4 } from "../integration/common" @@ -55,7 +55,7 @@ async function seedUserTestimony( if (type === "draft") return - await publishTestimony({ draftId: draftRef.id }) + await publishTestimonyv2({ draftId: draftRef.id }) if (type === "both") return diff --git a/tests/system/testimony.test.ts b/tests/system/testimony.test.ts index 92387cf7c..835bdaf98 100644 --- a/tests/system/testimony.test.ts +++ b/tests/system/testimony.test.ts @@ -17,11 +17,21 @@ const publishTestimony = httpsCallable< { publicationId: string } >(functions, "publishTestimony") +const publishTestimonyv2 = httpsCallable< + { draftId: string }, + { publicationId: string } +>(functions, "publishTestimonyv2") + const deleteTestimony = httpsCallable< { publicationId: string }, { deleted: boolean } >(functions, "deleteTestimony") +const deleteTestimonyv2 = httpsCallable< + { publicationId: string }, + { deleted: boolean } +>(functions, "deleteTestimonyv2") + jest.setTimeout(30000) let uid: string @@ -79,7 +89,7 @@ async function expectCreateDraft(draft?: any) { } async function expectPublish(draft: any, draftRef: DocumentReference) { - const result = await publishTestimony({ draftId: draftRef.id }) + const result = await publishTestimonyv2({ draftId: draftRef.id }) const publication = await getDoc( doc( @@ -95,7 +105,7 @@ async function expectPublish(draft: any, draftRef: DocumentReference) { } async function expectDelete(publicationId: string) { - const result = await deleteTestimony({ publicationId: publicationId }) + const result = await deleteTestimonyv2({ publicationId: publicationId }) const deletedDoc = await getDoc( doc(firestore, `/users/${uid}/publishedTestimony/${publicationId}`) )