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
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1",
"nodemailer": "^8.0.7",
"nodemon": "^3.1.14",
"pg": "^8.19.0",
"swagger-jsdoc": "^6.2.8",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Users" ADD COLUMN "password_reset_token" VARCHAR(255),
ADD COLUMN "password_reset_token_expires" TIMESTAMPTZ;
10 changes: 6 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ model Users {
email String @unique @db.VarChar(100)
password_hash String @db.VarChar(255)
phone String? @db.VarChar(20)
avatar_url String? @db.VarChar(500)
status Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @updatedAt @db.Timestamptz
avatar_url String? @db.VarChar(500)
status Boolean @default(true)
password_reset_token String? @db.VarChar(255)
password_reset_token_expires DateTime? @db.Timestamptz
created_at DateTime @default(now()) @db.Timestamptz
updated_at DateTime @updatedAt @db.Timestamptz

//Relaciones
store Stores? //Usuario solo puede tener asociado un comercio
Expand Down
37 changes: 37 additions & 0 deletions src/lib/email.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT || "587"),

Check warning on line 5 in src/lib/email.service.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU8j3qjpjODIObT&open=AZ5GdpU8j3qjpjODIObT&pullRequest=164
secure: process.env.EMAIL_SECURE === "true",
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});

export const sendPasswordResetEmail = async (email, token) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const resetUrl = `${frontendUrl}/restablecer-contrasena/${token}`;

