feat(invitations): flujo de invitacion de clientes por email

Backend:
- Nuevo modelo Prisma ClientInvitation con token unico, expiracion
  y estados (pending/accepted/expired).
- Migracion: 20260511213955_add_client_invitations
- Service client-invitations.service.ts: crear invitacion,
  validar token, registrar desde invitacion (reutiliza logica
  de creacion de tenant + usuario de despacho.service).
- Controller + routes: POST /invitations/client (admin),
  GET /invitations/client/validate/:token (publico),
  POST /invitations/client/register/:token (publico),
  GET /invitations/client (admin).
- Email template client-invitation.ts con link a
  /invitacion/registro/{token}.
- Agregado sendClientInvitation a email.service.

Frontend:
- Pagina /invitacion/registro/[token] para que el invitado
  complete registro (nombre, password, despacho, RFC, perfil).
- Pagina /admin/invitar-cliente para que admin global envie
  invitaciones y vea el historial.
- Hooks useCreateInvitation, useValidateInvitationToken,
  useRegisterFromInvitation, useClientInvitations.
- API client lib/api/client-invitations.ts.

Infra:
- PM2 ecosystem.config.js: usa node --import tsx con
  kill_timeout aumentado a 15s para evitar EADDRINUSE.
- React Query retry=2 con delay exponencial para resiliencia.

