diff --git a/server/api/routes/firebaseAPI.ts b/server/api/routes/firebaseAPI.ts index c21fe19..eaa6afc 100644 --- a/server/api/routes/firebaseAPI.ts +++ b/server/api/routes/firebaseAPI.ts @@ -3,12 +3,19 @@ import { db, FieldValue } from "../firebase/firebase"; const router = express.Router(); +// ARTIST COLLECTIONS const ARTIST_COLLECTION = "artist"; const ARTIST_SURVEY_COLLECTION = "artistSurvey"; const POEM_COLLECTION = "poem"; -const INCOMPLETE_SESSION_COLLECTION = "incompleteSession"; +const ARTIST_INCOMPLETE_SESSION_COLLECTION = "artistIncompleteSession"; -router.post("/autosave", async (req, res) => { +// AUDIENCE COLLECTIONS +const AUDIENCE_COLLECTION = "audience"; +const AUDIENCE_SURVEY_COLLECTION = "audienceSurvey"; +const AUDIENCE_INCOMPLETE_SESSION_COLLECTION = "audienceIncompleteSession"; + +// ARTIST ROUTES +router.post("/artist/autosave", async (req, res) => { try { const { sessionId, data } = req.body; @@ -32,7 +39,9 @@ router.post("/autosave", async (req, res) => { ? statusMap[data.data.timeStamps.length] || "started" : "started"; - const ref = db.collection(INCOMPLETE_SESSION_COLLECTION).doc(sessionId); + const ref = db + .collection(ARTIST_INCOMPLETE_SESSION_COLLECTION) + .doc(sessionId); const payload = { sessionId, role: data.role, @@ -49,7 +58,7 @@ router.post("/autosave", async (req, res) => { } }); -router.post("/commit-session", async (req, res) => { +router.post("/artist/commit-session", async (req, res) => { try { const { artistData, surveyData, poemData, sessionId } = req.body; @@ -75,7 +84,7 @@ router.post("/commit-session", async (req, res) => { const surveyRef = db.collection(ARTIST_SURVEY_COLLECTION).doc(); const poemRef = db.collection(POEM_COLLECTION).doc(); const incompleteRef = db - .collection(INCOMPLETE_SESSION_COLLECTION) + .collection(ARTIST_INCOMPLETE_SESSION_COLLECTION) .doc(sessionId); const artist = { @@ -99,4 +108,208 @@ router.post("/commit-session", async (req, res) => { } }); +// AUDIENCE ROUTES +router.post("/audience/autosave", async (req, res) => { + try { + const { sessionId, data } = req.body; + + if (!sessionId || !data) { + return res + .status(400) + .json({ error: "Missing sessionId or data objects" }); + } + + const statusMap: Record = { + 1: "captcha", + 2: "consent", + 3: "pre-survey", + 4: "instructions", + 5: "readPassage", + 6: "poemEvaluation1", + 7: "poemEvaluation2", + 8: "poemEvaluation3", + 9: "poemEvaluation4", + 10: "post-survey", + }; + + const status = data.data?.timeStamps + ? statusMap[data.data.timeStamps.length] || "started" + : "started"; + + const ref = db + .collection(AUDIENCE_INCOMPLETE_SESSION_COLLECTION) + .doc(sessionId); + const payload = { + sessionId, + role: data.role, + partialData: data.data, + lastUpdated: FieldValue.serverTimestamp(), + completionStatus: status, + }; + + await ref.set(payload, { merge: true }); + res.json({ success: true }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to autosave" }); + } +}); + +router.post("/audience/commit-session", async (req, res) => { + try { + const { audienceData, surveyData, sessionId } = req.body; + + if (!audienceData) { + return res.status(400).json({ error: "Missing audienceData" }); + } + + if (!surveyData) { + return res.status(400).json({ error: "Missing surveyData" }); + } + + if (!sessionId) { + return res.status(400).json({ error: "Missing sessionId" }); + } + + const batch = db.batch(); + + const audienceRef = db.collection(AUDIENCE_COLLECTION).doc(); + const surveyRef = db.collection(AUDIENCE_SURVEY_COLLECTION).doc(); + const incompleteRef = db + .collection(AUDIENCE_INCOMPLETE_SESSION_COLLECTION) + .doc(sessionId); + + const audience = { + surveyResponse: surveyRef, + timestamps: [...(audienceData.timeStamps ?? []), new Date()], + }; + + batch.set(audienceRef, audience); + batch.set(surveyRef, { audienceId: audienceRef.id, ...surveyData }); + batch.delete(incompleteRef); + + await batch.commit(); + + res.json({ success: true, audienceId: audienceRef.id }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Batch commit failed" }); + } +}); + +router.get("/audience/poems", async (req, res) => { + try { + const { passageId } = req.query; + + if (!passageId || typeof passageId !== "string") { + return res.status(400).json({ error: "Missing or invalid passageId" }); + } + + // query all poems with the given passageId + const snapshot = await db + .collection(POEM_COLLECTION) + .where("passageId", "==", passageId) + .get(); + + if (snapshot.empty) { + return res.status(404).json({ error: "No poems found for this passage" }); + } + + // map to { poemId, text } format + const allPoems = snapshot.docs.map((doc) => ({ + poemId: doc.id, + text: doc.data().text as number[], + })); + + // Fisher-Yates shuffle for true randomness + for (let i = allPoems.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [allPoems[i], allPoems[j]] = [allPoems[j], allPoems[i]]; + } + + // Take first 4 (or fewer if not enough poems exist) + const randomPoems = allPoems.slice(0, 4); + + res.json({ poems: randomPoems }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to get poems" }); + } +}); + +router.post("/audience/artist-statements", async (req, res) => { + try { + const { poemIds } = req.body; + + if (!poemIds || !Array.isArray(poemIds) || poemIds.length === 0) { + return res + .status(400) + .json({ error: "Missing or invalid poemIds array" }); + } + + // Get statements for the requested poem IDs + const poemStatements = await Promise.all( + poemIds.map((id: string) => getArtistStatement(id)) + ); + + // Get all poems to find 4 random other statements + const allPoemsSnapshot = await db.collection(POEM_COLLECTION).get(); + const requestedPoemIdSet = new Set(poemIds); + + // Filter out the requested poems + const otherPoemIds = allPoemsSnapshot.docs + .map((doc) => doc.id) + .filter((id) => !requestedPoemIdSet.has(id)); + + // Fisher-Yates shuffle for random selection + for (let i = otherPoemIds.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [otherPoemIds[i], otherPoemIds[j]] = [otherPoemIds[j], otherPoemIds[i]]; + } + + // Get statements for 4 random other poems + const randomPoemIds = otherPoemIds.slice(0, 4); + const randomStatementsResults = await Promise.all( + randomPoemIds.map((id) => getArtistStatement(id)) + ); + const randomStatements = randomStatementsResults + .filter((s): s is { poemId: string; statement: string } => s !== null) + .map((s) => s.statement); + + res.json({ + poemStatements, + randomStatements, + }); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Failed to get artist statements" }); + } +}); + +const getArtistStatement = async ( + poemId: string +): Promise<{ poemId: string; statement: string } | null> => { + // 1. get artistId from poemId + const poemDoc = await db.collection(POEM_COLLECTION).doc(poemId).get(); + if (!poemDoc.exists) return null; + + const artistId = poemDoc.data()?.artistId; + if (!artistId) return null; + + // 2. query survey collection for matching artistId + const surveySnapshot = await db + .collection(ARTIST_SURVEY_COLLECTION) + .where("artistId", "==", artistId) + .limit(1) + .get(); + + if (surveySnapshot.empty) return null; + + // 3. extract q14 from postAnswers + const statement = surveySnapshot.docs[0].data()?.postSurveyAnswers.q14; + if (!statement) return null; + + return { poemId, statement }; +}; + export default router; diff --git a/src/App.tsx b/src/App.tsx index 27576ac..33bf26d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import type { Audience, ArtistSurvey, AudienceSurvey, + SurveyAnswers, } from "./types"; import { Provider } from "./components/ui/provider"; import { Toaster } from "./components/ui/toaster"; @@ -53,6 +54,11 @@ interface DataContextValue { addPostSurvey: ( updates: Partial | Partial ) => void; + addPoemEvaluation: ( + poemId: string, + answers: SurveyAnswers, + additionalData?: Partial + ) => void; sessionId: string | null; flushSaves: () => Promise; } @@ -81,16 +87,24 @@ function App() { setSessionId(id); }, []); - const enqueueAutosave = (data: UserData | null) => { + const autoSave = (data: UserData | null) => { if (!data || !sessionId) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { - await fetch("/api/firebase/autosave", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId, data }), - }); + if (data.role === "artist") { + await fetch("/api/firebase/artist/autosave", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, data }), + }); + } else { + await fetch("/api/firebase/audience/autosave", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, data }), + }); + } }, 500); }; @@ -128,7 +142,7 @@ function App() { ...updates, }, }; - enqueueAutosave(next as UserData); + autoSave(next as UserData); return next; }); }; @@ -158,7 +172,7 @@ function App() { }, }, }; - enqueueAutosave(next as UserData); + autoSave(next as UserData); return next; }); }; @@ -188,7 +202,38 @@ function App() { }, }, }; - enqueueAutosave(next as UserData); + autoSave(next as UserData); + return next; + }); + }; + + const addPoemEvaluation = ( + poemId: string, + answers: SurveyAnswers, + additionalData?: Partial + ) => { + setUserData((prev: any) => { + if (!prev || !prev.data) { + throw new Error( + "Tried to update poem evaluation when userData is null." + ); + } + + const poemAnswer = { poemId, ...answers }; + const existingPoemAnswers = prev.data.surveyResponse?.poemAnswers ?? []; + + const next = { + ...prev, + data: { + ...prev.data, + ...additionalData, + surveyResponse: { + ...prev.data.surveyResponse, + poemAnswers: [...existingPoemAnswers, poemAnswer], + }, + }, + }; + autoSave(next as UserData); return next; }); }; @@ -223,6 +268,7 @@ function App() { addRoleSpecificData, addPostSurvey, addPreSurvey, + addPoemEvaluation, sessionId, flushSaves, }} diff --git a/src/pages/artist/PostSurvey.tsx b/src/pages/artist/PostSurvey.tsx index 48c858a..8649cd2 100644 --- a/src/pages/artist/PostSurvey.tsx +++ b/src/pages/artist/PostSurvey.tsx @@ -48,7 +48,7 @@ const ArtistPostSurvey = () => { // SEND IT RAHHHH try { - await fetch("/api/firebase/commit-session", { + await fetch("/api/firebase/artist/commit-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/pages/audience/AudienceCaptcha.tsx b/src/pages/audience/AudienceCaptcha.tsx index a5bacdf..a44a714 100644 --- a/src/pages/audience/AudienceCaptcha.tsx +++ b/src/pages/audience/AudienceCaptcha.tsx @@ -4,6 +4,7 @@ import HalfPageTemplate from "../../components/shared/pages/halfPage"; import { Button, Input } from "@chakra-ui/react"; import { toaster } from "../../components/ui/toaster"; import { DataContext } from "../../App"; +import { Passages } from "../../consts/passages"; const TEST_CAPTCHA = "*TEST"; const Captcha = () => { @@ -35,6 +36,12 @@ const Captcha = () => { setCaptchaMessage(captcha_text); }; + const getRandomPassage = () => { + const numPassages = Passages.length; + const randomIndex = Math.floor(Math.random() * numPassages); + return randomIndex.toString(); + }; + useEffect(() => { if (canvasRef.current) { const canvas = canvasRef.current; @@ -71,12 +78,14 @@ const Captcha = () => { if (inputCaptcha === captchaMessage) { addUserData({ role: "audience" }); addRoleSpecificData({ + passageId: getRandomPassage(), timeStamps: [...(userData?.data?.timeStamps ?? []), new Date()], }); navigate("/consent"); } else if (inputCaptcha == TEST_CAPTCHA) { addUserData({ role: "audience" }); addRoleSpecificData({ + passageId: getRandomPassage(), timeStamps: [...(userData?.data?.timeStamps ?? []), new Date()], }); navigate("/consent"); diff --git a/src/pages/audience/step1/Step1.tsx b/src/pages/audience/step1/Step1.tsx index abb3e27..c4bc142 100644 --- a/src/pages/audience/step1/Step1.tsx +++ b/src/pages/audience/step1/Step1.tsx @@ -14,12 +14,13 @@ const AudiencePassage = () => { const { userData, addRoleSpecificData } = context; - const passageId = (userData as any)?.data?.passage || "1"; + const passageId = (userData as any)?.data?.passageId || "1"; const passage = Passages.find((p) => p.id === passageId) || Passages[0]; const handleSubmit = () => { addRoleSpecificData({ + passageId: passageId, timeStamps: [...(userData?.data?.timeStamps ?? []), new Date()], }); navigate("/audience/poems"); diff --git a/src/pages/audience/step2/Step2.tsx b/src/pages/audience/step2/Step2.tsx index cf038d2..bb433d8 100644 --- a/src/pages/audience/step2/Step2.tsx +++ b/src/pages/audience/step2/Step2.tsx @@ -1,19 +1,28 @@ import PageTemplate from "../../../components/shared/pages/audiencePages/scrollFullPage"; import { useNavigate } from "react-router-dom"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { DataContext } from "../../../App"; import { Passages } from "../../../consts/passages"; -import { Poems } from "../../../consts/poems"; import SurveyScroll from "../../../components/survey/surveyScroll"; import { AudiencePoemQuestions } from "../../../consts/surveyQuestions"; -import { Button } from "@chakra-ui/react"; +import { Button, Spinner } from "@chakra-ui/react"; import { LuEyeClosed } from "react-icons/lu"; import { HiOutlineDocumentText } from "react-icons/hi2"; +import type { SurveyAnswers } from "../../../types"; + +interface FetchedPoem { + poemId: string; + text: number[]; +} const AudiencePoems = () => { const [currPoem, setCurrPoem] = useState(0); const [showScrollTop, setShowScrollTop] = useState(false); const [showPassage, setShowPassage] = useState(false); + const [poems, setPoems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const hasFetched = useRef(false); const navigate = useNavigate(); const context = useContext(DataContext); @@ -22,14 +31,44 @@ const AudiencePoems = () => { throw new Error("Component must be used within a DataContext.Provider"); } - const { userData, addRoleSpecificData } = context; + const { userData, addPoemEvaluation, addRoleSpecificData } = context; - const passageId = (userData as any)?.data?.passage || "1"; + const passageId = (userData as any)?.data?.passageId || "1"; const passage = Passages.find((p) => p.id === passageId) || Passages[0]; const words = passage.text.split(" "); - const poems = Poems; + // Fetch poems from API + useEffect(() => { + if (hasFetched.current) return; + hasFetched.current = true; + + const fetchPoems = async () => { + try { + setIsLoading(true); + const response = await fetch( + `/api/firebase/audience/poems?passageId=${encodeURIComponent( + passageId + )}` + ); + if (!response.ok) { + throw new Error("Failed to fetch poems"); + } + const data = await response.json(); + setPoems(data.poems); + + // Save the poem IDs to user data + const poemIds = data.poems.map((p: FetchedPoem) => p.poemId); + addRoleSpecificData({ poemsViewed: poemIds }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load poems"); + } finally { + setIsLoading(false); + } + }; + + fetchPoems(); + }, []); useEffect(() => { const container = document.querySelector( @@ -55,7 +94,11 @@ const AudiencePoems = () => { } }, []); - const handleSubmit = () => { + const handleSubmit = (answers: SurveyAnswers) => { + addPoemEvaluation(poems[currPoem].poemId, answers, { + timeStamps: [...(userData?.data?.timeStamps ?? []), new Date()], + }); + if (currPoem < poems.length - 1) { setCurrPoem(currPoem + 1); const container = document.querySelector( @@ -68,12 +111,35 @@ const AudiencePoems = () => { } return; } - addRoleSpecificData({ - timeStamps: [...(userData?.data?.timeStamps ?? []), new Date()], - }); navigate("/audience/passage"); }; + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error || poems.length === 0) { + return ( + +
+ {error || "No poems available for this passage"} +
+
+ ); + } + return (