await transporter.sendMail({
from: `"OpenMarket" <${process.env.EMAIL_USER}>`,
to: email,
subject: "Recuperación de contraseña",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #355347;">Recuperación de contraseña</h2>
<p>Recibimos una solicitud para restablecer la contraseña de tu cuenta.</p>
<p>Hacé clic en el botón para crear una nueva contraseña. Este enlace es válido por <strong>10 minutos</strong>.</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}"
style="background-color: #355347; color: #ffffff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: bold; display: inline-block;">
Restablecer contraseña
</a>
</div>
<p style="color: #666; font-size: 14px;">Si no solicitaste este cambio, ignorá este correo. Tu contraseña no será modificada.</p>
<p style="color: #666; font-size: 12px;">O copiá este enlace en tu navegador:<br>${resetUrl}</p>
</div>
`,
});
};
56 changes: 54 additions & 2 deletions src/modules/users/users/controllers/users.controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
createUserService,
updateUserPasswordService,
updateUserService,
getUserProfileService
getUserProfileService,
requestPasswordResetService,
validateResetTokenService,
resetPasswordService,
} from "../services/users.services.js";

const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
Expand Down Expand Up @@ -120,4 +123,53 @@ export const getUserProfile = async (req,res) => {

}

}
}

export const requestPasswordReset = async (req, res) => {
try {
const { email } = req.body;

if (!email) {
return res.status(400).json({ success: false, message: "El email es requerido" });
}

await requestPasswordResetService(email);

// Respuesta genérica para no revelar si el email existe en el sistema
return res.status(200).json({
success: true,
message: "Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.",
});
} catch (error) {
const statusCode = error.statusCode || error.status || 500;
return res.status(statusCode).json({ success: false, message: error.message || "Error interno del servidor" });
}
};

export const validateResetToken = async (req, res) => {
try {
const { token } = req.body;
const result = await validateResetTokenService(token);
return res.status(200).json({ success: true, ...result });
} catch (error) {
const statusCode = error.statusCode || error.status || 500;
return res.status(statusCode).json({ success: false, message: error.message });
}
};

export const resetPassword = async (req, res) => {
try {
const { token, newPassword } = req.body;

if (!token || !newPassword) {
return res.status(400).json({ success: false, message: "Token y nueva contraseña son requeridos" });
}

await resetPasswordService(token, newPassword);

return res.status(200).json({ success: true, message: "Contraseña restablecida exitosamente" });
} catch (error) {
const statusCode = error.statusCode || error.status || 500;
return res.status(statusCode).json({ success: false, message: error.message || "Error interno del servidor" });
}
};
12 changes: 10 additions & 2 deletions src/modules/users/users/routes/users.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import {
registerUser,
updateUser,
updateUserPassword,
getUserProfile
getUserProfile,
requestPasswordReset,
validateResetToken,
resetPassword,
} from "../controllers/users.controllers.js";

const router = Router();

// POST /api/users/register
router.post("/register", registerUser);

// Password reset (sin autenticación)
router.post("/forgot-password", requestPasswordReset);
router.post("/validate-reset-token", validateResetToken);
router.post("/reset-password", resetPassword);

router.put("/:id_user", authenticate, updateUser);
router.put("/:id_user/password", authenticate, updateUserPassword);
router.get("/:id_user", authenticate, getUserProfile);
Expand Down
81 changes: 81 additions & 0 deletions src/modules/users/users/services/users.services.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//users.services.js
import { prisma } from "../../../../lib/prisma.js";
import bcrypt from "bcrypt";
import crypto from "crypto";

Check warning on line 5 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObL&open=AZ5GdpU0j3qjpjODIObL&pullRequest=164
import { sendPasswordResetEmail } from "../../../../lib/email.service.js";

//const prisma = new PrismaClient();
const SALT_ROUNDS = 10;
Expand Down Expand Up @@ -407,4 +409,83 @@
}

return user; // sin restricción de rol
};

const RESET_TOKEN_EXPIRY_MS = 10 * 60 * 1000; // 10 minutos

export const requestPasswordResetService = async (email) => {
if (!email || !EMAIL_REGEX.test(email.trim())) {
throw { status: 400, message: "El formato del email no es válido" };

Check warning on line 418 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObM&open=AZ5GdpU0j3qjpjODIObM&pullRequest=164
}

const user = await prisma.users.findUnique({
where: { email: email.trim().toLowerCase() },
select: { id_user: true, status: true },
});

// No se retorna nada para no dar info de si el correo existe en la database
if (!user || !user.status) return;

Check warning on line 427 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObN&open=AZ5GdpU0j3qjpjODIObN&pullRequest=164

const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + RESET_TOKEN_EXPIRY_MS);

await prisma.users.update({
where: { id_user: user.id_user },
data: {
password_reset_token: token,
password_reset_token_expires: expires,
},
Comment thread
SebaKisser marked this conversation as resolved.
});

await sendPasswordResetEmail(email.trim().toLowerCase(), token);
};

export const validateResetTokenService = async (token) => {
if (!token) {
throw { status: 400, message: "Token requerido" };

Check warning on line 445 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObO&open=AZ5GdpU0j3qjpjODIObO&pullRequest=164
}

const user = await prisma.users.findFirst({
where: {
password_reset_token: token,
password_reset_token_expires: { gt: new Date() },
status: true,
},
select: { id_user: true },
});

if (!user) {
throw { status: 400, message: "El enlace de recuperación no es válido o ha expirado" };

Check warning on line 458 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObP&open=AZ5GdpU0j3qjpjODIObP&pullRequest=164
}

return { valid: true };
};

export const resetPasswordService = async (token, newPassword) => {
if (!token) {
throw { status: 400, message: "Token requerido" };

Check warning on line 466 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObQ&open=AZ5GdpU0j3qjpjODIObQ&pullRequest=164
}

if (!newPassword || newPassword.trim().length < 8) {
throw { status: 400, message: "La nueva contraseña debe tener al menos 8 caracteres" };

Check warning on line 470 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObR&open=AZ5GdpU0j3qjpjODIObR&pullRequest=164
}

const password_hash = await bcrypt.hash(newPassword.trim(), SALT_ROUNDS);

const result = await prisma.users.updateMany({
where: {
password_reset_token: token,
password_reset_token_expires: { gt: new Date() },
status: true,
},
data: {
password_hash,
password_reset_token: null,
password_reset_token_expires: null,
},
});

if (result.count === 0) {
throw { status: 400, message: "El enlace de recuperación no es válido o ha expirado" };

Check warning on line 489 in src/modules/users/users/services/users.services.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ5GdpU0j3qjpjODIObS&open=AZ5GdpU0j3qjpjODIObS&pullRequest=164
}
};
10 changes: 9 additions & 1 deletion tests/e2e/stores.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ vi.mock("../../src/lib/prisma.js", () => ({
findFirst: vi.fn(),
update: vi.fn(),
},
storeBusinessHours: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(),
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
},
}));

Expand Down Expand Up @@ -89,7 +93,11 @@ const mockProducts = [
// ─── GET /api/commerces/:id ───────────────────────────────────────────────────

describe("GET /api/commerces/:id", () => {
beforeEach(() => vi.resetAllMocks());
beforeEach(() => {
vi.resetAllMocks();
prisma.$queryRawUnsafe.mockResolvedValue([]);
prisma.storeBusinessHours.findMany.mockResolvedValue([]);
});

it("devuelve 200 con datos del comercio cuando existe", async () => {
prisma.stores.findUnique.mockResolvedValue(mockStore);
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/commerce/store-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ vi.mock("../../../src/lib/prisma.js", () => ({
products: {
updateMany: vi.fn(),
},
storeBusinessHours: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(),
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
}
}));

Expand Down
Loading
Loading