# DynamicCrudService — Documentación

Servicio gRPC que expone operaciones CRUD sobre las 44 tablas de `cloud_services` sin necesidad de definir un RPC por entidad. El caller especifica el nombre de la tabla como string.

---

## Paquetes y dependencias

### `server/`

| Paquete | Versión | Función |
|---|---|---|
| `Grpc.Tools` | 2.80.0 | Compila el `.proto` y genera las clases C# de requests, responses y stubs |
| `Grpc.AspNetCore.Server.Reflection` | 2.76.0 | Introspección del servicio (compatible con grpcurl, Postman, etc.) |
| `EFCore.NamingConventions` | 10.0.1 | `UseSnakeCaseNamingConvention()` — mapeo automático de columnas DB |
| `MySql.EntityFrameworkCore` | 10.0.1 | Proveedor EF Core para MariaDB (`UseMySQL()`) |

> `google.protobuf.Struct` no requiere paquete adicional — viene incluido en `Grpc.Tools` vía `Google.Protobuf`.

### `ServicesDAO/` (librería compartida)

| Paquete | Versión | Función |
|---|---|---|
| `Microsoft.EntityFrameworkCore` | 10.0.6 | Base de `BaseRepository<T>` — `DbSet<T>`, `FindAsync`, `SaveChangesAsync` |
| `Microsoft.EntityFrameworkCore.Relational` | 10.0.6 | `EF.Functions.Like()` — operador `like` en los filtros dinámicos |
| `MySql.EntityFrameworkCore` | 10.0.1 | Proveedor MySQL para el contexto compartido |

### `tests/DynamicCrud.Tests/`

| Paquete | Versión | Función |
|---|---|---|
| `xunit` | 2.9.3 | Framework de tests de integración |
| `xunit.runner.visualstudio` | 2.8.2 | Runner para `dotnet test` y Visual Studio |
| `Microsoft.NET.Test.Sdk` | 17.13.0 | Requerido por xUnit para descubrimiento de tests |
| `Microsoft.Extensions.Configuration.Json` | 10.0.0 | Lee `appsettings.test.json` con la cadena de conexión a MariaDB |
| `EFCore.NamingConventions` | 10.0.1 | Mismo DbContext con snake_case en los tests |

---

## Convención de nombres

### Nombre de modelo (tabla)

Se resuelve con `ModelRegistry` usando comparación **case-insensitive**:

```
"plans", "Plans", "PLANS"  →  todos válidos
```

Lista completa de modelos válidos al final de este documento.

### Nombre de campo

Los filtros (`Search`, `Create`, `Update`, etc.) usan el **nombre de la propiedad C#**, NO el nombre de columna de la DB. La comparación es case-insensitive.

| DB column                  | Propiedad C#              | Lo que se manda          |
|----------------------------|---------------------------|--------------------------|
| `idregion`                 | `Idregion`                | `"idregion"` ✓           |
| `region`                   | `RegionName`              | `"regionName"` ✓         |
| `exposition_category_name` | `ExpositionCategoryName`  | `"expositionCategoryName"` ✓ |

> **Importante:** `"exposition_category_name"` ✗ (nombre de columna DB, no funciona).  
> Usar `GetFields` para descubrir los nombres correctos.

---

## Validación de campos

Todos los métodos validan los campos antes de ejecutar. Si se envía un campo que no existe en el modelo se retorna error inmediatamente sin tocar la base de datos.

| Qué se valida | Métodos |
|---|---|
| `columns` inválidas | GetAll, GetById, Search |
| `field` inválido en filtros | Search, Count, BulkUpdate, BulkDelete |
| `op` inválido en filtros | Search, Count, BulkUpdate, BulkDelete |
| `order_by.field` inválido | GetAll, Search |
| Campos inválidos en `data` | Create, Update, Upsert, BulkCreate, ExecuteBatch |

