Skip to Content
UNIMAST

Patrones de Diseño

UNIMAST ERP implementa patrones de diseño robustos y probados que proporcionan escalabilidad, mantenibilidad y una experiencia de usuario consistente. Estos patrones se aplican en toda la arquitectura de la aplicación, desde la capa de presentación hasta la capa de datos.

🏗️ App Router Server-First

Arquitectura de Layouts Anidados

// app/app/(dashboard)/layout.tsx export default async function DashboardLayout({ children }: { children: React.ReactNode; }) { const session = await getSession(); if (!session) { redirect('/login'); } const isTerminalOp = await hasRequiredScopes([SCOPE_FLAGS.TERMINAL_OP]); return ( <SessionProvider session={session}> <Providers> <TooltipProvider> <main className="flex min-h-screen w-full flex-col bg-muted/40"> <SidebarProvider> <CollapsibleSidebar routes={routes} /> <SidebarContentWrapper> <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4"> <DashboardBreadcrumb /> <SearchInput /> <TerminalBadge /> <Payment /> <User /> </header> <main className="grid flex-1 items-start gap-2 p-4 md:gap-4 bg-muted/40"> {children} </main> </SidebarContentWrapper> </SidebarProvider> <Analytics /> </main> </TooltipProvider> </Providers> </SessionProvider> ); }

Jerarquía de Providers

// Estructura jerárquica de providers RootLayout (app/layout.tsx) ├── Toaster (notificaciones globales) └── DashboardLayout (app/(dashboard)/layout.tsx) ├── SessionProvider (contexto de sesión) ├── Providers (TooltipProvider) └── SidebarProvider (estado del sidebar) ├── CollapsibleSidebar (navegación) └── SidebarContentWrapper (contenido principal) ├── Header (breadcrumb, búsqueda, usuario) └── Main (contenido de la página)

Islas Cliente cuando es Necesario

// app/components/ui/sonner.tsx - Cliente necesario para tema "use client" import { useTheme } from "next-themes" import { Toaster as Sonner } from "sonner" const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme() return ( <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" toastOptions={{ classNames: { toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", description: "group-[.toast]:text-muted-foreground", actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", }, duration: 5000, position: "top-right" }} {...props} /> ) } // app/contexts/sidebar-provider.tsx - Cliente necesario para estado 'use client'; export const SidebarProvider = ({ children }: { children: ReactNode }) => { const [expanded, setExpanded] = useState(true); const toggleSidebar = () => { setExpanded((prev) => { const newState = !prev; localStorage.setItem('sidebarExpanded', String(newState)); window.dispatchEvent( new CustomEvent('sidebarStateChange', { detail: { expanded: newState } }) ); return newState; }); }; return ( <SidebarContext.Provider value={{ expanded, toggleSidebar }}> {children} </SidebarContext.Provider> ); };
// app/app/(dashboard)/layout.tsx const routes = [ { href: '/', label: 'Inicio', icon: <Home className="h-5 w-5" />, children: [] }, { href: '#', label: 'Finanzas', expanded: true, icon: <HandCoins className="h-5 w-5" />, children: [ { href: '/invoices', label: 'Facturas', icon: <FileClock className="h-5 w-5" />, requiredScopes: [SCOPE.READ_INVOICES], children: [] }, { href: '/customer-pay', label: 'Pago', icon: <DollarSign className="h-5 w-5" />, requiredScopes: [SCOPE.CREATE_PAYMENTS, SCOPE.READ_PAYMENT_METHODS], children: [] } ] }, { href: '#', label: 'eCF', expanded: true, icon: <FileDigit className="h-5 w-5" />, children: [ { href: '/ecf/issued', label: 'Comprobantes Emitidos', icon: <FileClock className="h-5 w-5" />, requiredScopes: [SCOPE.READ_TAX_DOCUMENT], children: [] }, { href: '/ecf/received', label: 'Comprobantes Recibidos', icon: <DollarSign className="h-5 w-5" />, requiredScopes: [SCOPE.READ_RECEIVED_TAX_DOCUMENT], children: [] } ] } ];

🔐 Autorización Declarativa

Patrón WithScopes para Componentes

