Skip to content

Commit dd7eed3

Browse files
Merge pull request #134 from Gezziy/rating-api
feat: Review & Rating API
2 parents 11d49d4 + af0a0f2 commit dd7eed3

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

__tests__/api/reviews.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { POST } from '@/app/api/reviews/route';
3+
import { GET } from '@/app/api/reviews/[userId]/route';
4+
import { sql } from '@/lib/db';
5+
6+
// Mock the db module
7+
vi.mock('@/lib/db', () => {
8+
return {
9+
sql: vi.fn(),
10+
};
11+
});
12+
13+
describe('Reviews API', () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
describe('POST /api/reviews', () => {
19+
it('returns 400 for invalid rating', async () => {
20+
const req = new Request('http://localhost/api/reviews', {
21+
method: 'POST',
22+
body: JSON.stringify({
23+
contractId: 1,
24+
reviewerId: 2,
25+
freelancerId: 3,
26+
rating: 6, // Invalid rating
27+
}),
28+
});
29+
30+
const response = await POST(req);
31+
expect(response.status).toBe(400);
32+
const data = await response.json();
33+
expect(data.error).toBe('Invalid input data');
34+
});
35+
36+
it('returns 404 if contract not found', async () => {
37+
// Mock contract not found
38+
(sql as any).mockResolvedValueOnce([]);
39+
40+
const req = new Request('http://localhost/api/reviews', {
41+
method: 'POST',
42+
body: JSON.stringify({
43+
contractId: 99,
44+
reviewerId: 2,
45+
freelancerId: 3,
46+
rating: 5,
47+
}),
48+
});
49+
50+
const response = await POST(req);
51+
expect(response.status).toBe(404);
52+
const data = await response.json();
53+
expect(data.error).toBe('Contract not found');
54+
});
55+
56+
it('successfully creates a verified review if contract is completed', async () => {
57+
// Mock contract status query (completed)
58+
(sql as any).mockResolvedValueOnce([{ status: 'completed', client_id: 2 }]);
59+
// Mock insert query
60+
(sql as any).mockResolvedValueOnce([{ id: 1, contract_id: 1, verified: true }]);
61+
62+
const req = new Request('http://localhost/api/reviews', {
63+
method: 'POST',
64+
body: JSON.stringify({
65+
contractId: 1,
66+
reviewerId: 2,
67+
freelancerId: 3,
68+
rating: 5,
69+
comment: 'Great work!',
70+
}),
71+
});
72+
73+
const response = await POST(req);
74+
expect(response.status).toBe(201);
75+
const data = await response.json();
76+
expect(data.verified).toBe(true);
77+
expect(sql).toHaveBeenCalledTimes(2);
78+
});
79+
80+
it('returns 409 on unique constraint violation', async () => {
81+
// Mock contract status query
82+
(sql as any).mockResolvedValueOnce([{ status: 'completed', client_id: 2 }]);
83+
// Mock insert query throwing unique violation
84+
const error: any = new Error('Unique constraint');
85+
error.code = '23505';
86+
(sql as any).mockRejectedValueOnce(error);
87+
88+
const req = new Request('http://localhost/api/reviews', {
89+
method: 'POST',
90+
body: JSON.stringify({
91+
contractId: 1,
92+
reviewerId: 2,
93+
freelancerId: 3,
94+
rating: 5,
95+
}),
96+
});
97+
98+
const response = await POST(req);
99+
expect(response.status).toBe(409);
100+
const data = await response.json();
101+
expect(data.error).toBe('One review allowed per contract.');
102+
});
103+
});
104+
105+
describe('GET /api/reviews/[userId]', () => {
106+
it('returns 400 for invalid userId', async () => {
107+
const req = new Request('http://localhost/api/reviews/invalid');
108+
const response = await GET(req, { params: Promise.resolve({ userId: 'invalid' }) });
109+
expect(response.status).toBe(400);
110+
});
111+
112+
it('returns paginated reviews', async () => {
113+
// Mock fetching reviews
114+
const mockRows = [
115+
{ id: 2, contract_id: 2, rating: 4, total_count: '2' },
116+
{ id: 1, contract_id: 1, rating: 5, total_count: '2' },
117+
];
118+
(sql as any).mockResolvedValueOnce(mockRows);
119+
120+
const req = new Request('http://localhost/api/reviews/3?page=1&limit=2');
121+
const response = await GET(req, { params: Promise.resolve({ userId: '3' }) });
122+
expect(response.status).toBe(200);
123+
124+
const data = await response.json();
125+
expect(data.data).toHaveLength(2);
126+
expect(data.data[0]).not.toHaveProperty('total_count'); // ensures mapping worked
127+
expect(data.meta.totalCount).toBe(2);
128+
expect(data.meta.page).toBe(1);
129+
expect(data.meta.totalPages).toBe(1);
130+
expect(sql).toHaveBeenCalledTimes(1);
131+
});
132+
});
133+
});

