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
4 changes: 3 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ import {

// Rutas de administración
import {
adminUsersRoutes,
adminUsersRoutes,
adminCategoryRoutes,
adminStoresRoutes,
adminProductsRoutes,
} from "./modules/admin/index.js";

const app = express();
Expand Down Expand Up @@ -96,6 +97,7 @@ app.use("/api/orders", orderRouter);
app.use("/api/admin", adminUsersRoutes);
app.use("/api/admin/categories", adminCategoryRoutes);
app.use("/api/admin/stores", adminStoresRoutes);
app.use("/api/admin/products", adminProductsRoutes);

// Rutas de distancias
app.use("/api/distances", distanceRoutes);
Expand Down
3 changes: 2 additions & 1 deletion src/modules/admin/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./categories/admin-category.routes.js";
export * from "./users/admin-users.routes.js";
export * from "./stores/admin-stores.routes.js";
export * from "./stores/admin-stores.routes.js";
export * from "./products/admin-products.routes.js";
26 changes: 26 additions & 0 deletions src/modules/admin/products/admin-products.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
getProductsAdminService,
updateProductApprovalStatusService,
} from "./admin-products.service.js";

export const getProducts = async (req, res, next) => {
try {
const { search, approvalStatus } = req.query;
const result = await getProductsAdminService({ search, approvalStatus }, req.pagination);
return res.status(200).json(result);
} catch (error) {
next(error);
}
};

export const updateProductStatus = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({ success: false, message: "Usuario autenticado requerido" });
}
const result = await updateProductApprovalStatusService(req.params.id, req.body);
return res.status(200).json(result);
} catch (error) {
next(error);
}
};
16 changes: 16 additions & 0 deletions src/modules/admin/products/admin-products.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Router } from "express";
import authenticate from "../../../config/jwt.config.js";
import { requireRole } from "../../../middlewares/auth.middleware.js";
import { parsePagination } from "../../../middlewares/pagination.middleware.js";
import { ROLES } from "../../../utils/contants/roles.constant.js";
import { getProducts, updateProductStatus } from "./admin-products.controller.js";

const router = Router();

// GET /api/admin/products?search=&approvalStatus=&page=&limit=
router.get("/", authenticate, requireRole(ROLES.ADMIN), parsePagination, getProducts);

// PATCH /api/admin/products/:id/status — body: { status: "ACTIVE"|"REJECTED", reason? }
router.patch("/:id/status", authenticate, requireRole(ROLES.ADMIN), updateProductStatus);

export { router as adminProductsRoutes };
173 changes: 173 additions & 0 deletions src/modules/admin/products/admin-products.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { prisma } from "../../../lib/prisma.js";
import { NotFoundError, ValidationError } from "../../../lib/errors.js";

const VALID_APPROVAL_STATUSES = ["PENDING", "ACTIVE", "REJECTED"];

const mapProductResponse = (product) => ({
id: product.id_product,
name: product.name,
description: product.description,
price: Number(product.price),
visible: product.visible,
approvalStatus: product.approval_status,
rejectionReason: product.rejection_reason ?? null,
category: product.product_category
? { id: product.product_category.id_product_category, name: product.product_category.name }
: null,
store: product.store
? { id: product.store.id_store, name: product.store.name }
: null,
reportCount: product._count?.product_reports ?? 0,
latestReport: product.product_reports?.[0]
? {
reason: product.product_reports[0].reason,
description: product.product_reports[0].description ?? null,
reportedAt: product.product_reports[0].created_at,
}
: null,
createdAt: product.created_at,
});

const productSelect = {
id_product: true,
name: true,
description: true,
price: true,
visible: true,
approval_status: true,
rejection_reason: true,
created_at: true,
product_category: {
select: { id_product_category: true, name: true },
},
store: {
select: { id_store: true, name: true, fk_user: true },
},
product_reports: {
where: { status: true },
orderBy: { created_at: "desc" },
take: 1,
select: { reason: true, description: true, created_at: true },
},
_count: {
select: { product_reports: { where: { status: true } } },
},
};

