diff --git a/src/index.js b/src/index.js index f8775d8..9446800 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import morgan from "morgan"; import cors from "cors"; import cookieParser from "cookie-parser"; import storeRoutes from "./modules/commerce/commerces/store.routes.js"; +import commerceAddressRoutes from "./modules/commerce/addresses/routes/addresses.routes.js"; import storeCategoryRoutes from "./modules/commerce/store-categories/store-category.routes.js"; import productRoutes from "./modules/commerce/products/product.routes.js"; import categoriesRoutes from "./modules/global/categories/categories.routes.js"; @@ -32,7 +33,8 @@ app.use(cors({ // Rutas de comercio app.use("/api/commerces/categories", storeCategoryRoutes); app.use("/api/commerces",storeRoutes); -app.use("/products", productRoutes); +app.use("/api/commerces", commerceAddressRoutes); + app.use("/products", productRoutes); //Desde aqui pueden usarse dos endpoints, para productos /api/categories/products, y /api/categories/stores //Se encuentra indexado app.use("/api/categories", categoriesRoutes); diff --git a/src/modules/commerce/addresses/controllers/addresses.controllers.js b/src/modules/commerce/addresses/controllers/addresses.controllers.js new file mode 100644 index 0000000..341710f --- /dev/null +++ b/src/modules/commerce/addresses/controllers/addresses.controllers.js @@ -0,0 +1,128 @@ +//addresses.controllers.js +import { + createStoreAddressService, + deleteStoreAddressService, + getStoreAddressByIdService, + getStoreAddressesService, + updateStoreAddressService, +} from "../services/addresses.services.js"; + +const getErrorStatusCode = (error) => error.statusCode || error.status || 500; + +// crea una direccion para el comercio autenticado +export const createStoreAddress = async (req, res) => { + try { + const { id } = req.params; + + const address = await createStoreAddressService( + req.user?.id_user, + id, + req.body + ); + + return res.status(201).json({ + success: true, + message: "Direccion del comercio creada exitosamente", + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// devuelve la lista de direcciones del comercio autenticado +export const getStoreAddresses = async (req, res) => { + try { + const { id } = req.params; + + const addresses = await getStoreAddressesService( + req.user?.id_user, + id + ); + + return res.status(200).json({ + success: true, + data: addresses, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// devuelve una direccion puntual del comercio autenticado +export const getStoreAddressById = async (req, res) => { + try { + const { id, id_address } = req.params; + + const address = await getStoreAddressByIdService( + req.user?.id_user, + id, + id_address + ); + + return res.status(200).json({ + success: true, + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// actualiza una direccion puntual del comercio autenticado +export const updateStoreAddress = async (req, res) => { + try { + const { id, id_address } = req.params; + + const address = await updateStoreAddressService( + req.user?.id_user, + id, + id_address, + req.body + ); + + return res.status(200).json({ + success: true, + message: "Direccion del comercio actualizada exitosamente", + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// desactiva una direccion puntual del comercio autenticado +export const deleteStoreAddress = async (req, res) => { + try { + const { id, id_address } = req.params; + + const address = await deleteStoreAddressService( + req.user?.id_user, + id, + id_address + ); + + return res.status(200).json({ + success: true, + message: "Direccion del comercio desactivada correctamente", + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; diff --git a/src/modules/commerce/addresses/routes/addresses.routes.js b/src/modules/commerce/addresses/routes/addresses.routes.js new file mode 100644 index 0000000..9219996 --- /dev/null +++ b/src/modules/commerce/addresses/routes/addresses.routes.js @@ -0,0 +1,25 @@ +//addresses.routes.js +import { Router } from "express"; +import authenticate from "../../../../config/jwt.config.js"; +import { + createStoreAddress, + deleteStoreAddress, + getStoreAddressById, + getStoreAddresses, + updateStoreAddress, +} from "../controllers/addresses.controllers.js"; + +const router = Router(); + +// crea una nueva direccion para el comercio +router.post("/:id/addresses", authenticate, createStoreAddress); +// lista las direcciones activas del comercio +router.get("/:id/addresses", authenticate, getStoreAddresses); +// obtiene una direccion puntual del comercio +router.get("/:id/addresses/:id_address", authenticate, getStoreAddressById); +// edita una direccion puntual del comercio +router.put("/:id/addresses/:id_address", authenticate, updateStoreAddress); +// desactiva una direccion puntual del comercio +router.delete("/:id/addresses/:id_address", authenticate, deleteStoreAddress); + +export default router; diff --git a/src/modules/commerce/addresses/services/addresses.services.js b/src/modules/commerce/addresses/services/addresses.services.js new file mode 100644 index 0000000..8fdee9c --- /dev/null +++ b/src/modules/commerce/addresses/services/addresses.services.js @@ -0,0 +1,263 @@ +//addresses.services.js +import { prisma } from "../../../../lib/prisma.js"; +import { getAuthorizedStoreOwnerService } from "../../commerces/store.service.js"; + +// mantiene uniforme la informacion que devolvemos en las respuestas +const ADDRESS_SELECT = { + id_address: true, + fk_user: true, + fk_store: true, + address: true, + city: true, + region: true, + postal_code: true, + status: true, + created_at: true, + updated_at: true, +}; + +// asegura que los ids recibidos por params sean enteros positivos +const parsePositiveInteger = (value, fieldName) => { + const parsedValue = Number(value); + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw { + status: 400, + message: `${fieldName} invalido`, + }; + } + + return parsedValue; +}; + +// valida campos de texto obligatorios al crear o editar una direccion +const validateRequiredStringField = (value, fieldName, maxLength = null) => { + const parsedValue = value?.toString().trim(); + + if (!parsedValue) { + throw { + status: 400, + message: `${fieldName} no puede estar vacio`, + }; + } + + if (maxLength && parsedValue.length > maxLength) { + throw { + status: 400, + message: `${fieldName} no puede superar ${maxLength} caracteres`, + }; + } + + return parsedValue; +}; + +// normaliza campos opcionales para permitir null cuando corresponda +const validateOptionalStringField = (value, fieldName, maxLength = null) => { + if (value === undefined) { + return undefined; + } + + if (value === null || value === "") { + return null; + } + + const parsedValue = value.toString().trim(); + + if (!parsedValue) { + throw { + status: 400, + message: `${fieldName} no puede estar vacio`, + }; + } + + if (maxLength && parsedValue.length > maxLength) { + throw { + status: 400, + message: `${fieldName} no puede superar ${maxLength} caracteres`, + }; + } + + return parsedValue; +}; + +// centraliza la validacion del payload para reutilizarla en create y update +const buildAddressData = (payload, { requireAllFields = false } = {}) => { + const data = {}; + + if (requireAllFields || payload?.address !== undefined) { + data.address = validateRequiredStringField(payload?.address, "address"); + } + + if (requireAllFields || payload?.city !== undefined) { + data.city = validateRequiredStringField(payload?.city, "city", 100); + } + + if (requireAllFields || payload?.region !== undefined) { + data.region = validateRequiredStringField(payload?.region, "region", 100); + } + + if (requireAllFields || payload?.postal_code !== undefined) { + const postalCode = validateOptionalStringField( + payload?.postal_code, + "postal_code", + 20 + ); + + data.postal_code = postalCode ?? null; + } + + if (!requireAllFields && Object.keys(data).length === 0) { + throw { + status: 400, + message: + "Debe enviar al menos uno de estos campos: address, city, region, postal_code", + }; + } + + return data; +}; + +// busca una direccion activa y confirma que pertenezca al comercio autenticado +const getOwnedStoreAddressOrThrow = async (storeId, requestedAddressId) => { + const addressId = parsePositiveInteger(requestedAddressId, "ID de direccion"); + + const address = await prisma.addresses.findFirst({ + where: { + id_address: addressId, + fk_store: storeId, + status: true, + }, + select: ADDRESS_SELECT, + }); + + if (!address) { + throw { + status: 404, + message: "Direccion del comercio no encontrada", + }; + } + + return address; +}; + +// crea una direccion asociada al comercio del usuario autenticado +export const createStoreAddressService = async ( + authenticatedUserId, + requestedStoreId, + payload +) => { + const store = await getAuthorizedStoreOwnerService( + authenticatedUserId, + requestedStoreId + ); + const dataToCreate = buildAddressData(payload, { requireAllFields: true }); + + const newAddress = await prisma.addresses.create({ + data: { + fk_user: store.fk_user, + fk_store: store.id_store, + ...dataToCreate, + }, + select: ADDRESS_SELECT, + }); + + return newAddress; +}; + +// devuelve la lista de direcciones activas del comercio autenticado +export const getStoreAddressesService = async ( + authenticatedUserId, + requestedStoreId +) => { + const store = await getAuthorizedStoreOwnerService( + authenticatedUserId, + requestedStoreId + ); + + const addresses = await prisma.addresses.findMany({ + where: { + fk_store: store.id_store, + status: true, + }, + orderBy: { + created_at: "desc", + }, + select: ADDRESS_SELECT, + }); + + return addresses; +}; + +// devuelve una sola direccion del comercio autenticado +export const getStoreAddressByIdService = async ( + authenticatedUserId, + requestedStoreId, + requestedAddressId +) => { + const store = await getAuthorizedStoreOwnerService( + authenticatedUserId, + requestedStoreId + ); + + return getOwnedStoreAddressOrThrow(store.id_store, requestedAddressId); +}; + +// actualiza una direccion puntual del comercio autenticado +export const updateStoreAddressService = async ( + authenticatedUserId, + requestedStoreId, + requestedAddressId, + payload +) => { + const store = await getAuthorizedStoreOwnerService( + authenticatedUserId, + requestedStoreId + ); + const existingAddress = await getOwnedStoreAddressOrThrow( + store.id_store, + requestedAddressId + ); + const dataToUpdate = buildAddressData(payload); + + const updatedAddress = await prisma.addresses.update({ + where: { + id_address: existingAddress.id_address, + }, + data: dataToUpdate, + select: ADDRESS_SELECT, + }); + + return updatedAddress; +}; + +// aplica borrado logico para desactivar una direccion del comercio +export const deleteStoreAddressService = async ( + authenticatedUserId, + requestedStoreId, + requestedAddressId +) => { + const store = await getAuthorizedStoreOwnerService( + authenticatedUserId, + requestedStoreId + ); + const existingAddress = await getOwnedStoreAddressOrThrow( + store.id_store, + requestedAddressId + ); + + const deletedAddress = await prisma.addresses.update({ + where: { + id_address: existingAddress.id_address, + }, + data: { + status: false, + }, + select: { + id_address: true, + status: true, + updated_at: true, + }, + }); + + return deletedAddress; +}; diff --git a/src/modules/commerce/commerces/store.service.js b/src/modules/commerce/commerces/store.service.js index 0febdd8..74cbbd6 100644 --- a/src/modules/commerce/commerces/store.service.js +++ b/src/modules/commerce/commerces/store.service.js @@ -149,7 +149,8 @@ const validateEmailField = (value) => { return normalizedEmail; }; -const getAuthorizedStoreOwnerService = async ( +// valida que el usuario autenticado sea el propietario del comercio solicitado +export const getAuthorizedStoreOwnerService = async ( authenticatedUserId, requestedStoreId ) => { diff --git a/src/modules/commerce/products/product.controller.js b/src/modules/commerce/products/product.controller.js index 88ee2be..0b92aad 100644 --- a/src/modules/commerce/products/product.controller.js +++ b/src/modules/commerce/products/product.controller.js @@ -1,14 +1,20 @@ -import { +import { createProductService, - getProductsSearchService, - getProductByIdService + getProductByIdService, + updateProductService } from "./product.service.js"; export const createProduct = async (req, res) => { try { - const authenticatedUserId = req.headers["x-user-id"]; - const product = await createProductService(authenticatedUserId, req.body); + if (!req.user?.id_user) { + return res.status(401).json({ + success: false, + message: "Usuario autenticado requerido" + }); + } + + const product = await createProductService(req.user.id_user, req.body); return res.status(201).json(product); } catch (error) { console.error("Error creando producto:", error); @@ -19,6 +25,28 @@ export const createProduct = async (req, res) => { } }; +export const updateProduct = async (req, res) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ + success: false, + message: "Usuario autenticado requerido" + }); + } + + const { id } = req.params; + const product = await updateProductService(req.user.id_user, id, req.body); + + return res.status(200).json(product); + } catch (error) { + console.error("Error actualizando producto:", error); + + return res.status(error.status || error.statusCode || 500).json({ + message: error.message || "Error interno del servidor" + }); + } +}; + /** GET: Para obtener todos losproductos o buscar segun nombre o descripcion (por el momento). * Recibe query con parametros de nombre o descripcion del producto y retorna una lista de productos. * @@ -57,4 +85,4 @@ export const getProductById = async (request, response) => { message:error.message || "Error interno del servidor." }); } -}; \ No newline at end of file +}; diff --git a/src/modules/commerce/products/product.routes.js b/src/modules/commerce/products/product.routes.js index 2b6f686..5f32723 100644 --- a/src/modules/commerce/products/product.routes.js +++ b/src/modules/commerce/products/product.routes.js @@ -1,13 +1,16 @@ import { Router } from "express"; -import { - createProduct, - getProductById, - getProductsSearch, +import authenticate from "../../../config/jwt.config.js"; +import { + createProduct, + getProductsSearch, + getProductById, + updateProduct } from "./product.controller.js"; const router = Router(); -router.post("/", createProduct); +router.post("/", authenticate, createProduct); +router.put("/:id", authenticate, updateProduct); router.get("/", getProductsSearch); router.get("/:id", getProductById); diff --git a/src/modules/commerce/products/product.service.js b/src/modules/commerce/products/product.service.js index 04b5dbb..e04a516 100644 --- a/src/modules/commerce/products/product.service.js +++ b/src/modules/commerce/products/product.service.js @@ -7,6 +7,40 @@ import { const DEFAULT_PRODUCT_INITIAL_STATUS = "pending"; const ALLOWED_PRODUCT_INITIAL_STATUS = new Set(["pending", "active"]); +const DEFAULT_PRODUCTS_PAGE = 1; +const DEFAULT_PRODUCTS_LIMIT = 20; +const MAX_PRODUCTS_LIMIT = 100; + +const PRODUCT_RESPONSE_SELECT = { + id_product: true, + name: true, + description: true, + price: true, + quantity: true, + fk_product_category: true, + fk_store: true, + visible: true, + created_at: true, + updated_at: true, + product_category: { + select: { + id_product_category: true, + name: true, + status: true + } + }, + product_tag_relations: { + where: { status: true }, + select: { + product_tag: { + select: { + id_product_tag: true, + name: true + } + } + } + } +}; const resolveInitialProductStatus = () => { const configuredStatus = String( @@ -18,6 +52,96 @@ const resolveInitialProductStatus = () => { : DEFAULT_PRODUCT_INITIAL_STATUS; }; +const parsePositiveInteger = (value, fieldName) => { + const parsedValue = Number(value); + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw { status: 400, message: `${fieldName} invalido` }; + } + + return parsedValue; +}; + +const sanitizePaginationValue = (value, fallback) => { + const parsedValue = Number(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + return fallback; + } + + return Math.floor(parsedValue); +}; + +const validateRequiredStringField = (value, fieldName, maxLength = null) => { + const normalizedValue = value?.toString().trim(); + + if (!normalizedValue) { + throw { status: 400, message: `${fieldName} es requerido` }; + } + + if (maxLength && normalizedValue.length > maxLength) { + throw { + status: 400, + message: `${fieldName} no puede superar ${maxLength} caracteres` + }; + } + + return normalizedValue; +}; + +const normalizeOptionalStringField = (value) => { + if (value === undefined) { + return undefined; + } + + if (value === null || value === "") { + return null; + } + + const normalizedValue = value.toString().trim(); + return normalizedValue ? normalizedValue : null; +}; + +const validatePriceField = (value) => { + if (value === undefined || value === null || value === "") { + throw { status: 400, message: "price es requerido" }; + } + + const parsedPrice = Number(value); + if (!Number.isFinite(parsedPrice) || parsedPrice <= 0) { + throw { status: 400, message: "price debe ser mayor a 0" }; + } + + return parsedPrice; +}; + +const parseQuantityField = (value) => { + if (value === undefined) { + return undefined; + } + + if (value === null || value === "") { + return null; + } + + const parsedQuantity = Number(value); + if (!Number.isInteger(parsedQuantity) || parsedQuantity < 0) { + throw { status: 400, message: "quantity debe ser un entero mayor o igual a 0" }; + } + + return parsedQuantity; +}; + +const parseCategoryField = async (value) => { + const categoryId = Number(value); + if (!Number.isInteger(categoryId) || categoryId <= 0) { + throw { status: 400, message: "categoryId es requerido y debe ser numerico" }; + } + + await validateProductCategoryService(categoryId); + return categoryId; +}; + const parseVisibilityOverride = (payload) => { if (payload?.visible !== undefined && payload?.visible !== null && payload?.visible !== "") { if (typeof payload.visible === "boolean") { @@ -59,6 +183,7 @@ const mapProductResponse = (product) => { name: product.name, description: product.description, price: Number(product.price), + quantity: product.quantity, categoryId: product.fk_product_category, category: product.product_category ? { @@ -73,6 +198,7 @@ const mapProductResponse = (product) => { name: relation.product_tag.name })) || [], commerceId: product.fk_store, + visible: product.visible, status: lifecycleStatus, createdAt: product.created_at, updatedAt: product.updated_at @@ -119,59 +245,207 @@ const getAuthenticatedSellerStore = async (authenticatedUserId) => { return seller.store.id_store; }; -export const createProductService = async (authenticatedUserId, payload) => { - const name = payload?.name?.toString().trim(); - const price = Number(payload?.price); - const categoryId = Number(payload?.categoryId); - const description = - payload?.description !== undefined && payload?.description !== null - ? payload.description.toString().trim() - : null; +const getExistingProductForUpdateService = async (productId) => { + const resolvedProductId = parsePositiveInteger(productId, "ID de producto"); - let quantity = null; - if (payload?.quantity !== undefined && payload?.quantity !== null && payload?.quantity !== "") { - quantity = Number(payload.quantity); + const product = await prisma.products.findUnique({ + where: { id_product: resolvedProductId }, + select: { + id_product: true, + fk_store: true, + status: true + } + }); + + if (!product || !product.status) { + throw { status: 404, message: "Producto no encontrado" }; } - if (!name) { - throw { status: 400, message: "name es requerido" }; + return product; +}; + +const getProductResponseByIdService = async ( + productId, + tx = prisma, + options = {} +) => { + const { requireVisible = false } = options; + + return tx.products.findFirst({ + where: { + id_product: productId, + status: true, + ...(requireVisible ? { visible: true } : {}) + }, + select: PRODUCT_RESPONSE_SELECT + }); +}; + +const buildCreateProductData = async (payload) => { + const name = validateRequiredStringField(payload?.name, "name", 100); + const price = validatePriceField(payload?.price); + const categoryId = await parseCategoryField(payload?.categoryId); + const quantity = parseQuantityField(payload?.quantity); + const description = normalizeOptionalStringField(payload?.description) ?? null; + const visibilityOverride = parseVisibilityOverride({ visible: payload?.visible }); + const tagIds = parseProductTagIdsService(payload?.tags); + + await validateProductTagsService(tagIds); + + return { + data: { + name, + description, + price, + quantity: quantity ?? null, + fk_product_category: categoryId + }, + tagIds, + visibilityOverride + }; +}; + +const buildUpdateProductData = async (payload) => { + const dataToUpdate = {}; + + if (payload?.name !== undefined) { + dataToUpdate.name = validateRequiredStringField(payload.name, "name", 100); } - if (payload?.price === undefined || payload?.price === null || payload?.price === "") { - throw { status: 400, message: "price es requerido" }; + if (payload?.description !== undefined) { + dataToUpdate.description = normalizeOptionalStringField(payload.description); } - if (!Number.isFinite(price) || price <= 0) { - throw { status: 400, message: "price debe ser mayor a 0" }; + if (payload?.price !== undefined) { + dataToUpdate.price = validatePriceField(payload.price); } - if (!Number.isInteger(categoryId) || categoryId <= 0) { - throw { status: 400, message: "categoryId es requerido y debe ser numerico" }; + if (payload?.categoryId !== undefined) { + dataToUpdate.fk_product_category = await parseCategoryField(payload.categoryId); } - if (quantity !== null && (!Number.isInteger(quantity) || quantity < 0)) { - throw { status: 400, message: "quantity debe ser un entero mayor o igual a 0" }; + if (payload?.quantity !== undefined) { + dataToUpdate.quantity = parseQuantityField(payload.quantity); } - const tagIds = parseProductTagIdsService(payload?.tags); - const commerceId = await getAuthenticatedSellerStore(authenticatedUserId); const visibilityOverride = parseVisibilityOverride(payload); + if (visibilityOverride !== null) { + dataToUpdate.visible = visibilityOverride; + } - await validateProductCategoryService(categoryId); - await validateProductTagsService(tagIds); + let nextTagIds; + if (payload?.tags !== undefined) { + nextTagIds = parseProductTagIdsService(payload.tags); + await validateProductTagsService(nextTagIds); + } + + if (Object.keys(dataToUpdate).length === 0 && nextTagIds === undefined) { + throw { + status: 400, + message: "Debe enviar al menos un campo para actualizar el producto" + }; + } + + return { + dataToUpdate, + nextTagIds + }; +}; + +const syncProductTagsService = async (tx, productId, nextTagIds) => { + const existingRelations = await tx.productTagRelations.findMany({ + where: { + fk_product: productId + }, + select: { + id_product_tag_relation: true, + fk_product_tag: true, + status: true + } + }); + + const activeRelationIdsToDisable = existingRelations + .filter( + (relation) => relation.status && !nextTagIds.includes(relation.fk_product_tag) + ) + .map((relation) => relation.id_product_tag_relation); + + if (activeRelationIdsToDisable.length) { + await tx.productTagRelations.updateMany({ + where: { + id_product_tag_relation: { + in: activeRelationIdsToDisable + } + }, + data: { + status: false + } + }); + } + + const activeTagIds = new Set( + existingRelations + .filter((relation) => relation.status) + .map((relation) => relation.fk_product_tag) + ); + + const inactiveRelationByTagId = new Map(); + for (const relation of existingRelations) { + if (!relation.status && !inactiveRelationByTagId.has(relation.fk_product_tag)) { + inactiveRelationByTagId.set( + relation.fk_product_tag, + relation.id_product_tag_relation + ); + } + } + + const relationIdsToReactivate = nextTagIds + .filter( + (tagId) => !activeTagIds.has(tagId) && inactiveRelationByTagId.has(tagId) + ) + .map((tagId) => inactiveRelationByTagId.get(tagId)); + + if (relationIdsToReactivate.length) { + await tx.productTagRelations.updateMany({ + where: { + id_product_tag_relation: { + in: relationIdsToReactivate + } + }, + data: { + status: true + } + }); + } + + const tagsToCreate = nextTagIds.filter( + (tagId) => !activeTagIds.has(tagId) && !inactiveRelationByTagId.has(tagId) + ); + + if (tagsToCreate.length) { + await tx.productTagRelations.createMany({ + data: tagsToCreate.map((tagId) => ({ + fk_product: productId, + fk_product_tag: tagId, + status: true + })) + }); + } +}; + +export const createProductService = async (authenticatedUserId, payload) => { + const commerceId = await getAuthenticatedSellerStore(authenticatedUserId); + const { data, tagIds, visibilityOverride } = await buildCreateProductData(payload); const initialLifecycleStatus = resolveInitialProductStatus(); - const initialVisibility = visibilityOverride ?? (initialLifecycleStatus === "active"); + const initialVisibility = + visibilityOverride ?? (initialLifecycleStatus === "active"); const createdProduct = await prisma.$transaction(async (tx) => { const product = await tx.products.create({ data: { - fk_product_category: categoryId, + ...data, fk_store: commerceId, - name, - description, - price, - quantity, visible: initialVisibility, status: true }, @@ -188,38 +462,7 @@ export const createProductService = async (authenticatedUserId, payload) => { }); } - return tx.products.findUnique({ - where: { id_product: product.id_product }, - select: { - id_product: true, - name: true, - description: true, - price: true, - fk_product_category: true, - fk_store: true, - visible: true, - created_at: true, - updated_at: true, - product_category: { - select: { - id_product_category: true, - name: true, - status: true - } - }, - product_tag_relations: { - where: { status: true }, - select: { - product_tag: { - select: { - id_product_tag: true, - name: true - } - } - } - } - } - }); + return getProductResponseByIdService(product.id_product, tx); }); if (!createdProduct) { @@ -229,6 +472,46 @@ export const createProductService = async (authenticatedUserId, payload) => { return mapProductResponse(createdProduct); }; +export const updateProductService = async ( + authenticatedUserId, + requestedProductId, + payload +) => { + const sellerStoreId = await getAuthenticatedSellerStore(authenticatedUserId); + const existingProduct = await getExistingProductForUpdateService(requestedProductId); + + if (existingProduct.fk_store !== sellerStoreId) { + throw { status: 403, message: "No tiene permisos para editar este producto" }; + } + + const { + dataToUpdate, + nextTagIds + } = await buildUpdateProductData(payload); + + const updatedProduct = await prisma.$transaction(async (tx) => { + if (Object.keys(dataToUpdate).length > 0) { + await tx.products.update({ + where: { + id_product: existingProduct.id_product + }, + data: dataToUpdate + }); + } + + if (nextTagIds !== undefined) { + await syncProductTagsService(tx, existingProduct.id_product, nextTagIds); + } + + return getProductResponseByIdService(existingProduct.id_product, tx); + }); + + if (!updatedProduct) { + throw { status: 500, message: "No se pudo recuperar el producto actualizado" }; + } + + return mapProductResponse(updatedProduct); +}; /** esta funcion recibe un filtro (search) y retorna los productos con status=true y visible=true*/ export const getProductsSearchService = async (filters) => { @@ -236,11 +519,15 @@ export const getProductsSearchService = async (filters) => { const categoryIdRaw = filters.categoryId ?? filters.category_id ?? filters.fk_product_category; //Paginacion - const page = Number(filters.page) > 0 ? Number(filters.page) : 1; - const limit = Number(filters.limit) > 0 ? Number(filters.limit) : 20; //por defecto trae hasta 20 productos + const page = sanitizePaginationValue(filters.page, DEFAULT_PRODUCTS_PAGE); + const requestedLimit = sanitizePaginationValue( + filters.limit, + DEFAULT_PRODUCTS_LIMIT + ); + const limit = Math.min(requestedLimit, MAX_PRODUCTS_LIMIT); //por defecto trae hasta 20 productos const skip = (page - 1) * limit; - const where = {status: true, visible: true}; + const where = { status: true, visible: true }; let orderBy = {id_product: 'desc'}; // orden por defecto @@ -256,37 +543,16 @@ export const getProductsSearchService = async (filters) => { //si se le pasa un search, se busca en name y description if (search) { where.OR = [ - {name: { contains: search, mode: "insensitive"},}, - {description: {contains: search, mode: "insensitive"}} - ] + { name: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } } + ]; // cambia el orden, solo si se busca o filtra por algun parametro orderBy = { _relevance: { - fields: ['name'], + fields: ["name"], search: search, - sort: 'desc', - }, - } - } - - const [totalProducts, products] = await Promise.all( - [prisma.products.count({where}), // se calcula el total de productos que cumplen el filtro - prisma.products.findMany({ // se trae los productos segun el filtro - where, - skip, - take: limit, - orderBy, - select: { - id_product: true, - name: true, - description: true, - price: true, - store: { - select: { - id_store: true, - name: true, - } + sort: "desc" } } })]) @@ -310,60 +576,50 @@ export const getProductByIdService = async (id)=>{ status:400, message:"ID de producto inválido" }; - } - const product = await prisma.products.findFirst({ - - where:{ - id_product:productId, - status:true - }, - - select:{ - - id_product:true, - name:true, - description:true, - price:true, - fk_product_category:true, - fk_store:true, - visible:true, - created_at:true, - updated_at:true, - - product_category:{ - select:{ - id_product_category:true, - name:true, - status:true - } - }, - - product_tag_relations:{ - where:{status:true}, - - select:{ - product_tag:{ - select:{ - id_product_tag:true, - name:true - } + const [totalProducts, products] = await Promise.all([ + prisma.products.count({ where }), // se calcula el total de productos que cumplen el filtro + prisma.products.findMany({ + // se trae los productos segun el filtro + where, + skip, + take: limit, + orderBy, + select: { + id_product: true, + name: true, + description: true, + price: true, + store: { + select: { + id_store: true, + name: true } } - } - + }) + ]); + return { + products, + pagination: { + totalProducts, + page, + limit, + totalPages: Math.ceil(totalProducts / limit) } + }; +}; +export const getProductByIdService = async (id) => { + const productId = parsePositiveInteger(id, "ID de producto"); + const product = await getProductResponseByIdService(productId, prisma, { + requireVisible: true }); - if(!product){ - + if (!product) { return null; - } return mapProductResponse(product); - -}; \ No newline at end of file +}; diff --git a/src/modules/users/addresses/controllers/addresses.controllers.js b/src/modules/users/addresses/controllers/addresses.controllers.js index 7c7a13f..c797d10 100644 --- a/src/modules/users/addresses/controllers/addresses.controllers.js +++ b/src/modules/users/addresses/controllers/addresses.controllers.js @@ -1,12 +1,91 @@ -import { updateAddressService } from "../services/addresses.services.js"; +//addresses.controllers.js +import { + createAddressService, + deleteAddressService, + getAddressByIdService, + getAddressesByUserService, + updateAddressService, +} from "../services/addresses.services.js"; +const getErrorStatusCode = (error) => error.statusCode || error.status || 500; + +// crea una direccion para el usuario autenticado +export const createAddress = async (req, res) => { + try { + const { id } = req.params; + + const address = await createAddressService( + req.user?.id_user, + id, + req.body + ); + + return res.status(201).json({ + success: true, + message: "Direccion creada exitosamente", + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// devuelve la lista de direcciones del usuario autenticado +export const getAddressesByUser = async (req, res) => { + try { + const { id } = req.params; + + const addresses = await getAddressesByUserService( + req.user?.id_user, + id + ); + + return res.status(200).json({ + success: true, + data: addresses, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// devuelve una direccion puntual del usuario autenticado +export const getAddressById = async (req, res) => { + try { + const { id, id_address } = req.params; + + const address = await getAddressByIdService( + req.user?.id_user, + id, + id_address + ); + + return res.status(200).json({ + success: true, + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// actualiza una direccion puntual del usuario autenticado export const updateAddress = async (req, res) => { try { - const { id_user, id_address } = req.params; + const { id, id_address } = req.params; const address = await updateAddressService( req.user?.id_user, - id_user, + id, id_address, req.body ); @@ -17,7 +96,31 @@ export const updateAddress = async (req, res) => { data: address, }); } catch (error) { - return res.status(error.status || error.statusCode || 500).json({ + return res.status(getErrorStatusCode(error)).json({ + success: false, + message: error.message || "Error interno del servidor", + }); + } +}; + +// desactiva una direccion puntual del usuario autenticado +export const deleteAddress = async (req, res) => { + try { + const { id, id_address } = req.params; + + const address = await deleteAddressService( + req.user?.id_user, + id, + id_address + ); + + return res.status(200).json({ + success: true, + message: "Direccion desactivada correctamente", + data: address, + }); + } catch (error) { + return res.status(getErrorStatusCode(error)).json({ success: false, message: error.message || "Error interno del servidor", }); diff --git a/src/modules/users/addresses/routes/addresses.routes.js b/src/modules/users/addresses/routes/addresses.routes.js index 84b5c1a..39b63f7 100644 --- a/src/modules/users/addresses/routes/addresses.routes.js +++ b/src/modules/users/addresses/routes/addresses.routes.js @@ -1,9 +1,25 @@ +//addresses.routes.js import { Router } from "express"; import authenticate from "../../../../config/jwt.config.js"; -import { updateAddress } from "../controllers/addresses.controllers.js"; +import { + createAddress, + deleteAddress, + getAddressById, + getAddressesByUser, + updateAddress, +} from "../controllers/addresses.controllers.js"; const router = Router(); -router.put("/:id_user/addresses/:id_address", authenticate, updateAddress); +// crea una nueva direccion personal +router.post("/:id/addresses", authenticate, createAddress); +// lista las direcciones personales activas del usuario +router.get("/:id/addresses", authenticate, getAddressesByUser); +// obtiene una direccion personal puntual +router.get("/:id/addresses/:id_address", authenticate, getAddressById); +// edita una direccion personal puntual +router.put("/:id/addresses/:id_address", authenticate, updateAddress); +// desactiva una direccion personal puntual +router.delete("/:id/addresses/:id_address", authenticate, deleteAddress); export default router; diff --git a/src/modules/users/addresses/services/addresses.services.js b/src/modules/users/addresses/services/addresses.services.js index 41e2fb9..457b58a 100644 --- a/src/modules/users/addresses/services/addresses.services.js +++ b/src/modules/users/addresses/services/addresses.services.js @@ -1,6 +1,10 @@ +//addresses.services.js import { prisma } from "../../../../lib/prisma.js"; -import { getAuthorizedCustomerService } from "../../users/services/users.services.js"; +// limita la cantidad de direcciones activas que puede tener un usuario +const MAX_USER_ADDRESSES = 5; + +// mantiene uniforme la informacion que devolvemos en las respuestas const ADDRESS_SELECT = { id_address: true, fk_user: true, @@ -14,6 +18,7 @@ const ADDRESS_SELECT = { updated_at: true, }; +// asegura que los ids recibidos por params sean enteros positivos const parsePositiveInteger = (value, fieldName) => { const parsedValue = Number(value); @@ -27,134 +32,331 @@ const parsePositiveInteger = (value, fieldName) => { return parsedValue; }; -export const updateAddressService = async ( +// valida que el usuario autenticado sea el mismo de la ruta y que siga activo +const getAuthorizedUserService = async ( authenticatedUserId, - requestedUserId, - requestedAddressId, - payload + requestedUserId ) => { - const customer = await getAuthorizedCustomerService( + if (!authenticatedUserId) { + throw { + status: 401, + message: "Usuario autenticado requerido", + }; + } + + const authenticatedId = parsePositiveInteger( authenticatedUserId, - requestedUserId - ); - const addressId = parsePositiveInteger( - requestedAddressId, - "ID de direccion" + "ID de usuario autenticado" ); + const targetUserId = parsePositiveInteger(requestedUserId, "ID de usuario"); - const existingAddress = await prisma.addresses.findFirst({ - where: { - id_address: addressId, - fk_user: customer.id_user, - fk_store: null, - status: true, - }, + if (authenticatedId !== targetUserId) { + throw { + status: 403, + message: "No tiene permisos para gestionar estas direcciones", + }; + } + + const user = await prisma.users.findUnique({ + where: { id_user: targetUserId }, select: { - id_address: true, + id_user: true, + status: true, }, }); - if (!existingAddress) { + if (!user || !user.status) { throw { status: 404, - message: "Direccion no encontrada", + message: "Usuario no encontrado o inactivo", }; } - const dataToUpdate = {}; + return user; +}; - if (payload?.address !== undefined) { - const address = payload.address?.toString().trim(); +// valida campos de texto obligatorios al crear o editar una direccion +const validateRequiredStringField = (value, fieldName, maxLength = null) => { + const parsedValue = value?.toString().trim(); - if (!address) { - throw { - status: 400, - message: "address no puede estar vacio", - }; - } + if (!parsedValue) { + throw { + status: 400, + message: `${fieldName} no puede estar vacio`, + }; + } - dataToUpdate.address = address; + if (maxLength && parsedValue.length > maxLength) { + throw { + status: 400, + message: `${fieldName} no puede superar ${maxLength} caracteres`, + }; } - if (payload?.city !== undefined) { - const city = payload.city?.toString().trim(); + return parsedValue; +}; - if (!city) { - throw { - status: 400, - message: "city no puede estar vacio", - }; - } +// normaliza campos opcionales para permitir null cuando corresponda +const validateOptionalStringField = (value, fieldName, maxLength = null) => { + if (value === undefined) { + return undefined; + } - if (city.length > 100) { - throw { - status: 400, - message: "city no puede superar 100 caracteres", - }; - } + if (value === null || value === "") { + return null; + } + + const parsedValue = value.toString().trim(); + + if (!parsedValue) { + throw { + status: 400, + message: `${fieldName} no puede estar vacio`, + }; + } - dataToUpdate.city = city; + if (maxLength && parsedValue.length > maxLength) { + throw { + status: 400, + message: `${fieldName} no puede superar ${maxLength} caracteres`, + }; } - if (payload?.region !== undefined) { - const region = payload.region?.toString().trim(); + return parsedValue; +}; + +// centraliza la validacion del payload para reutilizarla en create y update +const buildAddressData = (payload, { requireAllFields = false } = {}) => { + const data = {}; + + if (requireAllFields || payload?.address !== undefined) { + data.address = validateRequiredStringField(payload?.address, "address"); + } + + if (requireAllFields || payload?.city !== undefined) { + data.city = validateRequiredStringField(payload?.city, "city", 100); + } + + if (requireAllFields || payload?.region !== undefined) { + data.region = validateRequiredStringField(payload?.region, "region", 100); + } + + if (requireAllFields || payload?.postal_code !== undefined) { + const postalCode = validateOptionalStringField( + payload?.postal_code, + "postal_code", + 20 + ); + + data.postal_code = postalCode ?? null; + } - if (!region) { + if (!requireAllFields && Object.keys(data).length === 0) { + throw { + status: 400, + message: + "Debe enviar al menos uno de estos campos: address, city, region, postal_code", + }; + } + + return data; +}; + +// busca una direccion personal activa y confirma que pertenezca al usuario autenticado +const getOwnedPersonalAddressOrThrow = async (userId, requestedAddressId) => { + const addressId = parsePositiveInteger(requestedAddressId, "ID de direccion"); + + const address = await prisma.addresses.findFirst({ + where: { + id_address: addressId, + fk_user: userId, + fk_store: null, + status: true, + }, + select: ADDRESS_SELECT, + }); + + if (!address) { + throw { + status: 404, + message: "Direccion no encontrada", + }; + } + + return address; +}; + +// crea una direccion personal y valida el limite maximo de direcciones activas +export const createAddressService = async ( + authenticatedUserId, + requestedUserId, + payload +) => { + const user = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); + const dataToCreate = buildAddressData(payload, { requireAllFields: true }); + + const newAddress = await prisma.$transaction(async (tx) => { + const lockedUsers = await tx.$queryRaw` + SELECT 1 + FROM "Users" + WHERE "id_user" = ${user.id_user} + AND "status" = TRUE + FOR UPDATE + `; + + if (!lockedUsers.length) { throw { - status: 400, - message: "region no puede estar vacio", + status: 404, + message: "Usuario no encontrado o inactivo", }; } - if (region.length > 100) { + const addressesFromUser = await tx.addresses.count({ + where: { + fk_user: user.id_user, + fk_store: null, + status: true, + }, + }); + + if (addressesFromUser >= MAX_USER_ADDRESSES) { throw { status: 400, - message: "region no puede superar 100 caracteres", + message: `El usuario ya alcanzo el limite de ${MAX_USER_ADDRESSES} direcciones`, }; } - dataToUpdate.region = region; - } + return tx.addresses.create({ + data: { + fk_user: user.id_user, + fk_store: null, + ...dataToCreate, + }, + select: ADDRESS_SELECT, + }); + }); - if (payload?.postal_code !== undefined) { - if (payload.postal_code === null || payload.postal_code === "") { - dataToUpdate.postal_code = null; - } else { - const postalCode = payload.postal_code.toString().trim(); + return newAddress; +}; - if (!postalCode) { - throw { - status: 400, - message: "postal_code no puede estar vacio", - }; - } +// devuelve la lista de direcciones personales activas del usuario autenticado +export const getAddressesByUserService = async ( + authenticatedUserId, + requestedUserId +) => { + const user = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); - if (postalCode.length > 20) { - throw { - status: 400, - message: "postal_code no puede superar 20 caracteres", - }; - } + const addresses = await prisma.addresses.findMany({ + where: { + fk_user: user.id_user, + fk_store: null, + status: true, + }, + orderBy: { + created_at: "desc", + }, + select: ADDRESS_SELECT, + }); - dataToUpdate.postal_code = postalCode; - } - } + return addresses; +}; + +// devuelve una sola direccion si pertenece al usuario autenticado +export const getAddressByIdService = async ( + authenticatedUserId, + requestedUserId, + requestedAddressId +) => { + const user = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); - if (Object.keys(dataToUpdate).length === 0) { + return getOwnedPersonalAddressOrThrow(user.id_user, requestedAddressId); +}; + +// actualiza solo los campos enviados de una direccion personal del usuario +export const updateAddressService = async ( + authenticatedUserId, + requestedUserId, + requestedAddressId, + payload +) => { + const user = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); + const addressId = parsePositiveInteger(requestedAddressId, "ID de direccion"); + const dataToUpdate = buildAddressData(payload); + + const updatedAddresses = await prisma.addresses.updateMany({ + where: { + id_address: addressId, + fk_user: user.id_user, + fk_store: null, + status: true, + }, + data: dataToUpdate, + }); + + if (updatedAddresses.count !== 1) { throw { - status: 400, - message: - "Debe enviar al menos uno de estos campos: address, city, region, postal_code", + status: 404, + message: "Direccion no encontrada", }; } - const updatedAddress = await prisma.addresses.update({ + const updatedAddress = await prisma.addresses.findUnique({ where: { id_address: addressId, }, - data: dataToUpdate, select: ADDRESS_SELECT, }); + if (!updatedAddress) { + throw { + status: 500, + message: "No se pudo recuperar la direccion actualizada", + }; + } + return updatedAddress; }; + +// aplica borrado logico para que la direccion deje de estar disponible +export const deleteAddressService = async ( + authenticatedUserId, + requestedUserId, + requestedAddressId +) => { + const user = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); + const existingAddress = await getOwnedPersonalAddressOrThrow( + user.id_user, + requestedAddressId + ); + + const deletedAddress = await prisma.addresses.update({ + where: { + id_address: existingAddress.id_address, + }, + data: { + status: false, + }, + select: { + id_address: true, + status: true, + updated_at: true, + }, + }); + + return deletedAddress; +}; diff --git a/src/modules/users/users/controllers/users.controllers.js b/src/modules/users/users/controllers/users.controllers.js index 1c64da7..82f69fd 100644 --- a/src/modules/users/users/controllers/users.controllers.js +++ b/src/modules/users/users/controllers/users.controllers.js @@ -2,6 +2,7 @@ import { createUserService, updateUserPasswordService, updateUserService, + getUserProfileService } from "../services/users.services.js"; const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; @@ -93,3 +94,29 @@ export const updateUserPassword = async (req, res) => { }); } } +export const getUserProfile = async (req,res) => { + + try{ + + const {id_user} = req.params; + + const user = await getUserProfileService( + req.user?.id_user, + id_user + ); + + return res.status(200).json({ + success:true, + data:user + }); + + }catch(error){ + + return res.status(error.status || 500).json({ + success:false, + message:error.message + }); + + } + +} \ No newline at end of file diff --git a/src/modules/users/users/routes/users.routes.js b/src/modules/users/users/routes/users.routes.js index 906b207..d83b0d0 100644 --- a/src/modules/users/users/routes/users.routes.js +++ b/src/modules/users/users/routes/users.routes.js @@ -4,6 +4,7 @@ import { registerUser, updateUser, updateUserPassword, + getUserProfile } from "../controllers/users.controllers.js"; const router = Router(); @@ -12,5 +13,5 @@ const router = Router(); router.post("/register", registerUser); router.put("/:id_user", authenticate, updateUser); router.put("/:id_user/password", authenticate, updateUserPassword); - -export default router; +router.get("/:id_user", authenticate, getUserProfile); +export default router; \ No newline at end of file diff --git a/src/modules/users/users/services/users.services.js b/src/modules/users/users/services/users.services.js index 1d95906..bb3b290 100644 --- a/src/modules/users/users/services/users.services.js +++ b/src/modules/users/users/services/users.services.js @@ -98,6 +98,48 @@ export const getAuthorizedCustomerService = async ( return user; }; +const getAuthorizedUserService = async ( + authenticatedUserId, + requestedUserId +) => { + if (!authenticatedUserId) { + throw { + status: 401, + message: "Usuario autenticado requerido", + }; + } + + const authenticatedId = parsePositiveInteger( + authenticatedUserId, + "ID de usuario autenticado" + ); + const targetUserId = parsePositiveInteger(requestedUserId, "ID de usuario"); + + if (authenticatedId !== targetUserId) { + throw { + status: 403, + message: "No tiene permisos para ver este perfil", + }; + } + + const user = await prisma.users.findUnique({ + where: { id_user: targetUserId }, + select: { + id_user: true, + status: true, + }, + }); + + if (!user || !user.status) { + throw { + status: 404, + message: "Usuario no encontrado o inactivo", + }; + } + + return user; +}; + export const createUserService = async (data) => { const { name, @@ -320,4 +362,27 @@ export const updateUserPasswordService = async ( return updatedUser; }; +export const getUserProfileService = async (authenticatedUserId, requestedUserId) => { + + const userProfile = await getAuthorizedUserService( + authenticatedUserId, + requestedUserId + ); + + const user = await prisma.users.findFirst({ + where: { + id_user: userProfile.id_user, + status: true, + }, + select: USER_PROFILE_SELECT + }); + + if (!user) { + throw { + status: 404, + message: "Usuario no encontrado o inactivo", + }; + } + return user; +};