From baf7eb0fee4141af5d1863180d7b38b707b22e45 Mon Sep 17 00:00:00 2001 From: Odzen Date: Sun, 30 Nov 2025 15:37:21 +0100 Subject: [PATCH 01/19] Add Juan Sebastian Velasquez and reorganize team section --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b71fcedf..d8ba858b 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,14 @@ This application is built with React, Flask, MongoDB, and Ant Design + + - - - + @@ -83,6 +83,9 @@ This application is built with React, Flask, MongoDB, and Ant Design + + +
Juan Sebastian Velasquez
Juan Sebastian Velasquez

IT Director
Leonardo Galindo
Leonardo Galindo

Technical Lead
Angela Luo
Angela Luo

Product Manager
Lam Tran
Lam Tran

Product Manager
Kelley Chau
Kelley Chau

Technical Lead
Kendall Hester
Kendall Hester

Technical Lead
Leonardo Galindo
Leonardo Galindo

Technical Lead
Faith Losbanes
Faith Losbanes

Product Designer
Kendall Hester
Kendall Hester

Technical Lead
Nikhil Gargeya
Nikhil Gargeya

Product Designer
Nayonika Roy
Nayonika Roy

Software Developer
Michael Chen
Michael Chen

Software Developer
Zayyan Faizal
Zayyan Faizal

Software Developer
Luciana Toledo-López
Luciana Toledo-Lopez

Software Developer
Faith Losbanes
Faith Losbanes

