Compare commits
3 Commits
b9bd8cfc1e
...
fbcc788a76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcc788a76 | ||
|
|
745bc8385b | ||
|
|
0a63593aab |
@@ -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")
|
@@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.
|
/// Catálogo despacho — precios + limits editables por admin global.
|
||||||
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
||||||
/// porque están acopladas a UI/middleware y son contrato de código.
|
/// 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 adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||||
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
||||||
import { trialInvitationRoutes } from './routes/trial-invitations.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 despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||||
import metricasRoutes from './routes/metricas.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/despacho/audit-log', despachoAuditRoutes);
|
||||||
app.use('/api/metricas', metricasRoutes);
|
app.use('/api/metricas', metricasRoutes);
|
||||||
app.use('/api/invitations/trial', trialInvitationRoutes);
|
app.use('/api/invitations/trial', trialInvitationRoutes);
|
||||||
|
app.use('/api/invitations/client', clientInvitationRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
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));
|
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) => {
|
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
|
||||||
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
||||||
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
|
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" style="color: white;">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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
142
apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx
Normal file
142
apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Header } from '@/components/layouts/header';
|
||||||
|
import { useCreateInvitation, useClientInvitations } from '@/lib/hooks/use-client-invitations';
|
||||||
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { es } from 'date-fns/locale';
|
||||||
|
|
||||||
|
export default function InvitarClientePage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [nombreDespacho, setNombreDespacho] = useState('');
|
||||||
|
const [rfc, setRfc] = useState('');
|
||||||
|
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const createMut = useCreateInvitation();
|
||||||
|
const { data: invitations, isLoading } = useClientInvitations();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage(null);
|
||||||
|
if (!email.trim()) {
|
||||||
|
setMessage({ kind: 'error', text: 'El email es requerido' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createMut.mutateAsync({ email, nombreDespacho, rfc });
|
||||||
|
setMessage({ kind: 'ok', text: 'Invitación enviada exitosamente' });
|
||||||
|
setEmail('');
|
||||||
|
setNombreDespacho('');
|
||||||
|
setRfc('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setMessage({ kind: 'error', text: err.response?.data?.message || 'Error al enviar invitación' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (status: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
accepted: 'bg-green-100 text-green-800',
|
||||||
|
expired: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${map[status] || 'bg-gray-100'}`}>
|
||||||
|
{status === 'pending' ? 'Pendiente' : status === 'accepted' ? 'Aceptada' : 'Expirada'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Invitar cliente" />
|
||||||
|
<main className="p-6 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nueva invitación</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Email del cliente *</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="cliente@ejemplo.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Nombre del despacho (opcional)</label>
|
||||||
|
<Input
|
||||||
|
value={nombreDespacho}
|
||||||
|
onChange={(e) => setNombreDespacho(e.target.value)}
|
||||||
|
placeholder="Despacho Contable Pérez"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">RFC (opcional)</label>
|
||||||
|
<Input
|
||||||
|
value={rfc}
|
||||||
|
onChange={(e) => setRfc(e.target.value.toUpperCase())}
|
||||||
|
placeholder="XAXX010101000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<div className={`text-sm p-3 rounded ${message.kind === 'ok' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={createMut.isPending}>
|
||||||
|
{createMut.isPending ? 'Enviando...' : 'Enviar invitación'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invitaciones enviadas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
|
) : !invitations?.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No hay invitaciones enviadas</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="pb-2 font-medium">Email</th>
|
||||||
|
<th className="pb-2 font-medium">Despacho</th>
|
||||||
|
<th className="pb-2 font-medium">Estado</th>
|
||||||
|
<th className="pb-2 font-medium">Enviada</th>
|
||||||
|
<th className="pb-2 font-medium">Expira</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitations.map((inv) => (
|
||||||
|
<tr key={inv.id} className="border-b">
|
||||||
|
<td className="py-2">{inv.email}</td>
|
||||||
|
<td className="py-2">{inv.nombreDespacho || '-'}</td>
|
||||||
|
<td className="py-2">{statusBadge(inv.status)}</td>
|
||||||
|
<td className="py-2 text-muted-foreground">
|
||||||
|
{format(new Date(inv.sentAt), 'dd MMM yyyy', { locale: es })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground">
|
||||||
|
{format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
apps/web/app/invitacion/registro/[token]/page.tsx
Normal file
189
apps/web/app/invitacion/registro/[token]/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useValidateInvitationToken, useRegisterFromInvitation } from '@/lib/hooks/use-client-invitations';
|
||||||
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
export default function InvitationRegisterPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const token = params.token as string;
|
||||||
|
const { setTokens, setUser } = useAuthStore();
|
||||||
|
|
||||||
|
const { data: invitation, isLoading: validating, error: validationError } = useValidateInvitationToken(token);
|
||||||
|
const registerMut = useRegisterFromInvitation();
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nombre: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
nombreDespacho: '',
|
||||||
|
rfc: '',
|
||||||
|
verticalProfile: 'CONTABLE' as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA',
|
||||||
|
codigoPostal: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
alert('Las contraseñas no coinciden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.password.length < 6) {
|
||||||
|
alert('La contraseña debe tener al menos 6 caracteres');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await registerMut.mutateAsync({
|
||||||
|
token,
|
||||||
|
data: {
|
||||||
|
nombre: form.nombre,
|
||||||
|
password: form.password,
|
||||||
|
nombreDespacho: form.nombreDespacho,
|
||||||
|
rfc: form.rfc,
|
||||||
|
verticalProfile: form.verticalProfile,
|
||||||
|
codigoPostal: form.codigoPostal || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setTokens(result.accessToken, result.refreshToken);
|
||||||
|
setUser(result.user);
|
||||||
|
router.push('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.message || 'Error al registrarse');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (validating) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Validando invitación...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invitación inválida</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{(validationError as any).response?.data?.message || 'Esta invitación no es válida o ha expirado.'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/40 p-4">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Crear cuenta en Horux Despachos</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Has sido invitado a registrarte. Completa tus datos para continuar.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Email</label>
|
||||||
|
<Input value={invitation?.email || ''} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Tu nombre completo *</label>
|
||||||
|
<Input
|
||||||
|
value={form.nombre}
|
||||||
|
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||||
|
placeholder="Juan Pérez"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Contraseña *</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
placeholder="Mínimo 6 caracteres"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Confirmar contraseña *</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
|
||||||
|
placeholder="Repite tu contraseña"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Nombre del despacho *</label>
|
||||||
|
<Input
|
||||||
|
value={form.nombreDespacho}
|
||||||
|
onChange={(e) => setForm({ ...form, nombreDespacho: e.target.value })}
|
||||||
|
placeholder="Despacho Contable Pérez"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">RFC *</label>
|
||||||
|
<Input
|
||||||
|
value={form.rfc}
|
||||||
|
onChange={(e) => setForm({ ...form, rfc: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="XAXX010101000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Código postal</label>
|
||||||
|
<Input
|
||||||
|
value={form.codigoPostal}
|
||||||
|
onChange={(e) => setForm({ ...form, codigoPostal: e.target.value })}
|
||||||
|
placeholder="44100"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Perfil del despacho *</label>
|
||||||
|
<select
|
||||||
|
value={form.verticalProfile}
|
||||||
|
onChange={(e) => setForm({ ...form, verticalProfile: e.target.value as any })}
|
||||||
|
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="CONTABLE">Contable</option>
|
||||||
|
<option value="JURIDICO">Jurídico</option>
|
||||||
|
<option value="ARQUITECTURA">Arquitectura</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={registerMut.isPending}>
|
||||||
|
{registerMut.isPending ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
|
|||||||
queries: {
|
queries: {
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
58
apps/web/lib/api/client-invitations.ts
Normal file
58
apps/web/lib/api/client-invitations.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ClientInvitation {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
invitedBy: string;
|
||||||
|
nombreDespacho: string | null;
|
||||||
|
rfc: string | null;
|
||||||
|
status: string;
|
||||||
|
token: string;
|
||||||
|
sentAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
acceptedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvitation(data: {
|
||||||
|
email: string;
|
||||||
|
nombreDespacho?: string;
|
||||||
|
rfc?: string;
|
||||||
|
}): Promise<{ message: string; invitation: ClientInvitation }> {
|
||||||
|
const res = await apiClient.post('/invitations/client', data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateInvitationToken(token: string): Promise<{
|
||||||
|
email: string;
|
||||||
|
nombreDespacho: string | null;
|
||||||
|
rfc: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}> {
|
||||||
|
const res = await apiClient.get(`/invitations/client/validate/${token}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerFromInvitation(
|
||||||
|
token: string,
|
||||||
|
data: {
|
||||||
|
nombre: string;
|
||||||
|
password: string;
|
||||||
|
nombreDespacho: string;
|
||||||
|
rfc: string;
|
||||||
|
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: any;
|
||||||
|
}> {
|
||||||
|
const res = await apiClient.post(`/invitations/client/register/${token}`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClientInvitations(): Promise<ClientInvitation[]> {
|
||||||
|
const res = await apiClient.get('/invitations/client');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
33
apps/web/lib/hooks/use-client-invitations.ts
Normal file
33
apps/web/lib/hooks/use-client-invitations.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as api from '@/lib/api/client-invitations';
|
||||||
|
|
||||||
|
export function useCreateInvitation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createInvitation,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['client-invitations'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useValidateInvitationToken(token: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['invitation-token', token],
|
||||||
|
queryFn: () => api.validateInvitationToken(token),
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterFromInvitation() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ token, data }: { token: string; data: Parameters<typeof api.registerFromInvitation>[1] }) =>
|
||||||
|
api.registerFromInvitation(token, data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientInvitations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['client-invitations'],
|
||||||
|
queryFn: api.getClientInvitations,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,15 +3,15 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
name: 'horux-api',
|
name: 'horux-api',
|
||||||
interpreter: 'node',
|
interpreter: 'node',
|
||||||
script: '/root/HoruxDespachosNuevo/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
|
script: 'src/index.ts',
|
||||||
args: 'src/index.ts',
|
node_args: '--import /root/HoruxDespachosNuevo/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/loader.mjs',
|
||||||
cwd: '/root/HoruxDespachosNuevo/apps/api',
|
cwd: '/root/HoruxDespachosNuevo/apps/api',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
exec_mode: 'fork',
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_memory_restart: '1G',
|
max_memory_restart: '1G',
|
||||||
kill_timeout: 5000,
|
kill_timeout: 15000,
|
||||||
listen_timeout: 10000,
|
listen_timeout: 15000,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 4000,
|
PORT: 4000,
|
||||||
|
|||||||
Reference in New Issue
Block a user