diff --git a/.gitignore b/.gitignore index ac12e94..a75f8f2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ yarn-error.log* # vercel .vercel + +# Supabase +.branches +.temp \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..74baffc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5d0c9ed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": true, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/@types/Post.ts b/@types/Post.ts index 7262843..cc261fc 100644 --- a/@types/Post.ts +++ b/@types/Post.ts @@ -31,3 +31,13 @@ type Post = { createdAt?: string; updatedAt?: string; }; + +type CommentType = { + userId: string; + content: string; + username: string; + timestamp: string; + userImg: string; + url: string; + name: string; +}; \ No newline at end of file diff --git a/@types/User.ts b/@types/User.ts new file mode 100644 index 0000000..4386b57 --- /dev/null +++ b/@types/User.ts @@ -0,0 +1,19 @@ +type User = { + _id: string; + name: string; + username: string; + email: string; + image: string | null; + bio: string | null; + followers: string[] | null; + following: string[] | null; + verified: boolean | null; + role: string | null; + likes: string[] | null; + createdAt: Date | null; + updatedAt: Date | null; + + // For the frontend + id: string | null | undefined; + accessToken: string | null | undefined; +}; \ No newline at end of file diff --git a/@types/jsonb.ts b/@types/jsonb.ts new file mode 100644 index 0000000..caa4c7e --- /dev/null +++ b/@types/jsonb.ts @@ -0,0 +1,26 @@ +/** + * FIXME: Custom type for JSONB column (Due to a bug which causes JSONB to be inserted as a string from the database) + * NOTE: The below code is a HACK/Workaround, not to be used in production if the bug is fixed in the future + * TODO: Remove this file and use the JSONB type directly from drizzle-orm/pg-core +*/ + +import { customType } from 'drizzle-orm/pg-core'; + +const jsonb = customType<{ data: any }>({ + dataType() { + return 'jsonb'; + }, + toDriver(val) { + return val as any; + }, + fromDriver(value) { + if (typeof value === 'string') { + try { + return JSON.parse(value) as any; + } catch {} + } + return value as any; + }, +}); + +export default jsonb; \ No newline at end of file diff --git a/README.md b/README.md index 4beaa8a..1a63e4c 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,28 @@ ArtSphere aims to bridge gaps in art and culture, creating an inclusive virtual ## Table of Contents 📜 +- Tech Stack - Features - Getting Started - Contributing - License - Contact +## Tech Stack 🛠️ + +ArtSphere is built with cutting-edge technologies to provide a seamless and engaging user experience: + +- Next.js 14 +- TypeScript +- Tailwind CSS +- Supabase +- PostgreSQL (Supabase) +- Drizzle-ORM +- NextAuth +- JsonWebToken (JWT) +- Recoil +- SWR (Data Fetching) + ## Features 🌟 ArtSphere offers a plethora of features designed to inspire and empower artists: diff --git a/__tests__/Feed.test.tsx b/__tests__/Feed.test.tsx index 5d9a550..aefd67a 100644 --- a/__tests__/Feed.test.tsx +++ b/__tests__/Feed.test.tsx @@ -13,6 +13,13 @@ import { SWRConfig } from "swr"; import { server } from "@/__mocks__/server"; import { HttpResponse, http } from "msw"; import { backendUrl } from "@/app/utils/config/backendUrl"; +import crypto from "crypto"; + +Object.defineProperty(global, "crypto", { + value: { + randomUUID: () => crypto.randomUUID(), + }, +}); global.confirm = jest.fn(() => true); // mock window.confirm diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 69f42b3..73ee8d3 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,73 +1,6 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; +import { authOptions } from "@/lib/authOptions"; import NextAuth from "next-auth/next"; -import GoogleProvider from "next-auth/providers/google"; -import SignToken from "@/app/api/auth/jwt"; -import { NextAuthOptions } from "next-auth"; - -export const authOptions: NextAuthOptions = { - session: { - strategy: "jwt", - maxAge: 7 * 24 * 60 * 60, // 7 days - }, - jwt: { - maxAge: 7 * 24 * 60 * 60, - }, - - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID || "", - clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", - }), - ], - callbacks: { - async jwt({ token, user, account }) { - if (account?.provider === "google") { - try { - await connectMongoDB(); - const userExists = await User.findOne({ email: user.email }); - - if (!userExists) { - // Create the user document and add it to the token - const newUser = await User.create({ - name: user.name, - username: user.email?.split("@")[0] || user.name, - image: user?.image || "", - email: user.email, - }); - token._id = newUser._id; // Include the MongoDB ObjectId in the token - const data = { - email: newUser.email, - _id: newUser._id, - }; - const tokenString = await SignToken(data); - token.accessToken = tokenString; - } else { - const data = { - email: userExists.email, - _id: userExists._id, - }; - const tokenString = await SignToken(data); - token.accessToken = tokenString; - token._id = userExists._id; - } - } catch (error) { - console.error(error); - } - } - - return token; - }, - - async session({ session, token }) { - session.user.id = token._id as string; - session.user.accessToken = token.accessToken as string; - session.user.expires = token.exp as number; - return session; - }, - }, -}; const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; +export { handler as GET, handler as POST }; \ No newline at end of file diff --git a/app/api/auth/jwt.ts b/app/api/auth/jwt.ts index 9b4b702..9e9bdc5 100644 --- a/app/api/auth/jwt.ts +++ b/app/api/auth/jwt.ts @@ -1,6 +1,7 @@ +import { UUID } from "crypto"; import jwt from "jsonwebtoken"; -const SignToken = async (data: { email: any; _id: any }) => { +const SignToken = async (data: { email: string; _id: string }) => { const token = await jwt.sign( { email: data?.email, @@ -8,7 +9,7 @@ const SignToken = async (data: { email: any; _id: any }) => { }, process.env.JWT_SECRET, { expiresIn: "10d" } - ); + ) as string; return token; }; diff --git a/app/api/events/[username]/route.ts b/app/api/events/[slug]/route.ts similarity index 59% rename from app/api/events/[username]/route.ts rename to app/api/events/[slug]/route.ts index 6bfed67..c67196d 100644 --- a/app/api/events/[username]/route.ts +++ b/app/api/events/[slug]/route.ts @@ -1,23 +1,22 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Events from "@/models/Events"; +import { db } from "@/db"; +import { eq, sql } from "drizzle-orm"; +import { events as Event } from "@/models/Events"; import { NextResponse } from "next/server"; -import User from "@/models/User"; -import mongoose from "mongoose"; +import { users as User } from "@/models/User"; import crypto from "crypto"; // Public route // Get event by id export async function GET(request: Request) { const slug = request.url.split("/")[3]; - await connectMongoDB(); - const event = await Events.findOne({ slug }); + const [event] = await db.select().from(Event).where(eq(Event._id, slug)); if (!event) { return NextResponse.json({ message: "No event found" }, { status: 404 }); } return NextResponse.json({ event }, { status: 200 }); } -function generateToken(length) { +function generateToken(length: number) { const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let token = ""; @@ -29,15 +28,17 @@ function generateToken(length) { } export async function POST(request: Request) { - await connectMongoDB(); - const token = request.headers.get("Authorization"); - if (!token) { + const username = request.url.split("/")[5]; + const userId = request.headers.get("userId"); + + if (!userId) { return NextResponse.json({ message: "Not Authorized!" }, { status: 401 }); } - const user = await User.findOne({ username: request.url.split("/")[5] }); - if (!user) { - return NextResponse.json({ message: "No user found" }, { status: 404 }); + const [user] = await db.select().from(User).where(eq(User.username, username)); + + if (!user || user._id !== userId) { + return NextResponse.json({ message: "Unauthorized" }, { status: 404 }); } if (user.role !== "organizer") { @@ -59,9 +60,10 @@ export async function POST(request: Request) { location, coordinates, attendees, + link } = data; - const organizerId = new mongoose.Types.ObjectId(userId); + const organizerId = user._id; if (!title || !date || !location || !userId) { return NextResponse.json( @@ -70,7 +72,15 @@ export async function POST(request: Request) { ); } - const eventExists = await Events.findOne({ title }); + if (userId !== user._id) { + return NextResponse.json( + { message: "User not authorized to create event" }, + { status: 401 } + ); + } + + // Check if event already exists by title + const [eventExists] = await db.select().from(Event).where(eq(Event.title, title)); if (eventExists) { return NextResponse.json( { message: "Event already exists" }, @@ -79,17 +89,26 @@ export async function POST(request: Request) { } const token = generateToken(10); - const event = await Events.create({ + + // remove quotes from coordinates + if (coordinates) { + coordinates.lat = parseFloat(coordinates?.lat) || 0; + coordinates.lng = parseFloat(coordinates?.lng) || 0; + } + + const [event] = await db.insert(Event).values({ title, description, image, date, location, - coordinates, + token, + link, + coordinates: coordinates || { lat: 0, lng: 0 }, organizer: organizerId, attendees, - token, - }); + }).returning(); + return NextResponse.json({ event }, { status: 200 }); } catch (error) { console.error("Error creating event: ", error); diff --git a/app/api/events/live/route.ts b/app/api/events/live/route.ts index 3288589..5b811e5 100644 --- a/app/api/events/live/route.ts +++ b/app/api/events/live/route.ts @@ -1,8 +1,9 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Events from "@/models/Events"; +import { db } from "@/db"; +import { Attendees, events as Event } from "@/models/Events"; import { NextResponse } from "next/server"; -import User from "@/models/User"; -import mongoose from "mongoose"; +import { users as User } from "@/models/User"; +import { eq, and, sql, Param } from "drizzle-orm"; +import { randomUUID } from "crypto"; // Private route export async function POST(request: Request) { @@ -10,28 +11,15 @@ export async function POST(request: Request) { const { userId, eventId, token } = body; - const accesstoken = request.headers.get("Authorization"); - if (!token || !accesstoken) { + const userIdFromToken = request.headers.get("userId"); + if (!token || !userId || !eventId || !userIdFromToken || userId !== userIdFromToken) { return NextResponse.json({ message: "Not Authorized!" }, { status: 401 }); } - await connectMongoDB(); + // check if the user and the event exists + const [user] = await db.select().from(User).where(eq(User._id, userId)); + const [event] = await db.select().from(Event).where(eq(Event._id, eventId)); - // check if the user exists - const user = await User.findById(userId); - // check if the user is already in the event using the event id and the user id - const isUserInEvent = await Events.findOne({ - _id: eventId, - attendees: { - $elemMatch: - { - userId: new mongoose.Types.ObjectId(userId), - } - }, - }); - - // check if the token is valid - const event = await Events.findById(eventId); if (!event || !user) { return NextResponse.json( { message: "Event or user not found" }, @@ -39,6 +27,17 @@ export async function POST(request: Request) { ); } + let attendees = event.attendees as Attendees[]; + + if (!attendees) { + attendees = []; + } + + const isUserInEvent = attendees.find( + (attendee) => attendee.userId === userId + ) as Attendees; + + // check if the token is valid if (event?.token !== token) { return NextResponse.json({ message: "Invalid token" }, { status: 401 }); } @@ -48,7 +47,7 @@ export async function POST(request: Request) { return NextResponse.json( { message: "User already in the event", - code: isUserInEvent.attendees[0]?.code, + code: isUserInEvent.code, }, { status: 201 } ); @@ -56,14 +55,12 @@ export async function POST(request: Request) { try { // create a code for the user to join the event - const code = new mongoose.Types.ObjectId().toString(); - const updatedEvent = await Events.findByIdAndUpdate( - eventId, - { - $push: { attendees: { userId: userId, code: code } }, - }, - { new: true } - ); + const code = randomUUID(); + + // add the user to the event + const updatedEvent = await db.update(Event).set({ + attendees: [...attendees, { userId: userId, code: code }], + }).where(eq(Event._id, eventId)); if (updatedEvent) { return NextResponse.json( diff --git a/app/api/events/route.ts b/app/api/events/route.ts index fc2e43f..21d23e1 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,26 +1,29 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Events from "@/models/Events"; +import { db } from "@/db"; +import { eq, not } from "drizzle-orm"; +import { events as Event } from "@/models/Events"; import { NextResponse } from "next/server"; -import User from "@/models/User"; +import { users as User } from "@/models/User"; // Private route // Get all events export async function GET(request: Request) { - await connectMongoDB(); const userId = request.headers.get("userId"); - // Find the user based on the userId in the header - const user = await User.findOne({ _id: userId }); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + // Find the user based on the userId in the header + const [user] = await db.select().from(User).where(eq(User._id, userId)); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 404 }); } // Query all events where the user's ID matches the organizer field - const eventsWhereUserIsOrganizer = await Events.find({ organizer: user._id }); + const eventsWhereUserIsOrganizer = await db.select().from(Event).where(eq(Event.organizer, user._id)); // Query all events where the user is not the organizer - const eventsWhereUserIsNotOrganizer = await Events.find({ organizer: { $ne: user._id } }); + const eventsWhereUserIsNotOrganizer = await db.select().from(Event).where(not(eq(Event.organizer, user._id))); // Combine the two arrays const allEvents = eventsWhereUserIsOrganizer.concat(eventsWhereUserIsNotOrganizer); diff --git a/app/api/posts/[id]/comment/route.ts b/app/api/posts/[id]/comment/route.ts deleted file mode 100644 index d318b3b..0000000 --- a/app/api/posts/[id]/comment/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Post from "@/models/Post"; -import mongoose from "mongoose"; -import { NextResponse } from "next/server"; - - -// Private route handle post update -export async function PUT(request: Request) { - const bearer = request.headers.get("Authorization"); - const userId = request.headers.get("userId"); - - if (!bearer) { - return NextResponse.json({ message: "Not authorized" }, { status: 401 }); - } - const postId = request.url.split("/")[5]; - - await connectMongoDB(); - - const post = await Post.findOne({ _id: postId }).lean() as any; - - if (!post) { - return NextResponse.json({ message: "No posts found" }, { status: 404 }); - } - - const { _id, username, name, userImg, content, timestamp, url } = await request.json(); - - const newComment = { - _id: _id, - userId: userId, - content: content, - username: username, - timestamp: timestamp, - url: url, - userImg: userImg, - name: name, - }; - - post.comments.push(newComment); - - await Promise.all([ - Post.updateOne({ _id: postId }, { $push: { comments: newComment } }), - ]); - - return NextResponse.json({ post: newComment }, { status: 200 }); -} - -export async function DELETE(request: Request) { - const userId = request.headers.get("userId"); - const commentId = request.url.split("/")[5]; - - try { - await connectMongoDB(); - // find the comment in the post.comments array - const post = await Post.findOne({ "comments._id": commentId }); - - if (!post) { - return NextResponse.json({ message: "No posts found" }, { status: 404 }); - } - - // check if the user is the author of the comment - const comment = post.comments.find((comment) => { - return comment?.userId === userId; - }); - - if (!comment) { - return NextResponse.json({ message: "Not authorized" }, { status: 401 }); - } - - // delete comment from post.comments array - const newComments = post.comments.filter((comment) => { - return comment._id.toString() !== commentId; - }); - - await post.updateOne({ comments: newComments }); - - return NextResponse.json({ message: "Comment deleted" }, { status: 200 }); - } catch (error) { - console.error("Error: ", error); - return NextResponse.json({ message: "Something went wrong" }, { status: 500 }); - } -} \ No newline at end of file diff --git a/app/api/posts/[id]/like/route.ts b/app/api/posts/[id]/like/route.ts deleted file mode 100644 index d877f7f..0000000 --- a/app/api/posts/[id]/like/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Post from "@/models/Post"; -import User from "@/models/User"; -import { NextResponse } from "next/server"; - -// Private route handle post update -export async function PUT(request: Request) { - const postId = request.url.split("/")[5]; - const userId = request.headers.get("userId"); - - if (!postId || !userId) { - return NextResponse.json({ message: "Invalid data" }, { status: 400 }); - } - - await connectMongoDB(); - const post = await Post.findOne({ _id: postId }); - const user = await User.findOne({ _id: userId }); - - if (!post || !user) { - return NextResponse.json( - { message: "Post or user not found" }, - { status: 404 } - ); - } - - const postLikeIndex = post.likes.indexOf(userId); - const userLikeIndex = user.likes.indexOf(postId); - - if (postLikeIndex > -1 && userLikeIndex > -1) { - await removeLike(post, postLikeIndex, user, userLikeIndex); - } else { - await addLike(post, userId, user, postId); - } - - return NextResponse.json( - { message: "Like updated successfully!" }, - { status: 200 } - ); -} - -async function removeLike(post: { likes: any[]; updateOne: (arg0: { likes: any; }) => any; }, postLikeIndex: any, user: { likes: any[]; updateOne: (arg0: { likes: any; }) => any; }, userLikeIndex: any) { - post.likes.splice(postLikeIndex, 1); - user.likes.splice(userLikeIndex, 1); - - await Promise.all([ - post.updateOne({ likes: post.likes }), - user.updateOne({ likes: user.likes }), - ]); -} - -async function addLike(post: { likes: any[]; updateOne: (arg0: { likes: any; }) => any; }, userId: any, user: { likes: any[]; updateOne: (arg0: { likes: any; }) => any; }, postId: any) { - post.likes.push(userId); - user.likes.push(postId); - - await Promise.all([ - post.updateOne({ likes: post.likes }), - user.updateOne({ likes: user.likes }), - ]); -} diff --git a/app/api/posts/[id]/route.ts b/app/api/posts/[id]/route.ts deleted file mode 100644 index d97a927..0000000 --- a/app/api/posts/[id]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Post from "@/models/Post"; -import { NextResponse } from "next/server"; - -// Public route -export async function GET(request: Request) { - const postId = request.url.split("/")[5]; - - await connectMongoDB(); - const post = await Post.findOne({ _id: postId }); - - if (!post) { - return NextResponse.json({ message: "No posts found" }, { status: 404 }); - } - return NextResponse.json({ post: post }, { status: 200 }); -} - -// Private route handle post delete -export async function DELETE(request: Request) { - const postId = request.url.split("/")[5]; - const userId = request.headers.get("userId"); - - await connectMongoDB(); - - const post = await Post.findOne({ _id: postId }); - if (!post) { - return NextResponse.json({ message: "No posts found" }, { status: 404 }); - } - - // Check if the user is the author of the post - if (post?.authorID !== userId) { - return NextResponse.json({ message: "Not authorized" }, { status: 401 }); - } - - // Delete the post from the database - await post.deleteOne({ _id: postId }); - - return NextResponse.json({ message: "Post deleted" }, { status: 200 }); -} diff --git a/app/api/posts/[slug]/comment/route.ts b/app/api/posts/[slug]/comment/route.ts new file mode 100644 index 0000000..dfca945 --- /dev/null +++ b/app/api/posts/[slug]/comment/route.ts @@ -0,0 +1,99 @@ +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { CommentType, posts as Posts} from "@/models/Post"; +import { NextResponse } from "next/server"; + + +// Private route handle post update +export async function PUT(request: Request) { + const bearer = request.headers.get("Authorization"); + const userId = request.headers.get("userId"); + + if (!bearer) { + return NextResponse.json({ message: "Not authorized" }, { status: 401 }); + } + const postId = request.url.split("/")[5]; + + try { + + const [post] = await db.select().from(Posts).where(eq(Posts._id, postId)); + + if (!post) { + return NextResponse.json({ message: "No posts found" }, { status: 404 }); + } + + const { _id, username, name, userImg, content, timestamp, url } = await request.json(); + + const newComment = { + _id: _id, + userId: userId, + content: content, + username: username, + timestamp: timestamp, + url: url, + userImg: userImg, + name: name, + }; + + // if post.comments is null, create a new array + if (!post.comments) { + post.comments = []; + } + + post.comments.push(newComment); + + await Promise.all([ + db.update(Posts).set({ comments: post.comments }).where(eq(Posts._id, postId)), + ]); + + return NextResponse.json({ post: newComment }, { status: 200 }); + } catch (error) { + console.error("Error: ", error); + return NextResponse.json({ message: "Something went wrong" }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + const userId = request.headers.get("userId"); + const commentId = request.url.split("/")[5]; + const body = await request.json(); + + const postId = body.postId; + + if (!userId || !commentId || !postId) { + return NextResponse.json({ message: "Not authorized" }, { status: 401 }); + } + + try { + // find the original post + const [post] = await db.select().from(Posts).where(eq(Posts._id, postId)); + + if (!post) { + return NextResponse.json({ message: "No posts found" }, { status: 404 }); + } + + const comments = post.comments as CommentType[]; + + // check if the user is the author of the comment + const comment = comments.find((comment) => { + return comment?.userId === userId && comment?._id === commentId; + }); + + if (!comment) { + return NextResponse.json({ message: "Not authorized" }, { status: 401 }); + } + + // delete comment from post.comments array + const newComments = post.comments?.filter((comment: CommentType) => { + return comment._id.toString() !== commentId; + }); + + // update the post with the new comments array + await db.update(Posts).set({ comments: newComments }).where(eq(Posts._id, post._id)); + + return NextResponse.json({ message: "Comment deleted" }, { status: 200 }); + } catch (error) { + console.error("Error: ", error); + return NextResponse.json({ message: "Something went wrong" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/posts/[slug]/like/route.ts b/app/api/posts/[slug]/like/route.ts new file mode 100644 index 0000000..2e380bd --- /dev/null +++ b/app/api/posts/[slug]/like/route.ts @@ -0,0 +1,64 @@ +import { db } from "@/db"; +import { posts as Post, PostType } from "@/models/Post"; +import { users as User, UserType } from "@/models/User"; +import { UUID } from "crypto"; +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +// Private route handle post update +export async function PUT(request: Request) { + const postId = request.url.split("/")[5] as UUID; + const userId = request.headers.get("userId") as UUID; + + if (!postId || !userId) { + return NextResponse.json({ message: "Unauthorised" }, { status: 400 }); + } + + const [post] = await db.select().from(Post).where(eq(Post._id, postId)); + const [user] = await db.select().from(User).where(eq(User._id, userId)); + + if (!post || !user) { + return NextResponse.json( + { message: "Post or user not found" }, + { status: 404 } + ); + } + + // if post.likes is null, set it to an empty array + post.likes = post.likes || []; + user.likes = user.likes || []; + + const postLikeIndex = post.likes?.indexOf(userId) + const userLikeIndex = user.likes?.indexOf(postId) + + if (postLikeIndex > -1 && userLikeIndex > -1) { + await removeLike(post, postLikeIndex, user, userLikeIndex); + } else { + await addLike(post, userId, user, postId); + } + + return NextResponse.json( + { message: "Like updated successfully!" }, + { status: 200 } + ); +} + +async function removeLike(post: PostType, postLikeIndex: number, user: UserType, userLikeIndex: number) { + post.likes?.splice(postLikeIndex, 1); + user.likes?.splice(userLikeIndex, 1); + + await Promise.all([ + db.update(Post).set({ likes: post.likes }).where(eq(Post._id, post._id)), + db.update(User).set({ likes: user.likes }).where(eq(User._id, user._id)), + ]); +} + +async function addLike(post: PostType, userId: UUID, user: UserType, postId: UUID) { + post.likes?.push(userId); + user.likes?.push(postId); + + await Promise.all([ + db.update(Post).set({ likes: post.likes }).where(eq(Post._id, post._id)), + db.update(User).set({ likes: user.likes }).where(eq(User._id, user._id)), + ]); +} diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts new file mode 100644 index 0000000..96b5d72 --- /dev/null +++ b/app/api/posts/[slug]/route.ts @@ -0,0 +1,63 @@ +import { db } from "@/db"; +import { Post, posts } from "@/models/Post"; +import { users } from "@/models/User"; +import { UUID, randomUUID } from "crypto"; +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +// Public route +export async function GET(request: Request) { + const postId = request.url.split("/")[5]; + + const validUUID = postId?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + + // early return if no postId or postId is not a valid UUID + if (!postId || !validUUID) { + return NextResponse.json({ message: "Invalid post ID" }, { status: 404 }); + } + + const [post] = await db.select({ + _id: posts._id, + userImg: users.image, + username: users.username, + name: users.name, + content: posts.content, + authorID: posts.authorID, + comments: posts.comments, + category: posts.category, + likes: posts.likes, + url: posts.url, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }).from(posts).fullJoin(users, eq(posts.authorID, users._id)).where(eq(posts._id, postId)); + + if (!post) { + return NextResponse.json({ message: "No posts found" }, { status: 404 }); + } + return NextResponse.json({ post: post }, { status: 200 }); +} + +// Private route handle post delete +export async function DELETE(request: Request) { + const postId = request.url.split("/")[5]; + const userId = request.headers.get("userId"); + + const [post] = await db.select().from(posts).where(eq(posts._id, postId)) + + if (!post) { + return NextResponse.json({ message: "No posts found" }, { status: 404 }); + } + + // Check if the user is the author of the post + if (post?.authorID !== userId) { + return NextResponse.json({ message: "Not authorized" }, { status: 401 }); + } + + try { + await db.delete(posts).where(eq(posts._id, postId)); + return NextResponse.json({ message: "Post deleted" }, { status: 200 }); + } catch (error) { + console.error("Error deleting post: ", error); + return NextResponse.json({ message: "Error deleting post" }, { status: 500 }); + } +} diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 9639f55..caabd56 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -1,42 +1,63 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Post from "../../../models/Post"; +import { db } from "@/db"; +import { posts as Posts } from "@/models/Post"; +import { users } from "@/models/User"; +import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { + + const type = req.nextUrl.searchParams.get("type") || null; + try { - await connectMongoDB(); - const type = req.nextUrl.searchParams.get("type") || null; + + const posts = await db + .select({ + _id: Posts._id, + userImg: users.image, + username: users.username, + name: users.name, + content: Posts.content, + authorID: Posts.authorID, + comments: Posts.comments, + category: Posts.category, + likes: Posts.likes, + url: Posts.url, + createdAt: Posts.createdAt, + updatedAt: Posts.updatedAt, + }) + .from(Posts) + .leftJoin(users, eq(Posts.authorID, users._id)) if (type) { - const posts = await Post.find({ category: type }); - return NextResponse.json({ posts }); + const filteredPosts = posts.filter((post) => post.category === type); + return NextResponse.json({ posts: filteredPosts }); } - const posts = await Post.find({}); // fetch data from the source return NextResponse.json({ posts }); // respond with JSON } catch (error) { console.error("Error fetching posts: ", error); - return NextResponse.json({ message: "Error fetching posts from MongoDB!" }); + return NextResponse.json({ message: "Error fetching posts from database!" }); } } // Private route, handle post creation -export async function POST(req) { +export async function POST(req: NextRequest) { try { - const { _id, username, name, userImg, content, authorID, category, url } = - await req.json(); - await connectMongoDB(); - const res = await Post.create({ + const { _id, content, authorID, category, url } = await req.json(); + + if (!_id || !authorID || !category) { + return NextResponse.json({ message: "Missing required fields!" }, { status: 400 }); + } + + await db.insert(Posts).values({ _id, - username, - name, - userImg, content, authorID, category, url, }); - return NextResponse.json({ message: "success", post: res }); + + return NextResponse.json({ message: "success"}); } catch (error) { console.error("Error creating post: ", error); return NextResponse.json({ message: "error: ", error }); diff --git a/app/api/posts/username/[slug]/route.ts b/app/api/posts/username/[slug]/route.ts index c153c81..fdc24b2 100644 --- a/app/api/posts/username/[slug]/route.ts +++ b/app/api/posts/username/[slug]/route.ts @@ -1,14 +1,31 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import Post from "@/models/Post"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { posts as Posts } from "@/models/Post"; import { NextResponse } from "next/server"; +import { users } from "@/models/User"; // Public route export async function GET(request: Request) { // get username from request params const username = request.url.split("/")[6]; - await connectMongoDB(); - const posts = await Post.find({ username }); + const posts = await db.select({ + _id: Posts._id, + userImg: users.image, + username: users.username, + name: users.name, + content: Posts.content, + authorID: Posts.authorID, + comments: Posts.comments, + category: Posts.category, + likes: Posts.likes, + url: Posts.url, + createdAt: Posts.createdAt, + updatedAt: Posts.updatedAt, + }) + .from(Posts) + .leftJoin(users, eq(Posts.authorID, users._id)) + .where(eq(users.username, username)); if (!posts) { return NextResponse.json({ message: "No posts found" }, { status: 404 }); } diff --git a/app/api/search/full/route.ts b/app/api/search/full/route.ts new file mode 100644 index 0000000..fa3bdaa --- /dev/null +++ b/app/api/search/full/route.ts @@ -0,0 +1,67 @@ +import { db } from "@/db"; +import { users as User } from "@/models/User"; +import { posts as Post, CommentType } from "@/models/Post"; +import { eq, like, or } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + + const query = req.nextUrl.searchParams.get("q") || null; + + try { + + const userId = req.headers.get("userId"); + + if (!query || query === "") { + return NextResponse.json({ message: "No search query provided!" }); + } + + if (!userId) { + return NextResponse.json({ message: "User not authenticated!" }); + } + + const userResults = await db + .select({ + _id: User._id, + image: User.image, + followers: User.followers, + username: User.username, + name: User.name + }) + .from(User) + .where( + or( + like(User.name, `%${query}%`), + like(User.username, `%${query}%`) + )) + .limit(5); + + const postResults = await db + .select({ + _id: Post._id, + userImg: User.image, + username: User.username, + name: User.name, + content: Post.content, + likes: Post.likes, + comments: Post.comments, + authorID: Post.authorID, + url: Post.url || "", + createdAt: Post.createdAt, + updatedAt: Post.updatedAt + }) + .from(Post) + .leftJoin(User, eq(Post.authorID, User._id)) + .where( + like(Post.content, `%${query}%`), + ) + .limit(15); + + // respond with JSON + return NextResponse.json({ userResults, postResults }); + + } catch (error) { + console.error("Error fetching users and posts: ", error); + return NextResponse.json({ message: "Error fetching users and posts from database!" }); + } +} \ No newline at end of file diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..f57ceff --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,33 @@ +import { db } from "@/db"; +import { users as User } from "@/models/User"; +import { eq, like, or } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + + const query = req.nextUrl.searchParams.get("search") || null; + + try { + // this is an auto complete search query that searches for posts and users + const results = await db + .select({ + _id: User._id, + userImg: User.image, + username: User.username, + name: User.name + }) + .from(User) + .where( + or( + like(User.name, `%${query}%`), + like(User.username, `%${query}%`) + )) + .limit(8); + // respond with JSON + return NextResponse.json({ results }); + + } catch (error) { + console.error("Error fetching posts: ", error); + return NextResponse.json({ message: "Error fetching posts from database!" }); + } +} \ No newline at end of file diff --git a/app/api/user/[username]/follow/route.ts b/app/api/user/[username]/follow/route.ts index b92d298..4014bb1 100644 --- a/app/api/user/[username]/follow/route.ts +++ b/app/api/user/[username]/follow/route.ts @@ -1,58 +1,92 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { users as User } from "@/models/User"; import { NextResponse } from "next/server"; const followUser = async (followFromUser: any, followToUser: any) => { - await User.updateOne( - { _id: followFromUser._id }, - { $push: { following: followToUser._id } } - ); - await User.updateOne( - { _id: followToUser._id }, - { $push: { followers: followFromUser._id } } - ); - return NextResponse.json({ message: "User followed" }, { status: 200 }); -} + try { + // add the user to the following list of the user currently logged-in + await db + .update(User) + .set({following: [...followFromUser.following, followToUser._id]}) + .where(eq(User._id, followFromUser._id)); + + // add the logged-in user to the followers list of the user being followed + await db + .update(User) + .set({ followers: [...followToUser.followers, followFromUser._id] }) + .where(eq(User._id, followToUser._id)); + + return NextResponse.json({ message: "User followed" }, { status: 200 }); + + } catch (error) { + console.error("Error following user: ", error); + return NextResponse.json({ message: "Error following user" }, { status: 500 }); + } +}; const unFollowUser = async (followFromUser: any, followToUser: any) => { - await User.updateOne( - { _id: followFromUser._id }, - { $pull: { following: followToUser._id } } - ); - await User.updateOne( - { _id: followToUser._id }, - { $pull: { followers: followFromUser._id } } - ); - return NextResponse.json({ message: "User unfollowed" }, { status: 200 }); -} + + try { + // remove the user from the following list of the user currently logged-in + await db.update(User) + .set({ + following: followFromUser.following.filter( + (id: any) => id !== followToUser._id + ), + }).where(eq(User._id, followFromUser._id)); + + // remove the logged-in user from the followers list of the user being followed + await db.update(User) + .set({ + followers: followToUser.followers.filter( + (id: any) => id !== followFromUser._id + ), + }).where(eq(User._id, followToUser._id)); + + return NextResponse.json({ message: "User unfollowed" }, { status: 200 }); + } catch (error) { + console.error("Error unfollowing user: ", error); + return NextResponse.json({ message: "Error unfollowing user" }, { status: 500 }); + } +}; export async function PUT(request: Request) { const { action, followFrom, followTo } = await request.json(); - const token = request.headers.get("Authorization"); + const userIdVerified = request.headers.get("userId"); - if (action !== "follow") { + if (action !== "follow" || !followFrom || !followTo) { return NextResponse.json({ message: "Invalid action" }, { status: 400 }); } - if (!token) { + if (!userIdVerified) { return NextResponse.json({ message: "Not Authorized!" }, { status: 401 }); } - await connectMongoDB(); + // Reject the request if the user is trying to follow themself + if (followFrom === followTo || followTo === userIdVerified ) { + return NextResponse.json({ message: "Unable to follow yourself!"}, { status: 400 }) + } + // find both users - const followFromUser = await User.findOne({ _id: followFrom }); - const followToUser = await User.findOne({ _id: followTo }); + const [followFromUser] = await db + .select() + .from(User) + .where(eq(User._id, followFrom)); + const [followToUser] = await db + .select() + .from(User) + .where(eq(User._id, followTo)); if (!followFromUser || !followToUser) { - return NextResponse.json( - { message: "User not found" }, - { status: 404 } - ); + return NextResponse.json({ message: "User not found" }, { status: 404 }); } // check if the user is already following the other user const isUserFollowing = followFromUser?.following?.includes(followToUser._id); - const isUserFollowedBy = followToUser?.followers?.includes(followFromUser._id); + const isUserFollowedBy = followToUser?.followers?.includes( + followFromUser._id + ); // if the user is already following the other user, unfollow if (isUserFollowing && isUserFollowedBy) { @@ -68,5 +102,4 @@ export async function PUT(request: Request) { { message: "Something went wrong" }, { status: 500 } ); - } diff --git a/app/api/user/[username]/route.ts b/app/api/user/[username]/route.ts index 1074850..c34ad13 100644 --- a/app/api/user/[username]/route.ts +++ b/app/api/user/[username]/route.ts @@ -1,13 +1,13 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; +import { db } from "@/db"; +import {users as User} from "@/models/User"; +import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; // Public route export async function GET(request: Request) { // get username from request params const username = request.url.split("/")[5]; - await connectMongoDB(); - const user = await User.findOne({ username }); + const [user] = await db.select().from(User).where(eq(User.username, username)); if (!user) { return NextResponse.json({ message: "User not found" }, { status: 404 }); } @@ -17,12 +17,12 @@ export async function GET(request: Request) { export async function PUT(request: Request) { // get username from request params const username = request.url.split("/")[5]; - await connectMongoDB(); - const user = await User.findOne({ username }); + const [user] = await db.select().from(User).where(eq(User.username, username)); if (!user) { return NextResponse.json({ message: "User not found" }, { status: 404 }); } + // update user user.role = "organizer"; - await user.save(); + await db.update(User).set(user).where(eq(User.username, username)); return NextResponse.json({ message: "User updated", user }, { status: 200 }); } diff --git a/app/api/user/[username]/userlikes/route.ts b/app/api/user/[username]/userlikes/route.ts deleted file mode 100644 index d51896a..0000000 --- a/app/api/user/[username]/userlikes/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; -import { NextResponse } from "next/server"; - -// Public route -export async function GET(request: Request) { - const username = request.url.split("/")[5]; - await connectMongoDB(); - //only get the likes array from the user dont get the id - const likedPosts = await User.find({ username }, { likes: 1, _id: 0 }); - - if (!likedPosts) { - return NextResponse.json({ message: "No posts found" }, { status: 404 }); - } - return NextResponse.json({ posts: likedPosts }, { status: 200 }); -} diff --git a/app/api/user/[username]/verify/route.ts b/app/api/user/[username]/verify/route.ts index e12ad13..694aa88 100644 --- a/app/api/user/[username]/verify/route.ts +++ b/app/api/user/[username]/verify/route.ts @@ -1,12 +1,13 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; +import { db } from "@/db"; +import { eq } from "drizzle-orm"; +import { users as User } from "@/models/User"; import { NextResponse } from "next/server"; export async function PUT(request: Request) { // get username from request params const username = request.url.split("/")[5]; - await connectMongoDB(); - const user = await User.findOne({ username }); + const [user] = await db.select().from(User).where(eq(User.username, username)); + if (!user) { return NextResponse.json({ message: "User not found" }, { status: 404 }); } @@ -20,6 +21,6 @@ export async function PUT(request: Request) { } user.verified = true; - await user.save(); + await db.update(User).set(user).where(eq(User.username, username)); return NextResponse.json({ message: "User updated", user }, { status: 200 }); } diff --git a/app/api/user/route.ts b/app/api/user/route.ts deleted file mode 100644 index 602ae36..0000000 --- a/app/api/user/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request) { - const { email } = await request.json(); - const username = email.split("@")[0]; - await connectMongoDB(); - const user = await User.create({ email, username }); - return NextResponse.json({ message: "User created", user }, { status: 201 }); -} - -export async function GET(request: Request) { - const { username } = await request.json(); - await connectMongoDB(); - try { - const user = await User.findOne({ username }); - return NextResponse.json({ message: "User found", user }, { status: 200 }); - } catch (e) { - return NextResponse.json({ message: "User not found" }, { status: 404 }); - } -} diff --git a/app/api/widgets/trending/posts/route.ts b/app/api/widgets/trending/posts/route.ts index 4dfd473..8c6fd1b 100644 --- a/app/api/widgets/trending/posts/route.ts +++ b/app/api/widgets/trending/posts/route.ts @@ -1,34 +1,76 @@ -import { connectMongoDB } from "@/libs/mongodb"; +import { db } from "@/db"; import { NextResponse } from "next/server"; -import Post from "@/models/Post"; +import { posts as Post, PostType } from "@/models/Post"; +import { eq, gte, ne, lt, and, desc } from "drizzle-orm"; import moment from "moment"; + +const getTrendingPosts = (posts: PostType[]) => { + + if (!posts || posts.length < 3) { + return []; + } + + return posts + .filter((post) => post.content && post.content.length < 50) + .sort((postA, postB) => { + // if post.likes is null, set it to an empty array + postA.likes = postA.likes || []; + postB.likes = postB.likes || []; + // if post.comments is null, set it to an empty array + postA.comments = postA.comments || []; + postB.comments = postB.comments || []; + + const scoreA = postA.likes.length + postA.comments.length; + const scoreB = postB.likes.length + postB.comments.length; + return scoreB - scoreA; + }) + .slice(0, 10); +} // Public route // Get the 10 most popular posts in the last 24 hours export async function GET(request: Request) { - await connectMongoDB(); - // Calculate the timestamp for 24 hours ago const twentyFourHoursAgo = moment().subtract(24, "hours").toDate(); try { // Query posts created in the last 24 hours - const posts = await Post.find({ - createdAt: { $gte: twentyFourHoursAgo }, - }); + const posts = await db + .select() + .from(Post) + .where(and(gte(Post.createdAt, twentyFourHoursAgo))) + // const posts = await Post.find({ + // createdAt: { $gte: twentyFourHoursAgo }, + // }); // if no posts or less than 3 posts, return the most recent 10 posts if (!posts || posts.length < 3) { - const trendingPosts = await Post.find({ - // where content is not empty and content length is not greater than 50 - content: { $ne: "" }, - $expr: { $lt: [{ $strLenCP: "$content" }, 50] }, - }).sort({ createdAt: -1 }).limit(10); + // const trendingPosts = await Post.find({ + // // where content is not empty and content length is not greater than 50 + // content: { $ne: "" }, + // $expr: { $lt: [{ $strLenCP: "$content" }, 50] }, + // }) + // .sort({ createdAt: -1 }) + // .limit(10); + + const allPosts = await db.select() + .from(Post) + .limit(10) + .orderBy(desc(Post.createdAt)); + + const trendingPosts = getTrendingPosts(allPosts); + return NextResponse.json({ trendingPosts }, { status: 200 }); } // Sort the posts by the total number of likes and comments combined posts.sort((postA, postB) => { + + postA.likes = postA.likes || []; + postB.likes = postB.likes || []; + postA.comments = postA.comments || []; + postB.comments = postB.comments || []; + const scoreA = postA.likes.length + postA.comments.length; const scoreB = postB.likes.length + postB.comments.length; return scoreB - scoreA; diff --git a/app/api/widgets/trending/users/route.ts b/app/api/widgets/trending/users/route.ts index 3cd7f21..3918373 100644 --- a/app/api/widgets/trending/users/route.ts +++ b/app/api/widgets/trending/users/route.ts @@ -1,33 +1,30 @@ -import { connectMongoDB } from "@/libs/mongodb"; -import User from "@/models/User"; +import { db } from "@/db"; +import { users as User } from "@/models/User"; +import { desc, gte } from "drizzle-orm"; import { NextResponse } from "next/server"; import moment from "moment"; +/** + * TODO: Scale the trending users route for production + */ +// Public route export async function GET(request: Request) { - await connectMongoDB(); try { // Calculate the timestamp for 24 hours ago const twentyFourHoursAgo = moment().subtract(24, "hours").toDate(); // Query users who received new followers in the last 24 hours - const users = await User.find({ - updatedAt: { $gte: twentyFourHoursAgo }, - }); - - // Sort the users by the number of new followers they received - users.sort((userA, userB) => { - const newFollowersA = - userA.followersCount - userA.followersCountAtLastUpdate; - const newFollowersB = - userB.followersCount - userB.followersCountAtLastUpdate; - return newFollowersB - newFollowersA; - }); + const users = await db.select() + .from(User) + .where(gte(User.updatedAt, twentyFourHoursAgo)) + .orderBy(desc(User.followers)) + .limit(10); // Get the top 10 users const topUsers = users.slice(0, 10); - return NextResponse.json({ topUsers }, { status: 200 }); + } catch (error) { console.error("Error fetching trending users:", error); return NextResponse.json( diff --git a/app/events/page.tsx b/app/events/page.tsx index e68c1f1..5bed44e 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -4,10 +4,11 @@ import Widgets from "@/components/Widgets"; import CreateEventLayout from "@/components/createEventLayout"; import EventsLayout from "@/components/EventsLayout"; import { getServerSession } from "next-auth"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { authOptions } from "@/lib/authOptions"; import Login from "../login/page"; import PostModal from "@/components/PostModal"; import { backendUrl } from "../utils/config/backendUrl"; +import { Session } from "next-auth/core/types"; export default async function Event({}) { @@ -53,7 +54,7 @@ export default async function Event({}) { ); - async function getEvents(session: any) { + async function getEvents(session: Session | null) { if (!backendUrl || backendUrl === "undefined") { return { diff --git a/app/for-you/page.tsx b/app/for-you/page.tsx new file mode 100644 index 0000000..f7ab86c --- /dev/null +++ b/app/for-you/page.tsx @@ -0,0 +1,63 @@ +import Sidebar from "@/components/Sidebar"; +import Widgets from "@/components/Widgets"; +import Feed from "@/components/Feed"; +import { backendUrl } from "@/app/utils/config/backendUrl"; + +export default async function ForYou() { + const { trendingPosts, randomUsersResults } = await getWidgetsData(); + + return ( +