From cd34e0c8e62b74786a4c8c9dce98ae0bf4a5c254 Mon Sep 17 00:00:00 2001 From: Maruf Bepary Date: Wed, 10 Dec 2025 18:16:42 +0000 Subject: [PATCH] REFACTOR: Separated logic from hooks --- hooks/comments/useCommentList.ts | 16 +-- hooks/comments/useCreateComment.ts | 47 ++------- hooks/comments/useDeleteComment.ts | 21 +--- hooks/community/useCommunitiesFeed.ts | 43 ++------ hooks/community/useCommunityImage.ts | 85 +++------------ hooks/community/useCommunityPrivacy.ts | 7 +- hooks/community/useCreateCommunity.ts | 44 ++------ hooks/community/useDeleteCommunity.ts | 90 ++-------------- hooks/community/useJoinCommunity.tsx | 39 ++----- hooks/community/useLeaveCommunity.tsx | 20 +--- hooks/community/useRemoveCommunityMember.ts | 4 +- hooks/posts/useCreatePost.ts | 42 ++------ hooks/posts/usePostDeletion.ts | 32 +----- hooks/posts/usePostVote.tsx | 109 +++++--------------- hooks/posts/usePostVoteSync.ts | 15 +-- hooks/posts/usePostsFeed.ts | 60 +++-------- hooks/posts/useSavedPosts.tsx | 40 ++----- hooks/useSearch.tsx | 30 +----- lib/comments/createComment.ts | 46 +++++++++ lib/comments/deleteComment.ts | 24 +++++ lib/comments/getComments.ts | 17 +++ lib/community/createCommunity.ts | 34 ++++++ lib/community/deleteCommunity.ts | 77 ++++++++++++++ lib/community/deleteCommunityImage.ts | 33 ++++++ lib/community/getCommunities.ts | 46 +++++++++ lib/community/joinCommunity.ts | 30 ++++++ lib/community/leaveCommunity.ts | 16 +++ lib/community/updateCommunityImage.ts | 39 +++++++ lib/community/updateCommunityPrivacy.ts | 11 ++ lib/posts/createPost.ts | 45 ++++++++ lib/posts/deletePost.ts | 33 ++++++ lib/posts/getCommunityPostVotes.ts | 20 ++++ lib/posts/getPost.ts | 12 +++ lib/posts/getPostVotes.ts | 30 ++++++ lib/posts/getPosts.ts | 51 +++++++++ lib/posts/getSavedPosts.ts | 14 +++ lib/posts/handlePostVote.ts | 58 +++++++++++ lib/posts/savePost.ts | 17 +++ lib/posts/unsavePost.ts | 6 ++ lib/search/getSearchData.ts | 34 ++++++ 40 files changed, 825 insertions(+), 612 deletions(-) create mode 100644 lib/comments/createComment.ts create mode 100644 lib/comments/deleteComment.ts create mode 100644 lib/comments/getComments.ts create mode 100644 lib/community/createCommunity.ts create mode 100644 lib/community/deleteCommunity.ts create mode 100644 lib/community/deleteCommunityImage.ts create mode 100644 lib/community/getCommunities.ts create mode 100644 lib/community/joinCommunity.ts create mode 100644 lib/community/leaveCommunity.ts create mode 100644 lib/community/updateCommunityImage.ts create mode 100644 lib/community/updateCommunityPrivacy.ts create mode 100644 lib/posts/createPost.ts create mode 100644 lib/posts/deletePost.ts create mode 100644 lib/posts/getCommunityPostVotes.ts create mode 100644 lib/posts/getPost.ts create mode 100644 lib/posts/getPostVotes.ts create mode 100644 lib/posts/getPosts.ts create mode 100644 lib/posts/getSavedPosts.ts create mode 100644 lib/posts/handlePostVote.ts create mode 100644 lib/posts/savePost.ts create mode 100644 lib/posts/unsavePost.ts create mode 100644 lib/search/getSearchData.ts diff --git a/hooks/comments/useCommentList.ts b/hooks/comments/useCommentList.ts index 3bb8e79..dafc48f 100644 --- a/hooks/comments/useCommentList.ts +++ b/hooks/comments/useCommentList.ts @@ -1,9 +1,8 @@ import { useCallback, useEffect, useState } from "react"; -import { collection, getDocs, orderBy, query, where } from "firebase/firestore"; -import { firestore } from "@/firebase/clientApp"; import { Post } from "@/types/post"; import useCustomToast from "@/hooks/useCustomToast"; import { Comment } from "../../types/comment"; +import { getComments as getCommentsLib } from "@/lib/comments/getComments"; /** * Loads comments for the selected post and keeps them in local state. @@ -19,17 +18,8 @@ const useCommentList = (selectedPost: Post | null) => { if (!selectedPost) return; setCommentFetchLoading(true); try { - const commentsQuery = query( - collection(firestore, "comments"), - where("postId", "==", selectedPost.id), - orderBy("createdAt", "desc") - ); - const commentDocs = await getDocs(commentsQuery); - const mappedComments = commentDocs.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })); - setComments(mappedComments as Comment[]); + const comments = await getCommentsLib(selectedPost.id!); + setComments(comments); } catch (error: any) { console.log("getPostComments error", error); showToast({ diff --git a/hooks/comments/useCreateComment.ts b/hooks/comments/useCreateComment.ts index 46d10b4..15d4c5b 100644 --- a/hooks/comments/useCreateComment.ts +++ b/hooks/comments/useCreateComment.ts @@ -1,19 +1,11 @@ import { Dispatch, SetStateAction, useState } from "react"; import { User } from "firebase/auth"; -import { - collection, - doc, - increment, - serverTimestamp, - Timestamp, - writeBatch, -} from "firebase/firestore"; -import { firestore } from "@/firebase/clientApp"; import { postStateAtom } from "@/atoms/postsAtom"; import { Post } from "@/types/post"; import { useSetAtom } from "jotai"; import useCustomToast from "@/hooks/useCustomToast"; import { Comment } from "../../types/comment"; +import { createComment } from "@/lib/comments/createComment"; /** * Creates a new comment or reply on the selected post and updates counts. @@ -30,7 +22,7 @@ const useCreateComment = ( const showToast = useCustomToast(); const [createLoading, setCreateLoading] = useState(false); - const createComment = async ( + const onCreateComment = async ( user: User, commentText: string, parentId?: string @@ -38,31 +30,14 @@ const useCreateComment = ( if (!selectedPost) return; setCreateLoading(true); try { - const batch = writeBatch(firestore); - - const commentDocRef = doc(collection(firestore, "comments")); - const newComment: Comment = { - id: commentDocRef.id, - creatorId: user.uid, - creatorDisplayText: user.email!.split("@")[0], - communityId: selectedPost.communityId, - postId: selectedPost.id!, - postTitle: selectedPost.title, - text: commentText, - createdAt: serverTimestamp() as Timestamp, - parentId: parentId || undefined, - }; - - if (!parentId) delete newComment.parentId; - - batch.set(commentDocRef, newComment); - - const postDocRef = doc(firestore, "posts", selectedPost.id!); - batch.update(postDocRef, { - numberOfComments: increment(1), - }); - - await batch.commit(); + const newComment = await createComment( + user, + selectedPost.communityId, + selectedPost.id!, + selectedPost.title, + commentText, + parentId + ); setComments((prev) => [newComment, ...prev]); setPostState((prev) => ({ @@ -85,7 +60,7 @@ const useCreateComment = ( }; return { - createComment, + createComment: onCreateComment, createLoading, }; }; diff --git a/hooks/comments/useDeleteComment.ts b/hooks/comments/useDeleteComment.ts index d723775..1511eab 100644 --- a/hooks/comments/useDeleteComment.ts +++ b/hooks/comments/useDeleteComment.ts @@ -1,10 +1,9 @@ import { Dispatch, SetStateAction, useState } from "react"; -import { doc, increment, writeBatch } from "firebase/firestore"; -import { firestore } from "@/firebase/clientApp"; import { postStateAtom } from "@/atoms/postsAtom"; import { useSetAtom } from "jotai"; import useCustomToast from "@/hooks/useCustomToast"; import { Comment } from "../../types/comment"; +import { deleteComment } from "@/lib/comments/deleteComment"; /** * Deletes a comment and its replies while syncing counts on the parent post. @@ -19,11 +18,9 @@ const useDeleteComment = ( const showToast = useCustomToast(); const [deleteLoadingId, setDeleteLoadingId] = useState(""); - const deleteComment = async (comment: Comment) => { + const onDeleteComment = async (comment: Comment) => { setDeleteLoadingId(comment.id); try { - const batch = writeBatch(firestore); - const getDescendantIds = (parentId: string): string[] => { const children = comments.filter((c) => c.parentId === parentId); let ids = children.map((c) => c.id); @@ -36,17 +33,7 @@ const useDeleteComment = ( const descendantIds = getDescendantIds(comment.id); const allIdsToDelete = [comment.id, ...descendantIds]; - allIdsToDelete.forEach((id) => { - const commentDocRef = doc(firestore, "comments", id); - batch.delete(commentDocRef); - }); - - const postDocRef = doc(firestore, "posts", comment.postId); - batch.update(postDocRef, { - numberOfComments: increment(-allIdsToDelete.length), - }); - - await batch.commit(); + await deleteComment(comment.id, comment.postId, descendantIds); setComments((prev) => prev.filter((item) => !allIdsToDelete.includes(item.id)) @@ -72,7 +59,7 @@ const useDeleteComment = ( }; return { - deleteComment, + deleteComment: onDeleteComment, deleteLoadingId, }; }; diff --git a/hooks/community/useCommunitiesFeed.ts b/hooks/community/useCommunitiesFeed.ts index 15b2764..b3a2bfd 100644 --- a/hooks/community/useCommunitiesFeed.ts +++ b/hooks/community/useCommunitiesFeed.ts @@ -1,17 +1,8 @@ -import { firestore } from "@/firebase/clientApp"; import useCustomToast from "@/hooks/useCustomToast"; -import { - collection, - DocumentData, - getDocs, - limit, - orderBy, - query, - QueryDocumentSnapshot, - startAfter, -} from "firebase/firestore"; +import { DocumentData, QueryDocumentSnapshot } from "firebase/firestore"; import { useEffect, useState } from "react"; import { Community } from "@/types/community"; +import { getCommunities as getCommunitiesLib } from "@/lib/community/getCommunities"; type UseCommunitiesFeedProps = { limitValue?: number; @@ -39,32 +30,16 @@ const useCommunitiesFeed = ({ if (loading) return; setLoading(true); try { - let communityQuery; - if (initial) { - communityQuery = query( - collection(firestore, "communities"), - orderBy("numberOfMembers", "desc"), - limit(limitValue) - ); - } else { - if (!lastVisible || !isPagination) return; - communityQuery = query( - collection(firestore, "communities"), - orderBy("numberOfMembers", "desc"), - startAfter(lastVisible), - limit(limitValue) - ); + if (!initial && (!lastVisible || !isPagination)) { + setLoading(false); + return; } - const communityDocs = await getDocs(communityQuery); - const fetchedCommunities = communityDocs.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })); + const { communities: fetchedCommunities, newLastVisible } = + await getCommunitiesLib(limitValue, initial ? null : lastVisible); - if (communityDocs.docs.length < limitValue) setNoMoreCommunities(true); - if (communityDocs.docs.length > 0) - setLastVisible(communityDocs.docs[communityDocs.docs.length - 1]); + if (fetchedCommunities.length < limitValue) setNoMoreCommunities(true); + if (newLastVisible) setLastVisible(newLastVisible); setCommunities((prev) => initial diff --git a/hooks/community/useCommunityImage.ts b/hooks/community/useCommunityImage.ts index 002696d..346c367 100644 --- a/hooks/community/useCommunityImage.ts +++ b/hooks/community/useCommunityImage.ts @@ -1,11 +1,10 @@ import { communityStateAtom } from "@/atoms/communitiesAtom"; -import { firestore, storage } from "@/firebase/clientApp"; -import { collection, doc, getDoc, getDocs, updateDoc } from "firebase/firestore"; -import { deleteObject, getDownloadURL, ref, uploadString } from "firebase/storage"; import { useSetAtom } from "jotai"; import { useState } from "react"; import useCustomToast from "../useCustomToast"; import { Community } from "@/types/community"; +import { updateCommunityImage } from "@/lib/community/updateCommunityImage"; +import { deleteCommunityImage } from "@/lib/community/deleteCommunityImage"; /** * Uploads or removes a community image while syncing snippets and local state. @@ -17,44 +16,15 @@ const useCommunityImage = (communityData: Community) => { const showToast = useCustomToast(); const [uploadingImage, setUploadingImage] = useState(false); - const updateImage = async (selectedFile: string) => { + const onUpdateImage = async (selectedFile: string) => { if (!selectedFile) return; setUploadingImage(true); try { - const imageRef = ref(storage, `communities/${communityData.id}/image`); - await uploadString(imageRef, selectedFile, "data_url"); - const downloadURL = await getDownloadURL(imageRef); - await updateDoc(doc(firestore, "communities", communityData.id), { - imageURL: downloadURL, - }); - - const usersSnapshot = await getDocs(collection(firestore, "users")); - usersSnapshot.forEach(async (userDoc) => { - const communitySnippetDoc = await getDoc( - doc( - firestore, - "users", - userDoc.id, - "communitySnippets", - communityData.id - ) - ); - if (communitySnippetDoc.exists()) { - await updateDoc( - doc( - firestore, - "users", - userDoc.id, - "communitySnippets", - communityData.id - ), - { - imageURL: downloadURL, - } - ); - } - }); + const downloadURL = await updateCommunityImage( + communityData.id, + selectedFile + ); setCommunityStateValue((prev) => ({ ...prev, @@ -88,40 +58,9 @@ const useCommunityImage = (communityData: Community) => { } }; - const deleteCommunityImage = async () => { + const onDeleteCommunityImage = async () => { try { - const imageRef = ref(storage, `communities/${communityData.id}/image`); - await deleteObject(imageRef); - await updateDoc(doc(firestore, "communities", communityData.id), { - imageURL: "", - }); - - const usersSnapshot = await getDocs(collection(firestore, "users")); - usersSnapshot.forEach(async (userDoc) => { - const communitySnippetDoc = await getDoc( - doc( - firestore, - "users", - userDoc.id, - "communitySnippets", - communityData.id - ) - ); - if (communitySnippetDoc.exists()) { - await updateDoc( - doc( - firestore, - "users", - userDoc.id, - "communitySnippets", - communityData.id - ), - { - imageURL: "", - } - ); - } - }); + await deleteCommunityImage(communityData.id); setCommunityStateValue((prev) => ({ ...prev, @@ -144,7 +83,7 @@ const useCommunityImage = (communityData: Community) => { }), })); } catch (error) { - console.log("Error: onDeleteImage", error); + console.log("Error: onDeleteCommunityImage", error); showToast({ title: "Image not Deleted", description: "There was an error deleting the image", @@ -154,8 +93,8 @@ const useCommunityImage = (communityData: Community) => { }; return { - updateImage, - deleteCommunityImage, + updateImage: onUpdateImage, + deleteCommunityImage: onDeleteCommunityImage, uploadingImage, }; }; diff --git a/hooks/community/useCommunityPrivacy.ts b/hooks/community/useCommunityPrivacy.ts index a0efc0f..aa4d092 100644 --- a/hooks/community/useCommunityPrivacy.ts +++ b/hooks/community/useCommunityPrivacy.ts @@ -1,9 +1,8 @@ import { communityStateAtom } from "@/atoms/communitiesAtom"; import { Community } from "@/types/community"; -import { firestore } from "@/firebase/clientApp"; -import { doc, updateDoc } from "firebase/firestore"; import { useSetAtom } from "jotai"; import useCustomToast from "../useCustomToast"; +import { updateCommunityPrivacy } from "@/lib/community/updateCommunityPrivacy"; /** * Updates a community's privacy type and mirrors the change in local state. @@ -16,9 +15,7 @@ const useCommunityPrivacy = (communityData: Community) => { const updatePrivacyType = async (privacyType: string) => { try { - await updateDoc(doc(firestore, "communities", communityData.id), { - privacyType, - }); + await updateCommunityPrivacy(communityData.id, privacyType); setCommunityStateValue((prev) => ({ ...prev, currentCommunity: { diff --git a/hooks/community/useCreateCommunity.ts b/hooks/community/useCreateCommunity.ts index 9d7b583..3b4f32b 100644 --- a/hooks/community/useCreateCommunity.ts +++ b/hooks/community/useCreateCommunity.ts @@ -1,9 +1,9 @@ -import { auth, firestore } from "@/firebase/clientApp"; -import { doc, runTransaction, serverTimestamp } from "firebase/firestore"; -import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { auth } from "@/firebase/clientApp"; import { useAuthState } from "react-firebase-hooks/auth"; import useCustomToast from "../useCustomToast"; +import { createCommunity } from "@/lib/community/createCommunity"; /** * Creates a new community with validation and a creator snippet transaction. @@ -18,7 +18,7 @@ export const useCreateCommunity = () => { const router = useRouter(); const showToast = useCustomToast(); - const createCommunity = async ( + const onCreateCommunity = async ( communityName: string, communityType: string ) => { @@ -38,39 +38,7 @@ export const useCreateCommunity = () => { setLoading(true); try { - // check if community exists by using document reference - // takes firestore object, name of collection in db, and the id (community names are unique) - const communityDocRef = doc(firestore, "communities", communityName); - /** - * if one transaction fails they all fail - */ - await runTransaction(firestore, async (transaction) => { - const communityDoc = await transaction.get(communityDocRef); - if (communityDoc.exists()) { - // if community exists - throw new Error( - `The community ${communityName} is already taken. Try a different name! ` - ); - } - - // create community - transaction.set(communityDocRef, { - creatorId: user?.uid, - createdAt: serverTimestamp(), - numberOfMembers: 1, - privacyType: communityType, - }); - - // create community snippet on user - transaction.set( - // path: collection/document/collection/... - doc(firestore, `users/${user?.uid}/communitySnippets`, communityName), - { - communityId: communityName, - isAdmin: true, - } - ); - }); + await createCommunity(communityName, communityType, user?.uid!); router.push(`/community/${communityName}`); return true; @@ -88,5 +56,5 @@ export const useCreateCommunity = () => { } }; - return { createCommunity, loading, error, setError }; + return { createCommunity: onCreateCommunity, loading, error, setError }; }; diff --git a/hooks/community/useDeleteCommunity.ts b/hooks/community/useDeleteCommunity.ts index c2efd33..3ee7011 100644 --- a/hooks/community/useDeleteCommunity.ts +++ b/hooks/community/useDeleteCommunity.ts @@ -1,19 +1,8 @@ -import { Community } from "@/types/community"; -import { firestore, storage } from "@/firebase/clientApp"; -import { - collection, - collectionGroup, - doc, - DocumentReference, - getDocs, - query, - where, - writeBatch, -} from "firebase/firestore"; -import { deleteObject, ref } from "firebase/storage"; -import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Community } from "@/types/community"; import useCustomToast from "../useCustomToast"; +import { deleteCommunity } from "@/lib/community/deleteCommunity"; /** * Deletes a community and all related posts, comments, votes, and snippets. @@ -25,77 +14,10 @@ const useDeleteCommunity = (communityData: Community) => { const showToast = useCustomToast(); const [loading, setLoading] = useState(false); - const deleteCommunity = async () => { + const onDeleteCommunity = async () => { setLoading(true); try { - const postsQuery = query( - collection(firestore, "posts"), - where("communityId", "==", communityData.id) - ); - const postsSnapshot = await getDocs(postsQuery); - - const deletePostImagePromises: Promise[] = []; - postsSnapshot.docs.forEach((doc) => { - const post = doc.data(); - if (post.imageURL) { - const imageRef = ref(storage, `posts/${doc.id}/image`); - deletePostImagePromises.push( - deleteObject(imageRef).catch((e) => - console.log("Error deleting post image", e) - ) - ); - } - }); - await Promise.all(deletePostImagePromises); - - if (communityData.imageURL) { - const imageRef = ref(storage, `communities/${communityData.id}/image`); - await deleteObject(imageRef).catch((e) => - console.log("Error deleting community image", e) - ); - } - - let docsToDelete: DocumentReference[] = []; - docsToDelete.push(doc(firestore, "communities", communityData.id)); - postsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); - - for (const postDoc of postsSnapshot.docs) { - const commentsQuery = query( - collection(firestore, "comments"), - where("postId", "==", postDoc.id) - ); - const commentsSnapshot = await getDocs(commentsQuery); - commentsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); - - const votesQuery = query( - collectionGroup(firestore, "postVotes"), - where("postId", "==", postDoc.id) - ); - const votesSnapshot = await getDocs(votesQuery); - votesSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); - } - - const snippetsQuery = query( - collectionGroup(firestore, "communitySnippets"), - where("communityId", "==", communityData.id) - ); - const snippetsSnapshot = await getDocs(snippetsQuery); - snippetsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); - - const chunkArray = (arr: any[], size: number) => { - const chunks = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; - }; - - const chunks = chunkArray(docsToDelete, 450); - for (const chunk of chunks) { - const batch = writeBatch(firestore); - chunk.forEach((ref) => batch.delete(ref)); - await batch.commit(); - } + await deleteCommunity(communityData); showToast({ title: "Community Deleted", @@ -116,7 +38,7 @@ const useDeleteCommunity = (communityData: Community) => { } }; - return { deleteCommunity, loading }; + return { deleteCommunity: onDeleteCommunity, loading }; }; export default useDeleteCommunity; diff --git a/hooks/community/useJoinCommunity.tsx b/hooks/community/useJoinCommunity.tsx index f07a8c1..eb9dd23 100644 --- a/hooks/community/useJoinCommunity.tsx +++ b/hooks/community/useJoinCommunity.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { communityStateAtom } from "@/atoms/communitiesAtom"; -import { Community, CommunitySnippet } from "@/types/community"; -import { auth, firestore } from "@/firebase/clientApp"; -import { doc, increment, writeBatch } from "firebase/firestore"; +import { Community } from "@/types/community"; +import { auth } from "@/firebase/clientApp"; import { useSetAtom } from "jotai"; import { useAuthState } from "react-firebase-hooks/auth"; import useCustomToast from "../useCustomToast"; +import { joinCommunity } from "@/lib/community/joinCommunity"; /** * Adds the current user to a community and updates member counts and snippets. @@ -19,35 +19,18 @@ const useJoinCommunity = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const joinCommunity = async (communityData: Community) => { + const onJoinCommunity = async (communityData: Community) => { if (!user) return; setLoading(true); try { - const batch = writeBatch(firestore); - - const newSnippet: CommunitySnippet = { - communityId: communityData.id, - imageURL: communityData.imageURL || "", - isAdmin: - user?.uid === communityData.creatorId || - communityData.adminIds?.includes(user?.uid || ""), - }; - - batch.set( - doc( - firestore, - `users/${user?.uid}/communitySnippets`, - communityData.id - ), - newSnippet + const newSnippet = await joinCommunity( + user.uid, + communityData.id, + communityData.imageURL || "", + user.uid === communityData.creatorId || + (communityData.adminIds?.includes(user.uid || "") ?? false) ); - batch.update(doc(firestore, "communities", communityData.id), { - numberOfMembers: increment(1), - }); - - await batch.commit(); - setCommunityStateValue((prev) => ({ ...prev, mySnippets: [...prev.mySnippets, newSnippet], @@ -73,7 +56,7 @@ const useJoinCommunity = () => { }; return { - joinCommunity, + joinCommunity: onJoinCommunity, joinLoading: loading, joinError: error, }; diff --git a/hooks/community/useLeaveCommunity.tsx b/hooks/community/useLeaveCommunity.tsx index 10434db..a877254 100644 --- a/hooks/community/useLeaveCommunity.tsx +++ b/hooks/community/useLeaveCommunity.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { communityStateAtom } from "@/atoms/communitiesAtom"; -import { auth, firestore } from "@/firebase/clientApp"; -import { doc, increment, writeBatch } from "firebase/firestore"; +import { auth } from "@/firebase/clientApp"; import { useSetAtom } from "jotai"; import { useAuthState } from "react-firebase-hooks/auth"; import useCustomToast from "../useCustomToast"; +import { leaveCommunity } from "@/lib/community/leaveCommunity"; /** * Removes the current user from a community and decrements its member count. @@ -18,21 +18,11 @@ const useLeaveCommunity = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const leaveCommunity = async (communityId: string) => { + const onLeaveCommunity = async (communityId: string) => { if (!user) return; setLoading(true); try { - const batch = writeBatch(firestore); - - batch.delete( - doc(firestore, `users/${user?.uid}/communitySnippets`, communityId) - ); - - batch.update(doc(firestore, "communities", communityId), { - numberOfMembers: increment(-1), - }); - - await batch.commit(); + await leaveCommunity(user.uid, communityId); setCommunityStateValue((prev) => ({ ...prev, @@ -61,7 +51,7 @@ const useLeaveCommunity = () => { }; return { - leaveCommunity, + leaveCommunity: onLeaveCommunity, leaveLoading: loading, leaveError: error, }; diff --git a/hooks/community/useRemoveCommunityMember.ts b/hooks/community/useRemoveCommunityMember.ts index d5b1caf..fb78cfb 100644 --- a/hooks/community/useRemoveCommunityMember.ts +++ b/hooks/community/useRemoveCommunityMember.ts @@ -1,6 +1,6 @@ import { useState } from "react"; import useCustomToast from "../useCustomToast"; -import { removeCommunityMember as removeMemberLib } from "@/lib/community/removeCommunityMember"; +import { removeCommunityMember } from "@/lib/community/removeCommunityMember"; /** * Hook to remove a member from a community. @@ -13,7 +13,7 @@ const useRemoveCommunityMember = () => { const removeMember = async (communityId: string, memberId: string) => { setLoading(true); try { - await removeMemberLib(communityId, memberId); + await removeCommunityMember(communityId, memberId); showToast({ title: "User removed", description: "The user has been removed from the community.", diff --git a/hooks/posts/useCreatePost.ts b/hooks/posts/useCreatePost.ts index ee701c4..7f1ca97 100644 --- a/hooks/posts/useCreatePost.ts +++ b/hooks/posts/useCreatePost.ts @@ -1,18 +1,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { User } from "firebase/auth"; -import { - addDoc, - collection, - doc, - serverTimestamp, - Timestamp, - updateDoc, -} from "firebase/firestore"; -import { getDownloadURL, ref, uploadString } from "firebase/storage"; -import { firestore, storage } from "@/firebase/clientApp"; -import { Post } from "@/types/post"; -import useCustomToast from "../useCustomToast"; +import useCustomToast from "@/hooks/useCustomToast"; +import { createPost } from "@/lib/posts/createPost"; /** * Creates a new post, uploads an optional image, and notifies the user. @@ -39,29 +29,13 @@ const useCreatePost = () => { if (!user) return; setLoading(true); try { - const newPost: Post = { + await createPost( + user, communityId, - communityImageURL: communityImageURL || "", - creatorId: user.uid, - creatorUsername: user.displayName || user.email!.split("@")[0], - title: postData.title, - body: postData.body, - numberOfComments: 0, - voteStatus: 0, - createTime: serverTimestamp() as Timestamp, - }; - - const postDocRef = await addDoc(collection(firestore, "posts"), newPost); - - if (selectedFile) { - const imageRef = ref(storage, `posts/${postDocRef.id}/image`); - await uploadString(imageRef, selectedFile, "data_url"); - const downloadURL = await getDownloadURL(imageRef); - - await updateDoc(doc(firestore, "posts", postDocRef.id), { - imageURL: downloadURL, - }); - } + communityImageURL, + postData, + selectedFile + ); router.back(); showToast({ diff --git a/hooks/posts/usePostDeletion.ts b/hooks/posts/usePostDeletion.ts index 9982be3..edcab11 100644 --- a/hooks/posts/usePostDeletion.ts +++ b/hooks/posts/usePostDeletion.ts @@ -1,19 +1,9 @@ import { postStateAtom } from "@/atoms/postsAtom"; import { savedPostStateAtom } from "@/atoms/savedPostsAtom"; -import { firestore, storage } from "@/firebase/clientApp"; import { Post, PostVote } from "@/types/post"; -import { - collection, - deleteDoc, - doc, - getDocs, - query, - where, - writeBatch, -} from "firebase/firestore"; -import { deleteObject, ref } from "firebase/storage"; import { useAtom, useSetAtom } from "jotai"; import React from "react"; +import { deletePost } from "@/lib/posts/deletePost"; /** * Deletes posts along with their assets and related saved entries while keeping state in sync. @@ -44,25 +34,7 @@ const usePostDeletion = ( })); try { - if (post.imageURL) { - const imageRef = ref(storage, `posts/${post.id}/image`); - await deleteObject(imageRef); - } - - const postDocRef = doc(firestore, "posts", post.id!); - await deleteDoc(postDocRef); - - const commentsQuery = query( - collection(firestore, "comments"), - where("postId", "==", post.id) - ); - const commentDocs = await getDocs(commentsQuery); - const batch = writeBatch(firestore); - commentDocs.docs.forEach((d) => { - batch.delete(d.ref); - }); - await batch.commit(); - + await deletePost(post); return true; } catch (error) { console.log("Error deleting post", error); diff --git a/hooks/posts/usePostVote.tsx b/hooks/posts/usePostVote.tsx index 0c12c8a..31811cb 100644 --- a/hooks/posts/usePostVote.tsx +++ b/hooks/posts/usePostVote.tsx @@ -1,21 +1,14 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { authModalStateAtom } from "@/atoms/authModalAtom"; -import { auth, firestore } from "@/firebase/clientApp"; -import { - collection, - doc, - getDoc, - query, - updateDoc, - where, - writeBatch, -} from "firebase/firestore"; -import { getDocs } from "firebase/firestore"; +import { auth } from "@/firebase/clientApp"; import { useSetAtom } from "jotai"; import { useAuthState } from "react-firebase-hooks/auth"; import useCustomToast from "../useCustomToast"; import React from "react"; import { Post, PostVote } from "@/types/post"; +import { handlePostVote } from "@/lib/posts/handlePostVote"; +import { getPostVotes as getPostVotesLib } from "@/lib/posts/getPostVotes"; +import { getPost as getPostLib } from "@/lib/posts/getPost"; type SetPostState = React.Dispatch< React.SetStateAction<{ @@ -62,62 +55,33 @@ const usePostVote = ( (v) => v.postId === post.id ); - const batch = writeBatch(firestore); - const updatedPost = { ...post }; - const updatedPosts = [...postStateValue.posts]; - let updatedPostVotes = [...postStateValue.postVotes]; - let voteChange = vote; + const { voteChange, newVote, voteIdToDelete } = await handlePostVote( + user.uid, + post, + vote, + communityId, + existingVote + ); - if (!existingVote) { - const postVoteRef = doc( - collection(firestore, "users", `${user?.uid}/postVotes`) - ); - const newVote: PostVote = { - id: postVoteRef.id, - postId: post.id!, - communityId, - voteValue: vote, - }; + let updatedPostVotes = [...postStateValue.postVotes]; + const updatedPost = { ...post, voteStatus: voteStatus + voteChange }; + const updatedPosts = [...postStateValue.posts]; - batch.set(postVoteRef, newVote); - updatedPost.voteStatus = voteStatus + vote; - updatedPostVotes = [...updatedPostVotes, newVote]; - } else { - const postVoteRef = doc( - firestore, - "users", - `${user?.uid}/postVotes/${existingVote.id}` + if (voteIdToDelete) { + updatedPostVotes = updatedPostVotes.filter( + (v) => v.id !== voteIdToDelete ); - - if (existingVote.voteValue === vote) { - updatedPost.voteStatus = voteStatus - vote; - updatedPostVotes = updatedPostVotes.filter( - (v) => v.id !== existingVote.id - ); - batch.delete(postVoteRef); - voteChange *= -1; - } else { - updatedPost.voteStatus = voteStatus + 2 * vote; + } else if (newVote) { + if (existingVote) { const voteIndexPosition = postStateValue.postVotes.findIndex( (v) => v.id === existingVote.id ); - - updatedPostVotes[voteIndexPosition] = { - ...existingVote, - voteValue: vote, - }; - - batch.update(postVoteRef, { - voteValue: vote, - }); - voteChange = 2 * vote; + updatedPostVotes[voteIndexPosition] = newVote; + } else { + updatedPostVotes = [...updatedPostVotes, newVote]; } } - const postRef = doc(firestore, "posts", post.id!); - batch.update(postRef, { voteStatus: voteStatus + voteChange }); - await batch.commit(); - const postIndexPosition = postStateValue.posts.findIndex( (item) => item.id === post.id ); @@ -147,28 +111,7 @@ const usePostVote = ( const getPostVotes = async (postIds: string[]) => { if (!user || !postIds.length) return; try { - const chunks = []; - const chunkSize = 10; - for (let i = 0; i < postIds.length; i += chunkSize) { - chunks.push(postIds.slice(i, i + chunkSize)); - } - - const promises = chunks.map((chunk) => { - const postVotesQuery = query( - collection(firestore, `users/${user.uid}/postVotes`), - where("postId", "in", chunk) - ); - return getDocs(postVotesQuery); - }); - - const querySnapshots = await Promise.all(promises); - - const postVotes = querySnapshots.flatMap((snapshot) => - snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) - ); + const postVotes = await getPostVotesLib(user.uid, postIds); setPostStateValue((prev) => ({ ...prev, @@ -186,10 +129,8 @@ const usePostVote = ( const getPost = async (postId: string) => { try { - const postDocRef = doc(firestore, "posts", postId); - const postDoc = await getDoc(postDocRef); - if (postDoc.exists()) { - const post = { id: postDoc.id, ...(postDoc.data() as Post) }; + const post = await getPostLib(postId); + if (post) { setPostStateValue((prev) => ({ ...prev, selectedPost: post, diff --git a/hooks/posts/usePostVoteSync.ts b/hooks/posts/usePostVoteSync.ts index 33a14b9..e6893c5 100644 --- a/hooks/posts/usePostVoteSync.ts +++ b/hooks/posts/usePostVoteSync.ts @@ -1,11 +1,10 @@ import { communityStateAtom } from "@/atoms/communitiesAtom"; -import { firestore } from "@/firebase/clientApp"; -import { collection, getDocs, query, where } from "firebase/firestore"; import { useAtomValue } from "jotai"; import React, { useEffect } from "react"; import { useAuthState } from "react-firebase-hooks/auth"; import { auth } from "@/firebase/clientApp"; import { Post, PostVote } from "@/types/post"; +import { getCommunityPostVotes as getCommunityPostVotesLib } from "@/lib/posts/getCommunityPostVotes"; type SetPostState = React.Dispatch< React.SetStateAction<{ @@ -25,16 +24,8 @@ const usePostVoteSync = (setPostStateValue: SetPostState) => { const currentCommunity = useAtomValue(communityStateAtom).currentCommunity; const getCommunityPostVotes = async (communityId: string) => { - const postVotesQuery = query( - collection(firestore, "users", `${user?.uid}/postVotes`), - where("communityId", "==", communityId) - ); - - const postVoteDocs = await getDocs(postVotesQuery); - const postVotes = postVoteDocs.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })); + if (!user) return; + const postVotes = await getCommunityPostVotesLib(user.uid, communityId); setPostStateValue((prev) => ({ ...prev, postVotes: postVotes as PostVote[], diff --git a/hooks/posts/usePostsFeed.ts b/hooks/posts/usePostsFeed.ts index 1d24876..fe99344 100644 --- a/hooks/posts/usePostsFeed.ts +++ b/hooks/posts/usePostsFeed.ts @@ -1,22 +1,11 @@ import { postStateAtom } from "@/atoms/postsAtom"; -import { firestore } from "@/firebase/clientApp"; -import { - collection, - DocumentData, - getDocs, - limit, - orderBy, - query, - QueryConstraint, - QueryDocumentSnapshot, - startAfter, - where, -} from "firebase/firestore"; +import { DocumentData, QueryDocumentSnapshot } from "firebase/firestore"; import { useSetAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import useCustomToast from "../useCustomToast"; import { useIntersectionObserver } from "../useIntersectionObserver"; import { Post } from "@/types/post"; +import { getPosts as getPostsLib } from "@/lib/posts/getPosts"; type UsePostsFeedProps = { communityId?: string; @@ -46,28 +35,6 @@ const usePostsFeed = ({ const observerOptions = useMemo(() => ({ threshold: 0.5 }), []); const { ref, isIntersecting } = useIntersectionObserver(observerOptions); - const buildQuery = (initial: boolean) => { - const constraints: QueryConstraint[] = []; - - if (communityId) { - constraints.push(where("communityId", "==", communityId)); - constraints.push(orderBy("createTime", "desc")); - } else if (communityIds && communityIds.length > 0) { - constraints.push(where("communityId", "in", communityIds)); - constraints.push(orderBy("createTime", "desc")); - } else if (isGenericHome) { - constraints.push(orderBy("voteStatus", "desc")); - } - - if (!initial && lastVisible) { - constraints.push(startAfter(lastVisible)); - } - - constraints.push(limit(10)); - - return query(collection(firestore, "posts"), ...constraints); - }; - const fetchPosts = async (initial = false) => { if (loading) return; if (!initial && noMorePosts) return; @@ -75,16 +42,15 @@ const usePostsFeed = ({ setLoading(true); try { - const postQuery = buildQuery(initial); - const postDocs = await getDocs(postQuery); - const posts = postDocs.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })); - - if (postDocs.docs.length < 10) setNoMorePosts(true); - if (postDocs.docs.length > 0) - setLastVisible(postDocs.docs[postDocs.docs.length - 1]); + const { posts, newLastVisible } = await getPostsLib( + communityId, + communityIds, + isGenericHome, + initial ? null : lastVisible + ); + + if (posts.length < 10) setNoMorePosts(true); + if (newLastVisible) setLastVisible(newLastVisible); else if (initial) { setNoMorePosts(true); } @@ -98,8 +64,8 @@ const usePostsFeed = ({ } catch (error: any) { console.log("Error: fetchPosts", error); showToast({ - title: "Could not fetch posts", - description: error.message || "There was an error fetching posts", + title: "Could not Fetch Posts", + description: "There was an error fetching posts", status: "error", }); } finally { diff --git a/hooks/posts/useSavedPosts.tsx b/hooks/posts/useSavedPosts.tsx index 2ef48f9..6591d48 100644 --- a/hooks/posts/useSavedPosts.tsx +++ b/hooks/posts/useSavedPosts.tsx @@ -1,19 +1,14 @@ import { authModalStateAtom } from "@/atoms/authModalAtom"; import { savedPostStateAtom } from "@/atoms/savedPostsAtom"; -import { auth, firestore } from "@/firebase/clientApp"; +import { auth } from "@/firebase/clientApp"; import { Post } from "@/types/post"; -import { SavedPost } from "@/types/savedPost"; -import { - collection, - deleteDoc, - doc, - getDocs, - setDoc, -} from "firebase/firestore"; import { useAtom, useSetAtom } from "jotai"; import { useEffect, useState } from "react"; import { useAuthState } from "react-firebase-hooks/auth"; import useCustomToast from "../useCustomToast"; +import { getSavedPosts as getSavedPostsLib } from "@/lib/posts/getSavedPosts"; +import { savePost } from "@/lib/posts/savePost"; +import { unsavePost } from "@/lib/posts/unsavePost"; /** * Manages a user's saved posts collection and related UI state. @@ -32,13 +27,7 @@ const useSavedPosts = () => { if (!user) return; setLoading(true); try { - const querySnapshot = await getDocs( - collection(firestore, `users/${user.uid}/savedPosts`) - ); - const savedPosts = querySnapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })) as SavedPost[]; + const savedPosts = await getSavedPostsLib(user.uid); setSavedPostState((prev) => ({ ...prev, @@ -62,18 +51,12 @@ const useSavedPosts = () => { } try { - const savedPostRef = doc( - firestore, - `users/${user.uid}/savedPosts`, - post.id! - ); - const isSaved = savedPostState.savedPosts.find( (item) => item.postId === post.id ); if (isSaved) { - await deleteDoc(savedPostRef); + await unsavePost(user.uid, post.id!); setSavedPostState((prev) => ({ ...prev, savedPosts: prev.savedPosts.filter((item) => item.postId !== post.id), @@ -83,14 +66,7 @@ const useSavedPosts = () => { status: "success", }); } else { - const newSavedPost: SavedPost = { - id: post.id!, - postId: post.id!, - communityId: post.communityId, - postTitle: post.title, - communityImageURL: post.communityImageURL || "", - }; - await setDoc(savedPostRef, newSavedPost); + const newSavedPost = await savePost(user.uid, post); setSavedPostState((prev) => ({ ...prev, savedPosts: [...prev.savedPosts, newSavedPost], @@ -113,7 +89,7 @@ const useSavedPosts = () => { const onRemoveSavedPost = async (postId: string) => { if (!user) return; try { - await deleteDoc(doc(firestore, `users/${user.uid}/savedPosts`, postId)); + await unsavePost(user.uid, postId); setSavedPostState((prev) => ({ ...prev, savedPosts: prev.savedPosts.filter((item) => item.postId !== postId), diff --git a/hooks/useSearch.tsx b/hooks/useSearch.tsx index da1a43a..3db1f48 100644 --- a/hooks/useSearch.tsx +++ b/hooks/useSearch.tsx @@ -1,15 +1,7 @@ import { useState, useEffect } from "react"; -import { - collection, - getDocs, - query, - where, - limit, - orderBy, -} from "firebase/firestore"; -import { firestore } from "@/firebase/clientApp"; import { Community } from "@/types/community"; import { Post } from "@/types/post"; +import { getSearchData } from "@/lib/search/getSearchData"; /** * Client-side search hook that preloads public communities and recent posts. @@ -34,25 +26,7 @@ const useSearch = (searchTerm: string) => { const fetchData = async () => { setLoading(true); try { - const communitiesQuery = query( - collection(firestore, "communities"), - where("privacyType", "==", "public") - ); - const communitiesSnap = await getDocs(communitiesQuery); - const communities = communitiesSnap.docs.map( - (doc) => ({ id: doc.id, ...doc.data() } as Community) - ); - - const postsQuery = query( - collection(firestore, "posts"), - orderBy("createTime", "desc"), - limit(100) - ); - const postsSnap = await getDocs(postsQuery); - const posts = postsSnap.docs.map( - (doc) => ({ id: doc.id, ...doc.data() } as Post) - ); - + const { communities, posts } = await getSearchData(); setAllData({ communities, posts }); } catch (error) { console.error("Error fetching search data", error); diff --git a/lib/comments/createComment.ts b/lib/comments/createComment.ts new file mode 100644 index 0000000..1951d1e --- /dev/null +++ b/lib/comments/createComment.ts @@ -0,0 +1,46 @@ +import { firestore } from "@/firebase/clientApp"; +import { Comment } from "@/types/comment"; +import { + collection, + doc, + increment, + serverTimestamp, + Timestamp, + writeBatch, +} from "firebase/firestore"; +import { User } from "firebase/auth"; + +export const createComment = async ( + user: User, + communityId: string, + postId: string, + postTitle: string, + commentText: string, + parentId?: string +) => { + const batch = writeBatch(firestore); + const commentDocRef = doc(collection(firestore, "comments")); + const newComment: Comment = { + id: commentDocRef.id, + creatorId: user.uid, + creatorDisplayText: user.email!.split("@")[0], + communityId: communityId, + postId: postId, + postTitle: postTitle, + text: commentText, + createdAt: serverTimestamp() as Timestamp, + parentId: parentId || undefined, + }; + + if (!parentId) delete newComment.parentId; + + batch.set(commentDocRef, newComment); + + const postDocRef = doc(firestore, "posts", postId); + batch.update(postDocRef, { + numberOfComments: increment(1), + }); + + await batch.commit(); + return newComment; +}; diff --git a/lib/comments/deleteComment.ts b/lib/comments/deleteComment.ts new file mode 100644 index 0000000..9a46540 --- /dev/null +++ b/lib/comments/deleteComment.ts @@ -0,0 +1,24 @@ +import { firestore } from "@/firebase/clientApp"; +import { doc, increment, writeBatch } from "firebase/firestore"; + +export const deleteComment = async ( + commentId: string, + postId: string, + descendantIds: string[] +) => { + const batch = writeBatch(firestore); + const allIdsToDelete = [commentId, ...descendantIds]; + + allIdsToDelete.forEach((id) => { + const commentDocRef = doc(firestore, "comments", id); + batch.delete(commentDocRef); + }); + + const postDocRef = doc(firestore, "posts", postId); + batch.update(postDocRef, { + numberOfComments: increment(-allIdsToDelete.length), + }); + + await batch.commit(); + return allIdsToDelete.length; +}; diff --git a/lib/comments/getComments.ts b/lib/comments/getComments.ts new file mode 100644 index 0000000..8ac19e8 --- /dev/null +++ b/lib/comments/getComments.ts @@ -0,0 +1,17 @@ +import { firestore } from "@/firebase/clientApp"; +import { Comment } from "@/types/comment"; +import { collection, getDocs, orderBy, query, where } from "firebase/firestore"; + +export const getComments = async (postId: string) => { + const commentsQuery = query( + collection(firestore, "comments"), + where("postId", "==", postId), + orderBy("createdAt", "desc") + ); + const commentDocs = await getDocs(commentsQuery); + const comments = commentDocs.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Comment[]; + return comments; +}; diff --git a/lib/community/createCommunity.ts b/lib/community/createCommunity.ts new file mode 100644 index 0000000..88cbe13 --- /dev/null +++ b/lib/community/createCommunity.ts @@ -0,0 +1,34 @@ +import { firestore } from "@/firebase/clientApp"; +import { doc, runTransaction, serverTimestamp } from "firebase/firestore"; + +export const createCommunity = async ( + communityName: string, + communityType: string, + userId: string +) => { + const communityDocRef = doc(firestore, "communities", communityName); + + await runTransaction(firestore, async (transaction) => { + const communityDoc = await transaction.get(communityDocRef); + if (communityDoc.exists()) { + throw new Error(`Sorry, /r/${communityName} is taken. Try another.`); + } + + // create community + transaction.set(communityDocRef, { + creatorId: userId, + createdAt: serverTimestamp(), + numberOfMembers: 1, + privacyType: communityType, + }); + + // create community snippet on user + transaction.set( + doc(firestore, `users/${userId}/communitySnippets`, communityName), + { + communityId: communityName, + isModerator: true, + } + ); + }); +}; diff --git a/lib/community/deleteCommunity.ts b/lib/community/deleteCommunity.ts new file mode 100644 index 0000000..c029ec8 --- /dev/null +++ b/lib/community/deleteCommunity.ts @@ -0,0 +1,77 @@ +import { firestore, storage } from "@/firebase/clientApp"; +import { Community } from "@/types/community"; +import { + collection, + collectionGroup, + doc, + DocumentReference, + getDocs, + query, + where, + writeBatch, +} from "firebase/firestore"; +import { deleteObject, ref } from "firebase/storage"; + +export const deleteCommunity = async (communityData: Community) => { + const postsQuery = query( + collection(firestore, "posts"), + where("communityId", "==", communityData.id) + ); + const postsSnapshot = await getDocs(postsQuery); + + const deletePostImagePromises: Promise[] = []; + postsSnapshot.docs.forEach((doc) => { + const post = doc.data(); + if (post.imageURL) { + const imageRef = ref(storage, `posts/${post.id}/image`); + deletePostImagePromises.push(deleteObject(imageRef)); + } + }); + await Promise.all(deletePostImagePromises); + + if (communityData.imageURL) { + const imageRef = ref(storage, `communities/${communityData.id}/image`); + await deleteObject(imageRef).catch((e) => + console.log("Error deleting community image", e) + ); + } + + let docsToDelete: DocumentReference[] = []; + docsToDelete.push(doc(firestore, "communities", communityData.id)); + postsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); + + for (const postDoc of postsSnapshot.docs) { + const commentsQuery = query( + collection(firestore, "comments"), + where("postId", "==", postDoc.id) + ); + const commentsSnapshot = await getDocs(commentsQuery); + commentsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); + } + + const snippetsQuery = query( + collectionGroup(firestore, "communitySnippets"), + where("communityId", "==", communityData.id) + ); + const snippetsSnapshot = await getDocs(snippetsQuery); + snippetsSnapshot.docs.forEach((d) => docsToDelete.push(d.ref)); + + const chunkArray = (arr: any[], size: number) => { + const chunked_arr = []; + let index = 0; + while (index < arr.length) { + chunked_arr.push(arr.slice(index, size + index)); + index += size; + } + return chunked_arr; + }; + + const chunks = chunkArray(docsToDelete, 450); + for (const chunk of chunks) { + const batch = writeBatch(firestore); + chunk.forEach((docRef: DocumentReference) => { + batch.delete(docRef); + }); + await batch.commit(); + } +}; diff --git a/lib/community/deleteCommunityImage.ts b/lib/community/deleteCommunityImage.ts new file mode 100644 index 0000000..07c8e21 --- /dev/null +++ b/lib/community/deleteCommunityImage.ts @@ -0,0 +1,33 @@ +import { firestore, storage } from "@/firebase/clientApp"; +import { + collection, + doc, + getDoc, + getDocs, + updateDoc, +} from "firebase/firestore"; +import { deleteObject, ref } from "firebase/storage"; + +export const deleteCommunityImage = async (communityId: string) => { + const imageRef = ref(storage, `communities/${communityId}/image`); + await deleteObject(imageRef); + await updateDoc(doc(firestore, "communities", communityId), { + imageURL: "", + }); + + const usersSnapshot = await getDocs(collection(firestore, "users")); + const updatePromises = usersSnapshot.docs.map(async (userDoc) => { + const communitySnippetDoc = await getDoc( + doc(firestore, "users", userDoc.id, "communitySnippets", communityId) + ); + if (communitySnippetDoc.exists()) { + await updateDoc( + doc(firestore, "users", userDoc.id, "communitySnippets", communityId), + { + imageURL: "", + } + ); + } + }); + await Promise.all(updatePromises); +}; diff --git a/lib/community/getCommunities.ts b/lib/community/getCommunities.ts new file mode 100644 index 0000000..db93cf5 --- /dev/null +++ b/lib/community/getCommunities.ts @@ -0,0 +1,46 @@ +import { firestore } from "@/firebase/clientApp"; +import { Community } from "@/types/community"; +import { + collection, + DocumentData, + getDocs, + limit, + orderBy, + query, + QueryDocumentSnapshot, + startAfter, +} from "firebase/firestore"; + +export const getCommunities = async ( + limitValue: number, + lastVisible?: QueryDocumentSnapshot | null +) => { + let communityQuery; + if (!lastVisible) { + communityQuery = query( + collection(firestore, "communities"), + orderBy("numberOfMembers", "desc"), + limit(limitValue) + ); + } else { + communityQuery = query( + collection(firestore, "communities"), + orderBy("numberOfMembers", "desc"), + startAfter(lastVisible), + limit(limitValue) + ); + } + + const communityDocs = await getDocs(communityQuery); + const communities = communityDocs.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Community[]; + + const newLastVisible = + communityDocs.docs.length > 0 + ? communityDocs.docs[communityDocs.docs.length - 1] + : null; + + return { communities, newLastVisible }; +}; diff --git a/lib/community/joinCommunity.ts b/lib/community/joinCommunity.ts new file mode 100644 index 0000000..fa301b4 --- /dev/null +++ b/lib/community/joinCommunity.ts @@ -0,0 +1,30 @@ +import { firestore } from "@/firebase/clientApp"; +import { CommunitySnippet } from "@/types/community"; +import { doc, increment, writeBatch } from "firebase/firestore"; + +export const joinCommunity = async ( + userId: string, + communityId: string, + communityImageURL: string, + isCreatorOrAdmin: boolean +) => { + const batch = writeBatch(firestore); + + const newSnippet: CommunitySnippet = { + communityId: communityId, + imageURL: communityImageURL || "", + isAdmin: isCreatorOrAdmin, + }; + + batch.set( + doc(firestore, `users/${userId}/communitySnippets`, communityId), + newSnippet + ); + + batch.update(doc(firestore, "communities", communityId), { + numberOfMembers: increment(1), + }); + + await batch.commit(); + return newSnippet; +}; diff --git a/lib/community/leaveCommunity.ts b/lib/community/leaveCommunity.ts new file mode 100644 index 0000000..0be3be6 --- /dev/null +++ b/lib/community/leaveCommunity.ts @@ -0,0 +1,16 @@ +import { firestore } from "@/firebase/clientApp"; +import { doc, increment, writeBatch } from "firebase/firestore"; + +export const leaveCommunity = async (userId: string, communityId: string) => { + const batch = writeBatch(firestore); + + batch.delete( + doc(firestore, `users/${userId}/communitySnippets`, communityId) + ); + + batch.update(doc(firestore, "communities", communityId), { + numberOfMembers: increment(-1), + }); + + await batch.commit(); +}; diff --git a/lib/community/updateCommunityImage.ts b/lib/community/updateCommunityImage.ts new file mode 100644 index 0000000..87e89e7 --- /dev/null +++ b/lib/community/updateCommunityImage.ts @@ -0,0 +1,39 @@ +import { firestore, storage } from "@/firebase/clientApp"; +import { + collection, + doc, + getDoc, + getDocs, + updateDoc, +} from "firebase/firestore"; +import { getDownloadURL, ref, uploadString } from "firebase/storage"; + +export const updateCommunityImage = async ( + communityId: string, + selectedFile: string +) => { + const imageRef = ref(storage, `communities/${communityId}/image`); + await uploadString(imageRef, selectedFile, "data_url"); + const downloadURL = await getDownloadURL(imageRef); + await updateDoc(doc(firestore, "communities", communityId), { + imageURL: downloadURL, + }); + + const usersSnapshot = await getDocs(collection(firestore, "users")); + const updatePromises = usersSnapshot.docs.map(async (userDoc) => { + const communitySnippetDoc = await getDoc( + doc(firestore, "users", userDoc.id, "communitySnippets", communityId) + ); + if (communitySnippetDoc.exists()) { + await updateDoc( + doc(firestore, "users", userDoc.id, "communitySnippets", communityId), + { + imageURL: downloadURL, + } + ); + } + }); + await Promise.all(updatePromises); + + return downloadURL; +}; diff --git a/lib/community/updateCommunityPrivacy.ts b/lib/community/updateCommunityPrivacy.ts new file mode 100644 index 0000000..828c991 --- /dev/null +++ b/lib/community/updateCommunityPrivacy.ts @@ -0,0 +1,11 @@ +import { firestore } from "@/firebase/clientApp"; +import { doc, updateDoc } from "firebase/firestore"; + +export const updateCommunityPrivacy = async ( + communityId: string, + privacyType: string +) => { + await updateDoc(doc(firestore, "communities", communityId), { + privacyType, + }); +}; diff --git a/lib/posts/createPost.ts b/lib/posts/createPost.ts new file mode 100644 index 0000000..56fff49 --- /dev/null +++ b/lib/posts/createPost.ts @@ -0,0 +1,45 @@ +import { firestore, storage } from "@/firebase/clientApp"; +import { Post } from "@/types/post"; +import { User } from "firebase/auth"; +import { + addDoc, + collection, + doc, + serverTimestamp, + Timestamp, + updateDoc, +} from "firebase/firestore"; +import { getDownloadURL, ref, uploadString } from "firebase/storage"; + +export const createPost = async ( + user: User, + communityId: string, + communityImageURL: string | undefined, + postData: { title: string; body: string }, + selectedFile?: string +) => { + const newPost: Post = { + communityId, + communityImageURL: communityImageURL || "", + creatorId: user.uid, + creatorUsername: user.displayName || user.email!.split("@")[0], + title: postData.title, + body: postData.body, + numberOfComments: 0, + voteStatus: 0, + createTime: serverTimestamp() as Timestamp, + }; + + const postDocRef = await addDoc(collection(firestore, "posts"), newPost); + + if (selectedFile) { + const imageRef = ref(storage, `posts/${postDocRef.id}/image`); + await uploadString(imageRef, selectedFile, "data_url"); + const downloadURL = await getDownloadURL(imageRef); + + await updateDoc(doc(firestore, "posts", postDocRef.id), { + imageURL: downloadURL, + }); + } + return postDocRef.id; +}; diff --git a/lib/posts/deletePost.ts b/lib/posts/deletePost.ts new file mode 100644 index 0000000..b414a9c --- /dev/null +++ b/lib/posts/deletePost.ts @@ -0,0 +1,33 @@ +import { firestore, storage } from "@/firebase/clientApp"; +import { Post } from "@/types/post"; +import { + collection, + deleteDoc, + doc, + getDocs, + query, + where, + writeBatch, +} from "firebase/firestore"; +import { deleteObject, ref } from "firebase/storage"; + +export const deletePost = async (post: Post) => { + if (post.imageURL) { + const imageRef = ref(storage, `posts/${post.id}/image`); + await deleteObject(imageRef); + } + + const postDocRef = doc(firestore, "posts", post.id!); + await deleteDoc(postDocRef); + + const commentsQuery = query( + collection(firestore, "comments"), + where("postId", "==", post.id) + ); + const commentDocs = await getDocs(commentsQuery); + const batch = writeBatch(firestore); + commentDocs.docs.forEach((d) => { + batch.delete(d.ref); + }); + await batch.commit(); +}; diff --git a/lib/posts/getCommunityPostVotes.ts b/lib/posts/getCommunityPostVotes.ts new file mode 100644 index 0000000..3ae0b9f --- /dev/null +++ b/lib/posts/getCommunityPostVotes.ts @@ -0,0 +1,20 @@ +import { firestore } from "@/firebase/clientApp"; +import { PostVote } from "@/types/post"; +import { collection, getDocs, query, where } from "firebase/firestore"; + +export const getCommunityPostVotes = async ( + userId: string, + communityId: string +) => { + const postVotesQuery = query( + collection(firestore, "users", `${userId}/postVotes`), + where("communityId", "==", communityId) + ); + + const postVoteDocs = await getDocs(postVotesQuery); + const postVotes = postVoteDocs.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as PostVote[]; + return postVotes; +}; diff --git a/lib/posts/getPost.ts b/lib/posts/getPost.ts new file mode 100644 index 0000000..a199fa8 --- /dev/null +++ b/lib/posts/getPost.ts @@ -0,0 +1,12 @@ +import { firestore } from "@/firebase/clientApp"; +import { Post } from "@/types/post"; +import { doc, getDoc } from "firebase/firestore"; + +export const getPost = async (postId: string) => { + const postDocRef = doc(firestore, "posts", postId); + const postDoc = await getDoc(postDocRef); + if (postDoc.exists()) { + return { id: postDoc.id, ...(postDoc.data() as Post) }; + } + return null; +}; diff --git a/lib/posts/getPostVotes.ts b/lib/posts/getPostVotes.ts new file mode 100644 index 0000000..b6e57f6 --- /dev/null +++ b/lib/posts/getPostVotes.ts @@ -0,0 +1,30 @@ +import { firestore } from "@/firebase/clientApp"; +import { PostVote } from "@/types/post"; +import { collection, getDocs, query, where } from "firebase/firestore"; + +export const getPostVotes = async (userId: string, postIds: string[]) => { + const chunks = []; + const chunkSize = 10; + for (let i = 0; i < postIds.length; i += chunkSize) { + chunks.push(postIds.slice(i, i + chunkSize)); + } + + const promises = chunks.map((chunk) => { + const postVotesQuery = query( + collection(firestore, `users/${userId}/postVotes`), + where("postId", "in", chunk) + ); + return getDocs(postVotesQuery); + }); + + const querySnapshots = await Promise.all(promises); + + const postVotes = querySnapshots.flatMap((snapshot) => + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) + ); + + return postVotes as PostVote[]; +}; diff --git a/lib/posts/getPosts.ts b/lib/posts/getPosts.ts new file mode 100644 index 0000000..af3010f --- /dev/null +++ b/lib/posts/getPosts.ts @@ -0,0 +1,51 @@ +import { firestore } from "@/firebase/clientApp"; +import { Post } from "@/types/post"; +import { + collection, + DocumentData, + getDocs, + limit, + orderBy, + query, + QueryConstraint, + QueryDocumentSnapshot, + startAfter, + where, +} from "firebase/firestore"; + +export const getPosts = async ( + communityId?: string, + communityIds?: string[], + isGenericHome?: boolean, + lastVisible?: QueryDocumentSnapshot | null +) => { + const constraints: QueryConstraint[] = []; + + if (communityId) { + constraints.push(where("communityId", "==", communityId)); + constraints.push(orderBy("createTime", "desc")); + } else if (communityIds && communityIds.length > 0) { + constraints.push(where("communityId", "in", communityIds)); + constraints.push(orderBy("createTime", "desc")); + } else if (isGenericHome) { + constraints.push(orderBy("voteStatus", "desc")); + } + + if (lastVisible) { + constraints.push(startAfter(lastVisible)); + } + + constraints.push(limit(10)); + + const postQuery = query(collection(firestore, "posts"), ...constraints); + const postDocs = await getDocs(postQuery); + const posts = postDocs.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as Post[]; + + const newLastVisible = + postDocs.docs.length > 0 ? postDocs.docs[postDocs.docs.length - 1] : null; + + return { posts, newLastVisible }; +}; diff --git a/lib/posts/getSavedPosts.ts b/lib/posts/getSavedPosts.ts new file mode 100644 index 0000000..52413e7 --- /dev/null +++ b/lib/posts/getSavedPosts.ts @@ -0,0 +1,14 @@ +import { firestore } from "@/firebase/clientApp"; +import { SavedPost } from "@/types/savedPost"; +import { collection, getDocs } from "firebase/firestore"; + +export const getSavedPosts = async (userId: string) => { + const querySnapshot = await getDocs( + collection(firestore, `users/${userId}/savedPosts`) + ); + const savedPosts = querySnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as SavedPost[]; + return savedPosts; +}; diff --git a/lib/posts/handlePostVote.ts b/lib/posts/handlePostVote.ts new file mode 100644 index 0000000..a87151a --- /dev/null +++ b/lib/posts/handlePostVote.ts @@ -0,0 +1,58 @@ +import { firestore } from "@/firebase/clientApp"; +import { Post, PostVote } from "@/types/post"; +import { collection, doc, writeBatch } from "firebase/firestore"; + +export const handlePostVote = async ( + userId: string, + post: Post, + vote: number, + communityId: string, + existingVote?: PostVote +) => { + const batch = writeBatch(firestore); + let voteChange = vote; + let newVote: PostVote | undefined; + let voteIdToDelete: string | undefined; + + if (!existingVote) { + const postVoteRef = doc( + collection(firestore, "users", `${userId}/postVotes`) + ); + newVote = { + id: postVoteRef.id, + postId: post.id!, + communityId, + voteValue: vote, + }; + + batch.set(postVoteRef, newVote); + voteChange = vote; + } else { + const postVoteRef = doc( + firestore, + "users", + `${userId}/postVotes/${existingVote.id}` + ); + + if (existingVote.voteValue === vote) { + batch.delete(postVoteRef); + voteChange = -vote; + voteIdToDelete = existingVote.id; + } else { + batch.update(postVoteRef, { + voteValue: vote, + }); + voteChange = 2 * vote; + newVote = { + ...existingVote, + voteValue: vote, + }; + } + } + + const postRef = doc(firestore, "posts", post.id!); + batch.update(postRef, { voteStatus: post.voteStatus + voteChange }); + await batch.commit(); + + return { voteChange, newVote, voteIdToDelete }; +}; diff --git a/lib/posts/savePost.ts b/lib/posts/savePost.ts new file mode 100644 index 0000000..5130a04 --- /dev/null +++ b/lib/posts/savePost.ts @@ -0,0 +1,17 @@ +import { firestore } from "@/firebase/clientApp"; +import { Post } from "@/types/post"; +import { SavedPost } from "@/types/savedPost"; +import { doc, setDoc } from "firebase/firestore"; + +export const savePost = async (userId: string, post: Post) => { + const savedPostRef = doc(firestore, `users/${userId}/savedPosts`, post.id!); + const newSavedPost: SavedPost = { + id: post.id!, + postId: post.id!, + communityId: post.communityId, + postTitle: post.title, + communityImageURL: post.communityImageURL || "", + }; + await setDoc(savedPostRef, newSavedPost); + return newSavedPost; +}; diff --git a/lib/posts/unsavePost.ts b/lib/posts/unsavePost.ts new file mode 100644 index 0000000..b361d5b --- /dev/null +++ b/lib/posts/unsavePost.ts @@ -0,0 +1,6 @@ +import { firestore } from "@/firebase/clientApp"; +import { deleteDoc, doc } from "firebase/firestore"; + +export const unsavePost = async (userId: string, postId: string) => { + await deleteDoc(doc(firestore, `users/${userId}/savedPosts`, postId)); +}; diff --git a/lib/search/getSearchData.ts b/lib/search/getSearchData.ts new file mode 100644 index 0000000..b7bc0d1 --- /dev/null +++ b/lib/search/getSearchData.ts @@ -0,0 +1,34 @@ +import { firestore } from "@/firebase/clientApp"; +import { Community } from "@/types/community"; +import { Post } from "@/types/post"; +import { + collection, + getDocs, + limit, + orderBy, + query, + where, +} from "firebase/firestore"; + +export const getSearchData = async () => { + const communitiesQuery = query( + collection(firestore, "communities"), + where("privacyType", "==", "public") + ); + const communitiesSnap = await getDocs(communitiesQuery); + const communities = communitiesSnap.docs.map( + (doc) => ({ id: doc.id, ...doc.data() } as Community) + ); + + const postsQuery = query( + collection(firestore, "posts"), + orderBy("createTime", "desc"), + limit(100) + ); + const postsSnap = await getDocs(postsQuery); + const posts = postsSnap.docs.map( + (doc) => ({ id: doc.id, ...doc.data() } as Post) + ); + + return { communities, posts }; +};