# ARQUITECTURA · Innovium

> Documento vivo de decisiones técnicas. Cada decisión arquitectónica importante queda registrada acá con fecha y razón.

---

## ADR-001 · Multi-tenancy Modelo A (BD por tenant)

**Fecha:** Abril 2026
**Estado:** Aceptado

### Contexto

Innovium debe soportar múltiples funerarias en una misma instalación. Hay tres modelos posibles:

- **Modelo A** — Una BD por tenant + BD maestra
- **Modelo B** — Una BD compartida con `tenant_id` en cada tabla
- **Modelo C** — Schemas separados en una misma BD

### Decisión

**Modelo A.** Cada funeraria tiene su propia BD `innovium_<slug>`. Una BD maestra `innovium_master` registra los tenants y sus configuraciones.

### Razones

- **Aislamiento físico:** imposible que un bug en un query exponga datos de otra funeraria.
- **Backups granulares:** se puede exportar la BD de un tenant específico para entregárselo si decide irse.
- **Performance:** índices y queries optimizados por tenant, sin overhead de filtros por `tenant_id`.
- **Cumplimiento:** datos de cada empresa físicamente separados, más fácil justificar legalmente.
- **Escalabilidad horizontal:** un tenant grande puede moverse a su propio servidor sin reescribir queries.

### Trade-offs aceptados

- Migraciones tienen que aplicarse a todas las BDs de tenants (script CLI lo automatiza).
- Reportes cross-tenant (para Crono Systems) requieren queries en cada BD y agregar resultados.
- Mayor consumo de conexiones simultáneas (mitigado con connection pooling).

---

## ADR-002 · Resolución de tenant por subdominio

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

El subdominio identifica al tenant: `acoger.innovium.cl` → tenant con slug `acoger`.

Wildcard SSL `*.innovium.cl` cubre todos los tenants.

`admin.innovium.cl` reservado para Crono Systems (panel superadmin cross-tenant).

### Implementación

`App\Core\TenantResolver` se ejecuta primero en el front controller:

1. Lee `$_SERVER['HTTP_HOST']`.
2. Extrae el subdominio.
3. Si es `admin` → modo superadmin, NO inyecta tenant.
4. Si es otro → query a `innovium_master.tenants WHERE slug = ?` con prepared statement.
5. Si existe y está activo → inyecta `Tenant` en el container, abre conexión a `innovium_<slug>`.
6. Si no existe → 404.

### Razones

- URL clara, fácil de recordar para el usuario final.
- Aislamiento de cookies de sesión por subdominio (browser default).
- Branding por tenant: cada uno con su propio dominio.

---

## ADR-003 · Storage en Cloudflare R2 con segregación por path

**Fecha:** Abril 2026 (decisión inicial), actualizado Mayo 2026 (Sprint 1.4)
**Estado:** Aceptado

### Decisión

UN único bucket `innovium-storage` en Cloudflare R2 (renombrado en Sprint 1.4 desde el tentativo `innovium-prod-storage`). Cada tenant tiene un prefijo automático `/tenants/{slug}/`.

`App\Core\Storage` es el único componente autorizado a tocar R2. Aplica el prefijo automáticamente al `Tenant` del container.

URLs firmadas con expiración 1 hora (era 5 minutos en el ADR original; Sprint 1.4 lo subió a 3600s para que vistas server-rendered no caduquen mientras el usuario las mira). Nunca exponer URLs públicas directas.

### Razones

- R2 tiene **egress gratis**. Crítico para subir/descargar firmas, PDFs, imágenes constantemente.
- API S3-compatible, librerías maduras (`aws-sdk-php`).
- Performance global (CDN incluido).
- Más barato que S3 a largo plazo.
- Un bucket es más simple de operar que N buckets.

### Estructura de paths

```
/tenants/{slug}/
  /firmas/CT-{año}-{numero}.png
  /pdfs/{año}/{mes}/CT-{año}-{numero}.pdf
  /obituarios/{año}/{mes}/{slug-fallecido}.jpg
  /branding/logo.png
  /branding/favicon.ico
```

---

## ADR-004 · Sin framework PHP — MVC custom ligero

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

No usar Laravel, Symfony, CodeIgniter ni ningún framework completo. Construir un MVC mínimo en `App\Core` con solo lo necesario.

### Razones

