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
This commit is contained in:
@@ -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) {
|
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const router: Router = Router();
|
|||||||
router.get('/validate/:token', controller.validateToken);
|
router.get('/validate/:token', controller.validateToken);
|
||||||
router.post('/register/:token', controller.registerFromInvitation);
|
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('/', authenticate, controller.createInvitation);
|
||||||
|
router.post('/:id/resend', authenticate, controller.resendInvitation);
|
||||||
router.get('/', authenticate, controller.listInvitations);
|
router.get('/', authenticate, controller.listInvitations);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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() {
|
export async function listInvitations() {
|
||||||
return prisma.clientInvitation.findMany({
|
return prisma.clientInvitation.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Header } from '@/components/layouts/header';
|
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 { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { es } from 'date-fns/locale';
|
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 [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
const createMut = useCreateInvitation();
|
const createMut = useCreateInvitation();
|
||||||
|
const resendMut = useResendInvitation();
|
||||||
const { data: invitations, isLoading } = useClientInvitations();
|
const { data: invitations, isLoading } = useClientInvitations();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -114,6 +115,7 @@ export default function InvitarClientePage() {
|
|||||||
<th className="pb-2 font-medium">Estado</th>
|
<th className="pb-2 font-medium">Estado</th>
|
||||||
<th className="pb-2 font-medium">Enviada</th>
|
<th className="pb-2 font-medium">Enviada</th>
|
||||||
<th className="pb-2 font-medium">Expira</th>
|
<th className="pb-2 font-medium">Expira</th>
|
||||||
|
<th className="pb-2 font-medium"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -128,6 +130,18 @@ export default function InvitarClientePage() {
|
|||||||
<td className="py-2 text-muted-foreground">
|
<td className="py-2 text-muted-foreground">
|
||||||
{format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })}
|
{format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{inv.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resendMut.mutate(inv.id)}
|
||||||
|
disabled={resendMut.isPending}
|
||||||
|
>
|
||||||
|
{resendMut.isPending && resendMut.variables === inv.id ? 'Reenviando...' : 'Reenviar'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export async function registerFromInvitation(
|
|||||||
return res.data;
|
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<ClientInvitation[]> {
|
export async function getClientInvitations(): Promise<ClientInvitation[]> {
|
||||||
const res = await apiClient.get('/invitations/client');
|
const res = await apiClient.get('/invitations/client');
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -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() {
|
export function useClientInvitations() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['client-invitations'],
|
queryKey: ['client-invitations'],
|
||||||
|
|||||||
Reference in New Issue
Block a user