Refs: docs/CAMBIOS-2026-05-09.md
This commit is contained in:
Horux Dev
2026-05-11 22:03:03 +00:00
parent 0a63593aab
commit 745bc8385b
12 changed files with 859 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
import crypto from 'crypto';
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword } from '../auth/passwords.js';
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
import { emailService } from './email/email.service.js';
import type { JWTPayload, Role } from '@horux/shared';
const INVITATION_EXPIRY_DAYS = 7;
export async function createInvitation(data: {
email: string;
invitedBy: string;
invitedByName: string;
nombreDespacho?: string;
rfc?: string;
}) {
const { email, invitedBy, invitedByName, nombreDespacho, rfc } = data;
const normalizedEmail = email.toLowerCase().trim();
// Verificar que no exista un usuario con este email
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existingUser) {
throw new Error('Ya existe un usuario registrado con este email');
}
// Verificar que no haya una invitación pendiente para este email
const existingPending = await prisma.clientInvitation.findFirst({
where: { email: normalizedEmail, status: 'pending' },
});
if (existingPending) {
throw new Error('Ya existe una invitación pendiente para este email');
}
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
const invitation = await prisma.clientInvitation.create({
data: {
email: normalizedEmail,
invitedBy,
nombreDespacho: nombreDespacho || null,
rfc: rfc?.toUpperCase() || null,
token,
expiresAt,
},
});
const baseUrl = process.env.WEB_URL || 'https://horuxfin.com';
const registerUrl = `${baseUrl}/invitacion/registro/${token}`;
await emailService.sendClientInvitation(normalizedEmail, {
invitedByName,
registerUrl,
expiresAt: expiresAt.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
nombreDespacho: nombreDespacho || null,
});
return invitation;
}
export async function validateInvitationToken(token: string) {
const invitation = await prisma.clientInvitation.findUnique({ where: { token } });
if (!invitation) {
throw new Error('Invitación no encontrada');
}
if (invitation.status !== 'pending') {
throw new Error(`Invitación ya ${invitation.status === 'accepted' ? 'aceptada' : 'expirada'}`);
}
if (invitation.expiresAt < new Date()) {
await prisma.clientInvitation.update({
where: { id: invitation.id },
data: { status: 'expired' },
});
throw new Error('Invitación expirada');
}
return invitation;
}
export async function registerFromInvitation(
token: string,
data: {
nombre: string;
password: string;
nombreDespacho: string;
rfc: string;
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
codigoPostal?: string;
}
) {
const invitation = await validateInvitationToken(token);
// Verificar nuevamente que no exista el usuario
const existingUser = await prisma.user.findUnique({ where: { email: invitation.email } });
if (existingUser) {
throw new Error('Ya existe un usuario registrado con este email');
}
const passwordHash = await hashPassword(data.password);
const tenantSlug = `despacho_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
const databaseName = `horux_${tenantSlug}`;
const result = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
nombre: data.nombreDespacho,
rfc: data.rfc.toUpperCase(),
plan: 'trial',
databaseName,
verticalProfile: data.verticalProfile as any,
dbMode: 'MANAGED',
dbSchemaVersion: 0,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
codigoPostal: data.codigoPostal,
},
});
const user = await tx.user.create({
data: {
email: invitation.email,
passwordHash,
nombre: data.nombre,
lastTenantId: tenant.id,
},
});
const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRole) throw new Error('Rol owner no encontrado en BD');
await tx.tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
rolId: ownerRole.id,
isOwner: true,
},
});
await tx.clientInvitation.update({
where: { id: invitation.id },
data: { status: 'accepted', acceptedAt: new Date() },
});
return { tenant, user };
});
try {
await tenantDb.provisionDatabase(tenantSlug, databaseName);
} catch (err: any) {
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
}
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId: result.user.id,
email: result.user.email,
role: 'owner' as Role,
tenantId: result.tenant.id,
tokenVersion: 0,
};
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
await prisma.refreshToken.create({
data: {
userId: result.user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Send welcome email (fire-and-forget)
emailService.sendDespachoWelcome(result.user.email, {
nombre: result.user.nombre,
despachoNombre: result.tenant.nombre,
email: result.user.email,
}).catch(err => console.error('[Invitation] Welcome email failed:', err));
return {
accessToken,
refreshToken,
user: {
id: result.user.id,
email: result.user.email,
nombre: result.user.nombre,
role: 'owner' as Role,
tenantId: result.tenant.id,
tenantName: result.tenant.nombre,
tenantRfc: result.tenant.rfc,
plan: result.tenant.plan,
tenants: [{
id: result.tenant.id,
nombre: result.tenant.nombre,
rfc: result.tenant.rfc,
plan: result.tenant.plan,
role: 'owner' as Role,
isOwner: true,
}],
},
};
}
export async function listInvitations() {
return prisma.clientInvitation.findMany({
orderBy: { createdAt: 'desc' },
});
}

View File

@@ -80,6 +80,16 @@ export const emailService = {
await sendEmail(env.ADMIN_EMAIL, `Factura pendiente: primer pago de ${data.clienteNombre}`, primerPagoFacturarEmail(data));
},
sendClientInvitation: async (to: string, data: {
invitedByName: string;
registerUrl: string;
expiresAt: string;
nombreDespacho?: string | null;
}) => {
const { clientInvitationEmail } = await import('./templates/client-invitation.js');
await sendEmail(to, 'Invitación a Horux Despachos', clientInvitationEmail(data));
},
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));

View File

@@ -0,0 +1,64 @@
export interface ClientInvitationData {
invitedByName: string;
registerUrl: string;
expiresAt: string;
nombreDespacho?: string | null;
}
export function clientInvitationEmail(data: ClientInvitationData): string {
const despachoLine = data.nombreDespacho
? `<p>Has sido invitado a unirte a <strong>${data.nombreDespacho}</strong> en Horux Despachos.</p>`
: `<p>Has sido invitado a crear tu cuenta en <strong>Horux Despachos</strong>.</p>`;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #1e40af; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #1e40af; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.highlight { background: #dbeafe; padding: 15px; border-radius: 6px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📨 Invitación a Horux Despachos</h1>
</div>
<div class="content">
<p>Hola,</p>
${despachoLine}
<p>${data.invitedByName} te ha enviado esta invitación para que crees tu cuenta y empieces a gestionar tu despacho.</p>
<div class="highlight">
<strong>¿Qué es Horux Despachos?</strong>
<ul>
<li>Gestión de CFDIs emitidos y recibidos</li>
<li>Conciliación bancaria</li>
<li>Alertas fiscales automáticas</li>
<li>Sincronización con el SAT</li>
<li>Declaraciones y obligaciones</li>
</ul>
</div>
<p style="text-align: center;">
<a href="${data.registerUrl}" class="button">Crear mi cuenta</a>
</p>
<p><strong>Importante:</strong> Esta invitación expira el <strong>${data.expiresAt}</strong>. Si no la usas antes de esa fecha, deberás solicitar una nueva.</p>
<p>Si tienes alguna duda, contacta a nuestro equipo de soporte.</p>
</div>
<div class="footer">
<p>Horux Despachos — Simplificando la contabilidad</p>
</div>
</div>
</body>
</html>
`;
}