diff --git a/my-app/firebase.js b/my-app/firebase.js index 131da9e..012d98d 100644 --- a/my-app/firebase.js +++ b/my-app/firebase.js @@ -12,6 +12,7 @@ import { runTransaction, } from "firebase/database"; import { reaction, toJS } from "mobx"; +import { push } from "firebase/database"; /** * Firebase configuration and initialization. @@ -467,3 +468,33 @@ export async function getReviewsForCourse(courseCode) { }); return reviews; } +/** + * Add a comment to a specific review + * @param {string} courseCode - Course code (e.g. "A11HIB") + * @param {string} reviewUserId - The user ID of the person who wrote the main review + * @param {Object} commentObj - Object with { userName, text, timestamp } + */ +export async function addCommentToReview(courseCode, reviewUserId, commentObj) { + const commentsRef = ref(db, `reviews/${courseCode}/${reviewUserId}/comments`); + await push(commentsRef, commentObj); +} + +/** + * Get comments for a specific review + * @param {string} courseCode + * @param {string} reviewUserId + * @returns {Promise>} Array of comments: { id, userName, text, timestamp } + */ +export async function getCommentsForReview(courseCode, reviewUserId) { + const commentsRef = ref(db, `reviews/${courseCode}/${reviewUserId}/comments`); + const snapshot = await get(commentsRef); + if (!snapshot.exists()) return []; + const comments = []; + snapshot.forEach((childSnapshot) => { + comments.push({ + id: childSnapshot.key, + ...childSnapshot.val() + }); + }); + return comments; +} \ No newline at end of file diff --git a/my-app/firebase_rules.json b/my-app/firebase_rules.json index 4af3777..10fc332 100644 --- a/my-app/firebase_rules.json +++ b/my-app/firebase_rules.json @@ -1,42 +1,52 @@ { - "rules": { - // Courses and Metadata - "courses": { - ".read": true, - ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')" - }, - "metadata": { - ".read": true, - ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')" - }, - "departments": { + "rules": { + // Courses and Metadata + "courses": { ".read": true, - ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')" - }, - "locations": { + ".write": "auth != null && auth.uid === 'adminuid'" + }, + "metadata": { + ".read": true, + ".write": "auth != null && auth.uid === 'adminuid'" + }, + "departments": { + ".read": true, + ".write": "auth != null && auth.uid === 'adminuid'" + }, + "locations": { + ".read": true, + ".write": "auth != null && auth.uid === 'adminuid'" + }, + + // Reviews and Comments + "reviews": { ".read": true, - ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')" - }, - - // Reviews - "reviews": { - ".read":true, "$courseCode": { - "$userID": { - ".write": "auth != null && (auth.uid === $userID || data.child('uid').val() === auth.uid || !data.exists())", - ".validate": "newData.hasChildren(['text', 'timestamp']) && - newData.child('text').isString() && - newData.child('timestamp').isNumber()" - } - } - }, - - // Users - "users": { "$userID": { - ".read": "auth != null && auth.uid === $userID", - ".write": "auth != null && auth.uid === $userID" + // Only the review owner can write the main review fields (not including comments) + ".write": "auth != null && (auth.uid === $userID)", + + // Allow anyone to write a comment + "comments": { + ".read": true, + "$commentId": { + ".write": "auth != null", + ".validate": "newData.hasChildren(['userName', 'text', 'timestamp']) && + newData.child('userName').isString() && + newData.child('text').isString() && + newData.child('timestamp').isNumber()" + } + } } } + }, + + // Users + "users": { + "$userID": { + ".read": "auth != null && auth.uid === $userID", + ".write": "auth != null && auth.uid === $userID" + } } - } \ No newline at end of file + } +} diff --git a/my-app/src/model.js b/my-app/src/model.js index ad4fbf0..538ed25 100644 --- a/my-app/src/model.js +++ b/my-app/src/model.js @@ -201,7 +201,18 @@ export const model = { async getReviews(courseCode) { try { - return await getReviewsForCourse(courseCode); + const rawReviews = await getReviewsForCourse(courseCode); + if (!Array.isArray(rawReviews)) return []; + + const enriched = rawReviews.map((review) => { + return { + ...review, + uid: review.uid || review.id || "", + courseCode: courseCode || "", + }; + }); + + return enriched; } catch (error) { console.error("Error fetching reviews:", error); return []; diff --git a/my-app/src/presenters/ReviewPresenter.jsx b/my-app/src/presenters/ReviewPresenter.jsx index 6ebced3..7f3adcf 100644 --- a/my-app/src/presenters/ReviewPresenter.jsx +++ b/my-app/src/presenters/ReviewPresenter.jsx @@ -7,8 +7,8 @@ import { ReviewView } from '../views/ReviewView.jsx'; */ export const ReviewPresenter = observer(({ model, course }) => { const [reviews, setReviews] = useState([]); - const [postAnonymous, setAnonymous] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const [anonState, setAnonState] = useState(false); const [formData, setFormData] = useState({ text: "", overallRating: 0, @@ -20,7 +20,7 @@ export const ReviewPresenter = observer(({ model, course }) => { avgRating: 0, }); - // fetch reviews when the current course code or model updates. + // Fetch reviews when the current course code or model updates useEffect(() => { async function fetchReviews() { const data = await model.getReviews(course.code); @@ -29,25 +29,25 @@ export const ReviewPresenter = observer(({ model, course }) => { fetchReviews(); }, [course.code, model]); - /** - * Set an error message if the user is not logged in or posted already. - */ + const hasPreviousReview = !!model?.user?.uid && reviews.some(r => r.uid === model.user.uid); + + // Set error message based on login state or review existence useEffect(() => { - async function updateError() { - if(!model?.user?.uid) - setErrorMessage("You need to be logged in to post a comment - Posting anonymously is possible."); - else if(reviews.filter((review)=>{return review.uid == model?.user?.uid}).length > 0) - setErrorMessage("Everyone can only post once. Submitting a new comment will replace the old one."); + if (!model?.user?.uid) { + setErrorMessage("You need to be logged in to post a review - Posting anonymously is possible."); + } else if (hasPreviousReview) { + setErrorMessage("Everyone can only post once. Submitting a new review will replace the old one."); + } else { + setErrorMessage(""); } - updateError(); - }, [reviews, model?.user?.uid]); + }, [reviews, model?.user?.uid, hasPreviousReview]); /** - * Handle the submssion of a review and set errors if needed. - * @returns void + * Handle the submission of a review and set errors if needed. + * @param {boolean} anon - whether to post anonymously */ - const handleReviewSubmit = async () => { - if(!model?.user){ + const handleReviewSubmit = async (anon) => { + if (!model?.user) { setErrorMessage("You need to be logged in to post a comment - Posting anonymously is possible."); return; } @@ -57,32 +57,34 @@ export const ReviewPresenter = observer(({ model, course }) => { return; } - // create the post object - look into firebase rules if you want to change this. const review = { - userName: postAnonymous ? "Anonymous" : model.user?.displayName, + userName: anon ? "Anonymous" : model.user?.displayName, uid: model?.user?.uid, timestamp: Date.now(), ...formData, }; - - if(!await model.addReview(course.code, review)){ - setErrorMessage("Something went wrong when posting. Are you logged in?") + + const success = await model.addReview(course.code, review); + if (!success) { + setErrorMessage("Something went wrong when posting. Are you logged in?"); return; } - // refetch after submission + const updatedReviews = await model.getReviews(course.code); setReviews(updatedReviews); + setFormData({ text: "", overallRating: 0, difficultyRating: 0, professorName: "", + professorRating: 0, grade: "", - recommended: false, + recommend: null, + avgRating: 0, }); }; - return ( { handleReviewSubmit={handleReviewSubmit} errorMessage={errorMessage} setErrorMessage={setErrorMessage} - postAnonymous={postAnonymous} - setAnonymous={setAnonymous} + hasPreviousReview={hasPreviousReview} + anonState={anonState} + setAnonState={setAnonState} /> - ); }); diff --git a/my-app/src/views/Components/CommentTree.jsx b/my-app/src/views/Components/CommentTree.jsx new file mode 100644 index 0000000..af4fcd0 --- /dev/null +++ b/my-app/src/views/Components/CommentTree.jsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; +import RatingComponent from "./RatingComponent.jsx"; +import { model } from "../../model.js"; // Adjust the path if needed +import { addReviewForCourse } from "../../firebase"; // Adjust the path if needed + +function CommentTree({ courseCode, comment, level = 0 }) { + const [showReply, setShowReply] = useState(false); + const [replyText, setReplyText] = useState(""); + + const handleReplySubmit = async () => { + if (replyText.trim().length === 0) return; + + const reply = { + userName: model.user?.displayName || "Anonymous", + text: replyText, + timestamp: Date.now(), + overallRating: 0, + difficultyRating: 0, + professorRating: 0, + professorName: "", + grade: "", + recommend: null, + }; + + await addReviewForCourse(courseCode, reply, comment.id); + window.location.reload(); // quick reload for now; optional optimization later + }; + + return ( +
+
+
+

{comment.userName}

+

+ {new Date(comment.timestamp).toLocaleDateString()} +

+
+ +

{comment.text}

+ + + + {showReply && ( +
+