app/api/reviews/[userId]/route.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { NextResponse } from "next/server";
2+
import { sql } from "@/lib/db";
3+
4+
export async function GET(
5+
req: Request,
6+
{ params }: { params: Promise<{ userId: string }> }
7+
) {
8+
try {
9+
const { userId: userIdStr } = await params;
10+
const userId = parseInt(userIdStr, 10);
11+
12+
if (isNaN(userId) || userId <= 0) {
13+
return NextResponse.json(
14+
{ error: "Invalid userId" },
15+
{ status: 400 }
16+
);
17+
}
18+
19+
const { searchParams } = new URL(req.url);
20+
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
21+
const limit = Math.max(1, parseInt(searchParams.get("limit") || "10", 10));
22+
const offset = (page - 1) * limit;
23+
24+
// Use a single query with COUNT(*) OVER() to fetch both data and total count efficiently
25+
const reviews = (await sql`
26+
SELECT
27+
id, contract_id, reviewer_id, freelancer_id, rating, comment, verified, created_at,
28+
COUNT(*) OVER() AS total_count
29+
FROM reviews
30+
WHERE freelancer_id = ${userId}
31+
ORDER BY created_at DESC
32+
LIMIT ${limit} OFFSET ${offset}
33+
`) as any[];
34+
35+
const totalCount = reviews.length > 0 ? parseInt(reviews[0].total_count, 10) : 0;
36+
37+
// Map over reviews to remove the total_count property from each row
38+
const data = reviews.map(({ total_count, ...review }) => review);
39+
40+
return NextResponse.json({
41+
data,
42+
meta: {
43+
totalCount,
44+
page,
45+
limit,
46+
totalPages: Math.ceil(totalCount / limit)
47+
}
48+
});
49+
50+
} catch (error) {
51+
console.error("Error fetching reviews:", error);
52+
return NextResponse.json(
53+
{ error: "Internal server error" },
54+
{ status: 500 }
55+
);
56+
}
57+
}

app/api/reviews/route.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextResponse } from "next/server";
2+
import { z } from "zod";
3+
import { sql } from "@/lib/db";
4+
5+
const reviewSchema = z.object({
6+
contractId: z.number().int().positive(),
7+
reviewerId: z.number().int().positive(),
8+
freelancerId: z.number().int().positive(),
9+
rating: z.number().int().min(1).max(5),
10+
comment: z.string().optional(),
11+
});
12+
13+
export async function POST(req: Request) {
14+
try {
15+
const body = await req.json();
16+
17+
// Validate request body
18+
const result = reviewSchema.safeParse(body);
19+
if (!result.success) {
20+
return NextResponse.json(
21+
{ error: "Invalid input data", details: result.error.errors },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
const { contractId, reviewerId, freelancerId, rating, comment } = result.data;
27+
28+
// Check if contract exists and get its status
29+
const contractResult = (await sql`
30+
SELECT status, client_id FROM contracts WHERE id = ${contractId}
31+
`) as any[];
32+
33+
if (contractResult.length === 0) {
34+
return NextResponse.json(
35+
{ error: "Contract not found" },
36+
{ status: 404 }
37+
);
38+
}
39+
40+
const contract = contractResult[0];
41+
42+
// Determine if the review is verified (e.g. linked to a completed contract)
43+
const verified = contract.status === "completed";
44+
45+
// Insert the review
46+
const insertResult = (await sql`
47+
INSERT INTO reviews (contract_id, reviewer_id, freelancer_id, rating, comment, verified)
48+
VALUES (${contractId}, ${reviewerId}, ${freelancerId}, ${rating}, ${comment || null}, ${verified})
49+
RETURNING *
50+
`) as any[];
51+
52+
return NextResponse.json(insertResult[0], { status: 201 });
53+
54+
} catch (error: any) {
55+
// Check for Postgres unique constraint violation
56+
if (error.code === '23505') {
57+
return NextResponse.json(
58+
{ error: "One review allowed per contract." },
59+
{ status: 409 }
60+
);
61+
}
62+
63+
console.error("Error creating review:", error);
64+
return NextResponse.json(
65+
{ error: "Internal server error" },
66+
{ status: 500 }
67+
);
68+
}
69+
}

scripts/009-reviews-schema.sql

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- TaskChain Database Schema Update: Reviews API
2+
-- This migration drops the preliminary reviews table and creates a structured one based on Issue #123.
3+
4+
DROP TABLE IF EXISTS reviews CASCADE;
5+
6+
CREATE TABLE reviews (
7+
id SERIAL PRIMARY KEY,
8+
contract_id INTEGER NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
9+
reviewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
10+
freelancer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
11+
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
12+
comment TEXT,
13+
verified BOOLEAN DEFAULT false,
14+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
15+
-- Enforce uniqueness: Only one review allowed per contract
16+
CONSTRAINT uq_reviews_contract UNIQUE (contract_id)
17+
);
18+
19+
CREATE INDEX idx_reviews_freelancer ON reviews(freelancer_id);

0 commit comments

Comments
 (0)