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}`,
},
}),
]),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
};
Loading
Loading