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>
);
};Navegación Dinámica Basada en Permisos
// 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=15Configuració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.