export const getProductsAdminService = async ({ search, approvalStatus }, pagination) => {
const where = { status: true };

if (search?.toString().trim()) {
const term = search.toString().trim();
where.OR = [
{ name: { contains: term, mode: "insensitive" } },
{ store: { name: { contains: term, mode: "insensitive" } } },
];
}

if (approvalStatus !== undefined) {
const normalized = approvalStatus.toString().trim().toUpperCase();
if (!VALID_APPROVAL_STATUSES.includes(normalized)) {
throw new ValidationError(
`approvalStatus inválido. Valores permitidos: ${VALID_APPROVAL_STATUSES.join(", ")}`
);
}
where.approval_status = normalized;
}

const { page, limit, skip } = pagination;

const [total, products] = await Promise.all([
prisma.products.count({ where }),
prisma.products.findMany({
where,
skip,
take: limit,
orderBy: { created_at: "desc" },
select: productSelect,
}),
]);

return {
data: products.map(mapProductResponse),
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
};

export const updateProductApprovalStatusService = async (productId, { status, reason }) => {
const resolvedProductId = Number(productId);
if (!Number.isInteger(resolvedProductId) || resolvedProductId <= 0) {
throw new ValidationError("productId debe ser un entero positivo");
}

const normalizedStatus = status?.toString().trim().toUpperCase();
if (!["ACTIVE", "REJECTED"].includes(normalizedStatus)) {
throw new ValidationError("status debe ser ACTIVE o REJECTED");
}

if (normalizedStatus === "REJECTED" && !reason?.toString().trim()) {
throw new ValidationError("reason es requerido al rechazar un producto");
}

// Necesitamos el producto antes de la transacción para construir el mensaje de notificación
const product = await prisma.products.findFirst({
where: { id_product: resolvedProductId, status: true },
select: {
id_product: true,
name: true,
approval_status: true,
store: { select: { fk_user: true, name: true } },
},
});

if (!product) throw new NotFoundError("Producto no encontrado");

const isApproving = normalizedStatus === "ACTIVE";
const trimmedReason = reason?.toString().trim();

const updateData = {
approval_status: normalizedStatus,
visible: isApproving,
rejection_reason: isApproving ? null : trimmedReason,
};

// updateMany con condición sobre approval_status cierra la ventana TOCTOU:
// si dos admins ejecutan en paralelo, solo uno logrará el update (count > 0).
const [updateResult] = await prisma.$transaction([
prisma.products.updateMany({
where: {
id_product: resolvedProductId,
status: true,
approval_status: { not: normalizedStatus },
},
data: updateData,
}),
...(isApproving
? []
: [
prisma.notifications.create({
data: {
fk_user: product.store.fk_user,
title: "Producto rechazado",
message: `Tu producto "${product.name}" ha sido rechazado. Motivo: ${trimmedReason}`,
},
}),
]),
]);

if (updateResult.count === 0) {
throw new ValidationError(`El producto ya tiene el estado ${normalizedStatus}`);
}

const updated = await prisma.products.findUnique({
where: { id_product: resolvedProductId },
select: productSelect,
});

return mapProductResponse(updated);
};
55 changes: 53 additions & 2 deletions src/modules/global/reports/product/product-report.controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { getProductsReportsService, updateProductReportService, getProductsReportsFilteredService } from "./product-report.service.js";
import {
getProductsReportsService,
updateProductReportService,
getProductsReportsFilteredService,
getProductReportReasonsService,
createProductReportService,
checkProductReportService,
resolveProductReportAdminService,
} from "./product-report.service.js";

/**
* GET /api/reports/products
Expand Down Expand Up @@ -68,4 +76,47 @@ export const getProductsReportsFiltered = async (req, res, next) => {
} catch (error) {
next(error);
}
}
}

export const getProductReportReasons = (_req, res) => {
return res.status(200).json(getProductReportReasonsService());
};

export const createProductReport = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({ success: false, message: "Usuario autenticado requerido" });
}
const { productId, reason, description } = req.body;
const report = await createProductReportService(req.user.id_user, { productId, reason, description });
return res.status(201).json(report);
} catch (error) {
next(error);
}
};

export const checkProductReport = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({ success: false, message: "Usuario autenticado requerido" });
}
const { productId } = req.query;
const result = await checkProductReportService(req.user.id_user, productId);
return res.status(200).json(result);
} catch (error) {
next(error);
}
};

export const resolveProductReport = async (req, res, next) => {
try {
if (!req.user?.id_user) {
return res.status(401).json({ success: false, message: "Usuario autenticado requerido" });
}
const { status, notes } = req.body;
const result = await resolveProductReportAdminService(req.user.id_user, req.params.reportId, { status, notes });
return res.status(200).json(result);
} catch (error) {
next(error);
}
};
23 changes: 22 additions & 1 deletion src/modules/global/reports/product/product-report.routes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { Router } from "express";
import authenticate from "../../../../config/jwt.config.js";
import { requireRole } from "../../../../middlewares/auth.middleware.js";
import { ROLES } from "../../../../utils/contants/roles.constant.js";
import { parsePagination } from "../../../../middlewares/pagination.middleware.js";
import { getProductsReports, updateProductReport, getProductsReportsFiltered } from "./product-report.controller.js";
import {
getProductsReports,
updateProductReport,
getProductsReportsFiltered,
getProductReportReasons,
createProductReport,
checkProductReport,
resolveProductReport,
} from "./product-report.controller.js";


const router = Router({ mergeParams: true });
Expand Down Expand Up @@ -184,5 +194,16 @@ router.get("/products/filtered", authenticate, parsePagination, getProductsRepor
*/
router.put("/products/:reportId", authenticate, updateProductReport);

// GET /api/reports/products/reasons — Catálogo de motivos (sin auth, usado por el frontend para poblar el select)
router.get("/products/reasons", getProductReportReasons);

// POST /api/reports/products — Cliente reporta un producto
router.post("/products", authenticate, requireRole(ROLES.CUSTOMER), createProductReport);

// GET /api/reports/products/check?productId= — Cliente verifica si ya reportó un producto
router.get("/products/check", authenticate, requireRole(ROLES.CUSTOMER), checkProductReport);

// PATCH /api/reports/products/:reportId/resolve — Admin resuelve o rechaza un reporte
router.patch("/products/:reportId/resolve", authenticate, requireRole(ROLES.ADMIN), resolveProductReport);

export default router;
Loading
Loading