feat: agregar lógica para evitar duplicados de clientes por email y vincular usuarios existentes

This commit is contained in:
2026-10-06 11:52:24 -03:00
parent 13ba0fa4de
commit 0d6231a02e
4 changed files with 351 additions and 11 deletions
@@ -0,0 +1,8 @@
db.clients.aggregate([
{ $group: {
_id: { companyId: "$companyId", email: { $toLower: "$email" } },
count: { $sum: 1 },
emails: { $addToSet: "$email" }
} },
{ $match: { count: { $gt: 1 }, $expr: { $gt: [{ $size: "$emails" }, 1] } } }
])
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
banned: { type: Boolean, required: false, default: false },
});
//Unicidad por email dentro de la organización. La unicidad por usuario es
//parcial: excluye los clientes cuyo userId fue removido con $unset.
this.schema.index({ companyId: 1, email: 1 }, { unique: true });
this.schema.index(
{ companyId: 1, userId: 1 },
{ unique: true, partialFilterExpression: { userId: { $type: "oid" } } }
);
this.clientList = model<IClientDocument>("Client", this.schema);
}
+51 -11
View File
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
throw new Error("El cliente ya pertenece a la compañia");
}
//Si la organización ya tiene un cliente con el email del usuario, se
//vincula el usuario a ese cliente en lugar de crear un duplicado.
const existingByEmail = await this.clients.findOne({
companyId: data.companyId,
email: userCheck.email,
});
if (existingByEmail) {
existingByEmail.userId = data.userId;
await existingByEmail.save();
await NotificationsManager.sendSystemNotification({
userId: String(userCheck._id),
subject: "Ha sido dado de alta como cliente",
message: `Su usuario ha sido dado de alta como cliente en la organización ${companyCheck.name}`,
type: NotificationType.BILLING
});
return existingByEmail;
}
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
if (!canAdd) {
@@ -274,17 +295,36 @@ class ClientManager implements IClientsManager {
);
}
const newClient = await this.clients.create({
...data,
...{
email: userCheck.email,
firstName: userCheck.firstName,
lastName: userCheck.lastName,
phoneAreaCode: userCheck.phoneAreaCode,
phoneCountryCode: userCheck.phoneCountryCode,
phoneNumber: userCheck.phoneNumber,
},
});
let newClient: IClientDocument;
try {
newClient = await this.clients.create({
...data,
...{
email: userCheck.email,
firstName: userCheck.firstName,
lastName: userCheck.lastName,
phoneAreaCode: userCheck.phoneAreaCode,
phoneCountryCode: userCheck.phoneCountryCode,
phoneNumber: userCheck.phoneNumber,
},
});
} catch (error) {
//Carrera de concurrencia: el índice único rechazó el duplicado y la
//petición ganadora ya creó el cliente, lo contabilizó y notificó.
if ((error as { code?: number })?.code === 11000) {
const racedClient = await this.clients.findOne({
companyId: data.companyId,
userId: data.userId,
});
if (racedClient) {
return racedClient;
}
}
throw error;
}
await MetricsList.addClient({
userId: String(companyCheck.ownerId),
@@ -0,0 +1,284 @@
// Unit tests for ClientManager.createClientByUser: adopt-by-email path, create
// path, the existing userId guard, and the E11000 concurrent-create race.
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Employees/Employee", () => ({
__esModule: true,
default: {
checkPermission: jest.fn(),
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
canAddClient: jest.fn(),
addClient: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
Appointments: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
delete: jest.fn(),
},
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
deleteRepeatsByClient: jest.fn(),
},
}));
jest.mock("../../ClientAccounts/ClientAccount", () => ({
__esModule: true,
default: {
getAvailableAmount: jest.fn(),
deleteClientAccount: jest.fn(),
},
}));
jest.mock("../../Payments/Payments", () => ({
__esModule: true,
default: {
deletePaymentsByClient: jest.fn(),
},
}));
import ClientList from "../Clients";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
import { NotificationsManager } from "../../Notifications/Notifications";
import { NotificationType } from "../../SystemNotifications/SystemNotification.Interface";
const request = {
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
};
const sessionUser = { _id: "session-user" };
const company = {
_id: "company-001",
ownerId: "owner-user",
name: "Acme",
};
const user = {
_id: "user-001",
email: "client@example.com",
firstName: "John",
lastName: "Doe",
phoneAreaCode: "11",
phoneCountryCode: "54",
phoneNumber: "12345678",
};
// Helper to build a mock client document with save()
function buildClientDoc(overrides: Record<string, any> = {}): Record<string, any> {
return {
_id: "client-001",
id: "client-001",
companyId: "company-001",
email: "client@example.com",
firstName: "Admin Entered Name",
lastName: "Admin Entered Lastname",
save: jest.fn().mockResolvedValue(true),
...overrides,
};
}
describe("createClientByUser", () => {
// Keep the real adapter to restore it after each test and to inspect indexes
const realAdapter = ClientList.clients;
let mockFindOne: jest.Mock;
let mockCreate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockFindOne = jest.fn();
mockCreate = jest.fn();
(ClientList as any).clients = {
findOne: mockFindOne,
create: mockCreate,
};
// First call: session user validation. Second call: target user lookup.
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce(sessionUser)
.mockResolvedValueOnce(user);
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(company);
});
afterEach(() => {
(ClientList as any).clients = realAdapter;
});
// --- Adopt path: existing client by email ---
it("adopts the existing client found by email instead of creating a duplicate", async () => {
const existingClient = buildClientDoc();
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(existingClient); // lookup by companyId + email
const result = await ClientList.createClientByUser(request);
expect(mockCreate).not.toHaveBeenCalled();
expect(existingClient.userId).toBe("user-001");
expect(existingClient.save).toHaveBeenCalled();
// Admin-entered data must not be overwritten by the adopt path
expect(existingClient.firstName).toBe("Admin Entered Name");
expect(existingClient.lastName).toBe("Admin Entered Lastname");
// The client was already counted, so metrics are untouched
expect(MetricsList.canAddClient).not.toHaveBeenCalled();
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(existingClient);
});
// --- Create path: no client by userId nor email ---
it("creates a new client when no client exists by userId nor email", async () => {
const newClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockResolvedValue(newClient);
const result = await ClientList.createClientByUser(request);
expect(mockCreate).toHaveBeenCalledWith({
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
phoneAreaCode: user.phoneAreaCode,
phoneCountryCode: user.phoneCountryCode,
phoneNumber: user.phoneNumber,
});
expect(MetricsList.addClient).toHaveBeenCalledWith({
userId: "owner-user",
quantity: 1,
});
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(newClient);
});
// --- Existing guard preserved ---
it("throws when the client already belongs to the company", async () => {
mockFindOne.mockResolvedValueOnce(buildClientDoc()); // guard by companyId + userId
await expect(ClientList.createClientByUser(request)).rejects.toThrow(
/ya pertenece a la compan/
);
expect(mockCreate).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
// --- E11000 concurrent-create race ---
it("returns the winning client when create hits the unique index (E11000)", async () => {
const racedClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null) // lookup by companyId + email
.mockResolvedValueOnce(racedClient); // re-fetch after E11000
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue({ code: 11000 });
const result = await ClientList.createClientByUser(request);
expect(result).toBe(racedClient);
// The winning request already counted and notified
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
it("rethrows non-duplicate-key errors from create", async () => {
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue(new Error("database down"));
await expect(ClientList.createClientByUser(request)).rejects.toThrow("database down");
});
// --- Schema indexes ---
it("declares unique indexes for companyId+email and companyId+userId", () => {
const indexes = realAdapter.schema.indexes();
const emailIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.email === 1
);
expect(emailIndex).toBeDefined();
expect(emailIndex?.[1]).toMatchObject({ unique: true });
const userIdIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.userId === 1
);
expect(userIdIndex).toBeDefined();
expect(userIdIndex?.[1]).toMatchObject({
unique: true,
partialFilterExpression: { userId: { $type: "oid" } },
});
});
});