{"_id":"@deorta-dev/nestjs-resource-core","_rev":"2-675b27f979298612a089cd17b36f7162","name":"@deorta-dev/nestjs-resource-core","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@deorta-dev/nestjs-resource-core","version":"1.0.0","author":{"name":"Manuel Deorta","email":"manuel@deorta.dev"},"license":"MIT","_id":"@deorta-dev/nestjs-resource-core@1.0.0","maintainers":[{"name":"manueldeortac","email":"manueldeortac@gmail.com"}],"homepage":"https://github.com/Deorta-Dev/nestjs-resource-core#readme","bugs":{"url":"https://github.com/Deorta-Dev/nestjs-resource-core/issues"},"dist":{"shasum":"aa896294246eb78dac415face6b4f975d651891f","tarball":"https://registry.npmjs.org/@deorta-dev/nestjs-resource-core/-/nestjs-resource-core-1.0.0.tgz","fileCount":72,"integrity":"sha512-Ef7kq4B/f8w9zf/gX0T7dVAeOY7KhpK9t1m3PIqfUgHB7wume8mIE0DV6ch3z36udC/WTO3AuEjQ0LBu+4CEVQ==","signatures":[{"sig":"MEUCIQDpSN2VogsI7qvnsRm2ISIzJP23q85yPeMk1EhVZ/a3hQIgcckfcQLhxamBPYGo815S/TUwHcUhnlKh290CtPoZwA4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":441506},"main":"dist/index.js","types":"dist/index.d.ts","gitHead":"1350e7329fb039c9b10b99b7b2dd502409b0eb66","scripts":{"build":"tsc","prepare":"npm run build","test:e2e":"jest --config ./test/jest-e2e.json"},"_npmUser":{"name":"manueldeortac","email":"manueldeortac@gmail.com"},"repository":{"url":"git+https://github.com/Deorta-Dev/nestjs-resource-core.git","type":"git"},"_npmVersion":"11.16.0","description":"Library for NestJS that standardizes resource management in an API: full CRUD, configurable prefix permissions, authentication-based scope/workspace, filtering, dynamic reports, gateways, and more.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"socket.io":"^4.8.3","socket.io-client":"^4.8.3","@nestjs/websockets":"^10.4.22","@nestjs/platform-socket.io":"^10.4.22"},"_hasShrinkwrap":false,"devDependencies":{"jest":"^30.4.2","rxjs":"^7.8.2","ts-jest":"^29.4.12","supertest":"^7.2.2","typescript":"^5.0.0","@types/jest":"^30.0.0","@types/node":"^20.0.0","@nestjs/core":"^10.4.22","@nestjs/common":"^10.4.22","@nestjs/testing":"^10.4.22","@types/supertest":"^7.2.1","reflect-metadata":"^0.2.2","@nestjs/platform-express":"^10.4.22"},"peerDependencies":{"@nestjs/core":"^10.0.0","@nestjs/common":"^10.0.0","reflect-metadata":"^0.2.0"},"_npmOperationalInternal":{"tmp":"tmp/nestjs-resource-core_1.0.0_1786951016962_0.8740468254110807","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@deorta-dev/nestjs-resource-core","version":"1.0.1","description":"Library for NestJS that standardizes resource management in an API: full CRUD, configurable prefix permissions, authentication-based scope/workspace, filtering, dynamic reports, gateways, and more.","author":{"name":"Manuel Deorta","email":"manuel@deorta.dev"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/Deorta-Dev/nestjs-resource-core.git"},"main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc","prepare":"npm run build","test:e2e":"jest --config ./test/jest-e2e.json"},"peerDependencies":{"@nestjs/common":"^10.0.0","@nestjs/core":"^10.0.0","reflect-metadata":"^0.2.0"},"devDependencies":{"@nestjs/common":"^10.4.22","@nestjs/core":"^10.4.22","@nestjs/platform-express":"^10.4.22","@nestjs/testing":"^10.4.22","@types/jest":"^30.0.0","@types/node":"^20.0.0","@types/supertest":"^7.2.1","jest":"^30.4.2","reflect-metadata":"^0.2.2","rxjs":"^7.8.2","supertest":"^7.2.2","ts-jest":"^29.4.12","typescript":"^5.0.0"},"dependencies":{"@nestjs/platform-socket.io":"^10.4.22","@nestjs/websockets":"^10.4.22","socket.io":"^4.8.3","socket.io-client":"^4.8.3"},"gitHead":"c09e1949923d16d932524a122ab0d09126038c70","_id":"@deorta-dev/nestjs-resource-core@1.0.1","bugs":{"url":"https://github.com/Deorta-Dev/nestjs-resource-core/issues"},"homepage":"https://github.com/Deorta-Dev/nestjs-resource-core#readme","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-CHm2AigK4EMsYbZZe6HhgcWMf4rLFm8peKfswlbHm/Vj9COuzRqY4ySfvvm6LxeufYaJNkTygIK8+tTn6/4iPg==","shasum":"227a7f04f6e30b3d92190a8fd2d68fe73f872d93","tarball":"https://registry.npmjs.org/@deorta-dev/nestjs-resource-core/-/nestjs-resource-core-1.0.1.tgz","fileCount":72,"unpackedSize":447227,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDcrLYts44G2Q8tCtJ0ffM2MoIF8EFT5VXz/iaSEmbigwIgZr2s6Q9DZcOAL4pL39wjoSf4VVIk8SJQLDHV0ND3m+c="}]},"_npmUser":{"name":"manueldeortac","email":"manueldeortac@gmail.com"},"directories":{},"maintainers":[{"name":"manueldeortac","email":"manueldeortac@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/nestjs-resource-core_1.0.1_1786978230009_0.9934406762539238"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-17T07:16:56.758Z","modified":"2026-08-17T14:50:30.339Z","1.0.0":"2026-08-17T07:16:57.127Z","1.0.1":"2026-08-17T14:50:30.163Z"},"bugs":{"url":"https://github.com/Deorta-Dev/nestjs-resource-core/issues"},"author":{"name":"Manuel Deorta","email":"manuel@deorta.dev"},"license":"MIT","homepage":"https://github.com/Deorta-Dev/nestjs-resource-core#readme","repository":{"type":"git","url":"git+https://github.com/Deorta-Dev/nestjs-resource-core.git"},"description":"Library for NestJS that standardizes resource management in an API: full CRUD, configurable prefix permissions, authentication-based scope/workspace, filtering, dynamic reports, gateways, and more.","maintainers":[{"name":"manueldeortac","email":"manueldeortac@gmail.com"}],"readme":"# @deorta-dev/nestjs-resource-core\r\n\r\nLibrería para NestJS que **estandariza el manejo de recursos** en una API: CRUD completo, permisos con prefijo configurable, scope/workspace por autenticación, filtrado, reportes dinámicos por métricas y dimensiones, choices, vistas proyectadas, gateways de tiempo real y documentación automática con Scalar.\r\n\r\nEs **independiente de la capa de datos y de la autenticación**: nunca se conecta a la base de datos ni define la estrategia de auth directamente. La autenticación vive en un **módulo propio** (`AuthResourceApiModule`) con estrategias intercambiables, y el acceso a datos se delega a un repositorio dedicado (p. ej. [`@deorta-dev/nestjs-repository-core`](https://github.com/Deorta-Dev/nestjs-repository-core)). La librería se concentra en **control y reglas de negocio**.\r\n\r\n## Filosofía\r\n\r\nHay dos piezas de configuración separadas:\r\n\r\n- **`AuthResourceApiModule.register()`** — configura la **estrategia de autenticación**: cómo se generan las sesiones, cómo se guardan (memoria/redis/bd), cómo se autentica un token y cómo se resuelven los permisos. Es **una sola instancia compartida** por todos los recursos, y soporta **múltiples estrategias** a la vez (jwt, api-key, oauth, custom).\r\n- **`ResourceApiModule.register()`** — configura **cada recurso**: entidad, DTOs de entrada/salida, endpoints, permisos, filtros, reportes, choices, vistas, gateway y workspace. **Usa la instancia de auth** que ya configuraste en el `AuthResourceApiModule`.\r\n\r\nTodo se construye sobre dos clases base configurables: **`CommonApiController`** (registra los endpoints habilitados) y **`CommonApiService`** (lógica de negocio estandarizada).\r\n\r\n### Observables primero\r\n\r\nLa librería está construida **sobre Observables (rxjs)**. Todo el flujo interno — y el contrato de sus interfaces — usa `Observable<T>` como primitiva asíncrona principal y **evita `Promise` siempre que es posible**. Esto permite componer pipelines reactivos (reportes, gateways, lookups, emisiones en tiempo real) con operadores de rxjs.\r\n\r\n- Los servicios y controladores devuelven `Observable<T>`.\r\n- Las interfaces (`SessionStore`, `RepositoryService`, `PermissionProvider`, `WorkspaceProvider`, `AuthResourceApiStrategyProvider`, hooks) se definen con `Observable`.\r\n- En las **fronteras externas** (Redis, base de datos, SDKs basados en Promise) el adaptador implementa la interfaz envolviendo con `from()`; nunca al revés.\r\n- Al exponer un endpoint HTTP, Nest se encarga de subscribirse al Observable y resolver la respuesta.\r\n\r\n## Instalación\r\n\r\n```bash\r\nnpm install @deorta-dev/nestjs-resource-core\r\n# o\r\nyarn add @deorta-dev/nestjs-resource-core\r\n```\r\n\r\nPeer dependencies:\r\n\r\n```bash\r\nnpm install @nestjs/common @nestjs/core reflect-metadata rxjs\r\n```\r\n\r\nPara documentación con Scalar:\r\n\r\n```bash\r\nnpm install @scalar/nestjs-api-reference\r\n```\r\n\r\n## Inicio rápido\r\n\r\n```typescript\r\n// auth.module.ts — una sola estrategia compartida por todos los recursos\r\nimport { AuthResourceApiModule } from '@deorta-dev/nestjs-resource-core';\r\nimport { SessionRepositoryModule } from './repositories/session';\r\n\r\n@Module({\r\n  imports: [\r\n    AuthResourceApiModule.register({\r\n      default: 'jwt',\r\n      strategies: {\r\n        jwt: {\r\n          type: 'jwt',\r\n          secret: process.env.JWT_SECRET,\r\n          expiresIn: '12h',\r\n          // Sesiones persistidas en BD: no se borran al reiniciar el servicio.\r\n          // Para desarrollo puedes usar { type: 'memory' }.\r\n          sessionStore: { type: 'database', repository: SessionRepositoryModule, ttl: 12 * 3600 },\r\n        },\r\n      },\r\n    }),\r\n  ],\r\n})\r\nexport class AuthResourceApiCoreModule {}\r\n```\r\n\r\n```typescript\r\n// mobile.resource.ts — el recurso NO configura auth; el AuthResourceApiModule\r\n// resuelve la estrategia automáticamente por request\r\nimport { ResourceApiModule } from '@deorta-dev/nestjs-resource-core';\r\nimport { MobileRepositoryModule, MobileRepositoryService } from './repositories/mobile';\r\nimport { MobileEntity } from './entities/mobile.entity';\r\nimport { CreateMobileDto, UpdateMobileDto, MobileResponseDto } from './dtos';\r\n\r\nexport const MobileResourceApiModule = ResourceApiModule.register({\r\n  name: 'mobile',\r\n  route: 'mobiles',\r\n\r\n  entity: MobileEntity,\r\n  repositoryService: MobileRepositoryService,\r\n  repositoryModule: MobileRepositoryModule,\r\n\r\n  // DTOs de entrada y salida (documentación + tipado)\r\n  dtos: {\r\n    input: {\r\n      create: CreateMobileDto,\r\n      update: UpdateMobileDto,\r\n      transfer: TransferMobileDto,\r\n    },\r\n    output: {\r\n      get: MobileResponseDto,\r\n      list: MobileListResponseDto,\r\n      create: MobileResponseDto,\r\n    },\r\n  },\r\n\r\n  endpoints: {\r\n    mode: 'inclusion',\r\n    include: ['create', 'list', 'get', 'update', 'delete', 'count', 'choice'],\r\n  },\r\n\r\n  permissions: { prefix: 'mobile' },\r\n});\r\n```\r\n\r\n```typescript\r\n// app.module.ts\r\n@Module({\r\n  imports: [AuthResourceApiCoreModule, MobileResourceApiModule],\r\n})\r\nexport class AppModule {}\r\n```\r\n\r\nCon esto obtienes `POST /mobiles`, `GET /mobiles`, `GET /mobiles/:id`, `PUT /mobiles/:id`, `DELETE /mobiles/:id`, `GET /mobiles/count` y `GET /mobiles/choice`, protegidos con la estrategia `jwt`, con permisos `mobile:create`, `mobile:list`, etc., y documentados en Scalar.\r\n\r\n---\r\n\r\n## Módulo de Autenticación (`AuthResourceApiModule`)\r\n\r\nEl `AuthResourceApiModule` es **único en la app** y centraliza toda la autenticación. Varios recursos comparten la misma instancia y las mismas sesiones. Soporta **varias estrategias simultáneas** y decide por request cuál usar (el recurso no elige).\r\n\r\n### `AuthResourceApiModule.register(config)`\r\n\r\n```typescript\r\nAuthResourceApiModule.register({\r\n  default: 'jwt',                   // estrategia usada por defecto (fallback)\r\n\r\n  // Selección de estrategia por request: decide cuál usar según la\r\n  // credencial presente. Si devuelve null/undefined, usa `default`.\r\n  resolver: (request) => {\r\n    if (request.headers['x-api-key']) return 'apiKey';\r\n    if (request.headers.authorization?.startsWith('Bearer ')) return 'jwt';\r\n    if (request.headers.authorization?.startsWith('OAuth ')) return 'oauth';\r\n    return null;                    // → usa `default`\r\n  },\r\n\r\n  // Recursos/endpoints públicos: no requieren autenticación.\r\n  // Se puede declarar por nombre de recurso (todos sus endpoints) o por ruta.\r\n  publicResources: [\r\n    { name: 'catalog' },            // recurso completo público\r\n    { route: 'GET /health' },       // ruta puntual pública\r\n    { route: 'POST /auth/login' },  // (ej.: login no requiere auth)\r\n  ],\r\n\r\n  strategies: {\r\n    // ----- JWT -----\r\n    jwt: {\r\n      type: 'jwt',\r\n      secret: process.env.JWT_SECRET,\r\n      expiresIn: '1h',\r\n      // Genera/valida el payload de la sesión\r\n      issuer: 'my-api',\r\n    },\r\n\r\n    // ----- API Key -----\r\n    apiKey: {\r\n      type: 'api-key',\r\n      header: 'x-api-key',          // header donde viaja la key\r\n      provider: MyApiKeyProvider,   // valida la key y devuelve el AuthResourceApiContext\r\n      // Sin sessionStore → STATELESS (key permanente, ver \"API keys permanentes\").\r\n      // Con sessionStore opcional → cache de resolución + lastUsedAt.\r\n    },\r\n\r\n    // ----- OAuth / OIDC -----\r\n    oauth: {\r\n      type: 'oauth',\r\n      provider: MyOAuthProvider,    // intercambia token OAuth por AuthResourceApiContext\r\n      sessionStore: { type: 'custom', provider: DatabaseSessionStore },\r\n    },\r\n\r\n    // ----- Estrategia custom -----\r\n    custom: {\r\n      type: 'custom',\r\n      provider: MyAuthResourceApiStrategyProvider, // implements AuthResourceApiStrategyProvider\r\n      sessionStore: { type: 'custom', provider: MySessionStore },\r\n    },\r\n  },\r\n\r\n  // Estrategia de permisos compartida (ver \"Permisos\")\r\n  permissionProvider: MyPermissionProvider,\r\n\r\n  // Workspaces (ver \"Workspaces\") — estrategia de la sesión, compartida por todos los recursos\r\n  workspace: {\r\n    enabled: true,\r\n    provider: MyWorkspaceProvider,   // implements WorkspaceProvider\r\n    routes: {\r\n      current: 'workspace/current',  // GET: workspace actual de la sesión\r\n      select: 'workspace/select',    // PUT: seleccionar workspace\r\n      list: 'workspace/list',        // GET: workspaces a los que tiene acceso\r\n    },\r\n  },\r\n})\r\n```\r\n\r\n### Qué expone el `AuthResourceApiModule`\r\n\r\n- `AuthResourceApiService` — servicio inyectable con `getAuthentication(token)`, `createSession()`, `destroySession()`, etc. Lo usan los guards de todos los recursos.\r\n- `WorkspaceResourceApiService` — servicio de workspaces (`getCurrent()`, `list()`, `select()`) que alimenta las rutas de workspace y expone el workspace actual de la sesión para que los recursos lo usen en su scope.\r\n- `AuthResourceApiGuard` — guard global que resuelve la estrategia por request (`resolver` + `default`) y verifica `publicResources`.\r\n- `@AuthResourceApi()` — decorador que inyecta el `AuthResourceApiContext` resuelto en el request.\r\n- `publicResources` — registro central de recursos/endpoints públicos, consultado por el guard.\r\n\r\n### Cómo se elige la estrategia\r\n\r\nLa selección vive **solo en el `AuthResourceApiModule`**; el `ResourceApiModule` no configura nada de auth. Un guard global (`AuthResourceApiGuard`) aplica el `resolver` a **todos** los endpoints:\r\n\r\n1. Si el endpoint está en `publicResources` → se sirve sin autenticación.\r\n2. Si el endpoint declara `@ResourceAction({ strategy: 'apiKey' })` → usa esa estrategia.\r\n3. Si el `resolver(request)` devuelve una estrategia → la usa.\r\n4. Si no → usa `default`.\r\n\r\n```typescript\r\n// Tipo del resolver (interfaz provista por la librería)\r\nexport type AuthResourceApiStrategyResolver = (\r\n  request: any,\r\n) => string | null | Observable<string | null>;\r\n\r\n// Guard global registrado por AuthResourceApiModule (no se configura por recurso)\r\nAuthResourceApiGuard.apply(resolver); // interno\r\n```\r\n\r\n> El `ResourceApiModule` **no recibe `authStrategy`**: no conoce ni elige estrategias. La decisión la toma `AuthResourceApiModule` por request.\r\n\r\n### Endpoints públicos y excepciones por endpoint\r\n\r\nLos casos especiales se declaran **en el endpoint**, no en el recurso:\r\n\r\n```typescript\r\n@Get('health')\r\n@ResourceAction({ public: true })      // público, sin auth\r\nstatus(@AuthResourceApi() auth) { ... }\r\n\r\n@Get('external/report')\r\n@ResourceAction({ strategy: 'apiKey' }) // excepción puntual: usa apiKey\r\nreport(@AuthResourceApi() auth) { ... }\r\n```\r\n\r\n> Un endpoint marcado `public: true` se sirve sin autenticación (sin `@AuthResourceApi()` resuelto). Un `strategy` concreto sobreescribe la decisión del `resolver`/`default` para ese endpoint.\r\n\r\n### Estrategia custom (`AuthResourceApiStrategyProvider`)\r\n\r\nLa librería solo define la interfaz; la implementación la aporta tu proyecto (o un paquete complementario).\r\n\r\n```typescript\r\nexport interface AuthResourceApiStrategyProvider<C = any> {\r\n  readonly type: string;\r\n\r\n  // Dado el token/credencial, devuelve el contexto de autenticación\r\n  authenticate(tokenOrCredential: string, request: any): Observable<AuthResourceApiContext<C>>;\r\n\r\n  // Opcional: si NO se provee, la estrategia es STATELESS (sin sesión).\r\n  // Las API keys permanentes no necesitan sesión: la key es la credencial.\r\n  sessionStore?: SessionStore;\r\n}\r\n```\r\n\r\n> `sessionStore` es opcional. **Sin `sessionStore` la estrategia es stateless**: no se crea ni se persiste sesión (ideal para API keys permanentes). Con `sessionStore` (p. ej. memoria con TTL corto) puedes **cachear** la resolución de la key y registrar `lastUsedAt`.\r\n\r\n### API keys permanentes (acceso sin login)\r\n\r\nPara que un usuario consuma la API **sin login, de forma permanente**, se usa la estrategia `api-key` con un provider que valida la key contra la colección de `apiKeys` del proyecto. La key **no es una sesión**: es una credencial estática con su propio ciclo de vida (revocación inmediata al borrarla).\r\n\r\n**1. Configura la estrategia y el resolver** (en `AuthResourceApiModule`):\r\n\r\n```typescript\r\nAuthResourceApiModule.register({\r\n  default: 'jwt',\r\n\r\n  resolver: (request) => {\r\n    if (request.headers['x-api-key']) return 'apiKey';            // → key permanente\r\n    if (request.headers.authorization?.startsWith('Bearer ')) return 'jwt';  // → sesión\r\n    return null;\r\n  },\r\n\r\n  strategies: {\r\n    jwt: {\r\n      type: 'jwt',\r\n      secret: process.env.JWT_SECRET,\r\n      sessionStore: { type: 'database', repository: SessionRepositoryModule, ttl: 12 * 3600 },\r\n    },\r\n    apiKey: {\r\n      type: 'api-key',\r\n      header: 'x-api-key',\r\n      provider: ApiKeyProvider,        // valida la key y construye el contexto\r\n      // sin sessionStore: STATELESS (la key es permanente)\r\n    },\r\n  },\r\n})\r\n```\r\n\r\n**2. `apiKeys` como recurso** (scoped por workspace; `expiresAt: null` = permanente):\r\n\r\n```typescript\r\n// control/api-key/api-key.resource.ts\r\nResourceApiModule.register({\r\n  name: 'apiKey',\r\n  route: 'api-keys',\r\n  entity: ApiKey,\r\n  workspace: { field: 'propertyId', required: true },\r\n\r\n  actions: {\r\n    list: { permission: 'apiKey.view' },\r\n    get: { permission: 'apiKey.view' },\r\n    create: { permission: 'apiKey.create' },\r\n    delete: { permission: 'apiKey.create' },   // revocar\r\n  },\r\n  endpoints: { include: ['create', 'list', 'get', 'delete'] },\r\n\r\n  query: { scope: subtreeScope },   // el workspace y sus sub-workspaces\r\n\r\n  setServices: ApiKeyService,       // genera la key, guarda SOLO el hash\r\n})\r\n```\r\n\r\n**3. `ApiKeyService`** — genera la key en claro una única vez y guarda el hash (el claro nunca se persiste):\r\n\r\n```typescript\r\n@Injectable()\r\nexport class ApiKeyService extends CommonApiService<ApiKey> {\r\n  override create(ctx, body): Observable<{ id: string; key: string; expiresAt: Date | null }> {\r\n    const key = randomBytes(32).toString('hex');            // clave en claro (solo ahora)\r\n    const expiresAt = body.expiresAt ? new Date(body.expiresAt) : null;   // null = permanente\r\n    return this.repository.create({\r\n      ...body,\r\n      keyHash: sha256(key),                                 // SOLO el hash se guarda\r\n      createdBy: ctx.auth.userId,\r\n    }).pipe(\r\n      map((entity) => ({ id: entity._id, key, expiresAt })),  // se devuelve UNA vez\r\n    );\r\n  }\r\n}\r\n```\r\n\r\n**4. `ApiKeyProvider`** — valida la key por hash y **construye el contexto directamente** (sin sesión):\r\n\r\n```typescript\r\n// auth/strategies/api-key.provider.ts\r\n@Injectable()\r\nexport class ApiKeyProvider implements AuthResourceApiStrategyProvider<ApiKeyPayload> {\r\n  readonly type = 'api-key';\r\n\r\n  constructor(private readonly apiKeys: IBaseRepositoryService<ApiKey>) {}\r\n\r\n  // sin sessionStore → stateless: la key ES la credencial, no caduca\r\n  authenticate(key: string, request: any): Observable<AuthResourceApiContext<ApiKeyPayload>> {\r\n    return this.apiKeys.findOne({ keyHash: sha256(key) }).pipe(\r\n      switchMap((apiKey) => {\r\n        if (!apiKey || (apiKey.expiresAt && apiKey.expiresAt < new Date())) {\r\n          throw new UnauthorizedException('API key inválida o expirada');\r\n        }\r\n        return this.apiKeys.update(apiKey._id, { lastUsedAt: new Date() }).pipe(   // opcional\r\n          map(() => ({\r\n            token: apiKey.keyHash,                     // no es una sesión\r\n            userId: apiKey.userId,\r\n            workspace: { id: apiKey.propertyId, name: apiKey.propertyName },   // workspace fijo\r\n            payload: { scopes: apiKey.permissions, propertyIds: [apiKey.propertyId] },\r\n          }) as AuthResourceApiContext<ApiKeyPayload>),\r\n        );\r\n      }),\r\n    );\r\n  }\r\n}\r\n```\r\n\r\n**5. Revocación** — como la estrategia es **stateless**, revocar es instantáneo: `DELETE /api-keys/:id` borra la key y el provider deja de encontrarla por hash. No hay sesiones que matar.\r\n\r\n```typescript\r\n// el cliente solo envía el header\r\nfetch('/mobiles', { headers: { 'x-api-key': key } });\r\n```\r\n\r\n> En el **Módulo Control**, este mecanismo cubre las integraciones permanentes (sistemas externos, servicios de reporte, apps de terceros). Como la key es del `workspace` (`propertyId`), el scope (`query.scope`) aplica igual: solo ve los recursos de su property y sub-workspaces. Los ids hardcodeados del sistema actual se reemplazan por `apiKeys` de cada property.\r\n\r\n---\r\n\r\n## Sesiones de autenticación (persistencia en BD)\r\n\r\nLas sesiones **se persisten en base de datos por defecto**, de modo que **no se pierden al reiniciar el servicio**. La librería no conoce tu ORM, pero **suministra el `DatabaseSessionStore`** que usa tu `RepositoryService<Session>` (vía `@deorta-dev/nestjs-repository-core`): solo necesitas darle el repositorio de la colección `sessions`.\r\n\r\n### Interfaz base\r\n\r\n```typescript\r\nexport interface SessionData<S = any> {\r\n  token: string;\r\n  userId: string;\r\n  expiresAt: Date;\r\n  payload: S;\r\n  revoked?: boolean;\r\n  metadata?: Record<string, any>;\r\n}\r\n\r\n// Observable-first: los adaptadores externos envuelven con `from()`\r\nexport interface SessionStore<S = any> {\r\n  create(session: SessionData<S>): Observable<void>;\r\n  get(token: string): Observable<SessionData<S> | null>;\r\n  update(token: string, data: Partial<SessionData<S>>): Observable<void>;\r\n  delete(token: string): Observable<void>;\r\n  deleteByUser(userId: string): Observable<void>;\r\n  touch(token: string, ttl?: number): Observable<void>;\r\n  clearExpired(): Observable<number>;\r\n}\r\n```\r\n\r\n### Store por defecto: base de datos\r\n\r\n`DatabaseSessionStore` (provisto por la librería) persiste en tu BD y sobrevive a reinicios y reempliegues. Requiere un repositorio de sesiones (`sessions`) expuesto por tu `RepositoryService`:\r\n\r\n```typescript\r\n// sessions.repository.module.ts — de tu proyecto (repositorio de la colección sessions)\r\n@RepositoryModule({ name: 'session', model: Session })\r\nexport class SessionRepositoryModule {}\r\n```\r\n\r\n```typescript\r\nstrategies: {\r\n  jwt: {\r\n    type: 'jwt',\r\n    secret: process.env.JWT_SECRET,\r\n    expiresIn: '12h',\r\n    // Persistencia en BD: las sesiones no se borran al reiniciar el servicio\r\n    sessionStore: { type: 'database', repository: SessionRepositoryModule, ttl: 12 * 3600 },\r\n  },\r\n}\r\n```\r\n\r\nComportamiento del store:\r\n\r\n- `get(token)` consulta `{ token, expiresAt: { $gt: now } }`: una sesión expirada no se devuelve (equivale a no existir).\r\n- `create` guarda la sesión con su `expiresAt` (TTL).\r\n- `touch(token, ttl)` **extiende** `expiresAt` (actividad del usuario).\r\n- `deleteByUser(userId)` revoca todas las sesiones de un usuario (logout global).\r\n- `clearExpired()` borra `{ expiresAt: { $lt: now } }`. Ejecútalo en un cron (`@nestjs/schedule`) para no acumular registros.\r\n\r\n```typescript\r\n// app.module.ts — limpieza periódica de sesiones expiradas\r\n@Cron(CronExpression.EVERY_10_MINUTES)\r\nclearExpiredSessions(): void {\r\n  this.authSrv.clearExpiredSessions().subscribe();   // delega en el SessionStore\r\n}\r\n```\r\n\r\n**Índices recomendados** en la colección `sessions`: `{ token: 1 }` único, `{ userId: 1 }`, `{ expiresAt: 1 }` (para `clearExpired`).\r\n\r\n### Store de memoria: solo desarrollo\r\n\r\nLa implementación en memoria se mantiene para desarrollo/pruebas: **no persiste entre reinicios**.\r\n\r\n```typescript\r\nsessionStore: { type: 'memory', ttl: 3600 },\r\n```\r\n\r\n### Redis: capa opcional (no fuente de verdad)\r\n\r\nRedis es útil como **capa de cache/TTL** con acceso O(1), pero **si es el único store, las sesiones se pierden al reiniciar** (o al perder Redis, salvo AOF/RDB). Para persistencia real la fuente de verdad es la BD. Si usas Redis, implementa la interfaz `SessionStore` (o un paquete complementario):\r\n\r\n```typescript\r\n// my-redis-session.store.ts\r\nimport { Injectable } from '@nestjs/common';\r\nimport { Redis } from 'ioredis';\r\nimport { Observable, from, of } from 'rxjs';\r\nimport { map } from 'rxjs/operators';\r\nimport { SessionData, SessionStore } from '@deorta-dev/nestjs-resource-core';\r\n\r\n@Injectable()\r\nexport class RedisSessionStore implements SessionStore {\r\n  constructor(private readonly redis: Redis) {}\r\n\r\n  create(session: SessionData): Observable<void> {\r\n    return from(this.redis.setex(\r\n      `session:${session.token}`,\r\n      Math.floor((session.expiresAt.getTime() - Date.now()) / 1000),\r\n      JSON.stringify(session),\r\n    )).pipe(map(() => undefined));\r\n  }\r\n\r\n  get(token: string): Observable<SessionData | null> {\r\n    return from(this.redis.get(`session:${token}`)).pipe(\r\n      map((raw) => (raw ? JSON.parse(raw) : null)),\r\n    );\r\n  }\r\n\r\n  update(token: string, data: Partial<SessionData>): Observable<void> {\r\n    return this.get(token).pipe(\r\n      mergeMap((session) => (session ? this.create({ ...session, ...data }) : of(undefined))),\r\n    );\r\n  }\r\n\r\n  delete(token: string): Observable<void> {\r\n    return from(this.redis.del(`session:${token}`)).pipe(map(() => undefined));\r\n  }\r\n\r\n  deleteByUser(userId: string): Observable<void> {\r\n    return of(undefined);   // indexar por usuario si se necesita\r\n  }\r\n\r\n  touch(token: string, ttl?: number): Observable<void> {\r\n    return from(this.redis.expire(`session:${token}`, ttl ?? 3600)).pipe(map(() => undefined));\r\n  }\r\n\r\n  clearExpired(): Observable<number> {\r\n    return of(0); // Redis expira solo\r\n  }\r\n}\r\n```\r\n\r\n### Store externo (`custom`)\r\n\r\nPara casos especiales (otra BD, cache-first con write-through, etc.) implementas la interfaz y la registras:\r\n\r\n```typescript\r\nsessionStore: {\r\n  type: 'custom',\r\n  provider: RedisSessionStore,   // clase o instancia que implementa SessionStore\r\n}\r\n```\r\n\r\n> La misma lógica aplica para cache: la librería define la interfaz `CacheStore`; su única implementación interna es la de memoria (los cachés también pueden persistirse en BD si lo requieren).\r\n\r\n---\r\n\r\n## Login, registro de usuarios, roles y contexto\r\n\r\nLa librería **no gestiona usuarios, ni login, ni registro**. Solo gestiona **sesiones** (`SessionStore`), **validación de tokens** (`AuthResourceApiService`) y **permisos** (`PermissionProvider`). Los usuarios, sus credenciales y sus roles viven en **tu repositorio de usuarios** (base de datos del proyecto); el login y el registro son **endpoints custom** que tú implementas y declaras como públicos.\r\n\r\n### El contexto (`AuthResourceApiContext`)\r\n\r\nEs el objeto que recibe cada handler vía `@AuthResourceApi()`. La librería lo construye desde la sesión y los providers:\r\n\r\n```typescript\r\nexport interface AuthResourceApiContext<C = any> {\r\n  token: string;                       // token autenticado (de la sesión)\r\n  userId: string;                      // usuario autenticado\r\n  roles?: string[];                    // roles del usuario (los pone tu proyecto)\r\n  permissions?: string[];              // permisos resueltos (PermissionProvider)\r\n  workspace?: Workspace | null;        // workspace actual de la sesión\r\n  payload?: C;                         // payload de la estrategia (lo que quieras)\r\n  metadata?: Record<string, any>;\r\n  [key: string]: any;                  // extiende con datos propios del proyecto\r\n}\r\n```\r\n\r\n### Registro de usuarios\r\n\r\nEl registro es un **endpoint custom y público** de tu proyecto. La librería no interviene: tú validas, hasheas la contraseña, creas el usuario (con su rol) en tu repositorio y —si quieres— haces login automático:\r\n\r\n```typescript\r\n// auth.controller.ts — controlador del PROYECTO (no de la librería)\r\n@Controller('auth')\r\nexport class AuthController {\r\n  constructor(\r\n    private readonly users: UserRepositoryService,      // repo externo de tu proyecto\r\n    private readonly authSrv: AuthResourceApiService,   // de la librería\r\n  ) {}\r\n\r\n  @Post('register')\r\n  @ResourceAction({ public: true })                     // público: sin autenticación\r\n  register(@Body() dto: RegisterDto): Observable<TokenResponse> {\r\n    const passwordHash = hashPassword(dto.password);\r\n    return this.users.create({\r\n      email: dto.email,\r\n      passwordHash,\r\n      roles: dto.roles ?? ['user'],                     // roles asignados en el registro\r\n    }).pipe(\r\n      switchMap(() => this.login({ email: dto.email, password: dto.password })),\r\n    );\r\n  }\r\n}\r\n```\r\n\r\n> Recuerda registrar `POST /auth/register` en `publicResources` (junto a `POST /auth/login`). Los roles son **strings de tu dominio** (`admin`, `user`, `property-admin`, ...); la librería no los conoce ni los valida.\r\n\r\n### Login\r\n\r\nEl login también es un endpoint **custom y público**. Flujo:\r\n\r\n1. El cliente envía credenciales a `POST /auth/login`.\r\n2. Tu proyecto valida contra el **repositorio de usuarios** (externo).\r\n3. Construye el `payload` de la sesión (roles, workspace, etc.).\r\n4. Llama a `AuthResourceApiService.createSession()`: la librería genera el token y lo **persiste en el `SessionStore` configurado** (memoria/Redis/BD).\r\n5. Devuelve el token al cliente.\r\n\r\n```typescript\r\n@Post('login')\r\n@ResourceAction({ public: true })\r\nlogin(@Body() dto: LoginDto): Observable<{ token: string }> {\r\n  return this.users.findOne({ email: dto.email }).pipe(\r\n    switchMap((user) => {\r\n      if (!user || !verifyPassword(dto.password, user.passwordHash)) {\r\n        throw new UnauthorizedException('Credenciales inválidas');\r\n      }\r\n      return this.authSrv.createSession({\r\n        userId: user.id,\r\n        ttl: 3600,                                   // expiración (la respeta el SessionStore)\r\n        payload: {\r\n          email: user.email,\r\n          roles: user.roles,                         // para el contexto\r\n          // workspace, propertyIds, etc. según tu estrategia\r\n        },\r\n      });\r\n    }),\r\n  );\r\n}\r\n```\r\n\r\n### Cómo se resuelve el contexto en cada request\r\n\r\n1. El cliente envía `Authorization: Bearer <token>` (o la credencial de la estrategia resuelta).\r\n2. `AuthResourceApiGuard` (global) elige la estrategia con `resolver`/`default`.\r\n3. La estrategia consulta `SessionStore.get(token)` y valida expiración/revocación.\r\n4. La librería hidrata el `AuthResourceApiContext`: `userId`, `permissions` (vía `PermissionProvider.getPermissions`), `workspace` (vía `WorkspaceResourceApiService`), `payload`.\r\n5. El handler lo recibe con `@AuthResourceApi()` y lo usa en `query.scope`, permisos, hooks, etc.\r\n\r\n### Roles → permisos\r\n\r\nLa librería trabaja con **permisos (strings)**; la traducción roles→permisos la hace tu proyecto en `PermissionProvider`:\r\n\r\n```typescript\r\n@Injectable()\r\nexport class MyPermissionProvider implements PermissionProvider {\r\n  constructor(private readonly users: UserRepositoryService) {}\r\n\r\n  getPermissions(auth: AuthResourceApiContext): Observable<string[]> {\r\n    return this.users.findOne({ _id: oid(auth.userId) }).pipe(\r\n      map((user) => flattenPermissions(user.roles)),  // admin → ['*'], user → ['mobile:list', ...]\r\n    );\r\n  }\r\n\r\n  hasPermission(auth: AuthResourceApiContext, permission: string): Observable<boolean> {\r\n    return this.getPermissions(auth).pipe(\r\n      map((perms) => perms.includes('*') || perms.includes(permission)),\r\n    );\r\n  }\r\n}\r\n```\r\n\r\n### Resumen de responsabilidades\r\n\r\n| Tema | ¿Quién lo gestiona? |\r\n|------|---------------------|\r\n| Usuarios, contraseñas, roles | Tu proyecto (repositorio de usuarios) |\r\n| Registro de usuarios | Tu proyecto (endpoint custom público) |\r\n| Login (validar credenciales) | Tu proyecto (endpoint custom público) |\r\n| Roles → permisos | Tu proyecto (`PermissionProvider`) |\r\n| Persistir sesión/token | La librería (`DatabaseSessionStore` sobre tu `sessions`) |\r\n| Validar token por request | La librería (`AuthResourceApiService.getAuthentication`) |\r\n| Verificar permiso por endpoint | La librería (guard + `PermissionProvider`) |\r\n| Contexto por request | La librería (`@AuthResourceApi()` → `AuthResourceApiContext`) |\r\n\r\n---\r\n\r\n## Invitaciones de usuarios por workspace\r\n\r\nLa librería tampoco gestiona invitaciones: son una **entidad de tu proyecto** (como cualquier recurso). El patrón recomendado es modelar `Invitation` con `ResourceApiModule` (scoped por workspace) y exponer solo dos flujos:\r\n\r\n1. **Invitar** — autenticado, requiere permiso sobre el workspace.\r\n2. **Aceptar** — **público**, autenticado por el **token de la invitación** (no por sesión), ya que el invitado aún no es usuario.\r\n\r\n### Exposición del registro\r\n\r\nEl registro **se expone o no según lo que pongas en `publicResources`**. Para que los usuarios **solo** entren por invitación:\r\n\r\n- No declares `POST /auth/register` en `publicResources` (o ni lo implementes).\r\n- El alta de cuenta ocurre únicamente dentro del flujo de **aceptar invitación** (token válido → se crea el usuario). Así no hay registro abierto.\r\n\r\n### Recurso `Invitation`\r\n\r\n```typescript\r\n// invitation.resource.ts — como cualquier recurso, scoped por workspace\r\nResourceApiModule.register({\r\n  name: 'invitation',\r\n  route: 'invitations',\r\n  entity: InvitationEntity,\r\n\r\n  // el workspace es la entidad sobre la que se invita\r\n  workspace: { field: 'workspaceId', required: true },\r\n\r\n  permissions: { prefix: 'workspace' },   // workspace:invite, workspace:invite.list, ...\r\n\r\n  actions: {\r\n    create: { permission: 'workspace:invite' },\r\n    list: { permission: 'workspace:invite.list' },\r\n    get: { permission: 'workspace:invite.list' },\r\n    update: { permission: 'workspace:invite' },\r\n    delete: { permission: 'workspace:invite' },\r\n  },\r\n\r\n  endpoints: { include: ['create', 'list', 'get', 'update', 'delete'] },\r\n\r\n  // Crear una invitación genera su token y dispara el envío.\r\n  // Los hooks son funciones de config: para usar servicios inyectables\r\n  // envuélvelos en una factory o delega en tu dominio (sendInviteEmail).\r\n  hooks: {\r\n    afterCreate: ({ entity }) =>\r\n      sendInviteEmail(entity),   // función de tu dominio (usa el token del body)\r\n  },\r\n})\r\n```\r\n\r\nCada invitación guarda: `workspaceId`, `email`, `roles` (los que se asignarán), `token`, `status` (`pending`/`accepted`/`revoked`) y `expiresAt`.\r\n\r\n### Aceptar la invitación (público)\r\n\r\nEs un endpoint **custom y público** que valida el token de la invitación (no una sesión). Se agrega con `addController`:\r\n\r\n```typescript\r\n@Controller('invitations')\r\nexport class InvitationAcceptController implements ICustomActionController<Invitation> {\r\n  constructor(\r\n    private readonly invitations: InvitationRepositoryService,  // repo del proyecto\r\n    private readonly users: UserRepositoryService,              // repo de usuarios\r\n  ) {}\r\n\r\n  // NO está en publicResources por ruta: se marca público en el endpoint\r\n  @Post('accept')\r\n  @ResourceAction({ public: true })                 // sin auth: el token ES la credencial\r\n  accept(@Body() dto: { token: string }): Observable<User> {\r\n    return this.invitations.findOne({ token: dto.token }).pipe(\r\n      switchMap((inv) => {\r\n        if (!inv || inv.status !== 'pending' || inv.expiresAt < new Date()) {\r\n          throw new BadRequestException('Invitación inválida o expirada');\r\n        }\r\n        // Crea (o actualiza) el usuario con los roles de la invitación y\r\n        // lo vincula al workspace. A partir de aquí aparece en\r\n        // WorkspaceProvider.list(auth) y puede hacer `select`.\r\n        return this.users.createOrAttachToWorkspace({\r\n          email: inv.email,\r\n          roles: inv.roles,\r\n          workspaceId: inv.workspaceId,\r\n        }).pipe(\r\n          switchMap((user) =>\r\n            this.invitations.update(inv.id, { status: 'accepted' }).pipe(map(() => user)),\r\n          ),\r\n        );\r\n      }),\r\n    );\r\n  }\r\n}\r\n```\r\n\r\n```typescript\r\nResourceApiModule.register({\r\n  name: 'invitation',\r\n  route: 'invitations',\r\n  addController: InvitationAcceptController,\r\n  ...\r\n})\r\n```\r\n\r\n### Flujo completo\r\n\r\n1. Un usuario con `workspace:invite` invita a `email` (con `roles`) → se crea `Invitation` y se envía el link.\r\n2. El invitado abre el link. Dos casos:\r\n   - **Ya tiene cuenta** → inicia sesión (`POST /auth/login`, público) y acepta la invitación; el `PermissionProvider` ya le otorga los roles.\r\n   - **No tiene cuenta** → `POST /invitations/accept` (público) valida el token y **crea el usuario** con los roles de la invitación, sin registro abierto.\r\n3. El usuario acepta y ya puede `select` ese workspace (`WorkspaceResourceApiService`).\r\n\r\n> Con este flujo **no necesitas exponer `register`**: el alta de usuarios queda limitada a invitaciones válidas. Si además quieres registro abierto, simplemente agrégalo a `publicResources`.\r\n\r\n---\r\n\r\n## Configuración completa del recurso\r\n\r\n### `ResourceApiModule.register(config: ResourceConfig)`\r\n\r\n```typescript\r\nResourceApiModule.register({\r\n  // ---------------------------------------------------------------\r\n  // Identidad del recurso\r\n  // ---------------------------------------------------------------\r\n  name: 'mobile',\r\n  route: 'mobiles',\r\n  basePath: '/api/v1',           // Prefijo global opcional\r\n\r\n  // ---------------------------------------------------------------\r\n  // Entidad y DTOs (entrada y salida)\r\n  // ---------------------------------------------------------------\r\n  entity: MobileEntity,\r\n  dtos: {\r\n    input: {\r\n      create: CreateMobileDto,\r\n      update: UpdateMobileDto,\r\n      list: ListMobileDto,           // query params tipados\r\n      delete: DeleteMobileDto,\r\n      transfer: TransferMobileDto,\r\n      // custom: CustomActionDto,\r\n    },\r\n    output: {\r\n      create: MobileResponseDto,\r\n      update: MobileResponseDto,\r\n      get: MobileResponseDto,\r\n      list: MobileListResponseDto,   // { objects, pagination }\r\n      count: CountResponseDto,\r\n      choice: ChoiceResponseDto,\r\n      transfer: TransferResponseDto,\r\n      // custom: CustomActionResponseDto,\r\n    },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Capa de datos (delegada a nestjs-repository-core o equivalente)\r\n  // ---------------------------------------------------------------\r\n  repositoryService: MobileRepositoryService,\r\n  repositoryModule: MobileRepositoryModule,\r\n\r\n  // Nota: NO se configura auth aquí. La estrategia, endpoints públicos\r\n  // y resolución por request viven en AuthResourceApiModule.\r\n\r\n  // ---------------------------------------------------------------\r\n  // Endpoints habilitados — por inclusión o exclusión\r\n  // ---------------------------------------------------------------\r\n  endpoints: {\r\n    mode: 'inclusion',             // 'inclusion' | 'exclusion'\r\n    include: [\r\n      'create', 'list', 'get', 'update', 'delete',\r\n      'count', 'choice', 'transfer',\r\n    ],\r\n    // o si mode = 'exclusion':\r\n    // exclude: ['transfer'],\r\n    //\r\n    // Nota: getCurrent, updateCurrent, consolidate y similares NO son\r\n    // acciones base. Son funciones personalizadas: agrégalas con\r\n    // `addController` o heredando de CommonApiService.\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Permisos — prefijo por defecto + overrides por endpoint\r\n  // ---------------------------------------------------------------\r\n  permissions: {\r\n    prefix: 'mobile',              // 'mobile' -> mobile:create, mobile:list ...\r\n    delimiter: ':',\r\n    // defaults: sobrescribe los permisos base por acción\r\n    defaults: {\r\n      create: ['mobile:create'],\r\n      list: ['mobile:list'],\r\n      get: ['mobile:read'],\r\n      update: ['mobile:update'],\r\n      delete: ['mobile:delete'],\r\n      count: ['mobile:count'],\r\n      choice: ['mobile:choice'],\r\n      transfer: ['mobile:transfer'],\r\n      report: ['mobile:report'],\r\n    },\r\n  },\r\n\r\n  // Permiso específico por endpoint (reemplaza el prefijo)\r\n  actions: {\r\n    create: { permission: ['mobile:create', 'admin:mobiles'] },\r\n    list: { permission: 'mobile:list' },\r\n    get: { permission: 'mobile:read' },\r\n    update: { permission: 'mobile:update' },\r\n    delete: { permission: 'mobile:delete' },\r\n    count: { permission: 'mobile:count' },\r\n    choice: { permission: 'mobile:choice' },\r\n    // transfer mueve el recurso de un workspace a otro\r\n    transfer: { permission: 'mobile:transfer' },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Scope de la query — basado en auth (equivalente a authFunction)\r\n  // Se inyecta en TODA operación del recurso (list, get, update, ...)\r\n  // ---------------------------------------------------------------\r\n  query: {\r\n    // Recibe el auth (con su workspace actual) y devuelve el filtro que\r\n    // restringe los datos del recurso al ámbito del usuario\r\n    scope: (auth, ctx) => ({\r\n      $or: [\r\n        { mobilePropertyId: { $in: auth.propertyIds } },\r\n        { devicePropertyId: { $in: auth.propertyIds } },\r\n      ],\r\n    }),\r\n\r\n    // Filtros extra siempre presentes (p. ej. no mostrar eliminados)\r\n    extraMatch: { status: { $ne: 'deleted' } },\r\n\r\n    // Nota: la proyección de lectura NO va aquí — se configura en `views`\r\n    // (views.projections + views.default). Ver sección \"Vistas proyectadas\".\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Workspace del recurso — campo configurable de la entidad que\r\n  // identifica a qué workspace pertenece. NO tiene por qué llamarse\r\n  // workspaceId: puede ser propertyId, spaceId, projectId, etc.\r\n  // ---------------------------------------------------------------\r\n  workspace: {\r\n    field: 'propertyId',           // campo de la entidad (configurable)\r\n    // required: true,             // opcional: el recurso siempre pertenece a un workspace\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Filtrado — conversión de HTTP GET a filtro del repositorio (Mongo)\r\n  // Cada clave del dict es un query param; define cómo convertirlo\r\n  // (ver sección \"Filtrado: de HTTP GET a MongoDB\")\r\n  // ---------------------------------------------------------------\r\n  filters: {\r\n    // Diccionario de conversión (equivalente a queryMatchDirectory)\r\n    queryMatchDirectory: {\r\n      // Filtro simple sobre la misma columna\r\n      name: { operation: 'regex', type: 'string' },\r\n      status: { operation: 'eq', type: 'string' },\r\n\r\n      // Filtro sobre otra columna (attribute) con cast de tipo\r\n      deviceId: { operation: 'eq', type: 'id' },\r\n      startDeviceTime: { attribute: 'deviceTime', type: 'date', operation: 'gte' },\r\n      endDeviceTime: { attribute: 'deviceTime', type: 'date', operation: 'lt' },\r\n\r\n      // Params anidados (dot path) y tipo\r\n      prevStartDeviceTime: { attribute: 'prev.deviceTime', type: 'date', operation: 'gte' },\r\n\r\n      // Expresión personalizada (recibe el valor crudo del query)\r\n      sensor: {\r\n        expr: (value) => {\r\n          const queries = (Array.isArray(value) ? value : [value]).map((v) => {\r\n            const [sensorKey, valueString] = v.split('=');\r\n            return { $eq: [{ $toString: `$${sensorKey}` }, valueString] };\r\n          });\r\n          return { $expr: { $or: queries } };\r\n        },\r\n      },\r\n\r\n      // Params requeridos (error 400 si faltan)\r\n      hasMobile: {\r\n        expr: (value) => {\r\n          const booleans = (Array.isArray(value) ? value : [value]).map(\r\n            (v) => /^(s|yes|true|1|y)$/gi.test(v)\r\n          );\r\n          return {\r\n            $or: booleans.map((b) => ({\r\n              mobileId: { [b ? '$ne' : '$eq']: null },\r\n            })),\r\n          };\r\n        },\r\n        require: true,\r\n      },\r\n    },\r\n\r\n    // Paginación (query: $size, $page)\r\n    maxLimit: 100,\r\n    defaultLimit: 20,\r\n    defaultPage: 1,\r\n\r\n    // Ordenamiento (query: $sort:<campo>=asc|desc)\r\n    sort: {\r\n      default: { key: 'createdAt', value: 'desc' },\r\n      attributes: { createdAt: 'createdAt', name: 'name', lastPositionTime: 'interaction.lastPositionTime' },\r\n    },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Reportes dinámicos — métricas y dimensiones\r\n  // ---------------------------------------------------------------\r\n  reports: {\r\n    enabled: true,\r\n    route: 'report',               // GET /mobiles/report\r\n    metricMap: {\r\n      total: { $sum: 1 },\r\n      maxSpeed: { $max: '$speed' },\r\n      lastDeviceTime: { $max: '$deviceTime' },\r\n      firstDeviceTime: { $min: '$deviceTime' },\r\n    },\r\n    dimensionMap: {\r\n      mobileId: { expr: '$mobileId' },\r\n      mobileName: {\r\n        expr: '$mobileId',\r\n        replaceLookup: {\r\n          ormService: mobileSrv,       // repositorio externo\r\n          foreignerKey: '_id',\r\n          exprLabel: '$name',\r\n        },\r\n      },\r\n    },\r\n    pipelineBeforeReport: [{ $sort: { deviceTime: -1 } }],\r\n    sort: {\r\n      default: { key: 'deviceTime', value: 'asc' },\r\n      attributes: { deviceTime: 'deviceTime', speed: 'speed' },\r\n    },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Choices (listas de selección para frontend)\r\n  // ---------------------------------------------------------------\r\n  choices: {\r\n    enabled: true,\r\n    fields: ['type', 'status', 'category'],\r\n    // La inversión se hace en el mismo endpoint /choice con ?invert=true\r\n    invert: true,\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Vistas proyectadas — fuente ÚNICA de proyección de lectura\r\n  // (sustituye al antiguo query.projection)\r\n  // ---------------------------------------------------------------\r\n  views: {\r\n    default: 'base',               // proyección aplicada si no llega ?view=\r\n    available: ['base', 'summary', 'detail', 'minimal'],\r\n    paramName: 'view',             // ?view=summary\r\n\r\n    projections: {\r\n      // Campos a proyectar (lista) ...\r\n      summary: ['id', 'name', 'status', 'type'],\r\n      detail: ['*'],\r\n      minimal: ['id', 'name'],\r\n      // ... o expresión cruda (p. ej. estilo Mongo/aggregate)\r\n      base: { processPipe: 0 },\r\n    },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Gateway de tiempo real (Socket.IO) — por recurso.\r\n  // Cada cambio (create/update/delete/transfer) emite a los sockets\r\n  // autenticados cuya suscripción (filtro + scope) coincide con el elemento.\r\n  // ---------------------------------------------------------------\r\n  gateway: {\r\n    enabled: true,\r\n    namespace: '/mobiles',        // namespace propio del recurso\r\n\r\n    // Eventos que emite el gateway\r\n    events: {\r\n      element: 'mobile:element',        // elemento emitido\r\n      list: 'mobile:list',              // cambio de lista filtrada\r\n      subscribed: 'mobile:subscribed',  // confirmación de suscripción\r\n    },\r\n\r\n    // Acciones CRUD que disparan emisión\r\n    emitOn: ['create', 'update', 'delete', 'transfer'],\r\n\r\n    // Qué suscribir: reutiliza `queryMatchDirectory` del recurso, pero\r\n    // limitado a estos campos. El scope (query.scope) siempre se aplica.\r\n    filters: ['status', 'type', 'propertyId'],\r\n\r\n    // Autenticación: token en el handshake. La estrategia se resuelve\r\n    // por AuthResourceApiModule (resolver/default) igual que en HTTP.\r\n    auth: { tokenQuery: 'token' },\r\n\r\n    // Opcional: clase propia que extiende CommonGateway\r\n    // setGateway: MobileGateway,\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Bridge de tiempo real — consume un server socket externo\r\n  // (p. ej. el normalizador, estilo Traccar) y emite en este recurso.\r\n  // Ver sección \"Bridge de tiempo real (consumir un server socket externo)\".\r\n  // ---------------------------------------------------------------\r\n  realtime: {\r\n    enabled: true,\r\n    event: 'position',                    // evento del server socket que consume\r\n    transform: (msg) => toEntity(msg),    // payload normalizador → entidad del recurso\r\n    persist: false,                       // false: el normalizador ya escribió en BD\r\n    emit: true,                           // true: emite vía el gateway del recurso\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Hooks de ciclo de vida (reglas de negocio)\r\n  // ---------------------------------------------------------------\r\n  hooks: {\r\n    // Los hooks reciben el contexto y devuelven Observable (o valor directo)\r\n    beforeCreate: ({ body, auth }) => of(body),\r\n    afterCreate: ({ entity, auth }) => of(entity),\r\n    beforeList: ({ query, auth }) => of(query),\r\n    afterList: ({ result, auth }) => of(result),\r\n    beforeUpdate: ({ id, body, auth }) => of(body),\r\n    afterUpdate: ({ entity, auth }) => of(entity),\r\n    beforeDelete: ({ id, auth }) => of(true),\r\n    afterDelete: ({ id, auth }) => of(true),\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Cache\r\n  // ---------------------------------------------------------------\r\n  cache: {\r\n    enabled: true,\r\n    store: 'memory',               // 'memory' | custom\r\n    ttl: 300,\r\n    keyPrefix: 'resource:mobiles',\r\n    invalidateOn: ['create', 'update', 'delete'],\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Validación de DTOs\r\n  // ---------------------------------------------------------------\r\n  validation: {\r\n    whitelist: true,\r\n    forbidNonWhitelisted: true,\r\n    transform: true,\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Documentación con Scalar — config GENÉRICA del recurso\r\n  // CommonApiController la lleva consigo y los endpoints personalizados\r\n  // (addController / setController) la COMPLETAN con lo que falta.\r\n  // ---------------------------------------------------------------\r\n  scalar: {\r\n    enabled: true,\r\n    title: 'API de Móviles',          // título del tag del recurso\r\n    description: 'Gestión de móviles', // descripción del recurso\r\n    tags: ['Mobiles'],\r\n    // DTOs base (entrada/salida) ya vienen de `dtos`; aquí solo\r\n    // se personaliza el resumen/descripción por acción base.\r\n    decorators: {\r\n      create: {\r\n        summary: 'Crear móvil',\r\n        description: 'Registra un nuevo móvil en el workspace actual.',\r\n        responses: {\r\n          201: { description: 'Móvil creado' },\r\n          400: { description: 'Body inválido' },\r\n        },\r\n      },\r\n      list: { summary: 'Listar móviles' },\r\n    },\r\n  },\r\n\r\n  // ---------------------------------------------------------------\r\n  // Personalización — clases que siguen interfaces específicas\r\n  // (ver sección \"Personalización\")\r\n  // ---------------------------------------------------------------\r\n  setServices: MobileService,               // class implements ICustomService<T> extends CommonApiService<T>\r\n  setController: MobileController,          // class extends CommonApiController<T>\r\n  addController: [MobileActionsController], // class implements ICustomActionController<T>\r\n})\r\n```\r\n\r\n---\r\n\r\n## DTOs de entrada y salida\r\n\r\nLos DTOs son esenciales para **documentar** (Scalar) y **tipar** las respuestas. Se configuran por separado:\r\n\r\n- **`dtos.input`** — lo que el cliente envía (body y query).\r\n- **`dtos.output`** — lo que la API devuelve (serialización y documentación).\r\n\r\n```typescript\r\ndtos: {\r\n  input: {\r\n    create: CreateMobileDto,\r\n    update: UpdateMobileDto,\r\n    list: ListMobileDto,\r\n    delete: DeleteMobileDto,\r\n    transfer: TransferMobileDto,\r\n  },\r\n  output: {\r\n    create: MobileResponseDto,\r\n    update: MobileResponseDto,\r\n    get: MobileResponseDto,\r\n    list: MobileListResponseDto,\r\n  },\r\n}\r\n```\r\n\r\nLa librería usa `dtos.input.create` para validar el body de `POST`, y `dtos.output.get` para declarar el tipo de respuesta en Scalar y para la serialización (p. ej. ocultar campos sensibles).\r\n\r\n---\r\n\r\n## Permisos\r\n\r\n### Prefijo por defecto\r\n\r\nTodos los endpoints heredan un prefijo de permiso. Con `prefix: 'mobile'`, cada acción exige `mobile:<accion>`:\r\n\r\n| Acción | Permiso requerido |\r\n|--------|-------------------|\r\n| `create` | `mobile:create` |\r\n| `list` | `mobile:list` |\r\n| `get` | `mobile:read` |\r\n| `update` | `mobile:update` |\r\n| `delete` | `mobile:delete` |\r\n| `count` | `mobile:count` |\r\n| `choice` | `mobile:choice` |\r\n| `transfer` | `mobile:transfer` (mueve el recurso a otro workspace) |\r\n| `report` | `mobile:report` |\r\n\r\n> **`getCurrent`, `updateCurrent`, `consolidate`** no son acciones base: son **funciones personalizadas** de cada recurso. Agrégalas con `addController` o heredando de `CommonApiService`, y define su propio permiso (`mobile:current`, `mobile:consolidate`, etc.).\r\n\r\n### Permiso personalizado por endpoint\r\n\r\n```typescript\r\nactions: {\r\n  create: { permission: ['mobile:create', 'admin:mobiles'] },  // AND\r\n  transfer: { permission: 'mobile:transfer.manual' },\r\n}\r\n```\r\n\r\nSi no se configura, se completa automáticamente con `<prefix>:<accion>`.\r\n\r\n### Resolución de permisos de la sesión\r\n\r\nLa estrategia de permisos se define **una sola vez** en el `AuthResourceApiModule` (compartida por todos los recursos):\r\n\r\n```typescript\r\nexport interface PermissionProvider {\r\n  getPermissions(auth: AuthResourceApiContext): Observable<string[]>;\r\n  hasPermission(auth: AuthResourceApiContext, permission: string): Observable<boolean>;\r\n}\r\n```\r\n\r\n```typescript\r\n// auth.module.ts\r\nAuthResourceApiModule.register({\r\n  default: 'jwt',\r\n  strategies: { jwt: { type: 'jwt', sessionStore: { type: 'database', repository: SessionRepositoryModule } } },\r\n  permissionProvider: MyPermissionProvider,\r\n});\r\n```\r\n\r\n> También puedes resolver permisos desde el payload de la sesión si tu estrategia ya los incluye en `auth.permissions`.\r\n\r\n---\r\n\r\n## Endpoints: inclusión y exclusión\r\n\r\n`CommonApiController` registra solo los endpoints que habilites.\r\n\r\n```typescript\r\n// Solo estos endpoints\r\nendpoints: { mode: 'inclusion', include: ['create', 'list', 'get', 'update', 'delete'] }\r\n\r\n// Todos excepto estos\r\nendpoints: { mode: 'exclusion', exclude: ['transfer', 'report'] }\r\n```\r\n\r\nPor defecto, si no se configura, se habilitan los CRUD estándar: `create`, `list`, `get`, `update`, `delete`, `count`, `choice`.\r\n\r\n---\r\n\r\n## `transfer` — mover un recurso entre workspaces\r\n\r\n`transfer` mueve un recurso **de un workspace a otro**. Verifica que el usuario tenga acceso al workspace de origen (via `query.scope`) y al de destino, y actualiza **el campo configurado en `workspace.field`** del recurso.\r\n\r\n```typescript\r\n// PUT /mobiles/transfer\r\n// Body (TransferMobileDto)\r\n{\r\n  \"id\": \"64b3abc...\",\r\n  \"workspaceId\": \"64b3def...\"     // id del workspace de destino\r\n}\r\n```\r\n\r\n```typescript\r\n// resource\r\nResourceApiModule.register({\r\n  ...\r\n  workspace: { field: 'propertyId' },   // ← campo que se actualiza en el transfer\r\n  ...\r\n})\r\n```\r\n\r\nInternamente el servicio:\r\n\r\n1. Valida el permiso `mobile:transfer`.\r\n2. Verifica que el recurso pertenece al workspace actual del usuario (`query.scope`).\r\n3. Comprueba que el usuario tiene acceso al workspace de destino (vía `WorkspaceResourceApiService`).\r\n4. Actualiza el campo del workspace (**`workspace.field`**, p. ej. `propertyId` = destino) y registra el movimiento (hook `afterTransfer`).\r\n\r\n> Si el recurso usa un campo distinto (por ejemplo `spaceId`, `projectId`), `transfer` lo actualiza igual: el nombre del campo lo decide `workspace.field`, no el nombre del body. Para personalizar validaciones usa `hooks.beforeTransfer` / `hooks.afterTransfer` o sobrescribe el método con `setServices`.\r\n\r\n---\r\n\r\n## Scope por query (`query.scope` / authFunction)\r\n\r\nCada recurso define **qué datos puede ver/editar un usuario** mediante una función de scope (`query.scope`) que recibe el `auth` (con su workspace actual) y devuelve el filtro que se inyecta en **toda operación** del recurso (list, get, update, delete, count, report, choice). Es el equivalente directo de `authFunction` del ejemplo de Posiciones:\r\n\r\n```typescript\r\n// position.resource.ts\r\nResourceApiModule.register({\r\n  name: 'position',\r\n  route: 'positions',\r\n  ...\r\n  query: {\r\n    // authFunction: restringe los datos al ámbito del usuario\r\n    scope: (auth, ctx) => {\r\n      const propertyIds = (auth?.propertyIds || []).map((id) => oid(id));\r\n      return {\r\n        $or: [\r\n          { mobilePropertyId: { $in: propertyIds } },\r\n          { devicePropertyId: { $in: propertyIds } },\r\n        ],\r\n      };\r\n    },\r\n\r\n    // Filtros siempre presentes\r\n    extraMatch: { status: { $ne: 'deleted' } },\r\n\r\n    // Pipeline opcional que corre antes de reportes\r\n    pipelineBeforeReport: [{ $sort: { deviceTime: -1 } }],\r\n\r\n    // Nota: la proyección (p. ej. { processPipe: 0 }) se configura en `views`\r\n  },\r\n})\r\n```\r\n\r\n### Interfaz\r\n\r\n```typescript\r\nexport interface QueryScope<T = any> {\r\n  // Equivalente a authFunction: filtro del ámbito a partir del auth\r\n  scope: (auth: AuthResourceApiContext, ctx: ActionContext) => FilterQuery<T>;\r\n\r\n  // Filtros estáticos siempre presentes (AND)\r\n  extraMatch?: FilterQuery<T>;\r\n\r\n  // Pipeline previo a la agregación de reportes\r\n  pipelineBeforeReport?: PipelineStage[];\r\n}\r\n```\r\n\r\n> La **proyección de lectura** no forma parte de `query`; se declara en `views.projections` y se elige con `views.default` o `?view=`. Así evitamos solapar dos configuraciones para lo mismo.\r\n\r\n### Orden de composición\r\n\r\nCada operación construye su query en este orden:\r\n\r\n1. `query.scope(auth)` — ámbito del usuario (workspace, propiedad, organización).\r\n2. `extraMatch` — filtros estáticos del recurso.\r\n3. Filtros del cliente (`filters` / query params).\r\n4. Hooks `before*`.\r\n\r\n```typescript\r\n// internamente (list, por ejemplo)\r\nconst authMatch = config.query.scope(auth, ctx);\r\nconst extraMatch = config.query.extraMatch ?? {};\r\n// Convierte los query params a filtro Mongo según queryMatchDirectory\r\nconst clientFilter = this.extractFilter(ctx.query, config.filters, ctx);\r\nconst finalQuery = { $and: [authMatch, extraMatch, clientFilter] };\r\n```\r\n\r\n> El scope se aplica **siempre**, incluso en `get`, `update` y `delete` por id, para impedir acceder a datos fuera del ámbito del usuario.\r\n\r\n---\r\n\r\n## Filtrado: de HTTP GET a MongoDB (`queryMatchDirectory`)\r\n\r\nEl servicio convierte los **query params de la URL** en un filtro del repositorio (Mongo/aggregate). La configuración es un **diccionario** (`queryMatchDirectory`) donde cada clave es un query param y su valor describe cómo convertirlo.\r\n\r\n### Interfaz\r\n\r\n```typescript\r\nexport type TypeValueOperation =\r\n  | 'string' | 'number' | 'date' | 'boolean' | 'id';\r\n\r\nexport interface QueryMatchConfig {\r\n  // Operación a aplicar sobre el atributo\r\n  operation?: 'gte' | 'gt' | 'lt' | 'lte' | 'ne' | 'eq' | 'regex';\r\n  // Expresión personalizada (recibe el valor crudo + el contexto HTTP)\r\n  expr?: (value: string | string[], httpData?: ActionContext) => Expression;\r\n  // Tipo para hacer cast del valor recibido (string) al tipo real\r\n  type?: TypeValueOperation;\r\n  // Campo de la entidad sobre el que se filtra (soporta dot path).\r\n  // Si no se define, usa el nombre del query param.\r\n  attribute?: string;\r\n  // true => error 400 si el param no viene en el query\r\n  require?: boolean;\r\n}\r\n```\r\n\r\n### Conversión de cada param\r\n\r\nPara cada clave del query que exista en el `queryMatchDirectory`:\r\n\r\n1. **Resuelve el atributo**: `attribute ?? clave` (admite dot path: `prev.deviceTime`).\r\n2. **Convierte el valor** según `type`:\r\n\r\n| `type` | Cast del valor recibido |\r\n|--------|-------------------------|\r\n| `string` | `String(value)` |\r\n| `number` | `Number(value)` |\r\n| `date` | `new Date(value)` (ISO 8601) |\r\n| `boolean` | `parseBoolean(value)` (`true/false/1/0/yes/no/s`) |\r\n| `id` | `ObjectId(value)` (`oid`) |\r\n\r\n3. **Aplica la operación**:\r\n\r\n| `operation` | Filtro generado |\r\n|-------------|-----------------|\r\n| `eq` | `{ [attr]: value }` |\r\n| `ne` | `{ [attr]: { $ne: value } }` |\r\n| `gt` | `{ [attr]: { $gt: value } }` |\r\n| `gte` | `{ [attr]: { $gte: value } }` |\r\n| `lt` | `{ [attr]: { $lt: value } }` |\r\n| `lte` | `{ [attr]: { $lte: value } }` |\r\n| `regex` | `{ [attr]: { $regex: value, $options: 'i' } }` |\r\n| `expr` | Devuelve lo que retorne la función (se usa tal cual) |\r\n\r\n4. Si el valor es un **array** (param repetido `?deviceId=a&deviceId=b`): para `eq`/`ne` se genera `$in`/`$nin`; para el resto, cada elemento genera su condición.\r\n\r\n5. Todas las condiciones generadas se combinan con `$and`.\r\n\r\n### Ejemplo real (Posiciones)\r\n\r\n```typescript\r\n// position.resource.ts\r\nfilters: {\r\n  queryMatchDirectory: {\r\n    // ?deviceTime=... filtra sobre el campo deviceTime\r\n    startDeviceTime: { attribute: 'deviceTime', type: 'date', operation: 'gte' },\r\n    endDeviceTime:   { attribute: 'deviceTime', type: 'date', operation: 'lt' },\r\n\r\n    prevStartDeviceTime: { attribute: 'prev.deviceTime', type: 'date', operation: 'gte' },\r\n    prevEndDeviceTime:   { attribute: 'prev.deviceTime', type: 'date', operation: 'lt' },\r\n\r\n    deviceId: { operation: 'eq', type: 'id' },\r\n    mobileId: { operation: 'eq', type: 'id' },\r\n\r\n    // Params con expresión personalizada\r\n    sensor: {\r\n      expr: (value) => {\r\n        const queries = (Array.isArray(value) ? value : [value]).map((v) => {\r\n          const [sensorKey, valueString] = v.split('=');\r\n          return { $eq: [{ $toString: `$${sensorKey}` }, valueString] };\r\n        });\r\n        return { $expr: { $or: queries } };\r\n      },\r\n    },\r\n\r\n    hasMobile: {\r\n      expr: (value) => {\r\n        const booleans = (Array.isArray(value) ? value : [value]).map(\r\n          (v) => /^(s|yes|true|1|y)$/gi.test(v)\r\n        );\r\n        return {\r\n          $or: booleans.map((b) => ({ mobileId: { [b ? '$ne' : '$eq']: null } })),\r\n        };\r\n      },\r\n    },\r\n  },\r\n\r\n  maxLimit: 100,\r\n  defaultLimit: 20,\r\n\r\n  sort: {\r\n    default: { key: 'deviceTime', value: 'asc' },\r\n    attributes: { deviceTime: 'deviceTime', speed: 'speed', address: 'address' },\r\n  },\r\n}\r\n```\r\n\r\n### Query param → filtro Mongo\r\n\r\n```http\r\nGET /positions?startDeviceTime=2026-08-01T00:00:00Z&endDeviceTime=2026-08-02T00:00:00Z&deviceId=64b3abc...&sensor=ignition=true&sensor=door=closed\r\n```\r\n\r\nSe convierte a:\r\n\r\n```typescript\r\n{\r\n  $and: [\r\n    { deviceTime: { $gte: ISODate('2026-08-01T00:00:00Z'), $lt: ISODate('2026-08-02T00:00:00Z') } },\r\n    { deviceId: ObjectId('64b3abc...') },\r\n    {\r\n      $expr: {\r\n        $or: [\r\n          { $eq: [{ $toString: '$ignition' }, 'true'] },\r\n          { $eq: [{ $toString: '$door' }, 'closed'] },\r\n        ],\r\n      },\r\n    },\r\n  ],\r\n}\r\n```\r\n\r\n### Query params reservados\r\n\r\nEstos params **nunca** entran al filtro; se extraen antes:\r\n\r\n| Param | Uso |\r\n|-------|-----|\r\n| `$size`, `$page` | Paginación |\r\n| `$sort:<campo>` | Ordenamiento (`asc`/`desc`), mapeado por `sort.attributes` |\r\n| `$select` | Vista/proyección (`views`) |\r\n| `$metric:<nombre>`, `$dimension:<nombre>` | Reportes |\r\n\r\n---\r\n\r\n## Personalización: `setServices`, `setController` y `addController`\r\n\r\n`setServices`, `setController` y `addController` reciben **clases** (no funciones). Cada una sigue una interfaz específica y recibe sus dependencias por inyección de Nest.\r\n\r\n| Opción | Qué recibe | Extiende la clase común |\r\n|--------|------------|-------------------------|\r\n| `setServices` | Clase que **implementa `ICustomService<T>`** y **extiende `CommonApiService<T>`** | ✅ (hereda la lógica y recibe inyecciones) |\r\n| `setController` | Clase que **extiende `CommonApiController<T>`** | ✅ (hereda los endpoints y recibe el servicio) |\r\n| `addController` | Clase que **implementa `ICustomActionController<T>`** | ❌ (solo cumple la interfaz) |\r\n\r\n### Cómo se documenta un endpoint personalizado\r\n\r\n`CommonApiController` ya trae la **configuración genérica de documentación** del recurso (`scalar.title`, `scalar.description`, `scalar.tags`) y los **DTOs** (`dtos.input` / `dtos.output`). Cuando agregas o sobrescribes un endpoint, solo necesitas **completar lo que falta**: `summary`, `description` y `responses`. La librería fusiona ambos:\r\n\r\n```\r\nDocumentación final del endpoint =\r\n  config genérica del recurso (scalar + dtos)   // la pone CommonApiController\r\n  + metadata del endpoint (summary/description/responses)\r\n  + DTOs del endpoint (inputDto/outputDto)        // si aplica\r\n```\r\n\r\n### `setServices` — servicio personalizado\r\n\r\nEl módulo instancia esta clase como servicio del recurso, inyectándole la `ResourceConfig` más tus dependencias propias.\r\n\r\n```typescript\r\n// mobile.service.ts\r\nimport { CommonApiService, ResourceConfig, ActionContext } from '@deorta-dev/nestjs-resource-core';\r\n\r\n// Interfaz que debe cumplir la clase (se extiende CommonApiService para\r\n// recibir la config + inyecciones internas por DI)\r\nexport interface ICustomService<T = any> extends CommonApiService<T> {\r\n  restore(ctx: ActionContext): Observable<RestoreResponseDto>;\r\n  getHistory(ctx: ActionContext): Observable<HistoryDto>;\r\n}\r\n\r\n@Injectable()\r\nexport class MobileService\r\n  extends CommonApiService<Mobile>\r\n  implements ICustomService<Mobile>\r\n{\r\n  constructor(\r\n    // Inyección del módulo: la configuración del recurso\r\n    config: ResourceConfig<Mobile>,\r\n    // Inyecciones propias (Nest las resuelve al registrar la clase)\r\n    private readonly deviceSrv: DeviceRepositoryService,\r\n  ) {\r\n    super(config);\r\n  }\r\n\r\n  // Sobrescribir regla de negocio (todo en Observable)\r\n  override create(ctx: ActionContext<CreateMobileDto>): Observable<Mobile> {\r\n    return this.deviceSrv.findOne({ _id: oid(ctx.body.deviceId) }).pipe(\r\n      tap((device) => { if (device) ctx.body.simCardId = device.simCardId; }),\r\n      mergeMap(() => super.create(ctx)),\r\n    );\r\n  }\r\n\r\n  // Métodos nuevos reutilizando internals de la clase base\r\n  restore(ctx: ActionContext): Observable<RestoreResponseDto> {\r\n    return this.updateOne({ trashed: false }, ctx);\r\n  }\r\n}\r\n```\r\n\r\n```typescript\r\n// mobile.resource.ts\r\nResourceApiModule.register({\r\n  ...\r\n  setServices: MobileService,   // clase, no función\r\n})\r\n```\r\n\r\n> Extender `CommonApiService` garantiza que la clase reciba la `ResourceConfig` y los servicios internos (filtros, lookups, reportes, etc.) por DI, sin configuración manual.\r\n\r\n### `setController` — controlador personalizado\r\n\r\nEl módulo registra esta clase como controlador del recurso. Recibe el servicio (el común o el de `setServices`) por DI.\r\n\r\n```typescript\r\n// mobile.controller.ts\r\nimport { CommonApiController, ResourceAction } from '@deorta-dev/nestjs-resource-core';\r\n\r\n@Controller('mobiles')\r\nexport class MobileController extends CommonApiController<Mobile> {\r\n  constructor(service: MobileService) {   // el servicio de setServices (o el común)\r\n    super(service);\r\n  }\r\n\r\n  // Sobrescribir un endpoint base (hereda la doc de scalar.decorators.get\r\n  // y dtos.output.get; solo se completa lo que cambie)\r\n  @Get(':id')\r\n  @ResourceAction({\r\n    outputDto: MobileDetailResponseDto,\r\n    summary: 'Obtener móvil con detalle',\r\n  })\r\n  override get(\r\n    @Param() params: Record<string, string>,\r\n    @AuthResourceApi() auth: AuthResourceApiContext,\r\n  ) {\r\n    return this.service.getWithDetail({ params, auth });\r\n  }\r\n\r\n  // Agregar endpoints propios: CommonApiController ya tiene la config\r\n  // genérica (scalar + dtos), aquí se COMPLETA la doc del endpoint\r\n  @Get(':id/history')\r\n  @ResourceAction({\r\n    permission: 'mobile:history',\r\n    outputDto: HistoryDto,\r\n    summary: 'Historial del móvil',\r\n    description: 'Devuelve el historial de cambios del móvil.',\r\n    responses: {\r\n      200: { description: 'Historial obtenido' },\r\n      404: { description: 'Móvil no encontrado' },\r\n    },\r\n  })\r\n  history(\r\n    @Param() params: Record<string, string>,\r\n    @AuthResourceApi() auth: AuthResourceApiContext,\r\n  ) {\r\n    return this.service.getHistory({ params, auth });\r\n  }\r\n}\r\n```\r\n\r\n```typescript\r\nResourceApiModule.register({\r\n  ...\r\n  setServices: MobileService,\r\n  setController: MobileController,\r\n})\r\n```\r\n\r\n> Si `setController` sobrescribe métodos, usa `override` para que TypeScript verifique la firma contra el método base.\r\n\r\n### `addController` — acciones adicionales\r\n\r\nNo extiende la clase común: es una clase independiente que **implementa la interfaz** `ICustomActionController<T>` y define sus endpoints con decoradores Nest. Recibe el servicio (común o personalizado) por DI.\r\n\r\n```typescript\r\n// mobile.actions.controller.ts\r\nimport { ICustomActionController, ResourceAction, AuthResourceApi } from '@deorta-dev/nestjs-resource-core';\r\n\r\n@Controller('mobiles')\r\nexport class MobileActionsController implements ICustomActionController<Mobile> {\r\n  constructor(\r\n    public readonly service: MobileService,      // servicio común/personalizado\r\n    private readonly auditSrv: AuditService,     // inyecciones propias\r\n  ) {}\r\n\r\n  @Post('restore')\r\n  @ResourceAction({\r\n    permission: 'mobile:restore',\r\n    inputDto: RestoreMobileDto,\r\n    outputDto: RestoreResponseDto,\r\n    summary: 'Restaurar móvil',\r\n    description: 'Re-activa un móvil que fue marcado como eliminado.',\r\n    responses: {\r\n      200: { description: 'Móvil restaurado' },\r\n      404: { description: 'Móvil no encontrado' },\r\n    },\r\n  })\r\n  restore(\r\n    @Param() params: Record<string, string>,\r\n    @Body() body: RestoreMobileDto,\r\n    @AuthResourceApi() auth: AuthResourceApiContext,\r\n  ) {\r\n    return this.service.restore({ params, body, auth });\r\n  }\r\n\r\n  @Post('bulk/delete')\r\n  @ResourceAction({\r\n    permission: 'mobile:bulkDelete',\r\n    inputDto: BulkDeleteDto,\r\n    summary: 'Eliminación masiva',\r\n  })\r\n  deleteBulk(\r\n    @Query() query: Record<string, string>,\r\n    @AuthResourceApi() auth: AuthResourceApiContext,\r\n  ) {\r\n    return this.service.deleteBulk({ query, auth });\r\n  }\r\n\r\n  @Get('stats/:period')\r\n  @ResourceAction({\r\n    permission: 'mobile:stats',\r\n    outputDto: StatsResponseDto,\r\n    summary: 'Estadísticas por período',\r\n  })\r\n  stats(\r\n    @Param() params: Record<string, string>,\r\n    @AuthResourceApi() auth: AuthResourceApiContext,\r\n  ) {\r\n    return this.service.generateStats({ params, auth });\r\n  }\r\n}\r\n```\r\n\r\n```typescript\r\nResourceApiModule.register({\r\n  ...\r\n  setServices: MobileService,\r\n  addController: [MobileActionsController],   // clase o array de clases\r\n})\r\n```\r\n\r\n### Interfaz `ICusto","readmeFilename":"README.md"}