// app/components/auth/WithScopes.tsx - Cliente const WithScopes = (props: WithScopesProps) => { const { requiredScopes, children, fallback } = props; const hasScopes = useHasRequiredScopes(requiredScopes); if (hasScopes) { return children; } return fallback; }; // app/components/auth/server/WithScopes.tsx - Servidor export const hasRequiredScopes = async (requiredScopes: string[]) => { const session = await getSession(); const { scopes = [] } = session?.role || {}; return requiredScopes.every((scope) => scopes.includes(scope)); }; const WithScopes = async (props: WithScopesProps) => { const { requiredScopes, children, fallback } = props; const hasScopes = await hasRequiredScopes(requiredScopes); if (hasScopes) { return children; } return fallback; };

Hooks para Verificación de Scopes

// app/components/auth/WithScopes.tsx export const useHasRequiredScopes = (requiredScopes: string[]) => { const { session } = useSession(); const { scopes = [] } = session?.role || {}; return requiredScopes.every((scope) => scopes.includes(scope)); }; export const useRequiredScopesCheck = () => { const { session } = useSession(); const { scopes = [] } = session?.role || {}; return (requiredScopes: string[]) => requiredScopes.every((scope) => scopes.includes(scope)); }; // Uso en componentes export function InvoiceActions({ invoice }: InvoiceActionsProps) { const canEdit = useHasRequiredScopes([SCOPE.UPDATE_INVOICES]); const canDelete = useHasRequiredScopes([SCOPE.DELETE_INVOICES]); const canSendECF = useHasRequiredScopes([SCOPE.CREATE_TAX_DOCUMENT]); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {canEdit && ( <DropdownMenuItem onClick={() => handleEdit(invoice)}> <Edit className="mr-2 h-4 w-4" /> Editar </DropdownMenuItem> )} {canSendECF && ( <DropdownMenuItem onClick={() => handleSendECF(invoice)}> <Send className="mr-2 h-4 w-4" /> Enviar eCF </DropdownMenuItem> )} {canDelete && ( <DropdownMenuItem onClick={() => handleDelete(invoice)}> <Trash className="mr-2 h-4 w-4" /> Eliminar </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> ); }

Verificación de Scopes en Navegación

// app/app/(dashboard)/nav-item.tsx export function NavItem({ item }: NavItemProps) { const hasRequiredScopes = useRequiredScopesCheck(); const canAccess = item.requiredScopes ? hasRequiredScopes(item.requiredScopes) : true; if (!canAccess) { return null; } return ( <Link href={item.href} className={cn( "flex items-center gap-3 rounded-lg px-3 py-2 text-gray-500 transition-all hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-50", pathname === item.href && "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-50" )} > {item.icon} {item.label} </Link> ); }

📝 Formularios Escalables

Arquitectura de Formularios con react-hook-form + Zod

// app/components/ui/form.tsx const Form = FormProvider const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> >({ ...props }: ControllerProps<TFieldValues, TName>) => { return ( <FormFieldContext.Provider value={{ name: props.name }}> <Controller {...props} /> </FormFieldContext.Provider> ) } const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const id = React.useId() return ( <FormItemContext.Provider value={{ id }}> <div ref={ref} className={cn("space-y-2", className)} {...props} /> </FormItemContext.Provider> ) }) const FormLabel = React.forwardRef< React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> >(({ className, ...props }, ref) => { const { error, formItemId } = useFormField() return ( <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} /> ) }) const FormControl = React.forwardRef< React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot> >(({ ...props }, ref) => { const { error, formItemId, formDescriptionId, formMessageId } = useFormField() return ( <Slot ref={ref} id={formItemId} aria-describedby={ !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}` } aria-invalid={!!error} {...props} /> ) }) const FormMessage = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField() const body = error ? String(error?.message) : children if (!body) { return null } return ( <p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props} > {body} </p> ) })

Componente SubmitButton Especializado

