# Bulk Provisioning — La máquina de mandar misiles

> Provisionado masivo de tenants desde un CSV + (opcional) carpeta de JSONs.
>
> Comando: `bewpro:bulk-provision`
> Servicio: `App\Services\BulkProvisioningService`
> Creado: 2026-05-13

---

## Para qué

Hasta hoy, provisionar un tenant requería **una operación por cliente**:

- O bien checkout de Stripe (cliente pagante) → cron Airtable
- O bien `/reseller/projects/new` (reseller con su panel) → form web → cron Airtable
- O bien `php artisan bewpro:new` (manual, dev) → directo a Airtable + DB

Cuando hay que dar de alta **N tenants a la vez** (campaña, migración de cartera de reseller, demos comerciales), las 3 opciones no escalan. El bulk provisioner es la 4ta entrada al pipeline, optimizada para batch.

> Naming interno: **"máquina de mandar misiles"** — refleja la metáfora del CEO (Coke) cuando pidió el sistema. Cada misil = 1 tenant provisionado.

---

## Arquitectura

```
                       ┌────────────────────────────────────┐
                       │  bewpro:bulk-provision (CLI)       │
                       │  - lee CSV + JSONs                 │
                       │  - valida filas                    │
                       │  - confirma con el operador        │
                       └────────────────┬───────────────────┘
                                        │
                                        ▼
                       ┌────────────────────────────────────┐
                       │  BulkProvisioningService           │
                       │  - autoDedupSlugs($payload)        │
                       │  - createAirtableProject(payload)  │
                       │  - updateAirtableProject() (retry) │
                       │  - stash JSON → storage/app/...    │
                       │  - upsert TenantProject local      │
                       └────────────────┬───────────────────┘
                                        │
        ┌───────────────────────────────┼──────────────────────────────┐
        ▼                               ▼                              ▼
┌──────────────────┐         ┌──────────────────────┐      ┌──────────────────────┐
│  Airtable        │         │  storage/app/        │      │  bp-bewpro DB        │
│  Projects        │         │  reseller-imports/   │      │  tenant_projects     │
│  Pipeline_Status │         │  {airtable_id}.json  │      │  parent_reseller_id  │
│  =Required       │         │                      │      │  pipeline_status     │
└────────┬─────────┘         └──────────────────────┘      │  =required           │
         │                              ▲                  └──────────────────────┘
         │ cron VPS1, cada 1min         │ orchestrator lo lee
         ▼                              │ post-provisión
┌──────────────────────────────────────┴──────────────────┐
│  process-airtable2.sh (cron VPS1)                       │
│  → bewpro:new EMAIL TITLE SLUG                          │
│  → bewpro:import-content (si JSON existe)               │
│  → email de bienvenida                                  │
└─────────────────────────────────────────────────────────┘
```

**Punto clave**: el bulk provisioner **NO toca DNS, cPanel, MySQL del tenant, ni instala el codebase**. Solo deja Airtable con `Pipeline_Status=Required` para que el cron VPS1 (que ya está probado en producción) haga el trabajo pesado.

---

## Uso

### 1. Preparar el CSV

Una fila por tenant. Headers obligatorios: `email`, `project_name`, `subdomain`, `core`. Opcional: `json_file`.

```csv
email,project_name,subdomain,core,json_file
cliente1@example.com,Estudio Jurídico Pérez,perez-abogados,law-firm-digital,jsons/perez-abogados.json
cliente2@example.com,Cafetería Vista al Mar,vista-al-mar,restaurant-bar,jsons/vista-al-mar.json
cliente3@example.com,Constructora Andes,andes,construction,
```

Ejemplo completo: `database/seeders/bulk-imports/example.csv`.

### 2. (Opcional) Preparar los JSONs de contenido

Si querés que cada tenant arranque con contenido personalizado (no demo placeholder), poné un JSON por fila en la carpeta `--json-dir`:

```
database/seeders/bulk-imports/
├── example.csv
└── jsons/
    ├── perez-abogados.json     ← contenido inicial del cliente 1
    └── vista-al-mar.json       ← contenido inicial del cliente 2
```

