First Commit

master
dev 8 months ago
parent 669f6dd94d
commit 70655bbffc

@ -1,6 +1,6 @@
# Logr # Logr
Sistema de logging estructurado con soporte nativo para mensajes localizados. Depende de `@/libs/i18n` para la resolución de mensajes al locale activo. Sistema de logging estructurado con soporte nativo para mensajes localizados y sistema de transports extensible. Depende de `@/libs/i18n` para la resolución de mensajes al locale activo.
--- ---
@ -8,10 +8,12 @@ Sistema de logging estructurado con soporte nativo para mensajes localizados. De
``` ```
logr/ logr/
├── index.ts # Barrel — punto de entrada público ├── index.ts # Barrel — punto de entrada público
├── logr.engine.ts # Factory: createLogr() ├── logr.engine.ts # Factory: createLogr()
├── logr.types.ts # Tipos e interfaces ├── logr.transports.ts # Transports built-in
└── logr.test.ts ├── logr.types.ts # Tipos e interfaces
├── logr.test.ts # Tests del engine
└── logr.transports.test.ts # Tests de los transports
``` ```
--- ---
@ -20,10 +22,20 @@ logr/
```ts ```ts
// logr/index.ts — instancia global // logr/index.ts — instancia global
import { createLogr, LogLevel } from './logr.engine'; import { createLogr, LogLevel, consoleTransport, httpTransport } from './logr.engine';
import { i18n } from '@/libs/i18n/i18n.engine'; import { i18n } from '@/libs/i18n/i18n.engine';
export const logr = createLogr(i18n, { level: LogLevel.WARN }); export const logr = createLogr(i18n, {
level : LogLevel.WARN,
transports: [
consoleTransport(),
httpTransport({
url : 'https://logs.myapp.com/ingest',
headers: { 'Authorization': 'Bearer my-token' },
level : LogLevel.ERROR, // solo errores al servidor
}),
]
});
``` ```
```ts ```ts
@ -34,6 +46,8 @@ if (import.meta.env.DEV) {
} }
``` ```
> Si no se especifican transports, usa `consoleTransport()` por defecto.
--- ---
## API ## API
@ -61,9 +75,6 @@ logr.warn('db', 'Connection timeout', { host: 'db.prod', ms: 3000 });
// LocaleRecord — se resuelve al locale activo automáticamente // LocaleRecord — se resuelve al locale activo automáticamente
logr.error('auth', { es: 'Login fallido', en: 'Login failed' }); logr.error('auth', { es: 'Login fallido', en: 'Login failed' });
// Usando traducciones registradas en i18n
logr.warn('checkout', i18n.ts('checkout.total', { amount: 99, currency: '€' }));
``` ```
Un log solo se procesa si su nivel es **mayor o igual** al nivel configurado: Un log solo se procesa si su nivel es **mayor o igual** al nivel configurado:
@ -77,10 +88,10 @@ DEBUG(0) < INFO(1) < WARN(2) < ERROR(3) < NONE(4)
Devuelve las entradas del historial en memoria. Los filtros son opcionales y se combinan con AND. Devuelve las entradas del historial en memoria. Los filtros son opcionales y se combinan con AND.
```ts ```ts
logr.getLogs() // todas las entradas logr.getLogs() // todas las entradas
logr.getLogs({ level: LogLevel.ERROR }) // solo errores logr.getLogs({ level: LogLevel.ERROR }) // solo errores
logr.getLogs({ category: 'auth' }) // solo entradas de 'auth' logr.getLogs({ category: 'auth' }) // solo entradas de 'auth'
logr.getLogs({ since: new Date('2024-01-01') }) // desde una fecha logr.getLogs({ since: new Date('2024-01-01') }) // desde una fecha
logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados
``` ```
@ -88,7 +99,7 @@ logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados
### `serialize()` ### `serialize()`
Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**, no en el momento en que se registraron. Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**.
```ts ```ts
const json = logr.serialize(); const json = logr.serialize();
@ -105,8 +116,6 @@ const json = logr.serialize();
### `clear()` ### `clear()`
Vacía el historial en memoria.
```ts ```ts
logr.clear(); logr.clear();
logr.getLogs(); // → [] logr.getLogs(); // → []
@ -114,8 +123,6 @@ logr.getLogs(); // → []
### `setLevel(level)` ### `setLevel(level)`
Cambia el nivel mínimo en tiempo de ejecución.
```ts ```ts
logr.setLevel(LogLevel.DEBUG) // activa todos los niveles logr.setLevel(LogLevel.DEBUG) // activa todos los niveles
logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests
@ -123,8 +130,6 @@ logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests
### `setMaxLogs(max)` ### `setMaxLogs(max)`
Cambia el número máximo de entradas a retener. Cuando se supera el límite, se descartan las más antiguas.
```ts ```ts
logr.setMaxLogs(5000) // desarrollo logr.setMaxLogs(5000) // desarrollo
logr.setMaxLogs(500) // producción logr.setMaxLogs(500) // producción
@ -132,6 +137,96 @@ logr.setMaxLogs(500) // producción
--- ---
## Transports
Un transport es un destino de salida para las entradas de log. El engine emite cada entrada a todos los transports registrados, con el mensaje ya resuelto al locale activo.
### `consoleTransport(options?)`
Emite los logs a la consola usando el método apropiado según el nivel.
```ts
consoleTransport() // con timestamp y prefijo
consoleTransport({ timestamp: false }) // sin timestamp
consoleTransport({ prefix: false }) // sin prefijo [category]
consoleTransport({ timestamp: false, prefix: false }) // solo el mensaje
```
| Opción | Tipo | Default | Descripción |
|-------------|-----------|---------|-------------|
| `timestamp` | `boolean` | `true` | Incluye el ISO timestamp en el output |
| `prefix` | `boolean` | `true` | Incluye el prefijo `[category]` |
### `httpTransport(options)`
Envía las entradas a un endpoint HTTP remoto via POST. Fire-and-forget — los errores de red no interrumpen la aplicación.
```ts
httpTransport({
url : 'https://logs.myapp.com/ingest',
headers: { 'Authorization': 'Bearer token' },
level : LogLevel.ERROR // nivel mínimo independiente del logger
})
```
| Opción | Tipo | Default | Descripción |
|-----------|---------------|-----------------|-------------|
| `url` | `string` | — | Endpoint que recibe los logs |
| `headers` | `Record<string, string>` | `{}` | Headers adicionales |
| `level` | `LogLevel` | `LogLevel.ERROR` | Nivel mínimo para enviar al servidor |
El body enviado tiene la forma:
```json
{
"timestamp": "2024-01-01T00:00:00.000Z",
"level" : 3,
"category" : "auth",
"message" : "Login failed",
"context" : { "userId": 42 }
}
```
### `callbackTransport(fn)`
Invoca una función por cada entrada. El transport más flexible — útil para integraciones custom, Sentry, o capturar logs en tests sin output a consola.
```ts
// Integración con Sentry
callbackTransport((entry, message) => {
if (entry.level >= LogLevel.ERROR) {
Sentry.captureMessage(message, { extra: entry.context });
}
})
// Captura en tests — sin output a consola
const captured: string[] = [];
const logr = createLogr(i18n, {
level : LogLevel.DEBUG,
transports: [ callbackTransport((_, msg) => captured.push(msg)) ]
});
```
### Transport custom
Cualquier objeto que implemente la interfaz `Transport` es válido:
```ts
import type { Transport } from '@/libs/logr';
const myTransport: Transport = {
write(entry, resolvedMessage) {
myService.send({
level : entry.level,
message: resolvedMessage,
context: entry.context,
});
}
};
```
---
## Niveles ## Niveles
| Nivel | Valor | Uso recomendado | | Nivel | Valor | Uso recomendado |
@ -158,14 +253,15 @@ logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' });
// output → "[auth] Session expired" // output → "[auth] Session expired"
``` ```
> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output a consola, y en `serialize()` en el momento de la llamada. El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports.
> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialize()` en el momento de la llamada.
--- ---
## Configuración por entorno ## Configuración por entorno
```ts ```ts
// helpers de configuración rápida
export function setupDevelopmentLogging(): void { export function setupDevelopmentLogging(): void {
logr.setLevel(LogLevel.DEBUG); logr.setLevel(LogLevel.DEBUG);
logr.setMaxLogs(5000); logr.setMaxLogs(5000);
@ -185,14 +281,17 @@ export function setupTestLogging(): void {
## Tipos públicos ## Tipos públicos
| Tipo | Descripción | | Tipo | Descripción |
|------------------|-------------| |-------------------------|-------------|
| `LogLevel` | Enum de niveles de severidad | | `LogLevel` | Enum de niveles de severidad |
| `MessageCategory`| `string` — dominio funcional del log | | `MessageCategory` | `string` — dominio funcional del log |
| `LogEntry` | Entrada individual del historial | | `LogEntry` | Entrada individual del historial |
| `LogrOptions` | Opciones de configuración de `createLogr` | | `LogrOptions` | Opciones de configuración de `createLogr` |
| `LogFilters` | Filtros para `getLogs()` | | `LogFilters` | Filtros para `getLogs()` |
| `Logr` | Tipo de la instancia | | `Transport` | Interfaz que deben implementar los transports |
| `ConsoleTransportOptions` | Opciones de `consoleTransport()` |
| `HttpTransportOptions` | Opciones de `httpTransport()` |
| `Logr` | Tipo de la instancia |
--- ---
@ -202,4 +301,4 @@ export function setupTestLogging(): void {
vitest vitest
``` ```
Los tests usan un schema de i18n propio independiente del de producción. Los tests usan un schema de i18n propio independiente del de producción. Los transports se testean de forma aislada usando `callbackTransport` para capturar los mensajes sin output a consola.

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createI18n } from './index'; import { createI18n } from '../index.ts';
import type { TranslationNode, I18nString } from './index'; import type { TranslationNode, I18nString } from '../index.ts';
// ============================== // ==============================

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLogr, LogLevel } from './index.ts'; import { createLogr, LogLevel } from '../index.ts';
import { createI18n } from '@/libs/i18n'; import { createI18n } from '@/libs/i18n';
import type { TranslationNode, SupportedLocale } from '@/libs/i18n'; import type { TranslationNode, SupportedLocale } from '@/libs/i18n';

@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { consoleTransport, httpTransport, callbackTransport } from './logr.transports'; import { consoleTransport, httpTransport, callbackTransport } from '../logr.transports.ts';
import { createLogr, LogLevel } from './index'; import { createLogr, LogLevel } from '../index.ts';
import { createI18n } from '@/libs/i18n'; import { createI18n } from '@/libs/i18n';
import type { TranslationNode, SupportedLocale } from '@/libs/i18n'; import type { TranslationNode, SupportedLocale } from '@/libs/i18n';
import type { LogEntry } from './logr.types'; import type { LogEntry } from '../logr.types.ts';
// ============================================================================ // ============================================================================
// HELPERS // HELPERS
@ -104,7 +104,7 @@ describe('consoleTransport', () => {
const t = consoleTransport(); const t = consoleTransport();
t.write(makeEntry({ context: { userId: 42 } }), 'msg'); t.write(makeEntry({ context: { userId: 42 } }), 'msg');
const args = spy.mock.calls[0]; const args = spy.mock.calls[0];
expect(args).toContain(JSON.stringify({ userId: 42 }) || expect.objectContaining({ userId: 42 })); expect(args).toContainEqual(expect.objectContaining({ userId: 42 }));
spy.mockRestore(); spy.mockRestore();
}); });
}); });
@ -253,4 +253,3 @@ describe('múltiples transports', () => {
expect(() => logr.warn('auth', { es: 'msg' })).toThrow(); expect(() => logr.warn('auth', { es: 'msg' })).toThrow();
}); });
}); });

@ -1,85 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { i18n } from '@/libs/olds/i18n/index.ts'; // Importamos la instancia configurada
describe('I18N Modular System', () => {
beforeEach(() => {
// Resetear al idioma por defecto antes de cada test
i18n.setLocale('es');
});
describe('Core Functionality', () => {
it('debe cargar las traducciones iniciales del core', () => {
expect(i18n.t('core.loading')).toBe('Cargando...');
expect(i18n.t('core.error', undefined, 'en')).toBe('An unexpected error occurred');
});
it('debe cambiar el idioma globalmente', () => {
i18n.setLocale('en');
expect(i18n.t('core.loading')).toBe('Loading...');
});
});
describe('Dynamic Registration (addResource)', () => {
it('debe permitir añadir nuevos recursos después de la instanciación', () => {
// Registramos un recurso que no existía inicialmente
i18n.addResource('es', {
ui: {
buttons: { save: 'Guardar' }
}
});
expect(i18n.t('ui.buttons.save' as any)).toBe('Guardar');
});
it('debe registrar y ejecutar funciones dinámicas añadidas a posteriori', () => {
i18n.addResource('es', {
jsonLogic: {
errors: {
UNKNOWN_OPERATOR: (op: string) => `Op desconocido: ${op}`
}
}
});
// Validamos que la función se ejecute correctamente
expect(i18n.t('jsonLogic.errors.UNKNOWN_OPERATOR' as any, ['XOR'])).toBe('Op desconocido: XOR');
});
it('debe sobrescribir traducciones existentes si se registra la misma clave', () => {
i18n.addResource('es', {
core: { loading: 'Espera un momento...' }
});
expect(i18n.t('core.loading')).toBe('Espera un momento...');
});
});
describe('Fallback & Edge Cases', () => {
it('debe hacer fallback al idioma por defecto si la clave falta en el idioma actual', () => {
i18n.setLocale('en');
// 'ui.buttons.save' solo lo añadimos en 'es' en el test anterior
// (Asumiendo que los tests comparten instancia o lo añadimos aquí)
i18n.addResource('es', { common: { cancel: 'Cancelar' } });
expect(i18n.t('common.cancel' as any)).toBe('Cancelar');
});
it('debe devolver la clave si no existe en ningún idioma', () => {
// @ts-expect-error - Probando comportamiento ante claves inexistentes
expect(i18n.t('non.existent.key')).toBe('non.existent.key');
});
it('debe manejar correctamente la interpolación con valores 0 o strings vacíos', () => {
i18n.addResource('es', {
test: { zero: 'Valor: {{val}}', empty: 'Texto: {{val}}' }
});
expect(i18n.t('test.zero' as any, { val: 0 })).toBe('Valor: 0');
expect(i18n.t('test.empty' as any, { val: '' })).toBe('Texto: ');
});
});
});

@ -1,78 +0,0 @@
// i18n.ts
import type {
DotNestedKeys,
GetTypeAtPath,
InterpolationParams,
TranslationStore
} from './i18n-types';
export function createI18n<TSchema extends object, TLocale extends string>(
initialStore: Partial<TranslationStore<TSchema, TLocale>> = {}, // Ahora puede empezar vacío
defaultLocale: TLocale
) {
let currentLocale: TLocale = defaultLocale;
// El almacén plano donde se acumulará todo
const flatStore = {} as Record<TLocale, Record<string, any>>;
function flatten(obj: any, prefix = ''): Record<string, any> {
return Object.keys(obj).reduce((acc: any, k: string) => {
const path = prefix ? `${prefix}.${k}` : k;
if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
Object.assign(acc, flatten(obj[k], path));
} else {
acc[path] = obj[k];
}
return acc;
}, {});
}
// Función para procesar y añadir recursos
const addResource = (locale: TLocale, resource: any) => {
if (!flatStore[locale]) flatStore[locale] = {};
const flattened = flatten(resource);
Object.assign(flatStore[locale], flattened);
};
// Inicializar con lo que venga en el constructor
for (const loc in initialStore) {
addResource(loc as TLocale, initialStore[loc]);
}
return {
setLocale: (l: TLocale) => { currentLocale = l; },
getLocale: () => currentLocale,
/**
* Añade nuevas traducciones a un idioma específico después de la instanciación.
* Útil para cargar traducciones de módulos o plugins bajo demanda.
*/
addResource,
t: <K extends DotNestedKeys<TSchema>>(
key: K,
args?: GetTypeAtPath<TSchema, K> extends (...args: infer P) => any ? P : InterpolationParams,
overrideLocale?: TLocale
): string => {
const activeLocale = overrideLocale || currentLocale;
const entry = flatStore[activeLocale]?.[key] || flatStore[defaultLocale]?.[key];
if (!entry) return key;
if (typeof entry === 'function') {
const params = Array.isArray(args) ? args : [];
return entry(...params);
}
if (typeof entry === 'string' && args && !Array.isArray(args)) {
const params = args as InterpolationParams;
return entry.replace(/\{\{([^}]+)}}/g, (match: string, k: string): string => {
const value = params[k];
return value !== undefined ? String(value) : match;
});
}
return String(entry);
}
};
}

@ -1,37 +0,0 @@
/**
* Determina si un nodo es un terminal (un string o una función)
* o si debemos seguir navegando por el objeto.
*/
export type IsTerminal<T> = T extends (...args: any[]) => any
? true
: T extends object ? false : true;
/**
* Genera claves en notación de puntos (ej: 'errors.UNKNOWN_OPERATOR')
*/
export type DotNestedKeys<T> = T extends object
? {
[K in keyof T & string]: IsTerminal<T[K]> extends true
? K
: `${K}.${DotNestedKeys<T[K]>}`
}[keyof T & string]
: '';
/**
* Extrae el tipo exacto (String o Función) de una ruta específica
*/
export type GetTypeAtPath<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
? Head extends keyof T ? GetTypeAtPath<T[Head], Tail> : never
: Path extends keyof T ? T[Path] : never;
/**
* Estructura para variables de interpolación {{var}}
*/
export type InterpolationParams = Record<string, string | number>;
/**
* Definición del almacén de traducciones
*/
export type TranslationStore<TSchema, TLocale extends string> = {
[L in TLocale]: TSchema;
};

@ -1,39 +0,0 @@
import type { TranslationStore } from './i18n-types';
import type { AppSchema } from "@/libs/olds/i18n/schema.ts";
import { createI18n } from './i18n.ts';
/**
* 1. IDIOMAS SOPORTADOS
*/
export type SupportedLocales = 'es' | 'en';
/**
* 2. TRADUCCIONES INICIALES (CORE)
* Solo incluimos lo mínimo indispensable para que la app arranque.
*/
const initialTranslations: Partial<TranslationStore<AppSchema, SupportedLocales>> = {
es: {
core: {
error : "Ha ocurrido un error inesperado",
loading: "Cargando..."
}
},
en: {
core: {
error : "An unexpected error occurred",
loading: "Loading..."
}
}
};
/**
* 4. INSTANCIA EXPORTABLE
*/
export const i18n = createI18n<AppSchema, SupportedLocales>(
initialTranslations,
'es'
);
// Helper para exportar directamente la función de traducción
export const t = i18n.t;

@ -1,25 +0,0 @@
/**
* 1. DEFINICIÓN DEL ESQUEMA GLOBAL
* Reúne todas las claves posibles de tu aplicación o librería.
* Si usas módulos opcionales, puedes definirlos como parciales.
*/
export type AppSchema = {
// Diccionario base (siempre presente)
core: {
error: string;
loading: string;
};
// Diccionarios de módulos (se pueden llenar vía addResource)
jsonLogic?: {
errors: {
UNKNOWN_OPERATOR: (op: string) => string;
MISSING_DATA: (field: string) => string;
};
};
ui?: {
buttons: {
save: string;
delete: string;
};
};
};

@ -1,50 +0,0 @@
import type {
Paths,
GetTypeAtPath,
ParamsFor,
HasParams
} from './i18n.types.ts';
import { translations } from './schema.ts';
import type { TranslationSchema } from './schema.ts';
import type { SupportedLocale, DefaultLocale } from './i18n.types.ts';
let currentLocale: SupportedLocale = 'es';
export function setLocale(locale: SupportedLocale) {
currentLocale = locale;
}
export function getLocale() {
return currentLocale;
}
function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function t<
P extends Paths<TranslationSchema>,
TType = GetTypeAtPath<TranslationSchema, P>
>(
path: P,
...args: HasParams<TType> extends true
? [params: ParamsFor<TType>]
: []
): string {
const value = resolvePath(translations, path);
if (!value) {
throw new Error(`Missing translation key: ${path}`);
}
const record =
typeof value === 'function'
? value(args[0])
: value;
return (
record[currentLocale] ??
record['es'] // fallback default
);
}

@ -1,81 +0,0 @@
// ==============================
// LOCALES
// ==============================
export type DefaultLocale = 'es';
export type SupportedLocale =
| DefaultLocale
| 'en'
| 'de'
| 'fr'
| 'it'
| 'pt'
| 'ca'
| 'eu'
| 'gl';
// ==============================
// LOCALIZED TYPES
// ==============================
export type LocaleRecord<L extends string> = {
[K in L]?: string;
} & {
[K in DefaultLocale]: string;
};
export type TranslationValue<L extends string> =
| LocaleRecord<L>
| ((params: any) => LocaleRecord<L>);
// ==============================
// TYPE UTILITIES
// ==============================
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
type Join<K, P> =
P extends string
? `${K & string}.${P}`
: never;
export type Paths<
T,
D extends number = 6
> =
[D] extends [never]
? never
: T extends object
? {
[K in keyof T & string]:
| K
| Join<K, Paths<T[K], Prev[D]>>
}[keyof T & string]
: never;
export type GetTypeAtPath<
T,
P extends string
> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T
? GetTypeAtPath<T[K], Rest>
: never
: P extends keyof T
? T[P]
: never;
export type ParamsFor<T> =
T extends (params: infer P) => any
? P
: never;
export type HasParams<T> =
T extends (params: any) => any
? true
: false;

@ -1,25 +0,0 @@
import type { SupportedLocale, TranslationValue } from './i18n.types.ts';
export const translations = {
checkout: {
pay: {
es: "Pagar",
en: "Pay"
},
total: (params: { amount: number }) => ({
es: `Total: ${params.amount}€`,
en: `Total: $${params.amount}`
})
},
common: {
ok: {
es: "Aceptar",
en: "OK"
}
}
} satisfies Record<string, any>;
export type TranslationSchema = typeof translations;

@ -0,0 +1,182 @@
/**
* ============================================================================
* ENGINE MESSAGES
* ============================================================================
*
* Mensajes centralizados del engine — errores, warnings e info.
* Sin strings hardcodeados en el código.
*
* Dos tipos:
* - Técnicos (string): solo los ven developers — logs internos
* - Usuario (I18nString): los ve el usuario final — validaciones, UI
*/
import type { I18nString } from '@/libs/i18n';
import type { AttrID, OptionID, ObjectID, SectionID, ViewID } from '../types';
// ============================================================================
// CATEGORIES — para el sistema logr
// ============================================================================
export const ENGINE_CATEGORIES = {
RULE_ENGINE : 'rule-engine',
JSON_LOGIC : 'json-logic',
TEMPLATE_RESOLVER: 'template-resolver',
PRICING : 'pricing',
SELECTION : 'selection',
CONFIGURATION : 'configuration',
} as const;
export type EngineCategory = typeof ENGINE_CATEGORIES[keyof typeof ENGINE_CATEGORIES];
// ============================================================================
// RULE ENGINE — mensajes técnicos
// ============================================================================
export const RULE_ENGINE_ERRORS = {
RULE_EVALUATION_FAILED: (ruleId: string, error: string): string =>
`Error evaluating rule "${ruleId}": ${error}`,
} as const;
// ============================================================================
// JSON LOGIC — mensajes técnicos
// ============================================================================
export const JSON_LOGIC_WARNINGS = {
UNKNOWN_OPERATOR: (operator: string): string =>
`Unknown JsonLogic operator: "${operator}"`,
DIVISION_BY_ZERO: (): string =>
`Division by zero in JsonLogic expression`,
} as const;
// ============================================================================
// TEMPLATE RESOLVER — mensajes técnicos
// ============================================================================
export const TEMPLATE_RESOLVER_ERRORS = {
OBJECT_NOT_FOUND: (objectId: ObjectID): string =>
`Object "${objectId}" not found in catalog`,
SECTION_NOT_FOUND: (sectionId: SectionID, objectId: ObjectID): string =>
`Section "${sectionId}" not found in object "${objectId}"`,
SECTION_NOT_VISUAL: (sectionId: SectionID): string =>
`Section "${sectionId}" is not visual — cannot resolve image template`,
VIEW_NOT_FOUND: (viewId: ViewID, sectionId: SectionID): string =>
`View "${viewId}" not found in section "${sectionId}"`,
ATTRIBUTE_NOT_FOUND: (attrCode: string): string =>
`Attribute with code "${attrCode}" not found in section or object attributes`,
ATTRIBUTE_NO_VALUE: (attrId: AttrID): string =>
`Attribute "${attrId}" has no value in state — all attributes must have a default`,
OPTION_NOT_FOUND: (optionId: string): string =>
`Option "${optionId}" not found in catalog`,
TEMPLATE_UNRESOLVABLE: (template: string): string =>
`Could not resolve template "${template}" — using fallback image`,
} as const;
export const TEMPLATE_RESOLVER_WARNINGS = {
UNSUPPORTED_STRATEGY: (strategy: string): string =>
`TemplateResolver only handles "static_image" strategy — got "${strategy}"`,
NO_FALLBACK_IMAGE: (objectId?: ObjectID, sectionId?: SectionID): string =>
`No fallback image configured` +
(objectId ? ` for object "${objectId}"` : '') +
(sectionId ? ` section "${sectionId}"` : ''),
} as const;
// ============================================================================
// PRICING ENGINE — mensajes técnicos
// ============================================================================
export const PRICING_ERRORS = {
OPTION_NOT_FOUND: (optionId: OptionID): string =>
`Option "${optionId}" not found in catalog — cannot calculate price`,
EXPRESSION_FAILED: (optionId: OptionID, error: string): string =>
`Error evaluating price expression for option "${optionId}": ${error}`,
} as const;
// ============================================================================
// SELECTION — mensajes técnicos
// ============================================================================
export const SELECTION_ERRORS = {
ATTRIBUTE_NOT_FOUND: (attrId: AttrID): string =>
`Attribute "${attrId}" not found`,
INVALID_OPTION: (attrId: AttrID, optionId: OptionID): string =>
`Option "${optionId}" is not valid for attribute "${attrId}"`,
} as const;
// ============================================================================
// MENSAJES DE USUARIO — I18nString
// Los ve el usuario final en la UI
// ============================================================================
export const USER_MESSAGES = {
VALIDATION: {
CONFIGURATION_INVALID: {
es: 'La configuración tiene errores que deben resolverse',
en: 'The configuration has errors that must be resolved',
} satisfies I18nString,
ATTRIBUTE_REQUIRED: {
es: 'Este campo es obligatorio',
en: 'This field is required',
} satisfies I18nString,
VALUE_NOT_ALLOWED: {
es: 'Esta opción no está disponible con la configuración actual',
en: 'This option is not available with the current configuration',
} satisfies I18nString,
VALUE_FORBIDDEN: {
es: 'Esta opción no es compatible con otras selecciones',
en: 'This option is not compatible with other selections',
} satisfies I18nString,
},
IMAGE: {
LOADING: {
es: 'Cargando imagen...',
en: 'Loading image...',
} satisfies I18nString,
LOAD_ERROR: {
es: 'No se pudo cargar la imagen',
en: 'Could not load the image',
} satisfies I18nString,
},
PRICING: {
CONSULTATION: {
es: 'Precio bajo consulta',
en: 'Price on request',
} satisfies I18nString,
CALCULATING: {
es: 'Calculando precio...',
en: 'Calculating price...',
} satisfies I18nString,
},
} as const;

@ -0,0 +1,183 @@
/**
* ============================================================================
* JSON LOGIC EVALUATOR
* ============================================================================
*/
import type { Logr } from '@/libs/logr';
import type { JsonLogic } from '../types';
import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts/messages';
/**
* Evaluador de expresiones JsonLogic.
*
* Implementación propia sin dependencias externas.
* Soporta: variables, lógicos, comparación, condicionales, arrays y matemáticos.
*
* Las variables referencian el estado de selección normalizado:
* 'at:calidad' → 'at_calidad' en los datos pasados al evaluador.
*/
export class JsonLogicEvaluator {
private readonly logr: Logr;
constructor(logr: Logr) {
this.logr = logr;
}
/**
* Evalúa una expresión JsonLogic contra un contexto de datos.
*
* @param expression - Expresión JsonLogic a evaluar
* @param data - Contexto de datos — normalmente el estado de selección normalizado
* @returns - Resultado de la evaluación — tipo depende de la expresión
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
evaluate(expression: JsonLogic, data: Record<string, unknown>): unknown {
// Primitivos
if (expression === null) return null;
if (typeof expression === 'boolean') return expression;
if (typeof expression === 'number') return expression;
if (typeof expression === 'string') return expression;
if (Array.isArray(expression)) return expression.map(item => this.evaluate(item, data));
if (typeof expression !== 'object') return expression;
const expr = expression as Record<string, unknown>;
const operator = Object.keys(expr)[0];
const args = expr[operator];
switch (operator) {
// ----------------------------------------------------------------
// Variables
// ----------------------------------------------------------------
case 'var':
return this.getVar(args, data);
// ----------------------------------------------------------------
// Igualdad
// ----------------------------------------------------------------
case '==':
case '===':
return this.evaluateAll(args, data).every((v, i, arr) =>
i === 0 || v === arr[0]
);
case '!=':
case '!==': {
const [a, b] = this.evaluateAll(args, data);
return a !== b;
}
case '!':
return !this.evaluate(args as JsonLogic, data);
// ----------------------------------------------------------------
// Lógicos
// ----------------------------------------------------------------
case 'and':
return this.evaluateAll(args, data).every(Boolean);
case 'or':
return this.evaluateAll(args, data).some(Boolean);
// ----------------------------------------------------------------
// Comparación
// ----------------------------------------------------------------
case '>': {
const [a, b] = this.evaluateAll(args, data);
return (a as number) > (b as number);
}
case '>=': {
const [a, b] = this.evaluateAll(args, data);
return (a as number) >= (b as number);
}
case '<': {
const [a, b] = this.evaluateAll(args, data);
return (a as number) < (b as number);
}
case '<=': {
const [a, b] = this.evaluateAll(args, data);
return (a as number) <= (b as number);
}
// ----------------------------------------------------------------
// Condicional
// ----------------------------------------------------------------
case 'if': {
const [condition, thenBranch, elseBranch] = this.evaluateAll(args, data);
return condition ? thenBranch : (elseBranch ?? null);
}
// ----------------------------------------------------------------
// Arrays
// ----------------------------------------------------------------
case 'in': {
const [item, array] = this.evaluateAll(args, data);
return Array.isArray(array) && array.includes(item);
}
// ----------------------------------------------------------------
// Matemáticos
// ----------------------------------------------------------------
case '+':
return this.evaluateAll(args, data).reduce((a, b) => (a as number) + (b as number), 0);
case '-': {
const nums = this.evaluateAll(args, data);
return nums.length === 1
? -(nums[0] as number)
: (nums[0] as number) - (nums[1] as number);
}
case '*':
return this.evaluateAll(args, data).reduce((a, b) => (a as number) * (b as number), 1);
case '/': {
const [dividend, divisor] = this.evaluateAll(args, data);
if ((divisor as number) === 0) {
this.logr.warn(ENGINE_CATEGORIES.JSON_LOGIC, JSON_LOGIC_WARNINGS.DIVISION_BY_ZERO());
return null;
}
return (dividend as number) / (divisor as number);
}
case '%': {
const [a, b] = this.evaluateAll(args, data);
return (a as number) % (b as number);
}
// ----------------------------------------------------------------
// Operador desconocido
// ----------------------------------------------------------------
default:
this.logr.warn(ENGINE_CATEGORIES.JSON_LOGIC, JSON_LOGIC_WARNINGS.UNKNOWN_OPERATOR(operator));
return null;
}
}
private evaluateAll(args: unknown, data: Record<string, unknown>): unknown[] {
return (Array.isArray(args) ? args : [args]).map(arg =>
this.evaluate(arg as JsonLogic, data)
);
}
private getVar(path: unknown, data: Record<string, unknown>): unknown {
const resolvedPath = typeof path === 'string'
? path
: String(this.evaluate(path as JsonLogic, data));
return resolvedPath
.split('.')
.reduce<unknown>((current, part) => {
if (current === undefined || current === null) return undefined;
return (current as Record<string, unknown>)[part];
}, data);
}
}

@ -0,0 +1,298 @@
/**
* ============================================================================
* RULE ENGINE
* ============================================================================
*
* Evalúa ValidationRules usando el JsonLogicEvaluator propio.
* Determina qué atributos están permitidos/prohibidos/requeridos
* en función del estado de selección actual.
*/
import type { Logr } from '@/libs/logr';
import type {
ValidationRule,
ValidationRuleAction,
AttrID,
OptionID,
Value,
Severity
} from '../types';
import { JsonLogicEvaluator } from './evaluator.engine';
import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages';
// ============================================================================
// TYPES
// ============================================================================
/**
* Estado de selección actual del usuario.
* Mapa de AttrID → valor seleccionado.
*/
export type SelectionMap = Record<AttrID, Value>;
/**
* Resultado de evaluar una regla contra el estado actual.
*/
export interface RuleEvaluationResult {
rule : ValidationRule;
triggered: boolean;
action? : ValidationRuleAction;
}
/**
* Resultado consolidado de todas las reglas para un atributo concreto.
*/
export interface AttributeRuleResult {
attrId : AttrID;
/** Lista blanca — si está definida, solo estos valores son válidos */
allowedValues? : OptionID[];
/** Lista negra — estos valores están prohibidos */
forbiddenValues?: OptionID[];
/** Si el atributo debe tener un valor seleccionado */
required : boolean;
/** Violaciones activas para este atributo */
violations : RuleViolation[];
}
/**
* Violación de una regla — la condición se cumple pero el valor actual la incumple.
*/
export interface RuleViolation {
rule : ValidationRule;
severity: Severity;
}
// ============================================================================
// RULE ENGINE
// ============================================================================
export class RuleEngine {
private readonly evaluator: JsonLogicEvaluator;
private readonly logr : Logr;
constructor(logr: Logr) {
this.logr = logr;
this.evaluator = new JsonLogicEvaluator(logr);
}
// -------------------------------------------------------------------------
// Evaluación
// -------------------------------------------------------------------------
/**
* Evalúa todas las reglas contra el estado actual.
* Las reglas se ordenan por prioridad descendente antes de evaluarse.
*/
evaluateAll(
rules: ValidationRule[],
state: SelectionMap
): RuleEvaluationResult[] {
return [...rules]
.sort((a, b) => b.priority - a.priority)
.map(rule => this.evaluateRule(rule, state));
}
/**
* Evalúa una regla concreta contra el estado actual.
*/
evaluateRule(
rule : ValidationRule,
state: SelectionMap
): RuleEvaluationResult {
try {
const data = { attributes: normalizeKeys(state) };
const triggered = Boolean(this.evaluator.evaluate(rule.condition, data));
return triggered
? { rule, triggered: true, action: rule.action }
: { rule, triggered: false };
} catch (error) {
this.logr.error(
ENGINE_CATEGORIES.RULE_ENGINE,
RULE_ENGINE_ERRORS.RULE_EVALUATION_FAILED(rule.id, String(error)),
{ ruleId: rule.id }
);
return { rule, triggered: false };
}
}
// -------------------------------------------------------------------------
// Resultados por atributo
// -------------------------------------------------------------------------
/**
* Consolida los resultados de todas las reglas por atributo.
* Para cada atributo devuelve: valores permitidos, prohibidos,
* si es requerido y las violaciones activas.
*/
getAttributeResults(
rules: ValidationRule[],
state: SelectionMap
): Map<AttrID, AttributeRuleResult> {
const results = new Map<AttrID, AttributeRuleResult>();
const evaluated = this.evaluateAll(rules, state);
for (const { triggered, action, rule } of evaluated) {
if (!triggered || !action) continue;
const attrId = action.targetAttr;
if (!results.has(attrId)) {
results.set(attrId, {
attrId,
allowedValues : undefined,
forbiddenValues: [],
required : false,
violations : []
});
}
const result = results.get(attrId)!;
switch (action.type) {
case 'allow':
// Intersección si ya hay lista blanca — la restricción se acumula
result.allowedValues = result.allowedValues
? result.allowedValues.filter(v => action.values.includes(v))
: (action.values as OptionID[]);
break;
case 'forbid':
result.forbiddenValues = [
...(result.forbiddenValues ?? []),
...(action.values as OptionID[])
];
// Violación si el valor actual está prohibido
if (action.values.includes(state[attrId])) {
result.violations.push({ rule, severity: rule.severity });
}
break;
case 'require':
result.required = true;
// Violación si no hay valor seleccionado
if (!state[attrId]) {
result.violations.push({ rule, severity: rule.severity });
}
break;
case 'suggest':
// Informativo — no genera violaciones
break;
}
}
return results;
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
/**
* Verifica si un valor concreto está permitido para un atributo
* dado el estado de selección actual.
*/
isValueAllowed(
attrId: AttrID,
value : Value,
rules : ValidationRule[],
state : SelectionMap
): boolean {
const result = this.getAttributeResults(rules, state).get(attrId);
if (!result) return true;
if (result.forbiddenValues?.includes(value as OptionID)) return false;
if (result.allowedValues && !result.allowedValues.includes(value as OptionID)) return false;
return true;
}
/**
* Devuelve los valores permitidos para un atributo dado el estado actual.
* Si no hay restricciones activas devuelve undefined — cualquier valor es válido.
*/
getAllowedValues(
attrId: AttrID,
rules : ValidationRule[],
state : SelectionMap
): OptionID[] | undefined {
return this.getAttributeResults(rules, state).get(attrId)?.allowedValues;
}
/**
* Verifica si un atributo es requerido dado el estado actual.
*/
isRequired(
attrId: AttrID,
rules : ValidationRule[],
state : SelectionMap
): boolean {
return this.getAttributeResults(rules, state).get(attrId)?.required ?? false;
}
/**
* Devuelve todas las violaciones activas ordenadas por severidad.
* error → warning → info
*/
getViolations(
rules: ValidationRule[],
state: SelectionMap
): RuleViolation[] {
const violations: RuleViolation[] = [];
for (const result of this.getAttributeResults(rules, state).values()) {
violations.push(...result.violations);
}
return violations.sort((a, b) =>
severityOrder(a.severity) - severityOrder(b.severity)
);
}
/**
* Verifica si el estado actual es válido.
* Un estado es válido si no hay violaciones de severidad 'error'.
*/
isValid(
rules: ValidationRule[],
state: SelectionMap
): boolean {
return !this.getViolations(rules, state).some(v => v.severity === 'error');
}
}
// ============================================================================
// HELPERS
// ============================================================================
/**
* Normaliza las claves del estado para JsonLogic.
* 'at:calidad' → 'at_calidad' (los ':' no son válidos como nombres de variable)
*/
function normalizeKeys(state: SelectionMap): Record<string, Value> {
const normalized: Record<string, Value> = {};
for (const [key, value] of Object.entries(state)) {
normalized[key.replace(':', '_')] = value;
}
return normalized;
}
function severityOrder(severity: Severity): number {
switch (severity) {
case 'error' : return 0;
case 'warning': return 1;
case 'info' : return 2;
default : return 3;
}
}
// ============================================================================
// SINGLETON
// ============================================================================
// El singleton se crea en el wiring del engine, no aquí.
// Ejemplo: export const ruleEngine = new RuleEngine(logr);

@ -0,0 +1,290 @@
/**
* ============================================================================
* TEMPLATE RESOLVER
* ============================================================================
*
* Resuelve templates de imagen a URLs completas combinando:
* - basePath heredado en cascada (vista → sección → objeto → catálogo)
* - template con placeholders {at:<code>} resueltos al code de la opción activa
*/
import type { Logr } from '@/libs/logr';
import {
type ConfigurationCatalog,
type ConfigurableObject,
type VisualSection,
type SectionView,
type StaticImageConfig,
type ObjectID,
type SectionID,
type ViewID, isOptionID, type OptionID
} from '../types';
import {
ENGINE_CATEGORIES,
TEMPLATE_RESOLVER_ERRORS,
TEMPLATE_RESOLVER_WARNINGS
} from '../consts/messages';
import type { SelectionMap } from './rule.engine';
// ============================================================================
// TYPES
// ============================================================================
/**
* Contexto necesario para resolver un template.
* Identifica el objeto, sección y vista activos.
*/
export interface ResolveContext {
objectId : ObjectID;
sectionId: SectionID;
viewId : ViewID;
}
/**
* Resultado de la resolución de un template.
*/
export interface ResolveResult {
/** URL completa resuelta — null si se usó fallback */
url : string;
/** Si se usó el fallbackImage en lugar de la URL generada */
isFallback : boolean;
}
// ============================================================================
// TEMPLATE RESOLVER
// ============================================================================
/** Regex para extraer placeholders del tipo {at:code} */
const PLACEHOLDER_RE = /\{at:([^}]+)\}/g;
export class TemplateResolver {
private readonly logr: Logr;
constructor(logr: Logr) {
this.logr = logr;
}
/**
* Resuelve un template de imagen a una URL completa.
*
* Proceso:
* 1. Resuelve el basePath en cascada (vista → sección → objeto → catálogo)
* 2. Extrae los placeholders {at:<code>} del template
* 3. Resuelve cada placeholder al code de la opción activa en el catálogo
* 4. Concatena basePath + template resuelto
*
* Si algún placeholder no se puede resolver, usa fallbackImage.
*/
resolve(
context : ResolveContext,
state : SelectionMap,
catalog : ConfigurationCatalog
): ResolveResult {
const object = catalog.objects[context.objectId];
const section = object?.sections[context.sectionId];
if (!object || !section) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context });
return this.fallback(catalog, object, section as VisualSection | undefined);
}
if (section.kind !== 'visual') {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context });
return this.fallback(catalog, object, undefined);
}
const view = section.views[context.viewId];
if (!view) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context });
return this.fallback(catalog, object, section);
}
if (view.visualConfig.strategy !== 'static_image') {
this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy), { context });
return this.fallback(catalog, object, section);
}
return this.resolveStaticImage(
view.visualConfig,
view,
section,
object,
catalog,
state,
context
);
}
// -------------------------------------------------------------------------
// Resolución static_image
// -------------------------------------------------------------------------
private resolveStaticImage(
config : StaticImageConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog,
state : SelectionMap,
context : ResolveContext
): ResolveResult {
const basePath = this.resolveBasePath(config, view, section, object, catalog);
const resolved = this.resolveTemplate(config.template, state, catalog, context);
if (resolved === null) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template),
{ context }
);
return this.fallback(catalog, object, section);
}
return { url: `${basePath}${resolved}`, isFallback: false };
}
// -------------------------------------------------------------------------
// Resolución de template
// -------------------------------------------------------------------------
/**
* Sustituye cada {at:<code>} por el code de la opción activa.
* Devuelve null si algún placeholder no se puede resolver.
*/
private resolveTemplate(
template: string,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
let result = template;
let success = true;
result = result.replace(PLACEHOLDER_RE, (_, attrCode: string) => {
const resolved = this.resolvePlaceholder(attrCode, state, catalog, context);
if (resolved === null) {
success = false;
return '';
}
return resolved;
});
return success ? result : null;
}
/**
* Resuelve un placeholder {at:<attrCode>} al code de la opción seleccionada.
*
* Busca el atributo por code en:
* 1. Atributos de la sección
* 2. Atributos globales del objeto
*
* Luego resuelve el OptionID seleccionado al code de la OptionDefinition.
*/
private resolvePlaceholder(
attrCode: string,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
const object = catalog.objects[context.objectId];
const section = object?.sections[context.sectionId];
// Buscar atributo por code — primero en sección, luego en objeto
const sectionAttrs = section?.kind === 'visual' ? section.attributes : [];
const allAttrs = [...sectionAttrs, ...object.attributes];
const attr = allAttrs.find(a => a.code === attrCode);
if (!attr) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode),
{ context, attrCode }
);
return null;
}
// Obtener el valor seleccionado del estado
const selectedValue = state[attr.id];
if (!selectedValue) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id),
{ context, attrId: attr.id }
);
return null;
}
// Resolver OptionID → code de la OptionDefinition
if (!isOptionID(selectedValue)) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)),
{ context, optionId: String(selectedValue) }
);
return null;
}
const optionDef = catalog.options[selectedValue];
return optionDef.code;
}
// -------------------------------------------------------------------------
// basePath en cascada
// -------------------------------------------------------------------------
/**
* Resuelve el basePath en cascada:
* vista → sección → objeto → catálogo
*/
private resolveBasePath(
config : StaticImageConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog
): string {
return config.basePath
?? section.basePath
?? object.basePath
?? catalog.basePath
?? '';
}
// -------------------------------------------------------------------------
// Fallback
// -------------------------------------------------------------------------
/**
* Devuelve la imagen de fallback.
* Busca en cascada entre las views de la sección, y si no hay ninguna
* configurada logea un warning y devuelve string vacío.
*/
private fallback(
catalog : ConfigurationCatalog,
object? : ConfigurableObject,
section?: VisualSection,
): ResolveResult {
// Busca fallbackImage en cualquiera de las vistas de la sección
const viewFallback = section
? Object.values(section.views)
.map(v => (v.visualConfig as StaticImageConfig).fallbackImage)
.find(Boolean)
: undefined;
const fallbackImage = viewFallback ?? '';
if (!fallbackImage) {
this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id), {
objectId : object?.id,
sectionId: section?.id,
});
}
return { url: fallbackImage, isFallback: true };
}
}

@ -0,0 +1,391 @@
/**
* ============================================================================
* JSON LOGIC EVALUATOR — TESTS
* ============================================================================
*/
import type { Logr } from '@/libs/logr';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JsonLogicEvaluator } from '../engines/evaluator.engine';
// ============================================================================
// SETUP
// ============================================================================
function makeLogr(): Logr {
return {
debug : vi.fn(),
info : vi.fn(),
warn : vi.fn(),
error : vi.fn(),
getLogs : vi.fn(),
clear : vi.fn(),
serialize: vi.fn(),
setLevel : vi.fn(),
setMaxLogs: vi.fn(),
} as unknown as Logr;
}
let logr : Logr;
let evaluator: JsonLogicEvaluator;
beforeEach(() => {
logr = makeLogr();
evaluator = new JsonLogicEvaluator(logr);
});
// ============================================================================
// PRIMITIVOS
// ============================================================================
describe('primitivos', () => {
it('devuelve null para null', () => {
expect(evaluator.evaluate(null, {})).toBeNull();
});
it('devuelve boolean true', () => {
expect(evaluator.evaluate(true, {})).toBe(true);
});
it('devuelve boolean false', () => {
expect(evaluator.evaluate(false, {})).toBe(false);
});
it('devuelve número', () => {
expect(evaluator.evaluate(42, {})).toBe(42);
});
it('devuelve string', () => {
expect(evaluator.evaluate('hola', {})).toBe('hola');
});
it('evalúa array de primitivos', () => {
expect(evaluator.evaluate([1, 2, 3] as any, {})).toEqual([1, 2, 3]);
});
});
// ============================================================================
// VAR — acceso a datos
// ============================================================================
describe('var', () => {
it('accede a una propiedad de primer nivel', () => {
expect(evaluator.evaluate({ var: 'nombre' }, { nombre: 'Ana' })).toBe('Ana');
});
it('accede a una propiedad anidada con dot notation', () => {
const data = { attributes: { at_calidad: 'op:premium' } };
expect(evaluator.evaluate({ var: 'attributes.at_calidad' }, data)).toBe('op:premium');
});
it('devuelve undefined para clave inexistente', () => {
expect(evaluator.evaluate({ var: 'no_existe' }, {})).toBeUndefined();
});
it('devuelve undefined para ruta anidada inexistente', () => {
expect(evaluator.evaluate({ var: 'a.b.c' }, { a: {} })).toBeUndefined();
});
it('devuelve undefined si el nodo intermedio es null', () => {
expect(evaluator.evaluate({ var: 'a.b' }, { a: null })).toBeUndefined();
});
});
// ============================================================================
// IGUALDAD
// ============================================================================
describe('igualdad', () => {
it('== devuelve true si los valores son iguales', () => {
expect(evaluator.evaluate({ '==': [1, 1] }, {})).toBe(true);
});
it('== devuelve false si los valores son distintos', () => {
expect(evaluator.evaluate({ '==': [1, 2] }, {})).toBe(false);
});
it('=== funciona igual que ==', () => {
expect(evaluator.evaluate({ '===': ['a', 'a'] }, {})).toBe(true);
});
it('!= devuelve true si los valores son distintos', () => {
expect(evaluator.evaluate({ '!=': [1, 2] }, {})).toBe(true);
});
it('!= devuelve false si los valores son iguales', () => {
expect(evaluator.evaluate({ '!=': [1, 1] }, {})).toBe(false);
});
it('!== funciona igual que !=', () => {
expect(evaluator.evaluate({ '!==': ['a', 'b'] }, {})).toBe(true);
});
it('! niega un valor truthy', () => {
expect(evaluator.evaluate({ '!': true }, {})).toBe(false);
});
it('! niega un valor falsy', () => {
expect(evaluator.evaluate({ '!': false }, {})).toBe(true);
});
it('compara variable con valor literal', () => {
const data = { attributes: { at_calidad: 'op:calidad_estandar' } };
expect(evaluator.evaluate(
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] },
data
)).toBe(true);
});
});
// ============================================================================
// LÓGICOS
// ============================================================================
describe('lógicos', () => {
it('and devuelve true si todos son true', () => {
expect(evaluator.evaluate({ 'and': [true, true, true] }, {})).toBe(true);
});
it('and devuelve false si alguno es false', () => {
expect(evaluator.evaluate({ 'and': [true, false, true] }, {})).toBe(false);
});
it('or devuelve true si alguno es true', () => {
expect(evaluator.evaluate({ 'or': [false, true, false] }, {})).toBe(true);
});
it('or devuelve false si todos son false', () => {
expect(evaluator.evaluate({ 'or': [false, false] }, {})).toBe(false);
});
it('and con variables', () => {
const data = { a: true, b: true };
expect(evaluator.evaluate(
{ 'and': [{ var: 'a' }, { var: 'b' }] },
data
)).toBe(true);
});
it('or con variables — una false, otra true', () => {
const data = { a: false, b: true };
expect(evaluator.evaluate(
{ 'or': [{ var: 'a' }, { var: 'b' }] },
data
)).toBe(true);
});
});
// ============================================================================
// COMPARACIÓN NUMÉRICA
// ============================================================================
describe('comparación numérica', () => {
it('> devuelve true si mayor', () => {
expect(evaluator.evaluate({ '>': [5, 3] }, {})).toBe(true);
});
it('> devuelve false si igual', () => {
expect(evaluator.evaluate({ '>': [3, 3] }, {})).toBe(false);
});
it('>= devuelve true si igual', () => {
expect(evaluator.evaluate({ '>=': [3, 3] }, {})).toBe(true);
});
it('< devuelve true si menor', () => {
expect(evaluator.evaluate({ '<': [2, 5] }, {})).toBe(true);
});
it('<= devuelve true si igual', () => {
expect(evaluator.evaluate({ '<=': [5, 5] }, {})).toBe(true);
});
it('compara variable numérica', () => {
const data = { cantidad: 10 };
expect(evaluator.evaluate({ '>': [{ var: 'cantidad' }, 5] }, data)).toBe(true);
});
});
// ============================================================================
// CONDICIONAL — if
// ============================================================================
describe('if', () => {
it('devuelve rama then si condición es true', () => {
expect(evaluator.evaluate({ 'if': [true, 'si', 'no'] }, {})).toBe('si');
});
it('devuelve rama else si condición es false', () => {
expect(evaluator.evaluate({ 'if': [false, 'si', 'no'] }, {})).toBe('no');
});
it('devuelve null si no hay rama else y condición es false', () => {
expect(evaluator.evaluate({ 'if': [false, 'si'] }, {})).toBeNull();
});
it('evalúa condición con variable', () => {
const data = { attributes: { at_calidad: 'op:calidad_lujo' } };
expect(evaluator.evaluate(
{ 'if': [
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] },
'es lujo',
'no es lujo'
]},
data
)).toBe('es lujo');
});
});
// ============================================================================
// ARRAYS — in
// ============================================================================
describe('in', () => {
it('devuelve true si el elemento está en el array', () => {
expect(evaluator.evaluate({ 'in': ['b', ['a', 'b', 'c']] }, {})).toBe(true);
});
it('devuelve false si el elemento no está en el array', () => {
expect(evaluator.evaluate({ 'in': ['d', ['a', 'b', 'c']] }, {})).toBe(false);
});
it('funciona con variable como elemento', () => {
const data = { color: 'rojo' };
expect(evaluator.evaluate(
{ 'in': [{ var: 'color' }, ['rojo', 'azul']] },
data
)).toBe(true);
});
});
// ============================================================================
// MATEMÁTICOS
// ============================================================================
describe('matemáticos', () => {
it('+ suma múltiples valores', () => {
expect(evaluator.evaluate({ '+': [1, 2, 3] }, {})).toBe(6);
});
it('- resta dos valores', () => {
expect(evaluator.evaluate({ '-': [10, 3] }, {})).toBe(7);
});
it('- niega un valor único', () => {
expect(evaluator.evaluate({ '-': [5] }, {})).toBe(-5);
});
it('* multiplica', () => {
expect(evaluator.evaluate({ '*': [3, 4] }, {})).toBe(12);
});
it('/ divide', () => {
expect(evaluator.evaluate({ '/': [10, 2] }, {})).toBe(5);
});
it('/ devuelve null en división por cero y logea warning', () => {
expect(evaluator.evaluate({ '/': [10, 0] }, {})).toBeNull();
expect(logr.warn).toHaveBeenCalledTimes(1);
});
it('% calcula módulo', () => {
expect(evaluator.evaluate({ '%': [10, 3] }, {})).toBe(1);
});
it('calcula precio dinámico con descuento', () => {
const data = { attributes: { at_calidad: 'op:calidad_lujo' } };
const expr = {
'if': [
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] },
{ '*': [45, 0.9] },
45
]
};
expect(evaluator.evaluate(expr, data)).toBeCloseTo(40.5);
});
});
// ============================================================================
// OPERADOR DESCONOCIDO
// ============================================================================
describe('operador desconocido', () => {
it('devuelve null y logea warning', () => {
const result = evaluator.evaluate({ 'operadorFalso': [] } as any, {});
expect(result).toBeNull();
expect(logr.warn).toHaveBeenCalledTimes(1);
});
});
// ============================================================================
// EXPRESIONES COMPUESTAS — casos reales del catálogo de viviendas
// ============================================================================
describe('expresiones del catálogo de viviendas', () => {
it('condición FORBID — calidad estándar prohíbe mármol', () => {
const data = { attributes: { at_calidad: 'op:calidad_estandar' } };
expect(evaluator.evaluate(
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] },
data
)).toBe(true);
});
it('condición FORBID — calidad premium no activa la regla', () => {
const data = { attributes: { at_calidad: 'op:calidad_premium' } };
expect(evaluator.evaluate(
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] },
data
)).toBe(false);
});
it('condición ALLOW — microcemento en suelo activa restricción en baño', () => {
const data = { attributes: { at_suelo: 'op:suelo_microcemento' } };
expect(evaluator.evaluate(
{ '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_microcemento'] },
data
)).toBe(true);
});
it('condición con and — sanitario negro Y calidad estándar', () => {
const data = {
attributes: {
at_sanitario: 'op:sanit_negro',
at_calidad : 'op:calidad_estandar'
}
};
expect(evaluator.evaluate(
{ 'and': [
{ '==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro'] },
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }
]},
data
)).toBe(true);
});
it('condición con and — solo uno de los dos se cumple → false', () => {
const data = {
attributes: {
at_sanitario: 'op:sanit_blanco',
at_calidad : 'op:calidad_estandar'
}
};
expect(evaluator.evaluate(
{ 'and': [
{ '==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro'] },
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }
]},
data
)).toBe(false);
});
});

@ -0,0 +1,29 @@
/**
* ============================================================================
* API TYPES
* ============================================================================
*/
/**
* Método HTTP para llamadas a APIs externas de renderizado.
*/
export type APIMethod = 'GET' | 'POST';
/**
* Configuración de llamada a una API externa.
* Usada por la estrategia de renderizado `api_generated`.
*/
export interface APIConfig {
/** URL del endpoint */
endpoint: string;
/** Método HTTP — GET por defecto */
method? : APIMethod;
/**
* Parámetros a incluir en la llamada.
* El engine los resuelve desde el estado de configuración actual.
*/
params? : string[];
/** Timeout en milisegundos */
timeout?: number;
}

@ -0,0 +1,152 @@
/**
* ============================================================================
* ATTRIBUTE TYPES
* ============================================================================
*/
import type { Code, Value, DataType } from './primitives.types';
import type { AttrID, OptionID, SectionID } from './ids.types';
import type { I18nString } from '@/libs/i18n';
import type { Metadata } from './datas.types';
import type { JsonLogic } from './json-logic.types';
// ============================================================================
// ATTRIBUTE DISPLAY
// ============================================================================
/**
* Configuración de presentación del atributo en UI.
* Los flags pueden ser estáticos o evaluados dinámicamente con JsonLogic.
*/
export interface AttributeDisplay {
/** Si se muestra en UI */
uiVisible? : boolean | JsonLogic;
/**
* Si el valor de este atributo afecta la imagen renderizada.
* Los atributos con affectsVisual: true son candidatos a aparecer
* como placeholders en los templates de vista.
*/
affectsVisual? : boolean | JsonLogic;
/** Si afecta al precio */
affectsPrice? : boolean | JsonLogic;
/** Si es de solo lectura */
readonly? : boolean;
/** Orden de visualización */
order? : number;
/** Icono */
icon? : string;
/** Texto de ayuda */
helpText? : I18nString;
}
// ============================================================================
// ATTRIBUTE CATEGORY
// ============================================================================
export type AttributeCategory =
| 'physical'
| 'aesthetic'
| 'functional'
| 'structural'
| 'technical'
| 'financial';
// ============================================================================
// BASE ATTRIBUTE
// ============================================================================
export interface BaseAttribute {
id : AttrID;
/**
* Identificador legible usado en placeholders de templates.
* Debe ser único dentro del scope (sección u objeto).
*
* @example 'suelo' | 'pintura' | 'carpinteria'
*/
code : Code;
name : I18nString;
description: I18nString;
category? : AttributeCategory;
display : AttributeDisplay;
metadata? : Metadata;
}
// ============================================================================
// FIXED ATTRIBUTE
// Valor fijo — no lo elige el usuario
// ============================================================================
export interface FixedAttribute extends BaseAttribute {
type : 'fixed';
dataType: DataType;
value : Value;
unit? : I18nString;
}
// ============================================================================
// DYNAMIC ATTRIBUTE
// El usuario elige entre opciones del catálogo
// ============================================================================
export interface DynamicAttribute extends BaseAttribute {
type : 'dynamic';
dataType : 'reference';
defaultValue : OptionID;
options : AttributeOption[];
required? : boolean;
/**
* Si este atributo controla la disponibilidad de secciones.
* Ejemplo: elegir 'con_terraza' activa la sección 'sc:terraza'
*/
controls? : SectionID[];
filterExpression?: JsonLogic;
}
export interface AttributeOption {
optionId : OptionID;
priority? : number;
pricingOverride?: number;
metadata? : Metadata;
}
// ============================================================================
// QUANTIFIABLE ATTRIBUTE
// Valor numérico con rango o conjunto de valores
// ============================================================================
export interface QuantifiableAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'number';
quantity : QuantityDefinition;
unit? : I18nString;
userConfigurable?: boolean;
pricePerUnit? : number;
}
export type QuantityDefinition =
| number
| { min: number; max: number; step?: number; default: number }
| { values: number[]; default: number };
// ============================================================================
// COMPUTED ATTRIBUTE
// Calculado a partir de otros atributos — nunca editable por el usuario
// ============================================================================
export interface ComputedAttribute extends BaseAttribute {
type : 'computed';
dataType : DataType;
unit? : I18nString;
expression : JsonLogic;
dependencies: AttrID[];
}
// ============================================================================
// UNION
// ============================================================================
export type Attribute =
| FixedAttribute
| DynamicAttribute
| QuantifiableAttribute
| ComputedAttribute;

@ -0,0 +1,56 @@
/**
* ============================================================================
* CONFIGURATION CATALOG TYPES
* ============================================================================
*/
import type { Code } from './primitives.types';
import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.types';
import type { I18nString } from '@/libs/i18n';
import type { OptionDefinition } from './option.types';
import type { ConfigurableObject } from './object.types';
import type { ValidationRule } from './rule.types';
/**
* Catálogo de configuración.
*
* Raíz del sistema — contiene todas las opciones, objetos y reglas.
*
* El `basePath` actúa como fallback final de la cadena de herencia:
* vista → sección → objeto → catálogo
*
* La gestión de locales se delega al sistema i18n — el catálogo
* no necesita saber qué locales están activos.
*/
export interface ConfigurationCatalog {
id : CatalogID;
code : Code;
name : I18nString;
description?: I18nString;
/**
* basePath raíz — fallback final para todas las vistas
* que no definan el suyo en ningún nivel superior.
*
* @example '/renders'
*/
basePath? : string;
/**
* Opciones disponibles en el catálogo.
* Referenciadas por los atributos dinámicos de todo el sistema.
*/
options : Record<OptionID, OptionDefinition>;
/**
* Objetos configurables del catálogo.
*/
objects : Record<ObjectID, ConfigurableObject>;
/**
* Reglas de validación globales (opcional).
* Se evalúan sobre el estado de configuración completo.
*/
rules? : Record<RuleID, ValidationRule>;
}

@ -0,0 +1,39 @@
/**
* ============================================================================
* MEDIA TYPES
* ============================================================================
*/
/**
* ============================================================================
* METADATA TYPES
* ============================================================================
*/
/**
* Metadata flexible para extender cualquier entidad del modelo.
* Permite añadir datos arbitrarios sin modificar los tipos core.
*/
export type Metadata = Record<string, unknown>;
/**
* ============================================================================
* MEDIA TYPES
* ============================================================================
*/
/**
* Recursos visuales asociados a una opción o entidad.
*/
export interface Media {
/** Imagen en miniatura para selectores y listas */
thumbnail?: string;
/** Imagen principal */
image? : string;
/** Icono pequeño */
icon? : string;
/** Galería de imágenes adicionales */
gallery? : string[];
}

@ -0,0 +1,67 @@
/**
* ============================================================================
* HOTSPOT TYPES
* ============================================================================
*/
import type { AttrID, HotspotID } from './ids.types';
import type { I18nString } from '@/libs/i18n';
import type { JsonLogic } from './json-logic.types';
/**
* Punto interactivo sobre una imagen de vista.
* Permite al usuario hacer clic en una zona de la imagen para
* seleccionar o modificar el atributo asociado directamente.
*
* Las coordenadas son porcentuales (0-100) para ser independientes
* de la resolución de la imagen.
*
* @example
* // Hotspot sobre el suelo del salón
* {
* id : 'hs:suelo_salon',
* name : { es: 'Suelo', en: 'Floor' },
* x : 50,
* y : 80,
* targetAttributeId: 'at:suelo',
* scopeType : 'section',
* }
*/
export interface Hotspot {
id : HotspotID;
name: I18nString;
/** Posición X en porcentaje sobre la imagen (0-100) */
x: number;
/** Posición Y en porcentaje sobre la imagen (0-100) */
y: number;
/** Radio del área interactiva en píxeles */
radius?: number;
/** Color visual del hotspot */
color?: string;
/** Icono del hotspot */
icon?: string;
/**
* Scope del atributo que controla este hotspot.
* - `global` : El atributo pertenece al objeto (`ConfigurableObject.attributes`)
* - `section` : El atributo pertenece a la sección que contiene esta vista
*/
scopeType: 'global' | 'section';
/** ID del atributo que este hotspot controla */
targetAttributeId: AttrID;
/** Visibilidad estática o condicional */
visible?: boolean | JsonLogic;
/** Tooltip al hacer hover */
tooltip?: I18nString;
/** Z-index para superposición de hotspots */
zIndex?: number;
}

@ -0,0 +1,29 @@
/**
* ============================================================================
* BRANDED ID TYPES
* ============================================================================
*/
import type { ID } from './primitives.types';
export type AttrID = ID<'at'>;
export type SectionID = ID<'sc'>;
export type OptionID = ID<'op'>;
export type ObjectID = ID<'ob'>;
export type RuleID = ID<'rl'>;
export type ViewID = ID<'vw'>;
export type HotspotID = ID<'hs'>;
export type CatalogID = ID<'ct'>;
// ============================================================================
// TYPE GUARDS
// ============================================================================
export function isOptionID(value: unknown): value is OptionID {
return typeof value === 'string' && value.startsWith('op:');
}
export function isAttrID(value: unknown): value is AttrID {
return typeof value === 'string' && value.startsWith('at:');
}

@ -0,0 +1,16 @@
export * from './primitives.types.ts';
export * from './ids.types.ts';
export * from './datas.types.ts';
export * from './api.types.ts';
export * from './attribute.types.ts';
export * from './hotspot.types.ts';
export * from './json-logic.types.ts';
export * from './option.types.ts';
export * from './pricing.types.ts';
export * from './rule.types.ts';
export * from './section.types.ts';
export * from './view.types.ts';
export * from './object.types.ts';
export * from './catalog.types.ts';

@ -0,0 +1,28 @@
/**
* ============================================================================
* JSON LOGIC TYPES
* ============================================================================
*/
/**
* Expresión JsonLogic para lógica condicional declarativa.
*
* Intencionalmente permisivo — la validación estructural profunda
* se delega al engine de json-logic en runtime.
*
* Las variables referencian atributos por su ID:
* @example
* { var: 'at:calidad' } // valor del atributo at:calidad
* { '==': [{ var: 'at:calidad' }, 'op:premium'] } // comparación
* { 'and': [...] } // operador lógico
* { 'if': [...] } // condicional
*/
export type JsonLogic =
| { var: string }
| { [op: string]: JsonLogic | JsonLogic[] | unknown }
| string
| number
| boolean
| null;

@ -0,0 +1,68 @@
/**
* ============================================================================
* CONFIGURABLE OBJECT TYPES
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { ObjectID, SectionID } from './ids.types';
import type { Metadata } from './datas.types';
import type { Attribute } from './attribute.types';
import type { Section } from './section.types';
// ============================================================================
// CONFIGURABLE OBJECT
// ============================================================================
/**
* Objeto configurable — el producto que el usuario está configurando.
*
* Ejemplos: vivienda, coche, mueble, laptop.
*
* El objeto actúa como contexto global:
* - Sus atributos aplican a todas las secciones
* - Su basePath es el fallback de todas las vistas que no definan el suyo
*/
export interface ConfigurableObject {
id : ObjectID;
code : Code;
name : I18nString;
description: I18nString;
/**
* Atributos globales del objeto.
* Aplican independientemente de la sección activa.
* Pueden aparecer como placeholders en cualquier vista.
*
* @example 'calidad de acabados', 'color principal'
*/
attributes : Attribute[];
/**
* Secciones del objeto.
*
* @example { 'sc:salon': ..., 'sc:bano': ..., 'sc:dormitorio': ... }
*/
sections : Record<SectionID, Section>;
/**
* Orden de presentación de secciones en UI.
* Si no se especifica, el orden es el de inserción en `sections`.
*/
sectionOrder?: SectionID[];
/**
* basePath de este objeto — heredado por las secciones y vistas
* que no definan el suyo propio.
*
* @example '/renders/vivienda'
*/
basePath? : string;
/** Categoría del objeto */
category? : string;
/** Metadata adicional */
metadata? : Metadata;
}

@ -0,0 +1,41 @@
/**
* ============================================================================
* OPTION DEFINITION TYPES
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { OptionID } from './ids.types';
import type { Media, Metadata } from './datas.types';
import type { Pricing } from './pricing.types';
/**
* Opción seleccionable para un atributo dinámico.
*
* El campo `code` es el valor que se inyecta en los templates de imagen
* cuando este atributo tiene esta opción seleccionada.
*
* @example
* // Template: '{at:suelo}_{at:pintura}.png'
* // Con suelo = op:madera → code 'madera'
* // Con pintura = op:blanco → code 'blanco'
* // Resultado: 'madera_blanco.png'
*/
export interface OptionDefinition {
id : OptionID;
/**
* Identificador legible usado en templates de imagen.
* Sin prefijos, sin caracteres especiales.
*
* @example 'madera' | 'blanco' | 'roble'
*/
code : Code;
name : I18nString;
description: I18nString;
media? : Media;
pricing? : Pricing;
tags? : string[];
metadata? : Metadata;
}

@ -0,0 +1,84 @@
/**
* ============================================================================
* PRICING TYPES
* ============================================================================
*/
import type { JsonLogic } from './json-logic.types';
import type { I18nString } from '@/libs/i18n';
/**
* Información fiscal del precio.
* Diseñado para ser multinacional — el porcentaje es libre
* y la etiqueta permite nombrar el impuesto según el país.
*
* @example
* // España
* { included: true, rate: 0.21, label: { es: 'IVA', en: 'VAT' } }
* // Alemania
* { included: false, rate: 0.19, label: { de: 'MwSt', en: 'VAT' } }
* // Francia
* { included: true, rate: 0.20, label: { fr: 'TVA', en: 'VAT' } }
* // Tipo reducido
* { included: true, rate: 0.10, label: { es: 'IVA reducido', en: 'Reduced VAT' } }
*/
export interface TaxInfo {
/** Si el impuesto ya está incluido en `baseAmount` */
included: boolean;
/**
* Porcentaje del impuesto expresado como decimal.
* @example 0.21 → 21% | 0.10 → 10% | 0.04 → 4%
*/
rate : number;
/**
* Nombre del impuesto según el país.
* Permite mostrar 'IVA', 'VAT', 'TVA', 'MwSt'... en la UI.
*/
label? : I18nString;
}
/**
* Información de precio de una opción.
*
* El precio puede ser:
* - Un valor fijo (`baseAmount: number`)
* - Bajo consulta (`baseAmount: 'consultation'`)
* - Calculado dinámicamente (`dynamicExpression`) — sobreescribe `baseAmount`
*
* @example
* // Precio fijo
* { baseAmount: 45, currency: 'EUR' }
*
* // Precio dinámico con descuento por calidad
* {
* baseAmount: 45,
* currency: 'EUR',
* dynamicExpression: {
* 'if': [
* { '==': [{ var: 'at:calidad' }, 'op:lujo'] },
* { '*': [45, 0.9] },
* 45
* ]
* }
* }
*/
export interface Pricing {
/**
* Precio base.
* - `number` : Precio fijo en la moneda indicada
* - `'consultation'`: Precio bajo consulta — no se muestra valor
*/
baseAmount : number | 'consultation';
/** Código ISO 4217 de la moneda */
currency? : string;
/**
* Información fiscal.
* Opcional — no aplica si baseAmount es 'consultation'.
*/
tax? : TaxInfo;
/**
* Expresión JsonLogic para precio dinámico.
* Si está presente, sobreescribe `baseAmount` en runtime.
*/
dynamicExpression?: JsonLogic;
}

@ -0,0 +1,50 @@
/**
* ============================================================================
* CORE PRIMITIVES - Base Types
* ============================================================================
*/
/**
* ID con tipo branded para type-safety.
* El prefijo actúa de namespace y evita asignar IDs de distinto tipo por error.
*
* @example
* const attrId: ID<'at'> = 'at:suelo'
* const sectionId: ID<'sc'> = 'sc:salon'
*/
export type ID<T extends string = string> = `${T}:${string}`;
/**
* Code legible para usar en templates de imagen.
* Sin prefijos, sin caracteres especiales — solo el identificador
* que el equipo de negocio quiere ver en las URLs.
*
* @example
* 'madera' | 'blanco' | 'salon' | 'frontal'
*/
export type Code = string;
/**
* Valores que pueden tomar los atributos.
*/
export type Value =
| string
| number
| boolean
| Date
| ID
| Value[];
/**
* Tipos de datos soportados.
*/
export type DataType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'currency'
| 'dimension'
| 'reference'
| 'list';

@ -0,0 +1,137 @@
/**
* ============================================================================
* VALIDATION RULE TYPES
* ============================================================================
*/
import type { AttrID, RuleID, Value } from './index';
import type { I18nString } from '@/libs/i18n';
import type { JsonLogic } from './json-logic.types';
/**
* ============================================================================
* SEVERITY TYPES
* ============================================================================
*/
/**
* Severidad de una violación de regla.
*
* - `error` : Bloquea la configuración — no se puede confirmar
* - `warning`: Permite continuar pero informa al usuario
* - `info` : Puramente informativo — usado con reglas `suggest`
*/
export type Severity =
| 'error'
| 'warning'
| 'info';
// ============================================================================
// ACTION TYPE
// ============================================================================
/**
* Tipo de acción que aplica la regla sobre el atributo objetivo.
*
* - `allow` → Lista blanca: solo estos valores son válidos
* - `forbid` → Lista negra: estos valores están prohibidos
* - `require` → El atributo debe tener algún valor (values: [])
* - `suggest` → Recomienda valores sin forzar — severity siempre 'info'
*/
export type ValidationRuleActionType =
| 'allow'
| 'forbid'
| 'require'
| 'suggest';
// ============================================================================
// ACTION
// ============================================================================
export interface ValidationRuleAction {
/** Qué hace la regla cuando su condición se cumple */
type : ValidationRuleActionType;
/** Atributo sobre el que actúa */
targetAttr: AttrID;
/**
* Valores afectados por la acción.
* - `allow` / `forbid` / `suggest`: lista de OptionIDs
* - `require`: array vacío [] — no importa el valor, pero debe existir
*/
values : Value[];
}
// ============================================================================
// VALIDATION RULE
// ============================================================================
/**
* Regla de validación declarativa.
*
* Estructura: SI `condition` → ejecuta `action` sobre `action.targetAttr`
*
* Las reglas se evalúan en orden de prioridad descendente.
* Para relaciones bidireccionales se crean dos reglas independientes
* — una por cada dirección.
*
* @example
* // SI calidad === estándar → FORBID mármol en baño
* {
* id : 'rl:estandar_no_marmol',
* condition: { '==': [{ var: 'at:calidad' }, 'op:estandar'] },
* action : { type: 'forbid', targetAttr: 'at:revest_bano', values: ['op:marmol'] },
* affects : ['at:calidad', 'at:revest_bano'],
* priority : 9,
* severity : 'error',
* message : { es: 'El mármol no está disponible en calidad estándar' }
* }
*/
export interface ValidationRule {
id : RuleID;
name : I18nString;
/**
* Condición JsonLogic que activa la regla.
* Las variables referencian atributos por su ID: `{ var: 'at:calidad' }`
*/
condition: JsonLogic;
/** Acción a ejecutar cuando la condición se cumple */
action : ValidationRuleAction;
/**
* Prioridad de evaluación — mayor número = mayor prioridad.
*
* Rangos recomendados:
* - 10-15: Incompatibilidades técnicas críticas
* - 5-9 : Restricciones de negocio importantes
* - 1-4 : Sugerencias y recomendaciones estéticas
*/
priority : number;
/**
* Atributos involucrados en esta regla.
* Incluye los atributos de la condición Y el targetAttr de la acción.
*
* Usado por el RuleEngine para indexar reglas por atributo —
* permite evaluar solo las reglas relevantes para un atributo
* sin recorrer todas las condiciones JsonLogic.
*
* @example ['at:calidad', 'at:revest_bano']
*/
affects : AttrID[];
/**
* Severidad de la violación cuando la regla se incumple.
* - `error` : Bloquea la configuración
* - `warning`: Permite continuar pero avisa
* - `info` : Informativo — usado con `suggest`
*/
severity : Severity;
/** Mensaje que se muestra al usuario cuando la regla se viola */
message : I18nString;
}

@ -0,0 +1,138 @@
/**
* ============================================================================
* SECTION TYPES
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { AttrID, SectionID, ViewID } from './ids.types';
import type { Metadata } from './datas.types';
import type { Attribute } from './attribute.types';
import type { JsonLogic } from './json-logic.types';
import type { ViewType, ViewVisualConfig } from './view.types';
import type { Hotspot } from './hotspot.types';
// ============================================================================
// SECTION VIEW
// ============================================================================
/**
* Vista visual de una sección.
* Cada vista tiene su propia `ViewVisualConfig` — la estrategia de renderizado
* y el template son propios de cada vista porque `frontal` y `360` del mismo
* salón tienen configuraciones completamente distintas.
*/
export interface SectionView {
id : ViewID;
code : Code;
name : I18nString;
description?: I18nString;
type : ViewType;
/**
* Configuración visual de esta vista — union discriminada por estrategia.
* El basePath hereda en cascada (vista → sección → objeto → catálogo)
* si no se especifica aquí.
*/
visualConfig: ViewVisualConfig;
/** Hotspots interactivos sobre la imagen */
hotspots? : Hotspot[];
/** Orden en el selector de vistas */
order? : number;
/** Icono para el selector de vistas */
icon? : string;
/** Visibilidad estática o condicional */
visible? : boolean | JsonLogic;
}
// ============================================================================
// SECTION AVAILABILITY
// ============================================================================
/**
* Disponibilidad y comportamiento de activación/desactivación de la sección.
*/
export interface SectionAvailability {
mode : 'required' | 'optional' | 'conditional';
/** Condición JsonLogic — solo si mode es 'conditional' */
condition?: JsonLogic;
/** Atributos de los que depende esta sección */
dependsOn?: AttrID[];
/** Qué ocurre con los valores cuando se desactiva la sección */
onDeactivate?: {
action : 'clear' | 'preserve' | 'reset';
confirmWithUser?: boolean;
confirmMessage? : I18nString;
};
}
// ============================================================================
// SECTION — union discriminada por presencia de vistas
//
// La invariante "si hay views debe haber defaultView" se expresa en tipos,
// no solo en comentarios. TypeScript la valida en compilación.
// ============================================================================
/**
* Sección con visualización.
* DEBE tener al menos una vista y una vista por defecto.
* Genera y muestra imágenes propias según la vista activa.
*/
export interface VisualSection {
kind : 'visual';
id : SectionID;
code : Code;
name : I18nString;
description : I18nString;
attributes : Attribute[];
availability: SectionAvailability;
/** Vistas disponibles — al menos una */
views : Record<ViewID, SectionView>;
/**
* Vista activa por defecto.
* DEBE existir como clave en `views`.
*/
defaultView : ViewID;
/** basePath de esta sección — heredado por las vistas si no lo definen */
basePath? : string;
order? : number;
icon? : string;
metadata? : Metadata;
}
/**
* Sección sin visualización.
* Solo configura opciones — no genera ni muestra imágenes.
* Ejemplos: garantía, seguros, servicios adicionales.
*/
export interface ConfigSection {
kind : 'config';
id : SectionID;
code : Code;
name : I18nString;
description : I18nString;
attributes : Attribute[];
availability: SectionAvailability;
order? : number;
icon? : string;
metadata? : Metadata;
}
/**
* Union discriminada de secciones.
* Usa `section.kind` para distinguir en runtime.
*
* @example
* if (section.kind === 'visual') {
* // section.views y section.defaultView están disponibles
* }
*/
export type Section = VisualSection | ConfigSection;

@ -0,0 +1,143 @@
/**
* ============================================================================
* VIEW TYPES
* ============================================================================
*/
import type { AttrID } from './ids.types';
import type { APIConfig } from './api.types';
// ============================================================================
// VIEW TYPE
// ============================================================================
export type ViewType =
| 'front' // Vista frontal
| 'side' // Vista lateral
| 'rear' // Vista posterior
| 'top' // Vista desde arriba
| 'isometric' // Vista isométrica
| 'panoramic' // Vista 360°
| 'detail' // Vista de detalle/zoom
| 'blueprint' // Plano técnico
| 'custom'; // Tipo personalizado
// ============================================================================
// VIEW VISUAL CONFIG — union discriminada por estrategia
// ============================================================================
/**
* Configuración visual de una vista.
* Cada estrategia tiene exactamente los campos que necesita.
*
* El `basePath` en cada estrategia es un override local.
* Si no se especifica, el sistema hereda en cascada:
* vista → sección → objeto → catálogo
*/
export type ViewVisualConfig =
| StaticImageConfig
| CompositeLayersConfig
| ApiGeneratedConfig
| ThreeDConfig;
// ============================================================================
// STATIC IMAGE
// La URL se construye combinando basePath + template resuelto.
//
// Template placeholders:
// {at:<attrCode>} → code de la opción seleccionada para ese atributo
//
// @example
// basePath: '/renders/vivienda/salon'
// template: '{at:suelo}_{at:pintura}.png'
// → '/renders/vivienda/salon/madera_blanco.png'
// ============================================================================
export interface StaticImageConfig {
strategy: 'static_image';
/**
* Template de nombre de fichero.
* Solo contiene placeholders de atributos visualizables.
* El sistema resuelve cada {at:<code>} al code de la opción activa.
*
* @example '{at:suelo}_{at:pintura}.png'
*/
template : string;
/** Override local de basePath. Hereda del catálogo si no se especifica. */
basePath? : string;
/** Imagen de fallback si la URL generada no existe */
fallbackImage?: string;
}
// ============================================================================
// COMPOSITE LAYERS
// La imagen se compone superponiendo capas PNG.
// Cada capa se activa/desactiva según el valor del atributo asociado.
// ============================================================================
export interface CompositeLayersConfig {
strategy: 'composite_layers';
/** Capas en orden de renderizado (de abajo a arriba) */
layers : LayerDefinition[];
/** Override local de basePath */
basePath? : string;
/** Imagen de fallback */
fallbackImage?: string;
}
/**
* Definición de una capa de composición.
* El template funciona igual que en StaticImageConfig.
*/
export interface LayerDefinition {
/** Atributo que controla esta capa */
attrId : AttrID;
/**
* Template del fichero de capa.
* @example 'suelo_{at:suelo}.png'
*/
template: string;
/** Orden z-index de la capa */
zIndex? : number;
/** Si la capa puede estar ausente (atributo no seleccionado) */
optional?: boolean;
}
// ============================================================================
// API GENERATED
// La imagen se genera llamando a un servicio externo con el estado actual.
// ============================================================================
export interface ApiGeneratedConfig {
strategy : 'api_generated';
apiConfig : APIConfig;
/** Imagen de fallback si la API falla o tarda demasiado */
fallbackImage?: string;
}
// ============================================================================
// THREE D
// Modelo 3D en navegador con texturas/materiales dinámicos.
// ============================================================================
export interface ThreeDConfig {
strategy : 'three_d';
modelUrl : string;
/**
* Atributos que controlan texturas o materiales del modelo.
* scope indica si el atributo es global del objeto o de la sección.
*/
textureAttrs: Array<{
attrId: AttrID;
scope : 'global' | 'section';
}>;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.