diff --git a/lib/withAuth.ts b/lib/withAuth.ts new file mode 100644 index 0000000..ab4a311 --- /dev/null +++ b/lib/withAuth.ts @@ -0,0 +1,19 @@ +import { authOptions } from "@/src/app/api/auth/[...nextauth]/route"; +import { ApiHandler } from "@/src/types"; +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; + +export function withAuth(handler: ApiHandler) { + return async (req: Request, context: any) => { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json( + { success: false, message: "Unauthorized. User belum login." }, + { status: 401 }, + ); + } + + return handler(req, context, session); + }; +} diff --git a/src/app/api/product/route.ts b/src/app/api/product/route.ts index d292337..271f7d3 100644 --- a/src/app/api/product/route.ts +++ b/src/app/api/product/route.ts @@ -3,39 +3,6 @@ import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; -// export async function POST(_request: Request) { -// try { -// const products = [ -// { name: "ZenBook 14", brand: "ASUS" }, -// { name: "Swift 3", brand: "Acer" }, -// { name: "Surface Laptop 5", brand: "Microsoft" }, -// ]; - -// const result = await prisma.product.createMany({ -// data: products, -// }); - -// return NextResponse.json( -// { -// message: "Booking successful", -// data: result, -// }, -// { status: 201 }, -// ); -// } catch (error: unknown) { -// console.error("Create product error:", error); - -// if (error instanceof Prisma.PrismaClientKnownRequestError) { -// return NextResponse.json({ error: error.message }, { status: 400 }); -// } - -// return NextResponse.json( -// { error: "Internal Server Error" }, -// { status: 500 }, -// ); -// } -// } - export async function GET() { try { const count = await prisma.product.count(); diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 784994b..83c776c 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -1,71 +1,20 @@ -import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; -import { authOptions } from "../auth/[...nextauth]/route"; -import prisma from "@/lib/prisma"; - -export async function POST(req: Request) { - const session = await getServerSession(authOptions); - - if (!session?.user?.email) { - return NextResponse.json( - { success: false, message: "Unauthorized. User belum login." }, - { status: 401 }, - ); - } - - const body = await req.json(); - - const { - name, - bio, - profession, - preferredBrand, - preferredOS, - budgetMin, - budgetMax, - } = body; - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true }, - }); - - if (!user) { - return NextResponse.json( - { success: false, message: "User tidak ditemukan." }, - { status: 404 }, - ); - } +import { userService } from "@/src/services/profile.service"; +import { withAuth } from "@/lib/withAuth"; +export const POST = withAuth(async (req, _context, session) => { try { - const updatedUser = await prisma.user.update({ - where: { id: user.id }, - data: { - name, - bio, - preference: { - upsert: { - update: { - profession, - preferredBrand, - preferredOS, - budgetMin: budgetMin ? Number(budgetMin) : null, - budgetMax: budgetMax ? Number(budgetMax) : null, - }, - create: { - profession, - preferredBrand, - preferredOS, - budgetMin: budgetMin ? Number(budgetMin) : null, - budgetMax: budgetMax ? Number(budgetMax) : null, - }, - }, - }, - }, - include: { - preference: true, - }, - }); + const email = session.user?.email as string; + const body = await req.json(); + + const updatedUser = await userService.updateProfileByEmail(email, body); + + if (!updatedUser) { + return NextResponse.json( + { success: false, message: "User tidak ditemukan." }, + { status: 404 }, + ); + } return NextResponse.json({ success: true, @@ -73,44 +22,36 @@ export async function POST(req: Request) { data: updatedUser, }); } catch (error) { + console.error("[UPDATE_PROFILE_ERROR]", error); return NextResponse.json( { success: false, message: "Terjadi kesalahan server." }, { status: 500 }, ); } -} +}); -export async function GET() { - const session = await getServerSession(authOptions); +export const GET = withAuth(async (_req, _context, session) => { + try { + const email = session.user?.email as string; - if (!session?.user?.email) { + const user = await userService.getProfileByEmail(email); + + if (!user) { + return NextResponse.json( + { success: false, message: "User not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + success: true, + data: user, + }); + } catch (error) { + console.error("[GET_PROFILE_ERROR]", error); return NextResponse.json( - { success: false, message: "Unauthorized" }, - { status: 401 }, + { success: false, message: "Terjadi kesalahan server." }, + { status: 500 }, ); } - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - include: { - preference: { - select: { - preferredBrand: true, - preferredOS: true, - profession: true, - budgetMax: true, - budgetMin: true, - }, - }, - }, - }); - - if (!user) { - return NextResponse.json( - { success: false, message: "User not found" }, - { status: 404 }, - ); - } - - return NextResponse.json(user); -} +}); diff --git a/src/app/api/review/route.ts b/src/app/api/review/route.ts index 9163c3c..ba1d958 100644 --- a/src/app/api/review/route.ts +++ b/src/app/api/review/route.ts @@ -1,45 +1,13 @@ -import prisma from "@/lib/prisma"; -import { Prisma, Sentiment } from "@prisma/client"; -import { getServerSession } from "next-auth"; +import { Prisma } from "@prisma/client"; import { NextResponse } from "next/server"; -import { authOptions } from "../auth/[...nextauth]/route"; +import { getReviewService, reviewService } from "@/src/services/review.service"; +import { withAuth } from "@/lib/withAuth"; export const dynamic = "force-dynamic"; -export async function POST(_request: Request) { +export const POST = async () => { try { - const reviews = [ - { - productId: 2, - modelId: 1, - content: - "Laptop ini sangat ringan dan performanya cepat untuk kerja harian.", - keywords: ["ringan", "cepat", "kerja"], - sentiment: Sentiment.POSITIVE, - confidenceScore: 0.92, - }, - { - productId: 3, - modelId: 1, - content: "Baterainya awet, tapi harganya cukup mahal.", - keywords: ["baterai", "awet", "mahal"], - sentiment: Sentiment.NEUTRAL, - confidenceScore: 0.75, - }, - { - productId: 4, - modelId: 1, - content: "Performa kurang stabil dan sering panas.", - keywords: ["performa", "panas", "stabil"], - sentiment: Sentiment.NEGATIVE, - confidenceScore: 0.88, - }, - ]; - - const result = await prisma.review.createMany({ - data: reviews, - }); - + const result = await reviewService(); return NextResponse.json( { message: "Analysis successful", @@ -59,51 +27,13 @@ export async function POST(_request: Request) { { status: 500 }, ); } -} - -export async function GET() { - const session = await getServerSession(authOptions); - - if (!session?.user?.email) { - return NextResponse.json( - { success: false, message: "Unauthorized. User belum login." }, - { status: 401 }, - ); - } - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true }, - }); - - if (!user) { - return NextResponse.json( - { success: false, message: "User tidak ditemukan." }, - { status: 404 }, - ); - } +}; +export const GET = withAuth(async (_req, _context, session) => { try { - const review = await prisma.review.findMany({ - where: { userId: user.id }, - orderBy: { - createdAt: "asc", - }, - select: { - id: true, - createdAt: true, - confidenceScore: true, - sentiment: true, - content: true, - keywords: true, - product: { - select: { - name: true, - brand: true, - }, - }, - }, - }); + const email = session.user?.email as string; + + const review = await getReviewService(email); return NextResponse.json( { @@ -116,4 +46,4 @@ export async function GET() { console.log(error); return NextResponse.json({ message: "Error", data: [] }, { status: 500 }); } -} +}); diff --git a/src/app/api/review/sentiment-stats/route.ts b/src/app/api/review/sentiment-stats/route.ts index 14f86bc..87256d6 100644 --- a/src/app/api/review/sentiment-stats/route.ts +++ b/src/app/api/review/sentiment-stats/route.ts @@ -1,52 +1,12 @@ -import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "../../auth/[...nextauth]/route"; +import { withAuth } from "@/lib/withAuth"; +import { sentimentStatsService } from "@/src/services/sentimentStats.service"; -export async function GET() { +export const GET = withAuth(async (_req, _context, session) => { try { - const session = await getServerSession(authOptions); + const email = session.user?.email as string; - if (!session?.user?.email) { - return NextResponse.json( - { success: false, message: "Unauthorized. User belum login." }, - { status: 401 }, - ); - } - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true }, - }); - - if (!user) { - return NextResponse.json( - { success: false, message: "User tidak ditemukan." }, - { status: 404 }, - ); - } - - const grouped = await prisma.review.groupBy({ - by: ["sentiment"], - where: { - userId: user.id, - }, - _count: { - _all: true, - }, - }); - - const result = { - positive: 0, - negative: 0, - neutral: 0, - }; - - grouped.forEach((item) => { - if (item.sentiment === "POSITIVE") result.positive = item._count._all; - if (item.sentiment === "NEGATIVE") result.negative = item._count._all; - if (item.sentiment === "NEUTRAL") result.neutral = item._count._all; - }); + const result = await sentimentStatsService(email); return NextResponse.json({ success: true, data: result }, { status: 200 }); } catch (error) { @@ -56,4 +16,4 @@ export async function GET() { { status: 500 }, ); } -} +}); diff --git a/src/app/api/word-cloud/route.ts b/src/app/api/word-cloud/route.ts index 35fa16c..f801671 100644 --- a/src/app/api/word-cloud/route.ts +++ b/src/app/api/word-cloud/route.ts @@ -1,42 +1,14 @@ -import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "../auth/[...nextauth]/route"; +import { withAuth } from "@/lib/withAuth"; +import { wordCloudService } from "@/src/services/wordCloud.service"; -export async function GET() { +export const GET = withAuth(async (_req, _context, session) => { try { - const session = await getServerSession(authOptions); + const email = session.user?.email as string; - if (!session?.user?.email) { - return NextResponse.json( - { success: false, message: "Unauthorized" }, - { status: 401 }, - ); - } + const allKeywords = await wordCloudService(email); - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true }, - }); - - if (!user) { - return NextResponse.json( - { success: false, message: "User not found" }, - { status: 404 }, - ); - } - - const analyses = await prisma.analysis.findMany({ - where: { userId: user.id }, - select: { topKeywords: true }, - }); - - const allKeywords: string[] = analyses.flatMap((a) => { - if (Array.isArray(a.topKeywords)) { - return a.topKeywords.filter((k): k is string => typeof k === "string"); - } - return []; - }); + console.log(allKeywords); return NextResponse.json( { success: true, data: allKeywords }, @@ -49,4 +21,4 @@ export async function GET() { { status: 500 }, ); } -} +}); diff --git a/src/components/dashboards/Header.tsx b/src/components/dashboards/Header.tsx index 0f9696c..8a86c7b 100644 --- a/src/components/dashboards/Header.tsx +++ b/src/components/dashboards/Header.tsx @@ -48,7 +48,7 @@ export function Header() {
- {productCount} Brand + {productCount} Produk
diff --git a/src/hooks/useWordCloud.ts b/src/hooks/useWordCloud.ts index 1326fa8..78f0e0a 100644 --- a/src/hooks/useWordCloud.ts +++ b/src/hooks/useWordCloud.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; -import { WordItem } from "@/src/types"; +import { KeywordStats, WordCloudReview, WordItem } from "@/src/types"; import { WORD_LIMIT } from "../utils/const"; -import { Sentiment } from "@prisma/client"; export const useWordCloud = () => { const [words, setWords] = useState([]); @@ -19,27 +18,63 @@ export const useWordCloud = () => { return; } - const keywords: string[] = json.data; + const reviews: WordCloudReview[] = json.data; - const keywordMap: Record = {}; + const keywordMap: Record = reviews.reduce( + (acc, review) => { + const sentiment = ["POSITIVE", "NEGATIVE", "NEUTRAL"].includes( + review.sentiment, + ) + ? review.sentiment + : "NEUTRAL"; - keywords.forEach((keyword) => { - const key = keyword.toLowerCase(); - keywordMap[key] = (keywordMap[key] || 0) + 1; - }); + if (Array.isArray(review.keywords)) { + review.keywords.forEach((keyword) => { + const key = keyword.toLowerCase(); - const wordItems: WordItem[] = Object.entries(keywordMap) - .map(([text, count]) => ({ - text, - value: count, - sentiment: "NEUTRAL" as Sentiment, - })) + if (!acc[key]) { + acc[key] = { count: 0, POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0 }; + } + + acc[key].count += 1; + acc[key][sentiment] += 1; + }); + } + + return acc; + }, + {} as Record, + ); + + const wordItems = Object.entries(keywordMap) + .map(([text, data]) => { + let dominantSentiment: "POSITIVE" | "NEGATIVE" | "NEUTRAL" = + "NEUTRAL"; + + if ( + data.POSITIVE >= data.NEGATIVE && + data.POSITIVE >= data.NEUTRAL + ) { + dominantSentiment = "POSITIVE"; + } else if ( + data.NEGATIVE >= data.POSITIVE && + data.NEGATIVE >= data.NEUTRAL + ) { + dominantSentiment = "NEGATIVE"; + } + + return { + text, + value: data.count, + sentiment: dominantSentiment, + }; + }) .sort((a, b) => b.value - a.value) .slice(0, WORD_LIMIT); setWords(wordItems); } catch (error) { - console.error("Failed to fetch wordcloud data", error); + console.error("Gagal mengambil data word cloud:", error); } }; diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts index 8dac165..354a54c 100644 --- a/src/services/profile.service.ts +++ b/src/services/profile.service.ts @@ -1,3 +1,4 @@ +import prisma from "@/lib/prisma"; import { ProfileFormData } from "../types"; export const updateProfileService = async (formData: ProfileFormData) => { @@ -14,3 +15,67 @@ export const updateProfileService = async (formData: ProfileFormData) => { return result; }; + +export const userService = { + async getProfileByEmail(email: string) { + const user = await prisma.user.findUnique({ + where: { email }, + include: { + preference: { + select: { + preferredBrand: true, + preferredOS: true, + profession: true, + budgetMax: true, + budgetMin: true, + }, + }, + }, + }); + + return user; + }, + + async updateProfileByEmail(email: string, data: ProfileFormData) { + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!user) return null; + + const budgetMin = data.budgetMin ? Number(data.budgetMin) : null; + const budgetMax = data.budgetMax ? Number(data.budgetMax) : null; + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + name: data.name, + bio: data.bio, + preference: { + upsert: { + update: { + profession: data.profession, + preferredBrand: data.preferredBrand, + preferredOS: data.preferredOS, + budgetMin, + budgetMax, + }, + create: { + profession: data.profession, + preferredBrand: data.preferredBrand, + preferredOS: data.preferredOS, + budgetMin, + budgetMax, + }, + }, + }, + }, + include: { + preference: true, + }, + }); + + return updatedUser; + }, +}; diff --git a/src/services/review.service.ts b/src/services/review.service.ts new file mode 100644 index 0000000..6109618 --- /dev/null +++ b/src/services/review.service.ts @@ -0,0 +1,42 @@ +import prisma from "@/lib/prisma"; +import { reviewDatas } from "../utils/const"; + +export const reviewService = async () => { + const reviews = reviewDatas; + const result = await prisma.review.createMany({ + data: reviews, + }); + return result; +}; + +export const getReviewService = async (email: string) => { + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!user) return null; + + const review = await prisma.review.findMany({ + where: { userId: user.id }, + orderBy: { + createdAt: "asc", + }, + select: { + id: true, + createdAt: true, + confidenceScore: true, + sentiment: true, + content: true, + keywords: true, + product: { + select: { + name: true, + brand: true, + }, + }, + }, + }); + + return review; +}; diff --git a/src/services/sentimentStats.service.ts b/src/services/sentimentStats.service.ts new file mode 100644 index 0000000..b7945e9 --- /dev/null +++ b/src/services/sentimentStats.service.ts @@ -0,0 +1,41 @@ +import prisma from "@/lib/prisma"; + +export const sentimentStatsService = async (email: string) => { + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!user) return null; + + const grouped = await prisma.review.groupBy({ + by: ["sentiment"], + where: { + userId: user.id, + }, + _count: { + _all: true, + }, + }); + + const result = { + total: 0, + positive: 0, + negative: 0, + neutral: 0, + }; + + grouped.forEach((item) => { + const count = item._count._all; + + if (count) { + result.total += count; + } + + if (item.sentiment === "POSITIVE") result.positive = count; + if (item.sentiment === "NEGATIVE") result.negative = count; + if (item.sentiment === "NEUTRAL") result.neutral = count; + }); + + return result; +}; diff --git a/src/services/wordCloud.service.ts b/src/services/wordCloud.service.ts new file mode 100644 index 0000000..2250579 --- /dev/null +++ b/src/services/wordCloud.service.ts @@ -0,0 +1,17 @@ +import prisma from "@/lib/prisma"; + +export const wordCloudService = async (email: string) => { + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + + if (!user) return null; + + const analyses = await prisma.review.findMany({ + where: { userId: user.id }, + select: { keywords: true, sentiment: true }, + }); + + return analyses; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 6fdd95d..4c11acf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,8 @@ import { LucideIcon } from "lucide-react"; import { OS, Profession, Sentiment, Brand } from "@prisma/client"; import z from "zod"; import { profileSchema } from "../app/validation/profile.schema"; +import { Session } from "next-auth"; +import { NextResponse } from "next/server"; export interface ModelDB { modelName: string; @@ -255,3 +257,14 @@ export type UseProfileModalProps = Pick< ExtendedModalProps, "userData" | "router" | "onOptimisticUpdate" | "setShowModal" >; + +export type ApiHandler = ( + req: Request, + context: any, + session: Session, +) => Promise; + +export type WordCloudReview = { + keywords: string[]; + sentiment: Sentiment; +}; diff --git a/src/utils/const.ts b/src/utils/const.ts index 501b72b..0ee418a 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -7,6 +7,7 @@ import { } from "lucide-react"; import { SiAcer, SiAsus, SiLenovo, SiLinux, SiMacos } from "react-icons/si"; import { FaWindows } from "react-icons/fa"; +import { Sentiment } from "@prisma/client"; export const MODEL_OPTIONS = [ { @@ -26,7 +27,7 @@ export const MODEL_OPTIONS = [ }, ]; -export const WORD_LIMIT = 15; +export const WORD_LIMIT = 20; export const professionItems = [ { value: "PROGRAMMER", label: "Programmer", icon: Code }, @@ -49,3 +50,31 @@ export const OSItems = [ { value: "LINUX", label: "Linux", icon: SiLinux }, { value: "OTHER", label: "Other", icon: LucideCircleEllipsis }, ]; + +export const reviewDatas = [ + { + productId: 2, + modelId: 1, + content: + "Laptop ini sangat ringan dan performanya cepat untuk kerja harian.", + keywords: ["ringan", "cepat", "kerja"], + sentiment: Sentiment.POSITIVE, + confidenceScore: 0.92, + }, + { + productId: 3, + modelId: 1, + content: "Baterainya awet, tapi harganya cukup mahal.", + keywords: ["baterai", "awet", "mahal"], + sentiment: Sentiment.NEUTRAL, + confidenceScore: 0.75, + }, + { + productId: 4, + modelId: 1, + content: "Performa kurang stabil dan sering panas.", + keywords: ["performa", "panas", "stabil"], + sentiment: Sentiment.NEGATIVE, + confidenceScore: 0.88, + }, +]; diff --git a/src/utils/datas.ts b/src/utils/datas.ts index 24a5f21..34ab44d 100644 --- a/src/utils/datas.ts +++ b/src/utils/datas.ts @@ -1,39 +1,6 @@ -import { Frown, Meh, Smile } from "lucide-react"; import { ScrapeResult, WordCloudConfig, WordItem } from "../types"; import { Brand } from "@prisma/client"; -export const getSentimentDisplay = (sentiment: string) => { - switch (sentiment?.toLowerCase()) { - case "positive": - return { - label: "Positif", - icon: Smile, - bgClass: - "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800", - textClass: "text-green-600 dark:text-green-400", - borderClass: "border-green-200", - }; - case "negative": - return { - label: "Negatif", - icon: Frown, - bgClass: - "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800", - textClass: "text-red-600 dark:text-red-400", - borderClass: "border-red-200", - }; - default: - return { - label: "Netral", - icon: Meh, - bgClass: - "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700", - textClass: "text-gray-600 dark:text-gray-400", - borderClass: "border-gray-200", - }; - } -}; - export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => { const getSize = (value: number) => { if (maxValue === minValue) return 1.5; @@ -47,8 +14,10 @@ export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => { return "text-sentiment-positive hover:bg-sentiment-positive-light"; case "NEGATIVE": return "text-sentiment-negative hover:bg-sentiment-negative-light"; - default: + case "NEUTRAL": return "text-sentiment-neutral hover:bg-sentiment-neutral-light"; + default: + return "hover:bg-primary hover:text-card"; } };