Product Designer
## License From 6a0a06e69055162d39247d4e65acfb4e951c1ddb Mon Sep 17 00:00:00 2001 From: Juan Sebastian Velasquez Acevedo Date: Tue, 6 Jan 2026 16:28:04 -0500 Subject: [PATCH 02/19] Add download functionality for partner mentors and mentees data in various formats; enhance AdminPartnerData component with export options and message display improvements. --- .gitignore | 4 +- backend/api/views/download.py | 290 ++++++++++- frontend/src/components/AdminPartnerData.js | 545 +++++++++++++++++--- frontend/src/utils/api.js | 32 ++ 4 files changed, 798 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 8d0d0d75..dd4995aa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ pyproject.toml000 app.log backend/app.log tests/.env -tests/cypress/screenshots/ \ No newline at end of file +tests/cypress/screenshots/ + +.cursor \ No newline at end of file diff --git a/backend/api/views/download.py b/backend/api/views/download.py index b34f4147..866f48ea 100644 --- a/backend/api/views/download.py +++ b/backend/api/views/download.py @@ -528,7 +528,7 @@ def generate_sheet(sheet_name, row_data, columns): worksheet = writer.sheets[sheet_name] format = workbook.add_format() format.set_bg_color("#eeeeee") - worksheet.set_column(0, len(row_data[0]), 28) + worksheet.set_column(0, len(row_data[0]) if row_data else 0, 28) writer.close() output.seek(0) @@ -544,3 +544,291 @@ def generate_sheet(sheet_name, row_data, columns): msg = "Downloads failed" logger.info(msg) return create_response(status=422, message=msg) + + +def generate_file(file_name, row_data, columns, file_format="xlsx"): + """Generate either CSV or Excel file based on format parameter.""" + df = pd.DataFrame(row_data, columns=columns) + output = BytesIO() + + if file_format == "csv": + # Generate CSV + csv_data = df.to_csv(index=False) + output.write(csv_data.encode("utf-8")) + output.seek(0) + + try: + return send_file( + output, + mimetype="text/csv", + download_name="{0}.csv".format(file_name), + as_attachment=True, + ) + except FileNotFoundError: + msg = "Downloads failed" + logger.info(msg) + return create_response(status=422, message=msg) + else: + # Generate Excel (default) + writer = pd.ExcelWriter(output, engine="xlsxwriter") + + df.to_excel( + writer, startrow=0, merge_cells=False, sheet_name=file_name, index=False + ) + workbook = writer.book + worksheet = writer.sheets[file_name] + format = workbook.add_format() + format.set_bg_color("#eeeeee") + worksheet.set_column(0, len(row_data[0]) if row_data else 0, 28) + + writer.close() + output.seek(0) + + try: + return send_file( + output, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + download_name="{0}.xlsx".format(file_name), + as_attachment=True, + ) + except FileNotFoundError: + msg = "Downloads failed" + logger.info(msg) + return create_response(status=422, message=msg) + + +@download.route("/partner//accounts", methods=["GET"]) +@admin_only +def download_partner_accounts_data(partner_id): + """Download mentors or mentees assigned to a specific partner with activity status.""" + data = request.args + account_type = int(data.get("account_type", 0)) + file_format = data.get("format", "xlsx") # 'xlsx' or 'csv' + + try: + partner = PartnerProfile.objects.get(id=partner_id) + except: + msg = "Partner not found" + logger.info(msg) + return create_response(status=422, message=msg) + + messages = DirectMessage.objects() + + if account_type == Account.MENTOR: + return download_partner_mentor_accounts(partner, messages, file_format) + elif account_type == Account.MENTEE: + return download_partner_mentee_accounts(partner, messages, file_format) + + msg = "Invalid account type" + logger.info(msg) + return create_response(status=422, message=msg) + + +def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): + """Download mentors assigned to a partner with activity status.""" + if not partner.assign_mentors: + return generate_file("partner_mentors", [], get_mentor_columns_with_activity(), file_format) + + accts = [] + + for mentor_ref in partner.assign_mentors: + mentor_id = mentor_ref.get("id") + if not mentor_id: + continue + + try: + mentor = MentorProfile.objects.get(id=mentor_id) + except: + continue + + # Count messages + sent_messages = [msg for msg in messages if str(msg.sender_id) == str(mentor.id)] + received_messages = [msg for msg in messages if str(msg.recipient_id) == str(mentor.id)] + + # Determine activity status + total_sent = len(sent_messages) + total_received = len(received_messages) + has_messages = total_sent > 0 or total_received > 0 + + if has_messages: + is_active = "Yes" + activity_reason = f"Sent: {total_sent}, Received: {total_received} messages" + else: + is_active = "No" + activity_reason = "No messages sent or received" + + # Format educations + educations = [] + for edu in mentor.education: + educations.append( + "{0} from {1} ({2})".format( + edu.education_level if edu.education_level else "", + edu.school if edu.school else "", + edu.graduation_year if edu.graduation_year else "", + ) + ) + + accts.append( + [ + mentor.name, + mentor.email, + mentor.professional_title, + mentor.linkedin, + mentor.website, + "Yes" if mentor.image and mentor.image.url else "No", + mentor.image.url if mentor.image else "", + mentor.videos[0].url if mentor.videos and len(mentor.videos) > 0 else "", + "Yes" if mentor.videos and len(mentor.videos) > 0 else "No", + "|".join(educations), + ",".join(mentor.languages) if mentor.languages else "", + ",".join(mentor.specializations) if mentor.specializations else "", + mentor.biography, + "Yes" if mentor.taking_appointments else "No", + "Yes" if mentor.offers_in_person else "No", + "Yes" if mentor.offers_group_appointments else "No", + "Yes" if mentor.text_notifications else "No", + "Yes" if mentor.email_notifications else "No", + total_received, + total_sent, + partner.organization if partner.organization else "", + is_active, + activity_reason, + ] + ) + + return generate_file("partner_mentors", accts, get_mentor_columns_with_activity(), file_format) + + +def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): + """Download mentees assigned to a partner with activity status.""" + if not partner.assign_mentees: + return generate_file("partner_mentees", [], get_mentee_columns_with_activity(), file_format) + + accts = [] + + for mentee_ref in partner.assign_mentees: + mentee_id = mentee_ref.get("id") + if not mentee_id: + continue + + try: + mentee = MenteeProfile.objects.get(id=mentee_id) + except: + continue + + # Count messages + sent_messages = [msg for msg in messages if str(msg.sender_id) == str(mentee.id)] + received_messages = [msg for msg in messages if str(msg.recipient_id) == str(mentee.id)] + + # Determine activity status + total_sent = len(sent_messages) + total_received = len(received_messages) + has_messages = total_sent > 0 or total_received > 0 + + if has_messages: + is_active = "Yes" + activity_reason = f"Sent: {total_sent}, Received: {total_received} messages" + else: + is_active = "No" + activity_reason = "No messages sent or received" + + # Format educations + educations = [] + for edu in mentee.education: + educations.append( + "{0} from {1} ({2})".format( + edu.education_level if edu.education_level else "", + edu.school if edu.school else "", + edu.graduation_year if edu.graduation_year else "", + ) + ) + if mentee.education_level and not educations: + educations.append(EDUCATION_LEVEL.get(mentee.education_level, mentee.education_level)) + + accts.append( + [ + mentee.name, + mentee.gender, + mentee.location, + mentee.age, + mentee.email, + mentee.phone_number, + mentee.image.url if mentee.image else "", + "|".join(educations), + ",".join(mentee.languages) if mentee.languages else "", + "|".join(mentee.specializations) if mentee.specializations else "", + mentee.biography, + partner.organization if partner.organization else "", + "Yes" if mentee.image and mentee.image.url else "No", + "Yes" if mentee.video and mentee.video.url else "No", + 1 if mentee.text_notifications else 0, + 1 if mentee.email_notifications else 0, + 1 if mentee.is_private else 0, + mentee.video.url if mentee.video else "", + ",".join(mentee.favorite_mentors_ids) if mentee.favorite_mentors_ids else "", + total_sent, + total_received, + partner.organization if partner.organization else "", + is_active, + activity_reason, + ] + ) + + return generate_file("partner_mentees", accts, get_mentee_columns_with_activity(), file_format) + + +def get_mentor_columns_with_activity(): + return [ + "mentor Full Name", + "email", + "professional_title", + "linkedin", + "website", + "profile pic up", + "image url", + "video url", + "video(s) up", + "educations", + "languages", + "specializations", + "biography", + "taking_appointments", + "offers_in_person", + "offers_group_appointments", + "text_notifications", + "email_notifications", + "total_received_messages", + "total_sent_messages", + "Affiliated", + "is_active", + "activity_reason", + ] + + +def get_mentee_columns_with_activity(): + return [ + "mentee name", + "gender", + "location", + "age", + "email", + "phone number", + "image url", + "educations", + "languages", + "Areas of interest", + "biography", + "Organization Affiliation", + "profile pic up", + "video(s) up", + "text_notifications", + "email_notifications", + "private account", + "video url", + "favorite_mentor_ids", + "total_sent_messages", + "total_received_messages", + "Affiliated", + "is_active", + "activity_reason", + ] diff --git a/frontend/src/components/AdminPartnerData.js b/frontend/src/components/AdminPartnerData.js index 68d84a27..f14a7420 100644 --- a/frontend/src/components/AdminPartnerData.js +++ b/frontend/src/components/AdminPartnerData.js @@ -1,11 +1,15 @@ import React, { useEffect, useState } from "react"; -import { fetchAccounts, fetchAccountById } from "utils/api"; +import { + fetchAccounts, + fetchAccountById, + downloadPartnerMentorsData, + downloadPartnerMenteesData, +} from "utils/api"; import Meta from "antd/lib/card/Meta"; import { Table, Input, Dropdown, - Menu, message, Avatar, Layout, @@ -21,12 +25,22 @@ import { Typography, Tag, Button, + Tooltip, + Space, + Segmented, + Badge, } from "antd"; import { UserOutlined, SearchOutlined, DownOutlined, ExclamationCircleOutlined, + DownloadOutlined, + TeamOutlined, + CheckCircleOutlined, + MessageOutlined, + FileExcelOutlined, + FileTextOutlined, } from "@ant-design/icons"; import { HubsDropdown } from "../components/AdminDropdowns"; @@ -138,27 +152,131 @@ export const AdminPartnerData = () => { setModalData(sortedMessages || []); }; - const overlay = ( - - - setOption(options.MENTORS)}>Mentors - - - setOption(options.MENTEES)}>Mentees - - - ); + + // Collapsible message list component for the table + const MessageListCell = ({ message_receive_data, record }) => { + const [expanded, setExpanded] = useState(false); + const activeMessages = message_receive_data?.filter( + (item) => item.numberOfMessages > 0 + ); + + if (!activeMessages || activeMessages.length === 0) { + return ( + + No messages + + ); + } + + const displayCount = expanded ? activeMessages.length : 3; + const visibleItems = activeMessages.slice(0, displayCount); + const remainingCount = activeMessages.length - 3; + + return ( +
+ {visibleItems.map((item, index) => ( +
showDetailModal(item, record)} + > + } + src={item.image ? item.image.url : ""} + className={css` + flex-shrink: 0; + `} + /> +
+ + {item.receiver_name} + +
+ +
+ ))} + + {activeMessages.length > 3 && ( + + )} +
+ ); + }; const columns = [ { title: "Logo", dataIndex: "image", key: "image", + width: 60, render: (image) => { return (
} className="modal-profile-icon2" src={image ? image.url : ""} @@ -171,55 +289,37 @@ export const AdminPartnerData = () => { title: "Name", dataIndex: "name", key: "organization", - render: (organization) => {organization}, + width: 180, + render: (organization) => ( + {organization} + ), }, { title: "Email", dataIndex: "email", key: "email", - render: (email) => {email}, + width: 220, + render: (email) => ( + {email} + ), }, { - title: - option.key === ACCOUNT_TYPE.MENTEE - ? "Receiver(Count of Messages)" - : "Sender(Count of Messages)", + title: ( + + + {option.key === ACCOUNT_TYPE.MENTEE + ? "Conversations with Mentors" + : "Conversations with Mentees"} + + ), dataIndex: "message_receive_data", key: "message_receive_data", - render: (message_receive_data, record) => { - return ( - <> - {message_receive_data && - message_receive_data.length > 0 && - message_receive_data.map((item) => { - if (item.numberOfMessages > 0) { - return ( -
- } - className="modal-profile-icon2" - src={item.image ? item.image.url : ""} - /> -
showDetailModal(item, record)} - style={{ - cursor: "pointer", - textDecoration: "underline", - }} - > - {item.receiver_name}  ({item.numberOfMessages} - ) -
-
- ); - } else { - return <>; - } - })} - - ); - }, + render: (message_receive_data, record) => ( + + ), }, ]; useEffect(() => { @@ -511,24 +611,289 @@ export const AdminPartnerData = () => { paddingRight: "2rem", }} > -
- - - {option.text} - - - - setSelectActived(e)} - style={{ marginLeft: "2rem", marginRight: "0.5rem" }} - checked={selectActived} - /> - {"Active"} + {/* Header Controls */} +
+ {/* Left side: View toggle and Active filter */} + + {/* Mentors/Mentees Segmented Control */} +
+ + { + setOption( + value === ACCOUNT_TYPE.MENTOR + ? options.MENTORS + : options.MENTEES + ); + }} + options={[ + { + label: ( + + + Mentees + + ), + value: ACCOUNT_TYPE.MENTEE, + }, + { + label: ( + + + Mentors + + ), + value: ACCOUNT_TYPE.MENTOR, + }, + ]} + /> +
+ + {/* Active Toggle with Tooltip */} + + What is an Active User? +

+ An active user is someone who has exchanged at least one + message with another user (mentor or mentee). This filter + shows only users who have active conversations. +