- **Control total** sobre la inicialización del request (crítico para multi-tenancy).
- **Performance:** un Laravel arranca cargando ~200 archivos por request. Innovium puede arrancar con 15-20.
- **Menos sorpresas:** Eloquent + multi-tenant requiere magia y `BindingResolutionException` a las 3 AM.
- **Curva de aprendizaje:** otro developer que entre al proyecto solo necesita PHP, no años de Laravel.
- **Dependencias mínimas:** menos surface area de seguridad.

### Componentes a construir

- `Router` — pattern matching simple con grupos de rutas
- `Container` — DI container minimalista (clase de ~50 líneas)
- `Request` / `Response` — wrappers de superglobales
- `Validator` — reglas custom (RUT chileno, email, required, etc.)
- `Auth` — sesiones + cookies seguras
- `Csrf` — token por sesión, validado en POST/PUT/DELETE
- `Database` — factory de PDO con configuración por tenant
- `Storage` — gateway R2
- `AuditLog` — registro de mutaciones
- `View` — render de templates PHP simples (sin Blade ni Twig)

---

## ADR-005 · Vanilla JS + Alpine, no SPA pesado

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

Frontend construido con **PHP rendering** + **Alpine.js** para interactividad + Vanilla JS para lógica compleja. No React/Vue/Svelte como SPA.

Excepción: la **app móvil** companion sí es React Native/Expo (repo separado).

### Razones

- **SEO y obituarios públicos** funcionan mejor con server-side rendering.
- **Time-to-interactive** mucho más rápido sin bundle JS gigante.
- **Hosting limitado** (VPS HostGator) — un Node.js process no es ideal acá.
- **Alpine cubre 95% de necesidades de UI:** dropdowns, modales, tabs, tooltips, formularios reactivos.
- **Si hace falta más:** componentes web nativos custom, sin framework.

---

## ADR-006 · Tailwind con design system propio (no Tailwind UI)

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

Tailwind CSS para utility classes, pero **sin Tailwind UI ni librerías de componentes pre-fabricados**. Componentes construidos desde cero siguiendo el design system propio (`design-kit/`).

### Razones

- El producto tiene una identidad visual definida (Linear/Vercel/Stripe-tier) que NO debe verse genérica.
- Tailwind UI / Headless UI agregan dependencias y estilos que pelean con el design system.
- El design-kit ya tiene los tokens definidos en `tailwind.config.js`.

---

## ADR-007 · Cuentas de usuario aisladas por tenant

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

Las tablas de usuarios viven en la BD de cada tenant (no en la maestra). Un email puede repetirse entre tenants — son personas distintas para el sistema.

Excepción: `innovium_master.tenant_admin_users` registra los admins iniciales que pueden crear/configurar el tenant antes de que existan usuarios internos.

### Razones

- **Privacidad:** la lista de empleados de cada funeraria es confidencial.
- **Aislamiento:** si un email se filtra, solo afecta a ese tenant.
- **Escalabilidad:** evita una tabla `users` gigante creciendo con todos los tenants.

---

## ADR-008 · Argon2id para hashing de passwords

**Fecha:** Abril 2026
**Estado:** Aceptado

### Decisión

`password_hash($pass, PASSWORD_ARGON2ID, ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1])`.

### Razones

- Argon2id es el algoritmo recomendado por OWASP 2024.
- Soporte nativo en PHP 7.3+.
- Resistente a ataques GPU/ASIC mejor que bcrypt.

### Migración del legacy

Los usuarios del sistema legacy tienen passwords en MD5 / SHA1. En el primer login en Innovium, se les forzará a cambiar el password (re-hasheo a Argon2id).

---

## ADR-009 · Slug del tenant case-insensitive en HTTP, case-sensitive en CLI

**Fecha:** Mayo 2026
**Estado:** Aceptado

### Contexto

Sprint 1.2 implementa `TenantResolver` que mapea subdominio → tenant. Hay que decidir qué hacer cuando el browser envía un host como `Demo.innovium.test` (con mayúsculas).

### Decisión

- **TenantResolver (HTTP):** case-insensitive. `Demo.innovium.test`, `DEMO.innovium.test` y `demo.innovium.test` resuelven al mismo tenant. La normalización a minúsculas la hace `Request::fromGlobals` con `strtolower` sobre `$_SERVER['HTTP_HOST']`. El resolver no vuelve a transformar — confía en esa garantía.

