diff --git a/package-lock.json b/package-lock.json index fe7154f..edf67ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "bcryptjs": "^3.0.2", "cors": "^2.8.5", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "zod": "^4.1.12" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -1742,6 +1743,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a340f0b..d29d20e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "bcryptjs": "^3.0.2", "cors": "^2.8.5", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "zod": "^4.1.12" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/prisma/migrations/20251103001939_user_deal_vote_fix/migration.sql b/prisma/migrations/20251103001939_user_deal_vote_fix/migration.sql new file mode 100644 index 0000000..c2ea88d --- /dev/null +++ b/prisma/migrations/20251103001939_user_deal_vote_fix/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[dealId,userId]` on the table `DealVote` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "DealVote_dealId_userId_key" ON "DealVote"("dealId", "userId"); diff --git a/prisma/migrations/20251103012218_add_comment_table/migration.sql b/prisma/migrations/20251103012218_add_comment_table/migration.sql new file mode 100644 index 0000000..690acc4 --- /dev/null +++ b/prisma/migrations/20251103012218_add_comment_table/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Comment" ( + "id" SERIAL NOT NULL, + "text" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + "dealId" INTEGER NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251103030110_add_avatar_field/migration.sql b/prisma/migrations/20251103030110_add_avatar_field/migration.sql new file mode 100644 index 0000000..84420b7 --- /dev/null +++ b/prisma/migrations/20251103030110_add_avatar_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarUrl" VARCHAR(512); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a8defb7..763c4f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,37 +18,53 @@ model User { username String @unique email String @unique passwordHash String + avatarUrl String? @db.VarChar(512) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt Deal Deal[] votes DealVote[] + comments Comment[] } model Deal { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String description String? url String? imageUrl String? price Float? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt userId Int + score Int @default(0) - // yeni alan: - score Int @default(0) - - user User @relation(fields: [userId], references: [id]) - votes DealVote[] + user User @relation(fields: [userId], references: [id]) + votes DealVote[] + comments Comment[] // ← burası eklendi } + model DealVote { id Int @id @default(autoincrement()) dealId Int userId Int - voteType String // "UP" veya "DOWN" + voteType String createdAt DateTime @default(now()) deal Deal @relation(fields: [dealId], references: [id]) user User @relation(fields: [userId], references: [id]) + + @@unique([dealId, userId]) // aynı kullanıcı aynı ilana bir kez oy verebilir } + + +model Comment { + id Int @id @default(autoincrement()) + text String + createdAt DateTime @default(now()) + userId Int + dealId Int + + user User @relation(fields: [userId], references: [id]) + deal Deal @relation(fields: [dealId], references: [id]) +} \ No newline at end of file diff --git a/routes/authRoutes.js b/routes/authRoutes.js index cd5261d..1d4d4f6 100644 --- a/routes/authRoutes.js +++ b/routes/authRoutes.js @@ -3,6 +3,7 @@ const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const { PrismaClient } = require("@prisma/client"); const generateToken = require("../utils/generateToken"); +const authMiddleware = require("../middleware/authMiddleware"); const router = express.Router(); const prisma = new PrismaClient(); @@ -60,4 +61,19 @@ router.post("/login", async (req, res) => { }); +router.get("/me", authMiddleware, async (req, res) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.userId }, + select: { id: true, username: true, email: true }, + }) + + if (!user) return res.status(404).json({ error: "Kullanıcı bulunamadı" }) + res.json(user) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + module.exports = router; diff --git a/routes/deal/commentRoutes.js b/routes/deal/commentRoutes.js new file mode 100644 index 0000000..cd8e0d0 --- /dev/null +++ b/routes/deal/commentRoutes.js @@ -0,0 +1,46 @@ +const express = require("express") +const authMiddleware = require("../../middleware/authMiddleware") +const { + getCommentsByDealId, + createComment, + deleteComment, +} = require("../../services/deal/commentService") + +const router = express.Router() + +router.get("/:dealId", async (req, res) => { + try { + const comments = await getCommentsByDealId(req.params.dealId) + res.json(comments) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + +router.post("/", authMiddleware, async (req, res) => { + try { + const { dealId, text } = req.body + const userId = req.user.userId + if (!text?.trim()) return res.status(400).json({ error: "Yorum boş olamaz." }) + + const comment = await createComment({ dealId, userId, text }) + res.json(comment) + } catch (err) { + console.error(err) + res.status(500).json({ error: err.message || "Sunucu hatası" }) + } +}) + +router.delete("/:id", authMiddleware, async (req, res) => { + try { + const result = await deleteComment(req.params.id, req.user.userId) + res.json(result) + } catch (err) { + console.error(err) + const status = err.message.includes("yetkin") ? 403 : 404 + res.status(status).json({ error: err.message }) + } +}) + +module.exports = router diff --git a/routes/deal/dealRoutes.js b/routes/deal/dealRoutes.js new file mode 100644 index 0000000..f08cf1b --- /dev/null +++ b/routes/deal/dealRoutes.js @@ -0,0 +1,39 @@ +const express = require("express") +const router = express.Router() +const { getAllDeals, getDealById, createDeal } = require("../../services/deal/dealService") +const authMiddleware = require("../../middleware/authMiddleware") + +router.get("/", async (req, res) => { + try { + const page = Number(req.query.page) || 1 + const limit = 10 + const data = await getAllDeals(page, limit) + res.json(data) + } catch (e) { + console.error(e) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + +router.get("/:id", async (req, res) => { + try { + const deal = await getDealById(req.params.id) + if (!deal) return res.status(404).json({ error: "Deal bulunamadı" }) + res.json(deal) + } catch (e) { + console.error(e) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + +router.post("/", authMiddleware, async (req, res) => { + try { + const userId = req.user.userId + const deal = await createDeal(req.body, userId) + res.json(deal) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) +module.exports = router diff --git a/routes/deal/voteRoutes.js b/routes/deal/voteRoutes.js new file mode 100644 index 0000000..3a18a00 --- /dev/null +++ b/routes/deal/voteRoutes.js @@ -0,0 +1,51 @@ +const express = require("express") +const authMiddleware = require("../../middleware/authMiddleware") +const { voteDeal, getVotes } = require("../../services/deal/dealService") +const { z } = require("zod") + +const router = express.Router() + +// Şema tanımı +const voteSchema = z.object({ + dealId: z.number().int().positive(), + voteType: z.enum(["UP", "DOWN"]), +}) + +// Oy verme +router.post("/", authMiddleware, async (req, res) => { + const parsed = voteSchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ + error: "Geçersiz veri", + details: parsed.error.errors.map((e) => e.message), + }) + } + + const { dealId, voteType } = parsed.data + const userId = req.user.userId + + try { + const score = await voteDeal(dealId, userId, voteType) + res.json({ score }) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + +// Belirli deal için oyları çek +router.get("/:dealId", async (req, res) => { + try { + const dealId = Number(req.params.dealId) + if (isNaN(dealId) || dealId <= 0) + return res.status(400).json({ error: "Geçersiz dealId" }) + + const data = await getVotes(dealId) + res.json(data) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatası" }) + } +}) + +module.exports = router diff --git a/routes/dealRoutes.js b/routes/dealRoutes.js deleted file mode 100644 index 3ddd6c6..0000000 --- a/routes/dealRoutes.js +++ /dev/null @@ -1,21 +0,0 @@ -const express = require("express"); -const { PrismaClient } = require("@prisma/client"); -const router = express.Router(); -const prisma = new PrismaClient(); -const authMiddleware = require("../middleware/authMiddleware"); - - -router.get("/", async (req, res) => { - const deals = await prisma.deal.findMany({ include: { user: true } }); - res.json(deals); -}); - -router.post("/",authMiddleware, async (req, res) => { - const { title, description, url, imageUrl, price, userId } = req.body; - const deal = await prisma.deal.create({ - data: { title, description, url, imageUrl, price, userId }, - }); - res.json(deal); -}); - -module.exports = router; diff --git a/routes/dealVoteRoutes.js b/routes/dealVoteRoutes.js deleted file mode 100644 index e1fe219..0000000 --- a/routes/dealVoteRoutes.js +++ /dev/null @@ -1,73 +0,0 @@ -const express = require("express"); -const { PrismaClient } = require("@prisma/client"); -const authMiddleware = require("../middleware/authMiddleware"); - -const router = express.Router(); -const prisma = new PrismaClient(); - -// Oy verme -router.post("/", authMiddleware, async (req, res) => { - console.log("body:", req.body); - console.log("user:", req.user); - try { - const { dealId, voteType } = req.body; - const userId = req.user.userId; - if (!dealId || !userId || !voteType) - return res.status(400).json({ error: "Eksik veri." }); - - const existingVote = await prisma.dealVote.findFirst({ - where: { dealId, userId }, - }); - - let vote; - if (existingVote) { - // Aynı kullanıcı aynı ilana yeniden oy verirse güncelle - vote = await prisma.dealVote.update({ - where: { id: existingVote.id }, - data: { voteType }, - }); - } else { - vote = await prisma.dealVote.create({ - data: { dealId, userId, voteType }, - }); - } - - // Toplam oy sayısını güncelle - const upvotes = await prisma.dealVote.count({ - where: { dealId, voteType: "UP" }, - }); - const downvotes = await prisma.dealVote.count({ - where: { dealId, voteType: "DOWN" }, - }); - - await prisma.deal.update({ - where: { id: dealId }, - data: { score: upvotes - downvotes }, - }); - - res.json({ vote, score: upvotes - downvotes }); - } catch (err) { - console.error(err); - res.status(500).json({ error: "Sunucu hatası." }); - } -}); - -// Belirli bir deal için oyları çek -router.get("/:dealId", async (req, res) => { - try { - const { dealId } = req.params; - - const upvotes = await prisma.dealVote.count({ - where: { dealId: parseInt(dealId), voteType: "UP" }, - }); - const downvotes = await prisma.dealVote.count({ - where: { dealId: parseInt(dealId), voteType: "DOWN" }, - }); - - res.json({ upvotes, downvotes, score: upvotes - downvotes }); - } catch (err) { - console.error(err); - res.status(500).json({ error: "Sunucu hatası." }); - } -}); -module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 08883c7..6d24cca 100644 --- a/server.js +++ b/server.js @@ -3,9 +3,10 @@ const cors = require("cors"); require("dotenv").config(); const userRoutes = require("./routes/userRoutes"); -const dealRoutes = require("./routes/dealRoutes"); +const dealRoutes = require("./routes/deal/dealRoutes"); const authRoutes = require("./routes/authRoutes"); -const dealVoteRoutes = require("./routes/dealVoteRoutes"); +const dealVoteRoutes = require("./routes/deal/voteRoutes"); +const commentRoutes = require("./routes/deal/commentRoutes") const app = express(); @@ -16,5 +17,6 @@ app.use("/api/users", userRoutes); app.use("/api/deals", dealRoutes); app.use("/api/auth", authRoutes); app.use("/api/deal-votes", dealVoteRoutes); +app.use("/api/comments", commentRoutes) app.listen(3000, () => console.log("Server running on http://localhost:3000")); diff --git a/services/deal/commentService.js b/services/deal/commentService.js new file mode 100644 index 0000000..f59cd83 --- /dev/null +++ b/services/deal/commentService.js @@ -0,0 +1,66 @@ +// services/deal/commentService.js +const { PrismaClient } = require("@prisma/client") +const prisma = new PrismaClient() + +function assertPositiveInt(v, name = "id") { + const n = Number(v) + if (!Number.isInteger(n) || n <= 0) throw new Error(`Geçersiz ${name}.`) + return n +} + +async function getCommentsByDealId(dealId) { + const id = assertPositiveInt(dealId, "dealId") + + // Deal mevcut mu kontrol et + const deal = await prisma.deal.findUnique({ where: { id } }) + if (!deal) throw new Error("Deal bulunamadı.") + + return prisma.comment.findMany({ + where: { dealId: id }, + include: { user: { select: { username: true } } }, + orderBy: { createdAt: "desc" }, + }) +} + +async function createComment({ dealId, userId, text }) { + // Basit doğrulamalar + const dId = assertPositiveInt(dealId, "dealId") + const uId = assertPositiveInt(userId, "userId") + if (!text || typeof text !== "string" || !text.trim()) + throw new Error("Yorum boş olamaz.") + + // Deal var mı kontrol et + const deal = await prisma.deal.findUnique({ where: { id: dId } }) + if (!deal) throw new Error("Deal bulunamadı.") + + // (Opsiyonel) Kullanıcı var mı kontrolü (ek güvenlik) + const user = await prisma.user.findUnique({ where: { id: uId } }) + if (!user) throw new Error("Kullanıcı bulunamadı.") + + const comment = await prisma.comment.create({ + data: { + text: text.trim(), + userId: uId, + dealId: dId, + }, + include: { + user: { select: { username: true } }, + }, + }) + + return comment +} + +async function deleteComment(commentId, userId) { + const cId = assertPositiveInt(commentId, "commentId") + const uId = assertPositiveInt(userId, "userId") + + const comment = await prisma.comment.findUnique({ where: { id: cId } }) + if (!comment) throw new Error("Yorum bulunamadı.") + if (comment.userId !== uId) throw new Error("Bu yorumu silme yetkin yok.") + + await prisma.comment.delete({ where: { id: cId } }) + return { message: "Yorum silindi." } +} + +module.exports = { getCommentsByDealId, createComment, deleteComment } diff --git a/services/deal/dealService.js b/services/deal/dealService.js new file mode 100644 index 0000000..da99582 --- /dev/null +++ b/services/deal/dealService.js @@ -0,0 +1,91 @@ +const { PrismaClient } = require("@prisma/client") +const prisma = new PrismaClient() + + +async function getAllDeals(page = 1, limit = 10) { + const skip = (page - 1) * limit + + const [deals, total] = await Promise.all([ + prisma.deal.findMany({ + skip, + take: limit, + include: { user: { select: { username: true } } }, + orderBy: { createdAt: "desc" }, + }), + prisma.deal.count(), + ]) + + return { + page, + total, + totalPages: Math.ceil(total / limit), + results: deals, + } +} + +async function getDealById(id) { + return prisma.deal.findUnique({ + where: { id: Number(id) }, + include: { user: { select: { username: true } } }, + }) +} + +async function createDeal(data, userId) { + return prisma.deal.create({ + data: { + title: data.title, + description: data.description, + url: data.url, + imageUrl: data.imageUrl, + price: data.price, + user: { connect: { id: userId } }, // JWT’den gelen userId burada bağlanır + }, + }) +} + +async function voteDeal(dealId, userId, voteType) { + if (!dealId || !userId || !voteType) throw new Error("Eksik veri") + + const existingVote = await prisma.dealVote.findFirst({ + where: { dealId, userId }, + }) + + if (existingVote) { + await prisma.dealVote.update({ + where: { id: existingVote.id }, + data: { voteType }, + }) + } else { + await prisma.dealVote.create({ + data: { dealId, userId, voteType }, + }) + } + + const upvotes = await prisma.dealVote.count({ + where: { dealId, voteType: "UP" }, + }) + const downvotes = await prisma.dealVote.count({ + where: { dealId, voteType: "DOWN" }, + }) + + const score = upvotes - downvotes + + await prisma.deal.update({ + where: { id: dealId }, + data: { score }, + }) + + return score +} + +async function getVotes(dealId) { + const upvotes = await prisma.dealVote.count({ + where: { dealId: Number(dealId), voteType: "UP" }, + }) + const downvotes = await prisma.dealVote.count({ + where: { dealId: Number(dealId), voteType: "DOWN" }, + }) + return { upvotes, downvotes, score: upvotes - downvotes } +} + +module.exports = { getAllDeals, getDealById, createDeal, voteDeal, getVotes }