# Plan de deuda técnica — Lote 6

> Fecha: 2026-06-12  
> Criterio: ROI = Gravedad / Coste · Beneficio. Orden: ALTA×BAJO > MEDIA×BAJO > ALTA×MEDIO > MEDIA×MEDIO > BAJA×BAJO

---

## Resumen ejecutivo

10 items implementados y resueltos en una sesión. Todos verificados con `lint:container` (OK).

| # | ID | Gravedad | Coste | Descripción | ROI |
|---|-----|----------|-------|-------------|-----|
| 1 | DT-040 | BAJA | BAJO | `mb_strlen()` en PerfilController | BAJO |
| 2 | DT-041 | BAJA | BAJO | `true` (bool) en NotificacionRepository UPDATE | BAJO |
| 3 | DT-023 | BAJA | BAJO | `findOneBy()` en GrupoMiembroRepository | BAJO |
| 4 | DT-024 | BAJA | BAJO | Dimensiones máximas en Assert\Image | BAJO |
| 5 | DT-019 | MEDIA | MEDIO | Simplificar `generarCodigoUnico()` | MEDIO |
| 6 | DT-014 | MEDIA | MEDIO | LIKE en JSON con comillas para ROLE_ADMIN | MEDIO |
| 7 | DT-033 | ALTA | MEDIO | Extraer lógica Telegram a `TelegramService::conectarGrupo()` | ALTO |
| 8 | DT-046 | MEDIA | MEDIO | `NuevoEventoDto` + `data_class` en EventoFormType | MEDIO |
| 9 | DT-015 | ALTA | MEDIO | Rate limiter para password reset por IP | ALTO |
| 10 | DT-047 | MEDIA | MEDIO | `CambiarPasswordFormType` con constraints | MEDIO |

---

## Detalle de implementación

### DT-040 — `mb_strlen()` en PerfilController

**Archivo**: `src/Controller/PerfilController.php:76`

Cambio trivial. `strlen()` cuenta bytes, no caracteres. Una contraseña con ñ/é/€ contaría más bytes que caracteres.

```diff
- if (strlen($passwordNuevo) < 12) {
+ if (mb_strlen($passwordNuevo) < 12) {
```

---

### DT-041 — `true` bool en NotificacionRepository

**Archivo**: `src/Repository/NotificacionRepository.php:58`

DQL `->set('n.leida', 'true')` pasa una string que Doctrine puede interpretar como expresión DQL (el literal `TRUE` de SQL). Cambiar a booleano PHP hace explícito el tipo.

```diff
- ->set('n.leida', 'true')
+ ->set('n.leida', true)
```

---

### DT-023 — `findOneBy()` en GrupoMiembroRepository

**Archivo**: `src/Repository/GrupoMiembroRepository.php:26`

`find()` busca por clave primaria. `GrupoMiembro` tiene PK compuesta `(grupo, usuario)`, por lo que `find(['grupo' => ..., 'usuario' => ...])` funciona en la práctica, pero `findOneBy()` es la API semánticamente correcta para buscar por criterios.

```diff
- return $this->find(['grupo' => $grupo, 'usuario' => $usuario]);
+ return $this->findOneBy(['grupo' => $grupo, 'usuario' => $usuario]);
```

---

### DT-024 — Dimensiones máximas en Assert\Image

**Archivo**: `src/Form/GrupoFormType.php:39`

Sin límite de dimensiones, un usuario podría subir una imagen 20000×20000 px que pese < 2 MB (PNG muy comprimida), consumiendo memoria excesiva al procesarla.

```diff
- new Assert\Image(maxSize: '2M', mimeTypes: [...]),
+ new Assert\Image(maxSize: '2M', maxWidth: 5000, maxHeight: 5000, mimeTypes: [...]),
```

---

### DT-019 — `generarCodigoUnico()` simplificado

**Archivo**: `src/Service/GrupoService.php:228-244`

Dos problemas en el original:
1. 30 intentos → 30 queries a BD. Con espacio de 32^8 ≈ 10^12 posibilidades y asumiendo 100K grupos, la probabilidad de colisión en el primer intento es ~10^-7. 3 intentos son más que suficientes.
2. El fallback `bin2hex(random_bytes(4))` produce solo 16^8 ≈ 4.3B posibilidades y **no comprueba unicidad**. Silenciosamente devolvía un código potencialmente duplicado.

Ahora: 3 intentos → excepción explícita (probabilidad de llegar aquí: ~10^-21).

```php
private function generarCodigoUnico(): string
{
    $chars = self::CHARSET;
    $len   = strlen($chars);

    for ($i = 0; $i < 3; $i++) {
        $codigo = '';
        for ($j = 0; $j < 8; $j++) {
            $codigo .= $chars[random_int(0, $len - 1)];
        }
        if ($this->grupoRepository->findByCodigoAcceso($codigo) === null) {
            return $codigo;
        }
    }

    throw new \RuntimeException('No se pudo generar un código de grupo único tras varios intentos.');
}
```

---

### DT-014 — LIKE con comillas en JSON de roles

**Archivo**: `src/Repository/UsuarioRepository.php` (2 ocurrencias)

