From 69bf7417a82571fd68f9ccfa273d632945cc33cf Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Wed, 13 May 2026 23:19:07 +0000 Subject: [PATCH] feat(invitations): reenviar invitaciones pendientes desde admin Backend: - client-invitations.service.ts: funcion resendInvitation() que genera nuevo token, actualiza expiresAt y reenvia el email. - Controller + routes: POST /invitations/client/:id/resend Frontend: - API client + hook useResendInvitation con invalidacion de cache. - Pagina /admin/invitar-cliente: boton 'Reenviar' por cada invitacion pendiente en la tabla. Refs: docs/CAMBIOS-2026-05-09.md --- .../client-invitations.controller.ts | 18 +++++++++ .../src/routes/client-invitations.routes.ts | 3 +- .../services/client-invitations.service.ts | 40 +++++++++++++++++++ .../admin/invitar-cliente/page.tsx | 16 +++++++- apps/web/lib/api/client-invitations.ts | 5 +++ apps/web/lib/hooks/use-client-invitations.ts | 8 ++++ 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/api/src/controllers/client-invitations.controller.ts b/apps/api/src/controllers/client-invitations.controller.ts index a5f7804..e185105 100644 --- a/apps/api/src/controllers/client-invitations.controller.ts +++ b/apps/api/src/controllers/client-invitations.controller.ts @@ -68,6 +68,24 @@ export async function registerFromInvitation(req: Request, res: Response, next: } } +export async function resendInvitation(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 id = String(req.params.id); + const result = await clientInvitationService.resendInvitation( + id, + (req.user as any)?.nombre || 'Horux Despachos' + ); + res.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'); diff --git a/apps/api/src/routes/client-invitations.routes.ts b/apps/api/src/routes/client-invitations.routes.ts index c6a3ddd..aeaebe4 100644 --- a/apps/api/src/routes/client-invitations.routes.ts +++ b/apps/api/src/routes/client-invitations.routes.ts @@ -8,8 +8,9 @@ const router: Router = Router(); router.get('/validate/:token', controller.validateToken); router.post('/register/:token', controller.registerFromInvitation); -// Protegido: admin global crea y lista invitaciones +// Protegido: admin global crea, reenvia y lista invitaciones router.post('/', authenticate, controller.createInvitation); +router.post('/:id/resend', authenticate, controller.resendInvitation); 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 index b6c314a..14de102 100644 --- a/apps/api/src/services/client-invitations.service.ts +++ b/apps/api/src/services/client-invitations.service.ts @@ -207,6 +207,46 @@ export async function registerFromInvitation( }; } +export async function resendInvitation(invitationId: string, invitedByName: string) { + const invitation = await prisma.clientInvitation.findUnique({ + where: { id: invitationId }, + }); + if (!invitation) { + throw new Error('Invitación no encontrada'); + } + if (invitation.status !== 'pending') { + throw new Error('Solo se pueden reenviar invitaciones pendientes'); + } + + const newToken = crypto.randomBytes(32).toString('hex'); + const newExpiresAt = new Date(Date.now() + INVITATION_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + await prisma.clientInvitation.update({ + where: { id: invitationId }, + data: { + token: newToken, + expiresAt: newExpiresAt, + sentAt: new Date(), + }, + }); + + const baseUrl = process.env.WEB_URL || 'https://horuxfin.com'; + const registerUrl = `${baseUrl}/invitacion/registro/${newToken}`; + + await emailService.sendClientInvitation(invitation.email, { + invitedByName, + registerUrl, + expiresAt: newExpiresAt.toLocaleDateString('es-MX', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + nombreDespacho: invitation.nombreDespacho, + }); + + return { message: 'Invitación reenviada' }; +} + export async function listInvitations() { return prisma.clientInvitation.findMany({ orderBy: { createdAt: 'desc' }, diff --git a/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx b/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx index 423fb27..5cbc049 100644 --- a/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx +++ b/apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Header } from '@/components/layouts/header'; -import { useCreateInvitation, useClientInvitations } from '@/lib/hooks/use-client-invitations'; +import { useCreateInvitation, useClientInvitations, useResendInvitation } 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'; @@ -14,6 +14,7 @@ export default function InvitarClientePage() { const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); const createMut = useCreateInvitation(); + const resendMut = useResendInvitation(); const { data: invitations, isLoading } = useClientInvitations(); const handleSubmit = async (e: React.FormEvent) => { @@ -114,6 +115,7 @@ export default function InvitarClientePage() { Estado Enviada Expira + @@ -128,6 +130,18 @@ export default function InvitarClientePage() { {format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })} + + {inv.status === 'pending' && ( + + )} + ))} diff --git a/apps/web/lib/api/client-invitations.ts b/apps/web/lib/api/client-invitations.ts index 0497fbd..e43c5d9 100644 --- a/apps/web/lib/api/client-invitations.ts +++ b/apps/web/lib/api/client-invitations.ts @@ -52,6 +52,11 @@ export async function registerFromInvitation( return res.data; } +export async function resendInvitation(id: string): Promise<{ message: string }> { + const res = await apiClient.post(`/invitations/client/${id}/resend`); + 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 index 323f231..db9c859 100644 --- a/apps/web/lib/hooks/use-client-invitations.ts +++ b/apps/web/lib/hooks/use-client-invitations.ts @@ -25,6 +25,14 @@ export function useRegisterFromInvitation() { }); } +export function useResendInvitation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: api.resendInvitation, + onSuccess: () => qc.invalidateQueries({ queryKey: ['client-invitations'] }), + }); +} + export function useClientInvitations() { return useQuery({ queryKey: ['client-invitations'],