+
+ } + placement="bottom" + overlayStyle={{ maxWidth: 300 }} + > +
setSelectActived(!selectActived)} + > + setSelectActived(e)} + /> + + + Active Only + + + (?) + +
+ + + + {/* Right side: Export Button */} + {selectedPartner && ( + + + Export Mentees Data + + ), + children: [ + { + key: "mentees-xlsx", + label: ( + + + Excel (.xlsx) + + ), + onClick: () => { + messageApi.loading("Preparing mentees Excel export..."); + downloadPartnerMenteesData(selectedPartner._id.$oid, "xlsx") + .then(() => { + messageApi.success("Mentees Excel downloaded!"); + }) + .catch(() => { + messageApi.error("Failed to export mentees data"); + }); + }, + }, + { + key: "mentees-csv", + label: ( + + + CSV (.csv) + + ), + onClick: () => { + messageApi.loading("Preparing mentees CSV export..."); + downloadPartnerMenteesData(selectedPartner._id.$oid, "csv") + .then(() => { + messageApi.success("Mentees CSV downloaded!"); + }) + .catch(() => { + messageApi.error("Failed to export mentees data"); + }); + }, + }, + ], + }, + { + key: "mentors", + label: ( + + + Export Mentors Data + + ), + children: [ + { + key: "mentors-xlsx", + label: ( + + + Excel (.xlsx) + + ), + onClick: () => { + messageApi.loading("Preparing mentors Excel export..."); + downloadPartnerMentorsData(selectedPartner._id.$oid, "xlsx") + .then(() => { + messageApi.success("Mentors Excel downloaded!"); + }) + .catch(() => { + messageApi.error("Failed to export mentors data"); + }); + }, + }, + { + key: "mentors-csv", + label: ( + + + CSV (.csv) + + ), + onClick: () => { + messageApi.loading("Preparing mentors CSV export..."); + downloadPartnerMentorsData(selectedPartner._id.$oid, "csv") + .then(() => { + messageApi.success("Mentors CSV downloaded!"); + }) + .catch(() => { + messageApi.error("Failed to export mentors data"); + }); + }, + }, + ], + }, + { + type: "divider", + }, + { + key: "info", + label: ( + + Exports include activity status + + ), + disabled: true, + }, + ], + }} + trigger={["click"]} + > + + + )}
+ + {/* Partner Info Banner */} + {selectedPartner && ( +
+ } + src={selectedPartner.image ? selectedPartner.image.url : null} + /> +
+ + {selectedPartner.name || selectedPartner.organization} + +
+ + Showing {tableData?.length || 0} {option.text.toLowerCase()} + {selectActived ? " with active conversations" : ""} + +
+
+
+ )} + + {/* Table */}
{ `} spinning={subLoading} > - +
record.id?.$oid || record.email} + pagination={{ + pageSize: 10, + showSizeChanger: true, + showTotal: (total, range) => + `${range[0]}-${range[1]} of ${total} ${option.text.toLowerCase()}`, + }} + locale={{ + emptyText: selectedPartner ? ( +
+ +
+ + {selectActived + ? `No active ${option.text.toLowerCase()} found for this partner` + : `No ${option.text.toLowerCase()} assigned to this partner`} + +
+
+ ) : ( +
+ +
+ + Select a partner from the sidebar to view their{" "} + {option.text.toLowerCase()} + +
+
+ ), + }} + /> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 257adcdc..8cfe82c2 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1421,3 +1421,35 @@ export const getGroupParticipants = (hubId) => { } ); }; + +export const downloadPartnerMentorsData = async (partnerId, format = "xlsx") => { + const requestExtension = `/download/partner/${partnerId}/accounts`; + let response = await authGet(requestExtension, { + responseType: "blob", + params: { + account_type: ACCOUNT_TYPE.MENTOR, + format: format, + }, + }).catch(console.error); + + if (response) { + const extension = format === "csv" ? "csv" : "xlsx"; + downloadBlob(response, `partner_mentors.${extension}`); + } +}; + +export const downloadPartnerMenteesData = async (partnerId, format = "xlsx") => { + const requestExtension = `/download/partner/${partnerId}/accounts`; + let response = await authGet(requestExtension, { + responseType: "blob", + params: { + account_type: ACCOUNT_TYPE.MENTEE, + format: format, + }, + }).catch(console.error); + + if (response) { + const extension = format === "csv" ? "csv" : "xlsx"; + downloadBlob(response, `partner_mentees.${extension}`); + } +}; From 14254906a6be2dc61f9de83bf51caa4db5ab5f03 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Velasquez Acevedo Date: Tue, 6 Jan 2026 16:35:02 -0500 Subject: [PATCH 03/19] format backend --- backend/api/utils/profile_parse.py | 24 ++++++++------ backend/api/views/download.py | 52 ++++++++++++++++++++++-------- backend/api/views/main.py | 6 ++-- backend/uv.lock | 3 ++ 4 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 backend/uv.lock diff --git a/backend/api/utils/profile_parse.py b/backend/api/utils/profile_parse.py index 94265981..6c218244 100644 --- a/backend/api/utils/profile_parse.py +++ b/backend/api/utils/profile_parse.py @@ -332,9 +332,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video_data.get("title"), url=video_data.get("url"), tag=video_data.get("tag"), - date_uploaded=video_data.get("date_uploaded")["$date"] - if "$date" in video_data.get("date_uploaded") - else video_data.get("date_uploaded"), + date_uploaded=( + video_data.get("date_uploaded")["$date"] + if "$date" in video_data.get("date_uploaded") + else video_data.get("date_uploaded") + ), ) if profile.videos: profile.videos[0] = profile.video @@ -353,9 +355,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video.get("title"), url=video.get("url"), tag=video.get("tag"), - date_uploaded=video.get("date_uploaded")["$date"] - if "$date" in video.get("date_uploaded") - else video.get("date_uploaded"), + date_uploaded=( + video.get("date_uploaded")["$date"] + if "$date" in video.get("date_uploaded") + else video.get("date_uploaded") + ), ) for video in video_data ] @@ -413,9 +417,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video_data.get("title"), url=video_data.get("url"), tag=video_data.get("tag"), - date_uploaded=video_data.get("date_uploaded")["$date"] - if "$date" in video_data.get("date_uploaded") - else video_data.get("date_uploaded"), + date_uploaded=( + video_data.get("date_uploaded")["$date"] + if "$date" in video_data.get("date_uploaded") + else video_data.get("date_uploaded") + ), ) else: profile.video = None diff --git a/backend/api/views/download.py b/backend/api/views/download.py index 866f48ea..a5f37b2a 100644 --- a/backend/api/views/download.py +++ b/backend/api/views/download.py @@ -202,9 +202,7 @@ def download_mentor_apps(apps, partner_object): ( partner_object[acct.partner] if acct.partner and acct.partner in partner_object - else acct.organization - if acct.organization - else "" + else acct.organization if acct.organization else "" ), ] ) @@ -627,7 +625,9 @@ def download_partner_accounts_data(partner_id): def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): """Download mentors assigned to a partner with activity status.""" if not partner.assign_mentors: - return generate_file("partner_mentors", [], get_mentor_columns_with_activity(), file_format) + return generate_file( + "partner_mentors", [], get_mentor_columns_with_activity(), file_format + ) accts = [] @@ -642,8 +642,12 @@ def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): continue # Count messages - sent_messages = [msg for msg in messages if str(msg.sender_id) == str(mentor.id)] - received_messages = [msg for msg in messages if str(msg.recipient_id) == str(mentor.id)] + sent_messages = [ + msg for msg in messages if str(msg.sender_id) == str(mentor.id) + ] + received_messages = [ + msg for msg in messages if str(msg.recipient_id) == str(mentor.id) + ] # Determine activity status total_sent = len(sent_messages) @@ -677,7 +681,11 @@ def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): mentor.website, "Yes" if mentor.image and mentor.image.url else "No", mentor.image.url if mentor.image else "", - mentor.videos[0].url if mentor.videos and len(mentor.videos) > 0 else "", + ( + mentor.videos[0].url + if mentor.videos and len(mentor.videos) > 0 + else "" + ), "Yes" if mentor.videos and len(mentor.videos) > 0 else "No", "|".join(educations), ",".join(mentor.languages) if mentor.languages else "", @@ -696,13 +704,17 @@ def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): ] ) - return generate_file("partner_mentors", accts, get_mentor_columns_with_activity(), file_format) + return generate_file( + "partner_mentors", accts, get_mentor_columns_with_activity(), file_format + ) def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): """Download mentees assigned to a partner with activity status.""" if not partner.assign_mentees: - return generate_file("partner_mentees", [], get_mentee_columns_with_activity(), file_format) + return generate_file( + "partner_mentees", [], get_mentee_columns_with_activity(), file_format + ) accts = [] @@ -717,8 +729,12 @@ def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): continue # Count messages - sent_messages = [msg for msg in messages if str(msg.sender_id) == str(mentee.id)] - received_messages = [msg for msg in messages if str(msg.recipient_id) == str(mentee.id)] + sent_messages = [ + msg for msg in messages if str(msg.sender_id) == str(mentee.id) + ] + received_messages = [ + msg for msg in messages if str(msg.recipient_id) == str(mentee.id) + ] # Determine activity status total_sent = len(sent_messages) @@ -743,7 +759,9 @@ def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): ) ) if mentee.education_level and not educations: - educations.append(EDUCATION_LEVEL.get(mentee.education_level, mentee.education_level)) + educations.append( + EDUCATION_LEVEL.get(mentee.education_level, mentee.education_level) + ) accts.append( [ @@ -765,7 +783,11 @@ def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): 1 if mentee.email_notifications else 0, 1 if mentee.is_private else 0, mentee.video.url if mentee.video else "", - ",".join(mentee.favorite_mentors_ids) if mentee.favorite_mentors_ids else "", + ( + ",".join(mentee.favorite_mentors_ids) + if mentee.favorite_mentors_ids + else "" + ), total_sent, total_received, partner.organization if partner.organization else "", @@ -774,7 +796,9 @@ def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): ] ) - return generate_file("partner_mentees", accts, get_mentee_columns_with_activity(), file_format) + return generate_file( + "partner_mentees", accts, get_mentee_columns_with_activity(), file_format + ) def get_mentor_columns_with_activity(): diff --git a/backend/api/views/main.py b/backend/api/views/main.py index 97e4c45b..88d151f8 100644 --- a/backend/api/views/main.py +++ b/backend/api/views/main.py @@ -125,9 +125,9 @@ def get_accounts(account_type): if partner_account.assign_mentees: for mentee_item in partner_account.assign_mentees: if "id" in mentee_item: - partners_by_assign_mentee[ - str(mentee_item["id"]) - ] = partner_account + partners_by_assign_mentee[str(mentee_item["id"])] = ( + partner_account + ) for account in mentees_data: if str(account.id) in partners_by_assign_mentee: pair_partner = partners_by_assign_mentee[str(account.id)] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 00000000..c0ba0910 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" From 1f9763ee91134f4e9313e9b1627ec997756f5d7a Mon Sep 17 00:00:00 2001 From: Juan Sebastian Velasquez Acevedo Date: Tue, 6 Jan 2026 16:38:27 -0500 Subject: [PATCH 04/19] reran black --- backend/api/views/download.py | 4 +++- backend/api/views/main.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/api/views/download.py b/backend/api/views/download.py index a5f37b2a..242cf63a 100644 --- a/backend/api/views/download.py +++ b/backend/api/views/download.py @@ -202,7 +202,9 @@ def download_mentor_apps(apps, partner_object): ( partner_object[acct.partner] if acct.partner and acct.partner in partner_object - else acct.organization if acct.organization else "" + else acct.organization + if acct.organization + else "" ), ] ) diff --git a/backend/api/views/main.py b/backend/api/views/main.py index 88d151f8..97e4c45b 100644 --- a/backend/api/views/main.py +++ b/backend/api/views/main.py @@ -125,9 +125,9 @@ def get_accounts(account_type): if partner_account.assign_mentees: for mentee_item in partner_account.assign_mentees: if "id" in mentee_item: - partners_by_assign_mentee[str(mentee_item["id"])] = ( - partner_account - ) + partners_by_assign_mentee[ + str(mentee_item["id"]) + ] = partner_account for account in mentees_data: if str(account.id) in partners_by_assign_mentee: pair_partner = partners_by_assign_mentee[str(account.id)] From fd45c955a24afbde938d187463554ef6f76ed166 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Velasquez Acevedo Date: Tue, 6 Jan 2026 18:37:27 -0500 Subject: [PATCH 05/19] format frontend --- frontend/src/components/AdminPartnerData.js | 69 ++++++++++++++++----- frontend/src/utils/api.js | 10 ++- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/AdminPartnerData.js b/frontend/src/components/AdminPartnerData.js index f14a7420..ae2c2b89 100644 --- a/frontend/src/components/AdminPartnerData.js +++ b/frontend/src/components/AdminPartnerData.js @@ -152,7 +152,6 @@ export const AdminPartnerData = () => { setModalData(sortedMessages || []); }; - // Collapsible message list component for the table const MessageListCell = ({ message_receive_data, record }) => { const [expanded, setExpanded] = useState(false); @@ -750,13 +749,20 @@ export const AdminPartnerData = () => { ), onClick: () => { - messageApi.loading("Preparing mentees Excel export..."); - downloadPartnerMenteesData(selectedPartner._id.$oid, "xlsx") + messageApi.loading( + "Preparing mentees Excel export..." + ); + downloadPartnerMenteesData( + selectedPartner._id.$oid, + "xlsx" + ) .then(() => { messageApi.success("Mentees Excel downloaded!"); }) .catch(() => { - messageApi.error("Failed to export mentees data"); + messageApi.error( + "Failed to export mentees data" + ); }); }, }, @@ -769,13 +775,20 @@ export const AdminPartnerData = () => { ), onClick: () => { - messageApi.loading("Preparing mentees CSV export..."); - downloadPartnerMenteesData(selectedPartner._id.$oid, "csv") + messageApi.loading( + "Preparing mentees CSV export..." + ); + downloadPartnerMenteesData( + selectedPartner._id.$oid, + "csv" + ) .then(() => { messageApi.success("Mentees CSV downloaded!"); }) .catch(() => { - messageApi.error("Failed to export mentees data"); + messageApi.error( + "Failed to export mentees data" + ); }); }, }, @@ -799,13 +812,20 @@ export const AdminPartnerData = () => { ), onClick: () => { - messageApi.loading("Preparing mentors Excel export..."); - downloadPartnerMentorsData(selectedPartner._id.$oid, "xlsx") + messageApi.loading( + "Preparing mentors Excel export..." + ); + downloadPartnerMentorsData( + selectedPartner._id.$oid, + "xlsx" + ) .then(() => { messageApi.success("Mentors Excel downloaded!"); }) .catch(() => { - messageApi.error("Failed to export mentors data"); + messageApi.error( + "Failed to export mentors data" + ); }); }, }, @@ -818,13 +838,20 @@ export const AdminPartnerData = () => { ), onClick: () => { - messageApi.loading("Preparing mentors CSV export..."); - downloadPartnerMentorsData(selectedPartner._id.$oid, "csv") + messageApi.loading( + "Preparing mentors CSV export..." + ); + downloadPartnerMentorsData( + selectedPartner._id.$oid, + "csv" + ) .then(() => { messageApi.success("Mentors CSV downloaded!"); }) .catch(() => { - messageApi.error("Failed to export mentors data"); + messageApi.error( + "Failed to export mentors data" + ); }); }, }, @@ -909,13 +936,19 @@ export const AdminPartnerData = () => { pageSize: 10, showSizeChanger: true, showTotal: (total, range) => - `${range[0]}-${range[1]} of ${total} ${option.text.toLowerCase()}`, + `${range[0]}-${ + range[1] + } of ${total} ${option.text.toLowerCase()}`, }} locale={{ emptyText: selectedPartner ? (
@@ -928,7 +961,11 @@ export const AdminPartnerData = () => { ) : (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8cfe82c2..a7304fe3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1422,7 +1422,10 @@ export const getGroupParticipants = (hubId) => { ); }; -export const downloadPartnerMentorsData = async (partnerId, format = "xlsx") => { +export const downloadPartnerMentorsData = async ( + partnerId, + format = "xlsx" +) => { const requestExtension = `/download/partner/${partnerId}/accounts`; let response = await authGet(requestExtension, { responseType: "blob", @@ -1438,7 +1441,10 @@ export const downloadPartnerMentorsData = async (partnerId, format = "xlsx") => } }; -export const downloadPartnerMenteesData = async (partnerId, format = "xlsx") => { +export const downloadPartnerMenteesData = async ( + partnerId, + format = "xlsx" +) => { const requestExtension = `/download/partner/${partnerId}/accounts`; let response = await authGet(requestExtension, { responseType: "blob", From 1fd03e724d7609046cc7ccf5fa4231ec575ec551 Mon Sep 17 00:00:00 2001 From: Juan Velasquez <49286935+Odzen@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:36:40 -0500 Subject: [PATCH 06/19] Bug form submission (#1359) --- backend/api/models/BugReport.py | 33 ++ backend/api/models/__init__.py | 2 + backend/api/utils/google_storage.py | 39 ++ backend/api/views/admin.py | 116 ++++ backend/api/views/main.py | 247 +++++++++ frontend/src/app/App.js | 20 +- frontend/src/components/BugReportModal.js | 225 ++++++++ frontend/src/components/NavigationHeader.js | 11 +- .../src/components/pages/AdminBugReports.js | 498 ++++++++++++++++++ frontend/src/components/pages/HomeLayout.js | 9 +- frontend/src/index.js | 9 + frontend/src/utils/api.js | 53 ++ frontend/src/utils/hooks/useSidebars.js | 4 + 13 files changed, 1260 insertions(+), 6 deletions(-) create mode 100644 backend/api/models/BugReport.py create mode 100644 frontend/src/components/BugReportModal.js create mode 100644 frontend/src/components/pages/AdminBugReports.js diff --git a/backend/api/models/BugReport.py b/backend/api/models/BugReport.py new file mode 100644 index 00000000..683763dd --- /dev/null +++ b/backend/api/models/BugReport.py @@ -0,0 +1,33 @@ +from api.core import Mixin +from .base import db +from mongoengine import * +from flask_mongoengine import Document +from datetime import datetime + + +class BugReport(Document, Mixin): + """Bug Report Collection.""" + + description = StringField(required=True) + user_name = StringField(required=True) + user_email = StringField(required=True) + user_id = ObjectIdField() # Link to Users if logged in + role = StringField() + context = StringField() # navigation-header, home-layout, etc. + page_url = StringField() + status = StringField(default="new") # new, in_progress, resolved, closed + priority = StringField(default="medium") # low, medium, high, critical + attachments = ListField(DictField()) # Store file URLs and metadata from GCS + date_submitted = DateTimeField(required=True, default=datetime.utcnow) + resolved_date = DateTimeField() + resolved_by = StringField() + notes = StringField() # Admin notes + email_sent = BooleanField(default=False) + email_error = StringField() + + def __repr__(self): + return f"""""" diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index 3a0b7b8b..73bac6f1 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -36,6 +36,7 @@ from .SignedDocs import SignedDocs from .Announcement import Announcement from .CommunityLibrary import CommunityLibrary +from .BugReport import BugReport __all__ = [ "db", @@ -73,6 +74,7 @@ "PartnerGroupMessage", "Announcement", "CommunityLibrary", + "BugReport", ] # You must import all of the new Models you create to this page diff --git a/backend/api/utils/google_storage.py b/backend/api/utils/google_storage.py index 65377d5d..1e155fcd 100644 --- a/backend/api/utils/google_storage.py +++ b/backend/api/utils/google_storage.py @@ -2,6 +2,9 @@ from google.cloud import storage from api.core import logger from io import BytesIO +import base64 +import datetime +from uuid import uuid4 client = storage.Client() BUCKET = "app-mentee-global-images" @@ -15,6 +18,42 @@ def upload_image_to_storage(image, filename): return blob.public_url +def upload_bug_report_attachment(file_content_base64, original_filename, content_type): + """ + Upload bug report attachment to Google Cloud Storage + + :param file_content_base64: Base64 encoded file content + :param original_filename: Original filename + :param content_type: MIME type of the file + :return: tuple (public_url, gcs_filename) + """ + try: + # Decode base64 content + file_data = base64.b64decode(file_content_base64) + + # Generate unique filename: bug-reports/timestamp_uuid_originalname + timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + unique_id = str(uuid4())[:8] + # Sanitize filename + safe_filename = "".join(c for c in original_filename if c.isalnum() or c in "._- ") + gcs_filename = f"bug-reports/{timestamp}_{unique_id}_{safe_filename}" + + # Upload to GCS + bucket = client.get_bucket(BUCKET) + blob = bucket.blob(gcs_filename) + blob.upload_from_string(file_data, content_type=content_type) + + # Note: Bucket has uniform bucket-level access enabled + # Public access is controlled at bucket level, not per-object + + logger.info(f"Uploaded bug report attachment: {gcs_filename}") + return blob.public_url, gcs_filename + + except Exception as e: + logger.error(f"Failed to upload bug report attachment {original_filename}: {e}") + raise + + def delete_image_from_storage(filename): """Delete image from Google Cloud Storage""" bucket = client.get_bucket(BUCKET) diff --git a/backend/api/views/admin.py b/backend/api/views/admin.py index f956db47..eddbc69f 100644 --- a/backend/api/views/admin.py +++ b/backend/api/views/admin.py @@ -17,6 +17,7 @@ Guest, Hub, Image, + BugReport, ) from api.utils.require_auth import admin_only from api.utils.request_utils import get_profile_model, imgur_client @@ -272,6 +273,121 @@ def upload_account_emailText(): return create_response(status=200, message="Add users successfully") +# Bug Reports Management - MUST come before /admin/ route +@admin.route("/admin/bug-reports", methods=["GET"]) +@admin_only +def get_bug_reports(): + """Get all bug reports with optional filtering + + Query params: + status: Filter by status (new, in_progress, resolved, closed) + priority: Filter by priority (low, medium, high, critical) + user_email: Filter by user email + limit: Number of results (default 100) + """ + try: + # Get query parameters + status = request.args.get("status") + priority = request.args.get("priority") + user_email = request.args.get("user_email") + limit = int(request.args.get("limit", 100)) + + # Build query + query = {} + if status: + query["status"] = status + if priority: + query["priority"] = priority + if user_email: + query["user_email"] = user_email + + # Fetch bug reports, sorted by date (newest first) + if query: + bug_reports = BugReport.objects(**query).order_by("-date_submitted").limit(limit) + else: + bug_reports = BugReport.objects().order_by("-date_submitted").limit(limit) + + return create_response(data={"bug_reports": bug_reports}) + + except Exception as e: + logger.error(f"Failed to fetch bug reports: {e}") + return create_response(status=500, message=str(e)) + + +@admin.route("/admin/bug-reports/", methods=["GET"]) +@admin_only +def get_bug_report(id): + """Get a specific bug report by ID""" + try: + bug_report = BugReport.objects.get(id=id) + return create_response(data={"bug_report": bug_report}) + except: + msg = "Bug report not found" + logger.info(msg) + return create_response(status=404, message=msg) + + +@admin.route("/admin/bug-reports/", methods=["PUT"]) +@admin_only +def update_bug_report(id): + """Update bug report status, priority, or notes + + Request body: + status: new, in_progress, resolved, closed + priority: low, medium, high, critical + notes: Admin notes + resolved_by: Admin name who resolved it + """ + try: + bug_report = BugReport.objects.get(id=id) + data = request.get_json() + + if "status" in data: + bug_report.status = data["status"] + # If marking as resolved, add timestamp + if data["status"] == "resolved" and not bug_report.resolved_date: + from datetime import datetime + bug_report.resolved_date = datetime.utcnow() + + if "priority" in data: + bug_report.priority = data["priority"] + + if "notes" in data: + bug_report.notes = data["notes"] + + if "resolved_by" in data: + bug_report.resolved_by = data["resolved_by"] + + bug_report.save() + logger.info(f"Bug report {id} updated") + + return create_response( + data={"bug_report": bug_report}, + message="Bug report updated successfully" + ) + + except Exception as e: + logger.error(f"Failed to update bug report {id}: {e}") + return create_response(status=500, message=str(e)) + + +@admin.route("/admin/bug-reports/", methods=["DELETE"]) +@admin_only +def delete_bug_report(id): + """Delete a bug report""" + try: + bug_report = BugReport.objects.get(id=id) + bug_report.delete() + logger.info(f"Bug report {id} deleted") + + return create_response(message="Bug report deleted successfully") + + except: + msg = "Bug report not found" + logger.info(msg) + return create_response(status=404, message=msg) + + @admin.route("/admin/", methods=["GET"]) def get_admin(id): # return create_response(data={"admin": {"_id":{"$oid":"60765e9289899aeee51a8b27"},"email":"klhester3@gmail.com","firebase_uid":"xsW41z9Hc6Y9r6Te0JAcXhlYneA2","name":"candle"}}) diff --git a/backend/api/views/main.py b/backend/api/views/main.py index 97e4c45b..00ebad19 100644 --- a/backend/api/views/main.py +++ b/backend/api/views/main.py @@ -1017,3 +1017,250 @@ def getAllCountries(): return create_response(status=422, message=msg) return create_response(data={"countries": countries}) + + +@main.route("/bug-report", methods=["POST"]) +def submit_bug_report(): + """Endpoint to receive bug reports and send email notifications with attachments""" + import os + import base64 + from datetime import datetime + from sendgrid import SendGridAPIClient + from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition + from api.utils.request_utils import sendgrid_key, sender_email + from api.utils.google_storage import upload_bug_report_attachment + from api.models import BugReport, Users + + # Get JSON data with size limit check + try: + data = request.get_json() + except Exception as e: + logger.error(f"Failed to parse request JSON: {e}") + return create_response(status=400, message="Invalid request data") + + # Extract data from request + description = data.get("description", "") + user_name = data.get("user_name", "Not provided") + user_email = data.get("user_email", "Not provided") + role = data.get("role", "unknown") + context = data.get("context", "app") + page_url = data.get("page_url", "Not provided") + file_attachments = data.get("attachments", []) + + # Limit number of attachments + if len(file_attachments) > 3: + return create_response(status=400, message="Too many attachments. Maximum 3 files allowed.") + + # Validate required fields + if not description: + return create_response(status=400, message="Description is required") + + # Try to find user_id if user is logged in (by email) + user_id = None + try: + if user_email and user_email != "Not provided": + user_obj = Users.objects(email=user_email).first() + if user_obj: + user_id = user_obj.id + except Exception as e: + logger.warning(f"Could not find user by email {user_email}: {e}") + + # Upload attachments to Google Cloud Storage + uploaded_attachments = [] + for file_data in file_attachments: + try: + file_name = file_data.get("name", "attachment") + file_content_base64 = file_data.get("content", "") + file_type = file_data.get("type", "application/octet-stream") + + if file_content_base64: + # Upload to GCS + public_url, gcs_filename = upload_bug_report_attachment( + file_content_base64, + file_name, + file_type + ) + + uploaded_attachments.append({ + "original_name": file_name, + "gcs_filename": gcs_filename, + "url": public_url, + "content_type": file_type + }) + logger.info(f"Uploaded attachment to GCS: {file_name}") + except Exception as e: + logger.error(f"Failed to upload attachment {file_data.get('name', 'unknown')}: {e}") + # Continue with other attachments + + # Create BugReport document + try: + bug_report = BugReport( + description=description, + user_name=user_name, + user_email=user_email, + user_id=user_id, + role=role, + context=context, + page_url=page_url, + attachments=uploaded_attachments, + date_submitted=datetime.utcnow(), + status="new", + email_sent=False + ) + bug_report.save() + logger.info(f"Bug report saved to database: {bug_report.id}") + except Exception as e: + logger.error(f"Failed to save bug report to database: {e}") + return create_response(status=500, message="Failed to save bug report") + + # Recipients list - currently just one, but structured for multiple in the future + recipients = ["juan@menteeglobal.org"] + + # Build HTML email content with attachment links + attachments_html = "" + if uploaded_attachments: + attachments_list = "
".join([ + f"- {att['original_name']}" + for att in uploaded_attachments + ]) + attachments_html = f""" +

Attachments:
{attachments_list}

+ """ + + html_content = f""" + + +

Bug Report #{str(bug_report.id)}

+
+ +

Description:

+

+ {description.replace(chr(10), '
')} +

+ +

User Information

+

Name: {user_name}

+

Email: {user_email}

+

Role: {role}

+

User ID: {user_id if user_id else 'Not logged in'}

+ +

Context

+

Context: {context}

+

Page URL: {page_url}

+

Date Submitted: {bug_report.date_submitted.strftime('%Y-%m-%d %H:%M:%S')} UTC

+ + {attachments_html} + +
+

+ This bug report was submitted via the MENTEE platform.
+ Bug Report ID: {str(bug_report.id)} +

+ + + """ + + # Check if SendGrid is properly configured + if not sendgrid_key: + logger.warning("SENDGRID_API_KEY not found - using development mode") + logger.info(f"Bug report #{bug_report.id} received from {user_name} ({user_email})") + logger.info(f"Would send to: {', '.join(recipients)}") + if uploaded_attachments: + logger.info(f"Attachments: {len(uploaded_attachments)} file(s) uploaded to GCS") + + bug_report.email_sent = False + bug_report.email_error = "Development mode - email not sent" + bug_report.save() + + return create_response( + status=200, + message="Bug report submitted successfully (development mode)", + data={"bug_report_id": str(bug_report.id)} + ) + + if not sender_email: + logger.error("SENDER_EMAIL environment variable not set!") + bug_report.email_sent = False + bug_report.email_error = "SENDER_EMAIL not configured" + bug_report.save() + + return create_response( + status=500, + message="Email configuration error: SENDER_EMAIL not set" + ) + + # Send emails with attachments + success_count = 0 + failed_recipients = [] + email_error_msg = None + + for recipient in recipients: + try: + message = Mail( + from_email=sender_email, + to_emails=recipient, + subject=f"Bug Report #{str(bug_report.id)} - MENTEE Platform", + html_content=html_content + ) + + # Add file attachments from the original data (for email convenience) + for file_data in file_attachments: + try: + file_name = file_data.get("name", "attachment") + file_content_base64 = file_data.get("content", "") + file_type = file_data.get("type", "application/octet-stream") + + if file_content_base64: + attached_file = Attachment( + FileContent(file_content_base64), + FileName(file_name), + FileType(file_type), + Disposition('attachment') + ) + message.add_attachment(attached_file) + except Exception as attach_error: + logger.warning(f"Failed to attach file {file_data.get('name', 'unknown')} to email: {attach_error}") + + # Send email + sg = SendGridAPIClient(sendgrid_key) + response = sg.send(message) + + success_count += 1 + logger.info(f"Bug report #{bug_report.id} email sent successfully to {recipient}") + + except Exception as e: + failed_recipients.append(recipient) + error_msg = str(e) + email_error_msg = error_msg + logger.error(f"Failed to send bug report email to {recipient}: {error_msg}") + + # Update bug report with email status + if success_count > 0: + bug_report.email_sent = True + if failed_recipients: + bug_report.email_error = f"Partial failure: {', '.join(failed_recipients)}" + else: + bug_report.email_sent = False + bug_report.email_error = email_error_msg or "Unknown error" + + bug_report.save() + + # Return appropriate response + if success_count == 0: + return create_response( + status=500, + message=f"Bug report saved but failed to send emails: {', '.join(failed_recipients)}", + data={"bug_report_id": str(bug_report.id)} + ) + elif failed_recipients: + return create_response( + status=207, # Multi-Status + message=f"Bug report sent to {success_count} recipient(s), but failed for: {', '.join(failed_recipients)}", + data={"bug_report_id": str(bug_report.id)} + ) + + return create_response( + status=200, + message="Bug report submitted successfully", + data={"bug_report_id": str(bug_report.id)} + ) diff --git a/frontend/src/app/App.js b/frontend/src/app/App.js index 166fac52..ccbc094f 100644 --- a/frontend/src/app/App.js +++ b/frontend/src/app/App.js @@ -17,6 +17,7 @@ import ForgotPassword from "components/pages/ForgotPassword"; import ApplicationOrganizer from "components/pages/ApplicationOrganizer"; import AdminAccountData from "components/pages/AdminAccountData"; import AdminAppointmentData from "components/pages/AdminAppointmentData"; +import AdminBugReports from "components/pages/AdminBugReports"; import MenteeGallery from "components/pages/MenteeGallery"; import Messages from "components/pages/Messages"; import GroupMessages from "components/pages/GroupMessages"; @@ -220,7 +221,7 @@ function App() { {Object.keys(allHubData).map((hub_url) => { return ( - <> + @@ -231,7 +232,7 @@ function App() { )} - + ); })} @@ -591,6 +592,21 @@ function App() { )} + + {role == ACCOUNT_TYPE.ADMIN || role == ACCOUNT_TYPE.HUB ? ( + + ) : ( + <> + {cur_time - startPathTime > 100 && ( + + )} + + )} + {role == ACCOUNT_TYPE.ADMIN ? ( diff --git a/frontend/src/components/BugReportModal.js b/frontend/src/components/BugReportModal.js new file mode 100644 index 00000000..33d6619a --- /dev/null +++ b/frontend/src/components/BugReportModal.js @@ -0,0 +1,225 @@ +import React, { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { Button, Form, Input, Modal, Upload, message } from "antd"; +import { UploadOutlined } from "@ant-design/icons"; +import { submitBugReport } from "utils/api"; +import { ACCOUNT_TYPE_LABELS } from "utils/consts"; + +function BugReportModal({ open, onClose, contextLabel }) { + const [form] = Form.useForm(); + const [fileList, setFileList] = useState([]); + const [submitting, setSubmitting] = useState(false); + const user = useSelector((state) => state.user?.user); + const role = useSelector((state) => state.user?.role); + const isLoggedIn = Boolean(user && user.email); + + const initialValues = useMemo(() => ({}), []); + + const compressImage = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + // Max dimensions to keep file size reasonable + const MAX_WIDTH = 1920; + const MAX_HEIGHT = 1080; + + if (width > height) { + if (width > MAX_WIDTH) { + height *= MAX_WIDTH / width; + width = MAX_WIDTH; + } + } else { + if (height > MAX_HEIGHT) { + width *= MAX_HEIGHT / height; + height = MAX_HEIGHT; + } + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + // Convert to base64 with quality setting + const base64Content = canvas.toDataURL('image/jpeg', 0.8).split(',')[1]; + resolve({ + name: file.name.replace(/\.\w+$/, '.jpg'), // Change extension to jpg + content: base64Content, + type: 'image/jpeg', + }); + }; + img.onerror = reject; + img.src = e.target.result; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + const handleSubmit = async (values) => { + setSubmitting(true); + + try { + // Convert and compress files to base64 + const attachmentsPromises = fileList.map((file) => { + // If it's an image, compress it + if (file.type && file.type.startsWith('image/')) { + return compressImage(file.originFileObj); + } + + // For non-images (like PDFs), just convert to base64 + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64Content = reader.result.split(',')[1]; + resolve({ + name: file.name, + content: base64Content, + type: file.type || "application/octet-stream", + }); + }; + reader.onerror = reject; + reader.readAsDataURL(file.originFileObj); + }); + }); + + const attachments = await Promise.all(attachmentsPromises); + + // Show uploading message if there are attachments + if (attachments.length > 0) { + message.loading({ content: `Uploading ${attachments.length} file(s)...`, key: 'upload', duration: 0 }); + } + + const bugReportData = { + description: values.description, + user_name: user?.name || values.name || "Not provided", + user_email: user?.email || values.email || "Not provided", + role: ACCOUNT_TYPE_LABELS[role] || "unknown", + context: contextLabel || "app", + page_url: window.location.href, + attachments: attachments, + }; + + const response = await submitBugReport(bugReportData); + + // Dismiss loading message + message.destroy('upload'); + + if (response && response.status === 200) { + const bugReportId = response.data?.result?.bug_report_id; + if (bugReportId) { + message.success({ + content: `Bug report submitted successfully! Reference ID: ${bugReportId.slice(-8)}`, + duration: 5, + }); + } else { + message.success("Bug report submitted successfully!"); + } + form.resetFields(); + setFileList([]); + onClose(); + } else { + message.error( + response?.data?.message || + "Failed to submit bug report. Please try again." + ); + } + } catch (error) { + console.error("Error submitting bug report:", error); + message.destroy('upload'); + message.error("Failed to submit bug report. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + setFileList([]); + onClose(); + }; + + return ( + +
+ + + + {!isLoggedIn && ( + <> + + + + + + + + )} + + { + const isLt5M = file.size / 1024 / 1024 < 5; + if (!isLt5M) { + message.error(`${file.name} is too large. Max 5MB per file.`); + return Upload.LIST_IGNORE; + } + return false; + }} + onChange={({ fileList: nextList }) => setFileList(nextList)} + accept="image/*,.pdf" + maxCount={3} + > + + +
+ Files will be attached to the email. Max 3 files, 5MB each. +
+
+ + + + +
+ ); +} + +export default BugReportModal; diff --git a/frontend/src/components/NavigationHeader.js b/frontend/src/components/NavigationHeader.js index f52387c8..1ab5b4e5 100644 --- a/frontend/src/components/NavigationHeader.js +++ b/frontend/src/components/NavigationHeader.js @@ -6,6 +6,7 @@ import { useMediaQuery } from "react-responsive"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import NotificationBell from "components/NotificationBell"; +import BugReportModal from "components/BugReportModal"; import LanguageDropdown from "components/LanguageDropdown"; import { getLoginPath, logout } from "utils/auth.service"; import { useAuth } from "utils/hooks/useAuth"; @@ -35,6 +36,7 @@ function NavigationHeader() { const { user, role } = useSelector((state) => state.user); const isMobile = useMediaQuery({ query: `(max-width: 761px)` }); const [openDropdown, setOpenDropdown] = useState(false); + const [isBugReportOpen, setIsBugReportOpen] = useState(false); const supportUserID = localStorage.getItem("support_user_id"); const logoutUser = () => { @@ -225,11 +227,14 @@ function NavigationHeader() { {role !== ACCOUNT_TYPE.ADMIN && } - window.open("https://forms.gle/DCCFR6du9YckbnhY8")} - /> + setIsBugReportOpen(true)} /> + setIsBugReportOpen(false)} + contextLabel="navigation-header" + /> ); } diff --git a/frontend/src/components/pages/AdminBugReports.js b/frontend/src/components/pages/AdminBugReports.js new file mode 100644 index 00000000..85fae930 --- /dev/null +++ b/frontend/src/components/pages/AdminBugReports.js @@ -0,0 +1,498 @@ +import React, { useState, useEffect } from "react"; +import { + Button, + Breadcrumb, + Input, + Spin, + Table, + Tag, + Modal, + Select, + message, + Space, + Image, +} from "antd"; +import { + ReloadOutlined, + SearchOutlined, + EyeOutlined, + DeleteOutlined, + EditOutlined, +} from "@ant-design/icons"; +import { + fetchBugReports, + deleteBugReportById, + updateBugReport, +} from "../../utils/api"; +import { formatDateTime } from "utils/consts"; +import { useAuth } from "utils/hooks/useAuth"; +import "../css/AdminAccountData.scss"; + +const { TextArea } = Input; +const { Option } = Select; + +const STATUS_OPTIONS = [ + { value: "new", label: "New", color: "red" }, + { value: "in_progress", label: "In Progress", color: "blue" }, + { value: "resolved", label: "Resolved", color: "green" }, + { value: "closed", label: "Closed", color: "default" }, +]; + +const PRIORITY_OPTIONS = [ + { value: "low", label: "Low", color: "green" }, + { value: "medium", label: "Medium", color: "orange" }, + { value: "high", label: "High", color: "red" }, + { value: "critical", label: "Critical", color: "purple" }, +]; + +function AdminBugReports() { + const [isLoading, setIsLoading] = useState(false); + const [reload, setReload] = useState(true); + const [bugReports, setBugReports] = useState([]); + const [filteredData, setFilteredData] = useState([]); + const [selectedBug, setSelectedBug] = useState(null); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const [editForm, setEditForm] = useState({}); + const { onAuthStateChanged } = useAuth(); + + useEffect(() => { + async function getData() { + setIsLoading(true); + try { + const data = await fetchBugReports({ limit: 500 }); + if (data) { + // Add key for table + const dataWithKeys = data.map((item) => ({ + ...item, + key: item._id.$oid, + })); + setBugReports(dataWithKeys); + setFilteredData(dataWithKeys); + } + } catch (error) { + message.error("Failed to load bug reports"); + console.error(error); + } + setIsLoading(false); + } + // Wait for auth before fetching data + onAuthStateChanged(getData); + }, [reload]); + + const handleSearch = (value) => { + if (!value) { + setFilteredData(bugReports); + return; + } + const searchLower = value.toLowerCase(); + const filtered = bugReports.filter( + (bug) => + bug._id.$oid?.toLowerCase().includes(searchLower) || + bug.user_name?.toLowerCase().includes(searchLower) || + bug.user_email?.toLowerCase().includes(searchLower) || + bug.description?.toLowerCase().includes(searchLower) + ); + setFilteredData(filtered); + }; + + const handleFilterByStatus = (status) => { + if (!status || status === "all") { + setFilteredData(bugReports); + return; + } + const filtered = bugReports.filter((bug) => bug.status === status); + setFilteredData(filtered); + }; + + const handleViewDetails = (bug) => { + setSelectedBug(bug); + setDetailModalVisible(true); + }; + + const handleEdit = (bug) => { + setSelectedBug(bug); + setEditForm({ + status: bug.status, + priority: bug.priority, + notes: bug.notes || "", + }); + setEditModalVisible(true); + }; + + const handleSaveEdit = async () => { + try { + await updateBugReport(selectedBug._id.$oid, editForm); + message.success("Bug report updated successfully"); + setEditModalVisible(false); + setReload(!reload); + } catch (error) { + message.error("Failed to update bug report"); + } + }; + + const handleDelete = (id) => { + Modal.confirm({ + title: "Are you sure you want to delete this bug report?", + content: "This action cannot be undone.", + okText: "Yes, Delete", + okType: "danger", + onOk: async () => { + try { + await deleteBugReportById(id); + message.success("Bug report deleted successfully"); + setReload(!reload); + } catch (error) { + message.error("Failed to delete bug report"); + } + }, + }); + }; + + const columns = [ + { + title: "ID", + dataIndex: "_id", + key: "id", + width: 100, + render: (id) => ( + + {id.$oid.slice(-8)} + + ), + }, + { + title: "Date", + dataIndex: "date_submitted", + key: "date_submitted", + width: 150, + render: (date) => ( + {formatDateTime(new Date(date.$date))} + ), + sorter: (a, b) => + new Date(a.date_submitted.$date) - new Date(b.date_submitted.$date), + }, + { + title: "User", + key: "user", + width: 200, + render: (_, record) => ( +
+
{record.user_name}
+
+ {record.user_email} +
+ + {record.role || "unknown"} + +
+ ), + }, + { + title: "Description", + dataIndex: "description", + key: "description", + ellipsis: true, + render: (text) => ( +
+ {text.length > 100 ? `${text.substring(0, 100)}...` : text} +
+ ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + width: 120, + render: (status) => { + const statusObj = STATUS_OPTIONS.find((s) => s.value === status); + return ( + + {statusObj?.label || status} + + ); + }, + }, + { + title: "Priority", + dataIndex: "priority", + key: "priority", + width: 100, + render: (priority) => { + const priorityObj = PRIORITY_OPTIONS.find((p) => p.value === priority); + return ( + + {priorityObj?.label || priority} + + ); + }, + }, + { + title: "Attachments", + dataIndex: "attachments", + key: "attachments", + width: 100, + render: (attachments) => ( + {attachments?.length || 0} file(s) + ), + }, + { + title: "Actions", + key: "actions", + width: 150, + render: (_, record) => ( + + +
+ + + {/* Detail Modal */} + setDetailModalVisible(false)} + footer={[ + , + , + ]} + width={800} + > + {selectedBug && ( +
+

+ Full ID:{" "} + + {selectedBug._id.$oid} + +

+

User Information

+

+ Name: {selectedBug.user_name} +

+

+ Email: {selectedBug.user_email} +

+

+ Role: {selectedBug.role} +

+ +

Bug Details

+

+ Status:{" "} + s.value === selectedBug.status)?.color}> + {selectedBug.status} + +

+

+ Priority:{" "} + p.value === selectedBug.priority)?.color}> + {selectedBug.priority} + +

+

+ Description: +

+
+ {selectedBug.description} +
+ +

Context

+

+ Context: {selectedBug.context} +

+

+ Page URL:{" "} + + {selectedBug.page_url} + +

+

+ Date Submitted:{" "} + {formatDateTime(new Date(selectedBug.date_submitted.$date))} +

+ + {selectedBug.attachments && selectedBug.attachments.length > 0 && ( + <> +

Attachments

+ + {selectedBug.attachments.map((att, index) => ( +
+ + {att.original_name} + + {att.content_type.startsWith("image/") && ( +
+ +
+ )} +
+ ))} +
+ + )} + + {selectedBug.notes && ( + <> +

Admin Notes

+
+ {selectedBug.notes} +
+ + )} +
+ )} +
+ + {/* Edit Modal */} + setEditModalVisible(false)} + onOk={handleSaveEdit} + okText="Save" + > +
+ + +
+ +
+ + +
+ +
+ +