diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts index 9f550ed..8594c22 100644 --- a/app/(dashboard)/actions.ts +++ b/app/(dashboard)/actions.ts @@ -5,15 +5,15 @@ import { getCurrentUser } from "@/lib/session"; import { TRepository } from "@/types/repository"; export const getAllRepositoriesAction = async (): Promise<{ error: string } | TRepository[]> => { - try { - const user = await getCurrentUser(); - if (!user || !user.id) { - return ({ error: "User must be authenticated to perform this action." }); - } - const repositories = await getAllRepositories(); - return repositories as TRepository[]; - } catch (error) { - console.error("Error fetching repositories:", error.message); - return { error: "An unexpected error occurred." }; + try { + const user = await getCurrentUser(); + if (!user || !user.id) { + return { error: "User must be authenticated to perform this action." }; } - }; \ No newline at end of file + const repositories = await getAllRepositories(); + return repositories as TRepository[]; + } catch (error) { + console.error("Error fetching repositories:", error.message); + return { error: "An unexpected error occurred." }; + } +}; diff --git a/lib/constants.ts b/lib/constants.ts index 91e8159..a705f5d 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,5 +1,3 @@ -import "server-only"; - import { env } from "../env.mjs"; export const DEFAULT_CACHE_REVALIDATION_INTERVAL = 60 * 30; // 30 minutes @@ -17,8 +15,11 @@ export enum EVENT_TRIGGERS { INSTALLATION_CREATED = "installation.created", ISSUE_COMMENTED = "issue_comment.created", } +export const AWARD_POINTS_IDENTIFIER = "/award" as const; + export const ON_NEW_ISSUE = "Thanks for opening an issue! It's live on oss.gg!"; export const ON_REPO_NOT_REGISTERED = `This repository is not registered with oss.gg. Please register it at https://oss.gg.`; export const GITHUB_APP_APP_ID = env.GITHUB_APP_APP_ID as string; export const GITHUB_APP_PRIVATE_KEY = env.GITHUB_APP_PRIVATE_KEY as string; -export const GITHUB_WEBHOOK_SECRET = env.GITHUB_WEBHOOK_SECRET as string; \ No newline at end of file + +export const GITHUB_WEBHOOK_SECRET = env.GITHUB_WEBHOOK_SECRET as string; diff --git a/lib/github/hooks/issue.ts b/lib/github/hooks/issue.ts index 43a02bd..a28d2e5 100644 --- a/lib/github/hooks/issue.ts +++ b/lib/github/hooks/issue.ts @@ -1,4 +1,13 @@ -import { ASSIGN_IDENTIFIER, EVENT_TRIGGERS, LEVEL_LABEL, UNASSIGN_IDENTIFIER } from "@/lib/constants"; +import { + ASSIGN_IDENTIFIER, + AWARD_POINTS_IDENTIFIER, + EVENT_TRIGGERS, + LEVEL_LABEL, + UNASSIGN_IDENTIFIER, +} from "@/lib/constants"; +import { assignUserPoints } from "@/lib/points/service"; +import { getRepositoryByGithubId } from "@/lib/repository/service"; +import { createUser, getUser, getUserByGithubId } from "@/lib/user/service"; import { Webhooks } from "@octokit/webhooks"; import { getOctokitInstance } from "../utils"; @@ -83,3 +92,83 @@ export const onUnassignCommented = async (webhooks: Webhooks) => { } }); }; + +export const onAwardPoints = async (webhooks: Webhooks) => { + webhooks.on(EVENT_TRIGGERS.ISSUE_COMMENTED, async (context) => { + try { + const octokit = getOctokitInstance(context.payload.installation?.id!); + const repo = context.payload.repository.name; + const issueCommentBody = context.payload.comment.body; + const awardPointsRegex = new RegExp(`${AWARD_POINTS_IDENTIFIER}\\s+(\\d+)`); + const match = issueCommentBody.match(awardPointsRegex); + const isPR = !!context.payload.issue.pull_request; + const issueNumber = isPR ? context.payload.issue.number : undefined; + + let comment: string = ""; + + if (match) { + const points = parseInt(match[1], 10); + + if (!issueNumber) { + console.error("Comment is not on a PR."); + return; + } + + const ossGgRepo = await getRepositoryByGithubId(context.payload.repository.id); + + let usersThatCanAwardPoints = ossGgRepo?.installation.memberships.map((m) => m.userId); + if (!usersThatCanAwardPoints) { + throw new Error("No admins for the given repo in oss.gg!"); + } + const ossGgUsers = await Promise.all( + usersThatCanAwardPoints.map(async (userId) => { + const user = await getUser(userId); + return user?.githubId; + }) + ); + const isUserAllowedToAwardPoints = ossGgUsers?.includes(context.payload.comment.user.id); + if (!isUserAllowedToAwardPoints) { + comment = "You are not allowed to award points! Please contact an admin."; + } else { + if (!ossGgRepo) { + comment = "If you are the repo owner, please register at oss.gg to be able to award points"; + } else { + const prAuthorGithubId = context.payload.issue.user.id; + const prAuthorUsername = context.payload.issue.user.login; + const { data: prAuthorProfile } = await octokit.users.getByUsername({ + username: prAuthorUsername, + }); + let user = await getUserByGithubId(prAuthorGithubId); + if (!user) { + user = await createUser({ + githubId: prAuthorGithubId, + login: prAuthorUsername, + email: prAuthorProfile.email, + name: prAuthorProfile.name, + avatarUrl: context.payload.issue.user.avatar_url, + }); + comment = "Please register at oss.gg to be able to claim your rewards."; + } + await assignUserPoints( + user?.id, + points, + "Awarded points", + context.payload.comment.html_url, + ossGgRepo?.id + ); + comment = `Awarding ${user.login}: ${points} points!` + " " + comment; + } + } + + await octokit.issues.createComment({ + body: comment, + issue_number: issueNumber, + repo, + owner: "formbricks", + }); + } + } catch (err) { + console.error(err); + } + }); +}; diff --git a/lib/github/index.ts b/lib/github/index.ts index 3e2a5f5..51dde4f 100644 --- a/lib/github/index.ts +++ b/lib/github/index.ts @@ -1,8 +1,8 @@ import { Webhooks, createNodeMiddleware } from "@octokit/webhooks"; -import { onInstallationCreated } from "./hooks/installation"; -import { onAssignCommented, onIssueOpened, onUnassignCommented } from "./hooks/issue"; import { GITHUB_WEBHOOK_SECRET } from "../constants"; +import { onInstallationCreated } from "./hooks/installation"; +import { onAssignCommented, onAwardPoints, onIssueOpened, onUnassignCommented } from "./hooks/issue"; const webhooks = new Webhooks({ secret: GITHUB_WEBHOOK_SECRET, @@ -17,4 +17,5 @@ export const registerHooks = async () => { onInstallationCreated(webhooks); onAssignCommented(webhooks); onUnassignCommented(webhooks); + onAwardPoints(webhooks); }; diff --git a/lib/points/service.ts b/lib/points/service.ts new file mode 100644 index 0000000..83d287a --- /dev/null +++ b/lib/points/service.ts @@ -0,0 +1,40 @@ +import { db } from "@/lib/db"; +import { Prisma } from "@prisma/client"; + +export const assignUserPoints = async ( + userId: string, + points: number, + description: string, + url: string, + repositoryId: string +) => { + try { + const alreadyAssignedPoints = await db.pointTransaction.findFirst({ + where: { + userId, + repositoryId, + url, + }, + }); + if (alreadyAssignedPoints) { + throw new Error("Points already assigned for this user for the given url"); + } + + const pointsUpdated = await db.pointTransaction.create({ + data: { + points, + userId, + description, + url, + repositoryId, + }, + }); + return pointsUpdated; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error("An error occurred while updating user points:", error.message); + throw new Error("Database error occurred"); + } + throw error; + } +}; diff --git a/lib/repository/service.ts b/lib/repository/service.ts index d0c1437..282069b 100644 --- a/lib/repository/service.ts +++ b/lib/repository/service.ts @@ -18,3 +18,27 @@ export const getAllRepositories = async (): Promise => { throw error; } }; + +export const getRepositoryByGithubId = async (githubId: number) => { + try { + const repository = await db.repository.findFirst({ + where: { + githubId, + }, + include: { + installation: { + include: { + memberships: true, + }, + }, + }, + }); + return repository; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error("An error occurred while fetching repository:", error.message); + throw new Error("Database error occurred"); + } + throw error; + } +}; diff --git a/lib/user/cache.ts b/lib/user/cache.ts index f1a891e..598f719 100644 --- a/lib/user/cache.ts +++ b/lib/user/cache.ts @@ -3,6 +3,7 @@ import { revalidateTag } from "next/cache"; interface RevalidateProps { id?: string; login?: string; + githubId?: number; } export const userCache = { @@ -13,8 +14,11 @@ export const userCache = { byLogin(login: string) { return `users-${login}`; }, + byGithubId(githubId: number) { + return `users-${githubId}`; + }, }, - revalidate({ id, login }: RevalidateProps): void { + revalidate({ id, login, githubId }: RevalidateProps): void { if (id) { revalidateTag(this.tag.byId(id)); } @@ -22,5 +26,9 @@ export const userCache = { if (login) { revalidateTag(this.tag.byLogin(login)); } + + if (githubId) { + revalidateTag(this.tag.byGithubId(githubId)); + } }, }; diff --git a/lib/user/service.ts b/lib/user/service.ts index 1ded6e9..06ba3db 100644 --- a/lib/user/service.ts +++ b/lib/user/service.ts @@ -1,17 +1,11 @@ -import "server-only"; - import { db } from "@/lib/db"; import { ZId } from "@/types/common"; import { DatabaseError, ResourceNotFoundError } from "@/types/errors"; import { TUser, TUserCreateInput, TUserUpdateInput, ZUser, ZUserUpdateInput } from "@/types/user"; import { Prisma } from "@prisma/client"; -import { unstable_cache } from "next/cache"; -import { z } from "zod"; -import { DEFAULT_CACHE_REVALIDATION_INTERVAL } from "../constants"; import { formatDateFields } from "../utils/datetime"; import { validateInputs } from "../utils/validate"; -import { userCache } from "./cache"; const userSelection = { id: true, @@ -26,80 +20,78 @@ const userSelection = { // function to retrive basic information about a user's user export const getUser = async (id: string): Promise => { - const user = await unstable_cache( - async () => { - validateInputs([id, ZId]); - - try { - const user = await db.user.findUnique({ - where: { - id, - }, - select: userSelection, - }); - - if (!user) { - return null; - } - return user; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getUser-${id}`], - { - tags: [userCache.tag.byId(id)], - revalidate: DEFAULT_CACHE_REVALIDATION_INTERVAL, + try { + const user = await db.user.findUnique({ + where: { + id, + }, + select: userSelection, + }); + + if (!user) { + return null; } - )(); - - return user - ? { - ...user, - ...formatDateFields(user, ZUser), - } - : null; + return { + ...user, + ...formatDateFields(user, ZUser), + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } }; export const getUserByLogin = async (login: string): Promise => { - const user = await unstable_cache( - async () => { - validateInputs([login, z.string()]); - - try { - const user = await db.user.findFirst({ - where: { - login, - }, - select: userSelection, - }); - - return user; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getUserByLogin-${login}`], - { - tags: [userCache.tag.byLogin(login)], - revalidate: DEFAULT_CACHE_REVALIDATION_INTERVAL, + try { + const user = await db.user.findFirst({ + where: { + login, + }, + select: userSelection, + }); + if (!user) { + return null; + } + + return { + ...user, + ...formatDateFields(user, ZUser), + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )(); - - return user - ? { - ...user, - ...formatDateFields(user, ZUser), - } - : null; + + throw error; + } +}; + +export const getUserByGithubId = async (githubId: number): Promise => { + try { + const user = await db.user.findFirst({ + where: { + githubId, + }, + select: userSelection, + }); + if (!user) { + return null; + } + + return { + ...user, + ...formatDateFields(user, ZUser), + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } }; // function to update a user's user @@ -115,11 +107,6 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom select: userSelection, }); - userCache.revalidate({ - login: updatedUser.login, - id: updatedUser.id, - }); - return updatedUser; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") { @@ -140,11 +127,6 @@ export const deleteUser = async (id: string): Promise => { select: userSelection, }); - userCache.revalidate({ - login: user.login, - id, - }); - return user; }; @@ -156,10 +138,5 @@ export const createUser = async (data: TUserCreateInput): Promise => { select: userSelection, }); - userCache.revalidate({ - login: user.login, - id: user.id, - }); - return user; }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d090c8..4c2c232 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,5 +144,6 @@ model PointTransaction { user User @relation(fields: [userId], references: [id]) repository Repository @relation(fields: [repositoryId], references: [id]) + @@unique([userId, repositoryId, url]) @@map(name: "point_transactions") }