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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Orders" ADD COLUMN "shipping_cost" DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD COLUMN "shipping_distance_km" DECIMAL(8,2);
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ model Orders {
fk_store Int
fk_address Int?
fk_cart Int @unique //El carrito se relaciona en un pedido al confirmarse la compra, y ese mismo carrito no puede concretarse en un pedido multiples veces
shipping_cost Decimal @default(0) @db.Decimal(10, 2)
shipping_distance_km Decimal? @db.Decimal(8, 2)
total Decimal @db.Decimal(10, 2)
order_status OrderStatus @default(PENDING) // por defecto se crea en estado pendiente
notes String? @db.Text
Expand Down
21 changes: 19 additions & 2 deletions src/docs/schemas/commerce/store.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@ export const storeSchemas = {
// ─── REQUESTS ──────────────────────────────────────────────────
CreateStoreRequest: {
type: "object",
required: ["fk_store_category", "name", "email", "phone", "address", "city", "region"],
required: [
"fk_store_category",
"name",
"email",
"phone",
"address",
"latitude",
"longitude",
"base_price",
"distance_price"
],
properties: {
fk_store_category: { type: "integer", example: 2 },
name: { type: "string", maxLength: 100, example: "Mi Comercio" },
Expand All @@ -14,9 +24,13 @@ export const storeSchemas = {
instagram_url: { type: "string", nullable: true, example: "https://instagram.com/micomercio" },
tiktok_url: { type: "string", nullable: true, example: "https://tiktok.com/@micomercio" },
address: { type: "string", example: "Av. España 1234" },
latitude: { type: "number", example: -25.2961 },
longitude: { type: "number", example: -57.6222 },
city: { type: "string", maxLength: 100, example: "Asunción" },
region: { type: "string", maxLength: 100, example: "Villa Morra" },
postal_code: { type: "string", maxLength: 20, nullable: true, example: "1209" }
postal_code: { type: "string", maxLength: 20, nullable: true, example: "1209" },
base_price: { type: "number", minimum: 0, example: 2500 },
distance_price: { type: "number", minimum: 0, example: 4000 }
Comment on lines +27 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

No documentes como input campos que el alta recalcula sola.

createStoreService deriva city, region y postal_code desde latitude y longitude. Si Swagger los sigue mostrando como parte del request, parece que el cliente puede controlarlos cuando en realidad esos valores se ignoran.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/docs/schemas/commerce/store.schema.js` around lines 27 - 33, Los campos
city, region y postal_code aparecen en el schema de input pero son calculados
por createStoreService a partir de latitude/longitude; actualiza el schema para
que esos tres campos no formen parte del payload editable (por ejemplo
marcándolos como readOnly: true o moviéndolos al schema de response y
quitándolos de required), dejando latitude/longitude como inputs requeridos;
asegúrate de ajustar las propiedades city, region y postal_code en
src/docs/schemas/commerce/store.schema.js (los nombres city, region,
postal_code) para reflejar que el cliente no debe enviarlos.

}
},

Expand All @@ -36,6 +50,9 @@ export const storeSchemas = {
city: { type: "string", maxLength: 100 },
region: { type: "string", maxLength: 100 },
postal_code: { type: "string", maxLength: 20, nullable: true }
,
base_price: { type: "number", minimum: 0 },
distance_price: { type: "number", minimum: 0 }
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expresá en OpenAPI que base_price y distance_price son un par.

El update del comercio rechaza payloads donde llegue uno sin el otro, pero este schema los deja como opcionales independientes. La documentación termina validando requests que siempre van a caer en 400.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/docs/schemas/commerce/store.schema.js` around lines 53 - 55, El schema
actual declara base_price y distance_price como números independientes, pero la
API exige que vengan como par (ambos presentes o ninguno). Updatea el schema en
store.schema.js (referencia a las propiedades base_price y distance_price) para
forzar la relación "ambos o ninguno": reemplaza la simple declaración por una
combinación de validadores JSON Schema/OpenAPI (por ejemplo un oneOf que
contenga 1) un objeto con required: ["base_price","distance_price"] y 2) un
objeto que niegue la presencia de cualquiera de los dos cuando el otro falta —
de ese modo el spec refleja que solo se aceptan payloads con ambos campos o sin
ninguno.

}
},

Expand Down
144 changes: 137 additions & 7 deletions src/modules/commerce/commerces/store.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { validateStoreCategoryService } from "../store-categories/store-category

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ALLOWED_LOCAL_LOGO_DIRECTORIES = new Set(["uploads", "storage", "public"]);
const DISTANCE_THRESHOLD_KM = 2;
const STORE_RESPONSE_SELECT = {
id_store: true,
fk_user: true,
Expand Down Expand Up @@ -71,6 +72,18 @@ const STORE_RESPONSE_SELECT = {
created_at: true,
updated_at: true
}
},
shipping_zones: {
where: { status: true },
orderBy: { created_at: "asc" },
select: {
id_shipping_zone: true,
base_price: true,
distance_price: true,
status: true,
created_at: true,
updated_at: true
}
}
};

Expand Down Expand Up @@ -178,6 +191,29 @@ const validateCoordinateField = (value, fieldName, min, max) => {
return parsedValue;
};

const validateNonNegativeNumberField = (value, fieldName) => {
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "")
) {
throw {
status: 400,
message: `${fieldName} es requerido`
};
}

