From 75500bacd2460c80097778cd803c761e5e7888f0 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 19 Mar 2025 13:40:48 +0100 Subject: [PATCH 01/11] feat: multi-contributors backend code --- apps/api/src/db/courses/courses.py | 3 +- apps/api/src/db/resource_authors.py | 7 + apps/api/src/routers/courses/courses.py | 58 ++++++ apps/api/src/security/rbac/rbac.py | 2 + apps/api/src/services/courses/contributors.py | 176 ++++++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/services/courses/contributors.py diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py index bbc97af6..122526df 100644 --- a/apps/api/src/db/courses/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -14,6 +14,7 @@ class CourseBase(SQLModel): tags: Optional[str] thumbnail_image: Optional[str] public: bool + open_to_contributors: bool class Course(CourseBase, table=True): @@ -38,7 +39,7 @@ class CourseUpdate(CourseBase): learnings: Optional[str] tags: Optional[str] public: Optional[bool] - + open_to_contributors: Optional[bool] class CourseRead(CourseBase): id: int diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py index 3f938397..51dc3e8b 100644 --- a/apps/api/src/db/resource_authors.py +++ b/apps/api/src/db/resource_authors.py @@ -6,9 +6,15 @@ from sqlmodel import Field, SQLModel class ResourceAuthorshipEnum(str, Enum): CREATOR = "CREATOR" + CONTRIBUTOR = "CONTRIBUTOR" MAINTAINER = "MAINTAINER" REPORTER = "REPORTER" +class ResourceAuthorshipStatusEnum(str, Enum): + ACTIVE = "ACTIVE" + PENDING = "PENDING" + INACTIVE = "INACTIVE" + class ResourceAuthor(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -17,5 +23,6 @@ class ResourceAuthor(SQLModel, table=True): sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) ) authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR + authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE creation_date: str = "" update_date: str = "" diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 700ae7f6..6867b783 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -32,6 +32,12 @@ from src.services.courses.updates import ( get_updates_by_course_uuid, update_update, ) +from src.services.courses.contributors import ( + apply_course_contributor, + update_course_contributor, + get_course_contributors, +) +from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum router = APIRouter() @@ -63,6 +69,7 @@ async def api_create_course( about=about, learnings=learnings, tags=tags, + open_to_contributors=False, ) return await create_course( request, org_id, course, current_user, db_session, thumbnail @@ -195,6 +202,19 @@ async def api_delete_course( return await delete_course(request, course_uuid, current_user, db_session) +@router.post("/{course_uuid}/apply-contributor") +async def api_apply_course_contributor( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Apply to be a contributor for a course + """ + return await apply_course_contributor(request, course_uuid, current_user, db_session) + + @router.get("/{course_uuid}/updates") async def api_get_course_updates( request: Request, @@ -259,3 +279,41 @@ async def api_delete_course_update( """ return await delete_update(request, courseupdate_uuid, current_user, db_session) + + +@router.get("/{course_uuid}/contributors") +async def api_get_course_contributors( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Get all contributors for a course + """ + return await get_course_contributors(request, course_uuid, current_user, db_session) + + +@router.put("/{course_uuid}/contributors/{contributor_id}") +async def api_update_course_contributor( + request: Request, + course_uuid: str, + contributor_user_id: int, + authorship: ResourceAuthorshipEnum, + authorship_status: ResourceAuthorshipStatusEnum, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Update a course contributor's role and status + Only administrators can perform this action + """ + return await update_course_contributor( + request, + course_uuid, + contributor_user_id, + authorship, + authorship_status, + current_user, + db_session + ) diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 66a2dc89..1e74fe75 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -70,6 +70,8 @@ async def authorization_verify_if_user_is_author( if resource_author.user_id == int(user_id): if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER + ) or ( + resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR ): return True else: diff --git a/apps/api/src/services/courses/contributors.py b/apps/api/src/services/courses/contributors.py new file mode 100644 index 00000000..750c2fd8 --- /dev/null +++ b/apps/api/src/services/courses/contributors.py @@ -0,0 +1,176 @@ +from datetime import datetime +from fastapi import HTTPException, Request, status +from sqlmodel import Session, select, and_ +from src.db.users import PublicUser, AnonymousUser, User, UserRead +from src.db.courses.courses import Course +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum +from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_roles_and_authorship +from typing import List + + +async def apply_course_contributor( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Verify user is not anonymous + await authorization_verify_if_user_is_anon(current_user.id) + + # Check if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Check if user already has any authorship role for this course + existing_authorship = db_session.exec( + select(ResourceAuthor).where( + and_( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == current_user.id + ) + ) + ).first() + + if existing_authorship: + raise HTTPException( + status_code=400, + detail="You already have an authorship role for this course", + ) + + # Create pending contributor application + resource_author = ResourceAuthor( + resource_uuid=course_uuid, + user_id=current_user.id, + authorship=ResourceAuthorshipEnum.CONTRIBUTOR, + authorship_status=ResourceAuthorshipStatusEnum.PENDING, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(resource_author) + db_session.commit() + db_session.refresh(resource_author) + + return { + "detail": "Contributor application submitted successfully", + "status": "pending" + } + +async def update_course_contributor( + request: Request, + course_uuid: str, + contributor_user_id: int, + authorship: ResourceAuthorshipEnum, + authorship_status: ResourceAuthorshipStatusEnum, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + """ + Update a course contributor's role and status + Only administrators can perform this action + """ + # Verify user is not anonymous + await authorization_verify_if_user_is_anon(current_user.id) + + # RBAC check - verify if user has admin rights + authorized = await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, "update", course_uuid, db_session + ) + + if not authorized: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not authorized to update course contributors", + ) + + # Check if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Check if the contributor exists for this course + existing_authorship = db_session.exec( + select(ResourceAuthor).where( + and_( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == contributor_user_id + ) + ) + ).first() + + if not existing_authorship: + raise HTTPException( + status_code=404, + detail="Contributor not found for this course", + ) + + # Don't allow changing the role of the creator + if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: + raise HTTPException( + status_code=400, + detail="Cannot modify the role of the course creator", + ) + + # Update the contributor's role and status + existing_authorship.authorship = authorship + existing_authorship.authorship_status = authorship_status + existing_authorship.update_date = str(datetime.now()) + + db_session.add(existing_authorship) + db_session.commit() + db_session.refresh(existing_authorship) + + return { + "detail": "Contributor updated successfully", + "status": "success" + } + +async def get_course_contributors( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> List[dict]: + """ + Get all contributors for a course with their user information + """ + # Check if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Get all contributors for this course with user information + statement = ( + select(ResourceAuthor, User) + .join(User) # SQLModel will automatically join on foreign key + .where(ResourceAuthor.resource_uuid == course_uuid) + ) + results = db_session.exec(statement).all() + + return [ + { + "user_id": contributor.user_id, + "authorship": contributor.authorship, + "authorship_status": contributor.authorship_status, + "creation_date": contributor.creation_date, + "update_date": contributor.update_date, + "user": UserRead.model_validate(user).model_dump() + } + for contributor, user in results + ] \ No newline at end of file From 5f302106a9958450846dd4de2cb68644c297ac4c Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 20 Mar 2025 11:15:30 +0100 Subject: [PATCH 02/11] feat: add migration alembic script --- .../4a88b680263c_multi_contributors.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/api/migrations/versions/4a88b680263c_multi_contributors.py diff --git a/apps/api/migrations/versions/4a88b680263c_multi_contributors.py b/apps/api/migrations/versions/4a88b680263c_multi_contributors.py new file mode 100644 index 00000000..4bfa6ec3 --- /dev/null +++ b/apps/api/migrations/versions/4a88b680263c_multi_contributors.py @@ -0,0 +1,48 @@ +"""Multi-contributors + +Revision ID: 4a88b680263c +Revises: 87a621284ae4 +Create Date: 2025-03-20 11:05:24.951129 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 +from alembic_postgresql_enum import TableReference +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '4a88b680263c' +down_revision: Union[str, None] = '87a621284ae4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('course', sa.Column('open_to_contributors', sa.Boolean(), nullable=False, default=False, server_default='false')) + op.add_column('resourceauthor', sa.Column('authorship_status', postgresql.ENUM('ACTIVE', 'PENDING', 'INACTIVE', name='resourceauthorshipstatusenum', create_type=False), nullable=False, default='INACTIVE', server_default='INACTIVE')) + op.sync_enum_values( + enum_schema='public', + enum_name='resourceauthorshipenum', + new_values=['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'], + affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema='public', + enum_name='resourceauthorshipenum', + new_values=['CREATOR', 'MAINTAINER', 'REPORTER'], + affected_columns=[TableReference(table_schema='public', table_name='resourceauthor', column_name='authorship')], + enum_values_to_rename=[], + ) + op.drop_column('resourceauthor', 'authorship_status') + op.drop_column('course', 'open_to_contributors') + # ### end Alembic commands ### From 4ab8f52b0990a434a24e8c87f276a608ed29bddb Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 20 Mar 2025 13:38:10 +0100 Subject: [PATCH 03/11] feat: implement author roles in course management --- apps/api/src/db/courses/courses.py | 16 +- apps/api/src/db/resource_authors.py | 4 +- apps/api/src/routers/courses/courses.py | 2 +- apps/api/src/services/courses/courses.py | 196 ++++++++--- apps/api/src/services/explore/explore.py | 26 +- .../src/services/payments/payments_users.py | 27 +- .../course/[courseuuid]/[subpage]/page.tsx | 63 ++-- .../EditCourseContributors.tsx | 319 ++++++++++++++++++ .../Courses/CourseActions/CoursesActions.tsx | 37 +- apps/web/components/Objects/UserAvatar.tsx | 2 + apps/web/services/courses/courses.ts | 27 ++ 11 files changed, 623 insertions(+), 96 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx diff --git a/apps/api/src/db/courses/courses.py b/apps/api/src/db/courses/courses.py index 122526df..b50047a4 100644 --- a/apps/api/src/db/courses/courses.py +++ b/apps/api/src/db/courses/courses.py @@ -4,6 +4,15 @@ from sqlmodel import Field, SQLModel from src.db.users import UserRead from src.db.trails import TrailRead from src.db.courses.chapters import ChapterRead +from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum + + +class AuthorWithRole(SQLModel): + user: UserRead + authorship: ResourceAuthorshipEnum + authorship_status: ResourceAuthorshipStatusEnum + creation_date: str + update_date: str class CourseBase(SQLModel): @@ -41,10 +50,11 @@ class CourseUpdate(CourseBase): public: Optional[bool] open_to_contributors: Optional[bool] + class CourseRead(CourseBase): id: int org_id: int = Field(default=None, foreign_key="organization.id") - authors: Optional[List[UserRead]] + authors: List[AuthorWithRole] course_uuid: str creation_date: str update_date: str @@ -58,7 +68,7 @@ class FullCourseRead(CourseBase): update_date: Optional[str] # Chapters, Activities chapters: List[ChapterRead] - authors: List[UserRead] + authors: List[AuthorWithRole] pass @@ -68,7 +78,7 @@ class FullCourseReadWithTrail(CourseBase): creation_date: Optional[str] update_date: Optional[str] org_id: int = Field(default=None, foreign_key="organization.id") - authors: List[UserRead] + authors: List[AuthorWithRole] # Chapters, Activities chapters: List[ChapterRead] # Trail diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py index 51dc3e8b..9afa7e5a 100644 --- a/apps/api/src/db/resource_authors.py +++ b/apps/api/src/db/resource_authors.py @@ -22,7 +22,7 @@ class ResourceAuthor(SQLModel, table=True): user_id: int = Field( sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) ) - authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR - authorship_status: ResourceAuthorshipStatusEnum = ResourceAuthorshipStatusEnum.ACTIVE + authorship: ResourceAuthorshipEnum + authorship_status: ResourceAuthorshipStatusEnum creation_date: str = "" update_date: str = "" diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 6867b783..19042524 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -294,7 +294,7 @@ async def api_get_course_contributors( return await get_course_contributors(request, course_uuid, current_user, db_session) -@router.put("/{course_uuid}/contributors/{contributor_id}") +@router.put("/{course_uuid}/contributors/{contributor_user_id}") async def api_update_course_contributor( request: Request, course_uuid: str, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index afbd53c9..805997b5 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -10,7 +10,7 @@ from src.security.features_utils.usage import ( increase_feature_usage, ) from src.services.trail.trail import get_user_trail_with_orgid -from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.courses.courses import ( Course, @@ -18,6 +18,7 @@ from src.db.courses.courses import ( CourseRead, CourseUpdate, FullCourseReadWithTrail, + AuthorWithRole, ) from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, @@ -48,16 +49,28 @@ async def get_course( # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -82,16 +95,28 @@ async def get_course_by_id( # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -123,12 +148,15 @@ async def get_course_meta( # Start async tasks concurrently tasks = [] - # Task 1: Get course authors + # Task 1: Get course authors with their roles async def get_authors(): authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) return db_session.exec(authors_statement).all() @@ -153,10 +181,19 @@ async def get_course_meta( tasks.append(get_trail()) # Run all tasks concurrently - authors_raw, chapters, trail = await asyncio.gather(*tasks) + author_results, chapters, trail = await asyncio.gather(*tasks) - # Convert authors from User to UserRead - authors = [UserRead.model_validate(author) for author in authors_raw] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Create course read model course_read = CourseRead(**course.model_dump(), authors=authors) @@ -167,6 +204,7 @@ async def get_course_meta( trail=trail, ) + async def get_courses_orgslug( request: Request, current_user: PublicUser | AnonymousUser, @@ -225,6 +263,9 @@ async def get_courses_orgslug( select(ResourceAuthor, User) .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore + .order_by( + ResourceAuthor.id.asc() + ) ) author_results = db_session.exec(authors_query).all() @@ -234,13 +275,23 @@ async def get_courses_orgslug( for resource_author, user in author_results: if resource_author.resource_uuid not in course_authors: course_authors[resource_author.resource_uuid] = [] - course_authors[resource_author.resource_uuid].append(UserRead.model_validate(user)) + course_authors[resource_author.resource_uuid].append( + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + ) # Create CourseRead objects with authors course_reads = [] for course in courses: - course_read = CourseRead.model_validate(course) - course_read.authors = course_authors.get(course.course_uuid, []) + course_read = CourseRead( + **course.model_dump(), + authors=course_authors.get(course.course_uuid, []) + ) course_reads.append(course_read) return course_reads @@ -306,15 +357,31 @@ async def search_courses( # Fetch authors for each course course_reads = [] for course in courses: - authors_query = ( - select(User) - .join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore + # Get course authors with their roles + authors_statement = ( + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_query).all() + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course_read = CourseRead.model_validate(course) - course_read.authors = [UserRead.model_validate(author) for author in authors] + course_read.authors = authors course_reads.append(course_read) return course_reads @@ -368,6 +435,7 @@ async def create_course( resource_uuid=course.course_uuid, user_id=current_user.id, authorship=ResourceAuthorshipEnum.CREATOR, + authorship_status=ResourceAuthorshipStatusEnum.ACTIVE, creation_date=str(datetime.now()), update_date=str(datetime.now()), ) @@ -377,20 +445,32 @@ async def create_course( db_session.commit() db_session.refresh(resource_author) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Feature usage increase_feature_usage("courses", course.org_id, db_session) - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] - course = CourseRead(**course.model_dump(), authors=authors) return CourseRead.model_validate(course) @@ -444,16 +524,28 @@ async def update_course_thumbnail( db_session.commit() db_session.refresh(course) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) @@ -491,16 +583,28 @@ async def update_course( db_session.commit() db_session.refresh(course) - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) + .order_by( + ResourceAuthor.id.asc() + ) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # convert from User to UserRead - authors = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] course = CourseRead(**course.model_dump(), authors=authors) diff --git a/apps/api/src/services/explore/explore.py b/apps/api/src/services/explore/explore.py index 9cc866e5..6ae2ed95 100644 --- a/apps/api/src/services/explore/explore.py +++ b/apps/api/src/services/explore/explore.py @@ -3,8 +3,10 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select from sqlalchemy import text -from src.db.courses.courses import Course, CourseRead +from src.db.courses.courses import Course, CourseRead, AuthorWithRole from src.db.organizations import Organization, OrganizationRead +from src.db.users import User, UserRead +from src.db.resource_authors import ResourceAuthor def _get_sort_expression(salt: str): @@ -96,7 +98,27 @@ async def get_course_for_explore( detail="Course not found", ) - return CourseRead.model_validate(course) + # Get course authors with their roles + authors_statement = ( + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + author_results = db_session.exec(authors_statement).all() + + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] + + return CourseRead(**course.model_dump(), authors=authors) async def search_orgs_for_explore( request: Request, diff --git a/apps/api/src/services/payments/payments_users.py b/apps/api/src/services/payments/payments_users.py index 16af55a6..506070f2 100644 --- a/apps/api/src/services/payments/payments_users.py +++ b/apps/api/src/services/payments/payments_users.py @@ -1,7 +1,7 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select -from typing import Any -from src.db.courses.courses import Course, CourseRead +from typing import Any, List +from src.db.courses.courses import Course, CourseRead, AuthorWithRole from src.db.payments.payments_courses import PaymentsCourse from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData from src.db.payments.payments_products import PaymentsProduct @@ -231,19 +231,28 @@ async def get_owned_courses( # Get authors for each course and convert to CourseRead course_reads = [] for course in unique_courses: - # Get course authors + # Get course authors with their roles authors_statement = ( - select(User) - .join(ResourceAuthor) + select(ResourceAuthor, User) + .join(User, ResourceAuthor.user_id == User.id) .where(ResourceAuthor.resource_uuid == course.course_uuid) ) - authors = db_session.exec(authors_statement).all() + author_results = db_session.exec(authors_statement).all() - # Convert authors to UserRead - author_reads = [UserRead.model_validate(author) for author in authors] + # Convert to AuthorWithRole objects + authors = [ + AuthorWithRole( + user=UserRead.model_validate(user), + authorship=resource_author.authorship, + authorship_status=resource_author.authorship_status, + creation_date=resource_author.creation_date, + update_date=resource_author.update_date + ) + for resource_author, user in author_results + ] # Create CourseRead object - course_read = CourseRead(**course.model_dump(), authors=author_reads) + course_read = CourseRead(**course.model_dump(), authors=authors) course_reads.append(course_read) return course_reads diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 9c27ff92..a095945a 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -5,11 +5,11 @@ import { CourseProvider } from '../../../../../../../../components/Contexts/Cour import Link from 'next/link' import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop' import { motion } from 'framer-motion' -import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react' +import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users } from 'lucide-react' import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure' import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess' - +import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors' export type CourseOverviewParams = { orgslug: string courseuuid: string @@ -26,7 +26,7 @@ function CourseOverviewPage(props: { params: Promise }) { return (
-
+
}) {
- -
-
- -
Access
-
-
- + }) {
+ +
+
+ +
Access
+
+
+ + +
+
+ +
Contributors
+
+
+ @@ -96,6 +115,8 @@ function CourseOverviewPage(props: { params: Promise }) { {params.subpage == 'content' ? () : ('')} {params.subpage == 'general' ? () : ('')} {params.subpage == 'access' ? () : ('')} + {params.subpage == 'contributors' ? () : ('')} + diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx new file mode 100644 index 00000000..5a62ef5f --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx @@ -0,0 +1,319 @@ +import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import { getAPIUrl } from '@services/config/config' +import { editContributor, getCourseContributors } from '@services/courses/courses' +import { swrFetcher } from '@services/utils/ts/requests' +import { Check, ChevronDown, UserPen, Users } from 'lucide-react' +import React, { useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import useSWR, { mutate } from 'swr' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import UserAvatar from '@components/Objects/UserAvatar' + +type EditCourseContributorsProps = { + orgslug: string + course_uuid?: string +} + +type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' +type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' + +interface Contributor { + id: string; + user_id: string; + authorship: ContributorRole; + authorship_status: ContributorStatus; + user: { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + } +} + +function EditCourseContributors(props: EditCourseContributorsProps) { + const session = useLHSession() as any; + const access_token = session?.data?.tokens?.access_token; + const course = useCourse() as any; + const { isLoading, courseStructure } = course as any; + const dispatchCourse = useCourseDispatch() as any; + + const { data: contributors } = useSWR( + courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null, + (url: string) => swrFetcher(url, access_token) + ); + + const [isOpenToContributors, setIsOpenToContributors] = useState(undefined); + + useEffect(() => { + if (!isLoading && courseStructure?.open_to_contributors !== undefined) { + setIsOpenToContributors(courseStructure.open_to_contributors); + } + }, [isLoading, courseStructure]); + + useEffect(() => { + if (!isLoading && courseStructure?.open_to_contributors !== undefined && isOpenToContributors !== undefined) { + if (isOpenToContributors !== courseStructure.open_to_contributors) { + dispatchCourse({ type: 'setIsNotSaved' }); + const updatedCourse = { + ...courseStructure, + open_to_contributors: isOpenToContributors, + }; + dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse }); + } + } + }, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]); + + const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => { + try { + // Find the current contributor to get their current values + const currentContributor = contributors?.find(c => c.user_id === contributorId); + if (!currentContributor) return; + + // Don't allow editing if the user is a CREATOR + if (currentContributor.authorship === 'CREATOR') { + toast.error('Cannot modify a creator\'s role or status'); + return; + } + + // Always send both values in the request + const updatedData = { + authorship: data.authorship || currentContributor.authorship, + authorship_status: data.authorship_status || currentContributor.authorship_status + }; + + const res = await editContributor(courseStructure.course_uuid, contributorId, updatedData.authorship, updatedData.authorship_status, access_token); + if (res.status === 200 && res.data?.status === 'success') { + toast.success(res.data.detail || 'Successfully updated contributor'); + mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`); + } else { + toast.error(`Error: ${res.data?.detail || 'Failed to update contributor'}`); + } + } catch (error) { + toast.error('An error occurred while updating the contributor.'); + } + }; + + const RoleDropdown = ({ contributor }: { contributor: Contributor }) => ( + + + + + + {['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => ( + updateContributor(contributor.user_id, { authorship: role as ContributorRole })} + className="justify-between" + > + {role} + {contributor.authorship === role && } + + ))} + + + ); + + const StatusDropdown = ({ contributor }: { contributor: Contributor }) => ( + + + + + + {['ACTIVE', 'INACTIVE', 'PENDING'].map((status) => ( + updateContributor(contributor.user_id, { authorship_status: status as ContributorStatus })} + className="justify-between" + > + {status} + {contributor.authorship_status === status && } + + ))} + + + ); + + const getStatusStyle = (status: ContributorStatus) => { + switch (status) { + case 'ACTIVE': + return 'bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800'; + case 'INACTIVE': + return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800'; + case 'PENDING': + return 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800'; + default: + return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800'; + } + }; + + const sortContributors = (contributors: Contributor[] | undefined) => { + if (!contributors) return []; + + return [...contributors].sort((a, b) => { + // First sort by role priority + const rolePriority: Record = { + 'CREATOR': 0, + 'MAINTAINER': 1, + 'CONTRIBUTOR': 2, + 'REPORTER': 3 + }; + + const roleDiff = rolePriority[a.authorship] - rolePriority[b.authorship]; + if (roleDiff !== 0) return roleDiff; + + // Then sort by name + const nameA = `${a.user.first_name} ${a.user.last_name}`.toLowerCase(); + const nameB = `${b.user.first_name} ${b.user.last_name}`.toLowerCase(); + return nameA.localeCompare(nameB); + }); + }; + + return ( +
+ {courseStructure && ( +
+
+
+
+

Course Contributors

+

+ Choose if you want your course to be open for contributors and manage existing contributors +

+
+
+ + {isOpenToContributors && ( +
+ Active +
+ )} +
+ +
+ Open to Contributors +
+
+ The course is open for contributors. Users can apply to become contributors and help improve the course content. +
+
+
+ } + functionToExecute={() => setIsOpenToContributors(true)} + status="info" + /> + + {!isOpenToContributors && ( +
+ Active +
+ )} +
+ +
+ Closed to Contributors +
+
+ The course is closed for contributors. Only existing contributors can modify the course content. +
+
+
+ } + functionToExecute={() => setIsOpenToContributors(false)} + status="info" + /> +
+
+

Current Contributors

+

+ Manage the current contributors of this course +

+
+
+ + + + + Name + Email + Role + Status + + + + {sortContributors(contributors)?.map((contributor) => ( + + + + + + {contributor.user.first_name} {contributor.user.last_name} + + + {contributor.user.email} + + + + + + + + + ))} + +
+
+
+ + )} + + ); +} + +export default EditCourseContributors; \ No newline at end of file diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index 03e69c88..b81b0472 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -14,11 +14,14 @@ import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' interface Author { - user_uuid: string - avatar_image: string - first_name: string - last_name: string - username: string + user: { + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string + } + authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' } interface CourseRun { @@ -55,23 +58,23 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
Author
- {(author.first_name && author.last_name) ? ( + {(author.user.first_name && author.user.last_name) ? (
-

{`${author.first_name} ${author.last_name}`}

+

{`${author.user.first_name} ${author.user.last_name}`}

- @{author.username} + @{author.user.username}
) : (
-

@{author.username}

+

@{author.user.username}

)}
@@ -270,10 +273,20 @@ function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) { const session = useLHSession() as any const isMobile = useMediaQuery('(max-width: 768px)') + // Sort authors by role priority + const sortedAuthors = [...course.authors].sort((a, b) => { + const rolePriority: Record = { + 'CREATOR': 0, + 'MAINTAINER': 1, + 'CONTRIBUTOR': 2, + 'REPORTER': 3 + }; + return rolePriority[a.authorship] - rolePriority[b.authorship]; + }); return (
- +
diff --git a/apps/web/components/Objects/UserAvatar.tsx b/apps/web/components/Objects/UserAvatar.tsx index ff62c3de..170639d2 100644 --- a/apps/web/components/Objects/UserAvatar.tsx +++ b/apps/web/components/Objects/UserAvatar.tsx @@ -12,6 +12,7 @@ type UserAvatarProps = { border?: 'border-2' | 'border-4' | 'border-8' borderColor?: string predefined_avatar?: 'ai' | 'empty' + backgroundColor?: 'bg-white' | 'bg-gray-100' } function UserAvatar(props: UserAvatarProps) { @@ -78,6 +79,7 @@ function UserAvatar(props: UserAvatarProps) { ${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'} ${props.border ? `border ${props.border}` : ''} ${props.borderColor ?? 'border-white'} + ${props.backgroundColor ?? 'bg-gray-100'} shadow-xl aspect-square w-[${props.width ?? 50}px] diff --git a/apps/web/services/courses/courses.ts b/apps/web/services/courses/courses.ts index 562a219e..ec81f13b 100644 --- a/apps/web/services/courses/courses.ts +++ b/apps/web/services/courses/courses.ts @@ -126,3 +126,30 @@ export async function deleteCourseFromBackend(course_uuid: any, access_token:any const res = await errorHandling(result) return res } + +export async function getCourseContributors(course_uuid: string, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/contributors`, + RequestBodyWithAuthHeader('GET', null, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function editContributor(course_uuid: string, contributor_id: string, authorship: any, authorship_status: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/contributors/${contributor_id}?authorship=${authorship}&authorship_status=${authorship_status}`, + RequestBodyWithAuthHeader('PUT', null, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function applyForContributor(course_uuid: string, data: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/apply-contributor`, + RequestBodyWithAuthHeader('POST', data, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} From 4e7a06b74eeca5ccb4e1cbc29756491131086944 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 22 Mar 2025 13:10:26 +0100 Subject: [PATCH 04/11] fix: ordering issues --- apps/api/src/services/courses/courses.py | 4 +-- .../src/services/payments/payments_users.py | 2 +- .../EditCourseContributors.tsx | 25 ++++++------------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 805997b5..f7405e3d 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -152,10 +152,10 @@ async def get_course_meta( async def get_authors(): authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) return db_session.exec(authors_statement).all() diff --git a/apps/api/src/services/payments/payments_users.py b/apps/api/src/services/payments/payments_users.py index 506070f2..4791e766 100644 --- a/apps/api/src/services/payments/payments_users.py +++ b/apps/api/src/services/payments/payments_users.py @@ -1,6 +1,6 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select -from typing import Any, List +from typing import Any from src.db.courses.courses import Course, CourseRead, AuthorWithRole from src.db.payments.payments_courses import PaymentsCourse from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx index 5a62ef5f..2327492e 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx @@ -123,7 +123,7 @@ function EditCourseContributors(props: EditCourseContributorsProps) { - {['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => ( + {['CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => ( updateContributor(contributor.user_id, { authorship: role as ContributorRole })} @@ -180,23 +180,12 @@ function EditCourseContributors(props: EditCourseContributorsProps) { const sortContributors = (contributors: Contributor[] | undefined) => { if (!contributors) return []; - return [...contributors].sort((a, b) => { - // First sort by role priority - const rolePriority: Record = { - 'CREATOR': 0, - 'MAINTAINER': 1, - 'CONTRIBUTOR': 2, - 'REPORTER': 3 - }; - - const roleDiff = rolePriority[a.authorship] - rolePriority[b.authorship]; - if (roleDiff !== 0) return roleDiff; - - // Then sort by name - const nameA = `${a.user.first_name} ${a.user.last_name}`.toLowerCase(); - const nameB = `${b.user.first_name} ${b.user.last_name}`.toLowerCase(); - return nameA.localeCompare(nameB); - }); + // Find the creator and other contributors + const creator = contributors.find(c => c.authorship === 'CREATOR'); + const otherContributors = contributors.filter(c => c.authorship !== 'CREATOR'); + + // Return array with creator at the top, followed by other contributors in their original order + return creator ? [creator, ...otherContributors] : otherContributors; }; return ( From 5a80dd17f143f7b6a50f3d6fecbb629e3ff1dc69 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 22 Mar 2025 13:49:07 +0100 Subject: [PATCH 05/11] fix: Authors showing on the course page --- .../(withmenu)/course/[courseuuid]/course.tsx | 4 +- .../(withmenu)/course/[courseuuid]/page.tsx | 2 +- .../CourseActions/CourseActionsMobile.tsx | 281 ++++++++++++------ .../Courses/CourseActions/CoursesActions.tsx | 192 +++++++++--- apps/web/components/Objects/UserAvatar.tsx | 2 +- 5 files changed, 353 insertions(+), 128 deletions(-) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index c3ebb3a0..ecfa804a 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -29,6 +29,8 @@ const CourseClient = (props: any) => { const router = useRouter() const isMobile = useMediaQuery('(max-width: 768px)') + console.log(course) + function getLearningTags() { if (!course?.learnings) { setLearnings([]) @@ -333,7 +335,7 @@ const CourseClient = (props: any) => { {isMobile && ( -
+
)} diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx index d0c721a4..e3216016 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx @@ -72,7 +72,7 @@ const CoursePage = async (params: any) => { // Fetch course metadata once const course_meta = await getCourseMetadata( params.params.courseuuid, - { revalidate: 1800, tags: ['courses'] }, + { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null ) diff --git a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx index 702b0ec4..c559f377 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation' import { useLHSession } from '@components/Contexts/LHSessionContext' import { getUriWithoutOrg, getUriWithOrg } from '@services/config/config' import { getProductsByCourse } from '@services/payments/products' -import { LogIn, LogOut, ShoppingCart } from 'lucide-react' +import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react' import Modal from '@components/Objects/StyledElements/Modal/Modal' import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' @@ -13,11 +13,15 @@ import UserAvatar from '../../UserAvatar' import { getUserAvatarMediaDirectory } from '@services/media/media' interface Author { - user_uuid: string - avatar_image: string - first_name: string - last_name: string - username: string + user: { + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string + } + authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' + authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING' } interface CourseRun { @@ -49,11 +53,81 @@ interface CourseActionsMobileProps { } } +// Component for displaying multiple authors +const MultipleAuthors = ({ authors }: { authors: Author[] }) => { + const displayedAvatars = authors.slice(0, 3) + const remainingCount = Math.max(0, authors.length - 3) + + // Avatar size for mobile + const avatarSize = 36 + const borderSize = "border-2" + + return ( +
+
+ {displayedAvatars.map((author, index) => ( +
+ +
+ ))} + {remainingCount > 0 && ( +
+
+ +{remainingCount} +
+
+ )} +
+ +
+ + {authors.length > 1 ? 'Authors' : 'Author'} + + {authors.length === 1 ? ( + + {authors[0].user.first_name && authors[0].user.last_name + ? `${authors[0].user.first_name} ${authors[0].user.last_name}` + : `@${authors[0].user.username}`} + + ) : ( + + {authors[0].user.first_name && authors[0].user.last_name + ? `${authors[0].user.first_name} ${authors[0].user.last_name}` + : `@${authors[0].user.username}`} + {authors.length > 1 && ` & ${authors.length - 1} more`} + + )} +
+
+ ) +} + const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobileProps) => { const router = useRouter() const session = useLHSession() as any const [linkedProducts, setLinkedProducts] = useState([]) const [isLoading, setIsLoading] = useState(true) + const [isActionLoading, setIsActionLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [hasAccess, setHasAccess] = useState(null) @@ -107,106 +181,141 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil return } - if (isStarted) { - await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) - await revalidateTags(['courses'], orgslug) - router.refresh() - } else { - await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) - await revalidateTags(['courses'], orgslug) - - // Get the first activity from the first chapter - const firstChapter = course.chapters?.[0] - const firstActivity = firstChapter?.activities?.[0] - - if (firstActivity) { - // Redirect to the first activity - router.push( - getUriWithOrg(orgslug, '') + - `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}` - ) - } else { + setIsActionLoading(true) + try { + if (isStarted) { + await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) router.refresh() + } else { + await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + + // Get the first activity from the first chapter + const firstChapter = course.chapters?.[0] + const firstActivity = firstChapter?.activities?.[0] + + if (firstActivity) { + // Redirect to the first activity + router.push( + getUriWithOrg(orgslug, '') + + `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}` + ) + } else { + router.refresh() + } } + } catch (error) { + console.error('Failed to perform course action:', error) + } finally { + setIsActionLoading(false) } } if (isLoading) { - return
+ return
} - const author = course.authors[0] - const authorName = author.first_name && author.last_name - ? `${author.first_name} ${author.last_name}` - : `@${author.username}` + // Filter active authors and sort by role priority + const sortedAuthors = [...course.authors] + .filter(author => author.authorship_status === 'ACTIVE') + .sort((a, b) => { + const rolePriority: Record = { + 'CREATOR': 0, + 'MAINTAINER': 1, + 'CONTRIBUTOR': 2, + 'REPORTER': 3 + }; + return rolePriority[a.authorship] - rolePriority[b.authorship]; + }); return ( -
-
- -
- Author - {authorName} -
-
- -
+
+
+ + {linkedProducts.length > 0 ? ( - hasAccess ? ( - - ) : ( - <> - } - dialogTitle="Purchase Course" - dialogDescription="Select a payment option to access this course" - minWidth="sm" - /> +
+ {hasAccess ? ( +
+
+
+ You Own This Course +
+
+ ) : ( +
+
+ + Paid Course +
+
+ )} + + {hasAccess ? ( - - ) + ) : ( + <> + } + dialogTitle="Purchase Course" + dialogDescription="Select a payment option to access this course" + minWidth="sm" + /> + + + )} +
) : (
) : (

{props.activity.name}

)} setSelectedActivity(props.activity.id)} - className="text-neutral-400 hover:cursor-pointer size-3 min-w-3" + onClick={() => !isUpdatingName && setSelectedActivity(props.activity.id)} + className={`text-neutral-400 hover:cursor-pointer size-3 min-w-3 ${isUpdatingName ? 'opacity-50 cursor-not-allowed' : ''}`} />
From 9db6b605c5df5908f8aac9dc325ef6ee09b08e59 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 22 Mar 2025 14:19:39 +0100 Subject: [PATCH 07/11] feat: add the ability for the user to apply for contributions --- .../Courses/CourseActions/CoursesActions.tsx | 194 +++++++++++++++--- 1 file changed, 165 insertions(+), 29 deletions(-) diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index 73fc3349..fe4e7023 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -8,10 +8,12 @@ import { useLHSession } from '@components/Contexts/LHSessionContext' import { useMediaQuery } from 'usehooks-ts' import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config' import { getProductsByCourse } from '@services/payments/products' -import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react' +import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon } from 'lucide-react' import Modal from '@components/Objects/StyledElements/Modal/Modal' import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' +import { applyForContributor, getCourseContributors } from '@services/courses/courses' +import toast from 'react-hot-toast' interface Author { user: { @@ -44,6 +46,7 @@ interface Course { activity_type: string }> }> + open_to_contributors?: boolean } interface CourseActionsProps { @@ -186,8 +189,10 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { const [linkedProducts, setLinkedProducts] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isActionLoading, setIsActionLoading] = useState(false) + const [isContributeLoading, setIsContributeLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [hasAccess, setHasAccess] = useState(null) + const [contributorStatus, setContributorStatus] = useState<'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE'>('NONE') const isStarted = course.trail?.runs?.some( (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id @@ -212,6 +217,39 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { fetchLinkedProducts() }, [course.id, course.org_id, session.data?.tokens?.access_token]) + // Check if the current user is already a contributor + useEffect(() => { + const checkContributorStatus = async () => { + if (!session.data?.user) return + + try { + const response = await getCourseContributors( + 'course_' + courseuuid, + session.data?.tokens?.access_token + ) + + if (response && response.data) { + const currentUser = response.data.find( + (contributor: any) => contributor.user_id === session.data.user.id + ) + + if (currentUser) { + setContributorStatus(currentUser.authorship_status as 'PENDING' | 'ACTIVE' | 'INACTIVE') + } else { + setContributorStatus('NONE') + } + } + } catch (error) { + console.error('Failed to check contributor status:', error) + toast.error('Failed to check contributor status. Please try again later.') + } + } + + if (session.data?.user) { + checkContributorStatus() + } + }, [courseuuid, session.data?.tokens?.access_token, session.data?.user]) + useEffect(() => { const checkAccess = async () => { if (!session.data?.user) return @@ -225,6 +263,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } catch (error) { console.error('Failed to check course access') + toast.error('Failed to check course access. Please try again later.') setHasAccess(false) } } @@ -241,14 +280,20 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } setIsActionLoading(true) + const loadingToast = toast.loading( + isStarted ? 'Leaving course...' : 'Starting course...' + ) + try { if (isStarted) { await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) await revalidateTags(['courses'], orgslug) + toast.success('Successfully left the course', { id: loadingToast }) router.refresh() } else { await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) await revalidateTags(['courses'], orgslug) + toast.success('Successfully started the course', { id: loadingToast }) // Get the first activity from the first chapter const firstChapter = course.chapters?.[0] @@ -266,15 +311,101 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } } catch (error) { console.error('Failed to perform course action:', error) + toast.error( + isStarted + ? 'Failed to leave the course. Please try again later.' + : 'Failed to start the course. Please try again later.', + { id: loadingToast } + ) } finally { setIsActionLoading(false) } } + const handleApplyToContribute = async () => { + if (!session.data?.user) { + router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`)) + return + } + + setIsContributeLoading(true) + const loadingToast = toast.loading('Submitting contributor application...') + + try { + const data = { + message: "I would like to contribute to this course." + } + + await applyForContributor('course_' + courseuuid, data, session.data?.tokens?.access_token) + setContributorStatus('PENDING') + await revalidateTags(['courses'], orgslug) + toast.success('Your application to contribute has been submitted successfully', { id: loadingToast }) + } catch (error) { + console.error('Failed to apply as contributor:', error) + toast.error('Failed to submit your application. Please try again later.', { id: loadingToast }) + } finally { + setIsContributeLoading(false) + } + } + if (isLoading) { return
} + const renderContributorButton = () => { + // Don't render anything if the course is not open to contributors or if the user status is INACTIVE + if (contributorStatus === 'INACTIVE' || course.open_to_contributors !== true) { + return null; + } + + if (!session.data?.user) { + return ( + + ); + } + + if (contributorStatus === 'ACTIVE') { + return ( +
+ + You are a contributor +
+ ); + } + + if (contributorStatus === 'PENDING') { + return ( +
+ + Contributor application pending +
+ ); + } + + return ( + + ); + }; + if (linkedProducts.length > 0) { return (
@@ -312,6 +443,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { )} + {renderContributorButton()} ) : (
@@ -342,6 +474,7 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { Purchase Course + {renderContributorButton()} )}
@@ -349,34 +482,37 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } return ( - +
+ + {renderContributorButton()} +
) } From 95c3550c42c01f979761b61a0016ff4c6b3b4211 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 22 Mar 2025 14:23:19 +0100 Subject: [PATCH 08/11] refactor: update key usage in components for improved rendering --- apps/web/app/layout.tsx | 2 +- .../(withmenu)/course/[courseuuid]/course.tsx | 22 +++++++++---------- .../Pages/Courses/ActivityIndicators.tsx | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 14fedf12..1b770546 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -22,7 +22,7 @@ export default function RootLayout({ {isDevEnv ? '' :