Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions prisma/migrations/20260417000531_add_reports_tables/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
-- CreateEnum
CREATE TYPE "ReviewReportReason" AS ENUM ('SPAM', 'OFFENSIVE', 'FAKE', 'OTHER');

-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'RESOLVED', 'REJECTED');

-- CreateEnum
CREATE TYPE "ProductReportReason" AS ENUM ('DEFECTIVE', 'LATE_DELIVERY', 'CUSTOMER_SERVICE', 'WRONG_ITEM', 'MISSING_ITEM', 'OTHER');

-- CreateTable
CREATE TABLE "ReviewReports" (
"id_review_report" SERIAL NOT NULL,
"fk_product_review" INTEGER NOT NULL,
"fk_reporter" INTEGER NOT NULL,
"reason" "ReviewReportReason" NOT NULL,
"description" TEXT,
"report_status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"resolved_by" INTEGER,
"resolved_at" TIMESTAMPTZ,
"status" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,

CONSTRAINT "ReviewReports_pkey" PRIMARY KEY ("id_review_report")
);

-- CreateTable
CREATE TABLE "ProductReports" (
"id_product_report" SERIAL NOT NULL,
"fk_product" INTEGER NOT NULL,
"fk_reporter" INTEGER NOT NULL,
"fk_order" INTEGER,
"reason" "ProductReportReason" NOT NULL,
"description" TEXT,
"report_status" "ReportStatus" NOT NULL DEFAULT 'PENDING',
"notes" TEXT,
"resolved_by" INTEGER,
"resolved_at" TIMESTAMPTZ,
"status" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,

CONSTRAINT "ProductReports_pkey" PRIMARY KEY ("id_product_report")
);

-- CreateIndex
CREATE UNIQUE INDEX "ReviewReports_fk_product_review_fk_reporter_key" ON "ReviewReports"("fk_product_review", "fk_reporter");

-- AddForeignKey
ALTER TABLE "ReviewReports" ADD CONSTRAINT "ReviewReports_fk_product_review_fkey" FOREIGN KEY ("fk_product_review") REFERENCES "ProductReviews"("id_product_review") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ReviewReports" ADD CONSTRAINT "ReviewReports_fk_reporter_fkey" FOREIGN KEY ("fk_reporter") REFERENCES "Users"("id_user") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ReviewReports" ADD CONSTRAINT "ReviewReports_resolved_by_fkey" FOREIGN KEY ("resolved_by") REFERENCES "Users"("id_user") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProductReports" ADD CONSTRAINT "ProductReports_fk_product_fkey" FOREIGN KEY ("fk_product") REFERENCES "Products"("id_product") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProductReports" ADD CONSTRAINT "ProductReports_fk_reporter_fkey" FOREIGN KEY ("fk_reporter") REFERENCES "Users"("id_user") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProductReports" ADD CONSTRAINT "ProductReports_fk_order_fkey" FOREIGN KEY ("fk_order") REFERENCES "Orders"("id_order") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "ProductReports" ADD CONSTRAINT "ProductReports_resolved_by_fkey" FOREIGN KEY ("resolved_by") REFERENCES "Users"("id_user") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ReportStatus" ADD VALUE 'IN_PROGRESS';
73 changes: 73 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ model Users {
carts Carts[] //Usuario puede generar multiples carritos (Un carrito por tienda)
notifications Notifications[] //Usuario puede recibir varias notificaciones
product_reviews ProductReviews[] //Usuario puede dejar multiples resenas en distintos productos
product_review_reports ReviewReports[] @relation("ReviewReporter")//Usuario puede reportar multiples reseñas
reviewsResolved ReviewReports[] @relation("ReviewResolver") //Usuario admin puede resolver multiples reportes de reseñas
product_reports ProductReports[] @relation("ProductReporter") //Usuario puede reportar multiples productos
productsResolved ProductReports[] @relation("ProductReportResolver") //Usuario seller puede resolver multiples reportes de productos
}

//------------------------------------NOTIFICACIONES----------------------------------------
Expand Down Expand Up @@ -154,6 +158,7 @@ model Products {
wishlist_items WishlistItems[]
product_reviews ProductReviews[]
cart_items CartItems[]
product_reports ProductReports[]
}