Cada JSON respeta el shape de **content-import** (las 10 secciones canónicas: `site`, `services`, `team`, `blog`, `faqs`, `references`, `gallery`, `projects`, `menu`, `testimonials`).

Para generar JSONs base que sirvan de scaffold, descargar el template del core:

```bash
# Genera template-foundations-ong-2026-05-13.json
php artisan bewpro:reseller-template foundations-ong
```

O desde el web: `bewpro.com/reseller/projects/template/{core}`.

### 3. Dry-run primero

**Siempre** ejecutar con `--dry-run` antes de la corrida real. Valida CSV, JSONs, formato de subdomain, etc., **sin tocar Airtable ni DB**.

```bash
php artisan bewpro:bulk-provision \
  database/seeders/bulk-imports/example.csv \
  --json-dir=database/seeders/bulk-imports \
  --reseller-email=ezequiel@bewpro.com \
  --dry-run
```

Output esperado: tabla con las N filas, `DRY-RUN — nada se va a escribir.`

### 4. Provisión real

```bash
php artisan bewpro:bulk-provision \
  database/seeders/bulk-imports/example.csv \
  --json-dir=database/seeders/bulk-imports \
  --reseller-email=ezequiel@bewpro.com
```

El comando pide confirmación explícita: `¿Provisionar estos N proyecto(s)? (yes/no)`.

Tras confirmar:
1. Por cada fila: crea Airtable record + (opcional) stashea JSON + crea TenantProject local
2. Reporte final con tabla de resultados (creados / retried / errores)
3. Recordatorio del seguimiento por Airtable

### 5. Esperar al cron

El cron VPS1 corre cada 1 minuto. Tenant promedio tarda ~25min en estar live. Para 10 tenants en cola → ~4hs total (en serie, uno por iteración del cron).

Seguimiento:
- Airtable Projects: filtrar por `Pipeline_Status` (`Required` → `On_Development` → `Active`)
- VPS1 logs: `/var/log/process-airtable.log`
- Local DB: `tenant_projects.pipeline_status`

---

## Flags

| Flag | Default | Uso |
|------|---------|-----|
| `csv` (arg) | — | Path al CSV. Obligatorio. |
| `--json-dir=` | null | Base para resolver `json_file` relativos. Si null, `json_file` se resuelve absoluto. |
| `--reseller-email=` | — | Obligatorio. Define `parent_reseller_id` y el namespacing del mock Stripe customer. |
| `--dry-run` | false | Valida y muestra plan sin escribir. |
| `--continue-on-error` | false | Si una fila falla validación, salta esa fila y sigue. Sin esta flag, aborta al primer error. |

---

## Validaciones por fila

| Campo | Regla |
|-------|-------|
| `email` | `required\|email` |
| `project_name` | `required\|string\|max:120` |
| `subdomain` | `required\|string\|max:48\|regex:^[a-z0-9-]+$` |
| `core` | `required\|string\|max:80` (no verifica que el core exista en codebase — eso lo hace el orchestrator downstream) |
| `json_file` | Si está, el path debe existir y ser JSON válido (`json_decode` OK) |

Auto-deduplicación de slugs en el JSON (services, faqs, blog, projects, etc.): si dos items tienen el mismo slug, se sufijan `-2`, `-3`. Log info se emite con los fixes.

---

## Casos edge cubiertos

| Caso | Comportamiento |
|------|----------------|
| Subdomain duplicado, mismo reseller, status FAILED/REQUIRED | **Retry**: actualiza Airtable record + reusa TenantProject |
| Subdomain duplicado, mismo reseller, status activo | **Error**: "Subdomain con proyecto activo (status: X)" |
| Subdomain duplicado, otro reseller | **Error**: "Subdomain ya tomado por otro reseller" |
| Airtable rechaza un field (UNKNOWN_FIELD_NAME) | **Graceful**: dropea el field y reintenta (max 5 iteraciones) |
| Airtable down / timeout | **Error**: fila falla, sigue con la siguiente (si `--continue-on-error`) |
| JSON con slugs duplicados | **Auto-dedup**: sufija `-2`, `-3` y loggea fixes |
| JSON inválido | **Error**: fila bloqueada con mensaje específico de parseo |
| Core slug que no existe en Airtable Products | **Warning**: continúa pero loggea; orchestrator fallará downstream con mensaje claro |

