# Incidente — Content Import rompe `social_media` (cascada de bugs)

> Postmortem del bug detectado el 2026-05-12 en `marianocalabro.bewpro.com` tras usar `/admin/content-import` en modo `replace`.
>
> Severidad: 🔴 Producción — sitio público caído (`/about`, `/contact` con HTTP 500) + admin panel inutilizable (`/site-data?tab=welcome`).
>
> Estado: ✅ Resuelto · 7 commits · 4 capas de protección + 1 fix de causa raíz.

---

## TL;DR

Un import del JSON template del producto con `replace` mode dejó el shape del `site.social_media` corrupto en el tenant, rompiendo las vistas públicas y el admin. La cadena de bugs tenía **5 capas**, todas relacionadas con el mismo problema raíz: **el shape canónico nested se rompía al rearmar el config desde la DB**.

Resuelto con:
- 1 fix en la causa raíz (`SiteConfigService::buildNestedArray`)
- 1 fix preventivo en el origen (`CoreTemplateBuilder` emite shapes canónicos completos)
- 3 fixes de defensa en profundidad (controllers, blades, component class)
- 1 comando de reparación (`bewpro:fix-social-media`)

---

## Línea de tiempo

| Hora ART | Evento |
|----------|--------|
| 18:20 | Coke importa JSON template del producto `personal-brand` en marianocalabro vía `/admin/content-import` con modo `replace` |
| 18:30 | `/contact` y `/about` empiezan a tirar HTTP 500 (`Cannot access offset of type string on string` en `demo-accounting-2/about.blade.php:234`) |
| 18:33 | Reportado: blade espera `$social['active']` pero recibe string |
| 18:42 | Fix 1 commiteado (`b6a31981`): CoreTemplateBuilder emite shape canónico social_media |
| 18:46 | Sigue rompiendo `/contact` — falta defensive en blades |
| 18:48 | Fix 2 (`2b521141`): defensive en 5 blades + comando `bewpro:fix-social-media` |
| 18:52 | Admin `/site-data?tab=welcome` rompe con el mismo error |
| 18:53 | Fix 3 (`ea48c3ee`): defensive + auto-cura en `SiteDataController` |
| 18:55 | Admin sigue roto: `Undefined variable $view` en `schema-admin-form.blade.php` |
| 18:58 | Fix 4 (`bfc6d012`): rename prop `$view` → `$viewName` (collision con Laravel internal) |
| 19:02 | Causa raíz identificada: `SiteConfigService::buildNestedArray` pisa shape nested |
| 19:05 | Fix 5 (`df99508b`): pre-filter en buildNestedArray + shapes canónicos completos en CoreTemplateBuilder |
| 19:15 | Coke aplica `bewpro:fix-social-media` en marianocalabro → sitio recuperado ✅ |

---

## Causa raíz

### El bug en `SiteConfigService::buildNestedArray()`

El service persiste cada key del config en una row separada de la tabla `settings` con dot-notation:

```
key                                    | value
---------------------------------------+----------------
site.social_media.linkedin             | ""             ← row "parent" legacy
site.social_media.linkedin.url         | ""             ← row "children"
site.social_media.linkedin.icon        | fab fa-linkedin-in
site.social_media.linkedin.title       | LinkedIn
site.social_media.linkedin.active      | 1
```

Cuando `buildNestedArray()` itera estas rows (en orden indeterminado de MySQL), si la **parent** se procesa DESPUÉS de las children, hace:

```php
$current = $value;  // $current apunta a $nested[social_media][linkedin]
                    // que ya es array {url, icon, title, active}
                    // PERO $value es '' → pisa el array con string vacío
```

Resultado: `config('site.social_media.linkedin')` retorna `''` (string) en lugar del array nested. Cualquier blade que itere `$social['active']` crashea con `Cannot access offset of type string on string`.

### ¿De dónde venían las rows "parent" legacy?

Imports viejos del CoreTemplateBuilder cuando emitía:

```php
'social_media' => [
    'linkedin' => '',  // ← string, no array
    'facebook' => '',
    ...
]
```

Al hacer `setMany()`, el flattener generaba `site.social_media.linkedin = ''` como row.

Cuando un import nuevo escribía además las versiones children (`site.social_media.linkedin.url = '...'`), **ambas filas convivían** y el bug se manifestaba al re-leer.

---

## Las 5 capas del fix

### Capa 1 — Origen: `CoreTemplateBuilder` emite shape canónico completo

