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
@@ -133,6 +133,11 @@ module.exports = defineConfig({
|
||||
},
|
||||
|
||||
modules: [
|
||||
// Reseñas de productos (compradores verificados + moderacion del
|
||||
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
|
||||
{
|
||||
resolve: "./src/modules/product-review",
|
||||
},
|
||||
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
||||
// un modulo de fulfillment con el provider manual_manual
|
||||
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||
import { Star } from "@medusajs/icons";
|
||||
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { sdk } from "../../lib/sdk";
|
||||
|
||||
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
|
||||
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
|
||||
// recien se publican en la tienda cuando se aprueban aca.
|
||||
|
||||
type Status = "pending" | "approved" | "rejected";
|
||||
|
||||
type Review = {
|
||||
id: string;
|
||||
product_title: string;
|
||||
author_name: string;
|
||||
rating: number;
|
||||
title: string | null;
|
||||
content: string;
|
||||
status: Status;
|
||||
order_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const TABS: { status: Status; label: string }[] = [
|
||||
{ status: "pending", label: "Pendientes" },
|
||||
{ status: "approved", label: "Publicadas" },
|
||||
{ status: "rejected", label: "Rechazadas" },
|
||||
];
|
||||
|
||||
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
|
||||
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
|
||||
|
||||
function stars(rating: number): string {
|
||||
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
}
|
||||
|
||||
const ReviewsPage = () => {
|
||||
const [tab, setTab] = useState<Status>("pending");
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (status: Status) => {
|
||||
setLoading(true);
|
||||
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
|
||||
setReviews(data.reviews);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(tab);
|
||||
}, [load, tab]);
|
||||
|
||||
async function setStatus(review: Review, status: Status) {
|
||||
setBusyId(review.id);
|
||||
try {
|
||||
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
|
||||
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
|
||||
await load(tab);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(review: Review) {
|
||||
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
|
||||
setBusyId(review.id);
|
||||
try {
|
||||
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
|
||||
toast.success("Reseña borrada");
|
||||
await load(tab);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h2">Reseñas</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{TABS.map((item) => (
|
||||
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="px-6 py-8"><Text>Cargando…</Text></div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Text weight="plus">{review.product_title}</Text>
|
||||
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
|
||||
</div>
|
||||
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
|
||||
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
|
||||
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
|
||||
<Text size="small" className="mt-2 text-ui-fg-subtle">
|
||||
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{review.status !== "approved" && (
|
||||
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
|
||||
)}
|
||||
{review.status !== "rejected" && (
|
||||
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
|
||||
)}
|
||||
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Reseñas",
|
||||
icon: Star,
|
||||
});
|
||||
|
||||
export default ReviewsPage;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { MedusaError } from "@medusajs/framework/utils";
|
||||
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||
|
||||
const STATUSES = ["pending", "approved", "rejected"] as const;
|
||||
|
||||
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
|
||||
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
|
||||
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
|
||||
const status = req.body?.status;
|
||||
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
|
||||
}
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
|
||||
res.json({ review });
|
||||
}
|
||||
|
||||
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
await reviews.deleteProductReviews(req.params.id);
|
||||
res.json({ id: req.params.id, deleted: true });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||
|
||||
// GET /admin/reviews?status=pending -- cola de moderacion para la
|
||||
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
|
||||
order: { created_at: "DESC" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const productIds = [...new Set(list.map((review) => review.product_id))];
|
||||
const titles: Record<string, string> = {};
|
||||
if (productIds.length) {
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
|
||||
for (const product of products) titles[product.id] = product.title;
|
||||
}
|
||||
|
||||
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineMiddlewares } from "@medusajs/medusa"
|
||||
import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
|
||||
|
||||
// GET /store/products descarta en silencio la relacion `categories`
|
||||
// aunque `/admin/products` si la expone -- confirmado en vivo en la
|
||||
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
|
||||
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
|
||||
export default defineMiddlewares({
|
||||
routes: [
|
||||
// Reseñas: crear una y ver las propias exige cliente autenticado --
|
||||
// la verificacion de compra usa auth_context.actor_id (ver
|
||||
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
|
||||
// siguen publicos.
|
||||
{
|
||||
matcher: "/store/reviews",
|
||||
method: ["POST"],
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
{
|
||||
matcher: "/store/reviews/me",
|
||||
method: ["GET"],
|
||||
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||
},
|
||||
{
|
||||
matcher: "/store/products",
|
||||
middlewares: [
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
import ProductReviewModuleService from "./service"
|
||||
|
||||
export const PRODUCT_REVIEW_MODULE = "productReview"
|
||||
|
||||
export default Module(PRODUCT_REVIEW_MODULE, {
|
||||
service: ProductReviewModuleService,
|
||||
})
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"public"
|
||||
],
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": true,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"order_id": {
|
||||
"name": "order_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "'pending'",
|
||||
"comment": null,
|
||||
"enumItems": [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected"
|
||||
],
|
||||
"mappedType": "enum"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "now()",
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "now()",
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "product_review",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_product_review_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_product_review_product_id_status",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_product_review_customer_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_product_review_product_id_customer_id_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "product_review_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||
|
||||
export class Migration20260927062845 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
|
||||
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop table if exists "product_review" cascade;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
|
||||
// solo compradores verificados -- customer_id + order_id se validan en
|
||||
// POST /store/reviews contra un pedido real del cliente que contenga el
|
||||
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
|
||||
// dueño las aprueba desde el admin (pagina "Reseñas").
|
||||
export const ProductReview = model
|
||||
.define("product_review", {
|
||||
id: model.id({ prefix: "prev" }).primaryKey(),
|
||||
product_id: model.text(),
|
||||
customer_id: model.text(),
|
||||
order_id: model.text(),
|
||||
author_name: model.text(),
|
||||
rating: model.number(),
|
||||
title: model.text().nullable(),
|
||||
content: model.text(),
|
||||
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
|
||||
})
|
||||
.indexes([
|
||||
{ on: ["product_id", "status"] },
|
||||
{ on: ["customer_id"] },
|
||||
// Una reseña por cliente por producto.
|
||||
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
|
||||
])
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MedusaService } from "@medusajs/framework/utils"
|
||||
import { ProductReview } from "./models/product-review"
|
||||
|
||||
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
|
||||
|
||||
export default ProductReviewModuleService
|
||||
Reference in new issue
Block a user