feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,115 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { ReviewsService } from '../application/reviews-service.js';
import {
DuplicateReviewError,
InvalidReviewError,
ReviewNotEligibleError,
ReviewNotFoundError,
} from '../domain/errors.js';
import { PgOrderItemVerifier } from '../infrastructure/pg-order-item-verifier.js';
import { PgReviewsRepository } from '../infrastructure/pg-reviews-repository.js';
export interface ReviewsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const submitSchema = z
.object({
productId: z.uuid(),
orderItemId: z.uuid(),
rating: z.number().int().min(1).max(5),
title: z.string().min(1).max(200),
body: z.string().min(1).max(5000),
})
.strip();
const moderateSchema = z.object({ status: z.enum(['published', 'rejected']) });
const moderateParamSchema = z.object({ id: z.uuid() });
const productIdQuerySchema = z.object({ productId: z.uuid() });
const adminListSchema = z.object({
status: z.enum(['pending', 'published', 'rejected']).optional(),
limit: z.coerce.number().int().positive().max(100).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
});
export async function registerReviewsRoutes(
app: FastifyInstance,
deps: ReviewsRoutesDeps,
): Promise<void> {
const repository = new PgReviewsRepository(deps.pool);
const verifier = new PgOrderItemVerifier(deps.pool);
const service = new ReviewsService(repository, verifier);
app.post('/reviews', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(submitSchema, request.body);
try {
const review = await service.submit({ ...input, userId: user.id });
return reply.code(201).send(serializeReview(review));
} catch (error) {
throw mapError(error);
}
});
app.patch('/reviews/:id/moderate', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = moderateParamSchema.parse(request.params);
const { status } = moderateSchema.parse(request.body);
try {
const review = await service.moderate(id, status);
return reply.send(serializeReview(review));
} catch (error) {
throw mapError(error);
}
});
app.get('/reviews', async (request, reply) => {
const { productId } = productIdQuerySchema.parse(request.query);
const result = await service.listPublishedByProduct(productId);
return reply.send(result);
});
app.get('/reviews/admin', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { status, limit, offset } = adminListSchema.parse(request.query ?? {});
const result = await service.listAll({ status, limit, offset });
return reply.send(result);
});
}
function mapError(error: unknown): Error {
if (error instanceof DuplicateReviewError)
return new AppError(409, 'REVIEW_DUPLICATE', error.message);
if (error instanceof ReviewNotEligibleError)
return new AppError(403, 'REVIEW_NOT_ELIGIBLE', error.message);
if (error instanceof ReviewNotFoundError)
return new AppError(404, 'REVIEW_NOT_FOUND', error.message);
if (error instanceof InvalidReviewError)
return new AppError(422, 'INVALID_REVIEW', error.message);
return error instanceof Error ? error : new Error('Unknown review error');
}
function serializeReview(review: ReturnType<typeof Object>) {
return {
id: (review as { id: string }).id,
productId: (review as { productId: string }).productId,
userId: (review as { userId: string }).userId,
orderId: (review as { orderId: string }).orderId,
orderItemId: (review as { orderItemId: string }).orderItemId,
rating: (review as { rating: number }).rating,
title: (review as { title: string }).title,
body: (review as { body: string }).body,
status: (review as { status: string }).status,
createdAt: (review as { createdAt: Date }).createdAt.toISOString(),
updatedAt: (review as { updatedAt: Date }).updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,51 @@
import {
DuplicateReviewError,
InvalidReviewError,
ReviewNotEligibleError,
ReviewNotFoundError,
} from '../domain/errors.js';
import type { OrderItemVerifier, ReviewsRepository } from '../domain/ports.js';
import type {
ProductReviewAggregate,
PublishedReview,
Review,
SubmitReviewCommand,
} from '../domain/review.js';
export class ReviewsService {
constructor(
private readonly repo: ReviewsRepository,
private readonly verifier: OrderItemVerifier,
) {}
async submit(command: SubmitReviewCommand): Promise<Review> {
if (!Number.isInteger(command.rating) || command.rating < 1 || command.rating > 5) {
throw new InvalidReviewError('Rating must be an integer 1..5');
}
const existing = await this.repo.findByOrderItemId(command.orderItemId);
if (existing) throw new DuplicateReviewError();
const eligible = await this.verifier.isDeliveredForUser(command.userId, command.orderItemId);
if (!eligible) throw new ReviewNotEligibleError();
const inserted = await this.repo.insert(command);
if (!inserted) throw new DuplicateReviewError();
return inserted;
}
async moderate(id: string, status: 'published' | 'rejected'): Promise<Review> {
const updated = await this.repo.setStatus(id, status);
if (!updated) throw new ReviewNotFoundError();
return updated;
}
async listPublishedByProduct(
productId: string,
): Promise<{ items: PublishedReview[]; aggregate: ProductReviewAggregate }> {
const items = await this.repo.listPublishedByProduct(productId);
const aggregate = await this.repo.aggregate(productId);
return { items, aggregate };
}
async listAll(params?: { status?: string; limit?: number; offset?: number }) {
return this.repo.listAll(params);
}
}

View File

@@ -0,0 +1,27 @@
export class ReviewNotEligibleError extends Error {
constructor() {
super('Only delivered order items can be reviewed');
this.name = 'ReviewNotEligibleError';
}
}
export class DuplicateReviewError extends Error {
constructor() {
super('A review already exists for this order item');
this.name = 'DuplicateReviewError';
}
}
export class ReviewNotFoundError extends Error {
constructor() {
super('Review not found');
this.name = 'ReviewNotFoundError';
}
}
export class InvalidReviewError extends Error {
constructor(message = 'Invalid review') {
super(message);
this.name = 'InvalidReviewError';
}
}

View File

@@ -0,0 +1,19 @@
import type {
ProductReviewAggregate,
PublishedReview,
Review,
SubmitReviewCommand,
} from './review.js';
export interface ReviewsRepository {
insert(review: SubmitReviewCommand): Promise<Review | undefined>;
setStatus(id: string, status: 'published' | 'rejected'): Promise<Review | undefined>;
listPublishedByProduct(productId: string): Promise<PublishedReview[]>;
listAll(params?: { status?: string; limit?: number; offset?: number }): Promise<{ items: Review[]; total: number }>;
aggregate(productId: string): Promise<ProductReviewAggregate>;
findByOrderItemId(orderItemId: string): Promise<Review | undefined>;
}
export interface OrderItemVerifier {
isDeliveredForUser(userId: string, orderItemId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,40 @@
export type ReviewStatus = 'pending' | 'published' | 'rejected';
export interface Review {
id: string;
userId: string;
productId: string;
orderId: string;
orderItemId: string;
rating: number;
title: string;
body: string;
status: ReviewStatus;
createdAt: Date;
updatedAt: Date;
}
export interface SubmitReviewCommand {
userId: string;
productId: string;
orderItemId: string;
rating: number;
title: string;
body: string;
}
export interface ProductReviewAggregate {
productId: string;
averageRating: number;
count: number;
}
export interface PublishedReview {
id: string;
productId: string;
userId: string;
rating: number;
title: string;
body: string;
createdAt: Date;
}

View File

@@ -0,0 +1,19 @@
/** Public API of the reviews module. */
export { registerReviewsRoutes, type ReviewsRoutesDeps } from './api/reviews.routes.js';
export { ReviewsService } from './application/reviews-service.js';
export { PgReviewsRepository } from './infrastructure/pg-reviews-repository.js';
export { PgOrderItemVerifier } from './infrastructure/pg-order-item-verifier.js';
export {
ReviewNotEligibleError,
DuplicateReviewError,
ReviewNotFoundError,
InvalidReviewError,
} from './domain/errors.js';
export type { ReviewsRepository, OrderItemVerifier } from './domain/ports.js';
export type {
Review,
ReviewStatus,
SubmitReviewCommand,
PublishedReview,
ProductReviewAggregate,
} from './domain/review.js';

View File

@@ -0,0 +1,22 @@
import type pg from 'pg';
import type { OrderItemVerifier } from '../domain/ports.js';
/**
* Verifies that an order item is delivered and owned by the user. Order items
* are considered delivered when their order state is DELIVERED.
*/
export class PgOrderItemVerifier implements OrderItemVerifier {
constructor(private readonly pool: pg.Pool) {}
async isDeliveredForUser(userId: string, orderItemId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM orders_items oi
JOIN orders_orders oo ON oo.id = oi.order_id
WHERE oi.id = $1 AND oo.user_id = $2 AND oo.state = 'DELIVERED'
) AS exists`,
[orderItemId, userId],
);
return result.rows[0]?.exists ?? false;
}
}

View File

@@ -0,0 +1,148 @@
import type pg from 'pg';
import type { ReviewsRepository } from '../domain/ports.js';
import type {
ProductReviewAggregate,
PublishedReview,
Review,
ReviewStatus,
SubmitReviewCommand,
} from '../domain/review.js';
interface ReviewRow {
id: string;
user_id: string;
product_id: string;
order_id: string;
order_item_id: string;
rating: number;
title: string;
body: string;
status: ReviewStatus;
created_at: Date;
updated_at: Date;
}
export class PgReviewsRepository implements ReviewsRepository {
constructor(private readonly pool: pg.Pool) {}
async insert(command: SubmitReviewCommand): Promise<Review | undefined> {
const result = await this.pool.query<ReviewRow>(
`INSERT INTO reviews_reviews (user_id, product_id, order_id, order_item_id, rating, title, body)
VALUES ($1, $2, (SELECT order_id FROM orders_items WHERE id = $3), $3, $4, $5, $6)
ON CONFLICT (order_item_id) DO NOTHING
RETURNING *`,
[
command.userId,
command.productId,
command.orderItemId,
command.rating,
command.title,
command.body,
],
);
const row = result.rows[0];
return row ? toReview(row) : undefined;
}
async setStatus(id: string, status: 'published' | 'rejected'): Promise<Review | undefined> {
const result = await this.pool.query<ReviewRow>(
`UPDATE reviews_reviews SET status = $2, updated_at = now() WHERE id = $1 RETURNING *`,
[id, status],
);
const row = result.rows[0];
return row ? toReview(row) : undefined;
}
async listPublishedByProduct(productId: string): Promise<PublishedReview[]> {
const result = await this.pool.query<ReviewRow>(
`SELECT * FROM reviews_reviews WHERE product_id = $1 AND status = 'published' ORDER BY created_at DESC`,
[productId],
);
return result.rows.map(toPublishedReview);
}
async aggregate(productId: string): Promise<ProductReviewAggregate> {
const result = await this.pool.query<{ product_id: string; average: string; count: string }>(
`SELECT $1::uuid AS product_id,
COALESCE(AVG(rating), 0)::float AS average,
COUNT(*)::int AS count
FROM reviews_reviews WHERE product_id = $1 AND status = 'published'`,
[productId],
);
const row = result.rows[0];
return {
productId: row?.product_id ?? productId,
averageRating: Number(row?.average ?? 0),
count: Number(row?.count ?? 0),
};
}
async findByOrderItemId(orderItemId: string): Promise<Review | undefined> {
const result = await this.pool.query<ReviewRow>(
'SELECT * FROM reviews_reviews WHERE order_item_id = $1',
[orderItemId],
);
const row = result.rows[0];
return row ? toReview(row) : undefined;
}
async listAll(params?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<{ items: Review[]; total: number }> {
const conditions: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (params?.status) {
conditions.push(`status = $${idx++}`);
values.push(params.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const limit = params?.limit ?? 20;
const offset = params?.offset ?? 0;
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM reviews_reviews ${where}`,
values,
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const result = await this.pool.query<ReviewRow>(
`SELECT * FROM reviews_reviews ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
[...values, limit, offset],
);
return { items: result.rows.map(toReview), total };
}
}
function toReview(row: ReviewRow): Review {
return {
id: row.id,
userId: row.user_id,
productId: row.product_id,
orderId: row.order_id,
orderItemId: row.order_item_id,
rating: row.rating,
title: row.title,
body: row.body,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toPublishedReview(row: ReviewRow): PublishedReview {
return {
id: row.id,
productId: row.product_id,
userId: row.user_id,
rating: row.rating,
title: row.title,
body: row.body,
createdAt: row.created_at,
};
}

View File

@@ -0,0 +1,23 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('reviews persistence boundary', () => {
it('does not own other module tables', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
if (!file.endsWith('.ts') || file.endsWith('boundary.test.ts')) continue;
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/catalog_|inventory_|pricing_|shipping_|promotions_|cart_|users_addresses/,
);
}
});
});

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { ReviewsService } from '../application/reviews-service.js';
import {
DuplicateReviewError,
InvalidReviewError,
ReviewNotEligibleError,
ReviewNotFoundError,
} from '../domain/errors.js';
import type { OrderItemVerifier, ReviewsRepository } from '../domain/ports.js';
import type {
ProductReviewAggregate,
PublishedReview,
Review,
SubmitReviewCommand,
} from '../domain/review.js';
const REVIEW: Review = {
id: 'review-1',
userId: 'user-1',
productId: 'product-1',
orderId: 'order-1',
orderItemId: 'item-1',
rating: 5,
title: 'Great',
body: 'Excellent product',
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
function repo(overrides: Partial<ReviewsRepository> = {}): ReviewsRepository {
return {
insert: async () => REVIEW,
setStatus: async () => ({ ...REVIEW, status: 'published' }),
listPublishedByProduct: async () => [],
listAll: async () => ({ items: [], total: 0 }),
aggregate: async () =>
({ productId: 'product-1', averageRating: 0, count: 0 }) satisfies ProductReviewAggregate,
findByOrderItemId: async () => undefined,
...overrides,
};
}
function verifier(eligible: boolean): OrderItemVerifier {
return { isDeliveredForUser: async () => eligible };
}
const SUBMIT: SubmitReviewCommand = {
userId: 'user-1',
productId: 'product-1',
orderItemId: 'item-1',
rating: 5,
title: 'Great',
body: 'Excellent product',
};
describe('ReviewsService', () => {
it('rejects submission when order item is not delivered (AC1)', async () => {
const service = new ReviewsService(repo(), verifier(false));
await expect(service.submit(SUBMIT)).rejects.toBeInstanceOf(ReviewNotEligibleError);
});
it('rejects duplicate review (AC2)', async () => {
const service = new ReviewsService(
repo({ findByOrderItemId: async () => REVIEW }),
verifier(true),
);
await expect(service.submit(SUBMIT)).rejects.toBeInstanceOf(DuplicateReviewError);
});
it('rejects invalid rating', async () => {
const service = new ReviewsService(repo(), verifier(true));
await expect(service.submit({ ...SUBMIT, rating: 6 })).rejects.toBeInstanceOf(
InvalidReviewError,
);
});
it('creates review when eligible', async () => {
const service = new ReviewsService(repo(), verifier(true));
const review = await service.submit(SUBMIT);
expect(review.id).toBe('review-1');
});
it('moderates review and throws when missing', async () => {
const service = new ReviewsService(repo({ setStatus: async () => undefined }), verifier(true));
await expect(service.moderate('missing', 'published')).rejects.toBeInstanceOf(
ReviewNotFoundError,
);
});
it('returns published reviews with aggregate', async () => {
const aggregate: ProductReviewAggregate = {
productId: 'product-1',
averageRating: 4.5,
count: 2,
};
const items: PublishedReview[] = [
{
id: REVIEW.id,
productId: REVIEW.productId,
userId: REVIEW.userId,
rating: REVIEW.rating,
title: REVIEW.title,
body: REVIEW.body,
createdAt: REVIEW.createdAt,
},
];
const service = new ReviewsService(
repo({ listPublishedByProduct: async () => items, aggregate: async () => aggregate }),
verifier(true),
);
const result = await service.listPublishedByProduct('product-1');
expect(result).toEqual({ items, aggregate });
});
});