//------------------------------------PRODUCTOS-CATEGORIAS----------------------------------------
Expand Down Expand Up @@ -231,6 +236,50 @@ model ProductReviews {

user Users @relation(fields: [fk_user], references: [id_user])
product Products @relation(fields: [fk_product], references: [id_product])
review_reports ReviewReports[]
}

//------------------------------------REPORTES-PRODUCTOS/RESEÑAS----------------------------------------

model ReviewReports {
id_review_report Int @id @default(autoincrement())
fk_product_review Int //reseña que se reporta
fk_reporter Int //usuario que reporta la reseña
reason ReviewReportReason // SPAM, OFFENSIVE, FAKE, OTHER
description String? @db.Text // descripcion del motivo si elige OTHER
report_status ReportStatus @default(PENDING) //PENDING, RESOLVED, REJECTED
resolved_by Int? //id del admin que resolvio el reporte
resolved_at DateTime? @db.Timestamptz //cuando se resolvio el reporte
status Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @updatedAt @db.Timestamptz

product_review ProductReviews @relation(fields: [fk_product_review], references: [id_product_review])
reporter Users @relation("ReviewReporter", fields: [fk_reporter], references: [id_user])
resolver Users? @relation("ReviewResolver", fields: [resolved_by], references: [id_user])

@@unique([fk_product_review, fk_reporter]) //Para que un mismo usuario no pueda reportar la misma reseña multiples veces
}

model ProductReports {
id_product_report Int @id @default(autoincrement())
fk_product Int
fk_reporter Int
fk_order Int?
reason ProductReportReason // DEFECTIVE, LATE_DELIVERY, CUSTOMER_SERVICE, WRONG_ITEM, OTHER
description String? @db.Text
report_status ReportStatus @default(PENDING) //PENDING, RESOLVED, REJECTED
notes String? @db.Text //nota de como se resolvio el reporte
resolved_by Int? //id del admin que resolvio el reporte
resolved_at DateTime? @db.Timestamptz //cuando se resolvio el reporte
status Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @updatedAt @db.Timestamptz

product Products @relation(fields: [fk_product], references: [id_product])
reporter Users @relation("ProductReporter", fields: [fk_reporter], references: [id_user])
order Orders? @relation(fields: [fk_order], references: [id_order])
resolver Users? @relation("ProductReportResolver", fields: [resolved_by], references: [id_user])
}

//------------------------------------LISTAS-DESEOS----------------------------------------
Expand Down Expand Up @@ -343,6 +392,7 @@ model Orders {
address Addresses? @relation(fields: [fk_address], references: [id_address])
order_items OrderItems[]
delivery_assignment DeliveryAssignments?
product_reports ProductReports[]
}