**Ejemplo de error:**
```json
{
  "success": false,
  "message": "Campos de filtro no válidos: pariatur",
  "status": "error"
}
```

---

## GetFields — Descubrir campos de un modelo

Antes de usar Search o Create en un modelo desconocido, llama `GetFields` para obtener los nombres de propiedad, tipos y columnas reales.

**Request:**
```json
{ "model": "region" }
```

**Response:**
```json
{
  "success": true,
  "message": "OK",
  "fields": [
    { "name": "Idregion",    "type": "Int32",  "nullable": false, "column": "idregion" },
    { "name": "RegionName",  "type": "String", "nullable": true,  "column": "region"   }
  ]
}
```

- `name` → lo que se usa en `field` de los filtros y en las claves de `data` en Create/Update
- `type` → tipo .NET: `String`, `Int32`, `Int64`, `Boolean`, `DateTime`, `Decimal`, etc.
- `nullable` → si acepta `null` (puede usarse con `isnull`/`isnotnull`)
- `column` → nombre real en la DB (solo referencia)

---

## GetAll

Retorna registros de una tabla con un tope máximo.

**Request:**
```json
{
  "model": "region",
  "limit": 100,
  "columns": ["Idregion", "RegionName"],
  "order_by": [{ "field": "RegionName", "direction": "asc" }]
}
```