// app/components/submit-button.tsx export function SubmitButton({ children, className, variant = 'default', disabled = false }: { children: ReactNode; className?: string; variant?: 'default' | 'destructive' | 'outline' | 'secondary'; disabled?: boolean; }) { const form = useFormContext(); const { isSubmitting } = form.formState; return ( <Button disabled={disabled || isSubmitting} type="submit" variant={variant} className={cn('flex items-center justify-center relative', className)} > {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {children} </Button> ); }

Implementación de Formularios

// app/app/(dashboard)/settings/users/user-modal.tsx export function UserModal({ user, roles }: UserModalProps) { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: '', email: '', password: '' } }); const handleSubmit = async (values: z.infer<typeof formSchema>) => { try { if (user) { await updateUser(user.id, values); toast.success('Usuario actualizado exitosamente'); } else { await createUser(values); toast.success('Usuario creado exitosamente'); } onClose(); } catch (error) { toast.error('Error al guardar el usuario. Inténtalo de nuevo.'); } }; return ( <Dialog> <DialogContent> <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Nombre</FormLabel> <FormControl> <Input placeholder="Ingrese nombre" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Correo Electrónico</FormLabel> <FormControl> <Input type="email" placeholder="usuario@ejemplo.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="roleId" render={({ field }) => ( <FormItem> <FormLabel>Rol</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Seleccione un rol" /> </SelectTrigger> </FormControl> <SelectContent> {roles.map((role) => ( <SelectItem key={role.id} value={role.id}> {role.name} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <DialogFooter> <Button type="button" variant="outline" onClick={onClose}> Cancelar </Button> <SubmitButton> {user ? 'Actualizar' : 'Crear'} </SubmitButton> </DialogFooter> </form> </Form> </DialogContent> </Dialog> ); }

Validación con Zod

// app/app/(dashboard)/settings/users/user-modal.tsx const formSchema = z.object({ name: z.string().min(1, 'El nombre es obligatorio'), email: z.string().email('Correo no válido'), password: z .string() .min(6, 'Debe tener al menos 6 caracteres') .refine((val) => /[A-Z]/.test(val), { message: 'Debe contener al menos una mayúscula' }) .optional(), roleId: z.string().nonempty('El rol es obligatorio') }); // Validación condicional const conditionalSchema = z.object({ type: z.enum(['individual', 'company']), companyName: z.string().optional(), taxId: z.string().optional() }).refine((data) => { if (data.type === 'company') { return data.companyName && data.taxId; } return true; }, { message: 'Para empresas, el nombre y RNC son obligatorios', path: ['companyName'] });

⚡ Jobs Resilientes

Configuración de BullMQ con Backoff Exponencial

// app/lib/queue.ts const commonJobOptions = { attempts: 15, // Máximo 15 intentos backoff: { type: 'exponential', // Backoff exponencial delay: 5000 // Delay inicial de 5 segundos } }; export function queueSendTaxDocument( encf: string, customerDirectory?: ServiceDirectoryResponse ) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(sendTaxDocumentQueueName, { connection }); return queue.add( `send_tax_document:${encf}`, // Nombres idempotentes { encf, customerDirectory }, { ...commonJobOptions, delay: 1000 * 60 // Esperar 1 minuto } ); } export function queueSendSummaryTaxDocument(encf: string) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(sendSummaryTaxDocumentQueueName, { connection }); return queue.add( `send_summary_tax_document:${encf}`, { encf }, { ...commonJobOptions, delay: 1000 * 20 // Esperar 20 segundos } ); } export function queueCheckTaxDocumentStatus(encf: string) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(checkDocumentStatus, { connection }); return queue.add( `check_document_status:${encf}`, { encf }, { ...commonJobOptions, delay: 1000 * 30 // Esperar 30 segundos } ); }

Workers con Manejo de Errores