`roles` se almacena como `["ROLE_USER","ROLE_ADMIN"]`. El patrón `%ROLE_ADMIN%` coincidiría también con `ROLE_ADMIN_SUPER` si existiera. Con comillas, `%"ROLE_ADMIN"%` coincide exactamente con el valor serializado por Symfony.

```diff
- ->setParameter('rol', '%ROLE_ADMIN%');
+ ->setParameter('rol', '%"ROLE_ADMIN"%');
```

---

### DT-033 — `TelegramService::conectarGrupo()`

**Archivos**: `src/Service/TelegramService.php`, `src/Controller/GrupoController.php`

La lógica de conectar un grupo de Telegram (validar bot, validar chat, persistir `TelegramGrupo`) vivía íntegramente en `GrupoController::telegramConfig()`. Violaba "Fat Model, Thin Controller".

**TelegramService**: añadidos `TelegramGrupoRepository` y `EntityManagerInterface` al constructor. Nuevo método:

```php
public function conectarGrupo(Grupo $grupo, string $nombre, string $chatId, int $botId): array
{
    $bot = $this->findActiveBot($botId);
    if ($bot === null) {
        return ['ok' => false, 'error' => 'Bot no encontrado o inactivo.'];
    }

    $validacion = $this->validarChat($chatId, $bot);
    if (!$validacion['ok']) {
        return $validacion;
    }

    $telegramGrupo = $this->telegramGrupoRepository->findByGrupo($grupo) ?? new TelegramGrupo();
    // ...persist y flush...
    return ['ok' => true, 'nombreChat' => $validacion['nombreChat']];
}
```

**GrupoController**: bloque POST de 30 líneas reducido a:

```php
return new JsonResponse($telegramService->conectarGrupo($grupo, $nombre, $chatId, $botId));
```

Eliminado `EntityManagerInterface $em` del parámetro de `telegramConfig()` y el import `use App\Entity\TelegramGrupo`.

---

### DT-046 — `NuevoEventoDto` + `data_class` en EventoFormType

**Archivos**: `src/Form/Dto/NuevoEventoDto.php` (nuevo), `src/Form/EventoFormType.php`, `src/Controller/EventoController.php`

`EventoFormType` tenía `data_class: null`, lo que forzaba a acceder a los datos como array no tipado (`$form->getData()['titulo']`). Ahora:

**NuevoEventoDto**:
```php
class NuevoEventoDto
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 200)]
    public string $titulo = '';

    public ?string $descripcion = null;
}
```

**EventoFormType**: `data_class: NuevoEventoDto::class`. Las constraints del campo `titulo` se eliminan del form (ya están en el DTO). El campo `fechas` sigue `mapped: false`.

**EventoController**:
```diff
- $data = $form->getData();
- $evento = $eventoService->crear($data['titulo'], $data['descripcion'] ?? null, ...
+ /** @var NuevoEventoDto $dto */
+ $dto = $form->getData();
+ $evento = $eventoService->crear($dto->titulo, $dto->descripcion, ...
```

---

### DT-015 — Rate limiter en password reset

**Archivos**: `config/packages/rate_limiter.yaml`, `src/Controller/SecurityController.php`

Sin rate limiting, el endpoint `/recuperar-password` podía recibir peticiones masivas por IP para abusar del servicio de email.

**rate_limiter.yaml**:
```yaml
password_reset_by_ip:
    policy: fixed_window
    limit: 5
    interval: '15 minutes'
```

**SecurityController**:
```php
$limiter = $passwordResetByIpLimiter->create($request->getClientIp() ?? 'unknown');
if (!$limiter->consume()->isAccepted()) {
    $this->addFlash('error', 'Demasiados intentos. Espera unos minutos antes de volver a intentarlo.');
    return $this->redirectToRoute('app_request_reset');
}
```

Se usa `fixed_window` (no `sliding_window`) para que el límite sea predecible desde la perspectiva del usuario.

---

### DT-047 — `CambiarPasswordFormType`

**Archivos**: `src/Form/CambiarPasswordFormType.php` (nuevo), `src/Controller/PerfilController.php`

La validación de longitud de contraseña vivía inline en el controlador. Ahora el FormType lleva la constraint:

```php
->add('password_nuevo', PasswordType::class, [
    'constraints' => [
        new Assert\NotBlank(),
        new Assert\Length(min: 12, minMessage: 'La contraseña debe tener al menos {{ limit }} caracteres.'),
    ],
])
```

`getBlockPrefix()` retorna `''` y `csrf_protection: false` para que los nombres de campo (`password_actual`, `password_nuevo`, `password_confirm`) sean compatibles con la plantilla HTML existente sin modificarla.

El controlador ahora usa `$form->isValid()` y extrae los errores del FormType para mostrarlos como flash messages. La check de igualdad de contraseñas y de `isPasswordValid()` permanece en el controlador (son validaciones que requieren el contexto del usuario autenticado).

---

## Resultado

- `php bin/console lint:container` → OK
- 10 items marcados RESUELTO en `cerebro/deuda/deuda-tecnica.md`
- Items pendientes restantes: 12
