From 745bc8385b7a695d78b828d833d38b1712cb6623 Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Mon, 11 May 2026 22:03:03 +0000 Subject: [PATCH] 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 --- .../migration.sql | 28 +++ apps/api/prisma/schema.prisma | 21 ++ apps/api/src/app.ts | 2 + .../client-invitations.controller.ts | 83 +++++++ .../src/routes/client-invitations.routes.ts | 15 ++ .../services/client-invitations.service.ts | 214 ++++++++++++++++++ apps/api/src/services/email/email.service.ts | 10 + .../email/templates/client-invitation.ts | 64 ++++++ .../admin/invitar-cliente/page.tsx | 142 ++++++++++++ .../app/invitacion/registro/[token]/page.tsx | 189 ++++++++++++++++ apps/web/lib/api/client-invitations.ts | 58 +++++ apps/web/lib/hooks/use-client-invitations.ts | 33 +++ 12 files changed, 859 insertions(+) create mode 100644 apps/api/prisma/migrations/20260511213955_add_client_invitations/migration.sql create mode 100644 apps/api/src/controllers/client-invitations.controller.ts create mode 100644 apps/api/src/routes/client-invitations.routes.ts create mode 100644 apps/api/src/services/client-invitations.service.ts create mode 100644 apps/api/src/services/email/templates/client-invitation.ts create mode 100644 apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx create mode 100644 apps/web/app/invitacion/registro/[token]/page.tsx create mode 100644 apps/web/lib/api/client-invitations.ts create mode 100644 apps/web/lib/hooks/use-client-invitations.ts diff --git a/apps/api/prisma/migrations/20260511213955_add_client_invitations/migration.sql b/apps/api/prisma/migrations/20260511213955_add_client_invitations/migration.sql new file mode 100644 index 0000000..74f0a83 --- /dev/null +++ b/apps/api/prisma/migrations/20260511213955_add_client_invitations/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c53b94f..30273b5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f0ad238..3a8b56e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); diff --git a/apps/api/src/controllers/client-invitations.controller.ts b/apps/api/src/controllers/client-invitations.controller.ts new file mode 100644 index 0000000..a5f7804 --- /dev/null +++ b/apps/api/src/controllers/client-invitations.controller.ts @@ -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); + } +} diff --git a/apps/api/src/routes/client-invitations.routes.ts b/apps/api/src/routes/client-invitations.routes.ts new file mode 100644 index 0000000..c6a3ddd --- /dev/null +++ b/apps/api/src/routes/client-invitations.routes.ts @@ -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; diff --git a/apps/api/src/services/client-invitations.service.ts b/apps/api/src/services/client-invitations.service.ts new file mode 100644 index 0000000..b6c314a --- /dev/null +++ b/apps/api/src/services/client-invitations.service.ts @@ -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 = { + 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' }, + }); +} diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index c4c52fb..98d276c 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -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)); diff --git a/apps/api/src/services/email/templates/client-invitation.ts b/apps/api/src/services/email/templates/client-invitation.ts new file mode 100644 index 0000000..d323e58 --- /dev/null +++ b/apps/api/src/services/email/templates/client-invitation.ts @@ -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 + ? `

Has sido invitado a unirte a ${data.nombreDespacho} en Horux Despachos.

` + : `

Has sido invitado a crear tu cuenta en Horux Despachos.

`; + + return ` + + + + + + + +
+
+

📨 Invitación a Horux Despachos

+
+
+

Hola,

+ ${despachoLine} +

${data.invitedByName} te ha enviado esta invitación para que crees tu cuenta y empieces a gestionar tu despacho.

+ +
+ ¿Qué es Horux Despachos? +
    +
  • Gestión de CFDIs emitidos y recibidos
  • +
  • Conciliación bancaria
  • +
  • Alertas fiscales automáticas
  • +
  • Sincronización con el SAT
  • +
  • Declaraciones y obligaciones
  • +
+
+ +

+ Crear mi cuenta +

+ +

Importante: Esta invitación expira el ${data.expiresAt}. Si no la usas antes de esa fecha, deberás solicitar una nueva.

+ +

Si tienes alguna duda, contacta a nuestro equipo de soporte.

+
+ +
+ + +`; +} diff --git a/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx b/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx new file mode 100644 index 0000000..423fb27 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx @@ -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 = { + pending: 'bg-yellow-100 text-yellow-800', + accepted: 'bg-green-100 text-green-800', + expired: 'bg-gray-100 text-gray-800', + }; + return ( + + {status === 'pending' ? 'Pendiente' : status === 'accepted' ? 'Aceptada' : 'Expirada'} + + ); + }; + + return ( + <> +
+
+ + + Nueva invitación + + +
+
+ + setEmail(e.target.value)} + placeholder="cliente@ejemplo.com" + required + /> +
+
+ + setNombreDespacho(e.target.value)} + placeholder="Despacho Contable Pérez" + /> +
+
+ + setRfc(e.target.value.toUpperCase())} + placeholder="XAXX010101000" + /> +
+ {message && ( +
+ {message.text} +
+ )} + +
+
+
+ + + + Invitaciones enviadas + + + {isLoading ? ( +

Cargando...

+ ) : !invitations?.length ? ( +

No hay invitaciones enviadas

+ ) : ( +
+ + + + + + + + + + + + {invitations.map((inv) => ( + + + + + + + + ))} + +
EmailDespachoEstadoEnviadaExpira
{inv.email}{inv.nombreDespacho || '-'}{statusBadge(inv.status)} + {format(new Date(inv.sentAt), 'dd MMM yyyy', { locale: es })} + + {format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })} +
+
+ )} +
+
+
+ + ); +} diff --git a/apps/web/app/invitacion/registro/[token]/page.tsx b/apps/web/app/invitacion/registro/[token]/page.tsx new file mode 100644 index 0000000..c222b93 --- /dev/null +++ b/apps/web/app/invitacion/registro/[token]/page.tsx @@ -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 ( +
+

Validando invitación...

+
+ ); + } + + if (validationError) { + return ( +
+ + + Invitación inválida + + +

+ {(validationError as any).response?.data?.message || 'Esta invitación no es válida o ha expirado.'} +

+
+
+
+ ); + } + + return ( +
+ + + Crear cuenta en Horux Despachos +

+ Has sido invitado a registrarte. Completa tus datos para continuar. +

+
+ +
+
+ + +
+ +
+ + setForm({ ...form, nombre: e.target.value })} + placeholder="Juan Pérez" + required + /> +
+ +
+
+ + setForm({ ...form, password: e.target.value })} + placeholder="Mínimo 6 caracteres" + required + /> +
+
+ + setForm({ ...form, confirmPassword: e.target.value })} + placeholder="Repite tu contraseña" + required + /> +
+
+ +
+ +
+ + setForm({ ...form, nombreDespacho: e.target.value })} + placeholder="Despacho Contable Pérez" + required + /> +
+ +
+
+ + setForm({ ...form, rfc: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + required + /> +
+
+ + setForm({ ...form, codigoPostal: e.target.value })} + placeholder="44100" + maxLength={5} + /> +
+
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/lib/api/client-invitations.ts b/apps/web/lib/api/client-invitations.ts new file mode 100644 index 0000000..0497fbd --- /dev/null +++ b/apps/web/lib/api/client-invitations.ts @@ -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 { + const res = await apiClient.get('/invitations/client'); + return res.data; +} diff --git a/apps/web/lib/hooks/use-client-invitations.ts b/apps/web/lib/hooks/use-client-invitations.ts new file mode 100644 index 0000000..323f231 --- /dev/null +++ b/apps/web/lib/hooks/use-client-invitations.ts @@ -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[1] }) => + api.registerFromInvitation(token, data), + }); +} + +export function useClientInvitations() { + return useQuery({ + queryKey: ['client-invitations'], + queryFn: api.getClientInvitations, + }); +}