// app/lib/queue.ts export function mountWorkers() { const connection = new IORedis(process.env.REDIS_URL || '', { maxRetriesPerRequest: null }); // Worker para envío de documentos const sendTaxDocumentWorker = new Worker( sendTaxDocumentQueueName, async (job) => { const { encf, customerDirectory } = job.data; try { const result = await sendTaxDocument(encf, customerDirectory); return result; } catch (error) { // Log del error para debugging console.error(`Error sending tax document ${encf}:`, error); // Re-lanzar error para que BullMQ maneje el retry throw error; } }, { connection } ); // Event handlers para monitoreo sendTaxDocumentWorker.on('completed', (job) => { console.info(`Send Tax Document Job ${job.id} completed`); }); sendTaxDocumentWorker.on('failed', (job) => { console.error(`Send Tax Document Job ${job.id} failed:`, { reason: job.failedReason, attempts: job.attemptsMade, data: job.data }); }); // Worker para webhooks const webhooksWorker = new Worker( webhooksQueueName, async (job) => { const { event, payload } = job.data; const subscriptions = await getWebhookSubscriptions(event); const deliverQueue = new Queue(webhooksDeliverQueueName, { connection }); await Promise.all( subscriptions.map((subscription) => { return deliverQueue.add( `${event}/subscriptionId:${subscription.id}/${generateHash(8)}`, { event, payload, subscription }, commonJobOptions ); }) ); return Promise.resolve(); }, { connection } ); webhooksWorker.on('completed', (job) => { console.info(`Webhook Job ${job.id} completed`); }); webhooksWorker.on('failed', (job) => { console.error(`Webhook Job ${job.id} failed:`, job.failedReason); }); }

Colas Especializadas por Tipo de Trabajo

// app/lib/queue.ts // Colas específicas para diferentes tipos de trabajos export const sendTaxDocumentQueueName = 'send_tax_document'; export const sendSummaryTaxDocumentQueueName = 'send_summary_tax_document'; export const checkDocumentStatus = 'check_document_status'; export const sendDocumentApprovalQueueName = 'send_document_approval'; export const notificationsQueueName = 'notifications'; export const webhooksQueueName = 'webhooks'; export const webhooksDeliverQueueName = 'webhooks_deliver'; // Función para encolar trabajos con configuración específica export function queueSendDocumentApproval( xmlPath: string, toCustomer: boolean = false ) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(sendTaxDocumentQueueName, { connection }); return queue.add( `queue_document_approval:${xmlPath}:${toCustomer ? 'customer' : 'dgii'}`, { xmlPath, toCustomer }, commonJobOptions ); }

🔗 Webhooks Confiables

Headers Personalizados X-Unimast-*

// app/lib/webhooks.ts export async function dispatchWebhookEvent( event: string, payload: any, webhook: Webhook ) { const headerName = 'Unimast'; const response = await fetch(webhook.url, { method: 'POST', headers: { // Headers personalizados para identificación ['X-' + headerName + '-Webhook-Id']: webhook.id, ['X-' + headerName + '-Event-Type']: event, ['X-' + headerName + '-Event-Id']: payload.id, ['X-' + headerName + '-Event-Timestamp']: new Date().toISOString(), // Headers estándar 'User-Agent': headerName + '-Webhook/1.0', 'Content-Type': 'application/json' }, body: JSON.stringify({ type: event, payload }) }); // Control de errores HTTP if (response.status >= 300 || response.status < 200) { throw new Error('Webhook failed with status ' + response.status); } return response; }

Sistema de Reintentos y Control de Errores

// app/lib/queue.ts export function queueWebhookEvent(event: string, payload: any) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(webhooksQueueName, { connection }); return queue.add( `${event}/payload:${payload.id}/${generateHash(8)}`, // Nombres únicos { event, payload }, commonJobOptions // 15 intentos con backoff exponencial ); } // Worker para entrega de webhooks const webhooksDeliverWorker = new Worker( webhooksDeliverQueueName, async (job) => { const { event, payload, subscription } = job.data; try { const response = await dispatchWebhookEvent(event, payload, subscription); // Log de éxito console.info(`Webhook delivered successfully to ${subscription.url}`, { event, webhookId: subscription.id, status: response.status }); return response; } catch (error) { // Log detallado del error console.error(`Webhook delivery failed to ${subscription.url}`, { event, webhookId: subscription.id, error: error.message, attempts: job.attemptsMade }); // Re-lanzar error para retry automático throw error; } }, { connection } ); // Event handlers para monitoreo webhooksDeliverWorker.on('completed', (job) => { console.info(`Webhook Deliver Job ${job.id} completed`); }); webhooksDeliverWorker.on('failed', (job) => { console.error(`Webhook Deliver Job ${job.id} failed:`, { reason: job.failedReason, attempts: job.attemptsMade, data: job.data }); });