- `limit`: máximo de registros a retornar. Default `1000` si se omite o es `0`.
- `columns`: opcional, filtra las propiedades que se incluyen en la respuesta.
- `order_by`: opcional, ver sección [ORDER BY](#order-by).

> ⚠️ Para tablas grandes (`logs`, `consumption_history`) usar siempre `Search` con paginación en lugar de `GetAll`.

---

## GetById

```json
{ "model": "plans", "id": "42" }
```

- `id` siempre se manda como string; el servidor lo convierte al tipo de la PK del modelo.

---

## Create

```json
{
  "model": "region",
  "data": { "regionName": "Occidente" }
}
```

- `data`: `google.protobuf.Struct` — objeto nativo del cliente gRPC, no un JSON string.
- Las claves son los nombres de propiedad C# (case-insensitive).
- Retorna `inserted_id` con el valor del auto-increment.
- Campos no enviados quedan en `null` o su default de DB.
- Si el modelo tiene `CreatedAt` o `UpdatedAt`, se poblan automáticamente.

---

## Update

```json
{
  "model": "region",
  "id": "5",
  "data": { "regionName": "Occidente Norte" }
}
```

- `data`: `google.protobuf.Struct` — objeto nativo, no un JSON string.
- Solo se actualizan los campos incluidos en `data`.
- Retorna `status: "info"` si no hubo cambios reales.
- Si el modelo tiene `UpdatedAt`, se actualiza automáticamente.

---

## Delete

```json
{ "model": "region", "id": "5" }
```

> No aplica a modelos con PK compuesta (`role_menu_access`, `role_service_access`, `user_role`). Para esos casos usar `BulkDelete` con filtros.

---

## Search

Método unificado para filtrar, buscar texto libre y paginar. Todas las opciones son independientes y combinables.

**Request completo:**
```json
{
  "model": "plans",
  "and": [
    { "field": "Active",  "op": "eq",      "value": "true" },
    { "field": "Price",   "op": "between", "value": "100", "value2": "500" }
  ],
  "or": [
    { "field": "PlanName", "op": "like", "value": "%cloud%" },
    { "field": "PlanName", "op": "like", "value": "%nube%"  }
  ],
  "search_term": "cloud",
  "page": 1,
  "page_size": 20,
  "order_by": [{ "field": "PlanName", "direction": "asc" }],
  "columns": ["PlanName", "Price"]
}
```

- `and`: condiciones combinadas con AND.
- `or`: condiciones combinadas con OR.
- `search_term`: busca en todos los campos `String` del modelo (OR automático entre ellos).
- `page`: si es `0` o se omite → retorna todos los resultados. Si es `> 0` → pagina y retorna `total`.
- `page_size`: tamaño de página. Default `10` si se omite.
- `order_by`: opcional, ver sección [ORDER BY](#order-by).
- `columns`: opcional, filtra propiedades en la respuesta.

**Cuando `page > 0` el response incluye:**
```json
{ "total": 150, "page": 1, "page_size": 20 }
```

### Operadores disponibles

| Op           | Alias         | SQL generado                        | Notas                              |
|--------------|---------------|-------------------------------------|------------------------------------|
| `eq`         | `=`           | `field = valor`                     | `value: ""` o `"null"` → `IS NULL` |
| `ne`         | `!=`, `<>`    | `field != valor`                    | `value: ""` o `"null"` → `IS NOT NULL` |
| `gt`         | `>`           | `field > valor`                     |                                    |
| `gte`        | `>=`          | `field >= valor`                    |                                    |
| `lt`         | `<`           | `field < valor`                     |                                    |
| `lte`        | `<=`          | `field <= valor`                    |                                    |
| `like`       |               | `field LIKE 'patron'`               | Solo campos `String`. Usar `%` explícitamente. |
| `in`         |               | `field IN (1,2,3)`                  | Valores separados por coma: `"1,2,3"` |
| `between`    |               | `field >= low AND field <= high`    | Requiere `value` y `value2`        |
| `isnull`     | `is null`     | `field IS NULL`                     | Solo campos nullable. `value` se ignora. |
| `isnotnull`  | `is not null` | `field IS NOT NULL`                 | Solo campos nullable. `value` se ignora. |

### Ejemplos de filtros

```json
// Registros activos
{ "and": [{ "field": "Active", "op": "eq", "value": "true" }] }

// Registros sin fecha de baja (campo nullable)
{ "and": [{ "field": "DeletedAt", "op": "isnull" }] }

// Registros por lista de IDs
{ "and": [{ "field": "Idregion", "op": "in", "value": "1,2,3" }] }

// Por lista de strings
{ "and": [{ "field": "RegionName", "op": "in", "value": "Norte,Sur,Oriente" }] }

// Precios entre 100 y 500
{ "and": [{ "field": "Price", "op": "between", "value": "100", "value2": "500" }] }

// Nombre contiene "cloud" o "nube"
{
  "or": [
    { "field": "Name", "op": "like", "value": "%cloud%" },
    { "field": "Name", "op": "like", "value": "%nube%"  }
  ]
}
```

---

## Count

Retorna el total de registros que cumplen los filtros sin traer datos. Reutiliza los mismos filtros que `Search`.

**Request:**
```json
{
  "model": "plans",
  "and": [{ "field": "Active", "op": "eq", "value": "true" }]
}
```

**Response:**
```json
{ "success": true, "message": "OK", "total": 42 }
```

---

## BulkCreate

Inserta N registros en una sola llamada gRPC y un solo `INSERT` al servidor.

**Request:**
```json
{
  "model": "region",
  "records": [
    { "regionName": "Norte" },
    { "regionName": "Sur" },
    { "regionName": "Oriente" }
  ]
}
```

**Response:**
```json
{
  "success": true,
  "message": "3 registros insertados correctamente.",
  "status": "success",
  "inserted": 3,
  "inserted_ids": ["10", "11", "12"]
}
```

- `records`: lista de `google.protobuf.Struct` — cada elemento es un objeto nativo, no un JSON string.
- Si algún registro tiene campos inválidos, retorna error indicando el índice del registro fallido.
- Si el modelo tiene `CreatedAt`/`UpdatedAt`, se poblan automáticamente en todos los registros.

---

## BulkUpdate

Actualiza los mismos campos en múltiples registros seleccionados por filtro.

**Request:**
```json
{
  "model": "plans",
  "and": [{ "field": "Idplan", "op": "in", "value": "10,11,12" }],
  "data": { "active": false }
}
```

**Response:**
```json
{
  "success": true,
  "message": "3 registros actualizados correctamente.",
  "status": "success",
  "affected": 3
}
```

- Soporta todos los operadores y combinaciones AND/OR de `Search`.
- Si el modelo tiene `UpdatedAt`, se actualiza automáticamente en todos los registros afectados.
- Retorna `affected: 0` si ningún registro cambió.

---

## BulkDelete

Elimina múltiples registros seleccionados por filtro.

**Request:**
```json
{
  "model": "region",
  "and": [{ "field": "Idregion", "op": "in", "value": "5,6,7" }]
}
```

**Response:**
```json
{
  "success": true,
  "message": "3 registros eliminados correctamente.",
  "status": "success",
  "affected": 3
}
```

- Soporta todos los operadores y combinaciones AND/OR de `Search`.

---

## Upsert

Inserta un registro si no existe, o lo actualiza si ya existe, basándose en los `conflict_fields`.

**Request:**
```json
{
  "model": "parameters",
  "data": { "key": "max_vms", "value": "50" },
  "conflict_fields": ["key"]
}
```

- `data`: `google.protobuf.Struct` — objeto nativo, no un JSON string.
- Si `key = "max_vms"` ya existe → actualiza `value`.
- Si no existe → inserta el registro y retorna `inserted_id`.
- `conflict_fields`: lista de nombres de propiedad C# que determinan si el registro ya existe.

**Response cuando inserta:**
```json
{ "success": true, "message": "Registro insertado correctamente (upsert).", "status": "success", "inserted_id": "15" }
```

**Response cuando actualiza:**
```json
{ "success": true, "message": "Registro actualizado correctamente (upsert).", "status": "success" }
```

**Response cuando no hay cambios:**
```json
{ "success": true, "message": "No se realizó ningún cambio.", "status": "info" }
```

---

## ExecuteBatch

Ejecuta N operaciones en una sola transacción de base de datos. Si cualquier operación falla, todas se revierten.

**Request:**
```json
{
  "operations": [
    {
      "type": "create",
      "model": "contracted_services",
      "data": { "orgId": 10, "serviceId": 3 }
    },
    {
      "type": "update",
      "model": "requests_queue",
      "id": "45",
      "data": { "status": "processed" }
    },
    {
      "type": "delete",
      "model": "region",
      "id": "99"
    },
    {
      "type": "upsert",
      "model": "parameters",
      "data": { "key": "max_vms", "value": "50" },
      "conflict_fields": ["key"]
    }
  ]
}
```

- `data`: `google.protobuf.Struct` en cada operación — objeto nativo, no un JSON string.
- `type`: `"create"` | `"update"` | `"delete"` | `"upsert"`
- Si alguna operación falla, el batch completo se revierte y se retorna qué operación falló.

**Response exitoso:**
```json
{
  "success": true,
  "message": "4 operaciones ejecutadas correctamente.",
  "status": "success",
  "results": [
    { "index": 0, "success": true, "message": "Registro insertado correctamente.", "inserted_id": "55" },
    { "index": 1, "success": true, "message": "Registro actualizado correctamente." },
    { "index": 2, "success": true, "message": "Registro eliminado correctamente." },
    { "index": 3, "success": true, "message": "Registro insertado correctamente (upsert).", "inserted_id": "22" }
  ]
}
```

**Response con fallo:**
```json
{
  "success": false,
  "message": "Operación 1 falló — batch revertido: Registro no encontrado.",
  "status": "error",
  "results": [
    { "index": 0, "success": true, ... },
    { "index": 1, "success": false, "message": "Registro no encontrado.", "status": "error" }
  ]
}
```

---

## ORDER BY

Disponible en `GetAll` y `Search`. Es opcional y acepta múltiples campos.

```json
{
  "order_by": [
    { "field": "RegionName", "direction": "asc" },
    { "field": "Idregion",   "direction": "desc" }
  ]
}
```

- `field`: nombre de propiedad C# (case-insensitive).
- `direction`: `"asc"` (default) | `"desc"`.
- Múltiples campos se aplican en orden (`ORDER BY RegionName ASC, Idregion DESC`).
- Si `field` no existe en el modelo, retorna error de validación.

---

## Auditoría automática

Si el modelo tiene las propiedades `CreatedAt` o `UpdatedAt` (cualquier capitalización), el servidor las pobla automáticamente:

| Propiedad  | Cuándo se setea |
|---|---|
| `CreatedAt` | Create, BulkCreate, Upsert (cuando inserta) |
| `UpdatedAt` | Create, Update, BulkCreate, BulkUpdate, Upsert |

El caller **no necesita** enviar estos campos en `data` — si los envía, se ignoran y el servidor los sobreescribe con `DateTime.UtcNow`.

---

## Respuesta homologada (CrudResponse)

Todos los RPCs de lectura/escritura retornan esta estructura:

```protobuf
message CrudResponse {
  bool                  success     = 1;
  string                message     = 2;
  google.protobuf.Value data        = 3;
  string                detail      = 4;
  string                status      = 5;
  string                inserted_id = 6;
  int32                 total       = 7;
  int32                 page        = 8;
  int32                 page_size   = 9;
}
```

| Campo        | Cuándo se popula                                          |
|--------------|-----------------------------------------------------------|
| `data`       | GetAll, GetById, Search — tipo `google.protobuf.Value`    |
| `inserted_id`| Create, Upsert (cuando inserta)                           |
| `total`      | Search cuando `page > 0`                                  |
| `page`       | Search cuando `page > 0`                                  |
| `page_size`  | Search cuando `page > 0`                                  |
| `detail`     | Cuando `success: false` — detalle técnico del error       |
| `status`     | `"success"` / `"info"` / `"error"`                        |

### Nota sobre el campo `data`

`data` es `google.protobuf.Value` (definido en `google/protobuf/struct.proto`), no un `string` JSON.

**Por qué Value y no string:**
- Los clientes no hacen `json.loads()` adicional — el runtime de protobuf entrega el objeto/array ya deserializado.
- Compatible con todos los lenguajes que consumen el servicio: Python, Go, PHP, C#.
- Maneja tanto objetos únicos (`GetById`) como arrays (`GetAll`, `Search`) en un solo campo tipado.

---

## Envío de `data` en requests por lenguaje

El campo `data` es `google.protobuf.Struct` — se construye con tipos nativos del lenguaje, sin `json.dumps()`.

### Python
```python
from google.protobuf.struct_pb2 import Struct

data = Struct()
data.update({"name": "Plan Pro", "status": 2, "active": True})

stub.Create(CreateRequest(model="plans", data=data))

# BulkCreate — lista de Structs
records = []
for item in [{"name": "A"}, {"name": "B"}]:
    s = Struct()
    s.update(item)
    records.append(s)
stub.BulkCreate(BulkCreateRequest(model="plans", records=records))
```

### Go
```go
import "google.golang.org/protobuf/types/known/structpb"

data, _ := structpb.NewStruct(map[string]any{
    "name":   "Plan Pro",
    "status": 2,
    "active": true,
})
client.Create(ctx, &pb.CreateRequest{Model: "plans", Data: data})

// BulkCreate
var records []*structpb.Struct
for _, item := range []map[string]any{{"name": "A"}, {"name": "B"}} {
    s, _ := structpb.NewStruct(item)
    records = append(records, s)
}
client.BulkCreate(ctx, &pb.BulkCreateRequest{Model: "plans", Records: records})
```

### PHP
```php
use Google\Protobuf\Struct;
use Google\Protobuf\Value;

$data = new Struct();
$fields = $data->getFields();
$fields["name"]   = (new Value())->setStringValue("Plan Pro");
$fields["status"] = (new Value())->setNumberValue(2);

$request = new CreateRequest();
$request->setModel("plans");
$request->setData($data);
[$resp] = $client->Create($request)->wait();
```

### C#
```csharp
using Google.Protobuf.WellKnownTypes;

var data = new Struct();
data.Fields["name"]   = Value.ForString("Plan Pro");
data.Fields["status"] = Value.ForNumber(2);
data.Fields["active"] = Value.ForBool(true);

await client.CreateAsync(new CreateRequest { Model = "plans", Data = data });
```

---

## Consumo de `data` por lenguaje

### Python (grpcio + google.protobuf)

```python
from google.protobuf import json_format

response = stub.GetAll(GetAllRequest(model="plans", limit=10))

# Opción 1 — convertir a dict/list Python
data = json_format.MessageToDict(response.data)
# data es list[dict] para GetAll, dict para GetById

# Opción 2 — acceder directamente
for item in response.data.list_value.values:
    print(item.struct_value)  # dict-like
```

### Go (google.golang.org/protobuf)

```go
resp, _ := client.GetAll(ctx, &pb.GetAllRequest{Model: "plans", Limit: 10})

var result interface{}
b, _ := resp.Data.MarshalJSON()
json.Unmarshal(b, &result)
```

### PHP (google/protobuf)

```php
$resp = $client->GetAll(new GetAllRequest(['model' => 'plans', 'limit' => 10]));

$json = $resp->getData()->serializeToJsonString();
$data = json_decode($json, true);
```

### C# (Google.Protobuf)

```csharp
var resp = await client.GetAllAsync(new GetAllRequest { Model = "plans", Limit = 10 });

// Opción 1 — serializar a JSON string
string json = resp.Data.ToString();

// Opción 2 — deserializar a tipo fuerte
var items = JsonSerializer.Deserialize<List<Plan>>(json);
```

---

## Modelos disponibles

```
backup_operations, backup_plans, backup_restore, backup_schedule,
backup_tracking, cloudian_bucket_policy, cloudian_endpoint,
cloudian_tenant, cloudian_tenant_bucket, cloudian_tenant_group,
cloudian_tenant_provision,
cluster, consumption_history, contracted_services, container_images,
exp_category, exp_type_catalog, exp_type_value, firewall, licenses,
logs, menu_component, models, org_vcd, parameters,
periodicity, plans, plans_UpdateHistory, plans_historical, price_resources,
product_type, profile_permissions, profiles, region, requests_queue,
role, role_menu_access, role_service_access,
service_connection_info, service_endpoints, services, services_data,
status_catalog, status_type, terms_conditions, user_role, user_vcd,
users_profiles, vdc_vcd
```

> `view_user_roles_access` es una vista SQL — disponible internamente vía EF Core pero NO expuesta en el CRUD dinámico.

---

## Limitaciones conocidas

| Limitación | Descripción |
|---|---|
| PKs compuestas | `Delete` y `Update` por `id` no funcionan en `role_menu_access`, `role_service_access`, `user_role`. Usar `BulkDelete` / `BulkUpdate` con filtros. |
| `GetAll` sin `limit` | Si `limit` es `0` el servidor aplica el cap de `1000`. Para más registros usar `Search` con paginación. |
| Operador `like` en no-strings | Retorna error de validación si el campo no es `String`. |
| `isnull`/`isnotnull` en `NOT NULL` | Se ignoran silenciosamente en campos que no aceptan null. |
| `Upsert` no es atómico a nivel SQL | Usa find-then-insert/update, no `INSERT ON DUPLICATE KEY UPDATE`. Usar `ExecuteBatch` si se necesita atomicidad. |
| `in` con valores que contienen coma | No soportado — usar múltiples condiciones `or` con `eq`. |

---

## Cobertura de tests

| Clase | Casos | Qué valida |
|---|---|---|
| `ReadOperationsTests` | 44 × `GetAll` | Mapeo EF correcto, límite respetado |
| `ReadOperationsTests` | 44 × `Search` (paginado) | Query paginada sin errores |
| `ReadOperationsTests` | 44 × `Search` (sin filtros) | Search sin filtros no explota |
| `WriteOperationsTests` | 4 × ciclo completo | Create→GetById→Update→Delete→Verify en modelos sin FK |

**No cubierto por tests actuales:** operadores de Search con datos reales, Count, BulkCreate, BulkUpdate, BulkDelete, Upsert, ExecuteBatch, validación de campos, capa gRPC (proto serialization).

---

## Integrar un nuevo modelo al CRUD dinámico

Para que una nueva tabla quede disponible en los 12 métodos del `DynamicCrudService` sin modificar ningún `.proto` ni ningún servicio gRPC, se siguen **4 pasos**:

### Paso 1 — Crear la clase del modelo en `ServicesDAO/models/`

La convención del proyecto es PascalCase en C#. `UseSnakeCaseNamingConvention()` mapea automáticamente al snake_case de la base de datos.

```csharp
// ServicesDAO/models/StorageQuota.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ServicesDAO.models
{
    [Table("storage_quota")]
    public class StorageQuota
    {
        [Key]
        public int Id { get; set; }
        public int OrgId { get; set; }
        public long QuotaGb { get; set; }
        public bool Active { get; set; }
        public DateTime? CreatedAt { get; set; }
        public DateTime? UpdatedAt { get; set; }
    }
}
```

> `CreatedAt` y `UpdatedAt` son opcionales. Si existen, el servidor los pobla automáticamente en Create y Update (ver sección [Auditoría automática](#auditoría-automática)).

### Paso 2 — Registrar el `DbSet<T>` en el DbContext

En `ServicesDAO/Data/CloudServicesDbContext.cs`, sección "Tablas cloud_services (CRUD dinámico)":

```csharp
public DbSet<StorageQuota> StorageQuotas { get; set; }
```

Si la tabla tiene **llave compuesta**, agregar también la configuración en `OnModelCreating`:

```csharp
modelBuilder.Entity<StorageQuota>()
    .HasKey(sq => new { sq.OrgId, sq.ServiceId });
```

> Los modelos con PK compuesta no soportan `Delete` ni `Update` por `id`. Usar `BulkDelete`/`BulkUpdate` con filtros (ver [Limitaciones conocidas](#limitaciones-conocidas)).

### Paso 3 — Agregar la entrada en `ModelRegistry`

En `ServicesDAO/Repositories/ModelRegistry.cs`:

```csharp
{ "storage_quota", typeof(StorageQuota) },
```

El string de la clave es lo que el cliente enviará como `model` en todas las llamadas. La comparación es case-insensitive: `"storage_quota"`, `"Storage_Quota"` y `"STORAGE_QUOTA"` son equivalentes.

### Paso 4 — Verificar con `GetFields`

Sin modificar nada más, el modelo ya está disponible. Verificar:

```json
// Request
{ "model": "storage_quota" }

// Response esperado
{
  "success": true,
  "fields": [
    { "name": "Id",        "type": "Int32",    "nullable": false, "column": "id" },
    { "name": "OrgId",     "type": "Int32",    "nullable": false, "column": "org_id" },
    { "name": "QuotaGb",   "type": "Int64",    "nullable": false, "column": "quota_gb" },
    { "name": "Active",    "type": "Boolean",  "nullable": false, "column": "active" },
    { "name": "CreatedAt", "type": "DateTime", "nullable": true,  "column": "created_at" },
    { "name": "UpdatedAt", "type": "DateTime", "nullable": true,  "column": "updated_at" }
  ]
}
```

Si responde `"Modelo no encontrado"`, verificar la entrada en `ModelRegistry` y que `dotnet build` compiló sin errores.