const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue < 0) {
throw {
status: 400,
message: `${fieldName} no puede ser negativo`
};
}

return Number(parsedValue.toFixed(2));
};
Comment on lines +194 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mensaje de error impreciso para valores no numéricos.

Cuando el valor es NaN o Infinity (ej: "abc"), el mensaje dice "no puede ser negativo" lo cual es incorrecto. Deberían diferenciarse los casos.

🔧 Corrección sugerida
 const validateNonNegativeNumberField = (value, fieldName) => {
   if (
     value === null ||
     value === undefined ||
     (typeof value === "string" && value.trim() === "")
   ) {
     throw {
       status: 400,
       message: `${fieldName} es requerido`
     };
   }

   const parsedValue = Number(value);
-  if (!Number.isFinite(parsedValue) || parsedValue < 0) {
+  if (!Number.isFinite(parsedValue)) {
+    throw {
+      status: 400,
+      message: `${fieldName} debe ser un número válido`
+    };
+  }
+
+  if (parsedValue < 0) {
     throw {
       status: 400,
       message: `${fieldName} no puede ser negativo`
     };
   }

   return Number(parsedValue.toFixed(2));
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const validateNonNegativeNumberField = (value, fieldName) => {
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "")
) {
throw {
status: 400,
message: `${fieldName} es requerido`
};
}
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue < 0) {
throw {
status: 400,
message: `${fieldName} no puede ser negativo`
};
}
return Number(parsedValue.toFixed(2));
};
const validateNonNegativeNumberField = (value, fieldName) => {
if (
value === null ||
value === undefined ||
(typeof value === "string" && value.trim() === "")
) {
throw {
status: 400,
message: `${fieldName} es requerido`
};
}
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
throw {
status: 400,
message: `${fieldName} debe ser un número válido`
};
}
if (parsedValue < 0) {
throw {
status: 400,
message: `${fieldName} no puede ser negativo`
};
}
return Number(parsedValue.toFixed(2));
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/commerce/commerces/store.service.js` around lines 194 - 215, The
validateNonNegativeNumberField function currently throws the same "no puede ser
negativo" message for non-numeric/Infinite values; change the validation order
and messages: after computing parsedValue = Number(value), first check if
Number.isFinite(parsedValue) is false and throw a 400 with a message like
`${fieldName} debe ser un número válido` (to cover NaN/Infinity/non-numeric
strings), then separately check if parsedValue < 0 and throw the existing
`${fieldName} no puede ser negativo` error; keep the final return
Number(parsedValue.toFixed(2)). Use the existing function name
validateNonNegativeNumberField and the parsedValue variable to locate where to
adjust the checks and messages.


const getReverseGeocodedAddress = async (latitude, longitude) => {
const params = new URLSearchParams({
format: "json",
Expand Down Expand Up @@ -289,6 +325,14 @@ const mapStoreWithPricedProducts = (store) => {
...store,
products: Array.isArray(store.products)
? store.products.map(mapStoreProductPricing)
: [],
shipping_zones: Array.isArray(store.shipping_zones)
? store.shipping_zones.map((zone) => ({
...zone,
base_price: Number(zone.base_price),
distance_price: Number(zone.distance_price),
distance_threshold_km: DISTANCE_THRESHOLD_KM
}))
: []
};
};
Expand Down Expand Up @@ -414,6 +458,8 @@ export const createStoreService = async (data) => {
address,
latitude,
longitude,
base_price,
distance_price
} = data;

if (
Expand All @@ -424,7 +470,9 @@ export const createStoreService = async (data) => {
!phone ||
!address ||
latitude === undefined ||
longitude === undefined
longitude === undefined ||
base_price === undefined ||
distance_price === undefined
) {
throw { status: 400, message: "Faltan campos obligatorios" };
}
Expand Down Expand Up @@ -467,6 +515,8 @@ export const createStoreService = async (data) => {

const normalizedLatitude = validateCoordinateField(latitude, "latitud", -90, 90);
const normalizedLongitude = validateCoordinateField(longitude, "longitud", -180, 180);
const normalizedBasePrice = validateNonNegativeNumberField(base_price, "base_price");
const normalizedDistancePrice = validateNonNegativeNumberField(distance_price, "distance_price");
const reverseAddress = await getReverseGeocodedAddress(normalizedLatitude, normalizedLongitude);

try {
Expand Down Expand Up @@ -499,8 +549,8 @@ export const createStoreService = async (data) => {
throw { status: 400, message: "Categoria no valida" };
}

const nuevaTienda = await prisma.$transaction(async (tx) => {
const store = await tx.stores.create({
const newStore = await prisma.$transaction(async (tx) => {
const createdStore = await tx.stores.create({
data: {
fk_user,
fk_store_category,
Expand All @@ -518,7 +568,7 @@ export const createStoreService = async (data) => {
await tx.addresses.create({
data: {
fk_user,
fk_store: store.id_store,
fk_store: createdStore.id_store,
address: address.trim(),
city: reverseAddress.city,
region: reverseAddress.region,
Expand All @@ -528,16 +578,28 @@ export const createStoreService = async (data) => {
}
});

await tx.shippingZones.create({
data: {
fk_store: createdStore.id_store,
base_price: normalizedBasePrice,
distance_price: normalizedDistancePrice,
status: true
}
});

// Actualizamos rol del usuario a SELLER al crearse el comercio
await tx.users.update({
where: { id_user: fk_user },
data: { role: "SELLER" }
});

return store;
return tx.stores.findUnique({
where: { id_store: createdStore.id_store },
select: STORE_RESPONSE_SELECT
});
});

return nuevaTienda;
return mapStoreWithPricedProducts(newStore);
} catch (error) {
if (error.code === "P2002") {
throw { status: 409, message: "Email ya registrado" };
Expand All @@ -559,6 +621,7 @@ export const updateStoreService = async (

const dataToUpdate = {};
const addressDataToUpdate = {};
const shippingZoneDataToUpdate = {};
let hasCoordinatesToUpdate = false;

if (payload?.fk_store_category !== undefined) {
Expand Down Expand Up @@ -620,6 +683,8 @@ export const updateStoreService = async (

const hasLatitude = payload?.latitude !== undefined;
const hasLongitude = payload?.longitude !== undefined;
const hasBasePrice = payload?.base_price !== undefined;
const hasDistancePrice = payload?.distance_price !== undefined;

if (hasLatitude !== hasLongitude) {
throw {
Expand All @@ -628,6 +693,24 @@ export const updateStoreService = async (
};
}

if (hasBasePrice !== hasDistancePrice) {
throw {
status: 400,
message: "Debe enviar base_price y distance_price juntos"
};
}

if (hasBasePrice && hasDistancePrice) {
shippingZoneDataToUpdate.base_price = validateNonNegativeNumberField(
payload.base_price,
"base_price"
);
shippingZoneDataToUpdate.distance_price = validateNonNegativeNumberField(
payload.distance_price,
"distance_price"
);
}

if (hasLatitude && hasLongitude) {
const normalizedLatitude = validateCoordinateField(
payload.latitude,
Expand Down Expand Up @@ -656,7 +739,8 @@ export const updateStoreService = async (

if (
Object.keys(dataToUpdate).length === 0 &&
Object.keys(addressDataToUpdate).length === 0
Object.keys(addressDataToUpdate).length === 0 &&
Object.keys(shippingZoneDataToUpdate).length === 0
) {
throw {
status: 400,
Expand All @@ -665,6 +749,7 @@ export const updateStoreService = async (
}

let addressId = null;
let shippingZoneId = null;

if (Object.keys(addressDataToUpdate).length > 0) {
const existingAddress = await prisma.addresses.findFirst({
Expand All @@ -683,6 +768,23 @@ export const updateStoreService = async (
addressId = existingAddress?.id_address ?? null;
}

if (Object.keys(shippingZoneDataToUpdate).length > 0) {
const existingZone = await prisma.shippingZones.findFirst({
where: {
fk_store: store.id_store,
status: true
},
orderBy: {
created_at: "asc"
},
select: {
id_shipping_zone: true
}
});

shippingZoneId = existingZone?.id_shipping_zone ?? null;
Comment on lines +771 to +785

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No asumas una única zona activa por comercio sin respaldarlo en la base.

Acá se busca y actualiza sólo la primera shippingZone activa. Como la PR ya eliminó los campos que distinguían zonas (region/postal_code), cualquier comercio con varias filas históricas queda con registros indistinguibles y sólo uno se corrige; el resto puede seguir saliendo en la API con precios viejos. Hace falta sanear duplicados y forzar unicidad por fk_store.

Also applies to: 832-845

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/commerce/commerces/store.service.js` around lines 771 - 785, Aquí
se asume una única zona activa pero el código usa prisma.shippingZones.findFirst
y sólo actualiza una fila; en lugar de eso usa prisma.shippingZones.findMany
para localizar todas las filas activas para la tienda (fk_store) y sanea
duplicados: actualiza todas las filas relevantes con shippingZoneDataToUpdate (o
marca inactivas las duplicadas) y, si necesitas un id único, elige/consolida uno
(ajustando la variable shippingZoneId) o crea una nueva fila única; modifica las
llamadas que usan prisma.shippingZones.findFirst/update para usar
findMany/updateMany/delete según corresponda y aplica el mismo cambio en los
bloques donde aparece la misma lógica (referencias: prisma.shippingZones,
shippingZoneDataToUpdate, shippingZoneId).

}