Gestión de Suscripciones de Webhooks

// app/lib/webhooks.ts export async function getWebhookSubscriptions(event: string) { const subscriptions = await prisma.webhook.findMany({ where: { status: Status.ACTIVE, OR: [ { events: { has: event // Evento específico } }, { events: { has: '*' // Todos los eventos } } ] } }); return subscriptions; } // Uso en el sistema export async function triggerWebhookEvent(event: string, payload: any) { try { // Obtener suscripciones activas const subscriptions = await getWebhookSubscriptions(event); if (subscriptions.length === 0) { console.info(`No webhook subscriptions found for event: ${event}`); return; } // Encolar evento para cada suscripción await queueWebhookEvent(event, payload); console.info(`Webhook event ${event} queued for ${subscriptions.length} subscriptions`); } catch (error) { console.error(`Failed to trigger webhook event ${event}:`, error); throw error; } }

🎯 Patrones de Contexto y Estado

SessionProvider para Estado Global

// app/contexts/session-provider.tsx export interface SessionContextProps { session: Session | null; } const SessionContext = createContext<SessionContextProps>({ session: null, }); export const SessionProvider = ({ children, session, }: { children: ReactNode; session: Session | null; }) => { return ( <SessionContext.Provider value={{ session }}> {children} </SessionContext.Provider> ); }; export const useSession = () => { const context = useContext(SessionContext); if (!context) { throw new Error('useSession must be used within SessionProvider'); } return context; };

SidebarProvider con Persistencia

// app/contexts/sidebar-provider.tsx export const SidebarProvider = ({ children }: { children: ReactNode }) => { const [expanded, setExpanded] = useState(true); const toggleSidebar = () => { setExpanded((prev) => { const newState = !prev; // Persistir en localStorage localStorage.setItem('sidebarExpanded', String(newState)); // Disparar evento personalizado para sincronización window.dispatchEvent( new CustomEvent('sidebarStateChange', { detail: { expanded: newState } }) ); return newState; }); }; return ( <SidebarContext.Provider value={{ expanded, toggleSidebar }}> {children} </SidebarContext.Provider> ); };

🚀 Mejores Prácticas

1. Separación de Responsabilidades

// ✅ CORRECTO: Separación clara de responsabilidades // Layout principal - Solo estructura y providers export default async function DashboardLayout({ children }: Props) { const session = await getSession(); if (!session) redirect('/login'); return ( <SessionProvider session={session}> <Providers> <SidebarProvider> <CollapsibleSidebar /> <SidebarContentWrapper> <Header /> <main>{children}</main> </SidebarContentWrapper> </SidebarProvider> </Providers> </SessionProvider> ); } // Componente de navegación - Solo lógica de navegación export function CollapsibleSidebar({ routes }: CollapsibleSidebarProps) { const { expanded } = useSidebar(); return ( <aside className={cn( "transition-all duration-300", expanded ? "w-64" : "w-16" )}> <NavMenu routes={routes} /> </aside> ); } // ❌ INCORRECTO: Lógica mezclada en layout export default async function DashboardLayout({ children }: Props) { const session = await getSession(); if (!session) redirect('/login'); // Lógica de navegación mezclada en layout const routes = buildRoutes(session); const isTerminalOp = checkTerminalPermissions(session); return ( <div> {/* Lógica de UI mezclada con lógica de negocio */} {isTerminalOp && <TerminalBadge />} <NavMenu routes={routes} /> {children} </div> ); }

2. Patrones de Autorización Consistentes