- **CLI `tenant:create` (creación):** case-sensitive. `php scripts/innovium tenant:create Demo` falla con error de regex. El admin debe escribir `demo`.

### Razones

- **Browsers** envían el host como lo escribió el usuario en la barra de direcciones. Forzarle a tipear todo minúscula sería hostil. Resolver case-insensitive cubre typos y autocompletados de Safari/Chrome.
- **CLI** está bajo control de Crono Systems. Los slugs son un activo persistente que aparece en URLs, paths de R2, nombres de BD MySQL, audit logs. Aceptar `Demo` y guardarlo como `demo` silenciosamente abre la puerta a confusión ("¿el tenant se llama Demo o demo?"). Mejor rechazar y obligar a escribir lo que va a quedar.
- **Garantía única de fuente** del lowercase en `Request::fromGlobals` (un solo lugar) evita reglas duplicadas en cada componente que lee `HTTP_HOST`.

### Implementación

```php
// Request::fromGlobals
$host = strtolower((string)($_SERVER['HTTP_HOST'] ?? 'localhost'));

// TenantResolver — confía en el lowercase upstream
private function extractSlug(string $host): string {
    // ... regex /^[a-z][a-z0-9-]{2,30}$/ asume minúsculas
}

// CLI tenant:create — sin strtolower, valida tal cual escribió el admin
$slug = (string)$argv[2];
if (preg_match(SLUG_REGEX, $slug) !== 1) { /* error */ }
```

### Test que la cubre

`tests/integration/TenantIsolationTest::testSlugCaseInsensitive` valida que las 4 variantes (`demo`, `Demo`, `DEMO`, `DeMo`) resuelven al mismo `tenant->id`.

---

## ADR-010 · Idempotencia de soft delete vs idempotencia HTTP

**Fecha:** Mayo 2026 (Sprint 1.4)
**Estado:** Aceptado

### Contexto

Los models del catálogo (`Categoria::softDelete($id)`, etc.) son idempotentes a nivel BD: si la fila ya tiene `eliminado_en` seteado, devuelven `false` sin error. Pero los controllers admin (`AdminCategoriaController::destroy`, etc.) hacen `find($id)` ANTES de borrar, y `find()` filtra `eliminado_en IS NULL`. Resultado: el segundo `destroy` HTTP del mismo id devuelve **404**, no 200.

### Decisión

**Mantener el comportamiento actual: HTTP 404 en el segundo destroy.**

### Razones

- El cliente HTTP no debería retentar destroys. Si lo hace, es síntoma de un bug (race condition, refresh accidental). El 404 le dice "esto ya no existe" — semánticamente correcto.
- La idempotencia "real" del soft delete vive a nivel Model: si el sistema interno (cron, service) llama dos veces, no rompe nada — devuelve `false` sin tirar excepción.
- Si en el futuro queremos idempotencia HTTP (ej: API pública con clientes que retentan), la solución es agregar un `findIncludingDeleted($id)` al model y usarlo en el controller. No tocar el Model::softDelete (que es correcto).

### Test que la cubre

`tests/integration/AdminCategoriaControllerTest::test_destroy_segundo_intento_404`.

---

## Decisiones pendientes

- [ ] Proveedor de WhatsApp Business API (Twilio / Meta directo / WaSenderApi / 360dialog)
- [ ] Proveedor de SMTP (Mailgun / Postmark / SendGrid / Resend)
- [ ] Estrategia de queue para tareas async (Redis + worker PHP / cron + tabla / Cloudflare Workers)
- [ ] Plan de billing y suscripción para tenants
- [ ] Estrategia de backups automatizados de las BDs de tenants

---

## Glosario

- **Tenant:** una funeraria cliente de Innovium.
- **Slug:** identificador único en URL del tenant (`acoger`, `lirio`, `sanjose`).
- **NI / NF:** Necesidad Inmediata / Necesidad Futura — tipos de contrato funerario.
- **Contratante:** persona que firma y paga el contrato.
- **Beneficiario:** persona cubierta por el contrato (puede ser distinta del contratante).
- **Datero:** persona externa que refiere clientes a la funeraria a cambio de comisión.
- **Convenio:** acuerdo con empresa/institución para descuentos a sus afiliados.
- **Audit log:** registro inmutable de cada acción mutativa con quién/cuándo/qué.