---

## Diferencia con web flow

Ambos pasan por el mismo servicio (`BulkProvisioningService::provision()`):

| Aspecto | Web `/reseller/projects/new` | CLI `bewpro:bulk-provision` |
|---------|------------------------------|-----------------------------|
| Entrada | 1 form HTML | CSV multi-fila |
| Auth | Spatie permission `reseller.dashboard` | `--reseller-email=` (sin auth — solo accesible vía SSH/local) |
| JSON | 1 upload opcional (5MB max) | Carpeta de files referenciados por fila |
| Confirmación | Submit del form | Prompt CLI interactivo |
| Output | Redirect + flash message | Tabla en terminal + reporte |
| Velocidad humana | ~30s por tenant | ~5s por tenant (10 filas → 50s) |

---

## Troubleshooting

### "Reseller no encontrado en DB"
Verificar email exacto en `users`:
```sql
SELECT id, email FROM users WHERE email LIKE 'ezequiel%';
```

### "⚠ Reseller no tiene permiso reseller.dashboard"
El comando se ejecuta igual pero deja warning. Para resolver:
```bash
php artisan permission:role-permission ezequiel@bewpro.com reseller.dashboard --assign
```

### Fila falla con "No se pudo crear Airtable record"
Causas posibles:
1. `AIRTABLE_TOKEN` / `AIRTABLE_BASE_ID` no configurados en `.env`
2. Airtable rate-limit (5 req/sec) → retry manual la fila
3. Core slug no resuelve en Airtable Products → confirmar con `php artisan bewpro:products`

### Tenant nunca pasa a `On_Development`
El bulk provisioner solo crea Airtable + DB local. El procesamiento real lo hace el cron VPS1. Si pasaron >5min sin movimiento, revisar:
```bash
ssh vps1-claude 'tail -100 /var/log/process-airtable.log'
```

### JSON con shape incorrecto
El orchestrator de VPS1 corre `bewpro:import-content` post-seed. Si el JSON tiene shape inválido, el tenant queda en `On_Development` con error. Para validar JSON localmente antes de subirlo al batch:

```bash
php artisan bewpro:import-content path/to/file.json --dry-run --tenant-db=<db>
```

---

## Próximos pasos sugeridos

- [ ] CSV → Google Sheet con validación en línea (data validation de columna core)
- [ ] Subir CSV vía `/admin/bulk-provisioning` (UI admin) para que no requiera SSH
- [ ] Hook para Slack/email cuando el batch termina ("X/N tenants provisionados")
- [ ] Modo `--export-airtable=path.csv` que después de provisionar exporte resultado a CSV con airtable_id + domain

---

## Historial de incidentes

- **2026-05-14**: Primer batch real (13 leads law-firm-digital de campaña leadhunter). Los tenants se provisionaron OK pero el JSON NO se aplicaba (placeholder default en lugar de data del lead). Root cause: `apply-reseller-import.sh` asumía convención cPanel y operaba solo en VPS1, pero los tenants ahora se provisionan en HestiaCP en VPS1/VPS2/VPS3. Postmortem completo: [`hestiacp-import-migration.md`](hestiacp-import-migration.md).

---

## Referencias

- Comando: `app/Console/Commands/BulkProvisionProjects.php`
- Servicio: `app/Services/BulkProvisioningService.php`
- Controller web (mismo servicio): `app/Modules/Clients/Controllers/Reseller/ResellerProjectsController.php`
- Script post-provisión (vive en VPS1, versionado en repo): `scripts/apply-reseller-import.sh`
- Cron VPS1: `/root/scripts/process-airtable2.sh` + `/root/scripts/apply-pending-imports.sh`
- Pipeline orchestrator: `docs/bewpro2.0/operaciones/reseller-provisioning.md`
- JSON templates por core: `docs/bewpro2.0/sprint-q2/templates-json/`
- Content import: `docs/bewpro2.0/sprint-q2/content-import-alignment.md`
- Postmortem HestiaCP migration: [`hestiacp-import-migration.md`](hestiacp-import-migration.md)