model OrderItems {
Expand Down Expand Up @@ -395,4 +445,27 @@ enum StoreStatus {
ACTIVE
INACTIVE
SUSPENDED
}

enum ReviewReportReason {
SPAM
OFFENSIVE
FAKE
OTHER
}

enum ReportStatus { //estado del reporte que se incluye en ambos tipos de reporte producto/reseña
PENDING
IN_PROGRESS
RESOLVED
REJECTED
}

enum ProductReportReason {
DEFECTIVE
LATE_DELIVERY
CUSTOMER_SERVICE
WRONG_ITEM
MISSING_ITEM
OTHER
}
5 changes: 5 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import addressRoutes from "./modules/users/addresses/routes/addresses.routes.js"
import sessionRoutes from "./modules/session/routes/session.routes.js";
import userProductReviewRoutes from "./modules/users/product-review/product-review.routes.js";

import productReportRoutes from "./modules/global/reports/product/product-report.routes.js";

import wishlistRoutes from "./modules/users/wishlist/wishlist.routes.js";
import cartRoutes from "./modules/users/cart/cart.routes.js";

Expand Down Expand Up @@ -71,6 +73,9 @@ app.use("/products", productRoutes);
// Rutas del Cliente
app.use("/products/:id/reviews", userProductReviewRoutes);

// Rutas de reportes
app.use("/api/reports", productReportRoutes);

// Rutas de usuarios
app.use("/api/users", userRoutes);
app.use("/api/users", addressRoutes);
Expand Down
2 changes: 2 additions & 0 deletions src/config/swagger.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
productTagSchemas,
productReviewSchemas,
productSchemas,
productReportSchemas,
// /session
sessionSchemas,
// /admin
Expand Down Expand Up @@ -63,6 +64,7 @@ const options = {
...productReviewSchemas,
...productTagSchemas,
...productSchemas,
...productReportSchemas,

// /session
...sessionSchemas,
Expand Down
70 changes: 70 additions & 0 deletions src/docs/schemas/commerce/product-report.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@

export const productReportSchemas = {
ProductReport: {
type: "object",
properties: {
id_product_report: { type: "integer", example: 1 },
reason: { type: "string", example: "Producto en mal estado" },
description: { type: "string", example: "El producto llegó roto" },
report_status: {
type: "string",
enum: ["PENDING", "IN_PROGRESS", "RESOLVED", "REJECTED"],
example: "PENDING",
},
notes: { type: "string", nullable: true, example: "Se coordina reenvío del producto" },
created_at: { type: "string", format: "date-time" },
resolved_at: { type: "string", format: "date-time", nullable: true },
reporter: {
type: "object",
properties: {
id_user: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
phone: { type: "string" },
},
},
product: {
type: "object",
properties: {
id_product: { type: "integer" },
name: { type: "string" },
store: {
type: "object",
properties: {
id_store: { type: "integer" },
name: { type: "string" },
},
},
},
},
order: {
type: "object",
properties: {
id_order: { type: "integer" },
created_at: { type: "string", format: "date-time" },
},
},
},
},

ProductReportUpdated: {
type: "object",
properties: {
id_product_report: { type: "integer", example: 1 },
report_status: {
type: "string",
enum: ["PENDING", "IN_PROGRESS", "RESOLVED", "REJECTED"],
example: "RESOLVED",
},
notes: { type: "string", nullable: true, example: "Se coordina reenvío del producto" },
resolved_at: { type: "string", format: "date-time", nullable: true },
resolver: {
type: "object",
properties: {
id_user: { type: "integer" },
name: { type: "string" },
},
},
},
},
};
1 change: 1 addition & 0 deletions src/docs/schemas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./commerce/store.schema.js";
export * from "./commerce/product-review.schema.js";
export * from "./commerce/product-tag.schema.js";
export * from "./commerce/product.schema.js";
export * from "./commerce/product-report.schema.js";

export * from "./session/session.schema.js";

Expand Down
71 changes: 71 additions & 0 deletions src/modules/global/reports/product/product-report.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getProductsReportsService, updateProductReportService, getProductsReportsFilteredService } from "./product-report.service.js";

/**
* GET /api/reports/products
* Admin obtiene todos los reportes de productos.
* Seller obtiene los reportes de su tienda.
*/
export const getProductsReports = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({
success: false,
message: "Usuario autenticado requerido"
});
}
const productsReports = await getProductsReportsService(req.user.id_user);
return res.status(200).json({ productsReports });
} catch (error) {
next(error);
}
}

/**
* PUT /api/reports/products/:reportId
* Solo el Seller puede actualizar el estado de un reporte de producto (IN_PROGRESS o RESOLVED)
* y agregar notas explicativas. No puede modificar un reporte ya resuelto o rechazado.
* Body: { report_status, notes }
*/
export const updateProductReport = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({
success: false,
message: "Usuario autenticado requerido"
});
}

const { reportId } = req.params;
const { report_status, notes } = req.body;

const updatedReport = await updateProductReportService(req.user.id_user, reportId, { report_status, notes });

return res.status(200).json({ updatedReport });
} catch (error) {
next(error);
}
}

/**
* GET /api/reports/products/filtered
* Admin y Seller pueden obtener reportes de productos filtrados por estado del reporte y búsqueda por nombre del cliente que reportó.
* El Seller solo ve los reportes de su tienda.
* Query params: report_status (opcional), search (opcional), page (opcional, default 1), limit (opcional, default 10)
*/
export const getProductsReportsFiltered = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({
success: false,
message: "Usuario autenticado requerido"
});
}

const { report_status, search } = req.query;

const filteredReports = await getProductsReportsFilteredService(req.user.id_user, { report_status, search }, req.pagination);
return res.status(200).json({ filteredReports });
} catch (error) {
next(error);
}
}
Loading
Loading