try {
const updatedStore = await prisma.$transaction(async (tx) => {
if (Object.keys(dataToUpdate).length > 0) {
Expand Down Expand Up @@ -727,6 +829,21 @@ export const updateStoreService = async (
});
}

if (shippingZoneId) {
await tx.shippingZones.update({
where: { id_shipping_zone: shippingZoneId },
data: shippingZoneDataToUpdate
});
} else if (Object.keys(shippingZoneDataToUpdate).length > 0) {
await tx.shippingZones.create({
data: {
fk_store: store.id_store,
...shippingZoneDataToUpdate,
status: true
}
});
}

return tx.stores.findUnique({
where: { id_store: store.id_store },
select: STORE_RESPONSE_SELECT
Expand Down Expand Up @@ -770,6 +887,7 @@ export const getStoreByIdService = async (id, { ignoreStoreStatus = false } = {}
// Datos del comercio
select: {
id_store: true,
fk_store_category: true,
name: true,
description: true,
logo: true,
Expand Down Expand Up @@ -818,6 +936,18 @@ export const getStoreByIdService = async (id, { ignoreStoreStatus = false } = {}
created_at: true,
updated_at: true
}
},
shipping_zones: {
where: { status: true },
orderBy: { created_at: "asc" },
select: {
id_shipping_zone: true,
base_price: true,
distance_price: true,
status: true,
created_at: true,
updated_at: true
}
}
}
});
Expand Down
Loading
Loading