diff --git a/backend/apps/core/api/internal/algolia.py b/backend/apps/core/api/internal/algolia.py
index 3353b68fcd..1b9afe7f8f 100644
--- a/backend/apps/core/api/internal/algolia.py
+++ b/backend/apps/core/api/internal/algolia.py
@@ -14,10 +14,10 @@
from apps.common.index import IndexBase
from apps.common.utils import get_user_ip_address
+from apps.core.constants import CACHE_PREFIX
from apps.core.utils.index import deep_camelize, get_params_for_index
from apps.core.validators import validate_search_params
-CACHE_PREFIX = "algolia_proxy"
CACHE_TTL_IN_SECONDS = 3600 # 1 hour
diff --git a/backend/apps/core/constants.py b/backend/apps/core/constants.py
new file mode 100644
index 0000000000..49c682d694
--- /dev/null
+++ b/backend/apps/core/constants.py
@@ -0,0 +1,3 @@
+"""Core app constants."""
+
+CACHE_PREFIX = "algolia_proxy"
diff --git a/backend/apps/core/utils/index.py b/backend/apps/core/utils/index.py
index 1a80391302..7e7d799ae6 100644
--- a/backend/apps/core/utils/index.py
+++ b/backend/apps/core/utils/index.py
@@ -1,12 +1,17 @@
"""Index utils."""
import contextlib
+import logging
from algoliasearch_django import register, unregister
from algoliasearch_django.registration import RegistrationError
from django.apps import apps
+from django.core.cache import cache
from apps.common.utils import convert_to_camel_case
+from apps.core.constants import CACHE_PREFIX
+
+logger = logging.getLogger(__name__)
class DisableIndexing:
@@ -140,6 +145,18 @@ def get_params_for_index(index_name: str) -> dict:
]
params["aroundLatLngViaIP"] = True
+ case "programs":
+ params["attributesToRetrieve"] = [
+ "idx_description",
+ "idx_ended_at",
+ "idx_experience_levels",
+ "idx_key",
+ "idx_modules",
+ "idx_name",
+ "idx_started_at",
+ "idx_status",
+ ]
+
case "projects":
params["attributesToRetrieve"] = [
"idx_contributors_count",
@@ -213,3 +230,32 @@ def get_params_for_index(index_name: str) -> dict:
params["attributesToRetrieve"] = []
return params
+
+
+def clear_index_cache(index_name: str) -> None:
+ """Clear Algolia proxy cache entries from the cache store that match a given index name.
+
+ Args:
+ index_name (str): The specific index to clear cache for.
+ If empty, the function does nothing.
+
+ Returns:
+ None
+
+ """
+ if not index_name:
+ logger.info("No index name provided, skipping cache clear.")
+ return
+
+ pattern = f"{CACHE_PREFIX}:{index_name}*"
+ keys_to_delete = list(cache.iter_keys(pattern))
+
+ if not keys_to_delete:
+ logger.info("No matching cache keys found for pattern: %s", pattern)
+ return
+
+ logger.info("Deleting %d cache keys for pattern: %s", len(keys_to_delete), pattern)
+
+ for key in keys_to_delete:
+ logger.info("Deleting key: %s", key)
+ cache.delete(key)
diff --git a/backend/apps/mentorship/admin/program.py b/backend/apps/mentorship/admin/program.py
index 031d245a38..ea8c2f4c5e 100644
--- a/backend/apps/mentorship/admin/program.py
+++ b/backend/apps/mentorship/admin/program.py
@@ -2,7 +2,7 @@
from django.contrib import admin
-from apps.mentorship.models.program import Program
+from apps.mentorship.models import Program
class ProgramAdmin(admin.ModelAdmin):
diff --git a/backend/apps/mentorship/api/__init__.py b/backend/apps/mentorship/api/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/apps/mentorship/api/internal/__init__.py b/backend/apps/mentorship/api/internal/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/apps/mentorship/api/internal/mutations/__init__.py b/backend/apps/mentorship/api/internal/mutations/__init__.py
new file mode 100644
index 0000000000..4c0f1e5333
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/mutations/__init__.py
@@ -0,0 +1,4 @@
+"""Mentorship mutations."""
+
+from .module import ModuleMutation
+from .program import ProgramMutation
diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py
new file mode 100644
index 0000000000..b185f10148
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/mutations/module.py
@@ -0,0 +1,197 @@
+"""GraphQL mutations for mentorship modules in the mentorship app."""
+
+import logging
+
+import strawberry
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
+from django.db import transaction
+from django.utils import timezone
+
+from apps.github.models import User as GithubUser
+from apps.mentorship.api.internal.nodes.module import (
+ CreateModuleInput,
+ ModuleNode,
+ UpdateModuleInput,
+)
+from apps.mentorship.models import Mentor, Module, Program
+from apps.nest.api.internal.permissions import IsAuthenticated
+from apps.owasp.models import Project
+
+logger = logging.getLogger(__name__)
+
+
+def resolve_mentors_from_logins(logins: list[str]) -> set[Mentor]:
+ """Resolve a list of GitHub logins to a set of Mentor objects."""
+ mentors = set()
+ for login in logins:
+ try:
+ github_user = GithubUser.objects.get(login__iexact=login.lower())
+ mentor, _ = Mentor.objects.get_or_create(github_user=github_user)
+ mentors.add(mentor)
+ except GithubUser.DoesNotExist as e:
+ msg = f"GitHub user '{login}' not found."
+ logger.warning(msg, exc_info=True)
+ raise ValueError(msg) from e
+ return mentors
+
+
+def _validate_module_dates(started_at, ended_at, program_started_at, program_ended_at) -> tuple:
+ """Validate and normalize module start/end dates against program constraints."""
+ if started_at is None or ended_at is None:
+ msg = "Both start and end dates are required."
+ raise ValidationError(message=msg)
+
+ if timezone.is_naive(started_at):
+ started_at = timezone.make_aware(started_at)
+ if timezone.is_naive(ended_at):
+ ended_at = timezone.make_aware(ended_at)
+
+ if ended_at <= started_at:
+ msg = "End date must be after start date."
+ raise ValidationError(message=msg)
+
+ if started_at < program_started_at:
+ msg = "Module start date cannot be before program start date."
+ raise ValidationError(message=msg)
+
+ if ended_at > program_ended_at:
+ msg = "Module end date cannot be after program end date."
+ raise ValidationError(message=msg)
+
+ return started_at, ended_at
+
+
+@strawberry.type
+class ModuleMutation:
+ """GraphQL mutations related to the mentorship Module model."""
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> ModuleNode:
+ """Create a new mentorship module. User must be an admin of the program."""
+ user = info.context.request.user
+
+ try:
+ program = Program.objects.get(key=input_data.program_key)
+ project = Project.objects.get(id=input_data.project_id)
+ creator_as_mentor = Mentor.objects.get(nest_user=user)
+ except (Program.DoesNotExist, Project.DoesNotExist) as e:
+ msg = f"{e.__class__.__name__} matching query does not exist."
+ raise ObjectDoesNotExist(msg) from e
+ except Mentor.DoesNotExist as e:
+ msg = "Only mentors can create modules."
+ raise PermissionDenied(msg) from e
+
+ if not program.admins.filter(id=creator_as_mentor.id).exists():
+ raise PermissionDenied
+
+ started_at, ended_at = _validate_module_dates(
+ input_data.started_at,
+ input_data.ended_at,
+ program.started_at,
+ program.ended_at,
+ )
+
+ module = Module.objects.create(
+ name=input_data.name,
+ description=input_data.description,
+ experience_level=input_data.experience_level.value,
+ started_at=started_at,
+ ended_at=ended_at,
+ domains=input_data.domains,
+ tags=input_data.tags,
+ program=program,
+ project=project,
+ )
+
+ if module.experience_level not in program.experience_levels:
+ program.experience_levels.append(module.experience_level)
+ program.save(update_fields=["experience_levels"])
+
+ mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins or [])
+ mentors_to_set.add(creator_as_mentor)
+ module.mentors.set(list(mentors_to_set))
+
+ return module
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode:
+ """Update an existing mentorship module. User must be an admin of the program."""
+ user = info.context.request.user
+
+ try:
+ module = Module.objects.select_related("program").get(
+ key=input_data.key, program__key=input_data.program_key
+ )
+ except Module.DoesNotExist as e:
+ msg = "Module not found."
+ raise ObjectDoesNotExist(msg) from e
+
+ try:
+ creator_as_mentor = Mentor.objects.get(nest_user=user)
+ except Mentor.DoesNotExist as err:
+ msg = "Only mentors can edit modules."
+ logger.warning(
+ "User '%s' is not a mentor and cannot edit modules.",
+ user.username,
+ exc_info=True,
+ )
+ raise PermissionDenied(msg) from err
+
+ if not module.program.admins.filter(id=creator_as_mentor.id).exists():
+ raise PermissionDenied
+
+ started_at, ended_at = _validate_module_dates(
+ input_data.started_at,
+ input_data.ended_at,
+ module.program.started_at,
+ module.program.ended_at,
+ )
+
+ old_experience_level = module.experience_level
+
+ field_mapping = {
+ "name": input_data.name,
+ "description": input_data.description,
+ "experience_level": input_data.experience_level.value,
+ "started_at": started_at,
+ "ended_at": ended_at,
+ "domains": input_data.domains,
+ "tags": input_data.tags,
+ }
+
+ for field, value in field_mapping.items():
+ setattr(module, field, value)
+
+ try:
+ module.project = Project.objects.get(id=input_data.project_id)
+ except Project.DoesNotExist as err:
+ msg = f"Project with id '{input_data.project_id}' not found."
+ logger.warning(msg, exc_info=True)
+ raise ObjectDoesNotExist(msg) from err
+
+ if input_data.mentor_logins is not None:
+ mentors_to_set = resolve_mentors_from_logins(input_data.mentor_logins)
+ module.mentors.set(mentors_to_set)
+
+ module.save()
+
+ if module.experience_level not in module.program.experience_levels:
+ module.program.experience_levels.append(module.experience_level)
+
+ # Remove old experience level if no other module is using it
+ if (
+ old_experience_level != module.experience_level
+ and old_experience_level in module.program.experience_levels
+ and not Module.objects.filter(
+ program=module.program, experience_level=old_experience_level
+ )
+ .exclude(id=module.id)
+ .exists()
+ ):
+ module.program.experience_levels.remove(old_experience_level)
+
+ module.program.save(update_fields=["experience_levels"])
+
+ return module
diff --git a/backend/apps/mentorship/api/internal/mutations/program.py b/backend/apps/mentorship/api/internal/mutations/program.py
new file mode 100644
index 0000000000..0905e28c53
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/mutations/program.py
@@ -0,0 +1,166 @@
+"""Mentorship Program GraphQL Mutations."""
+
+import logging
+
+import strawberry
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
+from django.db import transaction
+
+from apps.mentorship.api.internal.mutations.module import resolve_mentors_from_logins
+from apps.mentorship.api.internal.nodes.enum import ProgramStatusEnum
+from apps.mentorship.api.internal.nodes.program import (
+ CreateProgramInput,
+ ProgramNode,
+ UpdateProgramInput,
+ UpdateProgramStatusInput,
+)
+from apps.mentorship.models import Mentor, Program
+from apps.nest.api.internal.permissions import IsAuthenticated
+
+logger = logging.getLogger(__name__)
+
+
+@strawberry.type
+class ProgramMutation:
+ """GraphQL mutations related to program."""
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def create_program(self, info: strawberry.Info, input_data: CreateProgramInput) -> ProgramNode:
+ """Create a new mentorship program."""
+ user = info.context.request.user
+
+ mentor, created = Mentor.objects.get_or_create(
+ nest_user=user, defaults={"github_user": user.github_user}
+ )
+ if created:
+ logger.info("Created a new mentor profile for user '%s'.", user.username)
+
+ if input_data.ended_at <= input_data.started_at:
+ msg = "End date must be after start date."
+ logger.warning(
+ "Validation error for user '%s' creating program '%s': %s",
+ user.username,
+ input_data.name,
+ msg,
+ )
+ raise ValidationError(msg)
+
+ program = Program.objects.create(
+ name=input_data.name,
+ description=input_data.description,
+ mentees_limit=input_data.mentees_limit,
+ started_at=input_data.started_at,
+ ended_at=input_data.ended_at,
+ domains=input_data.domains,
+ tags=input_data.tags,
+ status=ProgramStatusEnum.DRAFT.value,
+ )
+
+ program.admins.set([mentor])
+
+ logger.info(
+ "User '%s' successfully created program '%s' (ID: %s).",
+ user.username,
+ program.name,
+ program.id,
+ )
+
+ return program
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput) -> ProgramNode:
+ """Update an existing mentorship program. Only admins can update."""
+ user = info.context.request.user
+
+ try:
+ program = Program.objects.get(key=input_data.key)
+ except Program.DoesNotExist as err:
+ msg = f"Program with key '{input_data.key}' not found."
+ logger.warning(msg, exc_info=True)
+ raise ObjectDoesNotExist(msg) from err
+
+ try:
+ admin = Mentor.objects.get(nest_user=user)
+ except Mentor.DoesNotExist as err:
+ msg = "You must be a mentor to update a program."
+ logger.warning(
+ "User '%s' is not a mentor and cannot update programs.",
+ user.username,
+ exc_info=True,
+ )
+ raise PermissionDenied(msg) from err
+
+ if not program.admins.filter(id=admin.id).exists():
+ msg = "You must be an admin of this program to update it."
+ logger.warning(
+ "Permission denied for user '%s' to update program '%s'.",
+ user.username,
+ program.key,
+ )
+ raise PermissionDenied(msg)
+
+ if (
+ input_data.ended_at is not None
+ and input_data.started_at is not None
+ and input_data.ended_at <= input_data.started_at
+ ):
+ msg = "End date must be after start date."
+ logger.warning("Validation error updating program '%s': %s", program.key, msg)
+ raise ValidationError(msg)
+
+ field_mapping = {
+ "name": input_data.name,
+ "description": input_data.description,
+ "mentees_limit": input_data.mentees_limit,
+ "started_at": input_data.started_at,
+ "ended_at": input_data.ended_at,
+ "domains": input_data.domains,
+ "tags": input_data.tags,
+ }
+
+ for field, value in field_mapping.items():
+ if value is not None:
+ setattr(program, field, value)
+
+ if input_data.status is not None:
+ program.status = input_data.status.value
+
+ program.save()
+
+ if input_data.admin_logins is not None:
+ admins_to_set = resolve_mentors_from_logins(input_data.admin_logins)
+ program.admins.set(admins_to_set)
+
+ return program
+
+ @strawberry.mutation(permission_classes=[IsAuthenticated])
+ @transaction.atomic
+ def update_program_status(
+ self, info: strawberry.Info, input_data: UpdateProgramStatusInput
+ ) -> ProgramNode:
+ """Update only the status of a program."""
+ user = info.context.request.user
+
+ try:
+ program = Program.objects.get(key=input_data.key)
+ except Program.DoesNotExist as e:
+ msg = f"Program with key '{input_data.key}' not found."
+ raise ObjectDoesNotExist(msg) from e
+
+ try:
+ mentor = Mentor.objects.get(nest_user=user)
+ except Mentor.DoesNotExist as e:
+ msg = "You must be a mentor to update a program."
+ raise PermissionDenied(msg) from e
+
+ if not program.admins.filter(id=mentor.id).exists():
+ raise PermissionDenied
+
+ program.status = input_data.status.value
+ program.save()
+
+ logger.info("Updated status of program '%s' to '%s'", program.key, program.status)
+
+ return program
diff --git a/backend/apps/mentorship/api/internal/nodes/__init__.py b/backend/apps/mentorship/api/internal/nodes/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/apps/mentorship/api/internal/nodes/enum.py b/backend/apps/mentorship/api/internal/nodes/enum.py
new file mode 100644
index 0000000000..32bdad33b0
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/nodes/enum.py
@@ -0,0 +1,27 @@
+"""GraphQL enum for Mentorship App."""
+
+import enum
+
+import strawberry
+
+from apps.mentorship.models import Program
+from apps.mentorship.models.common.experience_level import ExperienceLevel
+
+
+@strawberry.enum
+class ExperienceLevelEnum(enum.Enum):
+ """experience level enum."""
+
+ BEGINNER = ExperienceLevel.ExperienceLevelChoices.BEGINNER
+ INTERMEDIATE = ExperienceLevel.ExperienceLevelChoices.INTERMEDIATE
+ ADVANCED = ExperienceLevel.ExperienceLevelChoices.ADVANCED
+ EXPERT = ExperienceLevel.ExperienceLevelChoices.EXPERT
+
+
+@strawberry.enum
+class ProgramStatusEnum(enum.Enum):
+ """program status enum."""
+
+ DRAFT = Program.ProgramStatus.DRAFT
+ PUBLISHED = Program.ProgramStatus.PUBLISHED
+ COMPLETED = Program.ProgramStatus.COMPLETED
diff --git a/backend/apps/mentorship/api/internal/nodes/mentor.py b/backend/apps/mentorship/api/internal/nodes/mentor.py
new file mode 100644
index 0000000000..5ef71cda1c
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/nodes/mentor.py
@@ -0,0 +1,25 @@
+"""GraphQL node for Mentor model."""
+
+import strawberry
+
+
+@strawberry.type
+class MentorNode:
+ """A GraphQL node representing a mentorship mentor."""
+
+ id: strawberry.ID
+
+ @strawberry.field
+ def avatar_url(self) -> str:
+ """Get the GitHub avatar URL of the mentor."""
+ return self.github_user.avatar_url if self.github_user else ""
+
+ @strawberry.field
+ def name(self) -> str:
+ """Get the GitHub name of the mentor."""
+ return self.github_user.name if self.github_user else ""
+
+ @strawberry.field
+ def login(self) -> str:
+ """Get the GitHub login of the mentor."""
+ return self.github_user.login if self.github_user else ""
diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py
new file mode 100644
index 0000000000..fa94ad4728
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/nodes/module.py
@@ -0,0 +1,71 @@
+"""GraphQL nodes for Module model."""
+
+from datetime import datetime
+
+import strawberry
+
+from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum
+from apps.mentorship.api.internal.nodes.mentor import MentorNode
+from apps.mentorship.api.internal.nodes.program import ProgramNode
+
+
+@strawberry.type
+class ModuleNode:
+ """A GraphQL node representing a mentorship module."""
+
+ id: strawberry.ID
+ key: str
+ name: str
+ description: str
+ domains: list[str] | None = None
+ ended_at: datetime
+ experience_level: ExperienceLevelEnum
+ program: ProgramNode | None = None
+ project_id: strawberry.ID | None = None
+ started_at: datetime
+ tags: list[str] | None = None
+
+ @strawberry.field
+ def mentors(self) -> list[MentorNode]:
+ """Get the list of mentors for this module."""
+ return self.mentors.all()
+
+ @strawberry.field
+ def project_name(self) -> str | None:
+ """Get the project name for this module."""
+ return self.project.name if self.project else None
+
+
+@strawberry.input
+class CreateModuleInput:
+ """Input for creating a mentorship module."""
+
+ name: str
+ description: str
+ domains: list[str] = strawberry.field(default_factory=list)
+ ended_at: datetime
+ experience_level: ExperienceLevelEnum
+ mentor_logins: list[str] | None = None
+ program_key: str
+ project_name: str
+ project_id: strawberry.ID
+ started_at: datetime
+ tags: list[str] = strawberry.field(default_factory=list)
+
+
+@strawberry.input
+class UpdateModuleInput:
+ """Input for updating a mentorship module."""
+
+ key: str
+ program_key: str
+ name: str
+ description: str
+ domains: list[str] = strawberry.field(default_factory=list)
+ ended_at: datetime
+ experience_level: ExperienceLevelEnum
+ mentor_logins: list[str] | None = None
+ project_id: strawberry.ID
+ project_name: str
+ started_at: datetime
+ tags: list[str] = strawberry.field(default_factory=list)
diff --git a/backend/apps/mentorship/api/internal/nodes/program.py b/backend/apps/mentorship/api/internal/nodes/program.py
new file mode 100644
index 0000000000..463caa076f
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/nodes/program.py
@@ -0,0 +1,81 @@
+"""GraphQL node for Program model."""
+
+from datetime import datetime
+
+import strawberry
+
+from apps.mentorship.api.internal.nodes.enum import (
+ ExperienceLevelEnum,
+ ProgramStatusEnum,
+)
+from apps.mentorship.api.internal.nodes.mentor import MentorNode
+
+
+@strawberry.type
+class ProgramNode:
+ """A mentorship program node."""
+
+ id: strawberry.ID
+ key: str
+ name: str
+ description: str
+ domains: list[str] | None = None
+ ended_at: datetime
+ experience_levels: list[ExperienceLevelEnum] | None = None
+ mentees_limit: int | None = None
+ started_at: datetime
+ status: ProgramStatusEnum
+ user_role: str | None = None
+ tags: list[str] | None = None
+
+ @strawberry.field
+ def admins(self) -> list[MentorNode] | None:
+ """Get the list of program administrators."""
+ return self.admins.all()
+
+
+@strawberry.type
+class PaginatedPrograms:
+ """A paginated list of mentorship programs."""
+
+ current_page: int
+ programs: list[ProgramNode]
+ total_pages: int
+
+
+@strawberry.input
+class CreateProgramInput:
+ """Input Node for creating a mentorship program."""
+
+ name: str
+ description: str
+ domains: list[str] = strawberry.field(default_factory=list)
+ ended_at: datetime
+ mentees_limit: int
+ started_at: datetime
+ tags: list[str] = strawberry.field(default_factory=list)
+
+
+@strawberry.input
+class UpdateProgramInput:
+ """Input for updating a mentorship program."""
+
+ key: str
+ name: str
+ description: str
+ admin_logins: list[str] | None = None
+ domains: list[str] | None = None
+ ended_at: datetime
+ mentees_limit: int
+ started_at: datetime
+ status: ProgramStatusEnum
+ tags: list[str] | None = None
+
+
+@strawberry.input
+class UpdateProgramStatusInput:
+ """Input for updating program status."""
+
+ key: str
+ name: str
+ status: ProgramStatusEnum
diff --git a/backend/apps/mentorship/api/internal/queries/__init__.py b/backend/apps/mentorship/api/internal/queries/__init__.py
new file mode 100644
index 0000000000..f690a807a0
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/queries/__init__.py
@@ -0,0 +1,5 @@
+"""Core Mentorship mutations."""
+
+from .mentorship import MentorshipQuery
+from .module import ModuleQuery
+from .program import ProgramQuery
diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py
new file mode 100644
index 0000000000..90bd1f7a8b
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/queries/mentorship.py
@@ -0,0 +1,33 @@
+"""GraphQL queries for mentorship role management."""
+
+import strawberry
+
+from apps.github.models.user import User as GithubUser
+from apps.mentorship.models.mentor import Mentor
+
+
+@strawberry.type
+class UserRolesResult:
+ """Result type for user roles query."""
+
+ roles: list[str]
+
+
+@strawberry.type
+class MentorshipQuery:
+ """GraphQL queries for mentorship-related data."""
+
+ @strawberry.field
+ def is_mentor(self, login: str) -> bool:
+ """Check if a GitHub login is a mentor."""
+ if not login or not login.strip():
+ return False
+
+ login = login.strip()
+
+ try:
+ github_user = GithubUser.objects.get(login=login)
+ except GithubUser.DoesNotExist:
+ return False
+
+ return Mentor.objects.filter(github_user=github_user).exists()
diff --git a/backend/apps/mentorship/api/internal/queries/module.py b/backend/apps/mentorship/api/internal/queries/module.py
new file mode 100644
index 0000000000..8fec753752
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/queries/module.py
@@ -0,0 +1,50 @@
+"""OWASP module GraphQL queries."""
+
+import logging
+
+import strawberry
+from django.core.exceptions import ObjectDoesNotExist
+
+from apps.mentorship.api.internal.nodes.module import ModuleNode
+from apps.mentorship.models import Module
+
+logger = logging.getLogger(__name__)
+
+
+@strawberry.type
+class ModuleQuery:
+ """Module queries."""
+
+ @strawberry.field
+ def get_program_modules(self, program_key: str) -> list[ModuleNode]:
+ """Get all modules by program Key. Returns an empty list if program is not found."""
+ return (
+ Module.objects.filter(program__key=program_key)
+ .select_related("program", "project")
+ .prefetch_related("mentors__github_user")
+ .order_by("started_at")
+ )
+
+ @strawberry.field
+ def get_project_modules(self, project_key: str) -> list[ModuleNode]:
+ """Get all modules by project Key. Returns an empty list if project is not found."""
+ return (
+ Module.objects.filter(project__key=project_key)
+ .select_related("program", "project")
+ .prefetch_related("mentors__github_user")
+ .order_by("started_at")
+ )
+
+ @strawberry.field
+ def get_module(self, module_key: str, program_key: str) -> ModuleNode:
+ """Get a single module by its key within a specific program."""
+ try:
+ return (
+ Module.objects.select_related("program", "project")
+ .prefetch_related("mentors__github_user")
+ .get(key=module_key, program__key=program_key)
+ )
+ except Module.DoesNotExist as err:
+ msg = f"Module with key '{module_key}' under program '{program_key}' not found."
+ logger.warning(msg, exc_info=True)
+ raise ObjectDoesNotExist(msg) from err
diff --git a/backend/apps/mentorship/api/internal/queries/program.py b/backend/apps/mentorship/api/internal/queries/program.py
new file mode 100644
index 0000000000..0abf8a263d
--- /dev/null
+++ b/backend/apps/mentorship/api/internal/queries/program.py
@@ -0,0 +1,80 @@
+"""OWASP program GraphQL queries."""
+
+import logging
+
+import strawberry
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q
+
+from apps.mentorship.api.internal.nodes.program import PaginatedPrograms, ProgramNode
+from apps.mentorship.models import Program
+from apps.mentorship.models.mentor import Mentor
+from apps.nest.api.internal.permissions import IsAuthenticated
+
+PAGE_SIZE = 25
+logger = logging.getLogger(__name__)
+
+
+@strawberry.type
+class ProgramQuery:
+ """Program queries."""
+
+ @strawberry.field
+ def get_program(self, program_key: str) -> ProgramNode:
+ """Get a program by Key."""
+ try:
+ program = Program.objects.prefetch_related("admins__github_user").get(key=program_key)
+ except Program.DoesNotExist as err:
+ msg = f"Program with key '{program_key}' not found."
+ logger.warning(msg, exc_info=True)
+ raise ObjectDoesNotExist(msg) from err
+
+ return program
+
+ @strawberry.field(permission_classes=[IsAuthenticated])
+ def my_programs(
+ self,
+ info: strawberry.Info,
+ search: str = "",
+ page: int = 1,
+ limit: int = 24,
+ ) -> PaginatedPrograms:
+ """Get paginated programs where the current user is admin or mentor."""
+ user = info.context.request.user
+
+ try:
+ mentor = Mentor.objects.select_related("github_user").get(github_user=user.github_user)
+ except Mentor.DoesNotExist:
+ logger.warning("Mentor for user '%s' not found.", user.username)
+ return PaginatedPrograms(programs=[], total_pages=0, current_page=page)
+
+ queryset = (
+ Program.objects.prefetch_related(
+ "admins__github_user", "modules__mentors__github_user"
+ )
+ .filter(Q(admins=mentor) | Q(modules__mentors=mentor))
+ .distinct()
+ )
+
+ if search:
+ queryset = queryset.filter(name__icontains=search)
+
+ total_count = queryset.count()
+ total_pages = max(1, (total_count + limit - 1) // limit)
+ page = max(1, min(page, total_pages))
+ offset = (page - 1) * limit
+
+ paginated_programs = queryset.order_by("-nest_created_at")[offset : offset + limit]
+
+ results = []
+ mentor_id = mentor.id
+ for program in paginated_programs:
+ is_admin = any(admin.id == mentor_id for admin in program.admins.all())
+ program.user_role = "admin" if is_admin else "mentor"
+ results.append(program)
+
+ return PaginatedPrograms(
+ programs=results,
+ total_pages=total_pages,
+ current_page=page,
+ )
diff --git a/backend/apps/mentorship/apps.py b/backend/apps/mentorship/apps.py
index 8d4cff0220..18f16a0dd4 100644
--- a/backend/apps/mentorship/apps.py
+++ b/backend/apps/mentorship/apps.py
@@ -8,3 +8,7 @@ class MentorshipConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.mentorship"
+
+ def ready(self):
+ """Ready."""
+ import apps.mentorship.signals # noqa: F401
diff --git a/backend/apps/mentorship/index/__init__.py b/backend/apps/mentorship/index/__init__.py
new file mode 100644
index 0000000000..ed3bc85c99
--- /dev/null
+++ b/backend/apps/mentorship/index/__init__.py
@@ -0,0 +1 @@
+from .registry.program import ProgramIndex
diff --git a/backend/apps/mentorship/index/registry/__init__.py b/backend/apps/mentorship/index/registry/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/apps/mentorship/index/registry/program.py b/backend/apps/mentorship/index/registry/program.py
new file mode 100644
index 0000000000..da7675943e
--- /dev/null
+++ b/backend/apps/mentorship/index/registry/program.py
@@ -0,0 +1,50 @@
+"""OWASP app program index."""
+
+from apps.common.index import IndexBase, register
+from apps.mentorship.models import Program
+
+
+@register(Program)
+class ProgramIndex(IndexBase):
+ """Program index."""
+
+ index_name = "programs"
+
+ fields = (
+ "idx_description",
+ "idx_ended_at",
+ "idx_experience_levels",
+ "idx_key",
+ "idx_name",
+ "idx_started_at",
+ "idx_status",
+ )
+
+ settings = {
+ "attributesForFaceting": [
+ "filterOnly(idx_status)",
+ "idx_experience_levels",
+ ],
+ "indexLanguages": ["en"],
+ "customRanking": [],
+ "ranking": [
+ "typo",
+ "words",
+ "filters",
+ "proximity",
+ "attribute",
+ "exact",
+ "custom",
+ ],
+ "searchableAttributes": [
+ "unordered(idx_description)",
+ "unordered(idx_experience_levels)",
+ "unordered(idx_name)",
+ ],
+ }
+
+ should_index = "is_indexable"
+
+ def get_entities(self):
+ """Return only published programs for indexing."""
+ return Program.objects.filter(status=Program.ProgramStatus.PUBLISHED)
diff --git a/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py b/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py
new file mode 100644
index 0000000000..28eda6e107
--- /dev/null
+++ b/backend/apps/mentorship/migrations/0004_module_key_program_key_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.4 on 2025-08-06 06:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("mentorship", "0003_tasklevel_task_level_and_more"),
+ ("owasp", "0044_chapter_chapter_updated_at_desc_idx_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="module",
+ name="key",
+ field=models.CharField(default="default", max_length=200, verbose_name="Key"),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="program",
+ name="key",
+ field=models.CharField(
+ default="default-key", max_length=200, unique=True, verbose_name="Key"
+ ),
+ preserve_default=False,
+ ),
+ migrations.AddConstraint(
+ model_name="module",
+ constraint=models.UniqueConstraint(
+ fields=("program", "key"), name="unique_module_key_in_program"
+ ),
+ ),
+ ]
diff --git a/backend/apps/mentorship/models/mixins/__init__.py b/backend/apps/mentorship/models/mixins/__init__.py
new file mode 100644
index 0000000000..2f4da21452
--- /dev/null
+++ b/backend/apps/mentorship/models/mixins/__init__.py
@@ -0,0 +1 @@
+"""Mentorship app models mixins."""
diff --git a/backend/apps/mentorship/models/mixins/program.py b/backend/apps/mentorship/models/mixins/program.py
new file mode 100644
index 0000000000..fd843159a8
--- /dev/null
+++ b/backend/apps/mentorship/models/mixins/program.py
@@ -0,0 +1,47 @@
+"""Mentorship program mixins."""
+
+from __future__ import annotations
+
+
+class ProgramIndexMixin:
+ """Program index mixin for mentorship programs."""
+
+ @property
+ def is_indexable(self) -> bool:
+ """Only index published programs."""
+ return self.status == self.__class__.ProgramStatus.PUBLISHED
+
+ @property
+ def idx_name(self) -> str:
+ """Name for Algolia indexing."""
+ return self.name
+
+ @property
+ def idx_key(self) -> str:
+ """Unique key for Algolia indexing."""
+ return self.key
+
+ @property
+ def idx_status(self) -> str:
+ """Status for Algolia indexing."""
+ return self.status
+
+ @property
+ def idx_description(self) -> str:
+ """Description for Algolia indexing."""
+ return self.description or ""
+
+ @property
+ def idx_experience_levels(self) -> list[str]:
+ """List of experience levels for Algolia filtering."""
+ return self.experience_levels or []
+
+ @property
+ def idx_started_at(self) -> str | None:
+ """Formatted start datetime."""
+ return self.started_at.isoformat() if self.started_at else None
+
+ @property
+ def idx_ended_at(self) -> str | None:
+ """Formatted end datetime for filtering/sorting."""
+ return self.ended_at.isoformat() if self.ended_at else None
diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py
index 89662bc993..7309baca73 100644
--- a/backend/apps/mentorship/models/module.py
+++ b/backend/apps/mentorship/models/module.py
@@ -5,6 +5,7 @@
from django.db import models
from apps.common.models import TimestampedModel
+from apps.common.utils import slugify
from apps.mentorship.models.common import (
ExperienceLevel,
MatchingAttributes,
@@ -18,6 +19,12 @@ class Module(ExperienceLevel, MatchingAttributes, StartEndRange, TimestampedMode
class Meta:
db_table = "mentorship_modules"
verbose_name_plural = "Modules"
+ constraints = [
+ models.UniqueConstraint(
+ fields=["program", "key"],
+ name="unique_module_key_in_program",
+ )
+ ]
description = models.TextField(
verbose_name="Description",
@@ -25,6 +32,11 @@ class Meta:
default="",
)
+ key = models.CharField(
+ verbose_name="Key",
+ max_length=200,
+ )
+
name = models.CharField(
max_length=200,
verbose_name="Name",
@@ -73,8 +85,8 @@ def __str__(self) -> str:
def save(self, *args, **kwargs):
"""Save module."""
if self.program:
- # Set default dates from program if not provided.
self.started_at = self.started_at or self.program.started_at
self.ended_at = self.ended_at or self.program.ended_at
+ self.key = slugify(self.name)
super().save(*args, **kwargs)
diff --git a/backend/apps/mentorship/models/program.py b/backend/apps/mentorship/models/program.py
index 9b81a11a6b..1ec11f4add 100644
--- a/backend/apps/mentorship/models/program.py
+++ b/backend/apps/mentorship/models/program.py
@@ -6,10 +6,16 @@
from django.db import models
from apps.common.models import TimestampedModel
-from apps.mentorship.models.common import ExperienceLevel, MatchingAttributes, StartEndRange
+from apps.common.utils import slugify
+from apps.mentorship.models.common import (
+ ExperienceLevel,
+ MatchingAttributes,
+ StartEndRange,
+)
+from apps.mentorship.models.mixins.program import ProgramIndexMixin
-class Program(MatchingAttributes, StartEndRange, TimestampedModel):
+class Program(MatchingAttributes, ProgramIndexMixin, StartEndRange, TimestampedModel):
"""Program model representing an overarching mentorship initiative."""
class Meta:
@@ -49,6 +55,7 @@ class ProgramStatus(models.TextChoices):
unique=True,
verbose_name="Name",
)
+ key = models.CharField(verbose_name="Key", max_length=200, unique=True)
status = models.CharField(
verbose_name="Status",
@@ -72,3 +79,9 @@ def __str__(self) -> str:
"""
return self.name
+
+ def save(self, *args, **kwargs) -> None:
+ """Save program."""
+ self.key = slugify(self.name)
+
+ super().save(*args, **kwargs)
diff --git a/backend/apps/mentorship/signals/__init__.py b/backend/apps/mentorship/signals/__init__.py
new file mode 100644
index 0000000000..376a8c1d0e
--- /dev/null
+++ b/backend/apps/mentorship/signals/__init__.py
@@ -0,0 +1 @@
+from .program import ProgramPostSaveHandler
diff --git a/backend/apps/mentorship/signals/program.py b/backend/apps/mentorship/signals/program.py
new file mode 100644
index 0000000000..6e8200197a
--- /dev/null
+++ b/backend/apps/mentorship/signals/program.py
@@ -0,0 +1,24 @@
+"""Signal handler for Program post_save to clear Algolia cache."""
+
+import logging
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from apps.core.utils.index import clear_index_cache
+from apps.mentorship.models import Program
+
+logger = logging.getLogger(__name__)
+
+
+class ProgramPostSaveHandler:
+ """Handles post_save signal for Program model to clear Algolia cache."""
+
+ @receiver(post_save, sender=Program)
+ def program_post_save_clear_algolia_cache(sender, instance, **kwargs): # noqa: N805
+ """Signal handler to clear Algolia cache for the Program index.
+
+ The sender, instance, and kwargs arguments are provided by the post_save signal.
+ """
+ logger.info("Signal received for program '%s'. Clearing 'programs' index.", instance.name)
+ clear_index_cache("programs")
diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py
index 22524fe144..1b881d44ef 100644
--- a/backend/apps/owasp/api/internal/nodes/project.py
+++ b/backend/apps/owasp/api/internal/nodes/project.py
@@ -9,7 +9,9 @@
from apps.github.api.internal.nodes.release import ReleaseNode
from apps.github.api.internal.nodes.repository import RepositoryNode
from apps.owasp.api.internal.nodes.common import GenericEntityNode
-from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode
+from apps.owasp.api.internal.nodes.project_health_metrics import (
+ ProjectHealthMetricsNode,
+)
from apps.owasp.models.project import Project
from apps.owasp.models.project_health_metrics import ProjectHealthMetrics
@@ -24,6 +26,7 @@
"contributors_count",
"created_at",
"forks_count",
+ "id",
"is_active",
"level",
"name",
diff --git a/backend/apps/owasp/api/internal/queries/project.py b/backend/apps/owasp/api/internal/queries/project.py
index e2c86ecdbd..154e25bfe9 100644
--- a/backend/apps/owasp/api/internal/queries/project.py
+++ b/backend/apps/owasp/api/internal/queries/project.py
@@ -1,7 +1,9 @@
"""OWASP project GraphQL queries."""
import strawberry
+from django.db.models import Q
+from apps.github.models.user import User as GithubUser
from apps.owasp.api.internal.nodes.project import ProjectNode
from apps.owasp.models.project import Project
@@ -38,3 +40,27 @@ def recent_projects(self, limit: int = 8) -> list[ProjectNode]:
"""
return Project.objects.filter(is_active=True).order_by("-created_at")[:limit]
+
+ @strawberry.field
+ def search_projects(self, query: str) -> list[ProjectNode]:
+ """Search active projects by name (case-insensitive, partial match)."""
+ if not query.strip():
+ return []
+
+ return Project.objects.filter(
+ is_active=True,
+ name__icontains=query.strip(),
+ ).order_by("name")[:3]
+
+ @strawberry.field
+ def is_project_leader(self, info: strawberry.Info, login: str) -> bool:
+ """Check if a GitHub login or name is listed as a project leader."""
+ try:
+ github_user = GithubUser.objects.get(login=login)
+ except GithubUser.DoesNotExist:
+ return False
+
+ return Project.objects.filter(
+ Q(leaders_raw__icontains=github_user.login)
+ | Q(leaders_raw__icontains=(github_user.name or ""))
+ ).exists()
diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py
index e69de29bb2..5ce17c615d 100644
--- a/backend/apps/owasp/models/__init__.py
+++ b/backend/apps/owasp/models/__init__.py
@@ -0,0 +1 @@
+from .project import Project
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 618e76b7ca..9d6b96afc6 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -14,6 +14,7 @@ omit = [
"**/admin.py",
"**/apps.py",
"**/migrations/*",
+ "**/mentorship/*", # TODO: work in progress
"manage.py",
"settings/*",
"tests/*",
diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py
index 2993c7a9fc..362519323e 100644
--- a/backend/settings/graphql.py
+++ b/backend/settings/graphql.py
@@ -3,18 +3,38 @@
import strawberry
from apps.github.api.internal.queries import GithubQuery
+from apps.mentorship.api.internal.mutations import (
+ ModuleMutation,
+ ProgramMutation,
+)
+from apps.mentorship.api.internal.queries import (
+ MentorshipQuery,
+ ModuleQuery,
+ ProgramQuery,
+)
from apps.nest.api.internal.mutations import NestMutations
from apps.nest.api.internal.queries import NestQuery
from apps.owasp.api.internal.queries import OwaspQuery
@strawberry.type
-class Mutation(NestMutations):
+class Mutation(
+ ModuleMutation,
+ ProgramMutation,
+ NestMutations,
+):
"""Schema mutations."""
@strawberry.type
-class Query(GithubQuery, NestQuery, OwaspQuery):
+class Query(
+ GithubQuery,
+ MentorshipQuery,
+ ModuleQuery,
+ NestQuery,
+ OwaspQuery,
+ ProgramQuery,
+):
"""Schema queries."""
diff --git a/backend/tests/apps/github/management/commands/github_match_users_test.py b/backend/tests/apps/github/management/commands/github_match_users_test.py
index a5faa2a0d8..51c8a23135 100644
--- a/backend/tests/apps/github/management/commands/github_match_users_test.py
+++ b/backend/tests/apps/github/management/commands/github_match_users_test.py
@@ -32,7 +32,6 @@ def test_add_arguments(self, command):
"""Test that the command adds the correct arguments."""
parser = MagicMock()
command.add_arguments(parser)
-
assert parser.add_argument.call_count == 2
parser.add_argument.assert_any_call(
"model_name",
@@ -75,7 +74,6 @@ def command(self):
"""Return a command instance."""
command = Command()
command.stdout = MagicMock()
-
return command
@pytest.fixture
@@ -91,7 +89,6 @@ def mock_users(self):
def test_no_leaders(self, command):
"""Test with no leaders provided."""
exact, fuzzy, unmatched = command.process_leaders([], 75, {})
-
assert exact == []
assert fuzzy == []
assert unmatched == []
@@ -100,7 +97,6 @@ def test_exact_match(self, command, mock_users):
"""Test exact matching."""
leaders_raw = ["john_doe", "Jane Doe"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users)
-
assert len(exact) == 2
assert mock_users[1] in exact
assert mock_users[2] in exact
@@ -110,13 +106,12 @@ def test_exact_match(self, command, mock_users):
@patch("apps.github.management.commands.github_match_users.fuzz")
def test_fuzzy_match(self, mock_fuzz, command, mock_users):
"""Test fuzzy matching."""
- mock_fuzz.token_sort_ratio.side_effect = (
- lambda left, right: 90 if "peter" in right.lower() or "peter" in left.lower() else 10
+ mock_fuzz.token_sort_ratio.side_effect = lambda left, right: (
+ 90 if "peter" in right.lower() or "peter" in left.lower() else 10
)
leaders_raw = ["pete_jones"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 80, mock_users)
-
assert exact == []
assert len(fuzzy) == 1
assert mock_users[3] in fuzzy
@@ -126,7 +121,6 @@ def test_unmatched_leader(self, command, mock_users):
"""Test unmatched leader."""
leaders_raw = ["unknown_leader"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 100, mock_users)
-
assert exact == []
assert fuzzy == []
assert unmatched == ["unknown_leader"]
@@ -134,7 +128,6 @@ def test_unmatched_leader(self, command, mock_users):
def test_mixed_matches(self, command, mock_users):
"""Test a mix of exact, fuzzy, and unmatched leaders."""
leaders_raw = ["john_doe", "pete_jones", "unknown_leader"]
-
with patch("apps.github.management.commands.github_match_users.fuzz") as mock_fuzz:
def ratio(s1, s2):
@@ -142,7 +135,6 @@ def ratio(s1, s2):
mock_fuzz.token_sort_ratio.side_effect = ratio
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 80, mock_users)
-
assert len(exact) == 1
assert mock_users[1] in exact
assert len(fuzzy) == 1
@@ -153,7 +145,6 @@ def test_duplicate_leaders(self, command, mock_users):
"""Test with duplicate leaders in raw list."""
leaders_raw = ["john_doe", "john_doe"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users)
-
assert len(exact) == 1
assert mock_users[1] in exact
assert fuzzy == []
@@ -163,7 +154,6 @@ def test_empty_and_none_leaders(self, command, mock_users):
"""Test with empty string and None in leaders raw list."""
leaders_raw = ["", None, "john_doe"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, mock_users)
-
assert len(exact) == 1
assert mock_users[1] in exact
assert fuzzy == []
@@ -177,7 +167,6 @@ def test_multiple_exact_matches_for_one_leader(self, command):
}
leaders_raw = ["JohnDoe"]
exact, fuzzy, unmatched = command.process_leaders(leaders_raw, 75, users)
-
assert len(exact) == 2
assert users[1] in exact
assert users[2] in exact
@@ -198,11 +187,16 @@ def command(self):
"""Return a command instance with mocked stdout."""
command = Command()
command.stdout = MagicMock()
-
return command
def test_invalid_model_name(
- self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command
+ self,
+ mock_member,
+ mock_project,
+ mock_committee,
+ mock_chapter,
+ mock_user,
+ command,
):
"""Test handle with an invalid model name."""
command.handle(model_name="invalid", threshold=75)
@@ -241,56 +235,55 @@ def test_handle_with_valid_models(
"Member": mock_member,
}
model_class = mock_models[model_class_str]
-
mock_user.objects.values.return_value = [
{"id": 1, "login": "leader_one", "name": "Leader One"},
{"id": 2, "login": "leader_two", "name": "Leader Two"},
]
-
mock_instance = MagicMock()
mock_instance.id = 1
-
if model_name == "member":
mock_instance.username = "leader_one"
mock_instance.real_name = "Leader Two"
else:
mock_instance.leaders_raw = ["leader_one", "leader_two"]
-
model_class.objects.prefetch_related.return_value = [mock_instance]
-
command.handle(model_name=model_name, threshold=90)
-
model_class.objects.prefetch_related.assert_called_once_with(relation_field)
-
relation = getattr(mock_instance, relation_field)
relation.set.assert_called_once_with({1, 2})
-
command.stdout.write.assert_any_call(f"Processing {model_name} 1...")
command.stdout.write.assert_any_call("Exact match found for leader_one: leader_one")
def test_handle_with_no_users(
- self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command
+ self,
+ mock_member,
+ mock_project,
+ mock_committee,
+ mock_chapter,
+ mock_user,
+ command,
):
"""Test handle when there are no users in the database."""
mock_user.objects.values.return_value = []
mock_chapter_instance = MagicMock(id=1, leaders_raw=["some_leader"])
mock_chapter.objects.prefetch_related.return_value = [mock_chapter_instance]
-
command.handle(model_name="chapter", threshold=75)
-
command.stdout.write.assert_any_call("Processing chapter 1...")
-
unmatched_call = [
c for c in command.stdout.write.call_args_list if "Unmatched" in c.args[0]
]
-
assert len(unmatched_call) == 1
assert "['some_leader']" in unmatched_call[0].args[0]
-
mock_chapter_instance.suggested_leaders.set.assert_called_once_with(set())
def test_handle_with_no_leaders_in_instance(
- self, mock_member, mock_project, mock_committee, mock_chapter, mock_user, command
+ self,
+ mock_member,
+ mock_project,
+ mock_committee,
+ mock_chapter,
+ mock_user,
+ command,
):
"""Test handle when an instance has no leaders."""
mock_user.objects.values.return_value = [
@@ -298,15 +291,10 @@ def test_handle_with_no_leaders_in_instance(
]
mock_chapter_instance = MagicMock(id=1, leaders_raw=[])
mock_chapter.objects.prefetch_related.return_value = [mock_chapter_instance]
-
command.handle(model_name="chapter", threshold=75)
-
command.stdout.write.assert_any_call("Processing chapter 1...")
-
unmatched_call = [
c for c in command.stdout.write.call_args_list if "Unmatched" in c.args[0]
]
-
assert len(unmatched_call) == 0
-
mock_chapter_instance.suggested_leaders.set.assert_called_once_with(set())
diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
index adec3ee1b8..830baf35a1 100644
--- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
+++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
@@ -23,6 +23,14 @@ jest.mock('next/link', () => {
return MockLink
})
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: jest.fn(),
+ replace: jest.fn(),
+ prefetch: jest.fn(),
+ }),
+}))
+
jest.mock('next/image', () => ({
__esModule: true,
default: ({
diff --git a/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx b/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx
new file mode 100644
index 0000000000..6417d2fd2f
--- /dev/null
+++ b/frontend/__tests__/unit/components/MarkdownWrapper.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react'
+import Markdown from 'components/MarkdownWrapper'
+
+// Mock dompurify and markdown-it for isolation
+jest.mock('dompurify', () => ({
+ sanitize: (html: string) => html,
+}))
+
+jest.mock('markdown-it/index.mjs', () => {
+ return jest.fn().mockImplementation(() => ({
+ render: (content: string) => {
+ // Very simple mock: replace **bold** and [link](url)
+ return content
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\[(.*?)\]\((.*?)\)/g, '$1')
+ },
+ }))
+})
+
+describe('Markdown component', () => {
+ it('renders markdown as HTML', () => {
+ render()
+ expect(screen.getByText('bold').tagName.toLowerCase()).toBe('strong')
+ })
+
+ it('applies custom className', () => {
+ render()
+ const wrapper = screen.getByText('test').closest('div')
+ expect(wrapper).toHaveClass('md-wrapper')
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('renders links', () => {
+ render()
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', 'https://google.com')
+ expect(link).toHaveTextContent('Google')
+ })
+
+ it('sanitizes dangerous HTML', () => {
+ render(hello'} />)
+ // In our mock, dompurify does nothing, so just check the content is present
+ expect(screen.getByText('hello')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/__tests__/unit/components/ModuleList.test.tsx b/frontend/__tests__/unit/components/ModuleList.test.tsx
new file mode 100644
index 0000000000..544bf3fc84
--- /dev/null
+++ b/frontend/__tests__/unit/components/ModuleList.test.tsx
@@ -0,0 +1,309 @@
+import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
+import { fireEvent, render, screen } from '@testing-library/react'
+import React from 'react'
+import ModuleList from 'components/ModuleList'
+
+// Mock FontAwesome icons
+jest.mock('@fortawesome/react-fontawesome', () => ({
+ FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => (
+
+ ),
+}))
+
+// Mock HeroUI Button component
+jest.mock('@heroui/button', () => ({
+ Button: ({
+ children,
+ onPress,
+ className,
+ 'aria-label': ariaLabel,
+ disableAnimation: _disableAnimation,
+ ..._props
+ }: {
+ children: React.ReactNode
+ onPress: () => void
+ className?: string
+ 'aria-label'?: string
+ disableAnimation?: boolean
+ [key: string]: unknown
+ }) => (
+
+ ),
+}))
+
+describe('ModuleList', () => {
+ describe('Empty and Null Cases', () => {
+ it('returns null when modules array is empty', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('returns null when modules is undefined', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('returns null when modules is null', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('Rendering with Different Module Counts', () => {
+ it('renders all modules when count is less than 5', () => {
+ const modules = ['Module 1', 'Module 2', 'Module 3']
+ render()
+
+ expect(screen.getByText('Module 1')).toBeInTheDocument()
+ expect(screen.getByText('Module 2')).toBeInTheDocument()
+ expect(screen.getByText('Module 3')).toBeInTheDocument()
+
+ // Should not show "Show more" button
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument()
+ })
+
+ it('renders all modules when count is exactly 5', () => {
+ const modules = ['Module 1', 'Module 2', 'Module 3', 'Module 4', 'Module 5']
+ render()
+
+ modules.forEach((module) => {
+ expect(screen.getByText(module)).toBeInTheDocument()
+ })
+
+ // Should not show "Show more" button
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument()
+ })
+
+ it('renders only first 5 modules when count is greater than 5', () => {
+ const modules = [
+ 'Module 1',
+ 'Module 2',
+ 'Module 3',
+ 'Module 4',
+ 'Module 5',
+ 'Module 6',
+ 'Module 7',
+ ]
+ render()
+
+ // First 5 should be visible
+ expect(screen.getByText('Module 1')).toBeInTheDocument()
+ expect(screen.getByText('Module 2')).toBeInTheDocument()
+ expect(screen.getByText('Module 3')).toBeInTheDocument()
+ expect(screen.getByText('Module 4')).toBeInTheDocument()
+ expect(screen.getByText('Module 5')).toBeInTheDocument()
+
+ // Last 2 should not be visible initially
+ expect(screen.queryByText('Module 6')).not.toBeInTheDocument()
+ expect(screen.queryByText('Module 7')).not.toBeInTheDocument()
+
+ // Should show "Show more" button
+ expect(screen.getByText('Show more')).toBeInTheDocument()
+ })
+ })
+
+ describe('Show More/Less Functionality', () => {
+ const manyModules = Array.from({ length: 8 }, (_, i) => `Module ${i + 1}`)
+
+ it('shows "Show more" button with correct aria-label initially', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: 'Show more modules' })
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText('Show more')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-chevron-down')).toBeInTheDocument()
+ })
+
+ it('expands to show all modules when "Show more" is clicked', () => {
+ render()
+
+ // Initially only first 5 are visible
+ expect(screen.getByText('Module 1')).toBeInTheDocument()
+ expect(screen.getByText('Module 5')).toBeInTheDocument()
+ expect(screen.queryByText('Module 6')).not.toBeInTheDocument()
+
+ // Click "Show more"
+ const showMoreButton = screen.getByText('Show more')
+ fireEvent.click(showMoreButton)
+
+ // Now all modules should be visible
+ expect(screen.getByText('Module 6')).toBeInTheDocument()
+ expect(screen.getByText('Module 7')).toBeInTheDocument()
+ expect(screen.getByText('Module 8')).toBeInTheDocument()
+ })
+
+ it('changes to "Show less" button after expanding', () => {
+ render()
+
+ const showMoreButton = screen.getByText('Show more')
+ fireEvent.click(showMoreButton)
+
+ // Button should change to "Show less"
+ expect(screen.getByText('Show less')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Show fewer modules' })).toBeInTheDocument()
+ expect(screen.getByTestId('icon-chevron-up')).toBeInTheDocument()
+ expect(screen.queryByText('Show more')).not.toBeInTheDocument()
+ })
+
+ it('collapses back to 5 modules when "Show less" is clicked', () => {
+ render()
+
+ // Expand first
+ const showMoreButton = screen.getByText('Show more')
+ fireEvent.click(showMoreButton)
+
+ // Verify all are visible
+ expect(screen.getByText('Module 8')).toBeInTheDocument()
+
+ // Click "Show less"
+ const showLessButton = screen.getByText('Show less')
+ fireEvent.click(showLessButton)
+
+ // Should be back to first 5 only
+ expect(screen.getByText('Module 5')).toBeInTheDocument()
+ expect(screen.queryByText('Module 6')).not.toBeInTheDocument()
+ expect(screen.queryByText('Module 8')).not.toBeInTheDocument()
+
+ // Button should be back to "Show more"
+ expect(screen.getByText('Show more')).toBeInTheDocument()
+ })
+ })
+
+ describe('Module Text Truncation', () => {
+ it('truncates module names longer than 50 characters', () => {
+ const longModuleName = 'A'.repeat(60) // 60 characters
+ const modules = [longModuleName, 'Short Module']
+
+ render()
+
+ const expectedTruncated = 'A'.repeat(50) + '...'
+ expect(screen.getByText(expectedTruncated)).toBeInTheDocument()
+ expect(screen.queryByText(longModuleName)).not.toBeInTheDocument()
+ })
+
+ it('does not truncate module names 50 characters or shorter', () => {
+ const exactlyFiftyChars = 'A'.repeat(50)
+ const modules = [exactlyFiftyChars, 'Short']
+
+ render()
+
+ expect(screen.getByText(exactlyFiftyChars)).toBeInTheDocument()
+ expect(screen.getByText('Short')).toBeInTheDocument()
+ })
+
+ it('adds title attribute for truncated modules', () => {
+ const longModuleName =
+ 'This is a very long module name that exceeds fifty characters and should be truncated'
+ const modules = [longModuleName]
+
+ render()
+
+ const truncatedButton = screen.getByRole('button', {
+ name: /This is a very long module name that exceeds/,
+ })
+ expect(truncatedButton).toHaveAttribute('title', longModuleName)
+ })
+
+ it('does not add title attribute for non-truncated modules', () => {
+ const shortModuleName = 'Short Module'
+ const modules = [shortModuleName]
+
+ render()
+
+ const button = screen.getByRole('button', { name: shortModuleName })
+ expect(button).not.toHaveAttribute('title')
+ })
+ })
+
+ describe('Module Button Properties', () => {
+ it('renders module buttons with correct classes', () => {
+ const modules = ['Test Module']
+ render()
+
+ const button = screen.getByRole('button', { name: 'Test Module' })
+ expect(button).toHaveClass(
+ 'rounded-lg',
+ 'border',
+ 'border-gray-400',
+ 'px-3',
+ 'py-1',
+ 'text-sm',
+ 'transition-all',
+ 'duration-200',
+ 'ease-in-out',
+ 'hover:scale-105',
+ 'hover:bg-gray-200',
+ 'dark:border-gray-300',
+ 'dark:hover:bg-gray-700'
+ )
+ })
+
+ it('sets correct button type', () => {
+ const modules = ['Test Module']
+ render()
+
+ const button = screen.getByRole('button', { name: 'Test Module' })
+ expect(button).toHaveAttribute('type', 'button')
+ })
+
+ it('generates unique keys for modules with same name', () => {
+ const modules = ['Same Name', 'Same Name', 'Different Name']
+ render()
+
+ const sameNameButtons = screen.getAllByText('Same Name')
+ expect(sameNameButtons).toHaveLength(2)
+ expect(screen.getByText('Different Name')).toBeInTheDocument()
+ })
+ })
+
+ describe('Container Structure', () => {
+ it('renders with correct container classes', () => {
+ const modules = ['Module 1']
+ const { container } = render()
+
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv).toHaveClass('mt-3')
+
+ const innerDiv = outerDiv.firstChild as HTMLElement
+ expect(innerDiv).toHaveClass('flex', 'flex-wrap', 'items-center', 'gap-2')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles modules with empty strings', () => {
+ const modules = ['', 'Valid Module', '']
+ render()
+
+ const buttons = screen.getAllByRole('button')
+ // Should render 3 buttons (including empty string ones)
+ expect(buttons).toHaveLength(3)
+ expect(screen.getByText('Valid Module')).toBeInTheDocument()
+ })
+
+ it('handles exactly 6 modules (edge case for show more)', () => {
+ const modules = Array.from({ length: 6 }, (_, i) => `Module ${i + 1}`)
+ render()
+
+ // First 5 should be visible
+ expect(screen.getByText('Module 5')).toBeInTheDocument()
+ expect(screen.queryByText('Module 6')).not.toBeInTheDocument()
+
+ // Should show "Show more" button
+ expect(screen.getByText('Show more')).toBeInTheDocument()
+
+ // Click to expand
+ fireEvent.click(screen.getByText('Show more'))
+ expect(screen.getByText('Module 6')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/components/ProgramActions.test.tsx b/frontend/__tests__/unit/components/ProgramActions.test.tsx
new file mode 100644
index 0000000000..d3d53dfe2d
--- /dev/null
+++ b/frontend/__tests__/unit/components/ProgramActions.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { useSession as mockUseSession } from 'next-auth/react'
+import { render } from 'wrappers/testUtil'
+import ProgramActions from 'components/ProgramActions'
+
+const mockPush = jest.fn()
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: () => ({ push: mockPush }),
+}))
+
+jest.mock('next-auth/react', () => {
+ const actual = jest.requireActual('next-auth/react')
+ return {
+ ...actual,
+ useSession: jest.fn(),
+ }
+})
+
+describe('ProgramActions', () => {
+ let setStatus: jest.Mock
+ beforeEach(() => {
+ setStatus = jest.fn()
+ mockPush.mockClear()
+ })
+
+ beforeAll(async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'Test User',
+ email: 'test@example.com',
+ login: 'testuser',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ })
+ })
+
+ test('renders and toggles dropdown', () => {
+ render()
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ expect(screen.getByText('Add Module')).toBeInTheDocument()
+ expect(screen.getByText('Publish Program')).toBeInTheDocument()
+ fireEvent.click(button)
+ expect(screen.queryByText('Add Module')).not.toBeInTheDocument()
+ })
+
+ test('handles Add Module action', () => {
+ render()
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ fireEvent.click(screen.getByRole('menuitem', { name: /add module/i }))
+ expect(mockPush).toHaveBeenCalled()
+ expect(setStatus).not.toHaveBeenCalled()
+ })
+
+ test('handles Publish action', () => {
+ render()
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ fireEvent.click(screen.getByRole('menuitem', { name: /publish program/i }))
+ expect(setStatus).toHaveBeenCalledWith('PUBLISHED')
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+
+ test('handles Move to Draft action', () => {
+ render()
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ fireEvent.click(screen.getByRole('menuitem', { name: /move to draft/i }))
+ expect(setStatus).toHaveBeenCalledWith('DRAFT')
+ })
+
+ test('handles Mark as Completed action', () => {
+ render()
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ fireEvent.click(screen.getByRole('menuitem', { name: /mark as completed/i }))
+ expect(setStatus).toHaveBeenCalledWith('COMPLETED')
+ })
+
+ test('dropdown closes on outside click', () => {
+ render(
+
+ )
+ const button = screen.getByTestId('program-actions-button')
+ fireEvent.click(button)
+ expect(screen.getByText('Add Module')).toBeInTheDocument()
+ fireEvent.mouseDown(screen.getByTestId('outside'))
+ expect(screen.queryByText('Add Module')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/__tests__/unit/components/ProgramCard.test.tsx b/frontend/__tests__/unit/components/ProgramCard.test.tsx
new file mode 100644
index 0000000000..a6c97a9d9f
--- /dev/null
+++ b/frontend/__tests__/unit/components/ProgramCard.test.tsx
@@ -0,0 +1,310 @@
+import { faEye } from '@fortawesome/free-regular-svg-icons'
+import { faEdit } from '@fortawesome/free-solid-svg-icons'
+import { fireEvent, render, screen } from '@testing-library/react'
+import React from 'react'
+import type { Program } from 'types/mentorship'
+import { ProgramStatusEnum } from 'types/mentorship'
+import ProgramCard from 'components/ProgramCard'
+
+jest.mock('@fortawesome/react-fontawesome', () => ({
+ FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => (
+
+ ),
+}))
+
+jest.mock('components/ActionButton', () => ({
+ __esModule: true,
+ default: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
+
+ ),
+}))
+
+describe('ProgramCard', () => {
+ const mockOnEdit = jest.fn()
+ const mockOnView = jest.fn()
+
+ const baseMockProgram: Program = {
+ id: '1',
+ key: 'test-program',
+ name: 'Test Program',
+ description: 'This is a test program description',
+ status: ProgramStatusEnum.PUBLISHED,
+ startedAt: '2024-01-01T00:00:00Z',
+ endedAt: '2024-12-31T23:59:59Z',
+ userRole: 'admin',
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Basic Rendering', () => {
+ it('renders program name correctly', () => {
+ render()
+
+ expect(screen.getByText('Test Program')).toBeInTheDocument()
+ })
+
+ it('renders program description correctly', () => {
+ render()
+
+ expect(screen.getByText('This is a test program description')).toBeInTheDocument()
+ })
+ })
+
+ describe('Access Level - Admin', () => {
+ it('shows user role badge when accessLevel is admin', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('admin')).toBeInTheDocument()
+ })
+
+ it('shows Preview and Edit buttons for admin access', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Preview')).toBeInTheDocument()
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ })
+
+ it('calls onView when Preview button is clicked', () => {
+ render(
+
+ )
+
+ const previewButton = screen.getByText('Preview').closest('button')
+ fireEvent.click(previewButton!)
+
+ expect(mockOnView).toHaveBeenCalledWith('test-program')
+ })
+
+ it('calls onEdit when Edit button is clicked', () => {
+ render(
+
+ )
+
+ const editButton = screen.getByText('Edit').closest('button')
+ fireEvent.click(editButton!)
+
+ expect(mockOnEdit).toHaveBeenCalledWith('test-program')
+ })
+ })
+
+ describe('Access Level - User', () => {
+ it('does not show user role badge when accessLevel is user', () => {
+ render()
+
+ expect(screen.queryByText('admin')).not.toBeInTheDocument()
+ })
+
+ it('shows only View Details button for user access', () => {
+ render()
+
+ expect(screen.getByText('View Details')).toBeInTheDocument()
+ expect(screen.queryByText('Preview')).not.toBeInTheDocument()
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument()
+ })
+
+ it('calls onView when View Details button is clicked', () => {
+ render()
+
+ const viewButton = screen.getByText('View Details').closest('button')
+ fireEvent.click(viewButton!)
+
+ expect(mockOnView).toHaveBeenCalledWith('test-program')
+ })
+ })
+
+ describe('User Role Badge Styling', () => {
+ it('applies admin role styling', () => {
+ const adminProgram = { ...baseMockProgram, userRole: 'admin' }
+ render()
+
+ const badge = screen.getByText('admin')
+ expect(badge).toHaveClass('bg-blue-100', 'text-blue-800')
+ })
+
+ it('applies mentor role styling', () => {
+ const mentorProgram = { ...baseMockProgram, userRole: 'mentor' }
+ render()
+
+ const badge = screen.getByText('mentor')
+ expect(badge).toHaveClass('bg-green-100', 'text-green-800')
+ })
+
+ it('applies default role styling for unknown role', () => {
+ const unknownRoleProgram = { ...baseMockProgram, userRole: 'unknown' }
+ render()
+
+ const badge = screen.getByText('unknown')
+ expect(badge).toHaveClass('bg-gray-100', 'text-gray-800')
+ })
+
+ it('applies default styling when userRole is undefined', () => {
+ const noRoleProgram = { ...baseMockProgram, userRole: undefined }
+ render()
+
+ // Should not render badge when userRole is undefined
+ expect(screen.queryByText(/bg-/)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Description Handling', () => {
+ it('truncates long descriptions to 100 characters', () => {
+ const longDescription = 'A'.repeat(150)
+ const longDescProgram = { ...baseMockProgram, description: longDescription }
+
+ render()
+
+ const expectedText = 'A'.repeat(100) + '...'
+ expect(screen.getByText(expectedText)).toBeInTheDocument()
+ })
+
+ it('shows full description when under 100 characters', () => {
+ const shortDescription = 'Short description'
+ const shortDescProgram = { ...baseMockProgram, description: shortDescription }
+
+ render()
+
+ expect(screen.getByText('Short description')).toBeInTheDocument()
+ })
+
+ it('shows fallback text when description is empty', () => {
+ const emptyDescProgram = { ...baseMockProgram, description: '' }
+
+ render()
+
+ expect(screen.getByText('No description available.')).toBeInTheDocument()
+ })
+
+ it('shows fallback text when description is undefined', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const noDescProgram = { ...baseMockProgram, description: undefined as any }
+
+ render()
+
+ expect(screen.getByText('No description available.')).toBeInTheDocument()
+ })
+ })
+
+ describe('Date Formatting', () => {
+ it('shows date range when both startedAt and endedAt are provided', () => {
+ render()
+
+ expect(screen.getByText('Jan 1, 2024 – Dec 31, 2024')).toBeInTheDocument()
+ })
+
+ it('shows only start date when endedAt is missing', () => {
+ const startOnlyProgram = { ...baseMockProgram, endedAt: '' }
+
+ render()
+
+ expect(screen.getByText('Started: Jan 1, 2024')).toBeInTheDocument()
+ })
+
+ it('shows fallback text when both dates are missing', () => {
+ const noDatesProgram = { ...baseMockProgram, startedAt: '', endedAt: '' }
+
+ render()
+
+ expect(screen.getByText('No dates set')).toBeInTheDocument()
+ })
+
+ it('shows fallback text when startedAt is missing but endedAt exists', () => {
+ const endOnlyProgram = { ...baseMockProgram, startedAt: '' }
+
+ render()
+
+ expect(screen.getByText('No dates set')).toBeInTheDocument()
+ })
+ })
+
+ describe('Icons', () => {
+ it('renders eye icon for Preview button', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument()
+ })
+
+ it('renders edit icon for Edit button', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('icon-edit')).toBeInTheDocument()
+ })
+
+ it('renders eye icon for View Details button', () => {
+ render()
+
+ expect(screen.getByTestId('icon-eye')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles missing onEdit prop gracefully for admin access', () => {
+ render()
+
+ // Should still render Edit button even without onEdit
+ expect(screen.getByText('Edit')).toBeInTheDocument()
+ })
+
+ it('handles program with minimal data', () => {
+ const minimalProgram: Program = {
+ id: '2',
+ key: 'minimal',
+ name: 'Minimal Program',
+ description: '',
+ status: ProgramStatusEnum.DRAFT,
+ startedAt: '',
+ endedAt: '',
+ }
+
+ render()
+
+ expect(screen.getByText('Minimal Program')).toBeInTheDocument()
+ expect(screen.getByText('No description available.')).toBeInTheDocument()
+ expect(screen.getByText('No dates set')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx
new file mode 100644
index 0000000000..8f7861fd59
--- /dev/null
+++ b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx
@@ -0,0 +1,413 @@
+import { faUsers } from '@fortawesome/free-solid-svg-icons'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React from 'react'
+import { render } from 'wrappers/testUtil'
+import type { ExtendedSession } from 'types/auth'
+import type { Module } from 'types/mentorship'
+import { ExperienceLevelEnum, ProgramStatusEnum } from 'types/mentorship'
+import SingleModuleCard from 'components/SingleModuleCard'
+
+// Mock dependencies
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}))
+
+jest.mock('next-auth/react', () => ({
+ useSession: jest.fn(),
+}))
+
+jest.mock('next/link', () => ({
+ __esModule: true,
+ default: ({
+ children,
+ href,
+ target,
+ rel,
+ className,
+ ...props
+ }: {
+ children: React.ReactNode
+ href: string
+ target?: string
+ rel?: string
+ className?: string
+ [key: string]: unknown
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+jest.mock('@fortawesome/react-fontawesome', () => ({
+ FontAwesomeIcon: ({ icon, className }: { icon: unknown; className?: string }) => (
+
+ ),
+}))
+
+jest.mock('utils/dateFormatter', () => ({
+ formatDate: jest.fn((date: string) => new Date(date).toLocaleDateString()),
+}))
+
+jest.mock('components/ModuleCard', () => ({
+ getSimpleDuration: jest.fn((start: string, end: string) => {
+ const startDate = new Date(start)
+ const endDate = new Date(end)
+ const diffTime = Math.abs(endDate.getTime() - startDate.getTime())
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+ return `${diffDays} days`
+ }),
+}))
+
+jest.mock('components/TopContributorsList', () => ({
+ __esModule: true,
+ default: ({ contributors, label }: { contributors: unknown[]; label: string }) => (
+
+
+ {label}: {contributors.length} contributors
+
+
+ ),
+}))
+
+const mockPush = jest.fn()
+const mockUseRouter = useRouter as jest.MockedFunction
+const mockUseSession = useSession as jest.MockedFunction
+
+// Test data
+const mockModule: Module = {
+ id: '1',
+ key: 'test-module',
+ name: 'Test Module',
+ description: 'This is a test module description',
+ status: ProgramStatusEnum.PUBLISHED,
+ experienceLevel: ExperienceLevelEnum.INTERMEDIATE,
+ mentors: [
+ {
+ name: 'user1',
+ login: 'user1',
+ avatarUrl: 'https://example.com/avatar1.jpg',
+ },
+ {
+ name: 'user2',
+ login: 'user2',
+ avatarUrl: 'https://example.com/avatar2.jpg',
+ },
+ ],
+ startedAt: '2024-01-01T00:00:00Z',
+ endedAt: '2024-12-31T23:59:59Z',
+ domains: ['frontend', 'backend'],
+ tags: ['react', 'nodejs'],
+}
+
+const mockAdmins = [{ login: 'admin1' }, { login: 'admin2' }]
+
+const mockSessionData: ExtendedSession = {
+ user: {
+ login: 'admin1',
+ isLeader: true,
+ email: 'admin@example.com',
+ image: 'https://example.com/admin-avatar.jpg',
+ },
+ expires: '2024-12-31T23:59:59Z',
+}
+
+describe('SingleModuleCard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseRouter.mockReturnValue({
+ push: mockPush,
+ back: jest.fn(),
+ forward: jest.fn(),
+ refresh: jest.fn(),
+ replace: jest.fn(),
+ prefetch: jest.fn(),
+ })
+ mockUseSession.mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ update: jest.fn(),
+ })
+ })
+
+ describe('Basic Rendering', () => {
+ it('renders module card with basic information', () => {
+ render()
+
+ expect(screen.getByText('Test Module')).toBeInTheDocument()
+ expect(screen.getByText('This is a test module description')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-users')).toBeInTheDocument()
+ expect(screen.getByTestId('icon-ellipsis')).toBeInTheDocument()
+ })
+
+ it('renders module details correctly', () => {
+ render()
+
+ expect(screen.getByText('Experience Level:')).toBeInTheDocument()
+ expect(screen.getByText('Intermediate')).toBeInTheDocument()
+ expect(screen.getByText('Start Date:')).toBeInTheDocument()
+ expect(screen.getByText('End Date:')).toBeInTheDocument()
+ expect(screen.getByText('Duration:')).toBeInTheDocument()
+ })
+
+ it('renders mentors list when mentors exist', () => {
+ render()
+
+ expect(screen.getByTestId('top-contributors-list')).toBeInTheDocument()
+ expect(screen.getByText('Mentors: 2 contributors')).toBeInTheDocument()
+ })
+
+ it('does not render mentors list when no mentors', () => {
+ const moduleWithoutMentors = { ...mockModule, mentors: [] }
+ render()
+
+ expect(screen.queryByTestId('top-contributors-list')).not.toBeInTheDocument()
+ })
+
+ it('renders module link with correct href', () => {
+ render()
+
+ const moduleLink = screen.getByTestId('module-link')
+ expect(moduleLink).toHaveAttribute('href', '//modules/test-module')
+ expect(moduleLink).toHaveAttribute('target', '_blank')
+ expect(moduleLink).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+ })
+
+ describe('Dropdown Menu', () => {
+ it('opens dropdown when ellipsis button is clicked', () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ })
+
+ it('closes dropdown when clicking outside', async () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+
+ // Click outside the dropdown
+ fireEvent.mouseDown(document.body)
+
+ await waitFor(() => {
+ expect(screen.queryByText('View Module')).not.toBeInTheDocument()
+ })
+ })
+
+ it('navigates to view module when View Module is clicked', () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ const viewButton = screen.getByText('View Module')
+ fireEvent.click(viewButton)
+
+ expect(mockPush).toHaveBeenCalledWith('//modules/test-module')
+ })
+
+ it('shows only View Module option for non-admin users', () => {
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
+ expect(screen.queryByText('Create Module')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Admin Functionality', () => {
+ beforeEach(() => {
+ mockUseSession.mockReturnValue({
+ data: mockSessionData,
+ status: 'authenticated',
+ update: jest.fn(),
+ })
+ })
+
+ it('shows Edit Module option for admin users when showEdit is true', () => {
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ expect(screen.getByText('Edit Module')).toBeInTheDocument()
+ expect(screen.getByText('Create Module')).toBeInTheDocument()
+ })
+
+ it('does not show Edit Module option when showEdit is false', () => {
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
+ expect(screen.getByText('Create Module')).toBeInTheDocument()
+ })
+
+ it('navigates to edit module when Edit Module is clicked', () => {
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ const editButton = screen.getByText('Edit Module')
+ fireEvent.click(editButton)
+
+ expect(mockPush).toHaveBeenCalledWith('//modules/test-module/edit')
+ })
+
+ it('navigates to create module when Create Module is clicked', () => {
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ const createButton = screen.getByText('Create Module')
+ fireEvent.click(createButton)
+
+ expect(mockPush).toHaveBeenCalledWith('//modules/create')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles missing module data gracefully', () => {
+ const incompleteModule = {
+ ...mockModule,
+ description: '',
+ mentors: [],
+ }
+
+ render()
+
+ expect(screen.getByText('Test Module')).toBeInTheDocument()
+ expect(screen.queryByTestId('top-contributors-list')).not.toBeInTheDocument()
+ })
+
+ it('handles undefined admins array', () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
+ })
+
+ it('handles null session data', () => {
+ mockUseSession.mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ update: jest.fn(),
+ })
+
+ render(
+
+ )
+
+ const ellipsisButton = screen.getByRole('button')
+ fireEvent.click(ellipsisButton)
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ expect(screen.queryByText('Edit Module')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('has proper button roles and interactions', () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+ expect(ellipsisButton).toBeInTheDocument()
+
+ fireEvent.click(ellipsisButton)
+
+ const viewButton = screen.getByText('View Module')
+ expect(viewButton.closest('button')).toBeInTheDocument()
+ })
+
+ it('supports keyboard navigation', () => {
+ render()
+
+ const ellipsisButton = screen.getByRole('button')
+
+ // Focus the button
+ ellipsisButton.focus()
+ expect(ellipsisButton).toHaveFocus()
+
+ // Press Enter to open dropdown
+ fireEvent.keyDown(ellipsisButton, { key: 'Enter', code: 'Enter' })
+ fireEvent.click(ellipsisButton) // Simulate the click that would happen
+
+ expect(screen.getByText('View Module')).toBeInTheDocument()
+ })
+ })
+
+ describe('Responsive Design', () => {
+ it('applies responsive classes correctly', () => {
+ render()
+
+ const moduleTitle = screen.getByText('Test Module')
+ expect(moduleTitle).toHaveClass('sm:break-normal', 'sm:text-lg', 'lg:text-2xl')
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/components/UserMenu.test.tsx b/frontend/__tests__/unit/components/UserMenu.test.tsx
index 123d9abf2e..418a5a46d2 100644
--- a/frontend/__tests__/unit/components/UserMenu.test.tsx
+++ b/frontend/__tests__/unit/components/UserMenu.test.tsx
@@ -80,6 +80,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'authenticated',
})
const { container } = render()
@@ -88,8 +89,9 @@ describe('UserMenu Component', () => {
it('renders with GitHub auth enabled', () => {
mockUseSession.mockReturnValue({
- session: null,
+ session: { user: null, expires: '2024-12-31' },
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -102,6 +104,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
isSyncing: true,
session: null,
+ status: 'loading',
})
render()
@@ -115,6 +118,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -128,6 +132,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -144,6 +149,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -164,6 +170,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
const { container } = render()
@@ -175,6 +182,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
const { rerender } = render()
@@ -184,6 +192,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
rerender()
@@ -197,6 +206,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -214,6 +224,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -243,6 +254,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -269,6 +281,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render(
@@ -301,6 +314,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -331,6 +345,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
const { rerender } = render()
@@ -378,6 +393,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: sessionWithoutImage,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -398,6 +414,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: sessionWithUndefinedImage,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -412,6 +429,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -425,6 +443,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -448,6 +467,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
const { rerender } = render()
@@ -480,6 +500,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -490,6 +511,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: undefined,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -500,6 +522,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: { user: null, expires: '2024-12-31' },
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -512,6 +535,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -536,6 +560,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -550,6 +575,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -573,6 +599,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -585,6 +612,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -605,6 +633,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: false,
+ status: 'unauthenticated',
})
render()
@@ -615,10 +644,21 @@ describe('UserMenu Component', () => {
'relative',
'flex',
'h-10',
- 'cursor-pointer',
'items-center',
'justify-center',
- 'gap-2'
+ 'gap-2',
+ 'rounded-md',
+ 'bg-[#87a1bc]',
+ 'p-4',
+ 'text-sm',
+ 'font-medium',
+ 'text-black',
+ 'hover:ring-1',
+ 'hover:ring-[#b0c7de]',
+ 'dark:bg-slate-900',
+ 'dark:text-white',
+ 'dark:hover:bg-slate-900/90',
+ 'dark:hover:ring-[#46576b]'
)
})
@@ -626,6 +666,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: null,
isSyncing: true,
+ status: 'unauthenticated',
})
render()
@@ -641,6 +682,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -656,6 +698,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -686,6 +729,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
render()
@@ -715,6 +759,7 @@ describe('UserMenu Component', () => {
mockUseSession.mockReturnValue({
session: mockSession,
isSyncing: false,
+ status: 'authenticated',
})
const { unmount } = render()
diff --git a/frontend/__tests__/unit/data/mockModuleData.ts b/frontend/__tests__/unit/data/mockModuleData.ts
new file mode 100644
index 0000000000..2115239c58
--- /dev/null
+++ b/frontend/__tests__/unit/data/mockModuleData.ts
@@ -0,0 +1,10 @@
+export const mockModuleData = {
+ name: 'Intro to Web',
+ description: 'A beginner friendly module.',
+ experienceLevel: 'beginner',
+ startedAt: '2025-01-01',
+ endedAt: '2025-03-01',
+ mentors: [{ login: 'mentor1' }],
+ tags: ['tag1'],
+ domains: ['domain1'],
+}
diff --git a/frontend/__tests__/unit/data/mockProgramData.ts b/frontend/__tests__/unit/data/mockProgramData.ts
new file mode 100644
index 0000000000..59a7248717
--- /dev/null
+++ b/frontend/__tests__/unit/data/mockProgramData.ts
@@ -0,0 +1,31 @@
+import { ProgramStatusEnum } from 'types/mentorship'
+export const mockPrograms = [
+ {
+ key: 'program_1',
+ name: 'Program 1',
+ description: 'This is a summary of Program 1.',
+ startedAt: '2025-01-01',
+ endedAt: '2025-12-31',
+ status: 'published',
+ modules: ['Module A', 'Module B'],
+ },
+]
+
+export const mockProgramDetailsData = {
+ getProgram: {
+ key: 'test-program',
+ name: 'Test Program',
+ description: 'Sample summary',
+ status: ProgramStatusEnum.DRAFT,
+ startedAt: '2025-01-01',
+ endedAt: '2025-12-31',
+ menteesLimit: 20,
+ experienceLevels: ['beginner', 'intermediate'],
+ admins: [{ login: 'admin-user', avatarUrl: 'https://example.com/avatar.png' }],
+ tags: ['web', 'security'],
+ domains: ['OWASP'],
+ },
+ getProgramModules: [],
+}
+
+export default mockProgramDetailsData
diff --git a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
index b4c89a164f..e8451d6763 100644
--- a/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
+++ b/frontend/__tests__/unit/pages/CommitteeDetails.test.tsx
@@ -1,4 +1,5 @@
import { useQuery } from '@apollo/client'
+
import { screen, waitFor } from '@testing-library/react'
import { mockCommitteeDetailsData } from '@unit/data/mockCommitteeDetailsData'
import { render } from 'wrappers/testUtil'
diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx
new file mode 100644
index 0000000000..eea7110aed
--- /dev/null
+++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx
@@ -0,0 +1,117 @@
+import { useApolloClient, useMutation, useQuery } from '@apollo/client'
+import { screen, fireEvent, waitFor, act } from '@testing-library/react'
+import { useRouter, useParams } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import { render } from 'wrappers/testUtil'
+import CreateModulePage from 'app/my/mentorship/programs/[programKey]/modules/create/page'
+
+jest.mock('next-auth/react', () => ({
+ ...jest.requireActual('next-auth/react'),
+ useSession: jest.fn(),
+}))
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+ useParams: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => {
+ const actual = jest.requireActual('@apollo/client')
+ return {
+ ...actual,
+ useMutation: jest.fn(),
+ useQuery: jest.fn(),
+ useApolloClient: jest.fn(),
+ gql: actual.gql,
+ }
+})
+
+describe('CreateModulePage', () => {
+ const mockPush = jest.fn()
+ const mockReplace = jest.fn()
+ const mockCreateModule = jest.fn()
+
+ beforeEach(() => {
+ jest.useFakeTimers()
+ ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace })
+ ;(useParams as jest.Mock).mockReturnValue({ programKey: 'test-program' })
+ ;(useApolloClient as jest.Mock).mockReturnValue({
+ query: jest.fn().mockResolvedValue({
+ data: {
+ searchProjects: [{ id: '123', name: 'Awesome Project' }],
+ },
+ }),
+ })
+ })
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+ jest.clearAllMocks()
+ })
+
+ it('submits the form and navigates to programs page', async () => {
+ ;(useSession as jest.Mock).mockReturnValue({
+ data: { user: { login: 'admin-user' } },
+ status: 'authenticated',
+ })
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: {
+ getProgram: {
+ admins: [{ login: 'admin-user' }],
+ },
+ },
+ loading: false,
+ })
+ ;(useMutation as jest.Mock).mockReturnValue([
+ mockCreateModule.mockResolvedValue({}),
+ { loading: false },
+ ])
+
+ render()
+
+ // Fill all inputs
+ fireEvent.change(screen.getByLabelText(/Module Name/i), {
+ target: { value: 'My Test Module' },
+ })
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'This is a test module' },
+ })
+ fireEvent.change(screen.getByLabelText(/Start Date/i), {
+ target: { value: '2025-07-15' },
+ })
+ fireEvent.change(screen.getByLabelText(/End Date/i), {
+ target: { value: '2025-08-15' },
+ })
+ fireEvent.change(screen.getByLabelText(/Domains/i), {
+ target: { value: 'AI, ML' },
+ })
+ fireEvent.change(screen.getByLabelText(/Tags/i), {
+ target: { value: 'react, graphql' },
+ })
+
+ // Simulate project typing and suggestion click
+ fireEvent.change(screen.getByLabelText(/Project Name/i), {
+ target: { value: 'Awesome Project' },
+ })
+
+ // Run debounce
+ await act(async () => {
+ jest.runAllTimers()
+ })
+
+ const suggestionButton = await screen.findByRole('button', {
+ name: /Awesome Project/i,
+ })
+
+ fireEvent.click(suggestionButton)
+
+ // Now the form should be valid → submit
+ fireEvent.click(screen.getByRole('button', { name: /Create Module/i }))
+
+ await waitFor(() => {
+ expect(mockCreateModule).toHaveBeenCalled()
+ expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program?refresh=true')
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/pages/CreateProgram.test.tsx b/frontend/__tests__/unit/pages/CreateProgram.test.tsx
new file mode 100644
index 0000000000..b57dd0781d
--- /dev/null
+++ b/frontend/__tests__/unit/pages/CreateProgram.test.tsx
@@ -0,0 +1,210 @@
+import { useMutation } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { useRouter as mockUseRouter } from 'next/navigation'
+import { useSession as mockUseSession } from 'next-auth/react'
+import { render } from 'wrappers/testUtil'
+import CreateProgramPage from 'app/my/mentorship/programs/create/page'
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useMutation: jest.fn(),
+}))
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}))
+
+jest.mock('next-auth/react', () => {
+ const actual = jest.requireActual('next-auth/react')
+ return {
+ ...actual,
+ useSession: jest.fn(),
+ }
+})
+
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+const mockRouterPush = jest.fn()
+const mockCreateProgram = jest.fn()
+
+describe('CreateProgramPage (comprehensive tests)', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(mockUseRouter as jest.Mock).mockReturnValue({ push: mockRouterPush })
+ ;(useMutation as jest.Mock).mockReturnValue([mockCreateProgram, { loading: false }])
+ })
+
+ test('redirects if unauthenticated', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ loading: false,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockRouterPush).toHaveBeenCalledWith('/')
+ })
+ })
+
+ test('shows nothing when session is loading', () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: null,
+ status: 'loading',
+ loading: true,
+ })
+
+ render()
+
+ expect(screen.queryByLabelText('Program Name *')).not.toBeInTheDocument()
+ })
+
+ test('redirects with toast if not a project leader', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'Test User',
+ email: 'test@example.com',
+ login: 'testuser',
+ isLeader: false,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Access Denied' }))
+ expect(mockRouterPush).toHaveBeenCalledWith('/')
+ })
+ })
+
+ test('renders form when user is project leader', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'Test User',
+ email: 'test@example.com',
+ login: 'testuser',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ })
+
+ render()
+
+ expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument()
+ })
+
+ test('submits form and redirects on success', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'Test User',
+ email: 'test@example.com',
+ login: 'testuser',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ })
+
+ mockCreateProgram.mockResolvedValue({
+ data: { createProgram: { key: 'program_1' } },
+ })
+
+ render()
+
+ fireEvent.change(screen.getByLabelText('Program Name *'), {
+ target: { value: 'Test Program' },
+ })
+ fireEvent.change(screen.getByLabelText('Description *'), {
+ target: { value: 'A description' },
+ })
+ fireEvent.change(screen.getByLabelText('Start Date *'), {
+ target: { value: '2025-01-01' },
+ })
+ fireEvent.change(screen.getByLabelText('End Date *'), {
+ target: { value: '2025-12-31' },
+ })
+ fireEvent.change(screen.getByLabelText('Tags'), {
+ target: { value: 'tag1, tag2' },
+ })
+ fireEvent.change(screen.getByLabelText('Domains'), {
+ target: { value: 'domain1, domain2' },
+ })
+
+ fireEvent.submit(screen.getByText('Save').closest('form')!)
+
+ await waitFor(() => {
+ expect(mockCreateProgram).toHaveBeenCalledWith({
+ variables: {
+ input: {
+ name: 'Test Program',
+ description: 'A description',
+ menteesLimit: 5,
+ startedAt: '2025-01-01',
+ endedAt: '2025-12-31',
+ tags: ['tag1', 'tag2'],
+ domains: ['domain1', 'domain2'],
+ },
+ },
+ })
+
+ expect(mockRouterPush).toHaveBeenCalledWith('/my/mentorship')
+ })
+ })
+
+ test('shows error toast if createProgram mutation fails', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'Test User',
+ email: 'test@example.com',
+ login: 'testuser',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ })
+
+ mockCreateProgram.mockRejectedValue(new Error('Server error'))
+
+ render()
+
+ fireEvent.change(screen.getByLabelText('Program Name *'), {
+ target: { value: 'Test Program' },
+ })
+ fireEvent.change(screen.getByLabelText('Description *'), {
+ target: { value: 'A description' },
+ })
+ fireEvent.change(screen.getByLabelText('Start Date *'), {
+ target: { value: '2025-01-01' },
+ })
+ fireEvent.change(screen.getByLabelText('End Date *'), {
+ target: { value: '2025-12-31' },
+ })
+
+ fireEvent.submit(screen.getByText('Save').closest('form')!)
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'GraphQL Request Failed' })
+ )
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/pages/EditModule.test.tsx b/frontend/__tests__/unit/pages/EditModule.test.tsx
new file mode 100644
index 0000000000..21f5329001
--- /dev/null
+++ b/frontend/__tests__/unit/pages/EditModule.test.tsx
@@ -0,0 +1,132 @@
+import { useQuery, useMutation, useApolloClient } from '@apollo/client'
+import { screen, fireEvent, waitFor, act } from '@testing-library/react'
+import { useRouter, useParams } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import { render } from 'wrappers/testUtil'
+import EditModulePage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page'
+
+// Mocks
+jest.mock('next-auth/react', () => ({
+ ...jest.requireActual('next-auth/react'),
+ useSession: jest.fn(),
+}))
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+ useParams: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => {
+ const actual = jest.requireActual('@apollo/client')
+ return {
+ ...actual,
+ useQuery: jest.fn(),
+ useMutation: jest.fn(),
+ useApolloClient: jest.fn(),
+ gql: actual.gql,
+ }
+})
+
+describe('EditModulePage', () => {
+ const mockPush = jest.fn()
+ const mockReplace = jest.fn()
+ const mockUpdateModule = jest.fn()
+
+ beforeEach(() => {
+ jest.useFakeTimers()
+ ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace })
+ ;(useParams as jest.Mock).mockReturnValue({
+ programKey: 'test-program',
+ moduleKey: 'test-module',
+ })
+ ;(useApolloClient as jest.Mock).mockReturnValue({
+ query: jest.fn().mockResolvedValue({
+ data: {
+ searchProjects: [{ id: '123', name: 'Awesome Project' }],
+ },
+ }),
+ })
+ })
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+ jest.clearAllMocks()
+ })
+
+ it('renders and submits form for editing module', async () => {
+ ;(useSession as jest.Mock).mockReturnValue({
+ data: { user: { login: 'admin-user' } },
+ status: 'authenticated',
+ })
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: false,
+ data: {
+ getProgram: {
+ admins: [{ login: 'admin-user' }],
+ },
+ getModule: {
+ name: 'Existing Module',
+ description: 'Old description',
+ experienceLevel: 'INTERMEDIATE',
+ startedAt: '2025-07-01',
+ endedAt: '2025-07-31',
+ domains: ['AI'],
+ tags: ['graphql'],
+ projectName: 'Awesome Project',
+ projectId: '123',
+ mentors: [{ login: 'mentor1' }],
+ },
+ },
+ })
+ ;(useMutation as jest.Mock).mockReturnValue([
+ mockUpdateModule.mockResolvedValue({}),
+ { loading: false },
+ ])
+
+ render()
+
+ // Ensure the form loads
+ expect(await screen.findByDisplayValue('Existing Module')).toBeInTheDocument()
+
+ // Modify values
+ fireEvent.change(screen.getByLabelText(/Module Name/i), {
+ target: { value: 'Updated Module Name' },
+ })
+ fireEvent.change(screen.getByLabelText(/Description/i), {
+ target: { value: 'Updated description' },
+ })
+ fireEvent.change(screen.getByLabelText(/Domains/i), {
+ target: { value: 'AI, ML' },
+ })
+ fireEvent.change(screen.getByLabelText(/Tags/i), {
+ target: { value: 'graphql, react' },
+ })
+ fireEvent.change(screen.getByLabelText(/Project Name/i), {
+ target: { value: 'Awesome Project' },
+ })
+
+ await act(async () => {
+ jest.runAllTimers()
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: /Save/i }))
+
+ await waitFor(() => {
+ expect(mockUpdateModule).toHaveBeenCalled()
+ expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program?refresh=true')
+ })
+ })
+
+ it('shows loading spinner initially', () => {
+ ;(useSession as jest.Mock).mockReturnValue({
+ data: null,
+ status: 'loading',
+ })
+ ;(useQuery as jest.Mock).mockReturnValue({ loading: true })
+
+ render()
+
+ expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0)
+ })
+})
diff --git a/frontend/__tests__/unit/pages/EditProgram.test.tsx b/frontend/__tests__/unit/pages/EditProgram.test.tsx
new file mode 100644
index 0000000000..ee9c4d3d49
--- /dev/null
+++ b/frontend/__tests__/unit/pages/EditProgram.test.tsx
@@ -0,0 +1,93 @@
+import { useQuery } from '@apollo/client'
+import { render, screen, waitFor } from '@testing-library/react'
+import { useParams, useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import EditProgramPage from 'app/my/mentorship/programs/[programKey]/edit/page'
+
+jest.mock('next-auth/react', () => ({
+ useSession: jest.fn(),
+}))
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+ useParams: jest.fn(),
+}))
+jest.mock('@apollo/client', () => {
+ const actual = jest.requireActual('@apollo/client')
+ return {
+ ...actual,
+ useMutation: jest.fn(() => [jest.fn(), { loading: false }]),
+ useQuery: jest.fn(),
+ }
+})
+jest.mock('@heroui/toast', () => ({
+ addToast: jest.fn(),
+}))
+
+describe('EditProgramPage', () => {
+ const mockPush = jest.fn()
+ const mockReplace = jest.fn()
+
+ beforeEach(() => {
+ ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace })
+ ;(useParams as jest.Mock).mockReturnValue({ programKey: 'program_1' })
+ jest.clearAllMocks()
+ })
+
+ test('shows loading spinner while checking access', () => {
+ ;(useSession as jest.Mock).mockReturnValue({ status: 'loading' })
+ ;(useQuery as jest.Mock).mockReturnValue({ loading: true })
+
+ render()
+
+ expect(screen.getAllByAltText('Loading indicator')).toHaveLength(2)
+ })
+
+ test('denies access for non-admins and redirects', async () => {
+ ;(useSession as jest.Mock).mockReturnValue({
+ data: { user: { login: 'nonadmin' } },
+ status: 'authenticated',
+ })
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: false,
+ data: {
+ getProgram: {
+ admins: [{ login: 'admin1' }],
+ },
+ },
+ })
+
+ render()
+
+ await waitFor(async () => {
+ expect(await screen.findByText('Access Denied')).toBeInTheDocument()
+ })
+ })
+
+ test('renders form for valid admin', async () => {
+ ;(useSession as jest.Mock).mockReturnValue({
+ data: { user: { login: 'admin1' } },
+ status: 'authenticated',
+ })
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: false,
+ data: {
+ getProgram: {
+ name: 'Test',
+ description: 'Test description',
+ menteesLimit: 10,
+ startedAt: '2025-01-01',
+ endedAt: '2025-12-31',
+ tags: ['react', 'js'],
+ domains: ['web'],
+ admins: [{ login: 'admin1' }],
+ status: 'DRAFT',
+ },
+ },
+ })
+
+ render()
+
+ expect(await screen.findByLabelText('Program Name *')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Test')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/__tests__/unit/pages/ModuleDetails.test.tsx b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx
new file mode 100644
index 0000000000..a52d9e62ea
--- /dev/null
+++ b/frontend/__tests__/unit/pages/ModuleDetails.test.tsx
@@ -0,0 +1,84 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor } from '@testing-library/react'
+import { mockModuleData } from '@unit/data/mockModuleData'
+import { useParams } from 'next/navigation'
+import { render } from 'wrappers/testUtil'
+import { handleAppError } from 'app/global-error'
+import ModuleDetailsPage from 'app/mentorship/programs/[programKey]/modules/[moduleKey]/page'
+
+jest.mock('next/navigation', () => ({
+ useParams: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+ useMutation: jest.fn(),
+}))
+
+jest.mock('app/global-error', () => ({
+ handleAppError: jest.fn(),
+ ErrorDisplay: ({ title }) => {title}
,
+}))
+
+jest.mock('components/LoadingSpinner', () => () => LoadingSpinner
)
+
+jest.mock('components/CardDetailsPage', () => (props) => (
+
+
{props.title}
+
{props.summary}
+
+))
+
+describe('ModuleDetailsPage', () => {
+ const mockUseParams = useParams as jest.Mock
+ const mockUseQuery = useQuery as jest.Mock
+
+ const admins = [{ login: 'admin1' }]
+
+ beforeEach(() => {
+ mockUseParams.mockReturnValue({
+ programKey: 'program-1',
+ moduleKey: 'module-1',
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('shows loading spinner initially', () => {
+ mockUseQuery.mockReturnValue({ loading: true })
+
+ const { container } = render()
+ expect(container.innerHTML).toContain('LoadingSpinner')
+ })
+
+ it('calls error handler on GraphQL error', async () => {
+ const error = new Error('Query failed')
+ mockUseQuery.mockReturnValue({ error, loading: false })
+
+ render()
+
+ await waitFor(() => {
+ expect(handleAppError).toHaveBeenCalledWith(error)
+ })
+ })
+
+ it('renders module details when data is present', async () => {
+ mockUseQuery.mockReturnValue({
+ loading: false,
+ data: {
+ getModule: mockModuleData,
+ getProgram: {
+ admins,
+ },
+ },
+ })
+
+ render()
+
+ expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web')
+ expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.')
+ })
+})
diff --git a/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx
new file mode 100644
index 0000000000..880109a9b5
--- /dev/null
+++ b/frontend/__tests__/unit/pages/ModuleDetailsPage.test.tsx
@@ -0,0 +1,84 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor } from '@testing-library/react'
+import { mockModuleData } from '@unit/data/mockModuleData'
+import { useParams } from 'next/navigation'
+import { render } from 'wrappers/testUtil'
+import { handleAppError } from 'app/global-error'
+import ModuleDetailsPage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page'
+
+jest.mock('next/navigation', () => ({
+ useParams: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+ useMutation: jest.fn(),
+}))
+
+jest.mock('app/global-error', () => ({
+ handleAppError: jest.fn(),
+ ErrorDisplay: ({ title }) => {title}
,
+}))
+
+jest.mock('components/LoadingSpinner', () => () => LoadingSpinner
)
+
+jest.mock('components/CardDetailsPage', () => (props) => (
+
+
{props.title}
+
{props.summary}
+
+))
+
+describe('ModuleDetailsPage', () => {
+ const mockUseParams = useParams as jest.Mock
+ const mockUseQuery = useQuery as jest.Mock
+
+ const admins = [{ login: 'admin1' }]
+
+ beforeEach(() => {
+ mockUseParams.mockReturnValue({
+ programKey: 'program-1',
+ moduleKey: 'module-1',
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('shows loading spinner initially', () => {
+ mockUseQuery.mockReturnValue({ loading: true })
+
+ const { container } = render()
+ expect(container.innerHTML).toContain('LoadingSpinner')
+ })
+
+ it('calls error handler on GraphQL error', async () => {
+ const error = new Error('Query failed')
+ mockUseQuery.mockReturnValue({ error, loading: false })
+
+ render()
+
+ await waitFor(() => {
+ expect(handleAppError).toHaveBeenCalledWith(error)
+ })
+ })
+
+ it('renders module details when data is present', async () => {
+ mockUseQuery.mockReturnValue({
+ loading: false,
+ data: {
+ getModule: mockModuleData,
+ getProgram: {
+ admins,
+ },
+ },
+ })
+
+ render()
+
+ expect(await screen.findByTestId('details-card')).toHaveTextContent('Intro to Web')
+ expect(screen.getByTestId('details-card')).toHaveTextContent('A beginner friendly module.')
+ })
+})
diff --git a/frontend/__tests__/unit/pages/MyMentorship.test.tsx b/frontend/__tests__/unit/pages/MyMentorship.test.tsx
new file mode 100644
index 0000000000..ca62264445
--- /dev/null
+++ b/frontend/__tests__/unit/pages/MyMentorship.test.tsx
@@ -0,0 +1,171 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { useRouter as useRouterMock } from 'next/navigation'
+import { useSession as mockUseSession } from 'next-auth/react'
+import { render } from 'wrappers/testUtil'
+import MyMentorshipPage from 'app/my/mentorship/page'
+
+jest.mock('@apollo/client', () => {
+ const actual = jest.requireActual('@apollo/client')
+ return {
+ ...actual,
+ useQuery: jest.fn(),
+ }
+})
+
+jest.mock('next/navigation', () => {
+ const actual = jest.requireActual('next/navigation')
+ return {
+ ...actual,
+ useRouter: jest.fn(),
+ useSearchParams: () => new URLSearchParams(''),
+ }
+})
+
+jest.mock('next-auth/react', () => {
+ const actual = jest.requireActual('next-auth/react')
+ return {
+ ...actual,
+ useSession: jest.fn(),
+ }
+})
+
+const mockUseQuery = useQuery as jest.Mock
+const mockPush = jest.fn()
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ ;(useRouterMock as jest.Mock).mockReturnValue({ push: mockPush })
+})
+
+const mockProgramData = {
+ myPrograms: {
+ programs: [
+ {
+ id: '1',
+ key: 'program-1',
+ name: 'Test Program',
+ description: 'Test Description',
+ status: 'draft',
+ startedAt: '2025-07-28',
+ endedAt: '2025-08-10',
+ experienceLevels: ['beginner'],
+ menteesLimit: 10,
+ admins: [],
+ },
+ ],
+ totalPages: 1,
+ },
+}
+
+describe('MyMentorshipPage', () => {
+ it('shows loading while checking access', () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: null,
+ status: 'loading',
+ })
+ mockUseQuery.mockReturnValue({ data: undefined, loading: false, error: undefined })
+
+ render()
+ expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0)
+ })
+
+ it('shows access denied if user is not project leader', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'user 1',
+ email: 'user@example.com',
+ login: 'user1',
+ isLeader: false,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ })
+
+ mockUseQuery.mockReturnValue({ data: undefined, loading: false, error: undefined })
+
+ render()
+ expect(await screen.findByText(/Access Denied/i)).toBeInTheDocument()
+ })
+
+ it('renders mentorship programs if user is leader', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'User',
+ email: 'leader@example.com',
+ login: 'user',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ })
+
+ mockUseQuery.mockReturnValue({
+ data: mockProgramData,
+ loading: false,
+ error: undefined,
+ })
+
+ render()
+ expect(await screen.findByText('My Mentorship')).toBeInTheDocument()
+ expect(await screen.findByText('Test Program')).toBeInTheDocument()
+ })
+
+ it('shows empty state when no programs found', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'User',
+ email: 'user@example.com',
+ login: 'user',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ })
+
+ mockUseQuery.mockReturnValue({
+ data: { myPrograms: { programs: [], totalPages: 1 } },
+ loading: false,
+ error: undefined,
+ })
+
+ render()
+ expect(await screen.findByText(/Program not found/i)).toBeInTheDocument()
+ })
+
+ it('navigates to create page on clicking create button', async () => {
+ ;(mockUseSession as jest.Mock).mockReturnValue({
+ data: {
+ user: {
+ name: 'User',
+ email: 'user@example.com',
+ login: 'User',
+ isLeader: true,
+ },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ })
+
+ mockUseQuery.mockReturnValue({
+ data: mockProgramData,
+ loading: false,
+ error: undefined,
+ })
+
+ render()
+
+ const btn = await screen.findByRole('button', { name: /create program/i })
+ fireEvent.click(btn)
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/create')
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/pages/Program.test.tsx b/frontend/__tests__/unit/pages/Program.test.tsx
new file mode 100644
index 0000000000..09960da32d
--- /dev/null
+++ b/frontend/__tests__/unit/pages/Program.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { mockPrograms } from '@unit/data/mockProgramData'
+import { useRouter } from 'next/navigation'
+import { render } from 'wrappers/testUtil'
+import ProgramsPage from 'app/mentorship/programs/page'
+
+import { fetchAlgoliaData } from 'server/fetchAlgoliaData'
+jest.mock('server/fetchAlgoliaData', () => ({
+ fetchAlgoliaData: jest.fn(),
+}))
+
+const mockRouter = {
+ push: jest.fn(),
+}
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: jest.fn(() => mockRouter),
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+jest.mock('components/Pagination', () =>
+ jest.fn(({ currentPage, onPageChange }) => (
+
+
+
+ ))
+)
+
+jest.mock('@/components/MarkdownWrapper', () => {
+ return ({ content, className }: { content: string; className?: string }) => (
+
+ )
+})
+
+describe('ProgramsPage Component', () => {
+ let mockRouter: { push: jest.Mock }
+ beforeEach(() => {
+ ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({
+ hits: mockPrograms,
+ totalPages: 2,
+ })
+ mockRouter = { push: jest.fn() }
+ ;(useRouter as jest.Mock).mockReturnValue(mockRouter)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders published program cards', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Program 1')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('This is a summary of Program 1.')).toBeInTheDocument()
+ expect(screen.getByText('View Details')).toBeInTheDocument()
+ })
+
+ test('shows empty message when no programs found', async () => {
+ ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({ hits: [], totalPages: 0 })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('No programs found')).toBeInTheDocument()
+ })
+ })
+
+ test('handles page change correctly', async () => {
+ window.scrollTo = jest.fn()
+ ;(fetchAlgoliaData as jest.Mock).mockResolvedValue({
+ hits: mockPrograms,
+ totalPages: 2,
+ })
+ render()
+ await waitFor(() => {
+ const nextPageButton = screen.getByText('Next Page')
+ fireEvent.click(nextPageButton)
+ })
+ expect(window.scrollTo).toHaveBeenCalledWith({
+ top: 0,
+ behavior: 'auto',
+ })
+ })
+
+ test('navigates to program detail page on View Details click', async () => {
+ render()
+
+ await waitFor(() => {
+ const viewButton = screen.getByText('View Details')
+ fireEvent.click(viewButton)
+ })
+
+ expect(mockRouter.push).toHaveBeenCalledWith('/mentorship/programs/program_1')
+ })
+})
diff --git a/frontend/__tests__/unit/pages/ProgramDetails.test.tsx b/frontend/__tests__/unit/pages/ProgramDetails.test.tsx
new file mode 100644
index 0000000000..4231f31836
--- /dev/null
+++ b/frontend/__tests__/unit/pages/ProgramDetails.test.tsx
@@ -0,0 +1,73 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor } from '@testing-library/react'
+import mockProgramDetailsData from '@unit/data/mockProgramData'
+import { render } from 'wrappers/testUtil'
+import ProgramDetailsPage from 'app/mentorship/programs/[programKey]/page'
+import '@testing-library/jest-dom'
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+ useMutation: jest.fn(() => [jest.fn()]),
+}))
+
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: () => ({ replace: jest.fn() }),
+ useParams: () => ({ programKey: 'test-program' }),
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+describe('ProgramDetailsPage', () => {
+ beforeEach(() => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockProgramDetailsData,
+ loading: false,
+ refetch: jest.fn(),
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders loading spinner when loading', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: true,
+ data: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders 404 if no program found', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: false,
+ data: { program: null },
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Program Not Found')).toBeInTheDocument()
+ })
+ })
+
+ test('renders program details correctly', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Program')).toBeInTheDocument()
+ expect(screen.getByText('Sample summary')).toBeInTheDocument()
+ expect(screen.getByText('Draft')).toBeInTheDocument()
+ expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument()
+ expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ expect(screen.getByText('beginner, intermediate')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx
new file mode 100644
index 0000000000..33e457e0d3
--- /dev/null
+++ b/frontend/__tests__/unit/pages/ProgramDetailsMentorship.test.tsx
@@ -0,0 +1,73 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor } from '@testing-library/react'
+import mockProgramDetailsData from '@unit/data/mockProgramData'
+import { render } from 'wrappers/testUtil'
+import ProgramDetailsPage from 'app/my/mentorship/programs/[programKey]/page'
+import '@testing-library/jest-dom'
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+ useMutation: jest.fn(() => [jest.fn()]),
+}))
+
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ useRouter: () => ({ replace: jest.fn() }),
+ useParams: () => ({ programKey: 'test-program' }),
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+describe('ProgramDetailsPage', () => {
+ beforeEach(() => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockProgramDetailsData,
+ loading: false,
+ refetch: jest.fn(),
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders loading spinner when loading', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: true,
+ data: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getAllByAltText('Loading indicator').length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders 404 if no program found', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ loading: false,
+ data: { program: null },
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Program Not Found')).toBeInTheDocument()
+ })
+ })
+
+ test('renders program details correctly', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Program')).toBeInTheDocument()
+ expect(screen.getByText('Sample summary')).toBeInTheDocument()
+ expect(screen.getByText('Draft')).toBeInTheDocument()
+ expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument()
+ expect(screen.getByText('Dec 31, 2025')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ expect(screen.getByText('beginner, intermediate')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts
index ba6bb956ef..61dc4209c9 100644
--- a/frontend/jest.setup.ts
+++ b/frontend/jest.setup.ts
@@ -6,6 +6,20 @@ import 'core-js/actual/structured-clone'
global.React = React
global.TextEncoder = TextEncoder
+jest.mock('next-auth/react', () => {
+ return {
+ ...jest.requireActual('next-auth/react'),
+ useSession: () => ({
+ data: {
+ user: { name: 'Test User', email: 'test@example.com', login: 'testuser', isLeader: true },
+ expires: '2099-01-01T00:00:00.000Z',
+ },
+ status: 'authenticated',
+ loading: false,
+ }),
+ }
+})
+
if (!global.structuredClone) {
global.structuredClone = (val) => JSON.parse(JSON.stringify(val))
}
@@ -57,6 +71,7 @@ beforeEach(() => {
dispatchEvent: jest.fn(),
})),
})
+
global.runAnimationFrameCallbacks = jest.fn()
global.removeAnimationFrameCallbacks = jest.fn()
})
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 377740ffed..0d2233f399 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -61,7 +61,7 @@ importers:
version: 15.4.6(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)
'@sentry/nextjs':
specifier: ^10.4.0
- version: 10.4.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))
+ version: 10.5.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -1437,21 +1437,21 @@ packages:
resolution: {integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
- '@jridgewell/gen-mapping@0.3.13':
- resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+ '@jridgewell/gen-mapping@0.3.12':
+ resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
- '@jridgewell/source-map@0.3.11':
- resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
+ '@jridgewell/source-map@0.3.10':
+ resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
- '@jridgewell/sourcemap-codec@1.5.5':
- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/sourcemap-codec@1.5.4':
+ resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
- '@jridgewell/trace-mapping@0.3.30':
- resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+ '@jridgewell/trace-mapping@0.3.29':
+ resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
@@ -2331,28 +2331,28 @@ packages:
'@rushstack/eslint-patch@1.12.0':
resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==}
- '@sentry-internal/browser-utils@10.4.0':
- resolution: {integrity: sha512-rDGv6S18i9FyNVi/cXSOzfZZsGVR8MLOvAZUDdoCc39a3lrxG8Mdwk1KLAO62gXTElgfXQyYWrE7GUSOSXchhw==}
+ '@sentry-internal/browser-utils@10.5.0':
+ resolution: {integrity: sha512-4KIJdEj/8Ip9yqJleVSFe68r/U5bn5o/lYUwnFNEnDNxmpUbOlr7x3DXYuRFi1sfoMUxK9K1DrjnBkR7YYF00g==}
engines: {node: '>=18'}
- '@sentry-internal/feedback@10.4.0':
- resolution: {integrity: sha512-uxcdDJFRNizp2MBMukR1A0yzxTWx/VP7Ckbn808lIj0FWWscOpJrTVfxJFrkOej2oBKvTX5qTlLmlQa8w86www==}
+ '@sentry-internal/feedback@10.5.0':
+ resolution: {integrity: sha512-x79P4VZwUxb1EGZb9OQ5EEgrDWFCUlrbzHBwV/oocQA5Ss1SFz5u6cP5Ak7yJtILiJtdGzAyAoQOy4GKD13D4Q==}
engines: {node: '>=18'}
- '@sentry-internal/replay-canvas@10.4.0':
- resolution: {integrity: sha512-WLhelB7v/BkzTd0LFsK8INEH2aydqbq/UfCYsDsHrmlJrW0ivoACeQLDdR0WRAvWrB4jtZKg4JcTsuQoglwCsQ==}
+ '@sentry-internal/replay-canvas@10.5.0':
+ resolution: {integrity: sha512-5nrRKd5swefd9+sFXFZ/NeL3bz/VxBls3ubAQ3afak15FikkSyHq3oKRKpMOtDsiYKXE3Bc0y3rF5A+y3OXjIA==}
engines: {node: '>=18'}
- '@sentry-internal/replay@10.4.0':
- resolution: {integrity: sha512-lCbLZOV3RHR7BovwG/Pba3nQ8S2RzIr52RmOwi1RntTMS410psirBb+NEY+RwS8xdQu9QsimCy3Nno7dG6zkHQ==}
+ '@sentry-internal/replay@10.5.0':
+ resolution: {integrity: sha512-Dp4coE/nPzhFrYH3iVrpVKmhNJ1m/jGXMEDBCNg3wJZRszI41Hrj0jCAM0Y2S3Q4IxYOmFYaFbGtVpAznRyOHg==}
engines: {node: '>=18'}
'@sentry/babel-plugin-component-annotate@4.1.0':
resolution: {integrity: sha512-UkcnqC7Bp9ODyoBN7BKcRotd1jz/I2vyruE/qjNfRC7UnP+jIRItUWYaXxQPON1fTw+N+egKdByk0M1y2OPv/Q==}
engines: {node: '>= 14'}
- '@sentry/browser@10.4.0':
- resolution: {integrity: sha512-tXvo7/vD9ZXFGufDm36JIo88szD5qhKt4ahsRl2ZmeHTj7UI1L6wl/rvk/9vn9jmiNjCQFY7ZMIQ+cf5S+4QmQ==}
+ '@sentry/browser@10.5.0':
+ resolution: {integrity: sha512-o5pEJeZ/iZ7Fmaz2sIirThfnmSVNiP5ZYhacvcDi0qc288TmBbikCX3fXxq3xiSkhXfe1o5QIbNyovzfutyuVw==}
engines: {node: '>=18'}
'@sentry/bundler-plugin-core@4.1.0':
@@ -2411,18 +2411,18 @@ packages:
engines: {node: '>= 10'}
hasBin: true
- '@sentry/core@10.4.0':
- resolution: {integrity: sha512-QJRDUwnMe0QckGFzq42R/ulUEdN7iGvpDGNQD1Tho/0zkCz9vwBvskXj3MVFzhXMdLnqdAz8ximFdz/zdufjsA==}
+ '@sentry/core@10.5.0':
+ resolution: {integrity: sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==}
engines: {node: '>=18'}
- '@sentry/nextjs@10.4.0':
- resolution: {integrity: sha512-ZLAIcK90zuLrFa1Pld91axDOSPpwMUdx6OBu8QtH84TFv8XsKNpyyQPuUbQsou2o0/PERteDNyRoGY2x1PCR7A==}
+ '@sentry/nextjs@10.5.0':
+ resolution: {integrity: sha512-CWozbPqbAX8qUx4DdVLgjEkjcG+JJ5vHyGczo8yiWVQQZAv/Ivd+TVxqAVMJiL68y+C4VQYfejGp64zsIYS3yw==}
engines: {node: '>=18'}
peerDependencies:
next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0
- '@sentry/node-core@10.4.0':
- resolution: {integrity: sha512-LTi5nT8l43VJR2CpdYWJ/Ux33BqLE7TpPAdEfiKovZ8R+MGJcofAFEPjkeRkIscn7U/YlqEzjlMArzA9sP5E1g==}
+ '@sentry/node-core@10.5.0':
+ resolution: {integrity: sha512-VC4FCKMvvbUT32apTE0exfI/WigqKskzQA+VdFz61Y+T7mTCADngNrOjG3ilVYPBU7R9KEEziEd/oKgencqkmQ==}
engines: {node: '>=18'}
peerDependencies:
'@opentelemetry/api': ^1.9.0
@@ -2433,12 +2433,12 @@ packages:
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0
'@opentelemetry/semantic-conventions': ^1.34.0
- '@sentry/node@10.4.0':
- resolution: {integrity: sha512-E5YhDkKY8M418xFcgKv+bdUJ54SCakjBUMLt7WH6bQIIhnIHgkPHwPA01do2ETvsLms1t73rC15i5R5v0pei2w==}
+ '@sentry/node@10.5.0':
+ resolution: {integrity: sha512-GqTkOc7tkWqRTKNjipysElh/bzIkhfLsvNGwH6+zel5kU15IdOCFtAqIri85ZLo9vbaIVtjQELXOzfo/5MMAFQ==}
engines: {node: '>=18'}
- '@sentry/opentelemetry@10.4.0':
- resolution: {integrity: sha512-vquqWm+mhS/yZArZ7BbJ6rixUAZWfn3JUxWgRe/T1tdSqaRChwdYaKNN2KId1hR5tY80h3TrlBi6+cvUUkeGIA==}
+ '@sentry/opentelemetry@10.5.0':
+ resolution: {integrity: sha512-/Qva5vngtuh79YUUBA8kbbrD6w/A+u1vy1jnLoPMKDxWTfNPqT4tCiOOmWYotnITaE3QO0UtXK/j7LMX8FhtUA==}
engines: {node: '>=18'}
peerDependencies:
'@opentelemetry/api': ^1.9.0
@@ -2447,14 +2447,14 @@ packages:
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0
'@opentelemetry/semantic-conventions': ^1.34.0
- '@sentry/react@10.4.0':
- resolution: {integrity: sha512-wuJY1KbVqvIfUG1Mk1BPjQjM1jm0Ui2jeyjCqLOrtZMD0ZsWekRo6vTcOmWtmR1b4DNia1xmZEWY/+D9+Mn4Yw==}
+ '@sentry/react@10.5.0':
+ resolution: {integrity: sha512-UHanvg+oIAvE/Hm76QCCdxYgb+tIuF0JszQoROApl5C5RxRfJJcU643pASQs6BDvrtxbuMQ/AHTacLTYpsn0cg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
- '@sentry/vercel-edge@10.4.0':
- resolution: {integrity: sha512-ijJ0FxR5zHkox65ZaqgCDRsrwKrUJAiO2arjAjLvUj6ea0Gf9ZsdQTS4RaypWZXDmXmnl1JcOoLQJG74BRS3yQ==}
+ '@sentry/vercel-edge@10.5.0':
+ resolution: {integrity: sha512-DoH+BrKyI9uVUHyEh6raSba2OUgQ0CLtFeitG0geU90VPgAlINNnjhNeKJPLp0rR3v1KesdHebnRNGUUlvXalA==}
engines: {node: '>=18'}
'@sentry/webpack-plugin@4.1.0':
@@ -2862,16 +2862,32 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/project-service@8.39.0':
+ resolution: {integrity: sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/project-service@8.39.1':
resolution: {integrity: sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/scope-manager@8.39.0':
+ resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/scope-manager@8.39.1':
resolution: {integrity: sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/tsconfig-utils@8.39.0':
+ resolution: {integrity: sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/tsconfig-utils@8.39.1':
resolution: {integrity: sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2885,16 +2901,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/types@8.39.0':
+ resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/types@8.39.1':
resolution: {integrity: sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/typescript-estree@8.39.0':
+ resolution: {integrity: sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/typescript-estree@8.39.1':
resolution: {integrity: sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/utils@8.39.0':
+ resolution: {integrity: sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/utils@8.39.1':
resolution: {integrity: sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2902,6 +2935,10 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/visitor-keys@8.39.0':
+ resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/visitor-keys@8.39.1':
resolution: {integrity: sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3327,8 +3364,11 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
- caniuse-lite@1.0.30001734:
- resolution: {integrity: sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==}
+ caniuse-lite@1.0.30001731:
+ resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
+
+ caniuse-lite@1.0.30001733:
+ resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==}
chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
@@ -3594,8 +3634,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
- electron-to-chromium@1.5.200:
- resolution: {integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==}
+ electron-to-chromium@1.5.199:
+ resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -6139,8 +6179,8 @@ snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
'@apollo/client@3.13.9(@types/react@19.1.10)(graphql@16.11.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
@@ -6209,8 +6249,8 @@ snapshots:
dependencies:
'@babel/parser': 7.28.0
'@babel/types': 7.28.2
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2':
@@ -7788,7 +7828,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.29
'@types/node': 22.17.1
chalk: 4.1.2
collect-v8-coverage: 1.0.2
@@ -7820,7 +7860,7 @@ snapshots:
'@jest/source-map@29.6.3':
dependencies:
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.29
callsites: 3.1.0
graceful-fs: 4.2.11
@@ -7842,7 +7882,7 @@ snapshots:
dependencies:
'@babel/core': 7.28.0
'@jest/types': 29.6.3
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.29
babel-plugin-istanbul: 6.1.1
chalk: 4.1.2
convert-source-map: 2.0.0
@@ -7877,29 +7917,29 @@ snapshots:
'@types/yargs': 17.0.33
chalk: 4.1.2
- '@jridgewell/gen-mapping@0.3.13':
+ '@jridgewell/gen-mapping@0.3.12':
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/sourcemap-codec': 1.5.4
+ '@jridgewell/trace-mapping': 0.3.29
'@jridgewell/resolve-uri@3.1.2': {}
- '@jridgewell/source-map@0.3.11':
+ '@jridgewell/source-map@0.3.10':
dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
- '@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/sourcemap-codec@1.5.4': {}
- '@jridgewell/trace-mapping@0.3.30':
+ '@jridgewell/trace-mapping@0.3.29':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/sourcemap-codec': 1.5.4
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/sourcemap-codec': 1.5.4
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
@@ -9100,33 +9140,33 @@ snapshots:
'@rushstack/eslint-patch@1.12.0': {}
- '@sentry-internal/browser-utils@10.4.0':
+ '@sentry-internal/browser-utils@10.5.0':
dependencies:
- '@sentry/core': 10.4.0
+ '@sentry/core': 10.5.0
- '@sentry-internal/feedback@10.4.0':
+ '@sentry-internal/feedback@10.5.0':
dependencies:
- '@sentry/core': 10.4.0
+ '@sentry/core': 10.5.0
- '@sentry-internal/replay-canvas@10.4.0':
+ '@sentry-internal/replay-canvas@10.5.0':
dependencies:
- '@sentry-internal/replay': 10.4.0
- '@sentry/core': 10.4.0
+ '@sentry-internal/replay': 10.5.0
+ '@sentry/core': 10.5.0
- '@sentry-internal/replay@10.4.0':
+ '@sentry-internal/replay@10.5.0':
dependencies:
- '@sentry-internal/browser-utils': 10.4.0
- '@sentry/core': 10.4.0
+ '@sentry-internal/browser-utils': 10.5.0
+ '@sentry/core': 10.5.0
'@sentry/babel-plugin-component-annotate@4.1.0': {}
- '@sentry/browser@10.4.0':
+ '@sentry/browser@10.5.0':
dependencies:
- '@sentry-internal/browser-utils': 10.4.0
- '@sentry-internal/feedback': 10.4.0
- '@sentry-internal/replay': 10.4.0
- '@sentry-internal/replay-canvas': 10.4.0
- '@sentry/core': 10.4.0
+ '@sentry-internal/browser-utils': 10.5.0
+ '@sentry-internal/feedback': 10.5.0
+ '@sentry-internal/replay': 10.5.0
+ '@sentry-internal/replay-canvas': 10.5.0
+ '@sentry/core': 10.5.0
'@sentry/bundler-plugin-core@4.1.0':
dependencies:
@@ -9186,19 +9226,19 @@ snapshots:
- encoding
- supports-color
- '@sentry/core@10.4.0': {}
+ '@sentry/core@10.5.0': {}
- '@sentry/nextjs@10.4.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))':
+ '@sentry/nextjs@10.5.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(next@15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.36.0
'@rollup/plugin-commonjs': 28.0.1(rollup@4.46.2)
- '@sentry-internal/browser-utils': 10.4.0
- '@sentry/core': 10.4.0
- '@sentry/node': 10.4.0
- '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
- '@sentry/react': 10.4.0(react@19.1.1)
- '@sentry/vercel-edge': 10.4.0
+ '@sentry-internal/browser-utils': 10.5.0
+ '@sentry/core': 10.5.0
+ '@sentry/node': 10.5.0
+ '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
+ '@sentry/react': 10.5.0(react@19.1.1)
+ '@sentry/vercel-edge': 10.5.0
'@sentry/webpack-plugin': 4.1.0(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))
chalk: 3.0.0
next: 15.4.6(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -9214,7 +9254,7 @@ snapshots:
- supports-color
- webpack
- '@sentry/node-core@10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
+ '@sentry/node-core@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0)
@@ -9223,11 +9263,11 @@ snapshots:
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.36.0
- '@sentry/core': 10.4.0
- '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
+ '@sentry/core': 10.5.0
+ '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
import-in-the-middle: 1.14.2
- '@sentry/node@10.4.0':
+ '@sentry/node@10.5.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0)
@@ -9259,35 +9299,35 @@ snapshots:
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.36.0
'@prisma/instrumentation': 6.13.0(@opentelemetry/api@1.9.0)
- '@sentry/core': 10.4.0
- '@sentry/node-core': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
- '@sentry/opentelemetry': 10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
+ '@sentry/core': 10.5.0
+ '@sentry/node-core': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
+ '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
import-in-the-middle: 1.14.2
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
- '@sentry/opentelemetry@10.4.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
+ '@sentry/opentelemetry@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.36.0
- '@sentry/core': 10.4.0
+ '@sentry/core': 10.5.0
- '@sentry/react@10.4.0(react@19.1.1)':
+ '@sentry/react@10.5.0(react@19.1.1)':
dependencies:
- '@sentry/browser': 10.4.0
- '@sentry/core': 10.4.0
+ '@sentry/browser': 10.5.0
+ '@sentry/core': 10.5.0
hoist-non-react-statics: 3.3.2
react: 19.1.1
- '@sentry/vercel-edge@10.4.0':
+ '@sentry/vercel-edge@10.5.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
- '@sentry/core': 10.4.0
+ '@sentry/core': 10.5.0
'@sentry/webpack-plugin@4.1.0(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17)))':
dependencies:
@@ -9695,6 +9735,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/project-service@8.39.0(typescript@5.8.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.8.3)
+ '@typescript-eslint/types': 8.39.0
+ debug: 4.4.1
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/project-service@8.39.1(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.39.1(typescript@5.8.3)
@@ -9704,11 +9753,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/scope-manager@8.39.0':
+ dependencies:
+ '@typescript-eslint/types': 8.39.0
+ '@typescript-eslint/visitor-keys': 8.39.0
+
'@typescript-eslint/scope-manager@8.39.1':
dependencies:
'@typescript-eslint/types': 8.39.1
'@typescript-eslint/visitor-keys': 8.39.1
+ '@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.8.3)':
+ dependencies:
+ typescript: 5.8.3
+
'@typescript-eslint/tsconfig-utils@8.39.1(typescript@5.8.3)':
dependencies:
typescript: 5.8.3
@@ -9725,8 +9783,26 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/types@8.39.0': {}
+
'@typescript-eslint/types@8.39.1': {}
+ '@typescript-eslint/typescript-estree@8.39.0(typescript@5.8.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.39.0(typescript@5.8.3)
+ '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.8.3)
+ '@typescript-eslint/types': 8.39.0
+ '@typescript-eslint/visitor-keys': 8.39.0
+ debug: 4.4.1
+ fast-glob: 3.3.3
+ is-glob: 4.0.3
+ minimatch: 9.0.5
+ semver: 7.7.2
+ ts-api-utils: 2.1.0(typescript@5.8.3)
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/typescript-estree@8.39.1(typescript@5.8.3)':
dependencies:
'@typescript-eslint/project-service': 8.39.1(typescript@5.8.3)
@@ -9743,6 +9819,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.5.1))
+ '@typescript-eslint/scope-manager': 8.39.0
+ '@typescript-eslint/types': 8.39.0
+ '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.8.3)
+ eslint: 9.33.0(jiti@2.5.1)
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)':
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.5.1))
@@ -9754,6 +9841,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/visitor-keys@8.39.0':
+ dependencies:
+ '@typescript-eslint/types': 8.39.0
+ eslint-visitor-keys: 4.2.1
+
'@typescript-eslint/visitor-keys@8.39.1':
dependencies:
'@typescript-eslint/types': 8.39.1
@@ -10087,7 +10179,7 @@ snapshots:
autoprefixer@10.4.21(postcss@8.5.6):
dependencies:
browserslist: 4.25.2
- caniuse-lite: 1.0.30001734
+ caniuse-lite: 1.0.30001731
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.1.1
@@ -10178,8 +10270,8 @@ snapshots:
browserslist@4.25.2:
dependencies:
- caniuse-lite: 1.0.30001734
- electron-to-chromium: 1.5.200
+ caniuse-lite: 1.0.30001733
+ electron-to-chromium: 1.5.199
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.2)
@@ -10222,7 +10314,9 @@ snapshots:
camelcase@6.3.0: {}
- caniuse-lite@1.0.30001734: {}
+ caniuse-lite@1.0.30001731: {}
+
+ caniuse-lite@1.0.30001733: {}
chalk@3.0.0:
dependencies:
@@ -10450,7 +10544,7 @@ snapshots:
eastasianwidth@0.2.0: {}
- electron-to-chromium@1.5.200: {}
+ electron-to-chromium@1.5.199: {}
emittery@0.13.1: {}
@@ -10673,7 +10767,7 @@ snapshots:
eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(jest@29.7.0(@types/node@22.17.1)(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.1)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
- '@typescript-eslint/utils': 8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)
+ '@typescript-eslint/utils': 8.39.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)
eslint: 9.33.0(jiti@2.5.1)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3))(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)
@@ -11925,11 +12019,11 @@ snapshots:
magic-string@0.30.17:
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/sourcemap-codec': 1.5.4
magic-string@0.30.8:
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/sourcemap-codec': 1.5.4
make-dir@4.0.0:
dependencies:
@@ -12049,7 +12143,7 @@ snapshots:
dependencies:
'@next/env': 15.4.6
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001734
+ caniuse-lite: 1.0.30001731
postcss: 8.4.31
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
@@ -12827,7 +12921,7 @@ snapshots:
sucrase@3.35.0:
dependencies:
- '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/gen-mapping': 0.3.12
commander: 4.1.1
glob: 10.4.5
lines-and-columns: 1.2.4
@@ -12907,7 +13001,7 @@ snapshots:
terser-webpack-plugin@5.3.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(webpack@5.101.0(@swc/core@1.13.3(@swc/helpers@0.5.17))):
dependencies:
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.29
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
@@ -12918,7 +13012,7 @@ snapshots:
terser@5.43.1:
dependencies:
- '@jridgewell/source-map': 0.3.11
+ '@jridgewell/source-map': 0.3.10
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -13179,7 +13273,7 @@ snapshots:
v8-to-istanbul@9.3.0:
dependencies:
- '@jridgewell/trace-mapping': 0.3.30
+ '@jridgewell/trace-mapping': 0.3.29
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts
index d856dee9dc..50ecd52902 100644
--- a/frontend/src/app/api/auth/[...nextauth]/route.ts
+++ b/frontend/src/app/api/auth/[...nextauth]/route.ts
@@ -1,5 +1,7 @@
import NextAuth, { type AuthOptions } from 'next-auth'
import GitHubProvider from 'next-auth/providers/github'
+import { apolloClient } from 'server/apolloClient'
+import { IS_PROJECT_LEADER_QUERY, IS_MENTOR_QUERY } from 'server/queries/mentorshipQueries'
import { ExtendedProfile, ExtendedSession } from 'types/auth'
import {
GITHUB_CLIENT_ID,
@@ -8,6 +10,34 @@ import {
NEXTAUTH_SECRET,
} from 'utils/credentials'
+async function checkIfProjectLeader(login: string): Promise {
+ try {
+ const client = await apolloClient
+ const { data } = await client.query({
+ query: IS_PROJECT_LEADER_QUERY,
+ variables: { login },
+ fetchPolicy: 'no-cache',
+ })
+ return data?.isProjectLeader ?? false
+ } catch (err) {
+ throw new Error('Failed to fetch project leader status Error', err)
+ }
+}
+
+async function checkIfMentor(login: string): Promise {
+ try {
+ const client = await apolloClient
+ const { data } = await client.query({
+ query: IS_MENTOR_QUERY,
+ variables: { login },
+ fetchPolicy: 'no-cache',
+ })
+ return data?.isMentor ?? false
+ } catch (err) {
+ throw new Error('Failed to fetch mentor status Error', err)
+ }
+}
+
const providers = []
if (IS_GITHUB_AUTH_ENABLED) {
@@ -42,9 +72,17 @@ const authOptions: AuthOptions = {
if (account?.access_token) {
token.accessToken = account.access_token
}
+
if ((profile as ExtendedProfile)?.login) {
- token.login = (profile as ExtendedProfile)?.login
+ const login = (profile as ExtendedProfile).login
+ token.login = login
+
+ const isLeader = await checkIfProjectLeader(login)
+ const isMentor = await checkIfMentor(login)
+ token.isLeader = isLeader
+ token.isMentor = isMentor
}
+
if (trigger === 'update' && session) {
token.isOwaspStaff = (session as ExtendedSession).user.isOwaspStaff || false
}
@@ -53,8 +91,11 @@ const authOptions: AuthOptions = {
async session({ session, token }) {
;(session as ExtendedSession).accessToken = token.accessToken as string
+
if (session.user) {
;(session as ExtendedSession).user.login = token.login as string
+ ;(session as ExtendedSession).user.isMentor = token.isMentor as boolean
+ ;(session as ExtendedSession).user.isLeader = token.isLeader as boolean
;(session as ExtendedSession).user.isOwaspStaff = token.isOwaspStaff as boolean
}
return session
diff --git a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx
new file mode 100644
index 0000000000..c2f4e2b1c6
--- /dev/null
+++ b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import { useQuery } from '@apollo/client'
+import upperFirst from 'lodash/upperFirst'
+import { useParams } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries'
+import type { Module } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import DetailsCard from 'components/CardDetailsPage'
+import LoadingSpinner from 'components/LoadingSpinner'
+import { getSimpleDuration } from 'components/ModuleCard'
+
+const ModuleDetailsPage = () => {
+ const { programKey, moduleKey } = useParams()
+ const [module, setModule] = useState(null)
+ const [admins, setAdmins] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ const { data, error } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, {
+ variables: {
+ programKey,
+ moduleKey,
+ },
+ })
+
+ useEffect(() => {
+ if (data?.getModule) {
+ setModule(data.getModule)
+ setAdmins(data.getProgram.admins)
+ setIsLoading(false)
+ } else if (error) {
+ handleAppError(error)
+ setIsLoading(false)
+ }
+ }, [data, error])
+
+ if (isLoading) return
+
+ if (!module) {
+ return (
+
+ )
+ }
+
+ const moduleDetails = [
+ { label: 'Experience Level', value: upperFirst(module.experienceLevel) },
+ { label: 'Start Date', value: formatDate(module.startedAt) },
+ { label: 'End Date', value: formatDate(module.endedAt) },
+ {
+ label: 'Duration',
+ value: getSimpleDuration(module.startedAt, module.endedAt),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default ModuleDetailsPage
diff --git a/frontend/src/app/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/page.tsx
new file mode 100644
index 0000000000..ccd7bbd687
--- /dev/null
+++ b/frontend/src/app/mentorship/programs/[programKey]/page.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import { useQuery } from '@apollo/client'
+import upperFirst from 'lodash/upperFirst'
+import { useParams, useSearchParams, useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { ErrorDisplay } from 'app/global-error'
+import { GET_PROGRAM_AND_MODULES } from 'server/queries/programsQueries'
+import type { Module, Program } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import DetailsCard from 'components/CardDetailsPage'
+import LoadingSpinner from 'components/LoadingSpinner'
+
+const ProgramDetailsPage = () => {
+ const { programKey } = useParams() as { programKey: string }
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const shouldRefresh = searchParams.get('refresh') === 'true'
+ const {
+ data,
+ refetch,
+ loading: isQueryLoading,
+ } = useQuery(GET_PROGRAM_AND_MODULES, {
+ variables: { programKey },
+ skip: !programKey,
+ notifyOnNetworkStatusChange: true,
+ })
+
+ const [program, setProgram] = useState(null)
+ const [modules, setModules] = useState([])
+ const [isRefetching, setIsRefetching] = useState(false)
+
+ const isLoading = isQueryLoading || isRefetching
+
+ useEffect(() => {
+ const processResult = async () => {
+ if (shouldRefresh) {
+ setIsRefetching(true)
+ try {
+ await refetch()
+ } finally {
+ setIsRefetching(false)
+
+ const params = new URLSearchParams(searchParams.toString())
+ params.delete('refresh')
+ const cleaned = params.toString()
+ router.replace(cleaned ? `?${cleaned}` : window.location.pathname, { scroll: false })
+ }
+ }
+
+ if (data?.getProgram) {
+ setProgram(data.getProgram)
+ setModules(data.getProgramModules || [])
+ }
+ }
+
+ processResult()
+ }, [shouldRefresh, data, refetch, router, searchParams])
+
+ if (isLoading) return
+
+ if (!program && !isLoading) {
+ return (
+
+ )
+ }
+
+ const programDetails = [
+ { label: 'Status', value: upperFirst(program.status) },
+ { label: 'Start Date', value: formatDate(program.startedAt) },
+ { label: 'End Date', value: formatDate(program.endedAt) },
+ { label: 'Mentees Limit', value: String(program.menteesLimit) },
+ {
+ label: 'Experience Levels',
+ value: program.experienceLevels?.join(', ') || 'N/A',
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default ProgramDetailsPage
diff --git a/frontend/src/app/mentorship/programs/page.tsx b/frontend/src/app/mentorship/programs/page.tsx
new file mode 100644
index 0000000000..66f78a897e
--- /dev/null
+++ b/frontend/src/app/mentorship/programs/page.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import { useSearchPage } from 'hooks/useSearchPage'
+import { useRouter } from 'next/navigation'
+import { Program } from 'types/mentorship'
+import ProgramCard from 'components/ProgramCard'
+import SearchPageLayout from 'components/SearchPageLayout'
+
+const ProgramsPage = () => {
+ const {
+ items: programs,
+ isLoaded,
+ currentPage,
+ totalPages,
+ searchQuery,
+ handleSearch,
+ handlePageChange,
+ } = useSearchPage({
+ indexName: 'programs',
+ pageTitle: 'OWASP Programs',
+ hitsPerPage: 24,
+ })
+
+ const router = useRouter()
+
+ const renderProgramCard = (program: Program) => {
+ const handleButtonClick = () => {
+ router.push(`/mentorship/programs/${program.key}`)
+ }
+
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {programs && programs.filter((p) => p.status === 'published').map(renderProgramCard)}
+
+
+ )
+}
+
+export default ProgramsPage
diff --git a/frontend/src/app/my/mentorship/page.tsx b/frontend/src/app/my/mentorship/page.tsx
new file mode 100644
index 0000000000..183a3a8ecb
--- /dev/null
+++ b/frontend/src/app/my/mentorship/page.tsx
@@ -0,0 +1,154 @@
+'use client'
+
+import { useQuery } from '@apollo/client'
+import { faPlus, faGraduationCap } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { addToast } from '@heroui/toast'
+import { debounce } from 'lodash'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React, { useEffect, useMemo, useState } from 'react'
+
+import { GET_MY_PROGRAMS } from 'server/queries/programsQueries'
+import type { ExtendedSession } from 'types/auth'
+import type { Program } from 'types/mentorship'
+
+import ActionButton from 'components/ActionButton'
+import LoadingSpinner from 'components/LoadingSpinner'
+import ProgramCard from 'components/ProgramCard'
+import SearchPageLayout from 'components/SearchPageLayout'
+
+const MyMentorshipPage: React.FC = () => {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const { data: session } = useSession()
+ const username = (session as ExtendedSession)?.user?.login
+
+ const initialQuery = searchParams.get('q') || ''
+ const initialPage = parseInt(searchParams.get('page') || '1', 10)
+
+ const [searchQuery, setSearchQuery] = useState(initialQuery)
+ const [debouncedQuery, setDebouncedQuery] = useState(initialQuery)
+ const [page, setPage] = useState(initialPage)
+ const [programs, setPrograms] = useState([])
+ const [totalPages, setTotalPages] = useState(1)
+
+ const debounceSearch = useMemo(() => debounce((q) => setDebouncedQuery(q), 400), [])
+
+ useEffect(() => {
+ debounceSearch(searchQuery)
+ return () => debounceSearch.cancel()
+ }, [debounceSearch, searchQuery])
+
+ useEffect(() => {
+ const params = new URLSearchParams()
+ if (searchQuery) params.set('q', searchQuery)
+ if (page > 1) params.set('page', String(page))
+ const nextUrl = params.toString() ? `?${params}` : window.location.pathname
+ if (window.location.search !== `?${params}`) {
+ router.push(nextUrl, { scroll: false })
+ }
+ }, [searchQuery, page, router])
+
+ const {
+ data: programData,
+ loading: loadingPrograms,
+ error,
+ } = useQuery(GET_MY_PROGRAMS, {
+ variables: { search: debouncedQuery, page, limit: 24 },
+ fetchPolicy: 'cache-and-network',
+ errorPolicy: 'all',
+ })
+ const isProjectLeader = (session as ExtendedSession)?.user.isLeader
+
+ useEffect(() => {
+ if (programData?.myPrograms) {
+ setPrograms(programData.myPrograms.programs)
+ setTotalPages(programData.myPrograms.totalPages || 1)
+ }
+ }, [programData])
+
+ useEffect(() => {
+ if (error) {
+ addToast({
+ title: 'GraphQL Error',
+ description: 'Failed to fetch your programs',
+ color: 'danger',
+ timeout: 3000,
+ variant: 'solid',
+ })
+ }
+ }, [error])
+
+ const handleCreate = () => router.push('/my/mentorship/programs/create')
+ const handleView = (key: string) => router.push(`/my/mentorship/programs/${key}`)
+ const handleEdit = (key: string) => router.push(`/my/mentorship/programs/${key}/edit`)
+
+ if (!username) {
+ return
+ }
+
+ if (!isProjectLeader) {
+ return (
+
+
+
Access Denied
+
+ Only project leaders can access this page.
+
+
+ )
+ }
+
+ return (
+
+
+
+
My Mentorship
+
Programs you’ve created or joined
+
+
+
+ {'Create Program'}
+
+
+
+
{
+ setPage(p)
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }}
+ onSearch={(q) => {
+ setSearchQuery(q)
+ setPage(1)
+ }}
+ searchQuery={searchQuery}
+ searchPlaceholder="Search your programs"
+ indexName="my-programs"
+ >
+
+ {programs.length === 0 ? (
+
+ ) : (
+ programs.map((p) => (
+
+ ))
+ )}
+
+
+
+ )
+}
+
+export default MyMentorshipPage
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx
new file mode 100644
index 0000000000..cdfcec0ca1
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/edit/page.tsx
@@ -0,0 +1,152 @@
+'use client'
+import { useQuery, useMutation } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import { useRouter, useParams } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import type React from 'react'
+import { useState, useEffect } from 'react'
+import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { UPDATE_PROGRAM } from 'server/mutations/programsMutations'
+import { GET_PROGRAM_DETAILS } from 'server/queries/programsQueries'
+import type { ExtendedSession } from 'types/auth'
+import { formatDateForInput } from 'utils/dateFormatter'
+import { parseCommaSeparated } from 'utils/parser'
+import slugify from 'utils/slugify'
+import LoadingSpinner from 'components/LoadingSpinner'
+import ProgramForm from 'components/ProgramForm'
+const EditProgramPage = () => {
+ const router = useRouter()
+ const { programKey } = useParams() as { programKey: string }
+ const { data: session, status: sessionStatus } = useSession()
+ const [updateProgram, { loading: mutationLoading }] = useMutation(UPDATE_PROGRAM)
+ const {
+ data,
+ error,
+ loading: queryLoading,
+ } = useQuery(GET_PROGRAM_DETAILS, {
+ variables: { programKey },
+ skip: !programKey,
+ fetchPolicy: 'network-only',
+ })
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ menteesLimit: 5,
+ startedAt: '',
+ endedAt: '',
+ tags: '',
+ domains: '',
+ adminLogins: '',
+ status: 'DRAFT',
+ })
+ const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking')
+ useEffect(() => {
+ if (sessionStatus === 'loading' || queryLoading) {
+ return
+ }
+ if (!data?.getProgram || sessionStatus === 'unauthenticated') {
+ setAccessStatus('denied')
+ return
+ }
+
+ const isAdmin = data.getProgram.admins?.some(
+ (admin: { login: string }) => admin.login === (session as ExtendedSession)?.user?.login
+ )
+
+ if (isAdmin) {
+ setAccessStatus('allowed')
+ } else {
+ setAccessStatus('denied')
+ addToast({
+ title: 'Access Denied',
+ description: 'Only program admins can edit this page.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 4000,
+ })
+ setTimeout(() => router.replace('/my/mentorship/programs'), 1500)
+ }
+ }, [sessionStatus, session, data, queryLoading, router])
+ useEffect(() => {
+ if (accessStatus === 'allowed' && data?.getProgram) {
+ const { getProgram: program } = data
+ setFormData({
+ name: program.name || '',
+ description: program.description || '',
+ menteesLimit: program.menteesLimit ?? 5,
+ startedAt: formatDateForInput(program.startedAt),
+ endedAt: formatDateForInput(program.endedAt),
+ tags: (program.tags || []).join(', '),
+ domains: (program.domains || []).join(', '),
+ adminLogins: (program.admins || [])
+ .map((admin: { login: string }) => admin.login)
+ .join(', '),
+ status: program.status || 'DRAFT',
+ })
+ } else if (error) {
+ handleAppError(error)
+ }
+ }, [accessStatus, data, error])
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ try {
+ const input = {
+ key: programKey,
+ name: formData.name,
+ description: formData.description,
+ menteesLimit: Number(formData.menteesLimit),
+ startedAt: formData.startedAt,
+ endedAt: formData.endedAt,
+ tags: parseCommaSeparated(formData.tags),
+ domains: parseCommaSeparated(formData.domains),
+ adminLogins: parseCommaSeparated(formData.adminLogins),
+ status: formData.status,
+ }
+
+ await updateProgram({ variables: { input } })
+
+ addToast({
+ title: 'Program Updated',
+ description: 'The program has been successfully updated.',
+ color: 'success',
+ variant: 'solid',
+ timeout: 3000,
+ })
+
+ router.push(`/my/mentorship/programs/${slugify(formData.name)}?refresh=true`)
+ } catch (err) {
+ addToast({
+ title: 'Update Failed',
+ description: 'There was an error updating the program.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 3000,
+ })
+ handleAppError(err)
+ }
+ }
+ if (accessStatus === 'checking') {
+ return
+ }
+ if (accessStatus === 'denied') {
+ return (
+
+ )
+ }
+ return (
+
+ )
+}
+export default EditProgramPage
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx
new file mode 100644
index 0000000000..679eeeb803
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx
@@ -0,0 +1,153 @@
+'use client'
+
+import { useMutation, useQuery } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import { useParams, useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React, { useEffect, useState } from 'react'
+import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { UPDATE_MODULE } from 'server/mutations/moduleMutations'
+import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries'
+import type { ExtendedSession } from 'types/auth'
+import { EXPERIENCE_LEVELS, type ModuleFormData } from 'types/mentorship'
+import { formatDateForInput } from 'utils/dateFormatter'
+import { parseCommaSeparated } from 'utils/parser'
+import LoadingSpinner from 'components/LoadingSpinner'
+import ModuleForm from 'components/ModuleForm'
+
+const EditModulePage = () => {
+ const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string }
+ const router = useRouter()
+ const { data: sessionData, status: sessionStatus } = useSession()
+
+ const [formData, setFormData] = useState(null)
+ const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking')
+
+ const [updateModule, { loading: mutationLoading }] = useMutation(UPDATE_MODULE)
+
+ const {
+ data,
+ loading: queryLoading,
+ error: queryError,
+ } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, {
+ variables: { programKey, moduleKey },
+ skip: !programKey || !moduleKey,
+ fetchPolicy: 'network-only',
+ })
+
+ useEffect(() => {
+ if (sessionStatus === 'loading' || queryLoading) {
+ return
+ }
+
+ if (
+ queryError ||
+ !data?.getProgram ||
+ !data?.getModule ||
+ sessionStatus === 'unauthenticated'
+ ) {
+ setAccessStatus('denied')
+ return
+ }
+
+ const currentUserLogin = (sessionData as ExtendedSession)?.user?.login
+ const isAdmin = data.getProgram.admins?.some(
+ (admin: { login: string }) => admin.login === currentUserLogin
+ )
+
+ if (isAdmin) {
+ setAccessStatus('allowed')
+ } else {
+ setAccessStatus('denied')
+ addToast({
+ title: 'Access Denied',
+ description: 'Only program admins can edit modules.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 4000,
+ })
+ setTimeout(() => router.replace(`/my/mentorship/programs/${programKey}`), 1500)
+ }
+ }, [sessionStatus, sessionData, queryLoading, data, programKey, queryError, router])
+
+ useEffect(() => {
+ if (accessStatus === 'allowed' && data?.getModule) {
+ const m = data.getModule
+ setFormData({
+ name: m.name || '',
+ description: m.description || '',
+ experienceLevel: m.experienceLevel || EXPERIENCE_LEVELS.BEGINNER,
+ startedAt: formatDateForInput(m.startedAt),
+ endedAt: formatDateForInput(m.endedAt),
+ domains: (m.domains || []).join(', '),
+ projectName: m.projectName,
+ tags: (m.tags || []).join(', '),
+ projectId: m.projectId || '',
+ mentorLogins: (m.mentors || []).map((mentor: { login: string }) => mentor.login).join(', '),
+ })
+ }
+ }, [accessStatus, data])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!formData) return
+
+ try {
+ const input = {
+ key: moduleKey,
+ programKey: programKey,
+ name: formData.name,
+ description: formData.description,
+ experienceLevel: formData.experienceLevel,
+ startedAt: formData.startedAt || null,
+ endedAt: formData.endedAt || null,
+ domains: parseCommaSeparated(formData.domains),
+ tags: parseCommaSeparated(formData.tags),
+ projectName: formData.projectName,
+ projectId: formData.projectId,
+ mentorLogins: parseCommaSeparated(formData.mentorLogins),
+ }
+
+ await updateModule({ variables: { input } })
+
+ addToast({
+ title: 'Module Updated',
+ description: 'The module has been successfully updated.',
+ color: 'success',
+ variant: 'solid',
+ timeout: 3000,
+ })
+ router.push(`/my/mentorship/programs/${programKey}?refresh=true`)
+ } catch (err) {
+ handleAppError(err)
+ }
+ }
+
+ if (accessStatus === 'checking' || !formData) {
+ return
+ }
+
+ if (accessStatus === 'denied') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default EditModulePage
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx
new file mode 100644
index 0000000000..dc0a7356b9
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx
@@ -0,0 +1,76 @@
+'use client'
+
+import { useQuery } from '@apollo/client'
+import upperFirst from 'lodash/upperFirst'
+import { useParams } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { GET_PROGRAM_ADMINS_AND_MODULES } from 'server/queries/moduleQueries'
+import type { Module } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import DetailsCard from 'components/CardDetailsPage'
+import LoadingSpinner from 'components/LoadingSpinner'
+import { getSimpleDuration } from 'components/ModuleCard'
+
+const ModuleDetailsPage = () => {
+ const { programKey, moduleKey } = useParams()
+ const [module, setModule] = useState(null)
+ const [admins, setAdmins] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ const { data, error } = useQuery(GET_PROGRAM_ADMINS_AND_MODULES, {
+ variables: {
+ programKey,
+ moduleKey,
+ },
+ })
+
+ useEffect(() => {
+ if (data?.getModule) {
+ setModule(data.getModule)
+ setAdmins(data.getProgram.admins)
+ setIsLoading(false)
+ } else if (error) {
+ handleAppError(error)
+ setIsLoading(false)
+ }
+ }, [data, error])
+
+ if (isLoading) return
+
+ if (!module) {
+ return (
+
+ )
+ }
+
+ const moduleDetails = [
+ { label: 'Experience Level', value: upperFirst(module.experienceLevel) },
+ { label: 'Start Date', value: formatDate(module.startedAt) },
+ { label: 'End Date', value: formatDate(module.endedAt) },
+ {
+ label: 'Duration',
+ value: getSimpleDuration(module.startedAt, module.endedAt),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default ModuleDetailsPage
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx
new file mode 100644
index 0000000000..8cc96d6d5b
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx
@@ -0,0 +1,146 @@
+'use client'
+
+import { useMutation, useQuery } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import { useRouter, useParams } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React, { useEffect, useState } from 'react'
+import { ErrorDisplay } from 'app/global-error'
+import { CREATE_MODULE } from 'server/mutations/moduleMutations'
+import { GET_PROGRAM_ADMIN_DETAILS } from 'server/queries/programsQueries'
+import type { ExtendedSession } from 'types/auth'
+import { EXPERIENCE_LEVELS } from 'types/mentorship'
+import { parseCommaSeparated } from 'utils/parser'
+import LoadingSpinner from 'components/LoadingSpinner'
+import ModuleForm from 'components/ModuleForm'
+
+const CreateModulePage = () => {
+ const router = useRouter()
+ const { programKey } = useParams() as { programKey: string }
+ const { data: sessionData, status: sessionStatus } = useSession()
+
+ const [createModule, { loading: mutationLoading }] = useMutation(CREATE_MODULE)
+
+ const {
+ data: programData,
+ loading: queryLoading,
+ error: queryError,
+ } = useQuery(GET_PROGRAM_ADMIN_DETAILS, {
+ variables: { programKey },
+ skip: !programKey,
+ fetchPolicy: 'network-only',
+ })
+
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ experienceLevel: EXPERIENCE_LEVELS.BEGINNER,
+ startedAt: '',
+ endedAt: '',
+ domains: '',
+ tags: '',
+ projectId: '',
+ projectName: '',
+ mentorLogins: '',
+ })
+
+ const [accessStatus, setAccessStatus] = useState<'checking' | 'allowed' | 'denied'>('checking')
+
+ useEffect(() => {
+ if (sessionStatus === 'loading' || queryLoading) {
+ return
+ }
+
+ if (queryError || !programData?.getProgram || sessionStatus === 'unauthenticated') {
+ setAccessStatus('denied')
+ return
+ }
+
+ const currentUserLogin = (sessionData as ExtendedSession)?.user?.login
+ const isAdmin = programData.getProgram.admins?.some(
+ (admin: { login: string }) => admin.login === currentUserLogin
+ )
+
+ if (isAdmin) {
+ setAccessStatus('allowed')
+ } else {
+ setAccessStatus('denied')
+ addToast({
+ title: 'Access Denied',
+ description: 'Only program admins can create modules.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 4000,
+ })
+ setTimeout(() => router.replace('/my/mentorship'), 1500)
+ }
+ }, [sessionStatus, sessionData, queryLoading, programData, programKey, queryError, router])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ try {
+ const input = {
+ name: formData.name,
+ description: formData.description,
+ experienceLevel: formData.experienceLevel,
+ startedAt: formData.startedAt || null,
+ endedAt: formData.endedAt || null,
+ domains: parseCommaSeparated(formData.domains),
+ tags: parseCommaSeparated(formData.tags),
+ programKey: programKey,
+ projectId: formData.projectId,
+ projectName: formData.projectName,
+ mentorLogins: parseCommaSeparated(formData.mentorLogins),
+ }
+
+ await createModule({ variables: { input } })
+
+ addToast({
+ title: 'Module Created',
+ description: 'The new module has been successfully created.',
+ color: 'success',
+ variant: 'solid',
+ timeout: 3000,
+ })
+
+ router.push(`/my/mentorship/programs/${programKey}?refresh=true`)
+ } catch (err) {
+ addToast({
+ title: 'Creation Failed',
+ description: err.message || 'Something went wrong while creating the module.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 4000,
+ })
+ }
+ }
+
+ if (accessStatus === 'checking') {
+ return
+ }
+
+ if (accessStatus === 'denied') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default CreateModulePage
diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx
new file mode 100644
index 0000000000..a28ef94602
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx
@@ -0,0 +1,159 @@
+'use client'
+
+import { useQuery, useMutation } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import upperFirst from 'lodash/upperFirst'
+import { useParams, useSearchParams, useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import { useEffect, useMemo, useState } from 'react'
+import { ErrorDisplay, handleAppError } from 'app/global-error'
+import { UPDATE_PROGRAM_STATUS_MUTATION } from 'server/mutations/programsMutations'
+import { GET_PROGRAM_AND_MODULES } from 'server/queries/programsQueries'
+import type { ExtendedSession } from 'types/auth'
+import type { Module, Program } from 'types/mentorship'
+import { ProgramStatusEnum } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import DetailsCard from 'components/CardDetailsPage'
+import LoadingSpinner from 'components/LoadingSpinner'
+
+const ProgramDetailsPage = () => {
+ const { programKey } = useParams() as { programKey: string }
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const shouldRefresh = searchParams.get('refresh') === 'true'
+
+ const { data: session } = useSession()
+ const username = (session as ExtendedSession)?.user?.login
+
+ const [program, setProgram] = useState(null)
+ const [modules, setModules] = useState([])
+ const [isRefetching, setIsRefetching] = useState(false)
+
+ const [updateProgram] = useMutation(UPDATE_PROGRAM_STATUS_MUTATION, {
+ onError: handleAppError,
+ })
+
+ const {
+ data,
+ refetch,
+ loading: isQueryLoading,
+ } = useQuery(GET_PROGRAM_AND_MODULES, {
+ variables: { programKey },
+ skip: !programKey,
+ notifyOnNetworkStatusChange: true,
+ })
+
+ const isLoading = isQueryLoading || isRefetching
+
+ const isAdmin = useMemo(
+ () => !!program?.admins?.some((admin) => admin.login === username),
+ [program, username]
+ )
+
+ const canUpdateStatus = useMemo(() => {
+ if (!isAdmin || !program?.status) return false
+ return true
+ }, [isAdmin, program])
+
+ const updateStatus = async (newStatus: ProgramStatusEnum) => {
+ if (!program || !isAdmin) {
+ addToast({
+ title: 'Permission Denied',
+ description: 'Only admins can update the program status.',
+ variant: 'solid',
+ color: 'danger',
+ timeout: 3000,
+ })
+ return
+ }
+
+ try {
+ await updateProgram({
+ variables: {
+ inputData: {
+ key: program.key,
+ name: program.name,
+ status: newStatus,
+ },
+ },
+ refetchQueries: [{ query: GET_PROGRAM_AND_MODULES, variables: { programKey } }],
+ })
+
+ addToast({
+ title: `Program status updated to ${upperFirst(newStatus)}`,
+ description: 'The status has been successfully updated.',
+ variant: 'solid',
+ color: 'success',
+ timeout: 3000,
+ })
+ } catch (err) {
+ handleAppError(err)
+ }
+ }
+
+ useEffect(() => {
+ const processResult = async () => {
+ if (shouldRefresh) {
+ setIsRefetching(true)
+ try {
+ await refetch()
+ } finally {
+ setIsRefetching(false)
+ const params = new URLSearchParams(searchParams.toString())
+ params.delete('refresh')
+ const cleaned = params.toString()
+ router.replace(cleaned ? `?${cleaned}` : window.location.pathname, { scroll: false })
+ }
+ }
+
+ if (data?.getProgram) {
+ setProgram(data.getProgram)
+ setModules(data.getProgramModules || [])
+ }
+ }
+
+ processResult()
+ }, [shouldRefresh, data, refetch, router, searchParams])
+
+ if (isLoading) return
+
+ if (!program && !isLoading) {
+ return (
+
+ )
+ }
+
+ const programDetails = [
+ { label: 'Status', value: upperFirst(program.status) },
+ { label: 'Start Date', value: formatDate(program.startedAt) },
+ { label: 'End Date', value: formatDate(program.endedAt) },
+ { label: 'Mentees Limit', value: String(program.menteesLimit) },
+ {
+ label: 'Experience Levels',
+ value: program.experienceLevels?.join(', ') || 'N/A',
+ },
+ ]
+
+ return (
+
+ )
+}
+
+export default ProgramDetailsPage
diff --git a/frontend/src/app/my/mentorship/programs/create/page.tsx b/frontend/src/app/my/mentorship/programs/create/page.tsx
new file mode 100644
index 0000000000..3b3c0cbd37
--- /dev/null
+++ b/frontend/src/app/my/mentorship/programs/create/page.tsx
@@ -0,0 +1,105 @@
+'use client'
+
+import { useMutation } from '@apollo/client'
+import { addToast } from '@heroui/toast'
+import { useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React, { useEffect, useState } from 'react'
+
+import { CREATE_PROGRAM } from 'server/mutations/programsMutations'
+import { ExtendedSession } from 'types/auth'
+import { parseCommaSeparated } from 'utils/parser'
+import LoadingSpinner from 'components/LoadingSpinner'
+import ProgramForm from 'components/ProgramForm'
+
+const CreateProgramPage = () => {
+ const router = useRouter()
+ const { data: session, status } = useSession()
+ const isProjectLeader = (session as ExtendedSession)?.user.isLeader
+
+ const [redirected, setRedirected] = useState(false)
+
+ const [createProgram, { loading }] = useMutation(CREATE_PROGRAM)
+
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ menteesLimit: 5,
+ startedAt: '',
+ endedAt: '',
+ tags: '',
+ domains: '',
+ })
+
+ useEffect(() => {
+ if (status === 'loading') return
+
+ if (!session || !isProjectLeader) {
+ addToast({
+ title: 'Access Denied',
+ description: 'You must be project leader to create a program.',
+ color: 'danger',
+ variant: 'solid',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ })
+ router.push('/')
+ setRedirected(true)
+ }
+ }, [session, status, router, isProjectLeader])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ try {
+ const input = {
+ name: formData.name,
+ description: formData.description,
+ menteesLimit: Number(formData.menteesLimit),
+ startedAt: formData.startedAt,
+ endedAt: formData.endedAt,
+ tags: parseCommaSeparated(formData.tags),
+ domains: parseCommaSeparated(formData.domains),
+ }
+
+ await createProgram({ variables: { input } })
+
+ addToast({
+ description: 'Program created successfully!',
+ title: 'Success',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'success',
+ variant: 'solid',
+ })
+
+ router.push('/my/mentorship')
+ } catch (err) {
+ addToast({
+ description: err?.message || 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ }
+ }
+
+ if (status === 'loading' || !session || redirected) {
+ return
+ }
+
+ return (
+
+ )
+}
+
+export default CreateProgramPage
diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx
index e048527872..2c3556967e 100644
--- a/frontend/src/components/Card.tsx
+++ b/frontend/src/components/Card.tsx
@@ -1,14 +1,17 @@
+import { faCalendar } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Tooltip } from '@heroui/tooltip'
import Link from 'next/link'
import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
import type { CardProps } from 'types/card'
import { ICONS } from 'utils/data'
+import { formatDateRange } from 'utils/dateFormatter'
import { getSocialIcon } from 'utils/urlIconMappings'
import ActionButton from 'components/ActionButton'
import ContributorAvatar from 'components/ContributorAvatar'
import DisplayIcon from 'components/DisplayIcon'
import Markdown from 'components/MarkdownWrapper'
+import ModuleList from 'components/ModuleList'
const Card = ({
title,
@@ -20,8 +23,10 @@ const Card = ({
button,
projectName,
projectLink,
+ modules,
social,
tooltipLabel,
+ timeline,
}: CardProps) => {
return (
@@ -73,6 +78,14 @@ const Card = ({
)}
)}
+
+ {/* Timeline Section (Optional) */}
+ {timeline?.start && timeline?.end && (
+
+
+ {formatDateRange(timeline.start, timeline.end)}
+
+ )}
{/* Project name link (if provided) */}
@@ -90,7 +103,8 @@ const Card = ({
{/* Project summary */}
- {/* Bottom section with social links, contributors and action button */}
+ {/* Modules section (if available) */}
+
{/* Social icons section */}
{social && social.length > 0 && (
diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx
index 2edfa53bc8..ca88e50724 100644
--- a/frontend/src/components/CardDetailsPage.tsx
+++ b/frontend/src/components/CardDetailsPage.tsx
@@ -11,6 +11,9 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import upperFirst from 'lodash/upperFirst'
import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import type { ExtendedSession } from 'types/auth'
import type { DetailsCardProps } from 'types/card'
import { IS_PROJECT_HEALTH_ENABLED } from 'utils/credentials'
import { getSocialIcon } from 'utils/urlIconMappings'
@@ -21,6 +24,8 @@ import InfoBlock from 'components/InfoBlock'
import LeadersList from 'components/LeadersList'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
import Milestones from 'components/Milestones'
+import ModuleCard from 'components/ModuleCard'
+import ProgramActions from 'components/ProgramActions'
import RecentIssues from 'components/RecentIssues'
import RecentPullRequests from 'components/RecentPullRequests'
import RecentReleases from 'components/RecentReleases'
@@ -33,6 +38,15 @@ import TopContributorsList from 'components/TopContributorsList'
const DetailsCard = ({
description,
details,
+ accessLevel,
+ status,
+ setStatus,
+ canUpdateStatus,
+ tags,
+ domains,
+ modules,
+ mentors,
+ admins,
entityKey,
geolocationData = null,
healthMetricsData,
@@ -55,12 +69,32 @@ const DetailsCard = ({
type,
userSummary,
}: DetailsCardProps) => {
+ const { data } = useSession()
+ const router = useRouter()
return (
{title}
+ {type === 'program' && accessLevel === 'admin' && canUpdateStatus && (
+
+ )}
+ {type === 'module' &&
+ accessLevel === 'admin' &&
+ admins?.some(
+ (admin) => admin.login === ((data as ExtendedSession)?.user?.login as string)
+ ) && (
+
+ )}
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
@@ -98,7 +132,13 @@ const DetailsCard = ({
}
- className={`${type !== 'chapter' ? 'md:col-span-5' : 'md:col-span-3'} gap-2`}
+ className={
+ type === 'program' || type === 'module'
+ ? 'gap-2 md:col-span-7'
+ : type !== 'chapter'
+ ? 'gap-2 md:col-span-5'
+ : 'gap-2 md:col-span-3'
+ }
>
{details?.map((detail) =>
detail?.label === 'Leaders' ? (
@@ -171,6 +211,28 @@ const DetailsCard = ({
)}
)}
+ {(type === 'program' || type === 'module') && (
+
+ {tags?.length > 0 && (
+ }
+ isDisabled={true}
+ />
+ )}
+ {domains?.length > 0 && (
+ }
+ isDisabled={true}
+ />
+ )}
+
+ )}
{topContributors && (
)}
+ {admins && admins.length > 0 && type === 'program' && (
+
+ )}
+ {mentors && mentors.length > 0 && (
+
+ )}
{(type === 'project' ||
type === 'repository' ||
type === 'user' ||
@@ -213,6 +291,14 @@ const DetailsCard = ({
)}
+ {type === 'program' && modules.length > 0 && (
+
}
+ >
+
+
+ )}
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
)}
@@ -230,7 +316,7 @@ const DetailsCard = ({
export default DetailsCard
-const SocialLinks = ({ urls }) => {
+export const SocialLinks = ({ urls }) => {
if (!urls || urls.length === 0) return null
return (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index afc6a1b56a..287e1955b2 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -82,23 +82,30 @@ export default function Header({ isGitHubAuthEnabled }: { readonly isGitHubAuthE
{/* Desktop Header Links */}
- {headerLinks.map((link, i) => {
- return link.submenu ? (
-
- ) : (
-
- {link.text}
-
- )
- })}
+ {headerLinks
+ .filter((link) => {
+ if (link.requiresGitHubAuth) {
+ return isGitHubAuthEnabled
+ }
+ return true
+ })
+ .map((link, i) => {
+ return link.submenu ? (
+
+ ) : (
+
+ {link.text}
+
+ )
+ })}
@@ -170,43 +177,50 @@ export default function Header({ isGitHubAuthEnabled }: { readonly isGitHubAuthE
- {headerLinks.map((link) =>
- link.submenu ? (
-
-
- {link.text}
-
-
- {link.submenu.map((sub, i) => (
-
- {sub.text}
-
- ))}
+ {headerLinks
+ .filter((link) => {
+ if (link.requiresGitHubAuth) {
+ return isGitHubAuthEnabled
+ }
+ return true
+ })
+ .map((link) =>
+ link.submenu ? (
+
+
+ {link.text}
+
+
+ {link.submenu.map((sub, i) => (
+
+ {sub.text}
+
+ ))}
+
-
- ) : (
-
- {link.text}
-
- )
- )}
+ ) : (
+
+ {link.text}
+
+ )
+ )}
diff --git a/frontend/src/components/InfoItem.tsx b/frontend/src/components/InfoItem.tsx
index 3e8c12532b..9bc10ca295 100644
--- a/frontend/src/components/InfoItem.tsx
+++ b/frontend/src/components/InfoItem.tsx
@@ -34,4 +34,21 @@ const InfoItem = ({
)
}
+export const TextInfoItem = ({
+ icon,
+ label,
+ value,
+}: {
+ icon: IconDefinition
+ label: string
+ value: string
+}) => {
+ return (
+
+
+ {label}: {value}
+
+ )
+}
+
export default InfoItem
diff --git a/frontend/src/components/ModuleCard.tsx b/frontend/src/components/ModuleCard.tsx
new file mode 100644
index 0000000000..7f1e184b9e
--- /dev/null
+++ b/frontend/src/components/ModuleCard.tsx
@@ -0,0 +1,110 @@
+import {
+ faChevronDown,
+ faChevronUp,
+ faLevelUpAlt,
+ faCalendarAlt,
+ faHourglassHalf,
+} from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import upperFirst from 'lodash/upperFirst'
+import { useRouter } from 'next/navigation'
+import { useState } from 'react'
+import type { Module } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import { TextInfoItem } from 'components/InfoItem'
+import SingleModuleCard from 'components/SingleModuleCard'
+import { TruncatedText } from 'components/TruncatedText'
+
+interface ModuleCardProps {
+ modules: Module[]
+ accessLevel?: string
+ admins?: { login: string }[]
+}
+
+const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => {
+ const [showAllModule, setShowAllModule] = useState(false)
+
+ if (modules.length === 1) {
+ return (
+
+ )
+ }
+
+ const displayedModule = showAllModule ? modules : modules.slice(0, 4)
+
+ return (
+
+
+ {displayedModule.map((module) => {
+ return
+ })}
+
+ {modules.length > 4 && (
+
+
+
+ )}
+
+ )
+}
+
+const ModuleItem = ({ details }: { details: Module }) => {
+ const router = useRouter()
+ const handleClick = () => {
+ router.push(`${window.location.pathname}/modules/${details.key}`)
+ }
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default ModuleCard
+
+export const getSimpleDuration = (start: string, end: string): string => {
+ const startDate = new Date(start)
+ const endDate = new Date(end)
+ if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
+ return 'Invalid duration'
+ }
+
+ const ms = endDate.getTime() - startDate.getTime()
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24))
+
+ const weeks = Math.ceil(days / 7)
+ return `${weeks} week${weeks !== 1 ? 's' : ''}`
+}
diff --git a/frontend/src/components/ModuleForm.tsx b/frontend/src/components/ModuleForm.tsx
new file mode 100644
index 0000000000..27da71d3f7
--- /dev/null
+++ b/frontend/src/components/ModuleForm.tsx
@@ -0,0 +1,380 @@
+'use client'
+
+import { useApolloClient } from '@apollo/client'
+import clsx from 'clsx'
+import debounce from 'lodash/debounce'
+import { useRouter } from 'next/navigation'
+import type React from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { SEARCH_PROJECTS } from 'server/queries/projectQueries'
+
+interface ModuleFormProps {
+ formData: {
+ name: string
+ description: string
+ experienceLevel: string
+ startedAt: string
+ endedAt: string
+ domains: string
+ tags: string
+ projectId: string
+ projectName: string
+ mentorLogins: string
+ }
+ setFormData: React.Dispatch
>
+ onSubmit: (e: React.FormEvent) => void
+ loading: boolean
+ isEdit?: boolean
+ title: string
+ submitText?: string
+}
+
+const EXPERIENCE_LEVELS = [
+ { key: 'BEGINNER', label: 'Beginner' },
+ { key: 'INTERMEDIATE', label: 'Intermediate' },
+ { key: 'ADVANCED', label: 'Advanced' },
+ { key: 'EXPERT', label: 'Expert' },
+]
+
+const ModuleForm = ({
+ formData,
+ setFormData,
+ onSubmit,
+ loading,
+ title,
+ isEdit,
+ submitText = 'Save',
+}: ModuleFormProps) => {
+ const router = useRouter()
+ const handleInputChange = (
+ e: React.ChangeEvent
+ ) => {
+ const { name, value } = e.target
+ setFormData((prev) => ({ ...prev, [name]: value }))
+ }
+
+ return (
+
+ )
+}
+
+export default ModuleForm
+
+type ProjectSelectorProps = {
+ value: string
+ defaultName: string
+ onProjectChange: (id: string | null, name: string) => void
+}
+
+export const ProjectSelector = ({ value, defaultName, onProjectChange }: ProjectSelectorProps) => {
+ const client = useApolloClient()
+ const [searchText, setSearchText] = useState(defaultName || '')
+ const [rawResults, setRawResults] = useState<{ id: string; name: string }[]>([])
+ const [suggestions, setSuggestions] = useState<{ id: string; name: string }[]>([])
+ const [showSuggestions, setShowSuggestions] = useState(false)
+ const [error, setError] = useState(null)
+
+ const suggestionClicked = useRef(false)
+ const blurTimeoutRef = useRef | null>(null)
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const fetchSuggestions = useCallback(
+ debounce(async (query: string) => {
+ if (!query.trim()) {
+ setRawResults([])
+ return
+ }
+
+ try {
+ const { data } = await client.query({
+ query: SEARCH_PROJECTS,
+ variables: { query },
+ })
+
+ setRawResults(data.searchProjects || [])
+ setShowSuggestions(true)
+ } catch (err) {
+ setRawResults([])
+ setShowSuggestions(false)
+ throw new Error('Error fetching suggestions:', err)
+ }
+ }, 300),
+ [client]
+ )
+
+ // Trigger search suggestions on user input
+ useEffect(() => {
+ fetchSuggestions(searchText)
+ return () => {
+ fetchSuggestions.cancel()
+ }
+ }, [searchText, fetchSuggestions])
+
+ // Filter out selected project from results
+ useEffect(() => {
+ const filtered = rawResults.filter((proj) => proj.id !== value)
+ setSuggestions(filtered.slice(0, 5))
+ }, [rawResults, value])
+
+ const handleSelect = (id: string, name: string) => {
+ suggestionClicked.current = true
+ setSearchText(name)
+ setShowSuggestions(false)
+ setError(null)
+ onProjectChange(id, name)
+ }
+
+ const handleBlur = () => {
+ blurTimeoutRef.current = setTimeout(() => {
+ setShowSuggestions(false)
+
+ if (!suggestionClicked.current && searchText.trim() && !value) {
+ setError('Project not found. Please select a valid project from the list.')
+ }
+
+ suggestionClicked.current = false
+ }, 150)
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const input = e.target.value
+ setSearchText(input)
+ setError(null)
+
+ if (value && input !== defaultName) {
+ onProjectChange(null, input)
+ }
+ }
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (blurTimeoutRef.current) {
+ clearTimeout(blurTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ return (
+
+
+
+
+ {showSuggestions && suggestions.length > 0 && (
+
+ {suggestions.map((project) => (
+
+ ))}
+
+ )}
+
+ {error &&
{error}
}
+
+ )
+}
diff --git a/frontend/src/components/ModuleList.tsx b/frontend/src/components/ModuleList.tsx
new file mode 100644
index 0000000000..b0668933e0
--- /dev/null
+++ b/frontend/src/components/ModuleList.tsx
@@ -0,0 +1,57 @@
+import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { Button } from '@heroui/button'
+import React, { useState } from 'react'
+
+interface ModuleListProps {
+ modules: string[]
+}
+
+const ModuleList: React.FC = ({ modules }) => {
+ const [showAll, setShowAll] = useState(false)
+
+ if (!modules || modules.length === 0) return null
+
+ const displayedModules = showAll ? modules : modules.slice(0, 5)
+
+ return (
+
+
+ {displayedModules.map((module, index) => {
+ const displayText = module.length > 50 ? `${module.slice(0, 50)}...` : module
+ return (
+
+ )
+ })}
+
+ {modules.length > 5 && (
+
+ )}
+
+
+ )
+}
+
+export default ModuleList
diff --git a/frontend/src/components/ProgramActions.tsx b/frontend/src/components/ProgramActions.tsx
new file mode 100644
index 0000000000..9ccf34324a
--- /dev/null
+++ b/frontend/src/components/ProgramActions.tsx
@@ -0,0 +1,92 @@
+'use client'
+
+import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { useRouter } from 'next/navigation'
+import type React from 'react'
+import { useState, useRef, useEffect } from 'react'
+
+interface ProgramActionsProps {
+ status: string
+ setStatus: (newStatus: 'DRAFT' | 'PUBLISHED' | 'COMPLETED') => void
+}
+
+const ProgramActions: React.FC = ({ status, setStatus }) => {
+ const router = useRouter()
+ const [dropdownOpen, setDropdownOpen] = useState(false)
+ const dropdownRef = useRef(null)
+
+ const handleAction = (actionKey: string) => {
+ switch (actionKey) {
+ case 'edit Program':
+ router.push(`${window.location.pathname}/edit`)
+ break
+ case 'create_module':
+ router.push(`${window.location.pathname}/modules/create`)
+ break
+ case 'publish':
+ setStatus('PUBLISHED')
+ break
+ case 'draft':
+ setStatus('DRAFT')
+ break
+ case 'completed':
+ setStatus('COMPLETED')
+ break
+ }
+ setDropdownOpen(false)
+ }
+
+ const options = [
+ { key: 'edit Program', label: 'Edit Program' },
+ { key: 'create_module', label: 'Add Module' },
+ ...(status === 'DRAFT' ? [{ key: 'publish', label: 'Publish Program' }] : []),
+ ...(status === 'PUBLISHED' || status === 'COMPLETED'
+ ? [{ key: 'draft', label: 'Move to Draft' }]
+ : []),
+ ...(status === 'PUBLISHED' ? [{ key: 'completed', label: 'Mark as Completed' }] : []),
+ ]
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setDropdownOpen(false)
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }, [])
+
+ return (
+
+
+ {dropdownOpen && (
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default ProgramActions
diff --git a/frontend/src/components/ProgramCard.tsx b/frontend/src/components/ProgramCard.tsx
new file mode 100644
index 0000000000..d6b335e3d6
--- /dev/null
+++ b/frontend/src/components/ProgramCard.tsx
@@ -0,0 +1,84 @@
+import { faEye } from '@fortawesome/free-regular-svg-icons'
+import { faEdit } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import type React from 'react'
+import { Program } from 'types/mentorship'
+import ActionButton from 'components/ActionButton'
+
+interface ProgramCardProps {
+ program: Program
+ onEdit?: (key: string) => void
+ onView: (key: string) => void
+ accessLevel: 'admin' | 'user'
+}
+
+const ProgramCard: React.FC = ({ program, onEdit, onView, accessLevel }) => {
+ const formatDate = (d: string) =>
+ new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+
+ const roleClass = {
+ admin: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
+ mentor: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
+ default: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
+ }
+
+ const description =
+ program.description?.length > 100
+ ? `${program.description.slice(0, 100)}...`
+ : program.description || 'No description available.'
+
+ return (
+
+
+
+
+
+ {program.name}
+
+ {accessLevel === 'admin' && (
+
+ {program.userRole}
+
+ )}
+
+
+ {program.startedAt && program.endedAt
+ ? `${formatDate(program.startedAt)} – ${formatDate(program.endedAt)}`
+ : program.startedAt
+ ? `Started: ${formatDate(program.startedAt)}`
+ : 'No dates set'}
+
+
{description}
+
+
+
+ {accessLevel === 'admin' ? (
+ <>
+
onView(program.key)}>
+
+ Preview
+
+
onEdit(program.key)}>
+
+ Edit
+
+ >
+ ) : (
+
onView(program.key)}>
+
+ View Details
+
+ )}
+
+
+
+ )
+}
+
+export default ProgramCard
diff --git a/frontend/src/components/ProgramForm.tsx b/frontend/src/components/ProgramForm.tsx
new file mode 100644
index 0000000000..d74bb703fa
--- /dev/null
+++ b/frontend/src/components/ProgramForm.tsx
@@ -0,0 +1,234 @@
+'use client'
+
+import type React from 'react'
+
+interface ProgramFormProps {
+ formData: {
+ name: string
+ description: string
+ menteesLimit: number
+ startedAt: string
+ endedAt: string
+ tags: string
+ domains: string
+ adminLogins?: string
+ status?: string
+ }
+ setFormData: React.Dispatch<
+ React.SetStateAction<{
+ name: string
+ description: string
+ menteesLimit: number
+ startedAt: string
+ endedAt: string
+ tags: string
+ domains: string
+ adminLogins?: string
+ status?: string
+ }>
+ >
+ onSubmit: (e: React.FormEvent) => void
+ loading: boolean
+ title: string
+ submitText?: string
+ isEdit?: boolean
+}
+
+const ProgramForm = ({
+ formData,
+ setFormData,
+ onSubmit,
+ loading,
+ title,
+ isEdit,
+ submitText = 'Save',
+}: ProgramFormProps) => {
+ const handleInputChange = (
+ e: React.ChangeEvent
+ ) => {
+ const { name, value } = e.target
+ setFormData((prev) => ({ ...prev, [name]: value }))
+ }
+
+ return (
+
+
+
{title}
+
+
+
+
+
+
+ )
+}
+
+export default ProgramForm
diff --git a/frontend/src/components/SearchPageLayout.tsx b/frontend/src/components/SearchPageLayout.tsx
index e64fbdcb7a..02d7842c83 100644
--- a/frontend/src/components/SearchPageLayout.tsx
+++ b/frontend/src/components/SearchPageLayout.tsx
@@ -12,7 +12,7 @@ interface SearchPageLayoutProps {
onPageChange: (page: number) => void
searchPlaceholder: string
- empty: string
+ empty?: string
indexName: string
loadingImageUrl?: string
children?: React.ReactNode
diff --git a/frontend/src/components/SingleModuleCard.tsx b/frontend/src/components/SingleModuleCard.tsx
new file mode 100644
index 0000000000..f38ee29ddc
--- /dev/null
+++ b/frontend/src/components/SingleModuleCard.tsx
@@ -0,0 +1,156 @@
+import { faUsers, faEllipsisV } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import upperFirst from 'lodash/upperFirst'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useSession } from 'next-auth/react'
+import React, { useState, useRef, useEffect } from 'react'
+import { ExtendedSession } from 'types/auth'
+import type { Module } from 'types/mentorship'
+import { formatDate } from 'utils/dateFormatter'
+import { getSimpleDuration } from 'components/ModuleCard'
+import TopContributorsList from 'components/TopContributorsList'
+
+interface SingleModuleCardProps {
+ module: Module
+ showEdit?: boolean
+ accessLevel?: string
+ admins?: {
+ login: string
+ }[]
+}
+
+const SingleModuleCard: React.FC = ({
+ module,
+ showEdit,
+ accessLevel,
+ admins,
+}) => {
+ const router = useRouter()
+ const { data } = useSession()
+ const [dropdownOpen, setDropdownOpen] = useState(false)
+ const dropdownRef = useRef(null)
+
+ const isAdmin =
+ accessLevel === 'admin' &&
+ admins?.some((admin) => admin.login === ((data as ExtendedSession)?.user?.login as string))
+
+ const handleView = () => {
+ setDropdownOpen(false)
+ router.push(`${window.location.pathname}/modules/${module.key}`)
+ }
+
+ const handleEdit = () => {
+ setDropdownOpen(false)
+ router.push(`${window.location.pathname}/modules/${module.key}/edit`)
+ }
+
+ const handleCreate = () => {
+ setDropdownOpen(false)
+ router.push(`${window.location.pathname}/modules/create`)
+ }
+
+ const moduleDetails = [
+ { label: 'Experience Level', value: upperFirst(module.experienceLevel) },
+ { label: 'Start Date', value: formatDate(module.startedAt) },
+ { label: 'End Date', value: formatDate(module.endedAt) },
+ { label: 'Duration', value: getSimpleDuration(module.startedAt, module.endedAt) },
+ ]
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setDropdownOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ return (
+
+
+
+
+
+
+ {module.name}
+
+
+
+
+
+
+
+ {dropdownOpen && (
+
+
+ {showEdit && isAdmin && (
+
+ )}
+ {isAdmin && (
+
+ )}
+
+ )}
+
+
+
+ {/* Description */}
+
+
+ {/* Details */}
+
+ {moduleDetails.map((detail) => (
+
+ {detail.label}: {detail.value || 'Unknown'}
+
+ ))}
+
+
+ {/* Mentors */}
+ {module.mentors?.length > 0 && (
+
+ )}
+
+ )
+}
+
+export default SingleModuleCard
diff --git a/frontend/src/components/ToggleableList.tsx b/frontend/src/components/ToggleableList.tsx
index caf7ce1e01..887a8444ad 100644
--- a/frontend/src/components/ToggleableList.tsx
+++ b/frontend/src/components/ToggleableList.tsx
@@ -10,11 +10,13 @@ const ToggleableList = ({
label,
icon,
limit = 10,
+ isDisabled = false,
}: {
items: string[]
label: React.ReactNode
limit?: number
icon?: IconDefinition
+ isDisabled?: boolean
}) => {
const [showAll, setShowAll] = useState(false)
const router = useRouter()
@@ -38,7 +40,7 @@ const ToggleableList = ({
diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx
index 7909ca8593..3bc5a9f985 100644
--- a/frontend/src/components/UserMenu.tsx
+++ b/frontend/src/components/UserMenu.tsx
@@ -8,17 +8,20 @@ import Image from 'next/image'
import Link from 'next/link'
import { signIn } from 'next-auth/react'
import { useEffect, useId, useRef, useState } from 'react'
+import { ExtendedSession } from 'types/auth'
export default function UserMenu({
isGitHubAuthEnabled,
}: {
readonly isGitHubAuthEnabled: boolean
}) {
- const { isSyncing, session } = useDjangoSession()
+ const { isSyncing, session, status } = useDjangoSession()
const { logout, isLoggingOut } = useLogout()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef(null)
const dropdownId = useId()
+ const isProjectLeader = (session as ExtendedSession)?.user?.isLeader
+ const isOwaspStaff = session?.user?.isOwaspStaff
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -30,9 +33,7 @@ export default function UserMenu({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
- if (!isGitHubAuthEnabled) {
- return null
- }
+ if (!isGitHubAuthEnabled) return null
if (isSyncing) {
return (
@@ -42,11 +43,11 @@ export default function UserMenu({
)
}
- if (!session) {
+ if (status === 'unauthenticated') {
return (
)}
diff --git a/frontend/src/hooks/useDjangoSession.ts b/frontend/src/hooks/useDjangoSession.ts
index 4e3d9f32b0..b61efd2709 100644
--- a/frontend/src/hooks/useDjangoSession.ts
+++ b/frontend/src/hooks/useDjangoSession.ts
@@ -7,7 +7,11 @@ import { ExtendedSession } from 'types/auth'
const SYNC_STATUS_KEY = 'django_session_synced'
-export const useDjangoSession: () => { isSyncing: boolean; session?: ExtendedSession } = () => {
+export const useDjangoSession: () => {
+ isSyncing: boolean
+ session?: ExtendedSession
+ status: string
+} = () => {
const { data: session, status, update } = useSession()
const [syncSession, { loading }] = useMutation(SYNC_DJANGO_SESSION_MUTATION)
const [isSyncing, setIsSyncing] = useState(false)
@@ -74,5 +78,6 @@ export const useDjangoSession: () => { isSyncing: boolean; session?: ExtendedSes
return {
isSyncing: loading || isSyncing || status === 'loading',
session: session as ExtendedSession,
+ status: status,
}
}
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index 185a8876cf..74397586ed 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -12,6 +12,6 @@ export default async function authenticationMiddleware(request: NextRequest) {
}
export const config = {
- // required authentication for all defined routes under matcher
- matcher: [],
+ //protected routes
+ matcher: ['/my/mentorship/:path*'],
}
diff --git a/frontend/src/server/apolloClient.ts b/frontend/src/server/apolloClient.ts
index e7c629ce86..647eecfad1 100644
--- a/frontend/src/server/apolloClient.ts
+++ b/frontend/src/server/apolloClient.ts
@@ -16,7 +16,6 @@ async function createApolloClient() {
},
}
})
-
const httpLink = createHttpLink({
credentials: 'same-origin',
uri: process.env.NEXT_SERVER_GRAPHQL_URL,
diff --git a/frontend/src/server/mutations/moduleMutations.ts b/frontend/src/server/mutations/moduleMutations.ts
new file mode 100644
index 0000000000..162494854c
--- /dev/null
+++ b/frontend/src/server/mutations/moduleMutations.ts
@@ -0,0 +1,45 @@
+import { gql } from '@apollo/client'
+
+export const UPDATE_MODULE = gql`
+ mutation UpdateModule($input: UpdateModuleInput!) {
+ updateModule(inputData: $input) {
+ id
+ key
+ name
+ description
+ experienceLevel
+ startedAt
+ endedAt
+ tags
+ domains
+ projectId
+ mentors {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
+
+export const CREATE_MODULE = gql`
+ mutation CreateModule($input: CreateModuleInput!) {
+ createModule(inputData: $input) {
+ id
+ key
+ name
+ description
+ experienceLevel
+ startedAt
+ endedAt
+ domains
+ tags
+ projectId
+ mentors {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
diff --git a/frontend/src/server/mutations/programsMutations.ts b/frontend/src/server/mutations/programsMutations.ts
new file mode 100644
index 0000000000..5c130fc994
--- /dev/null
+++ b/frontend/src/server/mutations/programsMutations.ts
@@ -0,0 +1,50 @@
+import { gql } from '@apollo/client'
+
+export const UPDATE_PROGRAM = gql`
+ mutation UpdateProgram($input: UpdateProgramInput!) {
+ updateProgram(inputData: $input) {
+ key
+ name
+ description
+ status
+ menteesLimit
+ startedAt
+ endedAt
+ tags
+ domains
+ admins {
+ login
+ }
+ }
+ }
+`
+
+export const CREATE_PROGRAM = gql`
+ mutation CreateProgram($input: CreateProgramInput!) {
+ createProgram(inputData: $input) {
+ id
+ key
+ name
+ description
+ menteesLimit
+ startedAt
+ endedAt
+ tags
+ domains
+ admins {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
+
+export const UPDATE_PROGRAM_STATUS_MUTATION = gql`
+ mutation updateProgramStatus($inputData: UpdateProgramStatusInput!) {
+ updateProgramStatus(inputData: $inputData) {
+ key
+ status
+ }
+ }
+`
diff --git a/frontend/src/server/queries/mentorshipQueries.ts b/frontend/src/server/queries/mentorshipQueries.ts
new file mode 100644
index 0000000000..cfb8c7d0e2
--- /dev/null
+++ b/frontend/src/server/queries/mentorshipQueries.ts
@@ -0,0 +1,13 @@
+import { gql } from '@apollo/client'
+
+export const IS_PROJECT_LEADER_QUERY = gql`
+ query IsProjectLeader($login: String!) {
+ isProjectLeader(login: $login)
+ }
+`
+
+export const IS_MENTOR_QUERY = gql`
+ query IsMentor($login: String!) {
+ isMentor(login: $login)
+ }
+`
diff --git a/frontend/src/server/queries/moduleQueries.ts b/frontend/src/server/queries/moduleQueries.ts
new file mode 100644
index 0000000000..9054675c46
--- /dev/null
+++ b/frontend/src/server/queries/moduleQueries.ts
@@ -0,0 +1,77 @@
+import { gql } from '@apollo/client'
+
+export const GET_MODULES_BY_PROGRAM = gql`
+ query ModulesByProgram($programKey: String!) {
+ getProgramModules(programKey: $programKey) {
+ id
+ key
+ name
+ description
+ experienceLevel
+ startedAt
+ endedAt
+ projectId
+ projectName
+ mentors {
+ id
+ githubUser {
+ id
+ login
+ avatarUrl
+ }
+ }
+ }
+ }
+`
+
+export const GET_MODULE_BY_ID = gql`
+ query GetModule($moduleKey: String!, $programKey: String!) {
+ getModule(moduleKey: $moduleKey, programKey: $programKey) {
+ id
+ key
+ name
+ description
+ tags
+ domains
+ experienceLevel
+ startedAt
+ endedAt
+ mentors {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
+
+export const GET_PROGRAM_ADMINS_AND_MODULES = gql`
+ query GetProgramAndModules($programKey: String!, $moduleKey: String!) {
+ getProgram(programKey: $programKey) {
+ id
+ admins {
+ login
+ name
+ avatarUrl
+ }
+ }
+ getModule(moduleKey: $moduleKey, programKey: $programKey) {
+ id
+ key
+ name
+ description
+ tags
+ projectId
+ projectName
+ domains
+ experienceLevel
+ startedAt
+ endedAt
+ mentors {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
diff --git a/frontend/src/server/queries/programsQueries.ts b/frontend/src/server/queries/programsQueries.ts
new file mode 100644
index 0000000000..bed70e3076
--- /dev/null
+++ b/frontend/src/server/queries/programsQueries.ts
@@ -0,0 +1,111 @@
+import { gql } from '@apollo/client'
+
+export const GET_PROGRAM_DATA = gql`
+ query GetPrograms($page: Int!, $search: String, $mentorUsername: String) {
+ allPrograms(page: $page, search: $search, mentorUsername: $mentorUsername) {
+ totalPages
+ currentPage
+ programs {
+ id
+ key
+ name
+ description
+ status
+ startedAt
+ endedAt
+ }
+ }
+ }
+`
+
+export const GET_MY_PROGRAMS = gql`
+ query GetMyPrograms($search: String, $page: Int, $limit: Int) {
+ myPrograms(search: $search, page: $page, limit: $limit) {
+ currentPage
+ totalPages
+ programs {
+ id
+ key
+ name
+ description
+ startedAt
+ endedAt
+ userRole
+ }
+ }
+ }
+`
+
+export const GET_PROGRAM_DETAILS = gql`
+ query GetProgramDetails($programKey: String!) {
+ getProgram(programKey: $programKey) {
+ id
+ key
+ name
+ description
+ status
+ menteesLimit
+ experienceLevels
+ startedAt
+ endedAt
+ domains
+ tags
+ admins {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
+export const GET_PROGRAM_AND_MODULES = gql`
+ query GetProgramAndModules($programKey: String!) {
+ getProgram(programKey: $programKey) {
+ id
+ key
+ name
+ description
+ status
+ menteesLimit
+ experienceLevels
+ startedAt
+ endedAt
+ domains
+ tags
+ admins {
+ login
+ name
+ avatarUrl
+ }
+ }
+ getProgramModules(programKey: $programKey) {
+ id
+ key
+ name
+ description
+ experienceLevel
+ startedAt
+ endedAt
+ mentors {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
+
+export const GET_PROGRAM_ADMIN_DETAILS = gql`
+ query GetProgramDetails($programKey: String!) {
+ getProgram(programKey: $programKey) {
+ id
+ key
+ name
+ admins {
+ login
+ name
+ avatarUrl
+ }
+ }
+ }
+`
diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts
index f21301c284..40653edce4 100644
--- a/frontend/src/server/queries/projectQueries.ts
+++ b/frontend/src/server/queries/projectQueries.ts
@@ -146,3 +146,12 @@ export const GET_TOP_CONTRIBUTORS = gql`
}
}
`
+
+export const SEARCH_PROJECTS = gql`
+ query SearchProjectNames($query: String!) {
+ searchProjects(query: $query) {
+ id
+ name
+ }
+ }
+`
diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts
index 1f930a6bde..55e9962dc8 100644
--- a/frontend/src/types/auth.ts
+++ b/frontend/src/types/auth.ts
@@ -1,15 +1,20 @@
+import type { Session } from 'next-auth'
+
export type ExtendedProfile = {
+ isLeader?: boolean
login: string
}
-export type ExtendedSession = {
+export type ExtendedSession = Session & {
accessToken?: string
- expires?: string
- user?: {
- email?: string
- image?: string
+ user?: Session['user'] & {
+ expires?: string
+ isLeader?: boolean
+ isMentor?: boolean
isOwaspStaff?: boolean
login?: string
name?: string
+ email?: string
+ image?: string
}
}
diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts
index e95150a892..9f37a9011e 100644
--- a/frontend/src/types/card.ts
+++ b/frontend/src/types/card.ts
@@ -7,6 +7,7 @@ import type { HealthMetricsProps } from 'types/healthMetrics'
import type { Icon } from 'types/icon'
import type { Issue } from 'types/issue'
import type { Level } from 'types/level'
+import type { Module } from 'types/mentorship'
import type { Milestone } from 'types/milestone'
import type { RepositoryCardProps } from 'types/project'
import type { PullRequest } from 'types/pullRequest'
@@ -19,9 +20,14 @@ export type CardProps = {
level?: Level
projectLink?: string
projectName?: string
+ modules?: string[]
social?: { title: string; icon: string; url: string }[]
summary: string
title: string
+ timeline?: {
+ start: string
+ end: string
+ }
tooltipLabel?: string
topContributors?: Contributor[]
url: string
@@ -34,20 +40,28 @@ type Stats = {
value: number
}
export interface DetailsCardProps {
+ accessLevel?: string
description?: string
details?: { label: string; value: string | JSX.Element }[]
+ domains?: string[]
entityKey?: string
geolocationData?: Chapter[]
healthMetricsData?: HealthMetricsProps[]
heatmap?: JSX.Element
isActive?: boolean
languages?: string[]
+ status?: string
+ setStatus?: (newStatus: string) => void
+ canUpdateStatus?: boolean
+ mentors?: Contributor[]
+ admins?: Contributor[]
projectName?: string
pullRequests?: PullRequest[]
recentIssues?: Issue[]
recentMilestones?: Milestone[]
recentReleases?: Release[]
repositories?: RepositoryCardProps[]
+ modules?: Module[]
showAvatar?: boolean
socialLinks?: string[]
stats?: Stats[]
@@ -55,6 +69,7 @@ export interface DetailsCardProps {
title?: string
topContributors?: Contributor[]
topics?: string[]
+ tags?: string[]
type: string
userSummary?: JSX.Element
}
diff --git a/frontend/src/types/contributor.ts b/frontend/src/types/contributor.ts
index 22e4d53a3d..62ce0c1f16 100644
--- a/frontend/src/types/contributor.ts
+++ b/frontend/src/types/contributor.ts
@@ -3,6 +3,6 @@ export type Contributor = {
contributionsCount?: number
login: string
name: string
- projectKey: string
+ projectKey?: string
projectName?: string
}
diff --git a/frontend/src/types/link.ts b/frontend/src/types/link.ts
index 2c646a508e..922ab9d896 100644
--- a/frontend/src/types/link.ts
+++ b/frontend/src/types/link.ts
@@ -3,4 +3,5 @@ export type Link = {
isSpan?: boolean
submenu?: Link[]
text: string
+ requiresGitHubAuth?: boolean
}
diff --git a/frontend/src/types/mentorship.ts b/frontend/src/types/mentorship.ts
new file mode 100644
index 0000000000..7afecf2465
--- /dev/null
+++ b/frontend/src/types/mentorship.ts
@@ -0,0 +1,77 @@
+import type { Contributor } from 'types/contributor'
+export enum ExperienceLevelEnum {
+ BEGINNER = 'beginner',
+ INTERMEDIATE = 'intermediate',
+ ADVANCED = 'advanced',
+ EXPERT = 'expert',
+}
+
+export enum ProgramStatusEnum {
+ DRAFT = 'draft',
+ PUBLISHED = 'published',
+ COMPLETED = 'completed',
+}
+
+export const EXPERIENCE_LEVELS = {
+ BEGINNER: 'BEGINNER',
+ INTERMEDIATE: 'INTERMEDIATE',
+ ADVANCED: 'ADVANCED',
+}
+
+// Main Program type
+export type Program = {
+ id: string
+ key: string
+ name: string
+ description: string
+ status: ProgramStatusEnum
+ experienceLevels?: ExperienceLevelEnum[]
+ menteesLimit?: number
+ startedAt: string
+ endedAt: string
+ domains?: string[]
+ tags?: string[]
+ userRole?: string
+ admins?: Contributor[]
+ modules?: Module[]
+}
+
+export type ProgramList = {
+ objectId: string
+ name: string
+ description: string
+ experienceLevels: string[]
+ status: string
+ key: string
+ admins: { name: string; login: string }[]
+ startedAt: string
+ endedAt: string
+ modules: string[]
+}
+
+export type Module = {
+ id: string
+ key: string
+ name: string
+ description: string
+ status: ProgramStatusEnum
+ experienceLevel: ExperienceLevelEnum
+ mentors: Contributor[]
+ startedAt: string
+ endedAt: string
+ domains: string[]
+ tags: string[]
+}
+
+export type ModuleFormData = {
+ name: string
+ description: string
+ experienceLevel: string
+ startedAt: string
+ endedAt: string
+ domains: string
+ tags: string
+ projectName: string
+ projectId: string
+ mentorLogins: string
+}
diff --git a/frontend/src/utils/capitalize.ts b/frontend/src/utils/capitalize.ts
new file mode 100644
index 0000000000..1aa9ba6589
--- /dev/null
+++ b/frontend/src/utils/capitalize.ts
@@ -0,0 +1,4 @@
+export function titleCaseWord(value: string): string {
+ if (!value) return value
+ return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
+}
diff --git a/frontend/src/utils/dateFormatter.ts b/frontend/src/utils/dateFormatter.ts
index 4688e575ba..26b6e0a170 100644
--- a/frontend/src/utils/dateFormatter.ts
+++ b/frontend/src/utils/dateFormatter.ts
@@ -59,3 +59,12 @@ export const formatDateRange = (startDate: number | string, endDate: number | st
return `${formatDate(startDate)} — ${formatDate(endDate)}`
}
}
+
+export const formatDateForInput = (dateStr: string) => {
+ if (!dateStr) return ''
+ const date = new Date(dateStr)
+ if (isNaN(date.getTime())) {
+ throw new Error('Invalid date')
+ }
+ return date.toISOString().slice(0, 10)
+}
diff --git a/frontend/src/utils/helpers/apolloClient.ts b/frontend/src/utils/helpers/apolloClient.ts
index 0df510e49a..d35b0231e1 100644
--- a/frontend/src/utils/helpers/apolloClient.ts
+++ b/frontend/src/utils/helpers/apolloClient.ts
@@ -3,7 +3,6 @@ import { setContext } from '@apollo/client/link/context'
import { AppError, handleAppError } from 'app/global-error'
import { GRAPHQL_URL } from 'utils/credentials'
import { getCsrfToken } from 'utils/utility'
-
const createApolloClient = () => {
if (!GRAPHQL_URL) {
const error = new AppError(500, 'Missing GraphQL URL')
diff --git a/frontend/src/utils/parser.ts b/frontend/src/utils/parser.ts
new file mode 100644
index 0000000000..1dfddbfd3b
--- /dev/null
+++ b/frontend/src/utils/parser.ts
@@ -0,0 +1,6 @@
+export function parseCommaSeparated(value?: string): string[] {
+ return (value || '')
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+}