diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index f2271983..cc81284e 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -36,6 +36,8 @@ from src.services.courses.contributors import ( apply_course_contributor, update_course_contributor, get_course_contributors, + add_bulk_course_contributors, + remove_bulk_course_contributors, ) from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum @@ -318,3 +320,45 @@ async def api_update_course_contributor( current_user, db_session ) + + +@router.post("/{course_uuid}/bulk-add-contributors") +async def api_add_bulk_course_contributors( + request: Request, + course_uuid: str, + usernames: List[str], + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Add multiple contributors to a course by their usernames + Only administrators can perform this action + """ + return await add_bulk_course_contributors( + request, + course_uuid, + usernames, + current_user, + db_session + ) + + +@router.put("/{course_uuid}/bulk-remove-contributors") +async def api_remove_bulk_course_contributors( + request: Request, + course_uuid: str, + usernames: List[str], + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +): + """ + Remove multiple contributors from a course by their usernames + Only administrators can perform this action + """ + return await remove_bulk_course_contributors( + request, + course_uuid, + usernames, + current_user, + db_session + ) diff --git a/apps/api/src/services/courses/contributors.py b/apps/api/src/services/courses/contributors.py index 750c2fd8..b055c901 100644 --- a/apps/api/src/services/courses/contributors.py +++ b/apps/api/src/services/courses/contributors.py @@ -173,4 +173,200 @@ async def get_course_contributors( "user": UserRead.model_validate(user).model_dump() } for contributor, user in results - ] \ No newline at end of file + ] + +async def add_bulk_course_contributors( + request: Request, + course_uuid: str, + usernames: List[str], + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + """ + Add multiple contributors to a course by their usernames + 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 add 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", + ) + + # Process results + results = { + "successful": [], + "failed": [] + } + + current_time = str(datetime.now()) + + for username in usernames: + try: + # Find user by username + user_statement = select(User).where(User.username == username) + user = db_session.exec(user_statement).first() + + if not user or user.id is None: + results["failed"].append({ + "username": username, + "reason": "User not found or invalid" + }) + continue + + # 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 == user.id + ) + ) + ).first() + + if existing_authorship: + results["failed"].append({ + "username": username, + "reason": "User already has an authorship role for this course" + }) + continue + + # Create contributor + resource_author = ResourceAuthor( + resource_uuid=course_uuid, + user_id=user.id, + authorship=ResourceAuthorshipEnum.CONTRIBUTOR, + authorship_status=ResourceAuthorshipStatusEnum.PENDING, + creation_date=current_time, + update_date=current_time, + ) + + db_session.add(resource_author) + db_session.commit() + db_session.refresh(resource_author) + + results["successful"].append({ + "username": username, + "user_id": user.id + }) + + except Exception as e: + results["failed"].append({ + "username": username, + "reason": str(e) + }) + + return results + +async def remove_bulk_course_contributors( + request: Request, + course_uuid: str, + usernames: List[str], + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + """ + Remove multiple contributors from a course by their usernames + 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 remove 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", + ) + + # Process results + results = { + "successful": [], + "failed": [] + } + + for username in usernames: + try: + # Find user by username + user_statement = select(User).where(User.username == username) + user = db_session.exec(user_statement).first() + + if not user or user.id is None: + results["failed"].append({ + "username": username, + "reason": "User not found or invalid" + }) + continue + + # Check if user has any authorship role for this course + existing_authorship = db_session.exec( + select(ResourceAuthor).where( + and_( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == user.id + ) + ) + ).first() + + if not existing_authorship: + results["failed"].append({ + "username": username, + "reason": "User is not a contributor for this course" + }) + continue + + # Don't allow removing the creator + if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: + results["failed"].append({ + "username": username, + "reason": "Cannot remove the course creator" + }) + continue + + # Remove the contributor + db_session.delete(existing_authorship) + db_session.commit() + + results["successful"].append({ + "username": username, + "user_id": user.id + }) + + except Exception as e: + results["failed"].append({ + "username": username, + "reason": str(e) + }) + + return results \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx index 2327492e..33d730dc 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors.tsx @@ -1,10 +1,12 @@ import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext' import { useLHSession } from '@components/Contexts/LHSessionContext' +import { useOrg } from '@components/Contexts/OrgContext' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import { getAPIUrl } from '@services/config/config' -import { editContributor, getCourseContributors } from '@services/courses/courses' +import { bulkAddContributors, bulkRemoveContributors, editContributor, getCourseContributors } from '@services/courses/courses' +import { searchOrgContent } from '@services/search/search' import { swrFetcher } from '@services/utils/ts/requests' -import { Check, ChevronDown, UserPen, Users } from 'lucide-react' +import { Check, ChevronDown, Search, UserPen, Users } from 'lucide-react' import React, { useEffect, useState } from 'react' import toast from 'react-hot-toast' import useSWR, { mutate } from 'swr' @@ -24,6 +26,9 @@ import { } from "@/components/ui/table" import { Button } from "@/components/ui/button" import UserAvatar from '@components/Objects/UserAvatar' +import { Input } from '@/components/ui/input' +import { useDebounce } from '@/hooks/useDebounce' +import { getUserAvatarMediaDirectory } from '@services/media/media' type EditCourseContributorsProps = { orgslug: string @@ -33,26 +38,58 @@ type EditCourseContributorsProps = { type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' +interface SearchUser { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + avatar_url?: string; + id: number; + user_uuid: string; +} + interface Contributor { id: string; user_id: string; authorship: ContributorRole; authorship_status: ContributorStatus; + creation_date: string; user: { username: string; first_name: string; last_name: string; email: string; avatar_image: string; + user_uuid: string; } } +interface BulkAddResponse { + successful: string[]; + failed: { + username: string; + reason: string; + }[]; +} + +// Helper function for date formatting +const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +}; + 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 org = useOrg() as any; const { data: contributors } = useSWR( courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null, @@ -60,6 +97,13 @@ function EditCourseContributors(props: EditCourseContributorsProps) { ); const [isOpenToContributors, setIsOpenToContributors] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const debouncedSearch = useDebounce(searchQuery, 300); + const [selectedContributors, setSelectedContributors] = useState([]); + const [masterCheckboxChecked, setMasterCheckboxChecked] = useState(false); useEffect(() => { if (!isLoading && courseStructure?.open_to_contributors !== undefined) { @@ -80,6 +124,95 @@ function EditCourseContributors(props: EditCourseContributorsProps) { } }, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]); + useEffect(() => { + const searchUsers = async () => { + if (debouncedSearch.trim().length === 0) { + setSearchResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + try { + const response = await searchOrgContent( + org?.slug, + debouncedSearch, + 1, + 5, + null, + access_token + ); + + if (response.success && response.data?.users) { + const users = response.data.users.map((user: SearchUser) => ({ + ...user, + avatar_url: user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : '' + })); + setSearchResults(users); + } else { + setSearchResults([]); + } + } catch (error) { + console.error('Error searching users:', error); + setSearchResults([]); + } + setIsSearching(false); + }; + + if (org?.slug && access_token) { + searchUsers(); + } + }, [debouncedSearch, org?.slug, access_token]); + + useEffect(() => { + if (contributors) { + const nonCreatorContributors = contributors.filter(c => c.authorship !== 'CREATOR'); + setMasterCheckboxChecked( + nonCreatorContributors.length > 0 && + selectedContributors.length === nonCreatorContributors.length + ); + } + }, [contributors, selectedContributors]); + + const handleUserSelect = (username: string) => { + setSelectedUsers(prev => { + if (prev.includes(username)) { + return prev.filter(u => u !== username); + } + return [...prev, username]; + }); + }; + + const handleAddContributors = async () => { + if (selectedUsers.length === 0) return; + + try { + const response = await bulkAddContributors(courseStructure.course_uuid, selectedUsers, access_token); + if (response.status === 200) { + const result = response.data as BulkAddResponse; + + // Show success message for successful adds + if (result.successful.length > 0) { + toast.success(`Successfully added ${result.successful.length} contributor(s)`); + } + + // Show error messages for failed adds + result.failed.forEach(failure => { + toast.error(`Failed to add ${failure.username}: ${failure.reason}`); + }); + + // Refresh contributors list + mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`); + // Clear selection and search + setSelectedUsers([]); + setSearchQuery(''); + } + } catch (error) { + console.error('Error adding contributors:', error); + toast.error('Failed to add contributors'); + } + }; + const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => { try { // Find the current contributor to get their current values @@ -188,6 +321,45 @@ function EditCourseContributors(props: EditCourseContributorsProps) { return creator ? [creator, ...otherContributors] : otherContributors; }; + const handleContributorSelect = (userId: string) => { + setSelectedContributors(prev => { + if (prev.includes(userId)) { + return prev.filter(id => id !== userId); + } + return [...prev, userId]; + }); + }; + + const handleBulkRemove = async () => { + if (selectedContributors.length === 0) return; + + try { + // Get the usernames from the selected contributors + const selectedUsernames = contributors + ?.filter(c => selectedContributors.includes(c.user_id)) + .map(c => c.user.username) || []; + + console.log('Sending usernames:', selectedUsernames); // Debug log + + const response = await bulkRemoveContributors( + courseStructure.course_uuid, + selectedUsernames, // Send as raw array, not stringified + access_token + ); + + if (response.status === 200) { + toast.success(`Successfully removed ${selectedContributors.length} contributor(s)`); + // Refresh contributors list + mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`); + // Clear selection + setSelectedContributors([]); + } + } catch (error) { + console.error('Error removing contributors:', error); + toast.error('Failed to remove contributors'); + } + }; + return (
{courseStructure && ( @@ -197,7 +369,7 @@ function EditCourseContributors(props: EditCourseContributorsProps) {

Course Contributors

- Choose if you want your course to be open for contributors and manage existing contributors + Manage contributors and add new ones to your course

@@ -252,51 +424,234 @@ function EditCourseContributors(props: EditCourseContributorsProps) { 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} - - - - - - - - - ))} - -
+
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ {searchQuery && ( +
+ {isSearching ? ( +
+ Searching... +
+ ) : searchResults && searchResults.length > 0 ? ( + <> + {selectedUsers.length > 0 && ( +
+
+ + {selectedUsers.length} user{selectedUsers.length > 1 ? 's' : ''} selected + +
+ + +
+
+
+ )} + {searchResults.map((user) => { + const isSelected = selectedUsers.includes(user.username); + const isExistingContributor = contributors?.some( + c => c.user.username === user.username + ); + + return ( +
{ + // Don't handle click if it's on a checkbox + if (e.target instanceof HTMLElement && e.target.closest('input[type="checkbox"]')) { + return; + } + if (!isExistingContributor) { + handleUserSelect(user.username); + } + }} + > +
+
e.stopPropagation()}> + !isExistingContributor && handleUserSelect(user.username)} + disabled={isExistingContributor} + className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500 disabled:opacity-50" + /> +
+ +
+
+ {user.first_name} {user.last_name} +
+
+ @{user.username} +
+
+
+ {isExistingContributor && ( + + Already a contributor + + )} +
+ ); + })} + + ) : ( +
+ No users found +
+ )} +
+ )} +
+ {selectedContributors.length > 0 && ( +
+
+ + {selectedContributors.length} contributor{selectedContributors.length > 1 ? 's' : ''} selected + +
+ + +
+
+
+ )} +
+ + + + + { + setMasterCheckboxChecked(e.target.checked); + if (contributors) { + if (e.target.checked) { + // Select all non-creator contributors + const nonCreatorContributors = contributors + .filter(c => c.authorship !== 'CREATOR') + .map(c => c.user_id); + setSelectedContributors(nonCreatorContributors); + } else { + setSelectedContributors([]); + } + } + }} + className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500" + /> + + + Name + Username + Email + Role + Status + Added On + + + + {sortContributors(contributors)?.map((contributor) => ( + { + // Don't handle click if it's on a dropdown or checkbox + if ( + e.target instanceof HTMLElement && + (e.target.closest('button') || + e.target.closest('input[type="checkbox"]')) + ) { + return; + } + if (contributor.authorship !== 'CREATOR') { + handleContributorSelect(contributor.user_id); + } + }} + > + e.stopPropagation()}> + handleContributorSelect(contributor.user_id)} + disabled={contributor.authorship === 'CREATOR'} + className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500 disabled:opacity-50" + /> + + + + + + {contributor.user.first_name} {contributor.user.last_name} + + + @{contributor.user.username} + + + {contributor.user.email} + + + + + + + + + {formatDate(contributor.creation_date)} + + + ))} + +
+
+
diff --git a/apps/web/components/ui/checkbox.tsx b/apps/web/components/ui/checkbox.tsx new file mode 100644 index 00000000..48d9234b --- /dev/null +++ b/apps/web/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index dd079e19..51b30a2d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@icons-pack/react-simple-icons": "^10.2.0", "@radix-ui/colors": "^0.1.9", "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-form": "^0.0.3", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index ca9f96bc..d0034bf3 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@radix-ui/react-aspect-ratio': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-checkbox': + specifier: ^1.3.2 + version: 1.3.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -728,6 +731,9 @@ packages: '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/react-arrow@1.1.2': resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} peerDependencies: @@ -754,6 +760,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.3.2': + resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==} + peerDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.2': resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} peerDependencies: @@ -790,6 +809,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -813,6 +841,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.0.0': resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==} peerDependencies: @@ -1137,6 +1174,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@1.0.0': resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==} peerDependencies: @@ -1169,6 +1219,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.2': resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} peerDependencies: @@ -1218,6 +1281,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.3': resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==} peerDependencies: @@ -1329,6 +1401,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.0.0': resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==} peerDependencies: @@ -1375,6 +1465,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.0': resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} peerDependencies: @@ -1384,6 +1483,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -1402,6 +1510,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': 19.0.10 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.1.2': resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==} peerDependencies: @@ -4023,6 +4140,8 @@ snapshots: '@radix-ui/primitive@1.1.1': {} + '@radix-ui/primitive@1.1.2': {} + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -4041,6 +4160,22 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-collection@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0) @@ -4071,6 +4206,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-context@1.0.0(react@19.0.0)': dependencies: '@babel/runtime': 7.27.0 @@ -4089,6 +4230,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-context@1.1.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-dialog@1.0.0(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.27.0 @@ -4464,6 +4611,16 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-primitive@1.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.27.0 @@ -4490,6 +4647,15 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4557,6 +4723,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4673,6 +4846,21 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-use-escape-keydown@1.0.0(react@19.0.0)': dependencies: '@babel/runtime': 7.27.0 @@ -4712,12 +4900,24 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.0.10)(react@19.0.0)': dependencies: react: 19.0.0 optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.10)(react@19.0.0)': dependencies: '@radix-ui/rect': 1.1.0 @@ -4732,6 +4932,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.10 + '@radix-ui/react-use-size@1.1.1(@types/react@19.0.10)(react@19.0.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.10 + '@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) diff --git a/apps/web/services/courses/courses.ts b/apps/web/services/courses/courses.ts index ec81f13b..dc1ca336 100644 --- a/apps/web/services/courses/courses.ts +++ b/apps/web/services/courses/courses.ts @@ -153,3 +153,21 @@ export async function applyForContributor(course_uuid: string, data: any, access const res = await getResponseMetadata(result) return res } + +export async function bulkAddContributors(course_uuid: string, data: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/bulk-add-contributors`, + RequestBodyWithAuthHeader('POST', data, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function bulkRemoveContributors(course_uuid: string, data: any, access_token:string | null | undefined) { + const result: any = await fetch( + `${getAPIUrl()}courses/${course_uuid}/bulk-remove-contributors`, + RequestBodyWithAuthHeader('PUT', data, null,access_token || undefined) + ) + const res = await getResponseMetadata(result) + return res +} \ No newline at end of file diff --git a/apps/web/services/utils/ts/requests.ts b/apps/web/services/utils/ts/requests.ts index 0c68a945..81d7836a 100644 --- a/apps/web/services/utils/ts/requests.ts +++ b/apps/web/services/utils/ts/requests.ts @@ -32,7 +32,7 @@ export const RequestBodyWithAuthHeader = ( headers: HeadersConfig, redirect: 'follow', credentials: 'include', - body: (method === 'POST' || method === 'PUT') ? JSON.stringify(data) : null, + body: (method === 'POST' || method === 'PUT' || method === 'DELETE') && data !== null ? JSON.stringify(data) : null, // Next.js next: next, }