Skip to content

Commit 02f6cb9

Browse files
feat: implement market comment API with pagination, thumbs-up, and admin moderation (#644)
Co-authored-by: Hahfyeez <36053600+Hahfyeex@users.noreply.github.com>
1 parent 591590d commit 02f6cb9

4 files changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Migration: create market_comments table and thumbs-up deduplication table
2+
CREATE TABLE IF NOT EXISTS market_comments (
3+
id SERIAL PRIMARY KEY,
4+
market_id INT REFERENCES markets(id) ON DELETE CASCADE,
5+
wallet_address TEXT NOT NULL,
6+
content VARCHAR(500) NOT NULL,
7+
thumbs_up_count INT NOT NULL DEFAULT 0,
8+
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
9+
created_at TIMESTAMPTZ DEFAULT NOW()
10+
);
11+
12+
CREATE INDEX IF NOT EXISTS idx_market_comments_market_id ON market_comments(market_id, is_hidden, created_at DESC);
13+
14+
CREATE TABLE IF NOT EXISTS comment_thumbs_up (
15+
comment_id INT REFERENCES market_comments(id) ON DELETE CASCADE,
16+
wallet_address TEXT NOT NULL,
17+
created_at TIMESTAMPTZ DEFAULT NOW(),
18+
PRIMARY KEY (comment_id, wallet_address)
19+
);

backend/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ app.use("/api", appCheckMiddleware);
9898
// Routes (MERGED — keep ALL)
9999
app.use("/api/auth", require("./routes/auth"));
100100
app.use("/api/markets", require("./routes/markets"));
101+
app.use("/api/markets/:id/comments", require("./routes/comments"));
102+
app.use("/api/comments", require("./routes/commentActions"));
101103
app.use("/api/bets", require("./routes/bets"));
102104
app.use("/api/notifications", require("./routes/notifications"));
103105
app.use("/api/reserves", require("./routes/reserves"));
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use strict";
2+
3+
const express = require("express");
4+
const router = express.Router();
5+
const db = require("../db");
6+
const logger = require("../utils/logger");
7+
const jwtAuth = require("../middleware/jwtAuth");
8+
9+
// POST /api/comments/:id/thumbs-up (JWT required, one per wallet)
10+
router.post("/:id/thumbs-up", jwtAuth, async (req, res) => {
11+
const commentId = parseInt(req.params.id, 10);
12+
const walletAddress = req.admin?.sub || req.admin?.wallet_address;
13+
14+
try {
15+
// Insert deduplication record — PK constraint prevents duplicates
16+
await db.query("INSERT INTO comment_thumbs_up (comment_id, wallet_address) VALUES ($1, $2)", [
17+
commentId,
18+
walletAddress,
19+
]);
20+
21+
const { rows } = await db.query(
22+
"UPDATE market_comments SET thumbs_up_count = thumbs_up_count + 1 WHERE id = $1 RETURNING thumbs_up_count",
23+
[commentId]
24+
);
25+
26+
if (rows.length === 0) {
27+
return res.status(404).json({ error: "Comment not found" });
28+
}
29+
30+
res.json({ thumbs_up_count: rows[0].thumbs_up_count });
31+
} catch (err) {
32+
if (err.code === "23505") {
33+
return res.status(409).json({ error: "Already thumbed up" });
34+
}
35+
if (err.code === "23503") {
36+
return res.status(404).json({ error: "Comment not found" });
37+
}
38+
logger.error({ err: err.message, commentId }, "Failed to thumbs-up comment");
39+
res.status(500).json({ error: "Internal server error" });
40+
}
41+
});
42+
43+
// DELETE /api/comments/:id (admin JWT required — sets is_hidden = TRUE)
44+
router.delete("/:id", jwtAuth, async (req, res) => {
45+
const commentId = parseInt(req.params.id, 10);
46+
47+
// Require admin role
48+
if (!req.admin?.isAdmin) {
49+
return res.status(403).json({ error: "Admin access required" });
50+
}
51+
52+
try {
53+
const { rows } = await db.query(
54+
"UPDATE market_comments SET is_hidden = TRUE WHERE id = $1 RETURNING id",
55+
[commentId]
56+
);
57+
58+
if (rows.length === 0) {
59+
return res.status(404).json({ error: "Comment not found" });
60+
}
61+
62+
res.json({ success: true });
63+
} catch (err) {
64+
logger.error({ err: err.message, commentId }, "Failed to hide comment");
65+
res.status(500).json({ error: "Internal server error" });
66+
}
67+
});
68+
69+
module.exports = router;

backend/src/tests/comments.test.js

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"use strict";
2+
3+
jest.mock("../db");
4+
jest.mock("../utils/logger", () => ({
5+
info: jest.fn(),
6+
warn: jest.fn(),
7+
error: jest.fn(),
8+
debug: jest.fn(),
9+
}));
10+
jest.mock("firebase-admin", () => ({ apps: [true], initializeApp: jest.fn() }));
11+
jest.mock("../middleware/appCheck", () => (req, res, next) => next());
12+
13+
const request = require("supertest");
14+
const express = require("express");
15+
const jwt = require("jsonwebtoken");
16+
const db = require("../db");
17+
18+
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
19+
20+
function makeToken(payload = {}) {
21+
return jwt.sign({ sub: "WALLET123", ...payload }, JWT_SECRET);
22+
}
23+
24+
function makeAdminToken() {
25+
return jwt.sign({ sub: "ADMIN_WALLET", isAdmin: true }, JWT_SECRET);
26+
}
27+
28+
const commentsRouter = require("../routes/comments");
29+
const commentActionsRouter = require("../routes/commentActions");
30+
31+
const app = express();
32+
app.use(express.json());
33+
app.use("/api/markets/:id/comments", commentsRouter);
34+
app.use("/api/comments", commentActionsRouter);
35+
36+
const makeComment = (id, overrides = {}) => ({
37+
id,
38+
market_id: 1,
39+
wallet_address: "WALLET123",
40+
content: "Test comment",
41+
thumbs_up_count: 0,
42+
created_at: new Date().toISOString(),
43+
...overrides,
44+
});
45+
46+
describe("Market Comments API", () => {
47+
beforeEach(() => jest.clearAllMocks());
48+
49+
// ── GET /api/markets/:id/comments ──────────────────────────────────────────
50+
describe("GET /api/markets/:id/comments", () => {
51+
it("returns paginated non-hidden comments", async () => {
52+
const comments = [makeComment(1), makeComment(2)];
53+
db.query
54+
.mockResolvedValueOnce({ rows: comments })
55+
.mockResolvedValueOnce({ rows: [{ total: "2" }] });
56+
57+
const res = await request(app).get("/api/markets/1/comments");
58+
59+
expect(res.status).toBe(200);
60+
expect(res.body.comments).toHaveLength(2);
61+
expect(res.body.meta).toMatchObject({ page: 0, pageSize: 20, total: 2 });
62+
});
63+
64+
it("uses page query param for offset", async () => {
65+
db.query
66+
.mockResolvedValueOnce({ rows: [] })
67+
.mockResolvedValueOnce({ rows: [{ total: "0" }] });
68+
69+
await request(app).get("/api/markets/1/comments?page=2");
70+
71+
expect(db.query).toHaveBeenNthCalledWith(
72+
1,
73+
expect.stringContaining("OFFSET $3"),
74+
[1, 20, 40]
75+
);
76+
});
77+
78+
it("returns 500 on db error", async () => {
79+
db.query.mockRejectedValueOnce(new Error("DB down"));
80+
const res = await request(app).get("/api/markets/1/comments");
81+
expect(res.status).toBe(500);
82+
});
83+
});
84+
85+
// ── POST /api/markets/:id/comments ────────────────────────────────────────
86+
describe("POST /api/markets/:id/comments", () => {
87+
it("creates a comment with valid content", async () => {
88+
const comment = makeComment(1);
89+
db.query.mockResolvedValueOnce({ rows: [comment] });
90+
91+
const res = await request(app)
92+
.post("/api/markets/1/comments")
93+
.set("Authorization", `Bearer ${makeToken()}`)
94+
.send({ content: "Hello world" });
95+
96+
expect(res.status).toBe(201);
97+
expect(res.body.comment).toMatchObject({ id: 1 });
98+
});
99+
100+
it("rejects missing content", async () => {
101+
const res = await request(app)
102+
.post("/api/markets/1/comments")
103+
.set("Authorization", `Bearer ${makeToken()}`)
104+
.send({});
105+
expect(res.status).toBe(400);
106+
expect(res.body.error).toMatch(/required/i);
107+
});
108+
109+
it("rejects content over 500 chars", async () => {
110+
const res = await request(app)
111+
.post("/api/markets/1/comments")
112+
.set("Authorization", `Bearer ${makeToken()}`)
113+
.send({ content: "x".repeat(501) });
114+
expect(res.status).toBe(400);
115+
expect(res.body.error).toMatch(/500/);
116+
});
117+
118+
it("rejects empty string content", async () => {
119+
const res = await request(app)
120+
.post("/api/markets/1/comments")
121+
.set("Authorization", `Bearer ${makeToken()}`)
122+
.send({ content: " " });
123+
expect(res.status).toBe(400);
124+
});
125+
126+
it("requires JWT", async () => {
127+
const res = await request(app).post("/api/markets/1/comments").send({ content: "Hello" });
128+
expect(res.status).toBe(401);
129+
});
130+
131+
it("rejects invalid JWT", async () => {
132+
const res = await request(app)
133+
.post("/api/markets/1/comments")
134+
.set("Authorization", "Bearer invalid.token.here")
135+
.send({ content: "Hello" });
136+
expect(res.status).toBe(401);
137+
});
138+
});
139+
140+
// ── POST /api/comments/:id/thumbs-up ──────────────────────────────────────
141+
describe("POST /api/comments/:id/thumbs-up", () => {
142+
it("increments thumbs_up_count", async () => {
143+
db.query
144+
.mockResolvedValueOnce({ rows: [] }) // insert dedup
145+
.mockResolvedValueOnce({ rows: [{ thumbs_up_count: 1 }] }); // update
146+
147+
const res = await request(app)
148+
.post("/api/comments/1/thumbs-up")
149+
.set("Authorization", `Bearer ${makeToken()}`);
150+
151+
expect(res.status).toBe(200);
152+
expect(res.body.thumbs_up_count).toBe(1);
153+
});
154+
155+
it("returns 409 on duplicate thumbs-up", async () => {
156+
const dupErr = new Error("duplicate");
157+
dupErr.code = "23505";
158+
db.query.mockRejectedValueOnce(dupErr);
159+
160+
const res = await request(app)
161+
.post("/api/comments/1/thumbs-up")
162+
.set("Authorization", `Bearer ${makeToken()}`);
163+
164+
expect(res.status).toBe(409);
165+
expect(res.body.error).toMatch(/already/i);
166+
});
167+
168+
it("returns 404 when comment not found", async () => {
169+
db.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); // no rows from UPDATE
170+
171+
const res = await request(app)
172+
.post("/api/comments/999/thumbs-up")
173+
.set("Authorization", `Bearer ${makeToken()}`);
174+
175+
expect(res.status).toBe(404);
176+
});
177+
178+
it("requires JWT", async () => {
179+
const res = await request(app).post("/api/comments/1/thumbs-up");
180+
expect(res.status).toBe(401);
181+
});
182+
});
183+
184+
// ── DELETE /api/comments/:id ──────────────────────────────────────────────
185+
describe("DELETE /api/comments/:id", () => {
186+
it("sets is_hidden = TRUE (admin only)", async () => {
187+
db.query.mockResolvedValueOnce({ rows: [{ id: 1 }] });
188+
189+
const res = await request(app)
190+
.delete("/api/comments/1")
191+
.set("Authorization", `Bearer ${makeAdminToken()}`);
192+
193+
expect(res.status).toBe(200);
194+
expect(res.body.success).toBe(true);
195+
expect(db.query).toHaveBeenCalledWith(expect.stringContaining("is_hidden = TRUE"), [1]);
196+
});
197+
198+
it("returns 403 for non-admin JWT", async () => {
199+
const res = await request(app)
200+
.delete("/api/comments/1")
201+
.set("Authorization", `Bearer ${makeToken()}`);
202+
expect(res.status).toBe(403);
203+
});
204+
205+
it("returns 404 when comment not found", async () => {
206+
db.query.mockResolvedValueOnce({ rows: [] });
207+
208+
const res = await request(app)
209+
.delete("/api/comments/999")
210+
.set("Authorization", `Bearer ${makeAdminToken()}`);
211+
212+
expect(res.status).toBe(404);
213+
});
214+
215+
it("requires JWT", async () => {
216+
const res = await request(app).delete("/api/comments/1");
217+
expect(res.status).toBe(401);
218+
});
219+
});
220+
});

0 commit comments

Comments
 (0)