From 5594e73ca38221d40fd1d946cf8a4765ad7dfab6 Mon Sep 17 00:00:00 2001 From: Kimin Kim <55262612+kiminkim724@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:32:22 -0500 Subject: [PATCH 01/14] Trigger Typesense Schema Update Function on Deploy (#2044) * Add Typesense schema update to each github deploy and adding more logs * Add more comments --- .github/workflows/deploy-backend-dev.yml | 9 +++++++++ .github/workflows/deploy-prod.yml | 9 +++++++++ functions/src/search/SearchIndexer.ts | 2 ++ functions/src/search/checkSearchIndexVersion.ts | 2 +- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-backend-dev.yml b/.github/workflows/deploy-backend-dev.yml index 22cb95f73..1131c1f63 100644 --- a/.github/workflows/deploy-backend-dev.yml +++ b/.github/workflows/deploy-backend-dev.yml @@ -29,3 +29,12 @@ jobs: GCP_SA_KEY: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} ASSEMBLY_API_KEY: ${{ secrets.ASSEMBLY_API_KEY }} PROJECT_ID: digital-testimony-dev + + # Update Typesense Schema + - uses: google-github-actions/auth@v3 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + - uses: google-github-actions/setup-gcloud@v3 + - name: Update Typesense Schema + run: | + gcloud pubsub topics publish --project=digital-testimony-dev checkSearchIndexVersion --message='{"check": true}' diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 8c68eead9..a4a79defd 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -23,3 +23,12 @@ jobs: GCP_SA_KEY: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} ASSEMBLY_API_KEY: ${{ secrets.ASSEMBLY_API_KEY }} PROJECT_ID: digital-testimony-prod + + # Update Typesense Schema + - uses: google-github-actions/auth@v3 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + - uses: google-github-actions/setup-gcloud@v3 + - name: Update Typesense Schema + run: | + gcloud pubsub topics publish --project=digital-testimony-prod checkSearchIndexVersion --message='{"check": true}' diff --git a/functions/src/search/SearchIndexer.ts b/functions/src/search/SearchIndexer.ts index 3e655c69d..bc409d239 100644 --- a/functions/src/search/SearchIndexer.ts +++ b/functions/src/search/SearchIndexer.ts @@ -48,7 +48,9 @@ export class SearchIndexer { const { alias } = this.config const isCollectionUpToDate = this.collectionName === (await this.getCurrentCollectionName()) + console.log(`Index for alias ${alias} up to date: ${isCollectionUpToDate}`) if (!isCollectionUpToDate) { + console.log(`Scheduling upgrade for alias ${alias}`) const upgradeDoc = db.doc(SearchIndexer.upgradePath(alias)) await upgradeDoc.delete() await upgradeDoc.create({ diff --git a/functions/src/search/checkSearchIndexVersion.ts b/functions/src/search/checkSearchIndexVersion.ts index 8a06e767f..8af4bf0db 100644 --- a/functions/src/search/checkSearchIndexVersion.ts +++ b/functions/src/search/checkSearchIndexVersion.ts @@ -2,7 +2,7 @@ import { runWith } from "firebase-functions" import { getRegisteredConfigs } from "./config" import { SearchIndexer } from "./SearchIndexer" -/** Schedules index upgrades for each config if necessary. Requires a message +/** Schedules index upgrades for each config/alias(bills/hearing/testimony) if necessary. Requires a message * wtih content `{ "check": true}` */ export const checkSearchIndexVersion = runWith({ secrets: ["TYPESENSE_API_KEY"] From deb2642286ab11facd57d65f2cbd1abc9b99ca61 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:59:01 -0500 Subject: [PATCH 02/14] Timestamp Urls (#2034) * basic framework to add timestamps to url * cleanup * copy to clipboard url button * handle page load * remove unneeded code * set aside common function * fix(transcripts): Make the auto-scroll work when first loading a Hearing page at a specific timestamp, some de-duping * cr2 * show share button only on active elemet * material ui share icon * cleanup --------- Co-authored-by: Mephistic --- components/buttons.tsx | 42 +++++ components/hearing/HearingDetails.tsx | 26 ++- components/hearing/Transcriptions.tsx | 129 ++++++++++++--- components/hearing/hearing.ts | 16 ++ package.json | 4 + pages/hearing/[hearingId].tsx | 1 - yarn.lock | 221 ++++++++++++++++++++++++++ 7 files changed, 411 insertions(+), 28 deletions(-) diff --git a/components/buttons.tsx b/components/buttons.tsx index 9758b45f0..723914368 100644 --- a/components/buttons.tsx +++ b/components/buttons.tsx @@ -341,6 +341,48 @@ export const CopyButton = ({ ) } +export const ShareLinkButton = ({ + text, + tooltipDurationMs = 1000, + children, + format = "text/html", + ...props +}: ButtonProps & { + text: string + tooltipDurationMs?: number + format?: string +}) => { + const { t } = useTranslation("common") + const [show, setShow] = useState(false) + const target = useRef(null) + const closeTimeout = useRef() + return ( + <> + { + if (success) { + clearTimeout(closeTimeout.current) + setShow(true) + closeTimeout.current = setTimeout( + () => setShow(false), + tooltipDurationMs + ) + } + }} + > + + + + {props => {t("copiedToClipboard")}} + + + ) +} + export const GearIcon = (
{ const { t } = useTranslation(["common", "hearing"]) - const [transcriptData, setTranscriptData] = useState(null) + const router = useRouter() + const [transcriptData, setTranscriptData] = useState(null) const [videoLoaded, setVideoLoaded] = useState(false) + const handleVideoLoad = () => { setVideoLoaded(true) } @@ -63,6 +69,15 @@ export const HearingDetails = ({ videoRef.current ? (videoRef.current.currentTime = value) : null } + useEffect(() => { + const startTime = router.query.t + const resultString: string = convertToString(startTime) + + if (startTime && videoRef.current) { + setCurTimeVideo(parseInt(resultString, 10)) + } + }, [router.query.t, videoRef.current]) + useEffect(() => { ;(async function () { if (!videoTranscriptionId || transcriptData !== null) return @@ -169,6 +184,7 @@ export const HearingDetails = ({ {transcriptData ? ( ([]) + const [initialScrollTarget, setInitialScrollTarget] = useState( + null + ) + const hasScrolledToInitial = useRef(false) const handleClearInput = () => { setSearchTerm("") } + // Shared function to scroll to a transcript index + const scrollToTranscript = (index: number) => { + const container = containerRef.current + const elem = transcriptRefs.current.get(index) + + if (elem && container) { + const elemTop = elem.offsetTop - container.offsetTop + const elemBottom = elemTop + elem.offsetHeight + const viewTop = container.scrollTop + const viewBottom = viewTop + container.clientHeight + + if (elemTop < viewTop) { + container.scrollTo({ + top: elemTop, + behavior: "smooth" + }) + } else if (elemBottom > viewBottom) { + container.scrollTo({ + top: elemBottom - container.clientHeight, + behavior: "smooth" + }) + } + } + } + useEffect(() => { setFilteredData( transcriptData.filter(el => @@ -145,32 +185,51 @@ export const Transcriptions = ({ ) }, [transcriptData, searchTerm]) + const router = useRouter() + const startTime = router.query.t + const resultString: string = convertToString(startTime) + + let currentIndex = transcriptData.findIndex( + element => parseInt(resultString, 10) <= element.end / 1000 + ) + + // Set the initial scroll target when we have a startTime and transcripts + useEffect(() => { + if ( + startTime && + transcriptData.length > 0 && + currentIndex !== -1 && + !hasScrolledToInitial.current + ) { + setInitialScrollTarget(currentIndex) + } + }, [startTime, transcriptData, currentIndex]) + + // Scroll to the initial target when the ref becomes available + useEffect(() => { + if (initialScrollTarget !== null && !searchTerm) { + const elem = transcriptRefs.current.get(initialScrollTarget) + + if (elem) { + setHighlightedId(initialScrollTarget) + scrollToTranscript(initialScrollTarget) + hasScrolledToInitial.current = true + setInitialScrollTarget(null) + } + } + }, [initialScrollTarget, transcriptRefs.current.size, searchTerm]) + useEffect(() => { const handleTimeUpdate = () => { - const currentIndex = transcriptData.findIndex( - element => videoRef.current.currentTime <= element.end / 1000 - ) + videoLoaded + ? (currentIndex = transcriptData.findIndex( + element => videoRef.current.currentTime <= element.end / 1000 + )) + : null if (containerRef.current && currentIndex !== highlightedId) { setHighlightedId(currentIndex) if (currentIndex !== -1 && !searchTerm) { - const container = containerRef.current - const elem = transcriptRefs.current.get(currentIndex) - const elemTop = elem.offsetTop - container.offsetTop - const elemBottom = elemTop + elem.offsetHeight - const viewTop = container.scrollTop - const viewBottom = viewTop + container.clientHeight - - if (elemTop < viewTop) { - container.scrollTo({ - top: elemTop, - behavior: "smooth" - }) - } else if (elemBottom > viewBottom) { - container.scrollTo({ - top: elemBottom - container.clientHeight, - behavior: "smooth" - }) - } + scrollToTranscript(currentIndex) } } } @@ -217,6 +276,7 @@ export const Transcriptions = ({ { @@ -249,12 +309,14 @@ export const Transcriptions = ({ const TranscriptItem = forwardRef(function TranscriptItem( { element, + hearingId, highlightedId, index, setCurTimeVideo, searchTerm }: { element: Paragraph + hearingId: string highlightedId: number index: number setCurTimeVideo: any @@ -275,6 +337,7 @@ const TranscriptItem = forwardRef(function TranscriptItem( const isHighlighted = (index: number): boolean => { return index === highlightedId } + const highlightText = (text: string, term: string) => { if (!term) return text const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") @@ -290,6 +353,8 @@ const TranscriptItem = forwardRef(function TranscriptItem( ) } + const [isHovered, setIsHovered] = useState(false) + return ( {highlightText(element.text, searchTerm)} + + {isHighlighted(index) ? ( + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {isHovered ? : } + + + ) : ( + <> + )} + ) }) diff --git a/components/hearing/hearing.ts b/components/hearing/hearing.ts index 4ba2b3439..c87bba25c 100644 --- a/components/hearing/hearing.ts +++ b/components/hearing/hearing.ts @@ -29,6 +29,15 @@ export type Paragraph = { text: string } +export const convertToString = ( + value: string | string[] | undefined +): string => { + if (Array.isArray(value)) { + return value.join(", ") + } + return value ?? "" +} + export async function fetchHearingData( hearingId: string ): Promise { @@ -98,6 +107,13 @@ export function formatMilliseconds(ms: number): string { } } +export function formatTotalSeconds(ms: number): string { + const totalSeconds = Math.floor(ms / 1000) + const formattedSeconds = String(totalSeconds) + + return `${formattedSeconds}` +} + export function formatVTTTimestamp(ms: number): string { const totalSeconds = Math.floor(ms / 1000) const milliseconds = ms % 1000 diff --git a/package.json b/package.json index b790ae255..ce8a00874 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,14 @@ ] }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@emotion/weak-memoize": "^0.3.1", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "^0.2.0", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", "@popperjs/core": "^2.11.8", "@react-aria/ssr": "^3.2.0", "@react-aria/utils": "^3.13.1", diff --git a/pages/hearing/[hearingId].tsx b/pages/hearing/[hearingId].tsx index 73a0ad6b7..84263d4e1 100644 --- a/pages/hearing/[hearingId].tsx +++ b/pages/hearing/[hearingId].tsx @@ -1,5 +1,4 @@ import { GetServerSideProps } from "next" -import { useRouter } from "next/router" import { serverSideTranslations } from "next-i18next/serverSideTranslations" import { z } from "zod" import { flags } from "components/featureFlags" diff --git a/yarn.lock b/yarn.lock index 2daf3791d..6199b1de7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1085,6 +1085,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.28.4": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" + integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== + "@babel/template@^7.22.15", "@babel/template@^7.27.2", "@babel/template@^7.3.3": version "7.27.2" resolved "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz" @@ -1185,6 +1190,23 @@ source-map "^0.5.7" stylis "4.2.0" +"@emotion/babel-plugin@^11.13.5": + version "11.13.5" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" + integrity sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/serialize" "^1.3.3" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.2.0" + "@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0": version "11.11.0" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz" @@ -1196,11 +1218,27 @@ "@emotion/weak-memoize" "^0.3.1" stylis "4.2.0" +"@emotion/cache@^11.14.0": + version "11.14.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.14.0.tgz#ee44b26986eeb93c8be82bb92f1f7a9b21b2ed76" + integrity sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA== + dependencies: + "@emotion/memoize" "^0.9.0" + "@emotion/sheet" "^1.4.0" + "@emotion/utils" "^1.4.2" + "@emotion/weak-memoize" "^0.4.0" + stylis "4.2.0" + "@emotion/hash@^0.9.1": version "0.9.1" resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz" integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ== +"@emotion/hash@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b" + integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== + "@emotion/is-prop-valid@^1.1.0", "@emotion/is-prop-valid@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz" @@ -1208,11 +1246,37 @@ dependencies: "@emotion/memoize" "^0.8.1" +"@emotion/is-prop-valid@^1.3.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz#e9ad47adff0b5c94c72db3669ce46de33edf28c0" + integrity sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw== + dependencies: + "@emotion/memoize" "^0.9.0" + "@emotion/memoize@^0.8.1": version "0.8.1" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz" integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== +"@emotion/memoize@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" + integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== + +"@emotion/react@^11.14.0": + version "11.14.0" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.14.0.tgz#cfaae35ebc67dd9ef4ea2e9acc6cd29e157dd05d" + integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.13.5" + "@emotion/cache" "^11.14.0" + "@emotion/serialize" "^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.2.0" + "@emotion/utils" "^1.4.2" + "@emotion/weak-memoize" "^0.4.0" + hoist-non-react-statics "^3.3.1" + "@emotion/react@^11.4.1", "@emotion/react@^11.8.1": version "11.11.1" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz" @@ -1238,11 +1302,39 @@ "@emotion/utils" "^1.2.1" csstype "^3.0.2" +"@emotion/serialize@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.3.3.tgz#d291531005f17d704d0463a032fe679f376509e8" + integrity sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA== + dependencies: + "@emotion/hash" "^0.9.2" + "@emotion/memoize" "^0.9.0" + "@emotion/unitless" "^0.10.0" + "@emotion/utils" "^1.4.2" + csstype "^3.0.2" + "@emotion/sheet@^1.2.2": version "1.2.2" resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz" integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== +"@emotion/sheet@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.4.0.tgz#c9299c34d248bc26e82563735f78953d2efca83c" + integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== + +"@emotion/styled@^11.14.1": + version "11.14.1" + resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.14.1.tgz#8c34bed2948e83e1980370305614c20955aacd1c" + integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.13.5" + "@emotion/is-prop-valid" "^1.3.0" + "@emotion/serialize" "^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks" "^1.2.0" + "@emotion/utils" "^1.4.2" + "@emotion/styled@^11.3.0": version "11.11.0" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz" @@ -1260,6 +1352,11 @@ resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz" integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== +"@emotion/unitless@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.10.0.tgz#2af2f7c7e5150f497bdabd848ce7b218a27cf745" + integrity sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg== + "@emotion/unitless@^0.7.4": version "0.7.5" resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" @@ -1275,16 +1372,31 @@ resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz" integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw== +"@emotion/use-insertion-effect-with-fallbacks@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz#8a8cb77b590e09affb960f4ff1e9a89e532738bf" + integrity sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg== + "@emotion/utils@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz" integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== +"@emotion/utils@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.4.2.tgz#6df6c45881fcb1c412d6688a311a98b7f59c1b52" + integrity sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA== + "@emotion/weak-memoize@^0.3.1": version "0.3.1" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz" integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== +"@emotion/weak-memoize@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" + integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -2443,6 +2555,11 @@ resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.20.tgz" integrity sha512-fXoGe8VOrIYajqALysFuyal1q1YmBARqJ3tmnWYDVl0scu8f6h6tZQbS2K8BY28QwkWNGyv4WRfuUkzN5HR3Ow== +"@mui/core-downloads-tracker@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz#99d9c60be3ce5632ec915b2c287682020ce19a99" + integrity sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg== + "@mui/icons-material@^5.0.1": version "5.14.19" resolved "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.14.19.tgz" @@ -2450,6 +2567,13 @@ dependencies: "@babel/runtime" "^7.23.4" +"@mui/icons-material@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.3.7.tgz#01a6019c552e27c7f8a3451bcb47171ede8b34ac" + integrity sha512-3Q+ulAqG+A1+R4ebgoIs7AccaJhIGy+Xi/9OnvX376jQ6wcy+rz4geDGrxQxCGzdjOQr4Z3NgyFSZCz4T999lA== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/material@^5.0.2": version "5.14.20" resolved "https://registry.npmjs.org/@mui/material/-/material-5.14.20.tgz" @@ -2468,6 +2592,24 @@ react-is "^18.2.0" react-transition-group "^4.4.5" +"@mui/material@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.7.tgz#50fc9b9f8645a4d26a48d7c5f7fa0c9876a8c679" + integrity sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/core-downloads-tracker" "^7.3.7" + "@mui/system" "^7.3.7" + "@mui/types" "^7.4.10" + "@mui/utils" "^7.3.7" + "@popperjs/core" "^2.11.8" + "@types/react-transition-group" "^4.4.12" + clsx "^2.1.1" + csstype "^3.2.3" + prop-types "^15.8.1" + react-is "^19.2.3" + react-transition-group "^4.4.5" + "@mui/private-theming@^5.14.20": version "5.14.20" resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.20.tgz" @@ -2477,6 +2619,15 @@ "@mui/utils" "^5.14.20" prop-types "^15.8.1" +"@mui/private-theming@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-7.3.7.tgz#f5b41d573df3824fbfd10a7e6ac8de94bbcf15c5" + integrity sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/utils" "^7.3.7" + prop-types "^15.8.1" + "@mui/styled-engine@^5.14.19": version "5.14.20" resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.20.tgz" @@ -2487,6 +2638,18 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/styled-engine@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-7.3.7.tgz#cde5a8381e14310f293a53dd59d27ae737a305fc" + integrity sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg== + dependencies: + "@babel/runtime" "^7.28.4" + "@emotion/cache" "^11.14.0" + "@emotion/serialize" "^1.3.3" + "@emotion/sheet" "^1.4.0" + csstype "^3.2.3" + prop-types "^15.8.1" + "@mui/system@^5.14.20": version "5.14.20" resolved "https://registry.npmjs.org/@mui/system/-/system-5.14.20.tgz" @@ -2501,11 +2664,32 @@ csstype "^3.1.2" prop-types "^15.8.1" +"@mui/system@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-7.3.7.tgz#530932e078ba58031cd9bcc71494a544fa635a27" + integrity sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/private-theming" "^7.3.7" + "@mui/styled-engine" "^7.3.7" + "@mui/types" "^7.4.10" + "@mui/utils" "^7.3.7" + clsx "^2.1.1" + csstype "^3.2.3" + prop-types "^15.8.1" + "@mui/types@^7.2.10": version "7.2.10" resolved "https://registry.npmjs.org/@mui/types/-/types-7.2.10.tgz" integrity sha512-wX1vbDC+lzF7FlhT6A3ffRZgEoKWPF8VqRoTu4lZwouFX2t90KyCMsgepMw5DxLak1BSp/KP86CmtZttikb/gQ== +"@mui/types@^7.4.10": + version "7.4.10" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.10.tgz#c80ed5850a1da7802a01c1d0153d8603ce41be10" + integrity sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/utils@^5.14.20": version "5.14.20" resolved "https://registry.npmjs.org/@mui/utils/-/utils-5.14.20.tgz" @@ -2516,6 +2700,18 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^7.3.7": + version "7.3.7" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.7.tgz#71443559a7fbd993b5b90fcb843fa26a60046f99" + integrity sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/types" "^7.4.10" + "@types/prop-types" "^15.7.15" + clsx "^2.1.1" + prop-types "^15.8.1" + react-is "^19.2.3" + "@ndelangen/get-tarball@^3.0.7": version "3.0.9" resolved "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz" @@ -4565,6 +4761,11 @@ resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz" integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== +"@types/prop-types@^15.7.15": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + "@types/qs@*", "@types/qs@^6.5.3", "@types/qs@^6.9.5": version "6.9.10" resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz" @@ -4596,6 +4797,11 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.12": + version "4.4.12" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" + integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== + "@types/react@*", "@types/react@>=16", "@types/react@>=16.9.11", "@types/react@^18.2.43": version "18.2.43" resolved "https://registry.npmjs.org/@types/react/-/react-18.2.43.tgz" @@ -6586,6 +6792,11 @@ clsx@^2.0.0: resolved "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz" integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz" @@ -7094,6 +7305,11 @@ csstype@^3.0.2, csstype@^3.1.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +csstype@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + csv-parse@^5.0.4: version "5.5.3" resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.3.tgz" @@ -14933,6 +15149,11 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +react-is@^19.2.3: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135" + integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== + react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz" From 6e580b8a3f73902deb61130c83c173fa25c59668 Mon Sep 17 00:00:00 2001 From: Andre Coullard <119697079+ACoullard@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:27:03 -0500 Subject: [PATCH 03/14] (#1875) add lifecycle json file for TTL on hearings mp4 files (#2040) * add lifecycle json * formatting fix from prettier --- infra/gcs-lifecycle/lifecycle.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 infra/gcs-lifecycle/lifecycle.json diff --git a/infra/gcs-lifecycle/lifecycle.json b/infra/gcs-lifecycle/lifecycle.json new file mode 100644 index 000000000..327e7ca24 --- /dev/null +++ b/infra/gcs-lifecycle/lifecycle.json @@ -0,0 +1,8 @@ +{ + "rule": [ + { + "action": { "type": "Delete" }, + "condition": { "age": 1, "matchesPrefix": ["hearing-"] } + } + ] +} From 914f8f067fe73a2a9c9e294932df406ca39eb3ee Mon Sep 17 00:00:00 2001 From: Astor Meredith-Goujon <42044772+AstorDG@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:07:38 -0500 Subject: [PATCH 04/14] Scrape Single function to firebase v2 (#2042) Implemented a v2 version of the scrape single hearing function. Changed the frontend to call the v2 version. Updated the check auth and check admin functions to be able to take a request object for both v1 and v2 firebase functino --- components/moderation/ScrapeHearing.tsx | 7 +++- functions/src/common.ts | 5 ++- functions/src/events/index.ts | 1 + functions/src/events/scrapeEvents.ts | 51 ++++++++++++++++++++++++- functions/src/index.ts | 3 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/components/moderation/ScrapeHearing.tsx b/components/moderation/ScrapeHearing.tsx index bfb6c648a..ab97503f4 100644 --- a/components/moderation/ScrapeHearing.tsx +++ b/components/moderation/ScrapeHearing.tsx @@ -16,6 +16,11 @@ const scrapeSingleHearing = httpsCallable< ScrapeHearingResponse >(functions, "scrapeSingleHearing") +const scrapeSingleHearingv2 = httpsCallable< + ScrapeHearingRequest, + ScrapeHearingResponse +>(functions, "scrapeSingleHearingv2") + export const ScrapeHearingForm = () => { const [eventId, setEventId] = useState("") const [loading, setLoading] = useState(false) @@ -39,7 +44,7 @@ export const ScrapeHearingForm = () => { setLoading(true) try { - const response = await scrapeSingleHearing({ eventId: parsedEventId }) + const response = await scrapeSingleHearingv2({ eventId: parsedEventId }) setResult({ type: "success", message: `${response.data.message} (ID: ${response.data.hearingId})` diff --git a/functions/src/common.ts b/functions/src/common.ts index aed0ab6d5..7838ada3f 100644 --- a/functions/src/common.ts +++ b/functions/src/common.ts @@ -1,6 +1,7 @@ import { FieldValue } from "@google-cloud/firestore" import axios from "axios" import { https, logger } from "firebase-functions" +import { CallableRequest } from "firebase-functions/v2/https" import { Null, Nullish, @@ -38,7 +39,7 @@ export function checkRequestZod( /** Return the authenticated user's id or fail if they are not authenticated. */ export function checkAuth( - context: https.CallableContext, + context: https.CallableContext | CallableRequest, checkEmailVerification = false ) { const uid = context.auth?.uid @@ -61,7 +62,7 @@ export function checkAuth( /** * Checks that the caller is an admin. */ -export function checkAdmin(context: https.CallableContext) { +export function checkAdmin(context: https.CallableContext | CallableRequest) { const callerRole = context.auth?.token.role if (callerRole !== "admin") { throw fail("permission-denied", "You must be an admin") diff --git a/functions/src/events/index.ts b/functions/src/events/index.ts index 9a7a84fad..96ff5307d 100644 --- a/functions/src/events/index.ts +++ b/functions/src/events/index.ts @@ -1,2 +1,3 @@ export * from "./scrapeEvents" export { scrapeSingleHearing } from "./scrapeEvents" +export { scrapeSingleHearingv2 } from "./scrapeEvents" diff --git a/functions/src/events/scrapeEvents.ts b/functions/src/events/scrapeEvents.ts index 38a1da7ba..4c989f16c 100644 --- a/functions/src/events/scrapeEvents.ts +++ b/functions/src/events/scrapeEvents.ts @@ -1,5 +1,6 @@ -import * as functions from "firebase-functions" -import { RuntimeOptions, runWith } from "firebase-functions" +import * as functions from "firebase-functions/v1" +import { RuntimeOptions, runWith } from "firebase-functions/v1" +import { onCall, CallableRequest } from "firebase-functions/v2/https" import { DateTime } from "luxon" import { JSDOM } from "jsdom" import { AssemblyAI } from "assemblyai" @@ -476,6 +477,52 @@ export const scrapeSingleHearing = functions } }) +export const scrapeSingleHearingv2 = onCall( + { timeoutSeconds: 480, memory: "4GiB", secrets: ["ASSEMBLY_API_KEY"] }, + async (request: CallableRequest) => { + // Require admin authentication + // Check how to integrate the new object with these helper functions + checkAuth(request, false) + checkAdmin(request) + + const { eventId } = request.data + + if (!eventId || typeof eventId !== "number") { + throw new functions.https.HttpsError( + "invalid-argument", + "The function must be called with a valid eventId (number)." + ) + } + + try { + // Create a temporary scraper instance to reuse the existing logic + const scraper = new HearingScraper() + const hearing = await scraper.getEvent( + { EventId: eventId }, + { ignoreCutoff: true } + ) + + // Save the hearing to Firestore + await db.doc(`/events/${hearing.id}`).set(hearing, { merge: true }) + + console.log(`Successfully scraped hearing ${eventId}`, hearing) + + return { + status: "success", + message: `Successfully scraped hearing ${eventId}`, + hearingId: hearing.id + } + } catch (error: any) { + console.error(`Failed to scrape hearing ${eventId}:`, error) + throw new functions.https.HttpsError( + "internal", + `Failed to scrape hearing ${eventId}`, + { details: error.message } + ) + } + } +) + export const scrapeSpecialEvents = new SpecialEventsScraper().function export const scrapeSessions = new SessionScraper().function export const scrapeHearings = new HearingScraper().function diff --git a/functions/src/index.ts b/functions/src/index.ts index 51343963d..d3effd4be 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -19,7 +19,8 @@ export { scrapeHearings, scrapeSessions, scrapeSpecialEvents, - scrapeSingleHearing + scrapeSingleHearing, + scrapeSingleHearingv2 } from "./events" export { syncHearingToSearchIndex, From 1663ad62212d1540de8a9435ae304d3babb77259 Mon Sep 17 00:00:00 2001 From: Andre Coullard <119697079+ACoullard@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:09:50 -0500 Subject: [PATCH 05/14] remove backfill containers and their references (#2048) --- infra/docker-compose.yml | 20 ------- package.json | 2 - .../backfillNotificationFrequency.ts | 35 ------------ scripts/firebase-admin/backfillUserEmails.ts | 53 ------------------- 4 files changed, 110 deletions(-) delete mode 100644 scripts/firebase-admin/backfillNotificationFrequency.ts delete mode 100644 scripts/firebase-admin/backfillUserEmails.ts diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 85b1e21cd..7b8a46ab2 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -130,25 +130,5 @@ services: volumes: - ..:/app - backfill-user-emails: - build: - context: .. - dockerfile: infra/Dockerfile.firebase - command: yarn backfill-user-emails - depends_on: - - firebase - volumes: - - ..:/app - - backfill-user-nf: - build: - context: .. - dockerfile: infra/Dockerfile.firebase - command: yarn backfill-user-nf - depends_on: - - firebase - volumes: - - ..:/app - volumes: search: {} diff --git a/package.json b/package.json index ce8a00874..808a44e28 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,6 @@ "build-storybook": "storybook build", "chromatic": "chromatic --project-token=f8618e690599", "copy-handlebars": "echo 'Copying handlebars files to /lib/email/...' && ncp functions/src/email/ functions/lib/email/ && echo '...done!'", - "backfill-user-emails": "ts-node -P tsconfig.script.json scripts/firebase-admin//backfillUserEmails.ts --swc", - "backfill-user-nf": "ts-node -P tsconfig.script.json scripts/firebase-admin/backfillNotificationFrequency.ts --swc", "setRole": "ts-node -P tsconfig.script.json scripts/firebase-admin/setRole.ts" }, "engines": { diff --git a/scripts/firebase-admin/backfillNotificationFrequency.ts b/scripts/firebase-admin/backfillNotificationFrequency.ts deleted file mode 100644 index 2e5c08f58..000000000 --- a/scripts/firebase-admin/backfillNotificationFrequency.ts +++ /dev/null @@ -1,35 +0,0 @@ -// DEPRECATED - This will no longer be useful after we've moved notificationFrequency to profiles -// This should no longer be run and will be removed in a future update - -import { Script } from "./types" -import { listAllUsers } from "./list-all-users" - -export const script: Script = async ({ db, auth }) => { - const allUsers = await listAllUsers(auth) - const usersById = new Map(allUsers.map(u => [u.uid, u])) - - console.log(`Configuring ${allUsers.length} users notification frequency`) - - for (const user of allUsers) { - // Get user document from Firestore - const userDoc = db.collection("profiles").doc(user.uid) - const doc = await userDoc.get() - - // If the user document exists in Firestore - if (doc.exists) { - const userData = doc.data() - - // If userData is not undefined and notificationFrequency is not set already - if (userData && !userData.notificationFrequency) { - // Remove second condition to run on all users, not just those without a notificationFrequency field set - // Update the user document in Firestore - await userDoc.update({ - notificationFrequency: "None" - }) - console.log(`Updated user ${user.uid} notification frequency to None.`) - } - } else { - console.log(`No document for user ${user.uid}`) - } - } -} diff --git a/scripts/firebase-admin/backfillUserEmails.ts b/scripts/firebase-admin/backfillUserEmails.ts deleted file mode 100644 index d3dc7c3e4..000000000 --- a/scripts/firebase-admin/backfillUserEmails.ts +++ /dev/null @@ -1,53 +0,0 @@ -// DEPRECATED - This will no longer be useful going forward -// We plan to rely on the `profiles` collection for casual email use -// and the firebase-auth API where we need verified email addresses -// This should no longer be run and will be removed in a future update - -import { UserRecord } from "firebase-admin/auth" -import { Auth } from "functions/src/types" -import { Script } from "./types" -import { listAllUsers } from "./list-all-users" - -/** Backfill the email field on user documents in Firestore. - * If the email does not exist, set it. - */ -export const script: Script = async ({ db, auth }) => { - const allUsers = await listAllUsers(auth), - usersById = new Map(allUsers.map(u => [u.uid, u])) - - console.log(`Configuring ${allUsers.length} users emails`) - - const writer = db.bulkWriter() - - for (const user of allUsers) { - try { - // Get the user's email - const existingEmail = user.email - - // Check if the email exists, if not, throw an error - if (!existingEmail) { - throw new Error(`User ${user.uid} does not have an email.`) - } - - // Get the user's document - const userDoc = db.collection("users").doc(user.uid) - const docData = (await userDoc.get()).data() || {} - - // If the email is not set in the user's document, set it. - if (!docData.email) { - console.log(`Updating email for user ${user.uid}`) - writer.set( - userDoc, - { ...docData, email: existingEmail }, - { merge: true } - ) - } - } catch (error) { - // Log the error and continue with the next user - console.error(`Error updating email for user ${user.uid}: `, error) - } - } - - await writer.close() - console.log(`Completed updating emails`) -} From ae4f59093f91b798b3afaaff9c3af1ad1036dcd4 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:05:34 -0500 Subject: [PATCH 06/14] switch url from localhost to prod (#2046) * switch url from localhost to prod * modify links --- components/hearing/Transcriptions.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx index fd892aeb6..d9905195e 100644 --- a/components/hearing/Transcriptions.tsx +++ b/components/hearing/Transcriptions.tsx @@ -15,6 +15,8 @@ import { } from "./hearing" import { ShareLinkButton } from "components/buttons" +import { siteUrl } from "components/links" + const ClearButton = styled(FontAwesomeIcon)` position: absolute; right: 3rem; @@ -386,9 +388,9 @@ const TranscriptItem = forwardRef(function TranscriptItem( <> setIsHovered(true)} From 5745f2d06127f7cc37ca6de119122964694dde25 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:21:36 -0400 Subject: [PATCH 07/14] Misc Mobile Margin patching (#2056) * pretty email wrap * fix NuLawLab on small viewsizes --- components/AboutPagesCard/AboutPagesCard.tsx | 2 +- components/shared/CommonComponents.tsx | 5 ++++- package.json | 1 + yarn.lock | 5 +++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/components/AboutPagesCard/AboutPagesCard.tsx b/components/AboutPagesCard/AboutPagesCard.tsx index b74e00d16..6fbf18b41 100644 --- a/components/AboutPagesCard/AboutPagesCard.tsx +++ b/components/AboutPagesCard/AboutPagesCard.tsx @@ -25,7 +25,7 @@ const AboutPagesCard: FC> = ({ children }) => { return ( - + {name} {email ? ( - {email} + + {email} + ) : null} {descr} diff --git a/package.json b/package.json index 808a44e28..979b2bb0c 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "react-is": "^18.2.0", "react-markdown": "^8.0.4", "react-overlays": "^5.1.1", + "react-pretty-email-wrap": "^0.1.4", "react-query": "^3.39.3", "react-redux": "^8.0.2", "react-select": "^5.2.2", diff --git a/yarn.lock b/yarn.lock index 6199b1de7..8fd9eab6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15194,6 +15194,11 @@ react-overlays@^5.1.1: uncontrollable "^7.2.1" warning "^4.0.3" +react-pretty-email-wrap@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/react-pretty-email-wrap/-/react-pretty-email-wrap-0.1.4.tgz#de78418b6002e192f272114e1ce458bd17bd2ea1" + integrity sha512-J/INGlIDT6F9Mzn102IHw2WMdg7RfsoJ5EotlI00sTouJ4vP/GtayAttLgVFngVQX4Q0AQLgdqkhEUrdlRDajA== + react-query@^3.32.1, react-query@^3.39.3: version "3.39.3" resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz" From 659801df143786c80c40e377114f9ee75f6384ea Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:25:00 -0400 Subject: [PATCH 08/14] Transcription hover element (#2053) * active element styling * reset hovered onclick * align update button center * readd active element border --- components/hearing/Transcriptions.tsx | 118 ++++++++++++++++++-------- 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx index d9905195e..27d0280d3 100644 --- a/components/hearing/Transcriptions.tsx +++ b/components/hearing/Transcriptions.tsx @@ -1,5 +1,6 @@ import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import ArrowRightAlt from "@mui/icons-material/ArrowRightAlt" import ShareIcon from "@mui/icons-material/Share" import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined" import { useRouter } from "next/router" @@ -14,7 +15,6 @@ import { formatTotalSeconds } from "./hearing" import { ShareLinkButton } from "components/buttons" - import { siteUrl } from "components/links" const ClearButton = styled(FontAwesomeIcon)` @@ -124,8 +124,16 @@ const TranscriptRow = styled(Row)` border-bottom-left-radius: 0.75rem; border-bottom-right-radius: 0.75rem; } + &:hover { + background-color: #d9dfea; + border-color: #1a3185; + border-style: solid; + border-width: 5px; + } ` +const TranscriptRowActive = styled(Row)`` + export const Transcriptions = ({ hearingId, transcriptData, @@ -334,6 +342,7 @@ const TranscriptItem = forwardRef(function TranscriptItem( set currentTime property of
-

- {t("verifyAccountSection.verifyAccount")} -

+

{t("verifyAccountSection.verifyAccount")}

{sendEmailVerification.status === "success" ? ( @@ -35,7 +33,7 @@ export const VerifyAccountSection = ({ {sendEmailVerification.status !== "success" ? ( sendEmailVerification.execute(user)} From 21ba422123bff225afb3015981f46dcca6affe69 Mon Sep 17 00:00:00 2001 From: violet <158512193+fastfadingviolets@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:16:12 -0400 Subject: [PATCH 12/14] fix: add a missing await in tests/integration/common (#2074) --- tests/integration/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/common.ts b/tests/integration/common.ts index daf5955be..6ea279184 100644 --- a/tests/integration/common.ts +++ b/tests/integration/common.ts @@ -57,7 +57,7 @@ export async function createNewBill(props?: Partial) { expect(Bill.validate(bill).success).toBeTruthy() - testDb + await testDb .doc(`/generalCourts/${currentGeneralCourt}/bills/${billId}`) .create({ ...bill, From 1c2edbec325928a3eef8607521c3cab17ab8202f Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:07:42 -0400 Subject: [PATCH 13/14] Ballot Initiative Banner (#2076) * add ballot_init banner, hide sponsors * updated text --- components/bill/BillDetails.tsx | 20 ++++++++++++++++---- public/locales/en/common.json | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/components/bill/BillDetails.tsx b/components/bill/BillDetails.tsx index 7dc5f608a..6e74adbd9 100644 --- a/components/bill/BillDetails.tsx +++ b/components/bill/BillDetails.tsx @@ -22,15 +22,22 @@ export const BillDetails = ({ bill }: BillProps) => { const isPendingUpgrade = useAuth().claims?.role === "pendingUpgrade" const flags = useFlags() - const { user } = useAuth() + let isBallotMeasure = false + const curComm = bill?.currentCommittee?.id + + if (curComm == "SJ42") { + isBallotMeasure = true + } + return ( <> {isPendingUpgrade && } {!isCurrentCourt(bill.court) && ( {t("bill.old_session", { billCourt: bill.court })} )} + {isBallotMeasure && {t("bill.ballot_initiative")}} @@ -74,14 +81,19 @@ export const BillDetails = ({ bill }: BillProps) => { )} - + - + - + {isBallotMeasure ? ( + <> + ) : ( + + )} + {flags.lobbyingTable && ( diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4e50e2ec2..0fc4af4f0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -11,6 +11,7 @@ "bill": { "and_others_one": "and {{count}} other", "and_others_other": "and {{count}} others", + "ballot_initiative": "This bill corresponds to a ballot initiative. The bill is currently before the Legislature, which may choose to enact it directly instead of sending it to voters.", "bill_tracker": "Bill Tracker", "bill_cosponsors": "{{billId}} Cosponsors", "branch": "Branch", From ccb623ead119cfa87a3ed397e1ccaaee910d879f Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 24 Mar 2026 15:53:39 -0400 Subject: [PATCH 14/14] Ballot summaries (#2080) * Add constant for Ballot Initiative Committee code, add more spacing for ballot initiative summaries * Use white-space: pre-wrap for ballot initiative summaries - these are long and preserving whitespace is important for legibility * Don't write DocumentText for bills if it is not present in the API - we are going to start manually (for now) overwriting DocumentText for bills with PDFs but not DocumentText, and we don't want the next scraper run to overwrite that data with null. * Adding type assertion to test for type change --- components/bill/BillDetails.tsx | 7 +++++-- components/bill/Summary.tsx | 23 +++++++++++++++++++++-- components/db/bills.ts | 2 +- functions/src/bills/bills.ts | 4 ++++ functions/src/shared/constants.ts | 4 ++++ tests/unit/billdetail.test.tsx | 2 +- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/components/bill/BillDetails.tsx b/components/bill/BillDetails.tsx index 6e74adbd9..d4d8a363a 100644 --- a/components/bill/BillDetails.tsx +++ b/components/bill/BillDetails.tsx @@ -15,7 +15,10 @@ import { Back } from "components/shared/CommonComponents" import { useFlags } from "components/featureFlags" import { FollowBillButton } from "components/shared/FollowButton" import { PendingUpgradeBanner } from "components/PendingUpgradeBanner" -import { isCurrentCourt } from "functions/src/shared" +import { + currentBallotInitiativeCommittee, + isCurrentCourt +} from "functions/src/shared" export const BillDetails = ({ bill }: BillProps) => { const { t } = useTranslation("common") @@ -27,7 +30,7 @@ export const BillDetails = ({ bill }: BillProps) => { let isBallotMeasure = false const curComm = bill?.currentCommittee?.id - if (curComm == "SJ42") { + if (curComm === currentBallotInitiativeCommittee) { isBallotMeasure = true } diff --git a/components/bill/Summary.tsx b/components/bill/Summary.tsx index 458dd8675..4b1696055 100644 --- a/components/bill/Summary.tsx +++ b/components/bill/Summary.tsx @@ -17,7 +17,10 @@ import { SmartIcon } from "./SmartIcon" import { TestimonyCounts } from "./TestimonyCounts" import { BillProps } from "./types" import { BillTopic } from "functions/src/bills/types" -import { currentGeneralCourt } from "functions/src/shared" +import { + currentBallotInitiativeCommittee, + currentGeneralCourt +} from "functions/src/shared" const Divider = styled(Col)` width: 2px; @@ -30,6 +33,10 @@ const FormattedBillDetails = styled(Col)` white-space: pre-wrap; ` +const BallotSummaryRow = styled(Row)` + white-space: pre-wrap; +` + const SmartTag = ({ topic }: { topic: BillTopic }) => { return ( setShowBillDetails(false) const billText = bill?.content?.DocumentText const hearingIds = bill?.hearingIds + const isBallotMeasure = + bill?.currentCommittee?.id === currentBallotInitiativeCommittee const { showLLMFeatures } = useFlags() @@ -203,10 +212,20 @@ export const Summary = ({
+ ) : bill.summary !== undefined && isBallotMeasure ? ( + <> +
+ ) : ( <> )} - {bill.summary} + {bill.summary !== undefined && isBallotMeasure ? ( + + {bill.summary} + + ) : ( + {bill.summary} + )} {bill.topics?.map(t => ( diff --git a/components/db/bills.ts b/components/db/bills.ts index 2e7dae363..4ea11d6bf 100644 --- a/components/db/bills.ts +++ b/components/db/bills.ts @@ -33,7 +33,7 @@ export type BillContent = { Cosponsors: MemberReference[] LegislationTypeName: string Pinslip: string - DocumentText: string + DocumentText?: string } export type BillTopic = { diff --git a/functions/src/bills/bills.ts b/functions/src/bills/bills.ts index f4218d2eb..3602c4f07 100644 --- a/functions/src/bills/bills.ts +++ b/functions/src/bills/bills.ts @@ -28,6 +28,10 @@ export const { fetchBatch: fetchBillBatch, startBatches: startBillBatches } = .getSimilarBills(court, id) .catch(logFetchError("similar bills", id)) .then(bills => bills?.map(b => b.BillNumber).filter(isString) ?? []) + if (content.DocumentText == null) { + delete content.DocumentText + } + const resource: Partial = { content, history, diff --git a/functions/src/shared/constants.ts b/functions/src/shared/constants.ts index 8051debe8..ff375ec22 100644 --- a/functions/src/shared/constants.ts +++ b/functions/src/shared/constants.ts @@ -35,3 +35,7 @@ export const currentGeneralCourt = supportedGeneralCourts[0] export const isCurrentCourt = (courtNumber: number) => courtNumber === currentGeneralCourt + +// Only applicable for court 193/194, but by the time we have another general court, +// the full ballot initiative feature should be available and we can remove this check +export const currentBallotInitiativeCommittee = "SJ42" diff --git a/tests/unit/billdetail.test.tsx b/tests/unit/billdetail.test.tsx index f349f9572..42d0de3cd 100644 --- a/tests/unit/billdetail.test.tsx +++ b/tests/unit/billdetail.test.tsx @@ -162,7 +162,7 @@ describe("BillDetails", () => { const readMoreButton = screen.getByRole("button", { name: "Read more.." }) expect(readMoreButton).toBeInTheDocument fireEvent.click(readMoreButton) - expect(screen.getByText(DocumentText)).toBeInTheDocument + expect(screen.getByText(DocumentText!)).toBeInTheDocument }) // below test assumes mockBill contains a primary sponsor