**Antes**:
```php
$out['og']           = ['title' => ..., 'description' => ..., 'image_alt' => ...];  // 3 keys parciales
$out['twitter']      = [..., 'handle' => ''];   // 'handle' no existe en blades — inventado
$out['social_media'] = ['instagram' => '', ...];  // strings vacíos — shape roto
$out['assets']       = ['main_logo_alt' => ..., 'main_logo_height' => 40];  // 2 keys
```

**Después**:
```php
// Lee shape canónico de config/site.php (lo mismo que blades + admin consumen)
$out['og']           = array_merge(config('site.og', []), [overrides desde core]);    // 13 keys
$out['twitter']      = array_merge(config('site.twitter', []), [overrides]);          // 9 keys
$out['social_media'] = config('site.social_media', []);                               // 7 redes nested
$out['assets']       = array_merge(config('site.assets', []), [overrides]);           // 13 keys
```

**Beneficio**: futuros imports NUNCA van a tener shape parcial. Si en el futuro se agregan campos al config (e.g. nueva red social), el template los exporta automáticamente.

### Capa 2 — Causa raíz: `SiteConfigService::buildNestedArray()` filtra parents legacy

```php
// Pre-filter: descartar keys que son prefix de otras keys
foreach ($flatArray as $key => $value) {
    $isPrefixOfOther = false;
    foreach ($allKeys as $other) {
        if ($other !== $key && str_starts_with($other, $key . '.')) {
            $isPrefixOfOther = true;
            break;
        }
    }
    if (!$isPrefixOfOther) $filtered[$key] = $value;
}
```

**Beneficio**: si tenemos `site.X` y `site.X.Y`, solo procesamos `site.X.Y`. El shape nested SIEMPRE queda íntegro independiente del orden de la DB.

### Capa 3 — Admin controller defensive + auto-cura

`SiteDataController::index()` ahora normaliza el shape de `social_media` antes de pasarlo a la vista. Si encuentra una red con shape malo (string en lugar de array), la convierte a `['url' => ..., 'active' => false]` en memoria. Si después el admin guarda cualquier cambio, el shape correcto se persiste a la DB.

**Beneficio**: el admin nunca va a caer + auto-cura silenciosa al primer load.

### Capa 4 — Blades defensive en 5 demos

```blade
{{-- Antes --}}
@if($social['active'])  // crash si $social es string

{{-- Ahora --}}
@if(is_array($social) && ($social['active'] ?? false))
```

Aplicado en:
- `demo-accounting-2/about.blade.php`
- `demo-accounting-2/contact.blade.php`
- `demo-marketing-1/contact.blade.php`
- `demo-photography-3/contact.blade.php`
- `demo-transportation-logistic/contact.blade.php`

**Beneficio**: si por cualquier razón el shape vuelve a romperse, las redes inválidas se ignoran silenciosamente — el sitio público no cae.

### Capa 5 — Comando `bewpro:fix-social-media` para tenants ya rotos

```bash
php artisan bewpro:fix-social-media [--dry-run]
```

Lo que hace:
1. Detecta y elimina rows nested legacy (`site.social_media.linkedin`, `site.social_media.facebook.url`, etc.)
2. Escribe `site.social_media` con shape canónico como JSON único
3. Invalida cache de SiteConfigService

**Beneficio**: limpia tenants ya provisionados con shape roto sin requerir re-import.

### Capa 6 (bonus) — Fix collision `$view` ↔ Laravel internal

Encontrado durante el incidente: el component `<x-schema-admin-form>` declaraba `public string $view` que chocaba con la variable interna que Laravel inyecta al renderizar Blade components. Resultado: `Undefined variable $view` en el template.

Fix: rename prop `$view` → `$viewName` en el class + template + 3 call sites.

**Beneficio**: lección para futuras components — nunca usar `$view` como property name.

---

## Validación post-fix

### Los 9 productos market-ready

```
🟢 LOS 9 TEMPLATES EMITEN SHAPE CANÓNICO COMPLETO
  ✅ foundations-ong       — sm:7 og:13 tw:9 assets:13
  ✅ creative-video-editor — sm:7 og:13 tw:9 assets:13
  ✅ corporative           — sm:7 og:13 tw:9 assets:13
  ✅ construction          — sm:7 og:13 tw:9 assets:13
  ✅ art-design            — sm:7 og:13 tw:9 assets:13
  ✅ law-firm-digital      — sm:7 og:13 tw:9 assets:13
  ✅ real-estate           — sm:7 og:13 tw:9 assets:13
  ✅ restaurant-bar        — sm:7 og:13 tw:9 assets:13
  ✅ personal-brand        — sm:7 og:13 tw:9 assets:13
```

### Tenant rehabilitado

