diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index c3a9812..82307e7 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,9 +1,14 @@ import { DashboardHeader } from "@/components/header"; import { DashboardShell } from "@/components/shell"; -import PointsCard from "@/components/ui/pointsCard"; +import UserPointsAndLevelCard from "@/components/ui/userPointsAndLevelCard"; import { authOptions } from "@/lib/auth"; import { getEnrolledRepositories } from "@/lib/enrollment/service"; import { getCurrentUser } from "@/lib/session"; +import { + calculateAssignabelNonAssignableIssuesForUserInALevel, + findCurrentAndNextLevelOfCurrentUser, +} from "@/lib/utils/levelUtils"; +import { TLevel } from "@/types/level"; import { TPointTransaction } from "@/types/pointTransaction"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -46,14 +51,17 @@ export default async function DashboardPage() { const repositoriesUserIsEnrolledIn = await getEnrolledRepositories(user.id); - const calculateTotalPointsForCurrentUser = (currentUserId: string, pointTransactions: TPointTransaction[]) => { + const calculateTotalPointsForCurrentUser = ( + currentUserId: string, + pointTransactions: TPointTransaction[] + ) => { return pointTransactions.reduce((acc, transaction) => { if (transaction.userId === currentUserId) { return acc + transaction.points; } return acc; }, 0); - } + }; const calculateRankOfCurrentUser = (currentUserId: string, pointTransactions: TPointTransaction[]) => { // Create an object to store the total points for each user enrolled in the repositories that the current user is in. const totalPointsOfAllUsersInTheRepo = {}; @@ -84,12 +92,20 @@ export default async function DashboardPage() { return userRank; }; - // Calculate total points and rank for the current user in repositories they are enrolled in. - const pointsPerRepository = repositoriesUserIsEnrolledIn.map((repository) => { + // Calculate total points,rank,current level for the current user in repositories they are enrolled in. + const pointsAndLevelsPerRepository = repositoriesUserIsEnrolledIn.map((repository) => { const pointTransactions = repository.pointTransactions || []; const totalPoints = calculateTotalPointsForCurrentUser(user.id, pointTransactions); const rank = calculateRankOfCurrentUser(user.id, pointTransactions); + const { currentLevelOfUser, nextLevelForUser } = findCurrentAndNextLevelOfCurrentUser( + repository, + totalPoints + ); + const levels = (repository?.levels as TLevel[]) || []; + const modifiedTagsArray = calculateAssignabelNonAssignableIssuesForUserInALevel(levels); //gets all assignable tags be it from the current level and from lower levels. + + const assignableTags = modifiedTagsArray.find((item) => item.levelId === currentLevelOfUser?.id); //finds the curent level in the modifiedTagsArray. return { id: repository.id, @@ -97,6 +113,9 @@ export default async function DashboardPage() { points: totalPoints, repositoryLogo: repository.logoUrl, rank: rank, + currentLevelOfUser: currentLevelOfUser, + nextLevelForUser: nextLevelForUser, + assignableTags: assignableTags?.assignableIssues || [], }; }); @@ -104,18 +123,20 @@ export default async function DashboardPage() { -
- {pointsPerRepository.map((point, index) => { - const isLastItem = index === pointsPerRepository.length - 1; - const isSingleItemInLastRow = pointsPerRepository.length % 2 !== 0 && isLastItem; +
+ {pointsAndLevelsPerRepository.map((repositoryData, index) => { return ( -
- +
); diff --git a/app/(dashboard)/enroll/[repositoryId]/issues/page.tsx b/app/(dashboard)/enroll/[repositoryId]/issues/page.tsx index 61a24e3..42a627c 100644 --- a/app/(dashboard)/enroll/[repositoryId]/issues/page.tsx +++ b/app/(dashboard)/enroll/[repositoryId]/issues/page.tsx @@ -1,6 +1,7 @@ import GitHubIssue from "@/components/ui/githubIssue"; import { getAllOssGgIssuesOfRepo } from "@/lib/github/service"; import { getRepositoryById } from "@/lib/repository/service"; +import { TLevel } from "@/types/level"; export default async function OpenIssuesPage({ params }) { const repository = await getRepositoryById(params.repositoryId); @@ -9,14 +10,15 @@ export default async function OpenIssuesPage({ params }) { } const issues = await getAllOssGgIssuesOfRepo(repository.githubId); + const levelsInRepo: TLevel[] = repository.levels as TLevel[]; return ( <> -
+
{issues.length === 0 ? (

Currently, there are no open oss.gg issues available.

) : ( - issues.map((issue) => ) + issues.map((issue) => ) )}
diff --git a/app/(dashboard)/enroll/[repositoryId]/layout.tsx b/app/(dashboard)/enroll/[repositoryId]/layout.tsx index 7dc5702..4707691 100644 --- a/app/(dashboard)/enroll/[repositoryId]/layout.tsx +++ b/app/(dashboard)/enroll/[repositoryId]/layout.tsx @@ -1,7 +1,10 @@ +import { DashboardHeader } from "@/components/header"; +import { DashboardShell } from "@/components/shell"; import { getRepositoryById } from "@/lib/repository/service"; import type { Metadata } from "next"; -import LayoutTabs from "./layoutTabs"; +import LayoutTabs from "../../../../components/ui/layoutTabs"; +import EnrollmentStatusBar from "./enrollmentStatusBar"; interface MetadataProps { params: { repositoryId: string }; @@ -21,9 +24,19 @@ export default async function RepoDetailPageLayout({ params, children }) { if (!repository) { throw new Error("Repository not found"); } + const tabsData = [ + { href: `/enroll/${repository.id}/details`, value: "details", label: "Project Details" }, + { href: `/enroll/${repository.id}/levels`, value: "levels", label: "Levels" }, + { href: `/enroll/${repository.id}/leaderboard`, value: "leaderboard", label: "Leaderboard" }, + { href: `/enroll/${repository.id}/issues`, value: "issues", label: "Open Issues" }, + ]; return ( <> - + + + + +
{children}
); diff --git a/app/(dashboard)/enroll/[repositoryId]/layoutTabs.tsx b/app/(dashboard)/enroll/[repositoryId]/layoutTabs.tsx deleted file mode 100644 index 93d67dc..0000000 --- a/app/(dashboard)/enroll/[repositoryId]/layoutTabs.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { DashboardHeader } from "@/components/header"; -import { DashboardShell } from "@/components/shell"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -import EnrollmentStatusBar from "./enrollmentStatusBar"; - -export default function LayoutTabs({ repository }) { - const path = usePathname(); - const activeTab = path?.split("/")[3] || "details"; - - return ( - - - - - - {[ - { href: `/enroll/${repository.id}/details`, value: "details", label: "Project Details" }, - { href: `/enroll/${repository.id}/leaderboard`, value: "leaderboard", label: "Leaderboard" }, - { href: `/enroll/${repository.id}/issues`, value: "issues", label: "Open Issues" } - ].map((tab, index) => ( - - {tab.label} - - ))} - - - - ); -} diff --git a/app/(dashboard)/enroll/[repositoryId]/leaderboard/components/Leaderboard.tsx b/app/(dashboard)/enroll/[repositoryId]/leaderboard/components/Leaderboard.tsx index d5d0e83..69ea571 100644 --- a/app/(dashboard)/enroll/[repositoryId]/leaderboard/components/Leaderboard.tsx +++ b/app/(dashboard)/enroll/[repositoryId]/leaderboard/components/Leaderboard.tsx @@ -1,6 +1,9 @@ "use client"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; import UserProfileSummary from "@/components/ui/userProfileSummary"; +import { capitalizeEachWord } from "@/lib/utils/textformat"; +import { TLevel } from "@/types/level"; import { TPointTransactionWithUser } from "@/types/pointTransaction"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -10,12 +13,14 @@ interface LeaderboardProps { leaderboardProfiles: TPointTransactionWithUser[]; repositoryId: string; itemPerPage: number; + sortedLevels: TLevel[]; } export default function Leaderboard({ leaderboardProfiles, repositoryId, itemPerPage, + sortedLevels, }: LeaderboardProps): JSX.Element { const loadingRef = useRef(null); const [page, setPage] = useState(1); @@ -56,18 +61,69 @@ export default function Leaderboard({ }; }, [fetchNextPage, hasMore]); + let runningIndex = 1; return ( <> - {profiles.map((profile, idx) => ( - - ))} + {sortedLevels.length > 0 + ? sortedLevels.map((level, levelIndex) => { + // Filter profiles for this level + const eligibleProfiles = profiles.filter((profile) => { + // Check if profile is eligible for this level + const isEligible = profile.points >= level.pointThreshold; + + // Check if the profile has not been assigned to any lower level + const lowerLevels = sortedLevels.slice(0, levelIndex); // Lower levels + const assignedLowerLevel = lowerLevels.some( + (lowerLevel) => profile.points >= lowerLevel.pointThreshold + ); + + return isEligible && !assignedLowerLevel; + }); + + return ( +
+
+
+ + + +
+ Level {sortedLevels.length - levelIndex}: {capitalizeEachWord(level.name)} +
+
+
+ {eligibleProfiles.length > 0 ? ( + eligibleProfiles.map((profile) => ( + <> + + + )) + ) : ( +

+ No player levelled up here yet. Who′s gonna be the first? +

+ )} +
+ ); + }) + : profiles.map((profile, idx) => ( + + ))} +
); diff --git a/app/(dashboard)/enroll/[repositoryId]/leaderboard/page.tsx b/app/(dashboard)/enroll/[repositoryId]/leaderboard/page.tsx index 0eebb29..f074d54 100644 --- a/app/(dashboard)/enroll/[repositoryId]/leaderboard/page.tsx +++ b/app/(dashboard)/enroll/[repositoryId]/leaderboard/page.tsx @@ -1,6 +1,7 @@ import { ITEMS_PER_PAGE } from "@/lib/constants"; import { getPointsOfUsersInRepoByRepositoryId } from "@/lib/points/service"; import { getRepositoryById } from "@/lib/repository/service"; +import { TLevel } from "@/types/level"; import Leaderboard from "./components/Leaderboard"; @@ -12,15 +13,19 @@ export default async function LeaderboardPage({ params }) { const leaderboardProfiles = await getPointsOfUsersInRepoByRepositoryId(repository.id, 1); + const levels: TLevel[] = repository.levels as TLevel[]; + const sortedLevels = levels.sort((a, b) => b.pointThreshold - a.pointThreshold); //descending by threshold + return ( <> {leaderboardProfiles.length === 0 ? ( -

No users found in the leaderboard.

+

No users found in the leaderboard.

) : ( )} diff --git a/app/(dashboard)/enroll/[repositoryId]/levels/page.tsx b/app/(dashboard)/enroll/[repositoryId]/levels/page.tsx new file mode 100644 index 0000000..7e62db4 --- /dev/null +++ b/app/(dashboard)/enroll/[repositoryId]/levels/page.tsx @@ -0,0 +1,61 @@ +import LevelDetailCard from "@/components/ui/levelDetailCard"; +import { authOptions } from "@/lib/auth"; +import { getPointsForUserInRepoByRepositoryId } from "@/lib/points/service"; +import { getRepositoryById } from "@/lib/repository/service"; +import { getCurrentUser } from "@/lib/session"; +import { + ModifiedTagsArray, + calculateAssignabelNonAssignableIssuesForUserInALevel, + findCurrentAndNextLevelOfCurrentUser, +} from "@/lib/utils/levelUtils"; +import { TLevel } from "@/types/level"; +import { redirect } from "next/navigation"; + +export default async function Levels({ params: { repositoryId } }) { + const repository = await getRepositoryById(repositoryId); + + if (!repository) { + throw new Error("Repository not found"); + } + + const user = await getCurrentUser(); + + if (!user) { + redirect(authOptions?.pages?.signIn || "/login"); + } + + const levels: TLevel[] = repository.levels as TLevel[]; + + // Sort levels based on point threshold in ascending order to get tags in correct order of levels. + const sortedLevels = levels.sort((a, b) => a.pointThreshold - b.pointThreshold); + + const modifiedTagsArray: ModifiedTagsArray[] = + calculateAssignabelNonAssignableIssuesForUserInALevel(sortedLevels); + + const totalPointsForUserInThisRepo: number = await getPointsForUserInRepoByRepositoryId( + repositoryId, + user.id + ); + + const { currentLevelOfUser, nextLevelForUser } = findCurrentAndNextLevelOfCurrentUser( + repository, + totalPointsForUserInThisRepo + ); + + return sortedLevels.length ? ( + sortedLevels.map((level, idx) => ( + + )) + ) : ( +
No Levels yet.
+ ); +} diff --git a/app/(dashboard)/issues/page.tsx b/app/(dashboard)/issues/page.tsx index 1d1628f..5198ba3 100644 --- a/app/(dashboard)/issues/page.tsx +++ b/app/(dashboard)/issues/page.tsx @@ -22,11 +22,17 @@ export default async function IssuesPage() { const repoWithIssuesMap = enrolledRepos.reduce( (acc, repo, index) => { - acc[capitalizeFirstLetter(repo.name)] = issuesResults[index]; + acc[capitalizeFirstLetter(repo.name)] = { + issues: issuesResults[index], + level: repo.levels, + }; return acc; }, { - "All Projects": allOpenIssues, + "All Projects": { + issues: allOpenIssues, + level: [], + }, } ); @@ -55,10 +61,10 @@ export default async function IssuesPage() { ))} - {Object.entries(repoWithIssuesMap).map(([repoName, issues]) => ( + {Object.entries(repoWithIssuesMap).map(([repoName, { issues, level }]) => ( {issues.map((issue) => ( - + ))} ))} diff --git a/app/(dashboard)/repo-settings/[repositoryId]/layout.tsx b/app/(dashboard)/repo-settings/[repositoryId]/layout.tsx index de9dd12..58ed2f2 100644 --- a/app/(dashboard)/repo-settings/[repositoryId]/layout.tsx +++ b/app/(dashboard)/repo-settings/[repositoryId]/layout.tsx @@ -1,11 +1,10 @@ import EnrollPlayerSwitch from "@/components/enroll-player-switch"; import { DashboardHeader } from "@/components/header"; import { DashboardShell } from "@/components/shell"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import LayoutTabs from "@/components/ui/layoutTabs"; import { userHasPermissionForRepository } from "@/lib/repository/auth"; import { getRepositoryById } from "@/lib/repository/service"; import { getCurrentUser } from "@/lib/session"; -import Link from "next/link"; import { redirect } from "next/navigation"; export const metadata = { @@ -25,7 +24,16 @@ export default async function RepoSettingsLayout({ children, params }) { } const repository = await getRepositoryById(params.repositoryId); - + const tabsData = [ + { href: `/repo-settings/${params.repositoryId}/players`, value: "players", label: "Players" }, + { + href: `/repo-settings/${params.repositoryId}/description`, + value: "description", + label: "Project Description", + }, + { href: `/repo-settings/${params.repositoryId}/levels`, value: "levels", label: "Levels" }, + { href: `/repo-settings/${params.repositoryId}/bounties`, value: "bounties", label: "Bounties" }, + ]; return ( - - - - Players - - - Project Description - - {/* - Levels - */} - - Bounties - - - + + {children} ); diff --git a/app/(dashboard)/repo-settings/[repositoryId]/levels/action.ts b/app/(dashboard)/repo-settings/[repositoryId]/levels/action.ts index 6c6def7..b0d70d4 100644 --- a/app/(dashboard)/repo-settings/[repositoryId]/levels/action.ts +++ b/app/(dashboard)/repo-settings/[repositoryId]/levels/action.ts @@ -1,42 +1,54 @@ "use server"; -/* - -// import { createLevel, deleteLevel, updateLevel, updateLevelIcon } from "@/lib/levels/service"; +import { createLevel, deleteLevel, updateLevel } from "@/lib/levels/service"; import { getCurrentUser } from "@/lib/session"; -import { TLevelInput } from "@/types/level"; +import { deleteFile } from "@/lib/storage/service"; +import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; +import { TLevel } from "@/types/level"; -export async function createLevelAction(levelData: TLevelInput) { - const user = await getCurrentUser(); - if (!user || !user.id) { - return { error: "User must be authenticated to perform this action." }; +export const createLevelAction = async (levelData: TLevel) => { + try { + const user = await getCurrentUser(); + if (!user || !user.id) { + throw new Error("User must be authenticated to perform this action."); + } + return await createLevel(levelData); + } catch (error) { + throw new Error(`Failed to create level.`); } - await createLevel(levelData); -} +}; + +export const updateLevelAction = async (updateData: TLevel) => { + try { + const user = await getCurrentUser(); + if (!user || !user.id) { + throw new Error("User must be authenticated to perform this action."); + } -//TODO: fix the type -export async function updateLevelIconAction(updateData: { - name: string; - repositoryId: string; - iconUrl: string; -}) { - const user = await getCurrentUser(); - if (!user || !user.id) { - return { error: "User must be authenticated to perform this action." }; + return await updateLevel(updateData); + } catch (error) { + throw new Error(`Failed to update level.`); } - // update level icon +}; - await updateLevelIcon(updateData.name, updateData.repositoryId, updateData.iconUrl); -} +export const deleteLevelAction = async (repositoryId: string, levelId: string, iconUrl: string) => { + try { + const user = await getCurrentUser(); + if (!user || !user.id) { + throw new Error("User must be authenticated to perform this action."); + } + const fileName = getFileNameWithIdFromUrl(iconUrl); + if (!fileName) { + throw new Error("Invalid filename"); + } + const deletionResult = await deleteFile(repositoryId, "public", fileName); + if (!deletionResult.success) { + throw new Error("Deletion failed"); + } -export async function deleteLevelAction(levelData: { - repositoryId: string; - name: string; -}) { - const user = await getCurrentUser(); - if (!user || !user.id) { - return { error: "User must be authenticated to perform this action." }; + return await deleteLevel(repositoryId, levelId); + } catch (error) { + throw new Error(`Failed to delete level.`); } - await deleteLevel(levelData.name, levelData.repositoryId); -} */ +}; diff --git a/app/(dashboard)/repo-settings/[repositoryId]/levels/lib.ts b/app/(dashboard)/repo-settings/[repositoryId]/levels/lib.ts index f195f28..4969b1a 100644 --- a/app/(dashboard)/repo-settings/[repositoryId]/levels/lib.ts +++ b/app/(dashboard)/repo-settings/[repositoryId]/levels/lib.ts @@ -1,6 +1,6 @@ export const handleFileUpload = async ( file: File, - environmentId: string + repositoryId: string ): Promise<{ error?: string; url: string; @@ -21,7 +21,7 @@ export const handleFileUpload = async ( const payload = { fileName: file.name, fileType: file.type, - environmentId, + repositoryId, }; const response = await fetch("/api/storage", { @@ -53,7 +53,7 @@ export const handleFileUpload = async ( requestHeaders = { "X-File-Type": file.type, "X-File-Name": encodeURIComponent(updatedFileName), - "X-Environment-ID": environmentId ?? "", + "X-Environment-ID": repositoryId ?? "", "X-Signature": signature, "X-Timestamp": String(timestamp), "X-UUID": uuid, diff --git a/app/(dashboard)/repo-settings/[repositoryId]/levels/page.tsx b/app/(dashboard)/repo-settings/[repositoryId]/levels/page.tsx index 6cf0335..04231ca 100644 --- a/app/(dashboard)/repo-settings/[repositoryId]/levels/page.tsx +++ b/app/(dashboard)/repo-settings/[repositoryId]/levels/page.tsx @@ -1,6 +1,6 @@ -// import { getLevels } from "@/lib/levels/service"; -// import LevelsFormContainer from "@/components/level-form-container"; +import LevelsFormContainer from "@/components/level-form-container"; import { authOptions } from "@/lib/auth"; +import { getLevels } from "@/lib/levels/service"; import { getCurrentUser } from "@/lib/session"; import { redirect } from "next/navigation"; @@ -9,104 +9,6 @@ export const metadata = { description: "Set up levels for your repository.", }; -// const LevelDummyData: LevelsFormProps[] = [ -// { -// levelName: "Level 1", -// description: "Description for level 1", -// iconUrl: "https://via.placeholder.com/150", -// canHuntBounties: true, -// canReportBugs: true, -// limitIssues: true, -// topics: [ -// { -// id: "1", -// text: "topic1", -// }, -// { -// id: "2", -// text: "topic2", -// }, -// ], -// pointThreshold: 200, -// }, -// { -// levelName: "Level 2", -// description: "Description for level 2", -// iconUrl: "https://via.placeholder.com/150", -// canHuntBounties: true, -// canReportBugs: false, -// limitIssues: false, -// topics: [ -// { -// id: "3", -// text: "topic3", -// }, -// { -// id: "4", -// text: "topic4", -// }, -// ], -// pointThreshold: 500, -// }, -// { -// levelName: "Level 3", -// description: "Description for level 3", -// iconUrl: "https://via.placeholder.com/150", -// canHuntBounties: false, -// canReportBugs: true, -// limitIssues: true, -// topics: [ -// { -// id: "5", -// text: "topic5", -// }, -// { -// id: "6", -// text: "topic6", -// }, -// ], -// pointThreshold: 1000, -// }, -// { -// levelName: "Level 4", -// description: "Description for level 4", -// iconUrl: "https://via.placeholder.com/150", -// canHuntBounties: true, -// canReportBugs: true, -// limitIssues: false, -// topics: [ -// { -// id: "7", -// text: "topic7", -// }, -// { -// id: "8", -// text: "topic8", -// }, -// ], -// pointThreshold: 2000, -// }, -// { -// levelName: "Level 5", -// description: "Description for level 5", -// iconUrl: "https://via.placeholder.com/150", -// canHuntBounties: false, -// canReportBugs: false, -// limitIssues: true, -// topics: [ -// { -// id: "9", -// text: "topic9", -// }, -// { -// id: "10", -// text: "topic10", -// }, -// ], -// pointThreshold: 5000, -// }, -// ]; - export default async function Levels({ params: { repositoryId } }) { const user = await getCurrentUser(); @@ -114,13 +16,7 @@ export default async function Levels({ params: { repositoryId } }) { redirect(authOptions?.pages?.signIn || "/login"); } - // const levelsData = await getLevels(repositoryId); - const levelsData = []; //placeholder + const levelsData = await getLevels(repositoryId); - return ( - <> - {/* */} - Placeholder - - ); + return ; } diff --git a/app/(dashboard)/repo-settings/[repositoryId]/players/page.tsx b/app/(dashboard)/repo-settings/[repositoryId]/players/page.tsx index 1f6a3a9..fdbe92e 100644 --- a/app/(dashboard)/repo-settings/[repositoryId]/players/page.tsx +++ b/app/(dashboard)/repo-settings/[repositoryId]/players/page.tsx @@ -10,7 +10,13 @@ export default async function RepoSettings({ params }) { const players = await getUsersForRepository(params.repositoryId); const arrayOfPlayersWithTheirTotalPoints = players.map((player) => { - const totalPointsForThisSinglePlayer = player.pointTransactions.reduce((acc, pt) => acc + pt.points, 0); + const totalPointsForThisSinglePlayer = player.pointTransactions.reduce((acc, pt) => { + if (pt.repositoryId === params.repositoryId) { + return acc + pt.points; + } else { + return acc; + } + }, 0); return { ...player, totalPointsForThisSinglePlayer: totalPointsForThisSinglePlayer, diff --git a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/deleteFile.ts similarity index 69% rename from app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts rename to app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/deleteFile.ts index 740c339..5521b0d 100644 --- a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/deleteFile.ts +++ b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/deleteFile.ts @@ -3,13 +3,13 @@ import { storageCache } from "@/lib/storage/service"; import { deleteFile } from "@/lib/storage/service"; import { TAccessType } from "@/types/storage"; -export const handleDeleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => { +export const handleDeleteFile = async (repositoryId: string, accessType: TAccessType, fileName: string) => { try { - const { message, success, code } = await deleteFile(environmentId, accessType, fileName); + const { message, success, code } = await deleteFile(repositoryId, accessType, fileName); if (success) { // revalidate cache - storageCache.revalidate({ fileKey: `${environmentId}/${accessType}/${fileName}` }); + storageCache.revalidate({ fileKey: `${repositoryId}/${accessType}/${fileName}` }); return Response.json( { data: message, diff --git a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/getFile.ts similarity index 70% rename from app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts rename to app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/getFile.ts index 7f6ff00..4da6a6c 100644 --- a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/lib/getFile.ts +++ b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/lib/getFile.ts @@ -1,8 +1,8 @@ import { getS3File } from "@/lib/storage/service"; -const getFile = async (environmentId: string, accessType: string, fileName: string) => { +const getFile = async (repositoryId: string, accessType: string, fileName: string) => { try { - const signedUrl = await getS3File(`${environmentId}/${accessType}/${fileName}`); + const signedUrl = await getS3File(`${repositoryId}/${accessType}/${fileName}`); return new Response(null, { status: 302, diff --git a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/route.ts b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/route.ts similarity index 78% rename from app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/route.ts rename to app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/route.ts index 3532a10..e5794d4 100644 --- a/app/(dashboard)/storage/[environmentId]/[accessType]/[fileName]/route.ts +++ b/app/(dashboard)/storage/[repositoryId]/[accessType]/[fileName]/route.ts @@ -7,22 +7,21 @@ import getFile from "./lib/getFile"; export async function GET( _: NextRequest, - { params }: { params: { environmentId: string; accessType: string; fileName: string } } + { params }: { params: { repositoryId: string; accessType: string; fileName: string } } ) { const paramValidation = ZStorageRetrievalParams.safeParse(params); - if (!paramValidation.success) { return new Response("Fields are missing or incorrectly formatted", { status: 400 }); } - const { environmentId, accessType, fileName: fileNameOG } = paramValidation.data; + const { repositoryId, accessType, fileName: fileNameOG } = paramValidation.data; const fileName = decodeURIComponent(fileNameOG); // maybe we might have private files in future that would require some sort of authentication if (accessType === "public") { - return await getFile(environmentId, accessType, fileName); + return await getFile(repositoryId, accessType, fileName); } // if the user is authenticated via the session @@ -32,7 +31,7 @@ export async function GET( return new Response("User must be authenticated to perform this action.", { status: 401 }); } - return await getFile(environmentId, accessType, fileName); + return await getFile(repositoryId, accessType, fileName); } export async function DELETE(_: NextRequest, { params }: { params: { fileName: string } }) { @@ -40,9 +39,9 @@ export async function DELETE(_: NextRequest, { params }: { params: { fileName: s return new Response("Fields are missing or incorrectly formatted", { status: 400 }); } - const [environmentId, accessType, file] = params.fileName.split("/"); + const [repositoryId, accessType, file] = params.fileName.split("/"); - const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType }); + const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, repositoryId, accessType }); if (!paramValidation.success) { return new Response("Fields are missing or incorrectly formatted", { status: 400 }); @@ -57,7 +56,7 @@ export async function DELETE(_: NextRequest, { params }: { params: { fileName: s } return await handleDeleteFile( - paramValidation.data.environmentId, + paramValidation.data.repositoryId, paramValidation.data.accessType, paramValidation.data.fileName ); diff --git a/app/[githubLogin]/page.tsx b/app/[githubLogin]/page.tsx index 38d4cce..fd6ee2c 100644 --- a/app/[githubLogin]/page.tsx +++ b/app/[githubLogin]/page.tsx @@ -1,9 +1,12 @@ +import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import GitHubIssue from "@/components/ui/githubIssue"; import { getEnrolledRepositories } from "@/lib/enrollment/service"; import { getMergedPullRequestsByGithubLogin, getOpenPullRequestsByGithubLogin } from "@/lib/github/service"; import { getGithubUserByLogin } from "@/lib/githubUser/service"; +import { getPointsForUserInRepoByRepositoryId } from "@/lib/points/service"; import { getUserByLogin } from "@/lib/user/service"; +import { findCurrentAndNextLevelOfCurrentUser } from "@/lib/utils/levelUtils"; import Rick from "@/public/rick.webp"; import Image from "next/image"; import Link from "next/link"; @@ -107,14 +110,50 @@ export default async function ProfilePage({ params }) { const userEnrollments = await getEnrolledRepositories(user?.id); + const arrayCurrentLevelOfUserInEnrolledRepos = await Promise.all( + userEnrollments.map(async (repo) => { + const totalPointsForUserInThisRepo = await getPointsForUserInRepoByRepositoryId( + repo.id, + user?.id || "" + ); + const { currentLevelOfUser } = findCurrentAndNextLevelOfCurrentUser( + repo, + totalPointsForUserInThisRepo + ); + return { currentLevelOfUser: currentLevelOfUser, repoLogo: repo?.logoUrl || "" }; + }) + ); + return (
-
-
- {/*

#3

of 727

*/} +
+
+ {arrayCurrentLevelOfUserInEnrolledRepos.map((obj) => { + if (!obj.currentLevelOfUser) return; + return ( + <> +
+ + + + {obj.repoLogo && ( +
+ {"repo +
+ )} +
+ + ); + })}
-
+
{openPRs.length > 1 && (

Open PRs @ Formbricks by {githubUserData.name}

@@ -123,26 +162,24 @@ export default async function ProfilePage({ params }) { ))}
)} - {userEnrollments && (

Congrats!

{userEnrollments.map((item) => ( -
-
-
🎉
-
-

- {githubUserData?.name} enrolled to contribute to {item.name} -

-

Let the games begin!

-
+
+
🎉
+
+

+ {githubUserData?.name} enrolled to contribute to {item.name} +

+

Let the games begin!

))}
)} -
{mergedIssues.length > 1 ? ( <> diff --git a/app/api/storage/route.ts b/app/api/storage/route.ts index 4e98128..a4cd8dd 100644 --- a/app/api/storage/route.ts +++ b/app/api/storage/route.ts @@ -13,7 +13,7 @@ export interface ApiSuccessResponse { } export async function POST(req: NextRequest): Promise { - const { fileName, fileType, environmentId, allowedFileExtensions } = await req.json(); + const { fileName, fileType, repositoryId, allowedFileExtensions } = await req.json(); if (!fileName) { return new Response("fileName is required", { status: 400 }); @@ -45,7 +45,7 @@ export async function POST(req: NextRequest): Promise { const accessType = "public"; try { - const signedUrlResponse = await getUploadSignedUrl(fileName, environmentId, fileType, accessType); + const signedUrlResponse = await getUploadSignedUrl(fileName, repositoryId, fileType, accessType); return new Response(JSON.stringify({ data: signedUrlResponse }), { status: 200, }); diff --git a/components/delete-level-modal.tsx b/components/delete-level-modal.tsx index f0a3b76..81c1209 100644 --- a/components/delete-level-modal.tsx +++ b/components/delete-level-modal.tsx @@ -1,49 +1,61 @@ -import { deleteUserAction } from "@/app/(dashboard)/settings/actions"; +"use client"; + +import { deleteLevelAction } from "@/app/(dashboard)/repo-settings/[repositoryId]/levels/action"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; import { useToast } from "@/components/ui/use-toast"; -import { signOut } from "next-auth/react"; import { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; +interface DeleteLevelModalProps { + showDeleteLevelModal: boolean; + setShowDeleteLevelModal: Dispatch>; + setIsEditMode: Dispatch>; + repositoryId: string; + levelId: string; + iconUrl: string; +} + function DeleteLevelModal({ showDeleteLevelModal, setShowDeleteLevelModal, -}: { - showDeleteLevelModal: boolean; - setShowDeleteLevelModal: Dispatch>; -}) { + repositoryId, + levelId, + setIsEditMode, + iconUrl, +}: DeleteLevelModalProps) { const [deleting, setDeleting] = useState(false); const { toast } = useToast(); - async function deleteLevel() { + const handleDeleteLevel = async () => { + setDeleting(true); try { - setDeleting(true); - // await deleteUserAction(); - // signOut({ - // callbackUrl: `${window.location.origin}/login`, - // }); - } catch (error) { + await deleteLevelAction(repositoryId!, levelId!, iconUrl); + } catch (err) { toast({ - title: `Error deleting level`, - description: error.message, + title: "Error", + description: "Failed to delete level.", }); } finally { setDeleting(false); + setShowDeleteLevelModal(false); + setIsEditMode(false); } - } + }; return ( - -
-

Are you sure you want to delete this level?

+ +
+

Are you sure you want to delete this level?

- This action cannot be undone and will permanently delete this level + This action cannot be undone and will permanently delete this level

-
-
@@ -51,14 +63,18 @@ function DeleteLevelModal({ ); } -export function useDeleteLevelModal() { +export function useDeleteLevelModal(repositoryId: string, levelId: string, setIsEditMode, iconUrl: string) { const [showDeleteLevelModal, setShowDeleteLevelModal] = useState(false); const DeleteLevelModalCallback = useCallback(() => { return ( ); }, [showDeleteLevelModal, setShowDeleteLevelModal]); diff --git a/components/forms/levels-form.tsx b/components/forms/levels-form.tsx index 51d4166..dc8180a 100644 --- a/components/forms/levels-form.tsx +++ b/components/forms/levels-form.tsx @@ -1,10 +1,9 @@ "use client"; -/* import { +import { createLevelAction, - deleteLevelAction, - updateLevelIconAction, -} from "@/app/(dashboard)/repo-settings/[repositoryId]/levels/action"; + updateLevelAction, +} from "@/app/(dashboard)/repo-settings/[repositoryId]/levels/action"; import { handleFileUpload } from "@/app/(dashboard)/repo-settings/[repositoryId]/levels/lib"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -20,157 +19,130 @@ import { import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { TagInput, Tag as TagType } from "@/components/ui/tag-input"; +import { TFormSchema, TLevel, ZFormSchema } from "@/types/level"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useParams, useRouter } from "next/navigation"; -import React from "react"; +import { useParams } from "next/navigation"; +import React, { Dispatch, SetStateAction, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; import { useDeleteLevelModal } from "../delete-level-modal"; import { useToast } from "../ui/use-toast"; -const formSchema = z.object({ - levelName: z.string().min(3, { - message: "level name must be at least 3 characters.", - }), - pointThreshold: z.string().refine((val) => !Number.isNaN(parseInt(val, 10)), { - message: "Expected number, received a string", - }), - description: z.string().min(10, { - message: "description must be at least 10 characters.", - }), - icon: z.custom(), - - topics: z.array( - z.object({ - id: z.string(), - text: z.string(), - }) - ), - limitIssues: z.boolean(), - canReportBugs: z.boolean(), - canHuntBounties: z.boolean(), -}); - export interface LevelsFormProps { - levelName?: string; - pointThreshold?: number; - description?: string; - topics?: TagType[]; - limitIssues?: boolean; - iconUrl?: string; - canReportBugs?: boolean; - canHuntBounties?: boolean; - isForm?: boolean; + level: TLevel | null; + isForm: boolean; + setShowForm: Dispatch>; } export function LevelsForm({ - levelName, - canHuntBounties, - canReportBugs, - description, - iconUrl, - limitIssues, - pointThreshold, - topics, - // pass the isForm when you want the levelForm to be used as a form - isForm, + level, + setShowForm, + isForm, // pass the isForm when you want the levelForm to be used as a form }: LevelsFormProps) { - // 1. Define your form. - const form = useForm>({ - resolver: zodResolver(formSchema), + const { id, name, pointThreshold, description, iconUrl } = level ?? {}; + + const { limitIssues, canReportBugs, canHuntBounties, issueLabels } = level?.permissions || {}; + + const [newIconUrl, setNewIconUrl] = useState(iconUrl); //this will be just used to show the new image when image is replaced during an edit of a level. + const [isEditMode, setIsEditMode] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLimitIssues, setIsLimitIssues] = useState(limitIssues ?? false); + const [tags, setTags] = useState(issueLabels || []); + + const fileInputRef = useRef(null); + const { repositoryId } = useParams() as { repositoryId: string }; + + const { setShowDeleteLevelModal, DeleteLevelModal } = useDeleteLevelModal( + repositoryId, + id!, + setIsEditMode, + iconUrl! //if delete button is shown then it means it has image present. + ); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZFormSchema), defaultValues: { - levelName: levelName || "", + id: id || uuidv4(), + name: name || "", description: description || "", + iconUrl: iconUrl || "", limitIssues: limitIssues ?? false, pointThreshold: (pointThreshold ?? 0).toString(), - topics: topics || [], + issueLabels: issueLabels || [], canHuntBounties: canHuntBounties ?? false, canReportBugs: canReportBugs ?? false, }, }); - const [tags, setTags] = React.useState([]); - const [isEditMode, setIsEditMode] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); - const fileInputRef = React.useRef(null); - const { setShowDeleteLevelModal, DeleteLevelModal } = useDeleteLevelModal(); - const { toast } = useToast(); - const router = useRouter(); - const { repositoryId } = useParams() as { repositoryId: string }; - - const { setValue } = form; - const isFieldDisabled = (defaultValue: any) => { return !isEditMode && defaultValue !== undefined && defaultValue !== ""; }; - // 2. Define a submit handler. - async function onSubmit(values: z.infer) { + const handleCreateUpdateLevel = async (values: TFormSchema, isCreate: boolean) => { setIsLoading(true); try { - const icon = values.icon; - const { url, error } = await handleFileUpload(icon, repositoryId); - if (error) { - toast({ - title: "Error", - description: error, - }); - setIsLoading(false); + const isValid = await form.trigger(); + if (!isValid) { return; } - // call the server action to create a new level for the repository - await createLevelAction({ - name: values.levelName, - description: values.description, - pointThreshold: parseInt(values.pointThreshold, 10), - icon: url, - repositoryId: repositoryId, - permissions: { - canWorkOnIssues: values.limitIssues, - issueLabels: tags.map((tag) => tag.text), - canWorkOnBugs: values.canReportBugs, - canHuntBounties: values.canHuntBounties, - }, - tags: values.topics.map((tag) => tag.text), - }); - router.refresh(); - } catch (err) { - toast({ - title: "Error", - description: "Failed to create level. Please try ", - }); - setIsLoading(false); - } - setIsLoading(false); - } + const permissions = { + limitIssues: values.limitIssues, + canReportBugs: values.canReportBugs, + canHuntBounties: values.canHuntBounties, + issueLabels: isLimitIssues ? tags : [], + }; - const handleDeleteLevel = async () => { - // delete level action here + if (isCreate) { + const { url, error } = await handleFileUpload(values.iconUrl, repositoryId); - setIsLoading(true); - try { - //call the delete level action - await deleteLevelAction({ - name: levelName!, - repositoryId: repositoryId, - }); + if (error) { + toast({ + title: "Error", + description: error, + }); + return; + } - setIsLoading(false); + await createLevelAction({ + id: values.id, + name: values.name, + description: values.description, + pointThreshold: parseInt(values.pointThreshold, 10), + iconUrl: url, + repositoryId: repositoryId, + permissions: permissions, + }); + } else { + await updateLevelAction({ + id: values.id, + name: values.name, + description: values.description, + pointThreshold: parseInt(values.pointThreshold, 10), + iconUrl: values.iconUrl, + repositoryId: repositoryId, + permissions: permissions, + }); + setIsEditMode(false); + } } catch (err) { toast({ title: "Error", - description: "Level Update failed. Please try again.", + description: `Failed to ${isCreate ? "create" : "update"} level.`, }); + } finally { + setShowForm(false); setIsLoading(false); } }; async function handleFileChange(event: React.ChangeEvent) { const file = event.target.files?.[0]; + if (file) { - //call the s3 upload function to upload the image setIsLoading(true); + try { const { url, error } = await handleFileUpload(file, repositoryId); @@ -180,52 +152,39 @@ export function LevelsForm({ description: error, }); - setIsLoading(false); - return; - } - //call the update file action - if (!levelName) { - toast({ - title: "Error", - description: "Level name is required", - }); - - setIsLoading(false); return; } - await updateLevelIconAction({ - name: levelName, - repositoryId: repositoryId, - iconUrl: url, - }); + form.setValue("iconUrl", url); //this is necessary to make sure that form has the updated value of image.This iconUrl from form is being set to db so thus necessary. - router.refresh(); + setNewIconUrl(url); //this updates the old image with new. } catch (err) { toast({ title: "Error", description: "Avatar update failed. Please try again.", }); + } finally { setIsLoading(false); } } } + return (
handleCreateUpdateLevel(form.getValues(), true))} className="flex w-full gap-4 rounded-lg border border-gray-200 p-7 ">

Level

( - + Level Name - + @@ -235,7 +194,7 @@ export function LevelsForm({ control={form.control} name="pointThreshold" render={({ field }) => ( - + Point Threshold )} /> - {isFieldDisabled(iconUrl) ? ( + + {/* when edit and image => show image and replace button + when !edit and image => show image + when creating level for the first time => show upload component */} + {!isEditMode && iconUrl ? (
+
+ ) : isEditMode && iconUrl ? ( +
+ + +
-
- )} + {isFieldDisabled(issueLabels) + ? (issueLabels ?? []).length > 0 && ( +
+ {(issueLabels ?? []).map((tag, index) => ( + + {tag.text} + + ))} +
+ ) + : isLimitIssues && ( +
+ ( + + + { + setTags(newTags); + field.onChange(newTags); + }} + /> + + + + )} + /> +
+ )}
{isForm ? ( - ) : ( @@ -416,12 +387,12 @@ export function LevelsForm({ onClick={() => { setShowDeleteLevelModal(true); }} - loading={isLoading} + disabled={isLoading} variant="destructive"> Delete -
@@ -440,4 +411,3 @@ export function LevelsForm({ ); } -*/ diff --git a/components/level-form-container.tsx b/components/level-form-container.tsx index 519f5ba..e9f0c61 100644 --- a/components/level-form-container.tsx +++ b/components/level-form-container.tsx @@ -1,38 +1,34 @@ -/* "use client"; +"use client"; -import React from "react"; +import { TLevel } from "@/types/level"; +import React, { useEffect, useRef, useState } from "react"; -import { LevelsFormProps } from "./forms/levels-form"; +import { LevelsForm } from "./forms/levels-form"; import { Button } from "./ui/button"; -export default function LevelsFormContainer({ data }: { data: LevelsFormProps[] }) { - const [showForm, setShowForm] = React.useState(false); +export default function LevelsFormContainer({ levelsData }: { levelsData: TLevel[] }) { + const [showForm, setShowForm] = useState(false); + const containerRef = useRef(null); + + const toggleFormVisibility = () => { + setShowForm(!showForm); + }; + + useEffect(() => { + if (containerRef.current && showForm) { + containerRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); + } + }, [showForm]); return ( -
- {data.map((level, index) => ( - +
+ {levelsData.map((level) => ( + ))} - {/* shows an empty levels form when you click the add level */ -/* - {showForm && } - + {showForm && }
- +
); } - */ diff --git a/components/ui/conditionalParagraph.tsx b/components/ui/conditionalParagraph.tsx new file mode 100644 index 0000000..2eca4ae --- /dev/null +++ b/components/ui/conditionalParagraph.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { FaCheckCircle } from "react-icons/fa"; +import { FaRegCircle } from "react-icons/fa"; + +interface ConditionalParagraphProps { + condition: boolean; + text: string; +} + +export function ConditionalParagraphAssignable({ condition, text }: ConditionalParagraphProps) { + return condition ? ( +
+ +

{text}

+
+ ) : null; +} +export function ConditionalParagraphNonAssignable({ text }: { text: string }) { + return ( +
+ +

{text}

+
+ ); +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx index a89ecaa..d6d9d18 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -71,7 +71,7 @@ const FormItem = React.forwardRef -
+
); } diff --git a/components/ui/githubIssue.tsx b/components/ui/githubIssue.tsx index ef17cc9..1752703 100644 --- a/components/ui/githubIssue.tsx +++ b/components/ui/githubIssue.tsx @@ -1,21 +1,52 @@ +import { + ModifiedTagsArray, + calculateAssignabelNonAssignableIssuesForUserInALevel, +} from "@/lib/utils/levelUtils"; +import { TLevel } from "@/types/level"; import { GitMerge, GitPullRequestDraft } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -interface Issue { - href: string; - logoUrl: string; - title: string; - author: string; - repository?: string; - assignee?: string | null; - state?: string; - draft?: boolean; - isIssue: boolean; - points?: null | number; +import { Avatar, AvatarImage } from "./avatar"; + +interface IssueProps { + issue: { + href: string; + logoUrl: string; + title: string; + author: string; + repository?: string; + assignee?: string | null; + state?: string; + draft?: boolean; + isIssue: boolean; + points?: null | number; + labels?: string[]; + }; + levelsInRepo?: TLevel[]; } -const GitHubIssue: React.FC<{ issue: Issue }> = ({ issue }) => { +const GitHubIssue = ({ issue, levelsInRepo }: IssueProps) => { + const modifiedTagsArray: ModifiedTagsArray[] = calculateAssignabelNonAssignableIssuesForUserInALevel( + levelsInRepo || [] + ); + + const iconUrlOfLevelsMatchingLabelsSet: Set = new Set(); + + levelsInRepo && + modifiedTagsArray.forEach((modifiedTag) => { + issue.labels?.forEach((label) => { + if (modifiedTag.assignableIssues.includes(label)) { + const level = levelsInRepo.find((level) => level.id === modifiedTag.levelId); + if (level) { + iconUrlOfLevelsMatchingLabelsSet.add(level.iconUrl); + } + } + }); + }); + + const iconUrlOfLevelsMatchingLabelsArray: string[] = Array.from(iconUrlOfLevelsMatchingLabelsSet); + return ( = ({ issue }) => { opened by {issue.author} {issue.repository && in {issue.repository}}

-
+
+ {iconUrlOfLevelsMatchingLabelsArray.length > 0 && ( +
+ {iconUrlOfLevelsMatchingLabelsArray.map((iconUrl) => { + return ( +
+ + + +
+ ); + })} +
+ )} + {issue.assignee && issue.isIssue ? ( -
+
{issue.assignee} 🚧
) : ( issue.isIssue && ( -
+
Assign yourself 🫵
) )} {issue.points && issue.points !== null && ( -
+
{issue.points} Points 🔥
)} diff --git a/components/ui/layoutTabs.tsx b/components/ui/layoutTabs.tsx new file mode 100644 index 0000000..0ca353b --- /dev/null +++ b/components/ui/layoutTabs.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +interface LayoutTabsProps { + tabsData: { + href: string; + value: string; + label: string; + }[]; + tabNumberInUrlPathSegment: number; + defaultTab: string; +} + +export default function LayoutTabs({ tabsData, tabNumberInUrlPathSegment, defaultTab }: LayoutTabsProps) { + const path = usePathname(); + const activeTab = path?.split("/")[tabNumberInUrlPathSegment] || defaultTab; + + return ( + + + {tabsData.map((tab, index) => ( + + {tab.label} + + ))} + + + ); +} diff --git a/components/ui/levelDetailCard.tsx b/components/ui/levelDetailCard.tsx new file mode 100644 index 0000000..9e84ab0 --- /dev/null +++ b/components/ui/levelDetailCard.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { ModifiedTagsArray, calculateLevelProgress } from "@/lib/utils/levelUtils"; +import { TLevel } from "@/types/level"; +import { capitalizeEachWord } from "lib/utils/textformat"; +import { FaCheckCircle } from "react-icons/fa"; + +import { Avatar, AvatarImage } from "./avatar"; +import { ConditionalParagraphAssignable, ConditionalParagraphNonAssignable } from "./conditionalParagraph"; +import ProgressBar from "./progressBar"; + +interface LevelDetailCardProps { + level: TLevel; + idx: number; + modifiedTagsArray: ModifiedTagsArray[]; + totalPoints: number; + currentLevelOfUser: TLevel | null; + nextLevelForUser: TLevel | null; + sortedLevels: TLevel[]; +} + +const LevelDetailCard = ({ + level, + idx, + modifiedTagsArray, + totalPoints, + currentLevelOfUser, + nextLevelForUser, + sortedLevels, +}: LevelDetailCardProps) => { + const { progressMadeInThisLevel } = calculateLevelProgress( + totalPoints, + currentLevelOfUser, + nextLevelForUser + ); + const isLevelCompleted = idx <= sortedLevels.length - 1 && totalPoints > sortedLevels[idx].pointThreshold; + const completedCurrentLevelPercentage = progressMadeInThisLevel * 100; + const isNotStarted = totalPoints < level.pointThreshold; + + let levelStatus: string | React.ReactNode = ""; + let progressBarStatus: number; + + if (isLevelCompleted) { + levelStatus = ( + <> + Completed + + ); + progressBarStatus = 1; //1 means 100 in % + } else if (isNotStarted) { + levelStatus = `Not started`; + progressBarStatus = 0; + } else { + levelStatus = `${completedCurrentLevelPercentage}% Complete`; + progressBarStatus = progressMadeInThisLevel; + } + + return ( +
+ {level.id === currentLevelOfUser?.id && ( +
+ Your current level +
+ )} +
+ + + +
Level {idx}
+
{capitalizeEachWord(level.name)}
+
{level.description}
+
+
+
Powers
+ + + {modifiedTagsArray.map((item) => { + const allIssues = [...item.assignableIssues, ...item.nonAssignableIssues]; + + return ( + item.levelId === level.id && + allIssues.map((tag) => { + const isAssignable = item.assignableIssues.includes(tag); + return isAssignable ? ( + + ) : ( + + ); + }) + ); + })} +
+
+ {sortedLevels[idx] && ( + <> +
Points to level up
+
{sortedLevels[idx].pointThreshold}
+ +
+ {levelStatus} +
+ + )} +
+
+ ); +}; + +export default LevelDetailCard; diff --git a/components/ui/pointsCard.tsx b/components/ui/pointsCard.tsx deleted file mode 100644 index 722ebdf..0000000 --- a/components/ui/pointsCard.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { capitalizeFirstLetter } from "lib/utils/textformat"; -import Image from "next/image"; - -interface PointsCardProps { - repositoryName: string; - repositoryLogo?: string | null; - points: number; - rank: number | null; -} - -const PointsCard = ({ repositoryName, repositoryLogo, points, rank }: PointsCardProps) => { - return ( - <> -
-
-
-
{capitalizeFirstLetter(repositoryName)}
- {repositoryLogo && ( - repository-logo - )} -
-

{points}

-

{rank === null ? "No points, no rank 🤷" : `# Rank ${rank}`}

-
-
- - ); -}; - -export default PointsCard; diff --git a/components/ui/progressBar.tsx b/components/ui/progressBar.tsx new file mode 100644 index 0000000..de0de22 --- /dev/null +++ b/components/ui/progressBar.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface ProgressBarProps { + progress: number; + barColor?: string; + height: number; + className?: string; +} + +export default function ProgressBar({ progress, barColor, height, className }: ProgressBarProps) { + return ( +
+
+
+ ); +} diff --git a/components/ui/tag-input.tsx b/components/ui/tag-input.tsx index 172cc77..8a480ae 100644 --- a/components/ui/tag-input.tsx +++ b/components/ui/tag-input.tsx @@ -8,8 +8,8 @@ import { v4 as uuid } from "uuid"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { toast } from "../ui/use-toast"; -import { Autocomplete } from "./tag-auto-complete"; import { tagVariants } from "./tag"; +import { Autocomplete } from "./tag-auto-complete"; import { TagList } from "./tag-list"; import { TagPopover } from "./tag-popover"; @@ -90,7 +90,7 @@ const TagInput = React.forwardRef((props, ref) truncate, autocompleteFilter, borderStyle, - textCase, + textCase = null, interaction, animation, textStyle, @@ -132,61 +132,64 @@ const TagInput = React.forwardRef((props, ref) onInputChange?.(newValue); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (delimiterList ? delimiterList.includes(e.key) : e.key === delimiter || e.key === Delimiter.Enter) { - e.preventDefault(); - const newTagText = inputValue.trim(); + const addLabel = () => { + const newTagText = inputValue.trim(); - // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true - if ( - restrictTagsToAutocompleteOptions && - !autocompleteOptions?.some((option) => option.text === newTagText) - ) { - toast({ - title: "Invalid Tag", - description: "Please select a tag from the autocomplete options.", - variant: "destructive", - }); - return; - } + // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true + if ( + restrictTagsToAutocompleteOptions && + !autocompleteOptions?.some((option) => option.text === newTagText) + ) { + toast({ + title: "Invalid Tag", + description: "Please select a tag from the autocomplete options.", + variant: "destructive", + }); + return; + } - if (validateTag && !validateTag(newTagText)) { - return; - } + if (validateTag && !validateTag(newTagText)) { + return; + } - if (minLength && newTagText.length < minLength) { - console.warn("Tag is too short"); - toast({ - title: "Tag is too short", - description: "Please enter a tag with more characters", - variant: "destructive", - }); - return; - } + if (minLength && newTagText.length < minLength) { + console.warn("Tag is too short"); + toast({ + title: "Tag is too short", + description: "Please enter a tag with more characters", + variant: "destructive", + }); + return; + } - // Validate maxLength - if (maxLength && newTagText.length > maxLength) { - toast({ - title: "Tag is too long", - description: "Please enter a tag with less characters", - variant: "destructive", - }); - console.warn("Tag is too long"); - return; - } + // Validate maxLength + if (maxLength && newTagText.length > maxLength) { + toast({ + title: "Tag is too long", + description: "Please enter a tag with less characters", + variant: "destructive", + }); + console.warn("Tag is too long"); + return; + } - const newTagId = uuid(); + const newTagId = uuid(); - if ( - newTagText && - (allowDuplicates || !tags.some((tag) => tag.text === newTagText)) && - (maxTags === undefined || tags.length < maxTags) - ) { - setTags([...tags, { id: newTagId, text: newTagText }]); - onTagAdd?.(newTagText); - setTagCount((prevTagCount) => prevTagCount + 1); - } - setInputValue(""); + if ( + newTagText && + (allowDuplicates || !tags.some((tag) => tag.text === newTagText)) && + (maxTags === undefined || tags.length < maxTags) + ) { + setTags([...tags, { id: newTagId, text: newTagText }]); + onTagAdd?.(newTagText); + setTagCount((prevTagCount) => prevTagCount + 1); + } + setInputValue(""); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (delimiterList ? delimiterList.includes(e.key) : e.key === delimiter || e.key === Delimiter.Enter) { + e.preventDefault(); + addLabel(); } }; @@ -309,7 +312,7 @@ const TagInput = React.forwardRef((props, ref)
) : ( -
+
{!usePopoverForTags ? ( ((props, ref) /> )} +
)} {showCount && maxTags && ( diff --git a/components/ui/userPointsAndLevelCard.tsx b/components/ui/userPointsAndLevelCard.tsx new file mode 100644 index 0000000..f0e92cf --- /dev/null +++ b/components/ui/userPointsAndLevelCard.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { calculateLevelProgress } from "@/lib/utils/levelUtils"; +import { TLevel } from "@/types/level"; +import { capitalizeEachWord, capitalizeFirstLetter } from "lib/utils/textformat"; +import Image from "next/image"; +import Link from "next/link"; + +import { Avatar, AvatarImage } from "./avatar"; +import { ConditionalParagraphAssignable } from "./conditionalParagraph"; +import ProgressBar from "./progressBar"; + +interface UserPointsAndLevelCardProps { + repositoryName: string; + repositoryId: string; + repositoryLogo?: string | null; + points: number; + rank: number | null; + currentLevelOfUser: TLevel | null; + nextLevelForUser: TLevel | null; + assignableTags: string[]; +} + +const UserPointsAndLevelCard = ({ + repositoryName, + repositoryLogo, + repositoryId, + points, + rank, + currentLevelOfUser, + nextLevelForUser, + assignableTags, +}: UserPointsAndLevelCardProps) => { + return ( +
+ {currentLevelOfUser && ( + + )} + +
+ ); +}; + +export default UserPointsAndLevelCard; + +const UserLevelCard = ({ currentLevelOfUser, assignableTags }) => { + return ( +
+ + + +
{capitalizeEachWord(currentLevelOfUser.name)}
+ + + {assignableTags.map((tag, idx) => { + return ( + + ); + })} +
+ ); +}; + +const UserPointsCard = ({ + repositoryName, + repositoryLogo, + repositoryId, + points, + rank, + currentLevelOfUser, + nextLevelForUser, +}) => { + const { progressMadeInThisLevel } = calculateLevelProgress(points, currentLevelOfUser, nextLevelForUser); + return ( +
+ +
+
{capitalizeFirstLetter(repositoryName)}
+ {repositoryLogo && ( + repository-logo + )} +
+ +

{points}

+ +

{rank === null ? "No points, no rank 🤷" : `# Rank ${rank}`}

+ + {currentLevelOfUser && ( +
+ +
+
+
{capitalizeFirstLetter(currentLevelOfUser?.name)}
+
{currentLevelOfUser?.pointThreshold}
+
+ {nextLevelForUser?.pointThreshold && ( +
+
{capitalizeFirstLetter(nextLevelForUser.name)}
+
{nextLevelForUser.pointThreshold}
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/components/ui/userProfileSummary.tsx b/components/ui/userProfileSummary.tsx index 8ae3e4b..e914c1b 100644 --- a/components/ui/userProfileSummary.tsx +++ b/components/ui/userProfileSummary.tsx @@ -28,36 +28,37 @@ const UserProfileSummary: React.FC = ({ return; }; return ( -
-
-
#{index}
-
- + +
+
+
#{index}
+
+ +
+ +
{name}
- -
{name}
- -
-
- {showSettingButtons && ( -
- - - +
+ {showSettingButtons && ( +
+ + + +
+ )} +
+ {points} points
- )} -
- {points} points
-
+ ); }; export default UserProfileSummary; diff --git a/lib/enrollment/service.ts b/lib/enrollment/service.ts index eedb1c5..b96dd9a 100644 --- a/lib/enrollment/service.ts +++ b/lib/enrollment/service.ts @@ -112,6 +112,7 @@ export const getEnrolledRepositories = async (userId: string): Promise { return; } + //checking if the current level of user has the power to solve the issue on which the /assign comment was made. + const currentRepo = await getRepositoryByGithubId(context.payload.repository.id); + const user = await getUserByGithubId(context.payload.comment.user.id); + + if (currentRepo && user) { + const userTotalPoints = await getPointsForUserInRepoByRepositoryId(currentRepo.id, user.id); + const { currentLevelOfUser } = findCurrentAndNextLevelOfCurrentUser( + currentRepo as TRepository, + userTotalPoints + ); //this just has tags that limit the user to take on task of higher level but misses out on tags of lower levels. + + const levels = currentRepo?.levels as TLevel[]; + const modifiedTagsArray = calculateAssignabelNonAssignableIssuesForUserInALevel(levels); //gets all assignable tags be it from the current level and from lower levels. + + const labels = context.payload.issue.labels; + const tags = modifiedTagsArray.find((item) => item.levelId === currentLevelOfUser?.id); //finds the curent level in the modifiedTagsArray. + + const isAssignable = labels.some((label) => { + return tags?.assignableIssues.includes(label.name); + }); + + if (!isAssignable) { + await octokit.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `With your current level, you are not yet able to work on this issue. Please have a look on oss.gg to see which levels can work on which issues 🤓`, + }); + return; + } + } await octokit.issues.addAssignees({ owner, repo, diff --git a/lib/levels/cache.ts b/lib/levels/cache.ts new file mode 100644 index 0000000..1b069c4 --- /dev/null +++ b/lib/levels/cache.ts @@ -0,0 +1,18 @@ +import { revalidateTag } from "next/cache"; + +interface RevalidateProps { + repositoryId?: string; +} + +export const levelsCache = { + tag: { + byRepositoryId(repositoryId: string) { + return `repositories-${repositoryId}-levels`; + }, + }, + revalidate({ repositoryId }: RevalidateProps): void { + if (repositoryId) { + revalidateTag(this.tag.byRepositoryId(repositoryId)); + } + }, +}; diff --git a/lib/levels/service.ts b/lib/levels/service.ts index 9b5d239..9a31131 100644 --- a/lib/levels/service.ts +++ b/lib/levels/service.ts @@ -1,121 +1,162 @@ import { db } from "@/lib/db"; +import { ZId, ZString } from "@/types/common"; +import { TLevel, ZLevel } from "@/types/level"; +import { unstable_cache } from "next/cache"; -export const assignUserPoints = async ( - userId: string, - points: number, - description: string, - url: string, - repositoryId: string -) => { +import { DEFAULT_CACHE_REVALIDATION_INTERVAL } from "../constants"; +import { validateInputs } from "../utils/validate"; +import { levelsCache } from "./cache"; + +export const createLevel = async (levelData: TLevel): Promise => { + validateInputs([levelData, ZLevel]); try { - const alreadyAssignedPoints = await db.pointTransaction.findFirst({ + const repository = await db.repository.findUnique({ where: { - userId, - repositoryId, - url, + id: levelData.repositoryId, }, + select: { levels: true }, }); - if (alreadyAssignedPoints) { - throw new Error("Points already assigned for this user for the given url"); + + if (!repository) { + throw new Error("Repository not found"); } - const pointsUpdated = await db.pointTransaction.create({ - data: { - points, - userId, - description, - url, - repositoryId, - }, - }); - return pointsUpdated; - } catch (error) { - throw error; - } -}; + const existingLevels = (repository.levels || []) as TLevel[]; -//TODO: this should create and update the JSON in the repo model + existingLevels.push(levelData); -/* -export const createLevel = async (LevelData: TLevelInput) => { - try { - await db.level.create({ + const updatedRepositoryWithNewLevel = await db.repository.update({ + where: { + id: levelData.repositoryId, + }, data: { - description: LevelData.description, - name: LevelData.name, - pointThreshold: LevelData.pointThreshold, - icon: LevelData.icon, - repositoryId: LevelData.repositoryId, - permissions: LevelData.permissions, - tags: LevelData.tags, + levels: existingLevels, }, }); + + levelsCache.revalidate({ + repositoryId: updatedRepositoryWithNewLevel.id, + }); + + return updatedRepositoryWithNewLevel.levels as TLevel[]; } catch (error) { throw error; } -}; +}; -export const updateLevel = async (LevelData: TLevelInput) => { +export const updateLevel = async (levelData: TLevel): Promise => { + validateInputs([levelData, ZLevel]); try { - await db.level.update({ + const repository = await db.repository.findUnique({ where: { - repositoryId: LevelData.repositoryId, - name: LevelData.name, + id: levelData.repositoryId, }, - data: { - description: LevelData.description, - pointThreshold: LevelData.pointThreshold, - icon: LevelData.icon, - permissions: LevelData.permissions, - name: LevelData.name, - tags: LevelData.tags, + select: { + levels: true, }, }); - } catch (error) { - throw error; - } -}; -//TODO: type this better -export const updateLevelIcon = async (name: string, repositoryId: string, iconUrl: string) => { - try { - await db.level.update({ + if (!repository) { + throw new Error("Repository not found"); + } + const levels = repository.levels as TLevel[]; + const existingLevelIndex = levels.findIndex((level: TLevel) => level.id === levelData.id); + + if (existingLevelIndex === -1) { + throw new Error("Level not found in repository"); + } + + const updatedLevels = [...levels]; + updatedLevels[existingLevelIndex] = levelData; + + const updatedRepositoryWithUpdatedLevel = await db.repository.update({ where: { - repositoryId, - name, + id: levelData.repositoryId, }, data: { - icon: iconUrl, + levels: updatedLevels, }, }); + levelsCache.revalidate({ + repositoryId: updatedRepositoryWithUpdatedLevel.id, + }); + return updatedRepositoryWithUpdatedLevel.levels as TLevel[]; } catch (error) { throw error; } }; -export const deleteLevel = async (name: string, repositoryId: string ) => { +export const deleteLevel = async (repositoryId: string, levelId: string): Promise => { + validateInputs([levelId, ZString], [repositoryId, ZId]); try { - await db.level.delete({ + const repository = await db.repository.findUnique({ where: { - repositoryId: repositoryId, - name: name, + id: repositoryId, }, + select: { levels: true }, }); - } catch (error) { - throw error; - } -}; -export const getLevels = async (repositoryId: string) => { - try { - const levels = await db.level.findMany({ + if (!repository) { + throw new Error("Repository not found"); + } + + const existingLevels = repository.levels as TLevel[]; + + const levelIndex = existingLevels.findIndex((level) => level.id === levelId); + + if (levelIndex === -1) { + throw new Error("Level not found in repository"); + } + + const deletedLevel = existingLevels[levelIndex]; + + existingLevels.splice(levelIndex, 1); + + const updatedRepositoryWithUpdatedLevels = await db.repository.update({ where: { - repositoryId, + id: repositoryId, + }, + data: { + levels: existingLevels, }, }); - return levels; + + levelsCache.revalidate({ + repositoryId: updatedRepositoryWithUpdatedLevels.id, + }); + + return deletedLevel; } catch (error) { throw error; } -} -*/ +}; + +export const getLevels = async (repositoryId: string): Promise => { + const levels = await unstable_cache( + async () => { + validateInputs([repositoryId, ZId]); + + try { + const repository = await db.repository.findUnique({ + where: { + id: repositoryId, + }, + select: { levels: true }, + }); + + if (!repository) { + throw new Error("Repository not found"); + } + return repository.levels as TLevel[]; + } catch (error) { + throw error; + } + }, + [`getLevels-${repositoryId}`], + { + tags: [levelsCache.tag.byRepositoryId(repositoryId)], + revalidate: DEFAULT_CACHE_REVALIDATION_INTERVAL, + } + )(); + return levels; +}; diff --git a/lib/points/service.ts b/lib/points/service.ts index 20124b6..95a1a43 100644 --- a/lib/points/service.ts +++ b/lib/points/service.ts @@ -6,7 +6,6 @@ import { Prisma } from "@prisma/client"; import { unstable_cache } from "next/cache"; import { DEFAULT_CACHE_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants"; -import { formatDateFields } from "../utils/datetime"; import { validateInputs } from "../utils/validate"; import { pointsCache } from "./cache"; @@ -44,28 +43,6 @@ export const assignUserPoints = async ( } }; -export const totalRepoPoints = async (userId: string, repositoryId: string) => { - - try { - const totalPoints = await db.pointTransaction.aggregate({ - _sum: { - points: true, - }, - where: { - userId, - repositoryId, - }, - }); - if (!totalPoints._sum.points) { - return 0; - } - return totalPoints._sum.points; - } catch (error) { - throw error; - } - -} - export const getPointsOfUsersInRepoByRepositoryId = async ( repositoryId: string, page?: number @@ -116,3 +93,26 @@ export const getPointsOfUsersInRepoByRepositoryId = async ( )(); return points; }; +export const getPointsForUserInRepoByRepositoryId = async ( + repositoryId: string, + userId: string +): Promise => { + try { + const userPoints = await db.pointTransaction.aggregate({ + where: { + repositoryId: repositoryId, + userId: userId, + }, + _sum: { + points: true, + }, + }); + + return userPoints._sum.points ?? 0; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/lib/storage/service.ts b/lib/storage/service.ts index b648939..ed17c8a 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -160,7 +160,7 @@ export const getS3File = async (fileKey: string): Promise => { // ingle service for generating a signed url based on user's environment variables export const getUploadSignedUrl = async ( fileName: string, - environmentId: string, + repositoryId: string, fileType: string, accessType: TAccessType ): Promise => { @@ -179,13 +179,13 @@ export const getUploadSignedUrl = async ( updatedFileName, fileType, accessType, - environmentId + repositoryId ); return { signedUrl, presignedFields, - fileUrl: new URL(`${WEBAPP_URL}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, + fileUrl: new URL(`${WEBAPP_URL}/storage/${repositoryId}/${accessType}/${updatedFileName}`).href, }; } catch (err) { throw err; @@ -196,7 +196,7 @@ export const getS3UploadSignedUrl = async ( fileName: string, contentType: string, accessType: string, - environmentId: string + repositoryId: string ) => { const maxSize = MAX_SIZE; const postConditions: PresignedPostOptions["Conditions"] = [["content-length-range", 0, maxSize]]; @@ -206,7 +206,7 @@ export const getS3UploadSignedUrl = async ( const { fields, url } = await createPresignedPost(s3Client, { Expires: 10 * 60, // 10 minutes Bucket: S3_BUCKET_NAME, - Key: `${environmentId}/${accessType}/${fileName}`, + Key: `${repositoryId}/${accessType}/${fileName}`, Fields: { "Content-Type": contentType, }, @@ -227,13 +227,13 @@ export const putFile = async ( fileName: string, fileBuffer: Buffer, accessType: TAccessType, - environmentId: string + repositoryId: string ) => { try { const input = { Body: fileBuffer, Bucket: S3_BUCKET_NAME, - Key: `${environmentId}/${accessType}/${fileName}`, + Key: `${repositoryId}/${accessType}/${fileName}`, }; const command = new PutObjectCommand(input); @@ -245,9 +245,9 @@ export const putFile = async ( } }; -export const deleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => { +export const deleteFile = async (repositoryId: string, accessType: TAccessType, fileName: string) => { try { - await deleteS3File(`${environmentId}/${accessType}/${fileName}`); + await deleteS3File(`${repositoryId}/${accessType}/${fileName}`); return { success: true, message: "File deleted" }; } catch (err: any) { if (err.name === "NoSuchKey") { @@ -272,14 +272,14 @@ export const deleteS3File = async (fileKey: string) => { } }; -export const deleteS3FilesByEnvironmentId = async (environmentId: string) => { +export const deleteS3FilesByRepositoryId = async (repositoryId: string) => { try { - // List all objects in the bucket with the prefix of environmentId + // List all objects in the bucket with the prefix of repositoryId const s3Client = getS3Client(); const listObjectsOutput = await s3Client.send( new ListObjectsCommand({ Bucket: S3_BUCKET_NAME, - Prefix: environmentId, + Prefix: repositoryId, }) ); diff --git a/lib/storage/utils.ts b/lib/storage/utils.ts new file mode 100644 index 0000000..af49f8f --- /dev/null +++ b/lib/storage/utils.ts @@ -0,0 +1,11 @@ +export const getFileNameWithIdFromUrl = (fileURL: string) => { + try { + const fileNameFromURL = fileURL.startsWith("/storage/") + ? fileURL.split("/").pop() + : new URL(fileURL).pathname.split("/").pop(); + + return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : ""; + } catch (error) { + console.error("Error parsing file URL:", error); + } +}; diff --git a/lib/utils/levelUtils.ts b/lib/utils/levelUtils.ts new file mode 100644 index 0000000..55c4c92 --- /dev/null +++ b/lib/utils/levelUtils.ts @@ -0,0 +1,80 @@ +import { TLevel } from "@/types/level"; +import { TRepository } from "@/types/repository"; + +export interface LevelProgress { + totalPointsNeededToReachNextLevel: number; + progressMadeInThisLevel: number; +} + +export interface ModifiedTagsArray { + levelId: string; + assignableIssues: string[]; + nonAssignableIssues: string[]; +} + +export const calculateLevelProgress = ( + totalPoints: number, + currentLevel: TLevel | null, + nextLevel: TLevel | null +): LevelProgress => { + if (!currentLevel) { + return { totalPointsNeededToReachNextLevel: 0, progressMadeInThisLevel: 0 }; + } + if (!nextLevel) { + // if no next level this means user is in the last level thus 1 + return { totalPointsNeededToReachNextLevel: 1, progressMadeInThisLevel: 1 }; + } + + const totalPointsNeededToReachNextLevel = nextLevel.pointThreshold - currentLevel.pointThreshold; + const progressMadeInThisLevel = + (totalPoints - currentLevel.pointThreshold) / totalPointsNeededToReachNextLevel; + return { + totalPointsNeededToReachNextLevel: totalPointsNeededToReachNextLevel, + progressMadeInThisLevel: progressMadeInThisLevel, + }; +}; + +export const calculateAssignabelNonAssignableIssuesForUserInALevel = (levels: TLevel[]) => { + const sortedLevels = levels.sort((a, b) => a.pointThreshold - b.pointThreshold); + + // Collect all tags from the sorted levels + const allTags = sortedLevels.reduce((tags, level) => { + const levelTags = level.permissions.issueLabels.map((tag) => tag.text); + return [...tags, ...levelTags]; + }, []); + + const modifiedArray: ModifiedTagsArray[] = []; + + sortedLevels.forEach((level, index) => { + const levelTags = level.permissions.issueLabels.map((tag) => tag.text); //creates an array of tags(text) for this level in the map/loop. + + const assignableIssues = + index > 0 ? [...modifiedArray[index - 1].assignableIssues, ...levelTags] : [...levelTags]; //makes an array of assignable issues/tags i.e issues of just previous level and the current level in map/loop. The previous level will always have all tags of its previous level and so on. + + const nonAssignableIssues = allTags.filter((tag) => !assignableIssues.includes(tag)); //makes an array of all tags that are not assignable for this level by removing the tags of the upcoming next levels from the allTags array. + + modifiedArray.push({ levelId: level.id, assignableIssues, nonAssignableIssues }); //pushing it into final array. + }); + + return modifiedArray; +}; +export const findCurrentAndNextLevelOfCurrentUser = (repository: TRepository, totalPoints: number) => { + // Find the levels whose threshold is just less amd just higher than the users total points in a repo. + let currentLevelOfUser: TLevel | null = null; + let nextLevelForUser: TLevel | null = null; + let highestThreshold = -1; + + for (const level of repository.levels) { + if (level.pointThreshold <= totalPoints && level.pointThreshold > highestThreshold) { + currentLevelOfUser = level; + highestThreshold = level.pointThreshold; + } else if ( + level.pointThreshold > totalPoints && + (nextLevelForUser === null || level.pointThreshold < nextLevelForUser.pointThreshold) + ) { + nextLevelForUser = level; + } + } + + return { currentLevelOfUser, nextLevelForUser }; +}; diff --git a/lib/utils/textformat.ts b/lib/utils/textformat.ts index 3b6f962..ba6fc1d 100644 --- a/lib/utils/textformat.ts +++ b/lib/utils/textformat.ts @@ -1,3 +1,12 @@ -export function capitalizeFirstLetter(text) { +export function capitalizeFirstLetter(text: string) { return text.charAt(0).toUpperCase() + text.slice(1); } +export function capitalizeEachWord(sentence: string) { + const words = sentence.split(" "); + + const capitalizedWords = words.map((word: string) => { + return capitalizeFirstLetter(word); + }); + + return capitalizedWords.join(" "); +} diff --git a/types/level.ts b/types/level.ts index da2cf15..f3b427c 100644 --- a/types/level.ts +++ b/types/level.ts @@ -1,28 +1,64 @@ import { z } from "zod"; -export const ZEnrollment = z.object({ - id: z.string().optional(), - userId: z.string(), - repositoryId: z.string(), - enrolledAt: z.date().optional(), -}); - -export type TEnrollment = z.infer; - -export const ZLevelInput = z.object({ +export const ZLevel = z.object({ + id: z.string(), name: z.string(), description: z.string(), pointThreshold: z.number(), - icon: z.string(), + iconUrl: z.string(), repositoryId: z.string(), permissions: z.object({ - canWorkOnIssues: z.boolean(), - issueLabels: z.array(z.string()), - canWorkOnBugs: z.boolean(), + limitIssues: z.boolean(), + issueLabels: z.array( + z.object({ + id: z.string(), + text: z.string(), + }) + ), + canReportBugs: z.boolean(), canHuntBounties: z.boolean(), }), - tags: z.array(z.string()), }); -export type TLevelInput = z.infer; +export type TLevel = z.infer; + +export const ZFormSchema = z + .object({ + id: z.string(), + name: z.string().min(3, { + message: "Level name must be at least 3 characters.", + }), + pointThreshold: z.string().refine((val) => !Number.isNaN(parseInt(val, 10)) && parseInt(val, 10) >= 0, { + message: "Threshold must be a number greater than or equal to 0.", + }), + + description: z.string().min(10, { + message: "Description must be at least 10 characters.", + }), + iconUrl: z.custom(), + + issueLabels: z.array( + z.object({ + id: z.string(), + text: z.string(), + }) + ), + limitIssues: z.boolean(), + canReportBugs: z.boolean(), + canHuntBounties: z.boolean(), + }) + .refine( + (data) => { + if (data.limitIssues && data.issueLabels.length === 0) { + return false; + } + return true; + }, + + { + message: "At least one issue label is required when limit issues is enabled.", + path: ["issueLabels"], + } + ); +export type TFormSchema = z.infer; diff --git a/types/storage.ts b/types/storage.ts index d6071a9..af8bd9f 100644 --- a/types/storage.ts +++ b/types/storage.ts @@ -5,8 +5,8 @@ export type TAccessType = z.infer; export const ZStorageRetrievalParams = z.object({ fileName: z.string(), - //TODO: add cuid validation to the environmentId - environmentId: z.string(), + //TODO: add cuid validation to the repositoryId + repositoryId: z.string(), accessType: ZAccessType, });