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:
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "client_invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"invited_by" TEXT NOT NULL,
|
||||
"nombre_despacho" TEXT,
|
||||
"rfc" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"token" TEXT NOT NULL,
|
||||
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"accepted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "client_invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "client_invitations_token_key" ON "client_invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "client_invitations_token_idx" ON "client_invitations"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "client_invitations_status_idx" ON "client_invitations"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "client_invitations_email_idx" ON "client_invitations"("email");
|
||||
@@ -500,6 +500,27 @@ model TrialInvitation {
|
||||
@@map("trial_invitations")
|
||||
}
|
||||
|
||||
/// Invitaciones para nuevos clientes enviadas por admin global.
|
||||
/// El destinatario recibe un email con un link para completar su registro.
|
||||
model ClientInvitation {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
invitedBy String @map("invited_by")
|
||||
nombreDespacho String? @map("nombre_despacho")
|
||||
rfc String?
|
||||
status String @default("pending") // pending | accepted | expired
|
||||
token String @unique
|
||||
sentAt DateTime @default(now()) @map("sent_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([token])
|
||||
@@index([status])
|
||||
@@index([email])
|
||||
@@map("client_invitations")
|
||||
}
|
||||
|
||||
/// Catálogo despacho — precios + limits editables por admin global.
|
||||
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
||||
/// porque están acopladas a UI/middleware y son contrato de código.
|
||||
|
||||
@@ -39,6 +39,7 @@ import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
||||
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
||||
import { trialInvitationRoutes } from './routes/trial-invitations.routes.js';
|
||||
import clientInvitationRoutes from './routes/client-invitations.routes.js';
|
||||
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||
import metricasRoutes from './routes/metricas.routes.js';
|
||||
|
||||
@@ -107,6 +108,7 @@ app.use('/api/admin/addons', adminAddonsRoutes);
|
||||
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
||||
app.use('/api/metricas', metricasRoutes);
|
||||
app.use('/api/invitations/trial', trialInvitationRoutes);
|
||||
app.use('/api/invitations/client', clientInvitationRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
83
apps/api/src/controllers/client-invitations.controller.ts
Normal file
83
apps/api/src/controllers/client-invitations.controller.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as clientInvitationService from '../services/client-invitations.service.js';
|
||||
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||
|
||||
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { email, nombreDespacho, rfc } = req.body;
|
||||
if (!email) {
|
||||
return res.status(400).json({ message: 'El email es requerido' });
|
||||
}
|
||||
|
||||
// Solo platform_admin puede crear invitaciones
|
||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });
|
||||
}
|
||||
|
||||
const invitation = await clientInvitationService.createInvitation({
|
||||
email,
|
||||
invitedBy: req.user!.userId,
|
||||
invitedByName: (req.user as any)?.nombre || 'Horux Despachos',
|
||||
nombreDespacho,
|
||||
rfc,
|
||||
});
|
||||
|
||||
res.status(201).json({ message: 'Invitación enviada', invitation });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateToken(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = String(req.params.token);
|
||||
const invitation = await clientInvitationService.validateInvitationToken(token);
|
||||
res.json({
|
||||
email: invitation.email,
|
||||
nombreDespacho: invitation.nombreDespacho,
|
||||
rfc: invitation.rfc,
|
||||
expiresAt: invitation.expiresAt,
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerFromInvitation(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = String(req.params.token);
|
||||
const { nombre, password, nombreDespacho, rfc, verticalProfile, codigoPostal } = req.body;
|
||||
|
||||
if (!nombre || !password || !nombreDespacho || !rfc || !verticalProfile) {
|
||||
return res.status(400).json({ message: 'Todos los campos son requeridos' });
|
||||
}
|
||||
|
||||
const result = await clientInvitationService.registerFromInvitation(token, {
|
||||
nombre,
|
||||
password,
|
||||
nombreDespacho,
|
||||
rfc,
|
||||
verticalProfile,
|
||||
codigoPostal,
|
||||
});
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
||||
if (!isAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado' });
|
||||
}
|
||||
|
||||
const invitations = await clientInvitationService.listInvitations();
|
||||
res.json(invitations);
|
||||
} catch (error: any) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/routes/client-invitations.routes.ts
Normal file
15
apps/api/src/routes/client-invitations.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import * as controller from '../controllers/client-invitations.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// Público: validar token y registrarse desde invitación
|
||||
router.get('/validate/:token', controller.validateToken);
|
||||
router.post('/register/:token', controller.registerFromInvitation);
|
||||
|
||||
// Protegido: admin global crea y lista invitaciones
|
||||
router.post('/', authenticate, controller.createInvitation);
|
||||
router.get('/', authenticate, controller.listInvitations);
|
||||
|
||||
export default router;
|
||||
214
apps/api/src/services/client-invitations.service.ts
Normal file
214
apps/api/src/services/client-invitations.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
64
apps/api/src/services/email/templates/client-invitation.ts
Normal file
64
apps/api/src/services/email/templates/client-invitation.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user