`marianocalabro.bewpro.com` después de `bewpro:fix-social-media`:
- `/about` → HTTP 200 ✅
- `/contact` → HTTP 200 ✅
- `/site-data?tab=welcome` → admin carga ✅
- `/admin/content-import` → 422 ya no se produce (validation rule también arreglada en commit `da0c45de`)

---

## Política — Tenants viejos vs nuevos

**Decisión del CEO (2026-05-12)**: los tenants viejos (provisionados pre-fix) **quedan como están**. No se aplica `bewpro:fix-social-media` masivamente. Si algún tenant viejo presenta síntomas → ahí sí se aplica el comando ad-hoc.

**Por qué**: los tenants viejos están en producción con clientes pagando. Tocarlos para "limpiar shape" puede romper customizaciones manuales del cliente o pisar datos que ya editaron. El defensive en blades + el SiteDataController con auto-cura on-the-fly **ya los protege de caer**, aunque el shape DB siga "feo".

**Los tenants nuevos** se provisionan con clone fresco del codebase → heredan los 5 fixes integrados → no pueden tener el bug.

### Cuándo usar `bewpro:fix-social-media` ad-hoc

Solo si un tenant específico reporta:
- Vista pública con HTTP 500 mencionando "offset of type string on string"
- Admin `/site-data` que no carga

```bash
ssh vps1-claude
cd /home/{cpanel_user}/web/{tenant}.bewpro.com/public_html/git-files/{cpanel_user}

# 1. Preview (no toca nada)
php artisan bewpro:fix-social-media --dry-run

# 2. Aplicar el fix solo si el preview muestra rows nested legacy
php artisan bewpro:fix-social-media

# 3. Verificar
php artisan tinker --execute='print_r(config("site.social_media"));'
```

---

## Lecciones aprendidas

### 1. Source of truth único para shapes complejos

**Lección**: cuando una pieza de config tiene shape nested (`og`, `twitter`, `assets`, `social_media`), **definirla en un solo lugar** y leerla desde ahí en todas las capas (template builder, blades, admin form, schema validator).

**Acción tomada**: `CoreTemplateBuilder::extractSiteTopLevelFromCore()` ahora usa `config('site.X')` como base canónica.

**Pendiente**: considerar mover los shapes complejos a `database/schemas/site-shape.json` y validarlos en CI.

### 2. Defensive layers en boundaries

**Lección**: cualquier dato que cruza un boundary (DB → memoria, JSON → DB, admin form → DB) puede llegar con shape inesperado. **El consumer no puede confiar en que el shape sea correcto**.

**Acción tomada**: defensive en blades (`is_array`, `?? null`), defensive en controllers (normalizar antes de view), defensive en SiteConfigService (pre-filter parents).

**Pendiente**: aplicar patrón similar a otros bloques (`header.cta_button`, `seo.geo`, etc.) cuando se detecten incidentes.

### 3. Tests E2E del Template → Import → Blade pipeline

**Lección**: la causa raíz vivía en la interacción entre 3 componentes (CoreTemplateBuilder + ContentImportService + SiteConfigService). Ningún test unitario habría capturado el bug porque cada componente funciona "bien" en aislamiento.

**Acción tomada**: ninguna (deuda técnica).

**Pendiente**: Feature test en `tests/Feature/ContentImportE2ETest.php` que:
- Genere JSON template con `CoreTemplateBuilder` para los 9 cores
- Lo importe vía `ContentImportService` en modo `replace`
- Renderee las vistas públicas + admin de cada demo
- Asserte HTTP 200

Esto habría capturado el bug antes de prod.

### 4. Naming reservado en Laravel components

**Lección**: `$view` choca con variable interna de Laravel. Por extensión, posiblemente también `$component`, `$slot`, `$attributes`, `$__env`.

**Acción tomada**: rename a `$viewName` con doc explicativa.

**Pendiente**: lint rule (PHP-CS-Fixer custom?) que detecte properties de Component classes con nombres reservados.

---

## Archivos modificados (cronológico)

```
b6a31981 fix(template-builder): social_media debe emitir array nested
2b521141 fix(blades+cmd): defensive social_media + comando bewpro:fix-social-media
ea48c3ee fix(site-data): defensive en SiteDataController + auto-cura on-the-fly
bfc6d012 fix(component): renombrar prop $view → $viewName en SchemaAdminForm
df99508b fix(config): SiteConfigService rompe shape nested + shapes canónicos completos
```

## Referencias

- [Fórmula de productos](../formula-de-productos.md) — las 5 capas del sistema (esto fue un drift entre capa 1 y capa 4)
- [Content import alignment](content-import-alignment.md) — el feature que destapó el bug
- [Validación 9 productos](validacion-9-productos-publicados.md)
