feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 15:39:18 -05:00
1 parent 8ba845cc23
commit 3caf94609d
10 files changed
+178 -31

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import type { MenuItem } from './menu.js';
/**
* Pantallas que la rama de un cliente suma al menú (vacío en `main`), por id de módulo (`inventario`, `ventas`…): se
* agregan al final de ese módulo. Cada ruta necesita su pantalla en `client-routes.ts` del frontend; si el recurso que
* usa necesita permiso en el proxy del Service Layer, se declara en `sl-resources.extensions.ts` de la API.
*
* Ejemplo:
*
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
*/
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {};
+17 -19
View File
@@ -1,26 +1,24 @@
import { MENU, filterMenu, menuPaths } from './menu.js';
import { describe, expect, it } from 'vitest';
import { MENU, extendMenu } from './menu.js';
describe('MENU', () => {
it('no repite rutas y todas son absolutas', () => {
const paths = menuPaths();
expect(new Set(paths).size).toBe(paths.length);
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
});
});
describe('filterMenu', () => {
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
const base = [
{ id: 'a', label: 'A', items: [{ path: '/a/uno', label: 'Uno' }] },
{ id: 'b', label: 'B', items: [] },
];
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length));
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([]));
describe('extendMenu', () => {
it('suma las pantallas del cliente al final de su módulo y deja los demás igual', () => {
const m = extendMenu(base, { a: [{ path: '/a/dos', label: 'Dos' }] });
expect(m[0].items.map((i) => i.path)).toEqual(['/a/uno', '/a/dos']);
expect(m[1]).toBe(base[1]);
});
it('un módulo completo y una pantalla suelta de otro', () => {
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']);
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']);
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
it('un módulo que no existe o una ruta repetida es un error', () => {
expect(() => extendMenu(base, { zz: [{ path: '/zz/x', label: 'X' }] })).toThrow(/no existe el módulo/);
expect(() => extendMenu(base, { b: [{ path: '/a/uno', label: 'Otra' }] })).toThrow(/ya existe/);
});
it('omite los módulos sin pantallas permitidas', () => {
expect(ids(['/socios/clientes'])).toEqual(['socios']);
it('en main el menú es el del base (sin pantallas de cliente)', () => {
expect(MENU.length).toBeGreaterThan(0);
});
});
+29 -5
View File
@@ -1,3 +1,4 @@
import { CLIENT_MENU_ITEMS } from './menu.extensions.js';
import { isPathAllowed } from './permissions.js';
export interface MenuItem {
@@ -12,11 +13,8 @@ export interface MenuModule {
items: readonly MenuItem[];
}
/**
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
*/
export const MENU: readonly MenuModule[] = [
/** Menú del proyecto base; cada cliente suma sus pantallas en `menu.extensions.ts` (el resultado es `MENU`). */
const BASE_MENU: readonly MenuModule[] = [
{
id: 'ventas',
label: 'Ventas',
@@ -101,6 +99,32 @@ export const MENU: readonly MenuModule[] = [
},
];
/**
* Suma las pantallas de un cliente al final de los módulos del menú base (por id de módulo). Un módulo que no existe o
* una ruta repetida es un error: un descuido no debe pasar en silencio.
*/
export function extendMenu(base: readonly MenuModule[], extra: Readonly<Record<string, readonly MenuItem[]>>): readonly MenuModule[] {
for (const id of Object.keys(extra)) {
if (!base.some((m) => m.id === id)) throw new Error(`menu.extensions: no existe el módulo «${id}»`);
}
const taken = new Set(base.flatMap((m) => m.items.map((i) => i.path)));
return base.map((m) => {
const more = extra[m.id];
if (!more?.length) return m;
for (const item of more) {
if (taken.has(item.path)) throw new Error(`menu.extensions: la ruta «${item.path}» ya existe`);
taken.add(item.path);
}
return { ...m, items: [...m.items, ...more] };
});
}
/**
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
*/
export const MENU: readonly MenuModule[] = extendMenu(BASE_MENU, CLIENT_MENU_ITEMS);
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
return menu