feat(reseñas): reseñas de productos de compradores verificados, moderadas
Backend (commerce-backend):
- Módulo product-review (tabla nueva product_review; migración generada con
medusa db:generate, no toca tablas existentes).
- GET /store/reviews?product_id (solo aprobadas), /store/reviews/summary,
GET /store/reviews/me y POST /store/reviews con auth de cliente: exige que
el pedido sea del cliente, no esté cancelado y contenga el producto
("Compra verificada"); 1 reseña por cliente y producto; nace "pending".
- /admin/reviews (+ /:id POST status, DELETE) y página "Reseñas" en el admin
para aprobar/rechazar/borrar.
Storefront (ecommerce):
- /cuenta/pedidos ("Mis pedidos"): historial real + diálogo "Califica tu
compra" (radiogroup accesible, flechas de teclado).
- Ficha: sección de reseñas con "Compra verificada" y estrellas junto al
título; tarjetas: promedio y cantidad (una llamada /api/reviews/summary).
- Accesos: "Mis pedidos y reseñas" en la cuenta y en Ayuda.
Validado en entorno descartable (Postgres/Redis/Meilisearch/Medusa propios):
E2E de API 20/20 (incluye pedido interno por draft order, cancelación y
borrado), E2E de tienda con navegador 15/15, admin "Reseñas" verificado en
build de producción. Revisado por frontend-critic (contraste de estrellas
amber-600, patrón radiogroup).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
This commit is contained in:
1 parent
271ea30f44
commit
cfd9a7ec96
28 files changed
+1276
-2
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||
|
||||
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
|
||||
// estado), para que "Mis pedidos" sepa que productos ya califico y en
|
||||
// que estado de moderacion quedo cada una.
|
||||
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
|
||||
res.json({
|
||||
reviews: mine.map((review) => ({
|
||||
id: review.id,
|
||||
product_id: review.product_id,
|
||||
order_id: review.order_id,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
status: review.status,
|
||||
created_at: review.created_at,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
|
||||
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||
|
||||
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
|
||||
// mas promedio y cantidad. Publica (solo publishable key).
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
|
||||
if (!productId) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
|
||||
}
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const list = await reviews.listProductReviews(
|
||||
{ product_id: productId, status: "approved" },
|
||||
{ order: { created_at: "DESC" }, take: 50 },
|
||||
);
|
||||
const count = list.length;
|
||||
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
|
||||
res.json({
|
||||
reviews: list.map((review) => ({
|
||||
id: review.id,
|
||||
author_name: review.author_name,
|
||||
rating: review.rating,
|
||||
title: review.title,
|
||||
content: review.content,
|
||||
created_at: review.created_at,
|
||||
verified_purchase: true,
|
||||
})),
|
||||
average: Math.round(average * 10) / 10,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
type CreateReviewBody = {
|
||||
product_id?: string;
|
||||
order_id?: string;
|
||||
rating?: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
|
||||
// si el pedido indicado es SUYO, no esta cancelado y contiene el
|
||||
// producto: eso es lo que respalda el sello "Compra verificada". Nace
|
||||
// "pending" hasta que el dueño la apruebe en el admin.
|
||||
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
|
||||
const customerId = req.auth_context.actor_id;
|
||||
const { product_id, order_id, title } = req.body ?? {};
|
||||
const rating = Number(req.body?.rating);
|
||||
const content = (req.body?.content ?? "").trim();
|
||||
|
||||
if (!product_id || !order_id) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
|
||||
}
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
|
||||
}
|
||||
if (content.length < 10 || content.length > 2000) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
|
||||
}
|
||||
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const { data: orders } = await query.graph({
|
||||
entity: "order",
|
||||
fields: ["id", "customer_id", "status", "items.product_id"],
|
||||
filters: { id: order_id },
|
||||
});
|
||||
const order = orders[0];
|
||||
const purchased =
|
||||
order &&
|
||||
order.customer_id === customerId &&
|
||||
order.status !== "canceled" &&
|
||||
(order.items ?? []).some((item) => item?.product_id === product_id);
|
||||
if (!purchased) {
|
||||
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
|
||||
}
|
||||
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
|
||||
if (existing.length > 0) {
|
||||
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
|
||||
}
|
||||
|
||||
const { data: customers } = await query.graph({
|
||||
entity: "customer",
|
||||
fields: ["first_name", "last_name"],
|
||||
filters: { id: customerId },
|
||||
});
|
||||
const customer = customers[0];
|
||||
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
|
||||
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
|
||||
|
||||
const review = await reviews.createProductReviews({
|
||||
product_id,
|
||||
order_id,
|
||||
customer_id: customerId,
|
||||
author_name: authorName,
|
||||
rating,
|
||||
title: title?.trim() || null,
|
||||
content,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
res.status(201).json({ review: { id: review.id, status: review.status } });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||
|
||||
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
|
||||
// por producto, para las estrellas de las tarjetas del listado (una sola
|
||||
// llamada para todo el catalogo en vez de una por tarjeta).
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
|
||||
const summary: Record<string, { average: number; count: number }> = {};
|
||||
for (const review of approved) {
|
||||
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
|
||||
entry.average += review.rating;
|
||||
entry.count += 1;
|
||||
}
|
||||
for (const entry of Object.values(summary)) {
|
||||
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
|
||||
}
|
||||
res.json({ summary });
|
||||
}
|
||||
Reference in new issue
Block a user