From c89eec73c4e0f63f631c2945be0b1ce80944fde1 Mon Sep 17 00:00:00 2001 From: Harpreet Singh Date: Sat, 9 Mar 2024 12:07:20 +0530 Subject: [PATCH 1/2] Fix/Feat: comment counters, actions based on role --- prisma/schema.prisma | 2 + src/actions/comment/index.ts | 91 +++++++++++++++++-- src/actions/comment/schema.ts | 6 ++ src/actions/comment/types.ts | 8 +- src/actions/types.ts | 5 + src/app/admin/page.tsx | 2 +- src/app/courses/[...courseId]/page.tsx | 1 + src/components/Copy-to-clipbord.tsx | 4 +- src/components/NotionRenderer.tsx | 7 +- src/components/Signin.tsx | 14 +-- src/components/VideoPlayer2.tsx | 2 +- src/components/comment/CommentApproveForm.tsx | 47 ++++++++++ src/components/comment/CommentDeleteForm.tsx | 4 +- src/components/comment/CommentPinForm.tsx | 47 ++++++++++ src/components/comment/CommentVoteForm.tsx | 22 ++++- src/components/comment/Comments.tsx | 63 +++++++++++-- src/lib/utils.ts | 13 ++- 17 files changed, 302 insertions(+), 36 deletions(-) create mode 100644 src/components/comment/CommentApproveForm.tsx create mode 100644 src/components/comment/CommentPinForm.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62df392a3..3d8fb57e3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,6 +173,7 @@ model Comment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt votes Vote[] + isPinned Boolean @default(false) } model Vote { @@ -196,3 +197,4 @@ enum CommentType { INTRO DEFAULT } + diff --git a/src/actions/comment/index.ts b/src/actions/comment/index.ts index 5e9a334ed..dda00e42b 100644 --- a/src/actions/comment/index.ts +++ b/src/actions/comment/index.ts @@ -4,10 +4,12 @@ import { InputTypeApproveIntroComment, InputTypeCreateComment, InputTypeDeleteComment, + InputTypePinComment, InputTypeUpdateComment, ReturnTypeApproveIntroComment, ReturnTypeCreateComment, ReturnTypeDeleteComment, + ReturnTypePinComment, ReturnTypeUpdateComment, } from './types'; import { authOptions } from '@/lib/auth'; @@ -17,11 +19,13 @@ import { CommentApproveIntroSchema, CommentDeleteSchema, CommentInsertSchema, + CommentPinSchema, CommentUpdateSchema, } from './schema'; import { createSafeAction } from '@/lib/create-safe-action'; import { CommentType, Prisma } from '@prisma/client'; import { revalidatePath } from 'next/cache'; +import { ROLES } from '../types'; export const getComments = async ( q: Prisma.CommentFindManyArgs, @@ -39,11 +43,30 @@ export const getComments = async ( if (!parentComment) { delete q.where?.parentId; } + const pinnedComment = await prisma.comment.findFirst({ + where: { + contentId: q.where?.contentId, + isPinned: true, + ...(parentId ? { parentId: parseInt(parentId.toString(), 10) } : {}), + }, + include: q.include, + }); + if (pinnedComment) { + q.where = { + ...q.where, + NOT: { + id: pinnedComment.id, + }, + }; + } const comments = await prisma.comment.findMany(q); + const combinedComments = pinnedComment + ? [pinnedComment, ...comments] + : comments; return { - comments, + comments: combinedComments, parentComment, }; }; @@ -268,10 +291,15 @@ const updateCommentHandler = async ( const approveIntroCommentHandler = async ( data: InputTypeApproveIntroComment, ): Promise => { - const { content_comment_ids, approved, adminPassword } = data; + const session = await getServerSession(authOptions); + const { content_comment_ids, approved, adminPassword, currentPath } = data; - if (adminPassword !== process.env.ADMIN_SECRET) { - return { error: 'Unauthorized' }; + if (adminPassword) { + if (adminPassword !== process.env.ADMIN_SECRET) { + return { error: 'Unauthorized' }; + } + } else if (!session || !session.user || session.user.role !== ROLES.ADMIN) { + return { error: 'Unauthorized ' }; } const [contentId, commentId] = content_comment_ids.split(';'); @@ -315,7 +343,9 @@ const approveIntroCommentHandler = async ( }, }); }); - + if (currentPath) { + revalidatePath(currentPath); + } return { data: updatedComment! }; } catch (error) { return { error: 'Failed to update comment.' }; @@ -331,12 +361,15 @@ const deleteCommentHandler = async ( return { error: 'Unauthorized or insufficient permissions' }; } - const { commentId, adminPassword } = data; + const { commentId } = data; const userId = session.user.id; try { const existingComment = await prisma.comment.findUnique({ where: { id: commentId }, + include: { + parent: true, + }, }); if (!existingComment) { @@ -344,8 +377,8 @@ const deleteCommentHandler = async ( } if ( - existingComment.userId !== userId && - adminPassword !== process.env.ADMIN_SECRET + session.user?.role !== ROLES.ADMIN || + existingComment.userId !== userId ) { return { error: 'Unauthorized to delete this comment.' }; } @@ -365,6 +398,15 @@ const deleteCommentHandler = async ( await prisma.comment.deleteMany({ where: { parentId: commentId }, }); + await prisma.content.update({ + where: { id: existingComment.contentId }, + data: { commentsCount: { decrement: 1 } }, + }); + } else { + await prisma.comment.update({ + where: { id: existingComment.parentId }, + data: { repliesCount: { decrement: 1 } }, + }); } // Then delete the comment itself @@ -383,6 +425,38 @@ const deleteCommentHandler = async ( } }; +const pinCommentHandler = async ( + data: InputTypePinComment, +): Promise => { + const { commentId, contentId, currentPath } = data; + const session = await getServerSession(authOptions); + + if (!session || !session.user || session.user.role !== ROLES.ADMIN) { + return { error: 'Unauthorized or insufficient permissions' }; + } + let updatedComment; + try { + await prisma.$transaction(async (prisma) => { + // Unpin any currently pinned comment for the content + await prisma.comment.updateMany({ + where: { contentId, isPinned: true }, + data: { isPinned: false }, + }); + + updatedComment = await prisma.comment.update({ + where: { id: commentId }, + data: { isPinned: true }, + }); + }); + if (currentPath) { + revalidatePath(currentPath); + } + return { data: updatedComment }; + } catch (error: any) { + return { error: error.message || 'Failed to pin comment.' }; + } +}; + export const createMessage = createSafeAction( CommentInsertSchema, createCommentHandler, @@ -399,3 +473,4 @@ export const approveComment = createSafeAction( CommentApproveIntroSchema, approveIntroCommentHandler, ); +export const pinComment = createSafeAction(CommentPinSchema, pinCommentHandler); diff --git a/src/actions/comment/schema.ts b/src/actions/comment/schema.ts index 34163be1e..8bdce6f24 100644 --- a/src/actions/comment/schema.ts +++ b/src/actions/comment/schema.ts @@ -20,9 +20,15 @@ export const CommentApproveIntroSchema = z.object({ content_comment_ids: z.string(), approved: z.boolean().optional(), adminPassword: z.string().optional(), + currentPath: z.string().optional(), }); export const CommentDeleteSchema = z.object({ adminPassword: z.string().optional(), commentId: z.number(), currentPath: z.string().optional(), }); +export const CommentPinSchema = z.object({ + commentId: z.number(), + contentId: z.number(), + currentPath: z.string().optional(), +}); diff --git a/src/actions/comment/types.ts b/src/actions/comment/types.ts index 1c2ff6339..9c22c521c 100644 --- a/src/actions/comment/types.ts +++ b/src/actions/comment/types.ts @@ -5,9 +5,10 @@ import { CommentUpdateSchema, CommentDeleteSchema, CommentApproveIntroSchema, + CommentPinSchema, } from './schema'; import { Delete } from '../types'; -import { User, Comment } from '@prisma/client'; +import { User, Comment, Vote } from '@prisma/client'; export type InputTypeCreateComment = z.infer; export type ReturnTypeCreateComment = ActionState< @@ -33,7 +34,10 @@ export type ReturnTypeDeleteComment = ActionState< InputTypeDeleteComment, Delete >; +export type InputTypePinComment = z.infer; +export type ReturnTypePinComment = ActionState; export interface ExtendedComment extends Comment { - user: User; + user?: User; + votes?: Vote[]; } diff --git a/src/actions/types.ts b/src/actions/types.ts index 5870b84fc..58a7858ca 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -21,3 +21,8 @@ export enum CommentFilter { export type Delete = { message: string; }; + +export enum ROLES { + ADMIN = 'admin', + USER = 'user', +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 41cc3be4e..acbf5e91f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -27,7 +27,7 @@ export default function Courses() { const { register, handleSubmit } = useForm(); const onSubmit: SubmitHandler = async (data) => { - console.log(data); + // console.log(data); await fetch('/api/admin/course', { body: JSON.stringify(data), method: 'POST', diff --git a/src/app/courses/[...courseId]/page.tsx b/src/app/courses/[...courseId]/page.tsx index 261a833bc..4b6496887 100644 --- a/src/app/courses/[...courseId]/page.tsx +++ b/src/app/courses/[...courseId]/page.tsx @@ -16,6 +16,7 @@ import { QueryParams } from '@/actions/types'; const checkAccess = async (courseId: string) => { const session = await getServerSession(authOptions); + if (!session?.user) { return false; } diff --git a/src/components/Copy-to-clipbord.tsx b/src/components/Copy-to-clipbord.tsx index da47c666d..7658d6d45 100644 --- a/src/components/Copy-to-clipbord.tsx +++ b/src/components/Copy-to-clipbord.tsx @@ -21,7 +21,9 @@ const CopyToClipboard = ({ return (
); diff --git a/src/components/NotionRenderer.tsx b/src/components/NotionRenderer.tsx index 138bc1d34..5469d0d2e 100644 --- a/src/components/NotionRenderer.tsx +++ b/src/components/NotionRenderer.tsx @@ -31,7 +31,12 @@ export const NotionRenderer = ({ id }: { id: string }) => { return (
- +
); diff --git a/src/components/Signin.tsx b/src/components/Signin.tsx index c858ee292..70358f58f 100644 --- a/src/components/Signin.tsx +++ b/src/components/Signin.tsx @@ -24,10 +24,10 @@ const Signin = () => { const password = useRef(''); const handleSubmit = async (e?: React.FormEvent) => { - if(e){ + if (e) { e.preventDefault(); } - + if (!email.current || !password.current) { setRequiredError({ emailReq: email.current ? false : true, @@ -36,7 +36,6 @@ const Signin = () => { return; } - const res = await signIn('credentials', { username: email.current, password: password.current, @@ -96,12 +95,13 @@ const Signin = () => { })); password.current = e.target.value; }} - onKeyDown={async (e)=>{ - if(e.key === "Enter"){ + onKeyDown={async (e) => { + if (e.key === 'Enter') { setIsPasswordVisible(false); handleSubmit(); - }}} - /> + } + }} + /> + + ); +}; + +export default CommentApproveForm; diff --git a/src/components/comment/CommentDeleteForm.tsx b/src/components/comment/CommentDeleteForm.tsx index f344ee506..b7a5bc3df 100644 --- a/src/components/comment/CommentDeleteForm.tsx +++ b/src/components/comment/CommentDeleteForm.tsx @@ -29,7 +29,9 @@ const CommentDeleteForm = ({ commentId }: { commentId: number }) => { return (
); diff --git a/src/components/comment/CommentPinForm.tsx b/src/components/comment/CommentPinForm.tsx new file mode 100644 index 000000000..012e8bc19 --- /dev/null +++ b/src/components/comment/CommentPinForm.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { pinComment } from '@/actions/comment'; +import { useAction } from '@/hooks/useAction'; +import { PinIcon } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import React from 'react'; +import { toast } from 'sonner'; + +const CommentPinForm = ({ + commentId, + contentId, +}: { + commentId: number; + contentId: number; +}) => { + const currentPath = usePathname(); + + const { execute } = useAction(pinComment, { + onSuccess: () => { + toast('Comment Pinned'); + }, + onError: (error) => { + toast.error(error); + }, + }); + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + execute({ + commentId, + contentId, + currentPath, + }); + }; + return ( +
+ +
+ ); +}; + +export default CommentPinForm; diff --git a/src/components/comment/CommentVoteForm.tsx b/src/components/comment/CommentVoteForm.tsx index 79fb91da7..68184ab8d 100644 --- a/src/components/comment/CommentVoteForm.tsx +++ b/src/components/comment/CommentVoteForm.tsx @@ -11,15 +11,18 @@ const CommentVoteForm = ({ upVotes, downVotes, commentId, + voteType, }: { upVotes: number; downVotes: number; commentId: number; + voteType: VoteType | null; }) => { const currentPath = usePathname(); + const { execute } = useAction(voteHandlerAction, { onSuccess: () => { - toast('Comment added'); + toast('Comment Voted'); }, onError: (error) => { toast.error(error); @@ -50,7 +53,13 @@ const CommentVoteForm = ({ className="flex items-center gap-1 text-gray-500 dark:text-gray-400" type="submit" > - + {upVotes} @@ -60,7 +69,14 @@ const CommentVoteForm = ({ className="flex items-center gap-1 text-gray-500 dark:text-gray-400" type="submit" > - + {downVotes} diff --git a/src/components/comment/Comments.tsx b/src/components/comment/Comments.tsx index c2a0fbd7f..f5082981e 100644 --- a/src/components/comment/Comments.tsx +++ b/src/components/comment/Comments.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Card, CardContent, CardFooter, CardHeader } from '../ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; -import { CommentFilter, QueryParams } from '@/actions/types'; +import { CommentFilter, QueryParams, ROLES } from '@/actions/types'; import { constructCommentPrismaQuery, getUpdatedUrl, @@ -15,7 +15,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import CommentVoteForm from './CommentVoteForm'; import Pagination from '../Pagination'; import Link from 'next/link'; -import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'; +import { ArrowLeftIcon, ChevronDownIcon, MoreVerticalIcon } from 'lucide-react'; import TimeCodeComment from './TimeCodeComment'; import CopyToClipboard from '../Copy-to-clipbord'; import { @@ -30,6 +30,8 @@ import { CommentType } from '@prisma/client'; import CommentDeleteForm from './CommentDeleteForm'; import { authOptions } from '@/lib/auth'; import { getServerSession } from 'next-auth'; +import CommentPinForm from './CommentPinForm'; +import CommentApproveForm from './CommentApproveForm'; dayjs.extend(relativeTime); const Comments = async ({ content, @@ -48,6 +50,7 @@ const Comments = async ({ searchParams, paginationInfo, content.id, + session.user.id, ); const data = await getComments(q, searchParams.parentId); @@ -115,7 +118,7 @@ const Comments = async ({ parentId={data?.parentComment?.id} />
- + + + + + + + + {(session.user.id.toString() === + (c as ExtendedComment).userId.toString() || + session.user.role === ROLES.ADMIN) && ( + + )} + + + {session.user.role === ROLES.ADMIN && ( + + )} + + + {session.user.role === ROLES.ADMIN && ( + + )} + + +
+
{!data.parentComment && ( { const { pageSize, skip } = paginationInfo; const { commentfilter, type } = searchParams; @@ -294,7 +295,17 @@ export const constructCommentPrismaQuery = ( orderBy, skip, take: pageSize, - include: { user: true }, + include: { + user: true, + votes: { + where: { + userId, + }, + select: { + voteType: true, // Only fetch the voteType to determine if it's an upvote or downvote + }, + }, + }, }; return query; From 98aa22ca278b8521a1ee7a774385e57802444ce0 Mon Sep 17 00:00:00 2001 From: Harpreet Singh Date: Tue, 12 Mar 2024 20:50:00 +0530 Subject: [PATCH 2/2] removed console.log --- src/app/admin/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index acbf5e91f..9c49a1a59 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -27,7 +27,6 @@ export default function Courses() { const { register, handleSubmit } = useForm(); const onSubmit: SubmitHandler = async (data) => { - // console.log(data); await fetch('/api/admin/course', { body: JSON.stringify(data), method: 'POST',