diff --git a/apps/scoop-portal/server/api/journal/route.js b/apps/scoop-portal/server/api/journal/route.js index 91aaf59a..774324ee 100644 --- a/apps/scoop-portal/server/api/journal/route.js +++ b/apps/scoop-portal/server/api/journal/route.js @@ -7,7 +7,7 @@ const prisma = new PrismaClient(); * GET all journal entries * * @param {Object} req - The request object - * @param {Object} res - The response object to send all jurnal entries or an error + * @param {Object} res - The response object to send all journal entries or an error */ router.get("/", async (req, res) => { try { @@ -15,10 +15,7 @@ router.get("/", async (req, res) => { res.status(200).json(entries); } catch (error) { console.error("Error fetching journal entries:", error); - res.status(500).json({ - message: "Error fetching journal entries", - error: error.message, - }); + res.status(500).json({ message: "Error fetching journal entries", error: error.message }); } }); @@ -50,48 +47,39 @@ router.post("/", async (req, res) => { sender_id, topic_id, semester_GroupId: semester_GroupId ? Number(semester_GroupId) : null, - previous_entryid: previous_entryid, - entry_type: entry_type, - visibility_level: visibility_level, - privacy_level: privacy_level, + previous_entryid, + entry_type, + visibility_level, + privacy_level, }, - include:{ + include: { sender: true, recipients: true, topic: true, - next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: sender_id } - ], - }, - include:{ - sender: true, - recipients: true, - topic: true, - }, - }, + next_entries: { + where: { + OR: [ + { privacy_level: "PUBLIC" }, + { sender_id: sender_id } + ], + }, + include: { + sender: true, + recipients: true, + topic: true, + }, + }, }, }); notifyStatus({ userId: "zim1902", context: { journalEntryId: newEntry.id, notes: newEntry.notes } }) - .then(summary => { - console.log('Notification sent:', summary) - }) - .catch(error => { - console.error('Error sending notification:', error); - }); + .then(summary => console.log('Notification sent:', summary)) + .catch(error => console.error('Error sending notification:', error)); - res - .status(200) - .json({ message: "New journal entry created", entry: newEntry }); + res.status(200).json({ message: "New journal entry created", entry: newEntry }); } catch (error) { console.error("Error creating journal entry:", error); - res.status(500).json({ - message: "Error creating journal entry", - error: error.message, - }); + res.status(500).json({ message: "Error creating journal entry", error: error.message }); } }); @@ -112,72 +100,57 @@ router.put("/:id", async (req, res) => { res.status(200).json(updatedEntry); } catch (error) { console.error("Error updating journal entry:", error); - res.status(500).json({ - message: "Error updating journal entry", - error: error.message, - }); + res.status(500).json({ message: "Error updating journal entry", error: error.message }); } }); /** * GET journal entries for scoopdinator + * + * @param {Object} req - The request object + * @param {Object} res - The response object to send scoopdinator journal entries or an error */ router.get("/scoopdinator", async (req, res) => { - // Get query parameters from URL const { semester_GroupId, contactee_fname, contactee_lname } = req.query; - - // Develop whereClause conditionally for fitlering const whereClause = { journal_owner_type: "scoopdinator" }; - if (semester_GroupId) { - whereClause.semester_GroupId = Number(semester_GroupId); - } - if (contactee_fname) { - whereClause.contactee_fname = contactee_fname; - } - if (contactee_lname) { - whereClause.contactee_lname = contactee_lname; - } - - // Develop orderByClause for ordering - const orderByClause = { date: "desc" }; - - // Fetch journal entries based on the two clauses + if (semester_GroupId) whereClause.semester_GroupId = Number(semester_GroupId); + if (contactee_fname) whereClause.contactee_fname = contactee_fname; + if (contactee_lname) whereClause.contactee_lname = contactee_lname; try { const scoopdinatorEntries = await prisma.journalEntry.findMany({ where: whereClause, - orderBy: orderByClause, + orderBy: { date: "desc" }, }); res.status(200).json(scoopdinatorEntries); } catch (error) { console.error("Error fetching the scoopdinator journal entries: ", error); - res.status(500).json({ - message: "Error fetching scoopdinator journal entries", - error: error.message, - }); + res.status(500).json({ message: "Error fetching scoopdinator journal entries", error: error.message }); } }); /** * GET journal entries for scoopervisor + * + * @param {Object} req - The request object + * @param {Object} res - The response object to send scoopervisor journal entries or an error */ router.get("/scoopervisor", async (req, res) => { - const whereClause = { journal_owner_type: "scoopervisor" }; try { const scoopervisorEntries = await prisma.journalEntry.findMany({ - where: whereClause, + where: { journal_owner_type: "scoopervisor" }, }); res.status(200).json(scoopervisorEntries); } catch (error) { console.error("Error fetching the scoopervisor journal entries: ", error); - res.status(500).json({ - message: "Error fetching scoopervisor journal entries", - error: error.message, - }); + res.status(500).json({ message: "Error fetching scoopervisor journal entries", error: error.message }); } }); /** * GET journal entries for scooployee + * + * @param {Object} req - The request object + * @param {Object} res - The response object to send scooployee journal entries or an error */ router.get("/scooployee", async (req, res) => { try { @@ -187,244 +160,238 @@ router.get("/scooployee", async (req, res) => { res.status(200).json(scooployeeEntries); } catch (error) { console.error("Error fetching the scooployee journal entries: ", error); - res.status(500).json({ - message: "Error fetching scooployee journal entries", - error: error.message, - }); + res.status(500).json({ message: "Error fetching scooployee journal entries", error: error.message }); } }); /** * GET journal entries for user with id + * + * @param {Object} req - The request object + * @param {Object} res - The response object to send user's journal entries or an error */ router.get("/:id", async (req, res) => { - const {id} = req.params; - - try { - const user = await prisma.users.findUnique({ - where: { id: id }, - }); + const { id } = req.params; + try { + const user = await prisma.users.findUnique({ where: { id } }); let entries = []; - if(user.type == "scooployee"){ - const scooployeeEntries = await prisma.journalEntry.findMany({ + if (user.type == "scooployee") { + // Direct recipients always see the entry regardless of visibility_level + const recipientEntries = await prisma.journalEntry.findMany({ + where: { + recipients: { some: { id } }, + privacy_level: "PUBLIC", + }, + include: { + sender: true, + recipients: true, + topic: true, + next_entries: { + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + }, + include: { sender: true, recipients: true, topic: true }, + }, + }, + }); + + const generalEntries = await prisma.journalEntry.findMany({ where: { OR: [ - {sender_id: id}, - { recipients: { - some: { - id: id, - },},}, + { sender_id: id }, { topic_id: id }, ], privacy_level: "PUBLIC", - visibility_level: { - lt: 2 - }, + visibility_level: { lt: 2 }, }, include: { sender: true, recipients: true, topic: true, next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: id } - ], - visibility_level: { - lt: 2 - }, - }, - include:{ - sender: true, - recipients: true, - topic: true, + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + visibility_level: { lt: 2 }, }, + include: { sender: true, recipients: true, topic: true }, }, }, - }) - //res.status(200).json(scooployeeEntries); - entries = entries.concat(scooployeeEntries); - } - else if(user.type == "scoopdinator"){ + }); + + entries = entries.concat(recipientEntries, generalEntries); + + } else if (user.type == "scoopdinator") { const dinatorEntries = await prisma.journalEntry.findMany({ - where:{ + where: { privacy_level: "PUBLIC", - visibility_level: { - lt: 5 - }, + visibility_level: { lt: 5 }, }, include: { sender: true, recipients: true, topic: true, next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: id } - ], - visibility_level: { - lt: 5 - }, - }, - include: { - sender: true, - recipients: true, - topic: true, + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + visibility_level: { lt: 5 }, }, + include: { sender: true, recipients: true, topic: true }, }, }, }); - //res.status(200).json(dinatorEntries); entries = entries.concat(dinatorEntries); - } - else if(user.type == "scoopervisor"){ - const scoopervisorTeams = await prisma.teams.findMany({ + + } else if (user.type == "scoopervisor") { + // Direct recipients always see the entry regardless of visibility_level + const recipientEntries = await prisma.journalEntry.findMany({ where: { - scoopervisorId: user.id, + recipients: { some: { id } }, + privacy_level: "PUBLIC", }, - include: { - members: true, - } - }); - - const memberSet = new Set(); - - for (const team of scoopervisorTeams) { - for (const member of team.members) { - memberSet.add(member.id) - } - } - const memberArray = Array.from(memberSet); - //this currently allows Scoopervisors to see entries in which they are the topic - const scoopervisorEntries = await prisma.journalEntry.findMany({ + include: { + sender: true, + recipients: true, + topic: true, + next_entries: { where: { - OR: memberArray.flatMap(memberId => [ - { sender_id: memberId }, - { recipients: { - some: { - id: memberId, - },},}, - { topic_id: memberId }, - ]), - privacy_level: "PUBLIC", - visibility_level: { - lt: 4 - }, - }, - include: { - sender: true, - recipients: true, - topic: true, - next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: id } - ], - visibility_level: { - lt: 4 - }, - }, - include:{ - sender: true, - recipients: true, - topic: true, - }, - }, + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], }, - }); - //res.status(200).json(visorEntries); - entries = entries.concat(scoopervisorEntries); + include: { sender: true, recipients: true, topic: true }, + }, + }, + }); + + const scoopervisorTeams = await prisma.teams.findMany({ + where: { scoopervisorId: user.id }, + include: { members: true }, + }); + + const memberSet = new Set(); + for (const team of scoopervisorTeams) { + for (const member of team.members) { + memberSet.add(member.id); + } } - else if(user.type == "advisor"){ - const advisorEntries = await prisma.journalEntry.findMany({ + const memberArray = Array.from(memberSet); + + const teamEntries = await prisma.journalEntry.findMany({ where: { + OR: memberArray.flatMap(memberId => [ + { sender_id: memberId }, + { recipients: { some: { id: memberId } } }, + { topic_id: memberId }, + ]), privacy_level: "PUBLIC", - visibility_level: { - lt: 3 - }, + visibility_level: { lt: 4 }, }, include: { sender: true, recipients: true, topic: true, next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: id } - ], - visibility_level: { - lt: 3 - }, - }, - include:{ - sender: true, - recipients: true, - topic: true, + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + visibility_level: { lt: 4 }, }, + include: { sender: true, recipients: true, topic: true }, }, }, - }) - entries = entries.concat(advisorEntries); - } - const privateEntries = await prisma.journalEntry.findMany({ - where:{ - privacy_level: "PERSONAL", - sender_id: id, + }); + entries = entries.concat(recipientEntries, teamEntries); + + } else if (user.type == "advisor") { + // Direct recipients always see the entry regardless of visibility_level + const recipientEntries = await prisma.journalEntry.findMany({ + where: { + recipients: { some: { id } }, + privacy_level: "PUBLIC", }, include: { sender: true, recipients: true, topic: true, next_entries: { - where:{ - OR:[ - { privacy_level: "PUBLIC" }, - { sender_id: id } - ], + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], }, - include: { - sender: true, - recipients: true, - topic: true, + include: { sender: true, recipients: true, topic: true }, + }, + }, + }); + + const advisorEntries = await prisma.journalEntry.findMany({ + where: { + privacy_level: "PUBLIC", + visibility_level: { lt: 3 }, + }, + include: { + sender: true, + recipients: true, + topic: true, + next_entries: { + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + visibility_level: { lt: 3 }, }, + include: { sender: true, recipients: true, topic: true }, }, }, }); - entries = entries.concat(privateEntries); - res.status(200).json(entries); + entries = entries.concat(recipientEntries, advisorEntries); + } + + const privateEntries = await prisma.journalEntry.findMany({ + where: { + privacy_level: "PERSONAL", + sender_id: id, + }, + include: { + sender: true, + recipients: true, + topic: true, + next_entries: { + where: { + OR: [{ privacy_level: "PUBLIC" }, { sender_id: id }], + }, + include: { sender: true, recipients: true, topic: true }, + }, + }, + }); + entries = entries.concat(privateEntries); + + // Deduplicate entries + const seen = new Set(); + const deduped = entries.filter(entry => { + if (seen.has(entry.id)) return false; + seen.add(entry.id); + return true; + }); + + res.status(200).json(deduped); } catch (error) { console.error("Error fetching the users journal entries: ", error); - res.status(500).json({ - message: "Error fetching users journal entries", - error: error.message, - }); + res.status(500).json({ message: "Error fetching users journal entries", error: error.message }); } -}) +}); + +const NOTIFY_BASE = process.env.NOTIFY_BASE || 'http://localhost:4000/api/notifications'; +const APP_ID = process.env.APP_ID || 'scoop'; -const NOTIFY_BASE = process.env.NOTIFY_BASE || 'http://localhost:4000/api/notifications' -const APP_ID = process.env.APP_ID || 'scoop' export async function notifyStatus({ userId, context }) { const res = await fetch(`${NOTIFY_BASE}/dispatch/${APP_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - userId: userId, + userId, subject: "New Journal Entry Created", message: context.notes || "A new journal entry has been created.", - }) - }) - - - const data = await res.json() - if (!res.ok) throw new Error(`Dispatch failed: ${res.status} ${JSON.stringify(data)}`) - return data.summary + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`Dispatch failed: ${res.status} ${JSON.stringify(data)}`); + return data.summary; } - -export default router; +export default router; \ No newline at end of file diff --git a/apps/scoop-portal/ui/src/app/_components/journal/JournalHeader.js b/apps/scoop-portal/ui/src/app/_components/journal/JournalHeader.js index 35db65a5..9bc86959 100644 --- a/apps/scoop-portal/ui/src/app/_components/journal/JournalHeader.js +++ b/apps/scoop-portal/ui/src/app/_components/journal/JournalHeader.js @@ -6,7 +6,6 @@ export default function JournalHeader({ setFilterDialogOpen, setNewEntryOpen, handleJSONDownload - }) { const handleOpenFilterDialog = () => setFilterDialogOpen(true); diff --git a/apps/scoop-portal/ui/src/app/bubbles/layout.js b/apps/scoop-portal/ui/src/app/bubbles/layout.js new file mode 100644 index 00000000..0cb1b0f9 --- /dev/null +++ b/apps/scoop-portal/ui/src/app/bubbles/layout.js @@ -0,0 +1,12 @@ +import ProtectedRoute from "../utils/ProtectedRoute"; +import { Box } from "@mui/material"; + +export default function bubblesLayout({ children }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/apps/scoop-portal/ui/src/app/dashboard/layout.js b/apps/scoop-portal/ui/src/app/dashboard/layout.js new file mode 100644 index 00000000..b2313bc1 --- /dev/null +++ b/apps/scoop-portal/ui/src/app/dashboard/layout.js @@ -0,0 +1,12 @@ +import ProtectedRoute from "../utils/ProtectedRoute"; +import { Box } from "@mui/material"; + +export default function dashboardLayout({ children }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/apps/scoop-portal/ui/src/app/journal/layout.js b/apps/scoop-portal/ui/src/app/journal/layout.js new file mode 100644 index 00000000..c2370e44 --- /dev/null +++ b/apps/scoop-portal/ui/src/app/journal/layout.js @@ -0,0 +1,12 @@ +import ProtectedRoute from "../utils/ProtectedRoute"; +import { Box } from "@mui/material"; + +export default function journalLayout({ children }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/apps/scoop-portal/ui/src/app/journal/page.js b/apps/scoop-portal/ui/src/app/journal/page.js index 341ec3a5..f661334b 100644 --- a/apps/scoop-portal/ui/src/app/journal/page.js +++ b/apps/scoop-portal/ui/src/app/journal/page.js @@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react"; import FilterDialog from "@components/FilterDialog"; import Header from "@components/Header"; import JournalHeader from "@components/journal/JournalHeader"; -import EditNoteIcon from "@mui/icons-material/EditNote"; import { useUser } from "../utils/user-context/page"; import { Autocomplete, @@ -17,8 +16,6 @@ import { DialogActions, DialogContent, DialogTitle, - FormControl, - IconButton, MenuItem, Paper, Select, @@ -27,10 +24,9 @@ import { Tooltip, Typography, useTheme, - alpha + alpha } from "@mui/material"; -// MUI Lab imports for Timeline import Timeline from "@mui/lab/Timeline"; import TimelineItem from "@mui/lab/TimelineItem"; import TimelineSeparator from "@mui/lab/TimelineSeparator"; @@ -41,23 +37,19 @@ import TimelineOppositeContent, { timelineOppositeContentClasses } from "@mui/la import toast, { Toaster } from "react-hot-toast"; -/** - * Renders the content for the Journal Page - * @returns {JSX.Element} - */ export default function Journal() { const theme = useTheme(); - - // -- State variables -- + const [journalEntries, setJournalEntries] = useState([]); const [filteredJournalEntries, setFilteredJournalEntries] = useState([]); const [users, setUsers] = useState({}); + const [allUsers, setAllUsers] = useState([]); + const [scooployeeUsers, setScooployeeUsers] = useState({}); const [semesterGroups, setSemesterGroups] = useState({}); const [newEntryOpen, setNewEntryOpen] = useState(false); const [filterDialogOpen, setFilterDialogOpen] = useState(false); - - // Filter States + const [filterSemesterValue, setFilterSemesterValue] = useState(""); const [filterSenderValue, setFilterSenderValue] = useState(""); const [filterRecipientValue, setFilterRecipientValue] = useState(""); @@ -65,27 +57,27 @@ export default function Journal() { const [filterEntryTypeValue, setFilterEntryTypeValue] = useState(""); const [filterTimeValue, setFilterTimeValue] = useState("newest_first"); - // New Entry States + const [newEntryNotes, setNewEntryNotes] = useState(""); const [newEntrySemester, setNewEntrySemester] = useState(""); const [newEntryRecipientIds, setNewEntryRecipientIds] = useState([]); + const [newEntryRecipientObjects, setNewEntryRecipientObjects] = useState([]); const [newEntryTopicId, setNewEntryTopicId] = useState(""); const [newEntryPreviousId, setNewEntryPreviousId] = useState(null); const [newEntryVisibilityLevel, setNewEntryVisibilityLevel] = useState(""); const [newEntryIsComment, setNewEntryIsComment] = useState(false); - - // Edit States + const [editingEntry, setEditingEntry] = useState(null); const [editValue, setEditValue] = useState(""); const [replyEntry, setReplyEntry] = useState(null); const { user } = useUser(); - + useEffect(() => { - async function fetchEntries () { + async function fetchEntries() { if (user == null || user.id == null) return; try { const [entriesRes, usersRes, semestersRes] = await Promise.all([ - fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/journal/${user.id}`), + fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/journal/${user.id}`), fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users`), fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/semestergroup`), ]); @@ -100,10 +92,16 @@ export default function Journal() { handleApplyFilter(Array.isArray(entries) ? entries : []); const contacteeMap = {}; + const scooployeeMap = {}; loadedUsers.forEach((loadedUser) => { contacteeMap[loadedUser.id] = `${loadedUser.fname} ${loadedUser.lname}`; + if (loadedUser.type === "scooployee") { + scooployeeMap[loadedUser.id] = `${loadedUser.fname} ${loadedUser.lname}`; + } }); setUsers(contacteeMap); + setAllUsers(loadedUsers); + setScooployeeUsers(scooployeeMap); const semesterGroupMap = {}; semesterGroupsData.forEach((group) => { @@ -118,23 +116,19 @@ export default function Journal() { fetchEntries(); }, [user]); - //This is how the JSON download of all of the Journal entresi for a person will be done - - const handleJSONDownload = () =>{ - const JSONString = JSON.stringify(journalEntries,null,2); - const blob = new Blob([JSONString],{type:"application/json"}); + const handleJSONDownload = () => { + const JSONString = JSON.stringify(journalEntries, null, 2); + const blob = new Blob([JSONString], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `journal_entries_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(link); link.click(); - document.body.removeChild(link); URL.revokeObjectURL(url); }; - // -- Filters -- const handleFilterSemesterChange = (e) => setFilterSemesterValue(e.target.value || ""); const handleFilterRecipientChange = (e) => setFilterRecipientValue(e.target.value || ""); const handleFilterSenderChange = (e) => setFilterSenderValue(e.target.value || ""); @@ -142,49 +136,68 @@ export default function Journal() { const handleFilterEntryTypeChange = (e) => setFilterEntryTypeValue(e.target.value || ""); const handleFilterTimeChange = (e) => setFilterTimeValue(e.target.value || ""); - const handleApplyFilter = async (baseArray=null) => { - if(baseArray == null) baseArray = Array.from(journalEntries); - if(filterTopicValue) baseArray = baseArray.filter((entry) => entry.topic_id == filterTopicValue); + const handleApplyFilter = (baseArray = null) => { + if (baseArray == null) baseArray = Array.from(journalEntries); + if (filterTopicValue) baseArray = baseArray.filter((entry) => entry.topic_id == filterTopicValue); if (filterSemesterValue) baseArray = baseArray.filter((entry) => entry.semester_GroupId == filterSemesterValue); - if(filterRecipientValue) baseArray = baseArray.filter(entry => entry.recipients.some(recipient => recipient.id === filterRecipientValue)); - if(filterSenderValue) baseArray = baseArray.filter((entry) => entry.sender_id == filterSenderValue); - if(filterEntryTypeValue) baseArray = baseArray.filter((entry) => entry.entry_type == filterEntryTypeValue); - - if(filterTimeValue === "oldest_first") baseArray.sort((a,b) => new Date(a.date) - new Date(b.date)); - if(filterTimeValue === "newest_first") baseArray.sort((a,b) => new Date(b.date) - new Date(a.date)); - - setFilteredJournalEntries(baseArray) + if (filterRecipientValue) baseArray = baseArray.filter(entry => entry.recipients.some(recipient => recipient.id === filterRecipientValue)); + if (filterSenderValue) baseArray = baseArray.filter((entry) => entry.sender_id == filterSenderValue); + if (filterEntryTypeValue) baseArray = baseArray.filter((entry) => entry.entry_type == filterEntryTypeValue); + if (filterTimeValue === "oldest_first") baseArray.sort((a, b) => new Date(a.date) - new Date(b.date)); + if (filterTimeValue === "newest_first") baseArray.sort((a, b) => new Date(b.date) - new Date(a.date)); + setFilteredJournalEntries(baseArray); + }; + + const handleClearFilter = () => { + setFilterSemesterValue(""); + setFilterSenderValue(""); + setFilterRecipientValue(""); + setFilterTopicValue(""); + setFilterEntryTypeValue(""); + setFilterTimeValue("newest_first"); + const fullArray = Array.from(journalEntries).sort((a, b) => new Date(b.date) - new Date(a.date)); + setFilteredJournalEntries(fullArray); }; const getVisibilityOptions = () => { - let options = {"PERSONAL": "Private Note"}; - let options_map = {"scooployee": 1, "advisor": 2, "scoopervisor": 3, "scoopdinator": 4}; - if (!user?.id) return options; - switch(options_map[user.type]){ - case 4: options["4"] = "Scoopdinators only"; - case 3: options["3"] = "Scoopervisors and higher"; - case 2: options["2"] = "Advisors and higher"; - case 1: options["1"] = "Scooployees and higher"; - break; - } + const roleRank = { scooployee: 1, advisor: 2, scoopervisor: 3, scoopdinator: 4 }; + const rank = user?.type ? (roleRank[user.type] ?? 0) : 0; + const options = [{ value: "PERSONAL", label: "Private Note" }]; + if (rank >= 2) options.push({ value: "1", label: "Everyone" }); + if (rank >= 2) options.push({ value: "2", label: "Advisors and higher" }); + if (rank >= 3) options.push({ value: "3", label: "Scoopervisors and higher" }); + if (rank >= 4) options.push({ value: "4", label: "Scoopdinators only" }); return options; }; - // -- Edit Logic -- + const isPrivateEntry = newEntryVisibilityLevel === "PERSONAL"; + + const getEligibleRecipients = () => { + const roleRank = { scooployee: 1, advisor: 2, scoopervisor: 3, scoopdinator: 4 }; + const visibilityRank = parseInt(newEntryVisibilityLevel) || 0; + return Object.entries(users) + .filter(([id]) => { + const u = allUsers.find(u => u.id === id); + if (!u) return true; + return (roleRank[u.type] ?? 0) >= visibilityRank; + }) + .filter(([id]) => !newEntryRecipientIds.includes(id)) + .map(([id, name]) => ({ label: name, value: id })); + }; + const handleEditClick = (entry) => { setEditingEntry(entry); setEditValue(entry.notes); }; const handleCancelEdit = () => setEditingEntry(null); - + const saveEntryNotes = async (entry) => { try { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/journal/${entry.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ notes: editValue }), - } - ); + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ notes: editValue }), + }); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); setJournalEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, notes: editValue } : e))); setFilteredJournalEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, notes: editValue } : e))); @@ -198,48 +211,46 @@ export default function Journal() { }; const handleSaveEdit = (entry) => { - toast.promise(saveEntryNotes(entry), { loading: "Saving...", success: "Notes saved!", error: "Failed to save notes."}); + toast.promise(saveEntryNotes(entry), { loading: "Saving...", success: "Notes saved!", error: "Failed to save notes." }); setEditValue(""); }; - // -- New Entry Logic -- const postNewEntry = async () => { - let privacy_level = newEntryVisibilityLevel === "PERSONAL" ? "PERSONAL" : "PUBLIC"; - let visibility_level = newEntryVisibilityLevel !== "PERSONAL" ? parseInt(newEntryVisibilityLevel) : 1; - + const privacy_level = newEntryVisibilityLevel === "PERSONAL" ? "PERSONAL" : "PUBLIC"; + const visibility_level = newEntryVisibilityLevel !== "PERSONAL" ? parseInt(newEntryVisibilityLevel) : 1; + const entry = { date: new Date().toISOString(), sender_id: user.id, - notes: editValue, - recipient_ids: newEntryRecipientIds, + notes: newEntryNotes, + recipient_ids: isPrivateEntry ? [] : newEntryRecipientIds, topic_id: newEntryTopicId, semester_GroupId: Number(newEntrySemester), previous_entryid: parseInt(newEntryPreviousId) || null, entry_type: "MANUAL", - visibility_level: visibility_level, - privacy_level: privacy_level, + visibility_level, + privacy_level, }; try { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/journal`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(entry), - } - ); + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(entry), + }); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); const data = await res.json(); if (data.entry) { let updatedEntries = Array.from(journalEntries); - if(!newEntryIsComment){ - updatedEntries = [...updatedEntries, data.entry]; + if (!newEntryIsComment) { + updatedEntries = [...updatedEntries, data.entry]; } else { updatedEntries = updatedEntries.map((check_entry) => { if (check_entry.id == parseInt(newEntryPreviousId)) { - return { ...check_entry, next_entries: [...check_entry.next_entries, data.entry]}; + return { ...check_entry, next_entries: [...check_entry.next_entries, data.entry] }; } - return check_entry; + return check_entry; }); } setJournalEntries(updatedEntries); @@ -253,49 +264,45 @@ export default function Journal() { } }; + const handleOpenNewEntry = () => { + const visibilityOptions = getVisibilityOptions(); + if (visibilityOptions.length === 1 && visibilityOptions[0].value === "PERSONAL") { + setNewEntryVisibilityLevel("PERSONAL"); + } + setNewEntryOpen(true); + }; + const handleCancelNewEntry = () => { - setNewEntryOpen(false); - setEditValue(""); + setNewEntryOpen(false); + setTimeout(() => { + setNewEntryNotes(""); setNewEntrySemester(""); setNewEntryRecipientIds([]); + setNewEntryRecipientObjects([]); setNewEntryTopicId(""); setNewEntryPreviousId(null); setNewEntryVisibilityLevel(""); - setNewEntryIsComment(false); - } + setNewEntryIsComment(false); + }, 200); + }; + const handleCreateNewEntry = () => { - if (!newEntrySemester || !newEntryRecipientIds || !newEntryTopicId || !newEntryVisibilityLevel) { + const missingRecipients = !isPrivateEntry && !newEntryRecipientIds.length; + if (!newEntrySemester || missingRecipients || !newEntryTopicId || !newEntryVisibilityLevel || !newEntryNotes.trim()) { toast.error("Please fill out all fields."); return; } - toast.promise(postNewEntry(), { loading: "Creating new journal entry...", success: "Journal entry created!", error: "Failed to create a new journal entry."}); + toast.promise(postNewEntry(), { loading: "Creating new journal entry...", success: "Journal entry created!", error: "Failed to create a new journal entry." }); }; const getInitials = (fname, lname) => { return `${fname ? fname[0] : ""}${lname ? lname[0] : ""}`.toUpperCase(); }; - //This function will set the filter states back to their base, effectivily clearing the filter -const handleClearFilter = () =>{ - setFilterSemesterValue(""); - setFilterSenderValue(""); - setFilterRecipientValue(""); - setFilterTopicValue(""); - setFilterEntryTypeValue(""); - setFilterTimeValue("newest_first"); - - const fullArray = Array.from(journalEntries).sort((a, b) => new Date(b.date) - new Date(a.date)); - setFilteredJournalEntries(fullArray); -} - - /** - * Component: EntriesList - * Renders the Timeline with dark-mode support and readable avatars - */ - function EntriesList({entries, commentView = false} ){ + function EntriesList({ entries, commentView = false }) { const isDarkMode = theme.palette.mode === 'dark'; - if(entries == null || entries.length === 0){ + if (entries == null || entries.length === 0) { return ( No journal entries found. @@ -315,15 +322,11 @@ const handleClearFilter = () =>{ {entries.map((entry) => { const isSender = entry.sender_id === user.id; const entryDate = new Date(entry.date); - - // Determine color for the Avatar bubble const avatarBgColor = isSender ? theme.palette.primary.main : theme.palette.secondary.main; - // Determine text color based on contrast to background (fixes black-on-black) const avatarTextColor = theme.palette.getContrastText(avatarBgColor); return ( - {/* Left Side: Date */} {entryDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })} @@ -336,22 +339,21 @@ const handleClearFilter = () =>{ - {/* Center: Dot/Avatar */} - - {getInitials(entry.sender.fname, entry.sender.lname)} @@ -360,17 +362,15 @@ const handleClearFilter = () =>{ - {/* Right Side: Content */} - { {entry.sender.fname} {entry.sender.lname} - To: {entry.recipients.map(rec => rec.fname + " " + rec.lname).join(", ")} + To: {entry.recipients.length > 0 ? entry.recipients.map(rec => `${rec.fname} ${rec.lname}`).join(", ") : "No recipients"} - {isSender && ( - handleEditClick(entry)} sx={{ color: '#FF6A00' }}> - {/* */} + )} - {/* Metadata Chips */} - - {semesterGroups[entry.semester_GroupId] && ( - - )} - - + + {semesterGroups[entry.semester_GroupId] && ( + + )} + + - {/* The Note Body */} { color: 'text.primary' }} > - { - {/* Footer Actions */} {commentView && ( + - {/* Filter Dialog */} setFilterDialogOpen(false)} onSubmit={() => handleApplyFilter()} actionLabel="Apply Filter" - secondaryAction={} + secondaryAction={} > - + Semester - - + + Sender - - + + Recipient - - + + Topic - - + + Entry Type - - + + Time - + - {/* Edit Dialog */} {editingEntry && ( <> Edit Note setEditValue(e.target.value)} - fullWidth - sx={{ mt: 1 }} - /> + multiline + rows={8} + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + fullWidth + sx={{ mt: 1 }} + /> @@ -629,26 +647,25 @@ const handleClearFilter = () =>{ )} - {/* Reply Dialog */} setReplyEntry(null)} maxWidth="md" fullWidth> {replyEntry && ( - <> + <> Thread - {replyEntry.next_entries.length > 0 ? ( - - ) : ( - - No replies found -
- Be the first to reply! -
- )} + {replyEntry.next_entries.length > 0 ? ( + + ) : ( + + No replies found +
+ Be the first to reply! +
+ )}
- + - + )}
diff --git a/apps/scoop-portal/ui/src/app/scoopdinator/layout.js b/apps/scoop-portal/ui/src/app/scoopdinator/layout.js index b879f548..c5edb7ac 100644 --- a/apps/scoop-portal/ui/src/app/scoopdinator/layout.js +++ b/apps/scoop-portal/ui/src/app/scoopdinator/layout.js @@ -4,7 +4,7 @@ import { Box } from "@mui/material"; //edited "scoopdinator" export default function scoopdinatorLayout({ children }) { return ( - + {children} diff --git a/apps/scoop-portal/ui/src/app/scoopervisor/layout.js b/apps/scoop-portal/ui/src/app/scoopervisor/layout.js index d502a665..b373368c 100644 --- a/apps/scoop-portal/ui/src/app/scoopervisor/layout.js +++ b/apps/scoop-portal/ui/src/app/scoopervisor/layout.js @@ -5,7 +5,7 @@ import { Box } from "@mui/material"; export default function scoopervisorLayout({ children }) { return ( // add admin to required role? - + {children} diff --git a/apps/scoop-portal/ui/src/app/scooployee/layout.js b/apps/scoop-portal/ui/src/app/scooployee/layout.js new file mode 100644 index 00000000..4bbc514a --- /dev/null +++ b/apps/scoop-portal/ui/src/app/scooployee/layout.js @@ -0,0 +1,12 @@ +import ProtectedRoute from "../utils/ProtectedRoute"; +import { Box } from "@mui/material"; + +export default function scooployeeLayout({ children }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/apps/scoop-portal/ui/src/app/utils/ProtectedRoute.js b/apps/scoop-portal/ui/src/app/utils/ProtectedRoute.js index 75c934bd..29d83e7e 100644 --- a/apps/scoop-portal/ui/src/app/utils/ProtectedRoute.js +++ b/apps/scoop-portal/ui/src/app/utils/ProtectedRoute.js @@ -2,12 +2,12 @@ import { useUser } from "utils/user-context/page"; import UnauthorizedPage from "unauthorized/page"; -export default function ProtectedRoute({ children, requiredRole }) { +export default function ProtectedRoute({ children, requiredRoles = [] }) { const { user } = useUser(); - if (!user || user.type !== requiredRole) { + if (!user || !requiredRoles.includes(user.type)) { return ; } return children; -} +} \ No newline at end of file