diff --git a/lib/withAuth.ts b/lib/withAuth.ts index 2bd5bb4..dfaa3a2 100644 --- a/lib/withAuth.ts +++ b/lib/withAuth.ts @@ -3,6 +3,9 @@ import { ApiHandler, ServerActionHandler } from "@/src/types"; import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + export function withAuth(handler: ApiHandler) { return async (req: Request, context: any) => { const session = await getServerSession(authOptions); diff --git a/prisma/migrations/20260306013640_fix_brand_relations/migration.sql b/prisma/migrations/20260306013640_fix_brand_relations/migration.sql new file mode 100644 index 0000000..0d5b5b9 --- /dev/null +++ b/prisma/migrations/20260306013640_fix_brand_relations/migration.sql @@ -0,0 +1,205 @@ +/* + Warnings: + + - The primary key for the `Account` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Account` table. All the data in the column will be lost. + - The primary key for the `Analysis` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `compatibilityScore` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `generalSentiment` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `id` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `modelId` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `productId` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `targetProfession` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `topKeywords` on the `Analysis` table. All the data in the column will be lost. + - You are about to drop the column `verdict` on the `Analysis` table. All the data in the column will be lost. + - The primary key for the `Model` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Model` table. All the data in the column will be lost. + - The primary key for the `Review` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Review` table. All the data in the column will be lost. + - The primary key for the `Session` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Session` table. All the data in the column will be lost. + - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `User` table. All the data in the column will be lost. + - You are about to drop the `Product` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `UserPreference` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `updatedAt` to the `Analysis` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "BrandName" AS ENUM ('APPLE', 'ASUS', 'ACER', 'LENOVO', 'HP', 'DELL', 'MSI', 'AXIOO', 'ADVAN', 'ZYREX', 'OTHER'); + +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Analysis" DROP CONSTRAINT "Analysis_modelId_fkey"; + +-- DropForeignKey +ALTER TABLE "Analysis" DROP CONSTRAINT "Analysis_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "Analysis" DROP CONSTRAINT "Analysis_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Review" DROP CONSTRAINT "Review_modelId_fkey"; + +-- DropForeignKey +ALTER TABLE "Review" DROP CONSTRAINT "Review_productId_fkey"; + +-- DropForeignKey +ALTER TABLE "Review" DROP CONSTRAINT "Review_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "UserPreference" DROP CONSTRAINT "UserPreference_userId_fkey"; + +-- AlterTable +ALTER TABLE "Account" DROP CONSTRAINT "Account_pkey", +DROP COLUMN "id", +ADD COLUMN "accountId" SERIAL NOT NULL, +ADD CONSTRAINT "Account_pkey" PRIMARY KEY ("accountId"); + +-- AlterTable +ALTER TABLE "Analysis" DROP CONSTRAINT "Analysis_pkey", +DROP COLUMN "compatibilityScore", +DROP COLUMN "generalSentiment", +DROP COLUMN "id", +DROP COLUMN "modelId", +DROP COLUMN "productId", +DROP COLUMN "targetProfession", +DROP COLUMN "topKeywords", +DROP COLUMN "verdict", +ADD COLUMN "analysisId" SERIAL NOT NULL, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, +ADD CONSTRAINT "Analysis_pkey" PRIMARY KEY ("analysisId"); + +-- AlterTable +ALTER TABLE "Model" DROP CONSTRAINT "Model_pkey", +DROP COLUMN "id", +ADD COLUMN "modelId" SERIAL NOT NULL, +ADD CONSTRAINT "Model_pkey" PRIMARY KEY ("modelId"); + +-- AlterTable +ALTER TABLE "Review" DROP CONSTRAINT "Review_pkey", +DROP COLUMN "id", +ADD COLUMN "reviewId" SERIAL NOT NULL, +ADD CONSTRAINT "Review_pkey" PRIMARY KEY ("reviewId"); + +-- AlterTable +ALTER TABLE "Session" DROP CONSTRAINT "Session_pkey", +DROP COLUMN "id", +ADD COLUMN "sessionId" SERIAL NOT NULL, +ADD CONSTRAINT "Session_pkey" PRIMARY KEY ("sessionId"); + +-- AlterTable +ALTER TABLE "User" DROP CONSTRAINT "User_pkey", +DROP COLUMN "id", +ADD COLUMN "userId" SERIAL NOT NULL, +ADD CONSTRAINT "User_pkey" PRIMARY KEY ("userId"); + +-- DropTable +DROP TABLE "Product"; + +-- DropTable +DROP TABLE "UserPreference"; + +-- DropEnum +DROP TYPE "Brand"; + +-- CreateTable +CREATE TABLE "user_preferences" ( + "userPreferenceId" SERIAL NOT NULL, + "profession" "Profession", + "preferredOS" "OS", + "budgetMin" INTEGER, + "budgetMax" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" INTEGER NOT NULL, + "preferedBrandId" INTEGER, + + CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("userPreferenceId") +); + +-- CreateTable +CREATE TABLE "products" ( + "productId" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "brandId" INTEGER, + + CONSTRAINT "products_pkey" PRIMARY KEY ("productId") +); + +-- CreateTable +CREATE TABLE "Metric" ( + "metricId" SERIAL NOT NULL, + "generalSentiment" DOUBLE PRECISION NOT NULL, + "compatibilityScore" DOUBLE PRECISION NOT NULL, + "verdict" TEXT NOT NULL, + "topKeywords" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "analysisId" INTEGER NOT NULL, + "productId" INTEGER NOT NULL, + "modelId" INTEGER NOT NULL, + + CONSTRAINT "Metric_pkey" PRIMARY KEY ("metricId") +); + +-- CreateTable +CREATE TABLE "brands" ( + "brandId" SERIAL NOT NULL, + "name" "BrandName", + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "brands_pkey" PRIMARY KEY ("brandId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_preferences_userId_key" ON "user_preferences"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "products_url_key" ON "products"("url"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_pref_brand_fkey" FOREIGN KEY ("preferedBrandId") REFERENCES "brands"("brandId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "products" ADD CONSTRAINT "product_brand_fkey" FOREIGN KEY ("brandId") REFERENCES "brands"("brandId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("productId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("modelId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Metric" ADD CONSTRAINT "Metric_analysisId_fkey" FOREIGN KEY ("analysisId") REFERENCES "Analysis"("analysisId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Metric" ADD CONSTRAINT "Metric_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("productId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Metric" ADD CONSTRAINT "Metric_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("modelId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260306023434_fix_custom_id_relation/migration.sql b/prisma/migrations/20260306023434_fix_custom_id_relation/migration.sql new file mode 100644 index 0000000..ef4a517 --- /dev/null +++ b/prisma/migrations/20260306023434_fix_custom_id_relation/migration.sql @@ -0,0 +1,98 @@ +/* + Warnings: + + - You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Analysis" DROP CONSTRAINT "Analysis_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Review" DROP CONSTRAINT "Review_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "user_preferences" DROP CONSTRAINT "user_preferences_userId_fkey"; + +-- DropTable +DROP TABLE "Account"; + +-- DropTable +DROP TABLE "Session"; + +-- DropTable +DROP TABLE "User"; + +-- CreateTable +CREATE TABLE "accounts" ( + "id" SERIAL NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "user_id" INTEGER NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" SERIAL NOT NULL, + "session_token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + "user_id" INTEGER NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "userId" SERIAL NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + "password" TEXT, + "bio" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_providerAccountId_key" ON "accounts"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("userId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260306035117_fix_name_rows_type/migration.sql b/prisma/migrations/20260306035117_fix_name_rows_type/migration.sql new file mode 100644 index 0000000..23105d8 --- /dev/null +++ b/prisma/migrations/20260306035117_fix_name_rows_type/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - The `name` column on the `brands` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "brands" DROP COLUMN "name", +ADD COLUMN "name" TEXT; diff --git a/prisma/migrations/20260306065823_fix_name_rows_contraint_value/migration.sql b/prisma/migrations/20260306065823_fix_name_rows_contraint_value/migration.sql new file mode 100644 index 0000000..f0959a1 --- /dev/null +++ b/prisma/migrations/20260306065823_fix_name_rows_contraint_value/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `brands` will be added. If there are existing duplicate values, this will fail. + - Made the column `name` on table `brands` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "brands" ALTER COLUMN "name" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "brands_name_key" ON "brands"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1fb535c..17aa4de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,7 +26,7 @@ enum OS { OTHER } -enum Brand { +enum BrandName { APPLE ASUS ACER @@ -49,8 +49,7 @@ enum Profession { } model Account { - id Int @id @default(autoincrement()) - userId Int + id Int @id @default(autoincrement()) type String provider String providerAccountId String @@ -61,17 +60,23 @@ model Account { scope String? id_token String? session_state String? + + userId Int @map("user_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) + @@map("accounts") } model Session { id Int @id @default(autoincrement()) - sessionToken String @unique - userId Int + sessionToken String @unique @map("session_token") expires DateTime + + userId Int @map("user_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("sessions") } model VerificationToken { @@ -83,7 +88,7 @@ model VerificationToken { } model User { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) @map("userId") name String? email String? @unique emailVerified DateTime? @@ -99,40 +104,50 @@ model User { analyses Analysis[] preference UserPreference? - review Review[] + review Review[] + + @@map("users") } model UserPreference { - id Int @id @default(autoincrement()) - profession Profession? - preferredOS OS? - preferredBrand Brand? - budgetMin Int? - budgetMax Int? + userPreferenceId Int @id @default(autoincrement()) + profession Profession? + preferredOS OS? + budgetMin Int? + budgetMax Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId Int @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + preferedBrandId Int? + brand Brand? @relation(fields: [preferedBrandId], references: [brandId], map: "user_pref_brand_fkey") + + @@map("user_preferences") } model Product { - id Int @id @default(autoincrement()) - name String - brand String? - url String @unique - image String? + productId Int @id @default(autoincrement()) + name String + url String @unique + image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - reviews Review[] - analyses Analysis[] + reviews Review[] + metrics Metric[] + + brandId Int? + brand Brand? @relation(fields: [brandId], references: [brandId], map: "product_brand_fkey") + + @@map("products") } model Review { - id Int @id @default(autoincrement()) + reviewId Int @id @default(autoincrement()) content String sentiment Sentiment confidenceScore Float @@ -142,53 +157,73 @@ model Review { updatedAt DateTime @updatedAt productId Int - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [productId], onDelete: Cascade) modelId Int? - model Model? @relation(fields: [modelId], references: [id]) - + model Model? @relation(fields: [modelId], references: [modelId]) + userId Int? user User? @relation(fields: [userId], references: [id]) - } model Analysis { - id Int @id @default(autoincrement()) - - targetProfession String - - generalSentiment Float - compatibilityScore Float - verdict String - - topKeywords String[] - - createdAt DateTime @default(now()) - - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - productId Int - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - - modelId Int - model Model @relation(fields: [modelId], references: [id]) - -} - -model Model { - id Int @id @default(autoincrement()) - modelName String - description String? - accuracy Float - macroF1 Float - f1Negative Float - f1Neutral Float - isActive Boolean @default(true) + analysisId Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - analyses Analysis[] - reviews Review[] + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + metric Metric[] +} + +model Model { + modelId Int @id @default(autoincrement()) + modelName String + description String? + accuracy Float + macroF1 Float + f1Negative Float + f1Neutral Float + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + metrics Metric[] + reviews Review[] +} + +model Metric { + metricId Int @id @default(autoincrement()) + generalSentiment Float + compatibilityScore Float + verdict String + topKeywords String[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + analysisId Int + analysis Analysis @relation(fields: [analysisId], references: [analysisId]) + + productId Int + product Product @relation(fields: [productId], references: [productId], onDelete: Cascade) + + modelId Int + model Model @relation(fields: [modelId], references: [modelId]) +} + +model Brand { + brandId Int @id @default(autoincrement()) + name String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + products Product[] + userPreferences UserPreference[] + + @@map("brands") } diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 2b72140..8a0aa5a 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -12,10 +12,19 @@ export const authOptions = { GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + allowDangerousEmailAccountLinking: true, }), ], debug: true, session: { strategy: "database" as const }, + callbacks: { + async session({ session, user }: any) { + if (session.user) { + session.user.id = user.id; + } + return session; + }, + }, }; const handler = NextAuth(authOptions); diff --git a/src/app/api/user-metric/route.ts b/src/app/api/user-metric/route.ts new file mode 100644 index 0000000..addcab8 --- /dev/null +++ b/src/app/api/user-metric/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/lib/prisma"; +import { authOptions } from "../auth/[...nextauth]/route"; +import { AnalysisWithMetric } from "@/src/hooks/useAnalyzeText"; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userAnalysis = (await prisma.analysis.findFirst({ + where: { + user: { email: session.user.email }, + }, + select: { + metric: { + select: { + metricId: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + })) as AnalysisWithMetric | null; + + return NextResponse.json({ metricId: userAnalysis?.metric?.metricId }); +} diff --git a/src/app/dashboard/lib/actions.ts b/src/app/dashboard/lib/actions.ts index e433665..6450c11 100644 --- a/src/app/dashboard/lib/actions.ts +++ b/src/app/dashboard/lib/actions.ts @@ -4,6 +4,7 @@ import { withActionAuth } from "@/lib/withAuth"; import { getAnalysisData } from "@/src/services/analyze.service"; import { formatBrandStats } from "@/src/services/brand.service"; import { reportService } from "@/src/services/report.service"; +import { AnalysisData } from "@/src/types"; export const getClassificationReport = async () => { try { @@ -20,13 +21,29 @@ export const getTotalBrandAnalysis = withActionAuth(async (session) => { try { const email = session.user?.email as string; - const userAnalysis = await getAnalysisData(email); + const rawAnalysis = await getAnalysisData(email); + + const userAnalysis: AnalysisData[] = rawAnalysis.map((item: any) => ({ + analysisId: item.analysisId, + userId: item.userId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + product: + item.metric && item.metric[0]?.product + ? { + productId: item.metric[0].product.productId, // Gunakan productId, bukan id + brandName: item.metric[0].product.brand?.name || "Generic", // Gunakan brandName, bukan brand + name: item.metric[0].product.name, + reviewCount: item.metric[0].product._count?.reviews || 0, // Gunakan reviewCount, bukan _count.reviews + } + : null, + })); const formattedBrands = formatBrandStats(userAnalysis); return { formattedBrands, userAnalysis }; } catch (error) { console.error("Gagal mengambil data review:", error); - return []; + return { formattedBrands: [], userAnalysis: [] }; } }); diff --git a/src/app/profile/lib/action.ts b/src/app/profile/lib/action.ts index f57ea2e..46824dd 100644 --- a/src/app/profile/lib/action.ts +++ b/src/app/profile/lib/action.ts @@ -5,7 +5,12 @@ import { getAnotherUserDataService } from "@/src/services/users.service"; export const getAnotherUserData = withActionAuth(async (session) => { try { - const email = (await session.user?.email) as string; + const email = session.user?.email; + + if (!email) { + console.warn("No email found in session"); + return null; + } const userData = await getAnotherUserDataService(email); diff --git a/src/app/validation/profile.schema.ts b/src/app/validation/profile.schema.ts index 8d40c7a..3c42200 100644 --- a/src/app/validation/profile.schema.ts +++ b/src/app/validation/profile.schema.ts @@ -28,7 +28,7 @@ export const profileSchema = z.object({ name: z.string().min(2, "Nama minimal 2 karakter"), bio: z.string().min(10, "Bio minimal 10 karakter"), profession: professionEnum, - preferredBrand: brandEnum, + preferredBrand: z.string().min(2, "Merek laptop minimal 2 karakter"), preferredOS: osEnum, budgetMin: z.coerce.number().min(0), budgetMax: z.coerce.number().min(0), diff --git a/src/components/dashboards/DashboardClient.tsx b/src/components/dashboards/DashboardClient.tsx index a297323..998fb32 100644 --- a/src/components/dashboards/DashboardClient.tsx +++ b/src/components/dashboards/DashboardClient.tsx @@ -9,7 +9,6 @@ import { MessageSquareText, Smile, Sparkles, - TrendingUp, } from "lucide-react"; import { StatCard } from "./StatCard"; import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton"; diff --git a/src/components/dashboards/ProfileCard.tsx b/src/components/dashboards/ProfileCard.tsx index f218600..f26fbf4 100644 --- a/src/components/dashboards/ProfileCard.tsx +++ b/src/components/dashboards/ProfileCard.tsx @@ -8,8 +8,8 @@ import { Button } from "../ui/button"; import { Separator } from "../ui/separator"; import { formatRupiah, toTitleCase } from "@/src/utils/datas"; import { brandItems, OSItems, professionItems } from "@/src/utils/const"; -import { useProfileClient } from "@/src/hooks/useProfileClient"; import { ProfileModal } from "./ProfileModal"; +import { useProfileClient } from "@/src/hooks/useProfileClient"; export default function ProfileCard(props: ProfileClientProps) { const { diff --git a/src/components/dashboards/ProfileClient.tsx b/src/components/dashboards/ProfileClient.tsx index d842a84..0d785ab 100644 --- a/src/components/dashboards/ProfileClient.tsx +++ b/src/components/dashboards/ProfileClient.tsx @@ -23,7 +23,7 @@ export default async function ProfileClient() { name={user?.name || ""} bio={user?.bio || "None"} profession={user?.preference?.profession || ""} - preferenceBrand={user?.preference?.preferredBrand || ""} + preferenceBrand={user?.preference?.brand?.name || ""} preferenceOS={user?.preference?.preferredOS || ""} budgetMax={user?.preference?.budgetMax || 0} budgetMin={user?.preference?.budgetMin || 0} diff --git a/src/components/dashboards/ReviewTable.tsx b/src/components/dashboards/ReviewTable.tsx index 1edf9c8..ad8d63e 100644 --- a/src/components/dashboards/ReviewTable.tsx +++ b/src/components/dashboards/ReviewTable.tsx @@ -88,7 +88,8 @@ export function ReviewTable() {
- {review.product?.brand || "Generic"} + {/* Tambahkan .name di sini */} + {review.product?.brand?.name || "Generic"}
diff --git a/src/hooks/useAnalyzeText.ts b/src/hooks/useAnalyzeText.ts index bd243c6..15598d3 100644 --- a/src/hooks/useAnalyzeText.ts +++ b/src/hooks/useAnalyzeText.ts @@ -10,9 +10,18 @@ import { } from "../services/analyze.service"; import { analyzeSchema } from "../app/validation/analyze.schema"; // Sesuaikan path-nya import { getAnotherUserData } from "../app/profile/lib/action"; +import prisma from "@/lib/prisma"; +import { getMetricId } from "../services/metric.service"; export type AnalyzeFormData = z.infer; +export interface AnalysisWithMetric { + metric: { + metricId: number; + name: string; + } | null; +} + export const useAnalyseText = () => { const { data: session } = useSession(); const [loading, setLoading] = useState(false); @@ -121,9 +130,19 @@ export const useAnalyseText = () => { reviews: res.data.reviews, })); - const aiResult = await getAIRecommendation({ + const metricIdValue = await getMetricId(); + + console.log("Payload to AI:", { user_email: session.user.email, + metric_id: metricIdValue, + candidateCount: candidates.length, + totalReviews: candidates.reduce((acc, c) => acc + c.reviews.length, 0), + }); + + const aiResult = await getAIRecommendation({ + user_email: session.user.email as string, candidates: candidates, + metric_id: metricIdValue, }); setResult(aiResult); diff --git a/src/hooks/useProfileClient.ts b/src/hooks/useProfileClient.ts index 3ebe581..077b07f 100644 --- a/src/hooks/useProfileClient.ts +++ b/src/hooks/useProfileClient.ts @@ -47,7 +47,7 @@ export const useProfileClient = (props: ProfileClientProps) => { (newData.profession as Profession) || prev.preference.profession, preferredBrand: - (newData.preferredBrand as Brand) || prev.preference.preferredBrand, + (newData.preferredBrand ) || prev.preference.preferredBrand, preferredOS: (newData.preferredOS as OS) || prev.preference.preferredOS, @@ -74,7 +74,9 @@ export const useProfileClient = (props: ProfileClientProps) => { profession, } = preference; - const { brands } = brandFormat({ preferenceBrand }); + const { brands } = brandFormat({ + preferenceBrand: preferenceBrand as any, + }); return { session, diff --git a/src/hooks/useReviewTable.ts b/src/hooks/useReviewTable.ts index c21305e..ba82fbf 100644 --- a/src/hooks/useReviewTable.ts +++ b/src/hooks/useReviewTable.ts @@ -38,7 +38,7 @@ export const useReviewTable = ( const filteredData = selectedBrand ? data.filter( (review) => - review.product?.brand?.toLowerCase() === + review.product?.brand?.name.toLowerCase() === selectedBrand.toLowerCase(), ) : data; diff --git a/src/hooks/useSentiment.ts b/src/hooks/useSentiment.ts index 61e42b2..22baeb6 100644 --- a/src/hooks/useSentiment.ts +++ b/src/hooks/useSentiment.ts @@ -72,13 +72,13 @@ export const useSentiment = () => { let confidence: number; if (positiveScore > negativeScore) { - sentiment = "positif"; + sentiment = "POSITIVE"; confidence = 0.75 + Math.random() * 0.2; } else if (negativeScore > positiveScore) { - sentiment = "negatif"; + sentiment = "NEGATIVE"; confidence = 0.75 + Math.random() * 0.2; } else { - sentiment = "netral"; + sentiment = "NEUTRAL"; confidence = 0.6 + Math.random() * 0.2; } @@ -92,21 +92,21 @@ export const useSentiment = () => { const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => { const config = { - positif: { + POSITIVE: { icon: ThumbsUp, label: "Positif", bgClass: "bg-sentiment-positive-light", textClass: "text-sentiment-positive", borderClass: "border-sentiment-positive/30", }, - negatif: { + NEGATIVE: { icon: ThumbsDown, label: "Negatif", bgClass: "bg-sentiment-negative-light", textClass: "text-sentiment-negative", borderClass: "border-sentiment-negative/30", }, - netral: { + NEUTRAL: { icon: Minus, label: "Netral", bgClass: "bg-sentiment-neutral-light", diff --git a/src/services/analyze.service.ts b/src/services/analyze.service.ts index e9b4ec5..b4f5433 100644 --- a/src/services/analyze.service.ts +++ b/src/services/analyze.service.ts @@ -19,22 +19,6 @@ export const scrapeProduct = async (url: string) => { return data; }; -// export const getAIRecommendation = async (payload: { -// user_email: string; -// // profession: string; -// candidates: { name: string; url: string; reviews: any[] }[]; -// }) => { -// const aiRes = await fetch("http://localhost:8000/recommend", { -// method: "POST", -// headers: { "Content-Type": "application/json" }, -// body: JSON.stringify(payload), -// }); - -// if (!aiRes.ok) throw new Error("Gagal melakukan analisis AI"); - -// return await aiRes.json(); -// }; - export const getAnalysisData = async (email: string) => { const userAnalyses = await prisma.analysis.findMany({ where: { @@ -43,16 +27,24 @@ export const getAnalysisData = async (email: string) => { }, }, include: { - product: { + metric: { select: { - id: true, - brand: true, - _count: { + product: { select: { - reviews: { - where: { - user: { - email: email, + productId: true, + brand: { + select:{ + name: true + } + }, + _count: { + select: { + reviews: { + where: { + user: { + email: email, + }, + }, }, }, }, @@ -67,17 +59,28 @@ export const getAnalysisData = async (email: string) => { export const getAIRecommendation = async (payload: { user_email: string; + metric_id: number | 1; candidates: { name: string; url: string; reviews: string[] }[]; }): Promise => { + console.log("Fetching to FastAPI..."); const aiRes = await fetch("http://localhost:8000/recommend", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify(payload), }); if (!aiRes.ok) { const errorData = await aiRes.json(); - throw new Error(errorData.detail || "Gagal melakukan analisis AI"); + // DEBUG: Munculkan di console agar bisa dibaca strukturnya + console.error( + "DETAILED VALIDATION ERROR:", + JSON.stringify(errorData, null, 2), + ); + + // Ambil pesan error pertama dari list validation FastAPI + const errorMessage = + errorData.detail?.[0]?.msg || "Gagal melakukan analisis AI"; + throw new Error(errorMessage); } return await aiRes.json(); diff --git a/src/services/brand.service.ts b/src/services/brand.service.ts index f977925..e815556 100644 --- a/src/services/brand.service.ts +++ b/src/services/brand.service.ts @@ -5,9 +5,9 @@ export const formatBrandStats = (userAnalysis: AnalysisData[]) => { const brandCounts = userAnalysis.reduce( (acc: Record, analysis) => { - const productId = analysis.product?.id; - const rawBrand = analysis.product?.brand || "Unknown"; - const reviewCount = analysis.product?._count?.reviews || 0; + const productId = analysis.product?.productId; + const rawBrand = analysis.product?.brandName || "Unknown"; + const reviewCount = analysis.product?.reviewCount || 0; if (productId && countedProductIds.has(productId)) { return acc; @@ -20,7 +20,7 @@ export const formatBrandStats = (userAnalysis: AnalysisData[]) => { const formattedBrand = rawBrand .trim() .toLowerCase() - .replace(/\b\w/g, (char) => char.toUpperCase()); + .replace(/\b\w/g, (char: any) => char.toUpperCase()); if (!acc[formattedBrand]) { acc[formattedBrand] = 0; diff --git a/src/services/metric.service.ts b/src/services/metric.service.ts new file mode 100644 index 0000000..3375915 --- /dev/null +++ b/src/services/metric.service.ts @@ -0,0 +1,7 @@ +export const getMetricId = async () => { + const response = await fetch("/api/user-metric"); + if (!response.ok) return null; + + const data = await response.json(); + return data.metricId; +}; diff --git a/src/services/product.service.ts b/src/services/product.service.ts index e2b85c8..bca5af4 100644 --- a/src/services/product.service.ts +++ b/src/services/product.service.ts @@ -8,9 +8,11 @@ export const productService = async (email: string) => { if (!user) return null; - const totalProducts = await prisma.analysis.count({ + const totalProducts = await prisma.metric.count({ where: { - userId: user.id, + analysis: { + userId: user.id, + }, }, }); diff --git a/src/services/profile.service.ts b/src/services/profile.service.ts index 9a5c569..fdf357d 100644 --- a/src/services/profile.service.ts +++ b/src/services/profile.service.ts @@ -23,7 +23,11 @@ export const userService = { include: { preference: { select: { - preferredBrand: true, + brand: { + select: { + name: true, + }, + }, preferredOS: true, profession: true, budgetMax: true, @@ -54,25 +58,38 @@ export const userService = { bio: data.bio, preference: { upsert: { + where: { userId: user.id }, update: { profession: data.profession, - preferredBrand: data.preferredBrand, preferredOS: data.preferredOS, budgetMin, budgetMax, + brand: { + connectOrCreate: { + where: { name: data.preferredBrand }, + create: { name: data.preferredBrand }, + }, + }, }, create: { profession: data.profession, - preferredBrand: data.preferredBrand, preferredOS: data.preferredOS, budgetMin, budgetMax, + brand: { + connectOrCreate: { + where: { name: data.preferredBrand }, + create: { name: data.preferredBrand }, + }, + }, }, }, }, }, include: { - preference: true, + preference: { + include: { brand: true }, + }, }, }); diff --git a/src/services/review.service.ts b/src/services/review.service.ts index 6109618..c3b67c5 100644 --- a/src/services/review.service.ts +++ b/src/services/review.service.ts @@ -23,7 +23,7 @@ export const getReviewService = async (email: string) => { createdAt: "asc", }, select: { - id: true, + reviewId: true, createdAt: true, confidenceScore: true, sentiment: true, diff --git a/src/services/users.service.ts b/src/services/users.service.ts index 8803785..8fc77e0 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -10,9 +10,13 @@ export const getAnotherUserDataService = async (email: string) => { bio: true, preference: { select: { - id: true, + userPreferenceId: true, profession: true, - preferredBrand: true, + brand:{ + select:{ + name: true + } + }, preferredOS: true, budgetMin: true, budgetMax: true, @@ -20,6 +24,5 @@ export const getAnotherUserDataService = async (email: string) => { }, }, }); - return userData; }; diff --git a/src/types/index.ts b/src/types/index.ts index aaffe1d..7cfade7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import { LucideIcon } from "lucide-react"; -import { OS, Profession, Sentiment, Brand } from "@prisma/client"; +import { OS, Profession, Sentiment } from "@prisma/client"; import z from "zod"; import { profileSchema } from "../app/validation/profile.schema"; import { Session } from "next-auth"; @@ -18,7 +18,7 @@ export interface ModelDB { export interface ProfileClientProps { name: string; bio?: string; - preferenceBrand: string; + preferenceBrand: string preferenceOS: string; budgetMin: number; budgetMax: number; @@ -130,6 +130,33 @@ export interface UseStatCardProps { delay?: number; } +// export interface ReviewItem { +// reviewId: number; +// content: string; +// sentiment: Sentiment; +// confidenceScore: number; +// createdAt: string; +// keywords: string[]; +// product: { +// name: string; +// brand?: Brand; +// } | null; +// } + +export interface Brand { + brandId: number; + name: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Product { + productId: number; // Pastikan sesuai dengan schema.prisma (productId bukan id) + name: string; + url: string; + brand: Brand | null; // <--- Ubah dari string ke Brand object +} + export interface ReviewItem { id: number; content: string; @@ -137,10 +164,7 @@ export interface ReviewItem { confidenceScore: number; createdAt: string; keywords: string[]; - product: { - name: string; - brand?: string; - } | null; + product: Product | null; } export interface ApiResponse { @@ -307,14 +331,25 @@ export type ServerActionHandler = ( ...args: Args ) => Promise; +// export type AnalysisData = { +// product?: { +// id: number; +// brand: string | null; +// _count?: { +// reviews: number; +// }; +// }; +// }; + export type AnalysisData = { - product?: { - id: number; - brand: string | null; - _count?: { - reviews: number; - }; - }; + analysisId: number; + createdAt: string | Date; + product: { + productId: number; + brandName: string | null; + name: string; + reviewCount: number; + } | null; }; export type BodyData = (req: Request, body: any) => Promise;