diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5cd7762..a6943c77 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,17 +62,22 @@ model Notifications { //------------------------------------BANNERS PROMOCIONALES---------------------------------------- model Banners { - id_banner Int @id @default(autoincrement()) - title String @db.VarChar(120) - description String? @db.Text - image_url String @db.VarChar(500) - link_url String? @db.VarChar(500) - start_at DateTime @db.Timestamptz - end_at DateTime? @db.Timestamptz - is_active Boolean @default(true) - status Boolean @default(true) - created_at DateTime @default(now()) @db.Timestamptz - updated_at DateTime @updatedAt @db.Timestamptz + id_banner Int @id @default(autoincrement()) + fk_store Int? + title String @db.VarChar(120) + description String? @db.Text + image_url String? @db.VarChar(500) + link_url String? @db.VarChar(500) + start_at DateTime @db.Timestamptz + end_at DateTime? @db.Timestamptz + is_active Boolean @default(true) + approval_status ApprovalStatus @default(PENDING) + rejection_reason String? @db.Text + status Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz + updated_at DateTime @updatedAt @db.Timestamptz + + store Stores? @relation(fields: [fk_store], references: [id_store]) } //------------------------------------COMERCIOS---------------------------------------- @@ -103,6 +108,7 @@ model Stores { carts Carts[] order Orders[] business_hours StoreBusinessHours[] + banner_requests Banners[] //wishlist_items WishlistItems[] } diff --git a/src/app.js b/src/app.js index 5e7d9fee..59d7dc5a 100644 --- a/src/app.js +++ b/src/app.js @@ -32,6 +32,7 @@ import wishlistRoutes from "./modules/users/wishlist/wishlist.routes.js"; import cartRoutes from "./modules/users/cart/cart.routes.js"; import { orderRouter, userOrderRouter } from "./modules/users/orders/order.routes.js"; +import bannerRequestRoutes from "./modules/commerce/banner-requests/banner-request.routes.js"; import { errorHandler } from "./middlewares/errorHandler.js"; import { NotFoundError } from "./lib/errors.js"; @@ -94,6 +95,7 @@ app.use("/api/commerces", commerceBusinessHoursRoutes); app.use("/api/deliveries", deliveryRouter); app.use("/api/stores", storeDeliveryRouter); app.use("/api/stores", storeDeliveryAssignmentRoutes); +app.use("/api/stores/:storeId/banner-requests", bannerRequestRoutes); //Desde aqui pueden usarse dos endpoints, para productos /api/categories/products, y /api/categories/stores //Se encuentra indexado diff --git a/src/docs/schemas/commerce/banner-request.schema.js b/src/docs/schemas/commerce/banner-request.schema.js new file mode 100644 index 00000000..636b9cb1 --- /dev/null +++ b/src/docs/schemas/commerce/banner-request.schema.js @@ -0,0 +1,94 @@ +/** + * @swagger + * components: + * schemas: + * BannerRequestCreateBody: + * type: object + * required: + * - title + * - startAt + * properties: + * title: + * type: string + * maxLength: 120 + * description: Título del banner + * description: + * type: string + * description: Descripción o texto del banner + * imageUrl: + * type: string + * maxLength: 500 + * description: URL de la imagen del banner + * linkUrl: + * type: string + * maxLength: 500 + * description: Enlace relacionado al banner + * startAt: + * type: string + * format: date-time + * description: Fecha de inicio de la promoción + * endAt: + * type: string + * format: date-time + * nullable: true + * description: Fecha de fin de la promoción (opcional) + * + * BannerRequestResponse: + * type: object + * properties: + * id: + * type: integer + * storeId: + * type: integer + * title: + * type: string + * description: + * type: string + * nullable: true + * imageUrl: + * type: string + * nullable: true + * linkUrl: + * type: string + * nullable: true + * startAt: + * type: string + * format: date-time + * endAt: + * type: string + * format: date-time + * nullable: true + * approvalStatus: + * type: string + * enum: [PENDING, ACTIVE, REJECTED] + * rejectionReason: + * type: string + * nullable: true + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * + * BannerRequestListResponse: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/BannerRequestResponse' + * pagination: + * type: object + * properties: + * total: + * type: integer + * page: + * type: integer + * limit: + * type: integer + * totalPages: + * type: integer + */ + +export const bannerRequestSchemas = {}; diff --git a/src/docs/schemas/index.js b/src/docs/schemas/index.js index 259dd352..57a9f24f 100644 --- a/src/docs/schemas/index.js +++ b/src/docs/schemas/index.js @@ -12,7 +12,8 @@ export * from "./commerce/product.schema.js"; export * from "./commerce/product-report.schema.js"; export * from "./commerce/review-report.schema.js"; export * from "./commerce/category-request.schema.js"; -export * from "./commerce/delivery-assignment.schema.js"; +export * from "./commerce/banner-request.schema.js"; +export * from "./commerce/delivery-assignment.schema.js"; export * from "./delivery.schema.js"; export * from "./order.schema.js"; export * from "./banner.schema.js"; diff --git a/src/modules/admin/banners/admin-banners.controller.js b/src/modules/admin/banners/admin-banners.controller.js index 0eeb977a..9a1be652 100644 --- a/src/modules/admin/banners/admin-banners.controller.js +++ b/src/modules/admin/banners/admin-banners.controller.js @@ -4,6 +4,8 @@ import { getAdminBannersService, toggleAdminBannerActiveService, updateAdminBannerService, + getBannerRequestsService, + processBannerRequestService, } from "./admin-banners.service.js"; export const createAdminBanner = async (req, res, next) => { @@ -44,3 +46,23 @@ export const toggleAdminBannerActive = async (req, res, next) => { next(error); } }; + +export const getBannerRequests = async (req, res, next) => { + try { + const { search, approval_status } = req.query; + const result = await getBannerRequestsService({ search, approval_status }, req.pagination); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const processBannerRequest = async (req, res, next) => { + try { + const id = validateId(req.params.id); + const result = await processBannerRequestService(id, req.body); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/admin/banners/admin-banners.routes.js b/src/modules/admin/banners/admin-banners.routes.js index 5cd96830..63bd224d 100644 --- a/src/modules/admin/banners/admin-banners.routes.js +++ b/src/modules/admin/banners/admin-banners.routes.js @@ -8,6 +8,8 @@ import { getAdminBanners, toggleAdminBannerActive, updateAdminBanner, + getBannerRequests, + processBannerRequest, } from "./admin-banners.controller.js"; const router = Router(); @@ -163,4 +165,86 @@ router.put("/:id", authenticate, requireRole(ROLES.ADMIN), updateAdminBanner); */ router.patch("/:id/active", authenticate, requireRole(ROLES.ADMIN), toggleAdminBannerActive); +/** + * @swagger + * /api/admin/banners/requests: + * get: + * summary: Listar solicitudes de banners por comercios (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: approval_status + * schema: + * type: string + * enum: [PENDING, ACTIVE, REJECTED] + * description: Filtrar por estado (por defecto PENDING) + * - in: query + * name: search + * schema: + * type: string + * description: Buscar por título o nombre del comercio + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Lista de solicitudes + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + */ +router.get("/requests", authenticate, requireRole(ROLES.ADMIN), parsePagination, getBannerRequests); + +/** + * @swagger + * /api/admin/banners/requests/{id}: + * patch: + * summary: Aprobar o rechazar solicitud de banner (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID de la solicitud de banner + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - decision + * properties: + * decision: + * type: string + * enum: [APPROVE, REJECT] + * rejectionReason: + * type: string + * description: Requerido si decision es REJECT + * responses: + * 200: + * description: Solicitud procesada correctamente + * 400: + * description: Decisión inválida o solicitud ya procesada + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + * 404: + * description: Solicitud no encontrada + */ +router.patch("/requests/:id", authenticate, requireRole(ROLES.ADMIN), processBannerRequest); + export { router as adminBannersRoutes }; diff --git a/src/modules/admin/banners/admin-banners.service.js b/src/modules/admin/banners/admin-banners.service.js index a91291ca..5e9d52b3 100644 --- a/src/modules/admin/banners/admin-banners.service.js +++ b/src/modules/admin/banners/admin-banners.service.js @@ -1,7 +1,9 @@ import { prisma } from "../../../lib/prisma.js"; -import { NotFoundError, ValidationError } from "../../../lib/errors.js"; +import { ForbiddenError, NotFoundError, ValidationError } from "../../../lib/errors.js"; import { validateId } from "../../../lib/validators.js"; +const VALID_DECISIONS = ["APPROVE", "REJECT"]; + const TITLE_MAX = 120; const URL_MAX = 500; @@ -53,6 +55,7 @@ const parseDate = (value, fieldName, { required = false } = {}) => { const bannerSelect = { id_banner: true, + fk_store: true, title: true, description: true, image_url: true, @@ -60,9 +63,12 @@ const bannerSelect = { start_at: true, end_at: true, is_active: true, + approval_status: true, + rejection_reason: true, status: true, created_at: true, updated_at: true, + store: { select: { id_store: true, name: true } }, }; const mapBannerResponse = (banner) => ({ @@ -74,6 +80,9 @@ const mapBannerResponse = (banner) => ({ startAt: banner.start_at, endAt: banner.end_at ?? null, isActive: banner.is_active, + approvalStatus: banner.approval_status, + rejectionReason: banner.rejection_reason ?? null, + store: banner.store ?? null, status: banner.status, createdAt: banner.created_at, updatedAt: banner.updated_at, @@ -103,6 +112,7 @@ export const createAdminBannerService = async (payload = {}) => { start_at: startAt, end_at: endAt, is_active: isActive, + approval_status: "ACTIVE", status: true, }, select: bannerSelect, @@ -230,6 +240,100 @@ export const updateAdminBannerService = async (bannerId, payload = {}) => { return mapBannerResponse(updated); }; +export const getBannerRequestsService = async (filters = {}, pagination = {}) => { + const where = { fk_store: { not: null }, status: true }; + + if (filters.approval_status) { + const validStatuses = ["PENDING", "ACTIVE", "REJECTED"]; + if (!validStatuses.includes(filters.approval_status)) { + throw new ValidationError(`approval_status debe ser uno de: ${validStatuses.join(", ")}`); + } + where.approval_status = filters.approval_status; + } else { + where.approval_status = "PENDING"; + } + + if (filters.search?.toString().trim()) { + const term = filters.search.toString().trim(); + where.OR = [ + { title: { contains: term, mode: "insensitive" } }, + { store: { name: { contains: term, mode: "insensitive" } } }, + ]; + } + + const { page = 1, limit = 20, skip = 0 } = pagination; + const safeLimit = Number(limit) > 0 ? Number(limit) : 20; + const safeSkip = Number(skip) >= 0 ? Number(skip) : 0; + + const [total, requests] = await Promise.all([ + prisma.banners.count({ where }), + prisma.banners.findMany({ + where, + skip: safeSkip, + take: safeLimit, + orderBy: { created_at: "desc" }, + select: bannerSelect, + }), + ]); + + return { + data: requests.map(mapBannerResponse), + pagination: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; +}; + +export const processBannerRequestService = async (bannerId, payload = {}) => { + const id = validateId(bannerId); + const { decision, rejectionReason } = payload; + + if (!decision || !VALID_DECISIONS.includes(decision)) { + throw new ValidationError(`decision debe ser uno de: ${VALID_DECISIONS.join(", ")}`); + } + + if (decision === "REJECT" && (!rejectionReason || !rejectionReason.toString().trim())) { + throw new ValidationError("rejectionReason es requerido al rechazar una solicitud"); + } + + const exists = await prisma.banners.findFirst({ + where: { id_banner: id, fk_store: { not: null }, status: true }, + select: { id_banner: true, approval_status: true }, + }); + + if (!exists) { + throw new NotFoundError("Solicitud de banner no encontrada"); + } + + if (exists.approval_status !== "PENDING") { + throw new ValidationError("Solo se pueden procesar solicitudes en estado PENDING"); + } + + const data = + decision === "APPROVE" + ? { approval_status: "ACTIVE", is_active: true, rejection_reason: null } + : { approval_status: "REJECTED", is_active: false, rejection_reason: rejectionReason.toString().trim() }; + + const { count } = await prisma.banners.updateMany({ + where: { id_banner: id, fk_store: { not: null }, approval_status: "PENDING", status: true }, + data, + }); + + if (count === 0) { + throw new ValidationError("La solicitud ya fue procesada por otro administrador"); + } + + const updated = await prisma.banners.findUnique({ + where: { id_banner: id }, + select: bannerSelect, + }); + + return mapBannerResponse(updated); +}; + export const toggleAdminBannerActiveService = async (bannerId, isActive) => { const id = validateId(bannerId); const normalizedActive = parseBoolean(isActive, "isActive"); diff --git a/src/modules/commerce/banner-requests/banner-request.controller.js b/src/modules/commerce/banner-requests/banner-request.controller.js new file mode 100644 index 00000000..81dc0535 --- /dev/null +++ b/src/modules/commerce/banner-requests/banner-request.controller.js @@ -0,0 +1,45 @@ +import { + createBannerRequestService, + getMyBannerRequestsService, + deleteBannerRequestService, +} from "./banner-request.service.js"; + +export const createBannerRequest = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { storeId } = req.params; + const result = await createBannerRequestService(req.user.id_user, storeId, req.body); + return res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const getMyBannerRequests = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { storeId } = req.params; + const { approval_status } = req.query; + const result = await getMyBannerRequestsService(req.user.id_user, storeId, { approval_status }, req.pagination); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const deleteBannerRequest = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { storeId, requestId } = req.params; + await deleteBannerRequestService(req.user.id_user, storeId, requestId); + return res.status(204).send(); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/commerce/banner-requests/banner-request.routes.js b/src/modules/commerce/banner-requests/banner-request.routes.js new file mode 100644 index 00000000..a31b36e2 --- /dev/null +++ b/src/modules/commerce/banner-requests/banner-request.routes.js @@ -0,0 +1,131 @@ +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 "../../../constants/roles.constant.js"; +import { + createBannerRequest, + getMyBannerRequests, + deleteBannerRequest, +} from "./banner-request.controller.js"; + +const router = Router({ mergeParams: true }); + +/** + * @swagger + * /api/stores/{storeId}/banner-requests: + * post: + * summary: Crear solicitud de banner promocional + * description: > + * El comercio solicita la publicación de un banner promocional. + * La solicitud queda en estado PENDING hasta que el administrador la apruebe o rechace. + * tags: [BannerRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * description: ID del comercio + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BannerRequestCreateBody' + * responses: + * 201: + * description: Solicitud creada correctamente + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BannerRequestResponse' + * 400: + * description: Datos inválidos + * 401: + * description: No autenticado + * 403: + * description: Sin permisos sobre este comercio + * 404: + * description: Comercio no encontrado + */ +router.post("/", authenticate, requireRole(ROLES.SELLER), createBannerRequest); + +/** + * @swagger + * /api/stores/{storeId}/banner-requests: + * get: + * summary: Ver historial de solicitudes de banner del comercio + * tags: [BannerRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * - in: query + * name: approval_status + * schema: + * type: string + * enum: [PENDING, ACTIVE, REJECTED] + * description: Filtrar por estado + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Lista de solicitudes del comercio + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BannerRequestListResponse' + * 401: + * description: No autenticado + * 403: + * description: Sin permisos sobre este comercio + */ +router.get("/", authenticate, requireRole(ROLES.SELLER), parsePagination, getMyBannerRequests); + +/** + * @swagger + * /api/stores/{storeId}/banner-requests/{requestId}: + * delete: + * summary: Cancelar solicitud de banner (solo si está PENDING) + * tags: [BannerRequests] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: storeId + * required: true + * schema: + * type: integer + * - in: path + * name: requestId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Solicitud cancelada correctamente + * 400: + * description: La solicitud ya fue procesada y no puede cancelarse + * 401: + * description: No autenticado + * 403: + * description: Sin permisos sobre este comercio + * 404: + * description: Solicitud no encontrada + */ +router.delete("/:requestId", authenticate, requireRole(ROLES.SELLER), deleteBannerRequest); + +export default router; diff --git a/src/modules/commerce/banner-requests/banner-request.service.js b/src/modules/commerce/banner-requests/banner-request.service.js new file mode 100644 index 00000000..f1972f57 --- /dev/null +++ b/src/modules/commerce/banner-requests/banner-request.service.js @@ -0,0 +1,159 @@ +import { prisma } from "../../../lib/prisma.js"; +import { NotFoundError, ValidationError } from "../../../lib/errors.js"; +import { getAuthorizedStoreOwnerService } from "../commerces/store.service.js"; + +const TITLE_MAX = 120; +const URL_MAX = 500; + +const normalizeText = (value, fieldName, { required = false, maxLength } = {}) => { + if (value === undefined || value === null) { + if (required) throw new ValidationError(`${fieldName} es requerido`); + return null; + } + if (typeof value !== "string") throw new ValidationError(`${fieldName} debe ser string`); + const trimmed = value.trim(); + if (required && trimmed.length === 0) throw new ValidationError(`${fieldName} no puede estar vacío`); + if (maxLength && trimmed.length > maxLength) throw new ValidationError(`${fieldName} no puede superar ${maxLength} caracteres`); + return trimmed.length === 0 ? null : trimmed; +}; + +const parseDate = (value, fieldName, { required = false } = {}) => { + if (value === undefined || value === null || value === "") { + if (required) throw new ValidationError(`${fieldName} es requerido`); + return null; + } + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) throw new ValidationError(`${fieldName} debe ser una fecha válida`); + return date; +}; + +const bannerRequestSelect = { + id_banner: true, + fk_store: true, + title: true, + description: true, + image_url: true, + link_url: true, + start_at: true, + end_at: true, + approval_status: true, + rejection_reason: true, + created_at: true, + updated_at: true, +}; + +const mapRequestResponse = (b) => ({ + id: b.id_banner, + storeId: b.fk_store, + title: b.title, + description: b.description ?? null, + imageUrl: b.image_url ?? null, + linkUrl: b.link_url ?? null, + startAt: b.start_at, + endAt: b.end_at ?? null, + approvalStatus: b.approval_status, + rejectionReason: b.rejection_reason ?? null, + createdAt: b.created_at, + updatedAt: b.updated_at, +}); + +export const createBannerRequestService = async (authenticatedUserId, storeIdStr, payload = {}) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); + + const title = normalizeText(payload.title, "title", { required: true, maxLength: TITLE_MAX }); + const description = normalizeText(payload.description, "description", { maxLength: 500 }); + const imageUrl = normalizeText(payload.imageUrl, "imageUrl", { maxLength: URL_MAX }); + const linkUrl = normalizeText(payload.linkUrl, "linkUrl", { maxLength: URL_MAX }); + const startAt = parseDate(payload.startAt, "startAt", { required: true }); + const endAt = parseDate(payload.endAt, "endAt"); + + if (endAt && endAt < startAt) { + throw new ValidationError("endAt no puede ser anterior a startAt"); + } + + const created = await prisma.banners.create({ + data: { + fk_store: store.id_store, + title, + description, + image_url: imageUrl, + link_url: linkUrl, + start_at: startAt, + end_at: endAt, + is_active: false, + approval_status: "PENDING", + status: true, + }, + select: bannerRequestSelect, + }); + + return mapRequestResponse(created); +}; + +export const getMyBannerRequestsService = async (authenticatedUserId, storeIdStr, filters = {}, pagination = {}) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); + + const where = { fk_store: store.id_store, status: true }; + + if (filters.approval_status) { + const validStatuses = ["PENDING", "ACTIVE", "REJECTED"]; + if (!validStatuses.includes(filters.approval_status)) { + throw new ValidationError(`approval_status debe ser uno de: ${validStatuses.join(", ")}`); + } + where.approval_status = filters.approval_status; + } + + const { page = 1, limit = 10, skip = 0 } = pagination; + const safeLimit = Number(limit) > 0 ? Number(limit) : 10; + const safeSkip = Number(skip) >= 0 ? Number(skip) : 0; + + const [total, requests] = await Promise.all([ + prisma.banners.count({ where }), + prisma.banners.findMany({ + where, + skip: safeSkip, + take: safeLimit, + orderBy: { created_at: "desc" }, + select: bannerRequestSelect, + }), + ]); + + return { + data: requests.map(mapRequestResponse), + pagination: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; +}; + +export const deleteBannerRequestService = async (authenticatedUserId, storeIdStr, bannerIdStr) => { + const store = await getAuthorizedStoreOwnerService(authenticatedUserId, storeIdStr); + const bannerId = Number(bannerIdStr); + + if (!Number.isInteger(bannerId) || bannerId <= 0) { + throw new ValidationError("ID de solicitud inválido"); + } + + const exists = await prisma.banners.findFirst({ + where: { id_banner: bannerId, fk_store: store.id_store, status: true }, + select: { id_banner: true, approval_status: true }, + }); + + if (!exists) throw new NotFoundError("Solicitud de banner no encontrada"); + + if (exists.approval_status !== "PENDING") { + throw new ValidationError("Solo se pueden cancelar solicitudes en estado PENDING"); + } + + const { count } = await prisma.banners.updateMany({ + where: { id_banner: bannerId, fk_store: store.id_store, approval_status: "PENDING", status: true }, + data: { status: false }, + }); + + if (count === 0) { + throw new ValidationError("La solicitud ya fue procesada y no puede cancelarse"); + } +};