diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index ded18582..4c4c63db 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' cache: 'pip' - name: Install dependencies diff --git a/backend/Dockerfile b/backend/Dockerfile index aef1c4ca..b8554c90 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # this dockerfile is used for product deployments -FROM python:3.9 +FROM python:3.10 LABEL maintainer "Kelley Chau " COPY requirements.txt requirements.txt 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..7834c233 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,44 @@ 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..667491e7 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,123 @@ 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..433be035 100644 --- a/backend/api/views/main.py +++ b/backend/api/views/main.py @@ -1017,3 +1017,270 @@ 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/backend/pyproject.toml b/backend/pyproject.toml index 5b00de3c..2933594a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,9 +12,9 @@ start = "scripts:runserver" format = "scripts:format" [tool.poetry.dependencies] -python = "^3.8" +python = "^3.10" flask-mongoengine = "1.0.0" -gunicorn = "20.1.0" +gunicorn = "21.2.0" mongoengine = "0.26.0" Flask-Cors = "3.0.10" Flask-Migrate = "4.0.4" @@ -31,7 +31,7 @@ requests = "2.28.2" Pyrebase4 = "4.6.0" twilio = "7.16.4" Flask-SocketIO = "5.3.2" -eventlet = "0.30.2" +eventlet = "0.33.3" gevent-websocket = "0.10.1" alembic = "1.9.4" APScheduler = "3.11.0" @@ -47,7 +47,7 @@ colorama = "0.4.6" cryptography = "39.0.2" deprecated = "1.2.13" dill = "0.2.9" -dnspython = "1.16.0" +dnspython = "2.4.2" email-validator = "1.3.1" flask-sqlalchemy = "3.0.3" flask-wtf = "1.1.1" diff --git a/backend/requirements-override.txt b/backend/requirements-override.txt index 7bba479f..603bed09 100644 --- a/backend/requirements-override.txt +++ b/backend/requirements-override.txt @@ -1,2 +1,3 @@ gevent==23.9.1 -gevent-websocket==0.10.1 \ No newline at end of file +gevent-websocket==0.10.1 +eventlet==0.33.3 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index af856c74..77c00f34 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,116 +1,459 @@ -alembic==1.9.4 ; python_version >= "3.8" and python_version < "4.0" -apscheduler==3.11.0 ; python_version >= "3.8" and python_version < "4.0" -authlib==1.3.0 ; python_version >= "3.8" and python_version < "4.0" -backports-zoneinfo==0.2.1 ; python_version >= "3.8" and python_version < "3.9" -bidict==0.22.1 ; python_version >= "3.8" and python_version < "4.0" -cachecontrol==0.12.11 ; python_version >= "3.8" and python_version < "4.0" -cachetools==4.2.4 ; python_version >= "3.8" and python_version < "4.0" -certifi==2022.12.7 ; python_version >= "3.8" and python_version < "4.0" -cffi==1.15.1 ; python_version >= "3.8" and python_version < "4.0" -chardet==4.0.0 ; python_version >= "3.8" and python_version < "4.0" -charset-normalizer==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -click==8.1.3 ; python_version >= "3.8" and python_version < "4.0" -colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" -cryptography==39.0.2 ; python_version >= "3.8" and python_version < "4.0" -deprecated==1.2.13 ; python_version >= "3.8" and python_version < "4.0" -dill==0.2.9 ; python_version >= "3.8" and python_version < "4.0" -dnspython==1.16.0 ; python_version >= "3.8" and python_version < "4.0" -email-validator==1.3.1 ; python_version >= "3.8" and python_version < "4.0" -eventlet==0.30.2 ; python_version >= "3.8" and python_version < "4.0" -firebase-admin==6.1.0 ; python_version >= "3.8" and python_version < "4.0" -flask-cors==3.0.10 ; python_version >= "3.8" and python_version < "4.0" -flask-migrate==4.0.4 ; python_version >= "3.8" and python_version < "4.0" -flask-mongoengine==1.0.0 ; python_version >= "3.8" and python_version < "4.0" -flask-script==2.0.6 ; python_version >= "3.8" and python_version < "4.0" -flask-socketio==5.3.2 ; python_version >= "3.8" and python_version < "4.0" -flask-sqlalchemy==3.0.3 ; python_version >= "3.8" and python_version < "4.0" -flask-wtf==1.1.1 ; python_version >= "3.8" and python_version < "4.0" -flask==2.2.3 ; python_version >= "3.8" and python_version < "4.0" -future==0.16.0 ; python_version >= "3.8" and python_version < "4.0" -gax-google-logging-v2==0.8.3 ; python_version >= "3.8" and python_version < "4.0" -gax-google-pubsub-v1==0.8.3 ; python_version >= "3.8" and python_version < "4.0" -gcloud==0.18.3 ; python_version >= "3.8" and python_version < "4.0" -gevent-websocket==0.10.1 ; python_version >= "3.8" and python_version < "4.0" -gevent==22.10.2 ; python_version >= "3.8" and python_version < "4.0" -google-api-core==2.11.0 ; python_version >= "3.8" and python_version < "4.0" -google-api-core[grpc]==2.11.0 ; python_version >= "3.8" and python_version < "4.0" -google-api-python-client==2.80.0 ; python_version >= "3.8" and python_version < "4.0" -google-auth-httplib2==0.1.0 ; python_version >= "3.8" and python_version < "4.0" -google-auth==2.16.2 ; python_version >= "3.8" and python_version < "4.0" -google-cloud-core==2.3.2 ; python_version >= "3.8" and python_version < "4.0" -google-cloud-firestore==2.10.0 ; python_version >= "3.8" and python_version < "4.0" -google-cloud-storage==2.7.0 ; python_version >= "3.8" and python_version < "4.0" -google-cloud-translate==3.12.1 ; python_version >= "3.8" and python_version < "4.0" -google-crc32c==1.5.0 ; python_version >= "3.8" and python_version < "4.0" -google-gax==0.12.5 ; python_version >= "3.8" and python_version < "4.0" -google-resumable-media==2.4.1 ; python_version >= "3.8" and python_version < "4.0" -googleapis-common-protos==1.58.0 ; python_version >= "3.8" and python_version < "4.0" -greenlet==2.0.2 ; python_version >= "3.8" and python_version < "4.0" -grpc-google-logging-v2==0.8.1 ; python_version >= "3.8" and python_version < "4.0" -grpc-google-pubsub-v1==0.8.1 ; python_version >= "3.8" and python_version < "4.0" -grpcio-status==1.51.3 ; python_version >= "3.8" and python_version < "4.0" -grpcio==1.51.3 ; python_version >= "3.8" and python_version < "4.0" -gunicorn==20.1.0 ; python_version >= "3.8" and python_version < "4.0" -h11==0.14.0 ; python_version >= "3.8" and python_version < "4.0" -httplib2==0.21.0 ; python_version >= "3.8" and python_version < "4.0" -idna==2.10 ; python_version >= "3.8" and python_version < "4.0" -importlib-metadata==6.0.0 ; python_version >= "3.8" and python_version < "4.0" -importlib-resources==6.4.5 ; python_version >= "3.8" and python_version < "3.9" -itsdangerous==2.1.2 ; python_version >= "3.8" and python_version < "4.0" -jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0" -jwcrypto==1.4.2 ; python_version >= "3.8" and python_version < "4.0" -mako==1.2.4 ; python_version >= "3.8" and python_version < "4.0" -markupsafe==2.1.2 ; python_version >= "3.8" and python_version < "4.0" -mongoengine==0.26.0 ; python_version >= "3.8" and python_version < "4.0" -msgpack==1.0.4 ; python_version >= "3.8" and python_version < "4.0" -numpy==1.24.2 ; python_version >= "3.8" and python_version < "4.0" -oauth2client==4.1.3 ; python_version >= "3.8" and python_version < "4.0" -packaging==23.0 ; python_version >= "3.8" and python_version < "4.0" -pandas==1.5.3 ; python_version >= "3.8" and python_version < "4.0" -pillow==10.4.0 ; python_version >= "3.8" and python_version < "4.0" -ply==3.8 ; python_version >= "3.8" and python_version < "4.0" -proto-plus==1.22.2 ; python_version >= "3.8" and python_version < "4.0" -protobuf==4.22.0 ; python_version >= "3.8" and python_version < "4.0" -pyasn1-modules==0.2.8 ; python_version >= "3.8" and python_version < "4.0" -pyasn1==0.4.8 ; python_version >= "3.8" and python_version < "4.0" -pycparser==2.21 ; python_version >= "3.8" and python_version < "4.0" -pycryptodome==3.17 ; python_version >= "3.8" and python_version < "4.0" -pyjwt==2.6.0 ; python_version >= "3.8" and python_version < "4.0" -pyjwt[crypto]==2.6.0 ; python_version >= "3.8" and python_version < "4.0" -pymongo==4.6.0 ; python_version >= "3.8" and python_version < "4.0" -pyparsing==3.0.9 ; python_version >= "3.8" and python_version < "4.0" -pypdf2==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -pyrebase4==4.6.0 ; python_version >= "3.8" and python_version < "4.0" -python-dateutil==2.8.2 ; python_version >= "3.8" and python_version < "4.0" -python-dotenv==1.0.0 ; python_version >= "3.8" and python_version < "4.0" -python-editor==1.0.4 ; python_version >= "3.8" and python_version < "4.0" -python-engineio==4.3.4 ; python_version >= "3.8" and python_version < "4.0" -python-http-client==3.3.7 ; python_version >= "3.8" and python_version < "4.0" -python-jwt==4.0.0 ; python_version >= "3.8" and python_version < "4.0" -python-socketio==5.7.2 ; python_version >= "3.8" and python_version < "4.0" -pytz==2022.7.1 ; python_version >= "3.8" and python_version < "4.0" -requests-toolbelt==0.10.1 ; python_version >= "3.8" and python_version < "4.0" -requests==2.28.2 ; python_version >= "3.8" and python_version < "4" -rsa==4.9 ; python_version >= "3.8" and python_version < "4" -sendgrid==6.9.7 ; python_version >= "3.8" and python_version < "4.0" -setuptools==75.3.0 ; python_version >= "3.8" and python_version < "4.0" -simple-websocket==0.9.0 ; python_version >= "3.8" and python_version < "4.0" -six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" -sqlalchemy==2.0.4 ; python_version >= "3.8" and python_version < "4.0" -starkbank-ecdsa==2.2.0 ; python_version >= "3.8" and python_version < "4.0" -twilio==7.16.4 ; python_version >= "3.8" and python_version < "4.0" -typing-extensions==4.5.0 ; python_version >= "3.8" and python_version < "4.0" -tzdata==2025.1 ; python_version >= "3.8" and python_version < "4.0" and platform_system == "Windows" -tzlocal==5.2 ; python_version >= "3.8" and python_version < "4.0" -uritemplate==4.1.1 ; python_version >= "3.8" and python_version < "4.0" -urllib3==1.26.14 ; python_version >= "3.8" and python_version < "4.0" -werkzeug==2.2.3 ; python_version >= "3.8" and python_version < "4.0" -wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4.0" -wsproto==1.2.0 ; python_version >= "3.8" and python_version < "4.0" -wtforms-json==0.3.5 ; python_version >= "3.8" and python_version < "4.0" -wtforms==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -wtforms[email]==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -xlsxwriter==3.0.8 ; python_version >= "3.8" and python_version < "4.0" -zipp==3.15.0 ; python_version >= "3.8" and python_version < "4.0" -zope-event==4.6 ; python_version >= "3.8" and python_version < "4.0" -zope-interface==5.5.2 ; python_version >= "3.8" and python_version < "4.0" +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o requirements.txt --python-version 3.10 +alembic==1.9.4 + # via + # backend (pyproject.toml) + # flask-migrate +apscheduler==3.11.0 + # via backend (pyproject.toml) +authlib==1.3.0 + # via backend (pyproject.toml) +bidict==0.22.1 + # via + # backend (pyproject.toml) + # python-socketio +cachecontrol==0.12.11 + # via + # backend (pyproject.toml) + # firebase-admin +cachetools==4.2.4 + # via + # backend (pyproject.toml) + # google-auth +certifi==2022.12.7 + # via + # backend (pyproject.toml) + # requests +cffi==1.15.1 + # via + # backend (pyproject.toml) + # cryptography +chardet==4.0.0 + # via backend (pyproject.toml) +charset-normalizer==3.0.1 + # via + # backend (pyproject.toml) + # requests +click==8.1.3 + # via + # backend (pyproject.toml) + # flask +colorama==0.4.6 + # via backend (pyproject.toml) +cryptography==39.0.2 + # via + # backend (pyproject.toml) + # authlib + # jwcrypto + # pyjwt +deprecated==1.2.13 + # via + # backend (pyproject.toml) + # jwcrypto +dill==0.2.9 + # via backend (pyproject.toml) +dnspython==2.4.2 + # via + # backend (pyproject.toml) + # email-validator + # eventlet + # pymongo +email-validator==1.3.1 + # via + # backend (pyproject.toml) + # wtforms +eventlet==0.33.3 + # via backend (pyproject.toml) +firebase-admin==6.1.0 + # via backend (pyproject.toml) +flask==2.2.3 + # via + # backend (pyproject.toml) + # flask-cors + # flask-migrate + # flask-mongoengine + # flask-script + # flask-socketio + # flask-sqlalchemy + # flask-wtf +flask-cors==3.0.10 + # via backend (pyproject.toml) +flask-migrate==4.0.4 + # via backend (pyproject.toml) +flask-mongoengine==1.0.0 + # via backend (pyproject.toml) +flask-script==2.0.6 + # via backend (pyproject.toml) +flask-socketio==5.3.2 + # via backend (pyproject.toml) +flask-sqlalchemy==3.0.3 + # via + # backend (pyproject.toml) + # flask-migrate +flask-wtf==1.1.1 + # via + # backend (pyproject.toml) + # flask-mongoengine +future==0.16.0 + # via + # backend (pyproject.toml) + # google-gax +gax-google-logging-v2==0.8.3 + # via backend (pyproject.toml) +gax-google-pubsub-v1==0.8.3 + # via backend (pyproject.toml) +gcloud==0.18.3 + # via + # backend (pyproject.toml) + # pyrebase4 +gevent==22.10.2 + # via + # backend (pyproject.toml) + # gevent-websocket +gevent-websocket==0.10.1 + # via backend (pyproject.toml) +google-api-core==2.11.0 + # via + # backend (pyproject.toml) + # firebase-admin + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage + # google-cloud-translate +google-api-python-client==2.80.0 + # via + # backend (pyproject.toml) + # firebase-admin +google-auth==2.16.2 + # via + # backend (pyproject.toml) + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-storage +google-auth-httplib2==0.1.0 + # via + # backend (pyproject.toml) + # google-api-python-client +google-cloud-core==2.3.2 + # via + # backend (pyproject.toml) + # google-cloud-firestore + # google-cloud-storage + # google-cloud-translate +google-cloud-firestore==2.10.0 + # via + # backend (pyproject.toml) + # firebase-admin +google-cloud-storage==2.7.0 + # via + # backend (pyproject.toml) + # firebase-admin +google-cloud-translate==3.12.1 + # via backend (pyproject.toml) +google-crc32c==1.5.0 + # via + # backend (pyproject.toml) + # google-resumable-media +google-gax==0.12.5 + # via + # backend (pyproject.toml) + # gax-google-logging-v2 + # gax-google-pubsub-v1 +google-resumable-media==2.4.1 + # via + # backend (pyproject.toml) + # google-cloud-storage +googleapis-common-protos==1.58.0 + # via + # backend (pyproject.toml) + # gax-google-logging-v2 + # gax-google-pubsub-v1 + # gcloud + # google-api-core + # grpc-google-logging-v2 + # grpc-google-pubsub-v1 + # grpcio-status +greenlet==2.0.2 + # via + # backend (pyproject.toml) + # eventlet + # gevent +grpc-google-logging-v2==0.8.1 + # via + # backend (pyproject.toml) + # gax-google-logging-v2 +grpc-google-pubsub-v1==0.8.1 + # via + # backend (pyproject.toml) + # gax-google-pubsub-v1 +grpcio==1.51.3 + # via + # backend (pyproject.toml) + # google-api-core + # google-gax + # grpc-google-logging-v2 + # grpc-google-pubsub-v1 + # grpcio-status +grpcio-status==1.51.3 + # via + # backend (pyproject.toml) + # google-api-core +gunicorn==21.2.0 + # via backend (pyproject.toml) +h11==0.14.0 + # via + # backend (pyproject.toml) + # wsproto +httplib2==0.21.0 + # via + # backend (pyproject.toml) + # gcloud + # google-api-python-client + # google-auth-httplib2 + # oauth2client +idna==2.10 + # via + # backend (pyproject.toml) + # email-validator + # requests +importlib-metadata==6.0.0 + # via backend (pyproject.toml) +itsdangerous==2.1.2 + # via + # backend (pyproject.toml) + # flask + # flask-wtf +jinja2==3.1.2 + # via + # backend (pyproject.toml) + # flask +jwcrypto==1.4.2 + # via + # backend (pyproject.toml) + # python-jwt +mako==1.2.4 + # via + # backend (pyproject.toml) + # alembic +markupsafe==2.1.2 + # via + # backend (pyproject.toml) + # jinja2 + # mako + # werkzeug + # wtforms +mongoengine==0.26.0 + # via + # backend (pyproject.toml) + # flask-mongoengine +msgpack==1.0.4 + # via + # backend (pyproject.toml) + # cachecontrol +numpy==1.24.2 + # via + # backend (pyproject.toml) + # pandas +oauth2client==4.1.3 + # via + # backend (pyproject.toml) + # gax-google-logging-v2 + # gax-google-pubsub-v1 + # gcloud + # google-gax + # grpc-google-logging-v2 + # grpc-google-pubsub-v1 + # pyrebase4 +packaging==23.0 + # via + # backend (pyproject.toml) + # gunicorn +pandas==1.5.3 + # via backend (pyproject.toml) +pillow==10.4.0 + # via backend (pyproject.toml) +ply==3.8 + # via + # backend (pyproject.toml) + # google-gax +proto-plus==1.22.2 + # via + # backend (pyproject.toml) + # google-cloud-firestore + # google-cloud-translate +protobuf==4.22.0 + # via + # backend (pyproject.toml) + # gcloud + # google-api-core + # google-cloud-firestore + # google-cloud-translate + # google-gax + # googleapis-common-protos + # grpcio-status + # proto-plus +pyasn1==0.4.8 + # via + # backend (pyproject.toml) + # oauth2client + # pyasn1-modules + # rsa +pyasn1-modules==0.2.8 + # via + # backend (pyproject.toml) + # google-auth + # oauth2client +pycparser==2.21 + # via + # backend (pyproject.toml) + # cffi +pycryptodome==3.17 + # via + # backend (pyproject.toml) + # pyrebase4 +pyjwt==2.6.0 + # via + # backend (pyproject.toml) + # firebase-admin + # twilio +pymongo==4.6.0 + # via + # backend (pyproject.toml) + # mongoengine +pyparsing==3.0.9 + # via + # backend (pyproject.toml) + # httplib2 +pypdf2==3.0.1 + # via backend (pyproject.toml) +pyrebase4==4.6.0 + # via backend (pyproject.toml) +python-dateutil==2.8.2 + # via + # backend (pyproject.toml) + # pandas +python-dotenv==1.0.0 + # via backend (pyproject.toml) +python-editor==1.0.4 + # via backend (pyproject.toml) +python-engineio==4.3.4 + # via + # backend (pyproject.toml) + # python-socketio +python-http-client==3.3.7 + # via + # backend (pyproject.toml) + # sendgrid +python-jwt==4.0.0 + # via + # backend (pyproject.toml) + # pyrebase4 +python-socketio==5.7.2 + # via + # backend (pyproject.toml) + # flask-socketio +pytz==2022.7.1 + # via + # backend (pyproject.toml) + # pandas + # twilio +requests==2.28.2 + # via + # backend (pyproject.toml) + # cachecontrol + # google-api-core + # google-cloud-storage + # pyrebase4 + # requests-toolbelt + # twilio +requests-toolbelt==0.10.1 + # via + # backend (pyproject.toml) + # pyrebase4 +rsa==4.9 + # via + # backend (pyproject.toml) + # google-auth + # oauth2client +sendgrid==6.9.7 + # via backend (pyproject.toml) +setuptools==75.3.0 + # via + # gevent + # zope-event + # zope-interface +simple-websocket==0.9.0 + # via backend (pyproject.toml) +six==1.16.0 + # via + # backend (pyproject.toml) + # eventlet + # flask-cors + # gcloud + # google-auth + # google-auth-httplib2 + # oauth2client + # python-dateutil + # wtforms-json +sqlalchemy==2.0.4 + # via + # backend (pyproject.toml) + # alembic + # flask-sqlalchemy +starkbank-ecdsa==2.2.0 + # via + # backend (pyproject.toml) + # sendgrid +twilio==7.16.4 + # via backend (pyproject.toml) +typing-extensions==4.5.0 + # via + # backend (pyproject.toml) + # sqlalchemy +tzlocal==5.2 + # via apscheduler +uritemplate==4.1.1 + # via + # backend (pyproject.toml) + # google-api-python-client +urllib3==1.26.14 + # via + # backend (pyproject.toml) + # requests +werkzeug==2.2.3 + # via + # backend (pyproject.toml) + # flask +wrapt==1.15.0 + # via + # backend (pyproject.toml) + # deprecated +wsproto==1.2.0 + # via + # backend (pyproject.toml) + # simple-websocket +wtforms==3.0.1 + # via + # backend (pyproject.toml) + # flask-mongoengine + # flask-wtf + # wtforms-json +wtforms-json==0.3.5 + # via backend (pyproject.toml) +xlsxwriter==3.0.8 + # via backend (pyproject.toml) +zipp==3.15.0 + # via + # backend (pyproject.toml) + # importlib-metadata +zope-event==4.6 + # via + # backend (pyproject.toml) + # gevent +zope-interface==5.5.2 + # via + # backend (pyproject.toml) + # gevent diff --git a/backend/runtime.txt b/backend/runtime.txt index 2153d1e1..2bccd9d9 100644 --- a/backend/runtime.txt +++ b/backend/runtime.txt @@ -1 +1 @@ -python-3.9.7 +python-3.10.14 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..2b357063 --- /dev/null +++ b/frontend/src/components/BugReportModal.js @@ -0,0 +1,233 @@ +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..caee3871 --- /dev/null +++ b/frontend/src/components/pages/AdminBugReports.js @@ -0,0 +1,540 @@ +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) => ( + + +