// ✅ CORRECTO: Uso consistente de WithScopes export function InvoiceActions({ invoice }: InvoiceActionsProps) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <WithScopes requiredScopes={[SCOPE.READ_INVOICES]}> <DropdownMenuItem onClick={() => handleView(invoice)}> <Eye className="mr-2 h-4 w-4" /> Ver </DropdownMenuItem> </WithScopes> <WithScopes requiredScopes={[SCOPE.UPDATE_INVOICES]}> <DropdownMenuItem onClick={() => handleEdit(invoice)}> <Edit className="mr-2 h-4 w-4" /> Editar </DropdownMenuItem> </WithScopes> <WithScopes requiredScopes={[SCOPE.DELETE_INVOICES]}> <DropdownMenuItem onClick={() => handleDelete(invoice)}> <Trash className="mr-2 h-4 w-4" /> Eliminar </DropdownMenuItem> </WithScopes> </DropdownMenuContent> </DropdownMenu> ); } // ❌ INCORRECTO: Verificación manual de permisos export function InvoiceActions({ invoice }: InvoiceActionsProps) { const { session } = useSession(); const canEdit = session?.role?.scopes?.includes('update_invoices'); const canDelete = session?.role?.scopes?.includes('delete_invoices'); return ( <DropdownMenu> {/* Verificación manual inconsistente */} {canEdit && ( <DropdownMenuItem onClick={() => handleEdit(invoice)}> Editar </DropdownMenuItem> )} {canDelete && ( <DropdownMenuItem onClick={() => handleDelete(invoice)}> Eliminar </DropdownMenuItem> )} </DropdownMenu> ); }

3. Formularios con Validación Robusta

// ✅ CORRECTO: Formulario con validación completa export function UserForm({ user, onSubmit }: UserFormProps) { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { name: user?.name || '', email: user?.email || '', roleId: user?.roleId || '' } }); const handleSubmit = async (values: z.infer<typeof formSchema>) => { try { await onSubmit(values); toast.success('Usuario guardado exitosamente'); } catch (error) { toast.error('Error al guardar usuario'); } }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Nombre</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <SubmitButton> {user ? 'Actualizar' : 'Crear'} </SubmitButton> </form> </Form> ); } // ❌ INCORRECTO: Formulario sin validación export function UserForm({ user, onSubmit }: UserFormProps) { const [name, setName] = useState(user?.name || ''); const [email, setEmail] = useState(user?.email || ''); const handleSubmit = (e: FormEvent) => { e.preventDefault(); // Sin validación onSubmit({ name, email }); }; return ( <form onSubmit={handleSubmit}> <input value={name} onChange={(e) => setName(e.target.value)} /> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <button type="submit">Guardar</button> </form> ); }

4. Jobs con Configuración Robusta

// ✅ CORRECTO: Configuración robusta de jobs const commonJobOptions = { attempts: 15, // Suficientes reintentos backoff: { type: 'exponential', // Backoff inteligente delay: 5000 // Delay inicial razonable }, removeOnComplete: 100, // Limpiar jobs completados removeOnFail: 50 // Limpiar jobs fallidos }; export function queueTaxDocumentJob(encf: string, options?: Partial<JobOptions>) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue(sendTaxDocumentQueueName, { connection }); return queue.add( `send_tax_document:${encf}:${Date.now()}`, // Nombres únicos { encf }, { ...commonJobOptions, ...options, delay: options?.delay || 1000 * 60 // Delay por defecto } ); } // ❌ INCORRECTO: Configuración básica de jobs export function queueTaxDocumentJob(encf: string) { const connection = new IORedis(process.env.REDIS_URL as string); const queue = new Queue('tax_documents', { connection }); return queue.add('send_document', { encf }); // Sin configuración robusta }

🔧 Configuración y Variables de Entorno

Configuración de Colas

# .env # Redis para colas de trabajo REDIS_URL=redis://localhost:6379 # Configuración de jobs JOB_MAX_ATTEMPTS=15 JOB_BACKOFF_DELAY=5000 JOB_REMOVE_ON_COMPLETE=100 JOB_REMOVE_ON_FAIL=50 # Configuración de webhooks WEBHOOK_TIMEOUT=30000 WEBHOOK_MAX_RETRIES=15

Configuración de Scopes

