From 22805022a99a012acdd466680e172749838015b4 Mon Sep 17 00:00:00 2001 From: Terence <45381509+Vielheim@users.noreply.github.com> Date: Wed, 19 Oct 2022 16:48:03 +0800 Subject: [PATCH] [resumes][feat] Add resume comment upvote/downvote (#389) * [resumes][feat] Add upvote/downvote * [resumes][refactor] abstract comment votes fetching from comments * [resumes][chore] remove votes from comments query Co-authored-by: Terence Ho <> --- .../comments/ResumeCommentListItem.tsx | 94 +++++++++++++++++-- apps/portal/src/server/router/index.ts | 4 + .../router/resumes/resumes-comments-router.ts | 17 ---- .../resumes/resumes-comments-votes-router.ts | 38 ++++++++ .../resumes-comments-votes-user-router.ts | 45 +++++++++ apps/portal/src/types/resume-comments.d.ts | 9 +- 6 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts create mode 100644 apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts diff --git a/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx b/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx index a313a228..fee44c97 100644 --- a/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx +++ b/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { useState } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; @@ -6,6 +7,7 @@ import { ArrowUpCircleIcon, } from '@heroicons/react/20/solid'; import { FaceSmileIcon } from '@heroicons/react/24/outline'; +import { Vote } from '@prisma/client'; import { Button, TextArea } from '@tih/ui'; import { trpc } from '~/utils/trpc'; @@ -53,6 +55,31 @@ export default function ResumeCommentListItem({ }, ); + // COMMENT VOTES + const commentVotesQuery = trpc.useQuery([ + 'resumes.comments.votes.list', + { commentId: comment.id }, + ]); + const commentVotesUpsertMutation = trpc.useMutation( + 'resumes.comments.votes.user.upsert', + { + onSuccess: () => { + // Comment updated, invalidate query to trigger refetch + trpcContext.invalidateQueries(['resumes.comments.votes.list']); + }, + }, + ); + const commentVotesDeleteMutation = trpc.useMutation( + 'resumes.comments.votes.user.delete', + { + onSuccess: () => { + // Comment updated, invalidate query to trigger refetch + trpcContext.invalidateQueries(['resumes.comments.votes.list']); + }, + }, + ); + + // FORM ACTIONS const onCancel = () => { reset({ description: comment.description }); setIsEditingComment(false); @@ -60,7 +87,7 @@ export default function ResumeCommentListItem({ const onSubmit: SubmitHandler = async (data) => { const { id } = comment; - return await commentUpdateMutation.mutate( + return commentUpdateMutation.mutate( { id, ...data, @@ -77,6 +104,18 @@ export default function ResumeCommentListItem({ setValue('description', value.trim(), { shouldDirty: true }); }; + const onVote = async (value: Vote) => { + if (commentVotesQuery.data?.userVote?.value === value) { + return commentVotesDeleteMutation.mutate({ + commentId: comment.id, + }); + } + return commentVotesUpsertMutation.mutate({ + commentId: comment.id, + value, + }); + }; + return (
@@ -154,18 +193,57 @@ export default function ResumeCommentListItem({ {/* Upvote and edit */}
- {/* TODO: Implement upvote */} - -
{comment.numVotes}
- + + +
+ {commentVotesQuery.data?.numVotes ?? 0} +
+ + {isCommentOwner && !isEditingComment && ( - setIsEditingComment(true)}> Edit - + )}
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 8592986c..c3046659 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -13,6 +13,8 @@ import { questionsQuestionCommentRouter } from './questions-question-comment-rou import { questionsQuestionRouter } from './questions-question-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; +import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router'; +import { resumesCommentsVotesUserRouter } from './resumes/resumes-comments-votes-user-router'; import { resumesRouter } from './resumes/resumes-resume-router'; import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router'; import { resumesStarUserRouter } from './resumes/resumes-star-user-router'; @@ -33,6 +35,8 @@ export const appRouter = createRouter() .merge('resumes.resume.', resumesStarUserRouter) .merge('resumes.comments.', resumeCommentsRouter) .merge('resumes.comments.user.', resumesCommentsUserRouter) + .merge('resumes.comments.votes.', resumesCommentsVotesRouter) + .merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.', questionsAnswerRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter) diff --git a/apps/portal/src/server/router/resumes/resumes-comments-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-router.ts index fb9840ac..33d6256a 100644 --- a/apps/portal/src/server/router/resumes/resumes-comments-router.ts +++ b/apps/portal/src/server/router/resumes/resumes-comments-router.ts @@ -9,7 +9,6 @@ export const resumeCommentsRouter = createRouter().query('list', { resumeId: z.string(), }), async resolve({ ctx, input }) { - const userId = ctx.session?.user?.id; const { resumeId } = input; // For this resume, we retrieve every comment's information, along with: @@ -17,23 +16,12 @@ export const resumeCommentsRouter = createRouter().query('list', { // Number of votes, and whether the user (if-any) has voted const comments = await ctx.prisma.resumesComment.findMany({ include: { - _count: { - select: { - votes: true, - }, - }, user: { select: { image: true, name: true, }, }, - votes: { - take: 1, - where: { - userId, - }, - }, }, orderBy: { createdAt: 'desc', @@ -44,15 +32,10 @@ export const resumeCommentsRouter = createRouter().query('list', { }); return comments.map((data) => { - const hasVoted = data.votes.length > 0; - const numVotes = data._count.votes; - const comment: ResumeComment = { createdAt: data.createdAt, description: data.description, - hasVoted, id: data.id, - numVotes, resumeId: data.resumeId, section: data.section, updatedAt: data.updatedAt, diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts new file mode 100644 index 00000000..5d508c35 --- /dev/null +++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import type { ResumesCommentVote } from '@prisma/client'; +import { Vote } from '@prisma/client'; + +import { createRouter } from '../context'; + +import type { ResumeCommentVote } from '~/types/resume-comments'; + +export const resumesCommentsVotesRouter = createRouter().query('list', { + input: z.object({ + commentId: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; + const { commentId } = input; + + const votes = await ctx.prisma.resumesCommentVote.findMany({ + where: { + commentId, + }, + }); + + let userVote: ResumesCommentVote | null = null; + let numVotes = 0; + + votes.forEach((vote) => { + numVotes += vote.value === Vote.UPVOTE ? 1 : -1; + userVote = vote.userId === userId ? vote : null; + }); + + const resumeCommentVote: ResumeCommentVote = { + numVotes, + userVote, + }; + + return resumeCommentVote; + }, +}); diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts new file mode 100644 index 00000000..7dbeec77 --- /dev/null +++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import { Vote } from '@prisma/client'; + +import { createProtectedRouter } from '../context'; + +export const resumesCommentsVotesUserRouter = createProtectedRouter() + .mutation('upsert', { + input: z.object({ + commentId: z.string(), + value: z.nativeEnum(Vote), + }), + async resolve({ ctx, input }) { + const userId = ctx.session.user.id; + const { commentId, value } = input; + + await ctx.prisma.resumesCommentVote.upsert({ + create: { + commentId, + userId, + value, + }, + update: { + value, + }, + where: { + userId_commentId: { commentId, userId }, + }, + }); + }, + }) + .mutation('delete', { + input: z.object({ + commentId: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session.user.id; + const { commentId } = input; + + await ctx.prisma.resumesCommentVote.delete({ + where: { + userId_commentId: { commentId, userId }, + }, + }); + }, + }); diff --git a/apps/portal/src/types/resume-comments.d.ts b/apps/portal/src/types/resume-comments.d.ts index c0e181fb..335948c3 100644 --- a/apps/portal/src/types/resume-comments.d.ts +++ b/apps/portal/src/types/resume-comments.d.ts @@ -1,4 +1,4 @@ -import type { ResumesSection } from '@prisma/client'; +import type { ResumesCommentVote, ResumesSection } from '@prisma/client'; /** * Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList` @@ -7,9 +7,7 @@ import type { ResumesSection } from '@prisma/client'; export type ResumeComment = Readonly<{ createdAt: Date; description: string; - hasVoted: boolean; id: string; - numVotes: number; resumeId: string; section: ResumesSection; updatedAt: Date; @@ -19,3 +17,8 @@ export type ResumeComment = Readonly<{ userId: string; }; }>; + +export type ResumeCommentVote = Readonly<{ + numVotes: number; + userVote: ResumesCommentVote?; +}>;