// app/lib/constants.ts export const SCOPE = { // Usuarios CREATE_USERS: 'users:create', READ_USERS: 'users:read', UPDATE_USERS: 'users:update', DELETE_USERS: 'users:delete', // Roles CREATE_ROLES: 'roles:create', READ_ROLES: 'roles:read', UPDATE_ROLES: 'roles:update', DELETE_ROLES: 'roles:delete', // Facturas CREATE_INVOICES: 'invoices:create', READ_INVOICES: 'invoices:read', UPDATE_INVOICES: 'invoices:update', DELETE_INVOICES: 'invoices:delete', // Documentos fiscales CREATE_TAX_DOCUMENT: 'tax-documents:create', READ_TAX_DOCUMENT: 'tax-documents:read', UPDATE_TAX_DOCUMENT: 'tax-documents:update', DELETE_TAX_DOCUMENT: 'tax-documents:delete', // Webhooks CREATE_WEBHOOKS: 'webhooks:create', READ_WEBHOOKS: 'webhooks:read', UPDATE_WEBHOOKS: 'webhooks:update', DELETE_WEBHOOKS: 'webhooks:delete' } as const; export const SCOPE_FLAGS = { TERMINAL_OP: 'flag:terminal-operator' } as const;

🔍 Troubleshooting

Problemas Comunes

1. Errores de Layout y Providers

// Debug de providers export function debugProviders() { console.log('=== UNIMAST ERP Providers Debug ==='); // Verificar SessionProvider try { const { session } = useSession(); console.log('Session Provider:', session ? 'Working' : 'No session'); console.log('User:', session?.user?.name); console.log('Role:', session?.role?.name); console.log('Scopes:', session?.role?.scopes); } catch (error) { console.error('Session Provider Error:', error.message); } // Verificar SidebarProvider try { const { expanded, toggleSidebar } = useSidebar(); console.log('Sidebar Provider:', 'Working'); console.log('Expanded:', expanded); console.log('Toggle Function:', typeof toggleSidebar); } catch (error) { console.error('Sidebar Provider Error:', error.message); } }

2. Errores de Autorización

// Debug de scopes export function debugScopes() { console.log('=== UNIMAST ERP Scopes Debug ==='); const { session } = useSession(); console.log('User Scopes:', session?.role?.scopes); // Verificar scopes específicos const hasReadUsers = useHasRequiredScopes([SCOPE.READ_USERS]); const hasCreateUsers = useHasRequiredScopes([SCOPE.CREATE_USERS]); console.log('Can Read Users:', hasReadUsers); console.log('Can Create Users:', hasCreateUsers); }

3. Errores de Colas de Trabajo

// Debug de colas export function debugQueues() { console.log('=== UNIMAST ERP Queues Debug ==='); console.log('Redis URL:', process.env.REDIS_URL ? 'Set' : 'Not Set'); console.log('Common Job Options:', { attempts: 15, backoff: { type: 'exponential', delay: 5000 } }); // Verificar workers montados console.log('Workers:', 'Check queue.ts mountWorkers function'); }

Debug de Patrones de Diseño

// Función de debug para patrones de diseño export function debugDesignPatterns() { console.log('=== UNIMAST ERP Design Patterns Debug ==='); // App Router Server-First console.log('App Router:', 'Server-first architecture'); console.log('Layouts:', 'Nested layouts with providers'); console.log('Client Islands:', 'Only when necessary (theme, state)'); // Autorización Declarativa console.log('Authorization:', 'Declarative with WithScopes'); console.log('Scopes:', 'Centralized in constants.ts'); console.log('Server/Client:', 'Both implementations available'); // Formularios Escalables console.log('Forms:', 'react-hook-form + Zod + shadcn/ui'); console.log('Validation:', 'Schema-based with Zod'); console.log('Components:', 'Reusable form components'); // Jobs Resilientes console.log('Jobs:', 'BullMQ with exponential backoff'); console.log('Retries:', '15 attempts with exponential delay'); console.log('Idempotency:', 'Unique job names'); // Webhooks Confiables console.log('Webhooks:', 'Custom headers X-Unimast-*'); console.log('Retries:', 'Automatic retry with backoff'); console.log('Error Handling:', 'HTTP status validation'); }

¿Necesitas más detalles sobre algún aspecto específico? Revisa la documentación de auth-and-authorization para entender mejor el patrón de autorización declarativa, o la documentación de background-jobs para aprender sobre los patrones de jobs resilientes.