# Phase 1 — Data Model: index-relay MVP

**Feature**: 001-gsc-sitemap-coverage
**Date**: 2026-08-10

Los tipos se expresan de forma neutra. Toda tabla lleva `created_at` y `updated_at`; no se
repiten en cada listado.

---

## accounts

### Account

Titular de los datos. En el primer corte existe una sola, pero toda entidad cuelga de ella.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `email` | texto | único, obligatorio |
| `password` | texto | hash gestionado por el framework |
| `is_active` | booleano | por defecto verdadero |
| `is_staff` | booleano | acceso al panel interno |
| `plan_id` | fk → Plan | obligatorio; apunta al plan sin límites por defecto |

### ApiKey

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `name` | texto | obligatorio |
| `prefix` | texto | primeros caracteres, visibles para identificar la clave |
| `hashed_key` | texto | sólo el hash; el valor completo se muestra una única vez al crearla |
| `last_used_at` | fecha-hora | nulo hasta el primer uso |
| `revoked_at` | fecha-hora | nulo mientras esté vigente; se expone y las revocadas siguen listándose (FR-061) |

**Reglas**: una clave revocada nunca se reactiva. La autenticación actualiza `last_used_at` de
forma diferida para no escribir en cada petición.

### Session

Sesión abierta de una cuenta en la interfaz. Existe para que el usuario pueda decidir cuál cerrar
(FR-059): sin estos datos, las filas son indistinguibles entre sí.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `session_key` | texto | referencia a la sesión del framework |
| `user_agent` | texto | agente declarado por el navegador, tal como llegó |
| `ip_address` | texto | dirección de origen del último acceso |
| `last_activity_at` | fecha-hora | se actualiza de forma diferida, no en cada petición |
| `revoked_at` | fecha-hora | nulo mientras esté activa |

**Reglas**: la sesión en curso se marca como tal para no invitar a cerrarla por error. Cerrar una
sesión la invalida en el almacén del framework, no sólo en esta tabla.

---

## credentials

### GoogleProject

Proyecto de Google Cloud declarado por la cuenta. Su identificador **no es secreto** y se guarda
en claro.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `project_id` | texto | identificador de Google Cloud; validado por formato antes de cualquier llamada externa |
| `display_name` | texto | opcional, para reconocerlo en pantalla |
| `is_active` | booleano | |

**Reglas**: en el primer corte se admite un único proyecto activo por cuenta. La restricción vive
en la validación del servicio, no en el esquema, para no requerir migración al levantarla
(FR-014).

### Module

Catálogo de capacidades de la plataforma que requieren credencial. Se crea por migración de
datos; el primer corte contiene una sola fila.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `code` | texto | único; el primer corte crea únicamente `SEARCH_CONSOLE` |
| `name` | texto | nombre legible |
| `required_apis` | json | APIs de Google que deben estar habilitadas en el proyecto |
| `required_scopes` | json | permisos que se solicitan |

### Credential

Clave de cuenta de servicio. **El material secreto se cifra de forma reversible**, no se hashea:
debe poder usarse para firmar cada llamada a Google. El hash de una sola vía se aplica sólo a
`ApiKey`, que únicamente se compara.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `google_project_id` | fk → GoogleProject | obligatorio |
| `kind` | enum | `SERVICE_ACCOUNT` \| `DELEGATED_USER` (el segundo, no implementado) |
| `client_email` | texto | dirección de la cuenta de servicio; **no secreta**, se muestra para que el usuario la autorice |
| `encrypted_key` | binario | contenido de la clave, cifrado; nunca se devuelve por ninguna superficie |
| `key_fingerprint` | texto | huella derivada, para identificar la clave en pantalla sin exponerla |
| `private_key_id` | texto | identificador de la clave que informa Google; útil para reconocerla en la consola |
| `status` | enum | `UNVERIFIED` \| `VERIFIED` \| `INVALID` \| `REVOKED` |
| `last_checked_at` | fecha-hora | última comprobación |
| `last_error_code` | enum | ver abajo |
| `last_error_detail` | texto | mensaje accionable |
| `is_active` | booleano | |

**Valores de `last_error_code`**: `INVALID_KEY`, `API_NOT_ENABLED`, `PROJECT_MISMATCH`,
`NO_PROPERTIES`, `PROVIDER_UNAVAILABLE`. Cada uno exige un mensaje con acción concreta; un error
genérico incumple FR-010.

**Reglas**: `encrypted_key` nunca aparece en respuestas, plantillas, registros ni exportaciones
(FR-009, SC-012). Reemplazar una credencial crea una fila nueva y desactiva la anterior; los
dominios no se tocan (FR-012).

### ModuleCredential

Asignación de una credencial a un módulo. Tabla intermedia para que una misma credencial pueda
servir a varios módulos y para incorporar módulos nuevos sin migración (FR-013).

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `module_id` | fk → Module | obligatorio |
| `credential_id` | fk → Credential | obligatorio |
| `is_active` | booleano | |

**Reglas**: única activa por `(account, module)`. El primer corte crea una sola asignación, hacia
`SEARCH_CONSOLE`.

---

## onboarding

### OnboardingProgress

Una fila por cuenta. Permite retomar el recorrido en el mismo punto y saber si fue omitido.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | único |
| `current_step` | texto | identificador del paso vigente |
| `completed_steps` | json | lista de pasos ya cumplidos |
| `dismissed_at` | fecha-hora | nulo salvo que se haya omitido |
| `completed_at` | fecha-hora | nulo hasta terminar el recorrido |
| `context` | json | referencias a lo creado durante el recorrido, para no duplicarlo al retomar |

**Pasos**: `GOOGLE_PROJECT` → `ENABLE_API` → `SERVICE_ACCOUNT_KEY` → `AUTHORIZE_PROPERTY` →
`ADD_DOMAIN` → `ADD_SITEMAP` → `FIRST_BATCH`.

**Reglas**: cada paso se marca cumplido sólo tras una comprobación efectiva de su requisito, no
por haber sido visitado (FR-017). `context` guarda los identificadores de los objetos creados, de
modo que retomar el recorrido los reutilice en vez de crear duplicados (FR-019, SC-009). Omitirlo
no restringe ninguna funcionalidad (FR-018).

---

## domains

### Domain

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `hostname` | texto | normalizado a minúsculas, sin esquema |
| `property_type` | enum | `DOMAIN` \| `URL_PREFIX` |
| `property_uri` | texto | `sc-domain:ejemplo.com` o `https://ejemplo.com/`; único junto con la cuenta |
| `access_state` | enum | ver máquina de estados |
| `access_checked_at` | fecha-hora | última comprobación de acceso |
| `access_error` | texto | motivo de la última comprobación fallida |
| `daily_inspection_budget` | entero | por defecto 2000 |
| `manual_reserve` | entero | por defecto 200, parte del cupo reservada al uso manual |
| `notifications_enabled` | booleano | por defecto verdadero |
| `txt_token` | texto | generado siempre; usado sólo si la verificación TXT se enciende |
| `txt_verified_at` | fecha-hora | nulo; capacidad apagada (FR-036) |

**Máquina de estados de `access_state`**:

```text
AWAITING_ACCESS ──comprobación exitosa──▶ OPERATIONAL
AWAITING_ACCESS ──sin permiso──────────▶ AWAITING_ACCESS  (con access_error)
OPERATIONAL ─────pérdida detectada─────▶ ACCESS_LOST
ACCESS_LOST ─────comprobación exitosa──▶ OPERATIONAL
cualquiera ──────acción de admin───────▶ ACCESS_REVOKED
ACCESS_REVOKED ──acción de admin───────▶ AWAITING_ACCESS
cualquiera ──────acción de admin───────▶ SUSPENDED
```

**Reglas**: sólo un dominio en `OPERATIONAL` admite sincronización o inspección. El paso a
`ACCESS_LOST` conserva todo el historial y dispara notificación (FR-007). El cupo diario efectivo
para trabajo automático es `daily_inspection_budget - manual_reserve`.

---

## sitemaps

### Sitemap

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `domain_id` | fk → Domain | obligatorio |
| `parent_id` | fk → Sitemap | nulo si es raíz; apunta al índice que lo declaró |
| `location` | texto | URL absoluta, única por dominio |
| `kind` | enum | `INDEX` \| `URLSET` |
| `source` | enum | `DECLARED` (registrado a mano) \| `DISCOVERED` (hallado en un índice) |
| `url_count` | entero | URLs declaradas en la última lectura |
| `content_hash` | texto | huella del contenido leído; base de la idempotencia (R6) |
| `last_read_at` | fecha-hora | |
| `last_submitted_at` | fecha-hora | último envío **exitoso** a Search Console |
| `last_submit_result` | enum | `OK` \| `FAILED` \| `SKIPPED_UNCHANGED` |
| `last_error` | texto | motivo del último fallo de lectura o envío |

**Reglas**: un sitemap sólo se envía si `content_hash` difiere del vigente al momento del último
envío exitoso (FR-011). Un `INDEX` no se envía por sus hijos: se envía él mismo y se descubren
sus hijos para lectura de URLs.

### Url

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `domain_id` | fk → Domain | obligatorio |
| `sitemap_id` | fk → Sitemap | último sitemap donde se la vio |
| `loc` | texto | URL absoluta; única por dominio |
| `in_sitemap` | booleano | falso cuando desapareció del sitemap; no se borra la fila |
| `first_seen_at` | fecha-hora | |
| `last_seen_in_sitemap_at` | fecha-hora | |
| `coverage_state` | enum | estado interno vigente; `UNKNOWN` mientras no se inspeccionó |
| `last_checked_at` | fecha-hora | nulo mientras no se inspeccionó |
| `priority_score` | entero | orden de inspección; recalculado por el planificador |

**Reglas**: `coverage_state` y `last_checked_at` son una desnormalización del último
`CoverageRecord` para poder filtrar sin unir tablas. Una URL con `last_checked_at` nulo **debe**
mostrarse como sin datos y jamás como indexada o no indexada (FR-014). Las URLs con
`in_sitemap` falso no se inspeccionan pero conservan su historial.

**Índices**: `(domain_id, coverage_state)`, `(domain_id, last_checked_at)`,
`(domain_id, in_sitemap, priority_score)`.

---

## coverage

### CoverageRecord

Historial. Se escribe **sólo cuando el estado cambia** respecto del registro anterior (R13), más
la primera inspección de cada URL.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `url_id` | fk → Url | obligatorio |
| `batch_id` | fk → Batch | lote que produjo el dato |
| `state` | enum | estado interno derivado |
| `fetched_at` | fecha-hora | **obligatorio**; momento en que Google devolvió el dato |
| `raw_verdict` | texto | crudo |
| `raw_coverage_state` | texto | crudo |
| `raw_robots_state` | texto | crudo |
| `raw_indexing_state` | texto | crudo |
| `raw_page_fetch_state` | texto | crudo |
| `google_canonical` | texto | crudo |
| `user_canonical` | texto | crudo |
| `last_crawl_time` | fecha-hora | crudo, informado por Google |

**Valores de `state`**: `INDEXED`, `CRAWLED_NOT_INDEXED`, `DISCOVERED_NOT_INDEXED`,
`DUPLICATE_CANONICAL`, `EXCLUDED_NOINDEX`, `BLOCKED_ROBOTS`, `FETCH_ERROR`, `REDIRECT`,
`OTHER_NOT_INDEXED`. El valor `UNKNOWN` existe en `Url.coverage_state` pero **nunca** en un
`CoverageRecord`: un registro siempre proviene de una respuesta real.

**Regla de traducción (R4)**: `INDEXED` sólo si el veredicto crudo es `PASS`. Todo
`raw_coverage_state` no reconocido cae en `OTHER_NOT_INDEXED`. La traducción es una función pura
y probada por separado, de modo que los datos crudos permiten recalcularla sin gastar cuota.

---

## jobs

### Batch

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `domain_id` | fk → Domain | obligatorio |
| `kind` | enum | `SITEMAP_SYNC` \| `URL_INSPECTION` |
| `origin` | enum | `SCHEDULED` \| `MANUAL` \| `API` |
| `state` | enum | `QUEUED` \| `RUNNING` \| `COMPLETED` \| `PARTIAL` \| `FAILED` |
| `total_items` | entero | |
| `processed_items` | entero | |
| `failed_items` | entero | |
| `quota_consumed` | entero | llamadas efectivamente gastadas |
| `started_at` / `finished_at` | fecha-hora | |
| `idempotency_key` | texto | nulo salvo que el cliente la haya enviado; única por cuenta |
| `summary` | json | resumen para el correo y el detalle |

**Máquina de estados**:

```text
QUEUED ──▶ RUNNING ──▶ COMPLETED   (todos los ítems procesados sin error)
                   ├─▶ PARTIAL     (cupo agotado, límite de Google o ítems pendientes)
                   └─▶ FAILED      (no se procesó ningún ítem)
```

**Reglas**: un lote en estado terminal no se reprocesa ni reenvía correo (R6). `PARTIAL` es el
estado obligatorio cuando el trabajo se interrumpió por cuota: nunca `COMPLETED` (FR-017).

### QuotaBudget

Una fila por dominio y por día.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `domain_id` | fk → Domain | único junto con `date` |
| `date` | fecha | en la zona horaria de referencia declarada por el sistema (ver *Zona horaria*) |
| `limit_total` | entero | copiado del dominio al crear la fila |
| `manual_reserve` | entero | copiado del dominio |
| `used_automatic` | entero | |
| `used_manual` | entero | |

**Reglas**: la reserva es atómica —se bloquea la fila, se comprueba el saldo y se incrementa
antes de la llamada—. Si la llamada no llega a ejecutarse, se libera. El trabajo automático no
puede tocar `manual_reserve`. Ninguna llamada a Google ocurre sin una reserva previa concedida
(principio II).

---

## notifications

### Notification

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `domain_id` | fk → Domain | nulo si no es específica de un dominio |
| `kind` | enum | `BATCH_FINISHED` \| `COVERAGE_CHANGED` \| `ACCESS_LOST` |
| `batch_id` | fk → Batch | nulo salvo en `BATCH_FINISHED` |
| `sent_at` | fecha-hora | nulo mientras está pendiente |
| `delivery_state` | enum | `PENDING` \| `SENT` \| `FAILED` |
| `payload` | json | datos usados para componer el correo |
| `dedupe_key` | texto | único; agrupa por tipo, dominio y día (FR-022) |

**Reglas**: `dedupe_key` impone a nivel de base de datos el máximo de un correo por dominio, tipo
y día. Un reintento de la tarea no genera una segunda notificación.

---

## billing *(cableado y apagado — principio VI)*

### Plan

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `code` | texto | único; el primer corte crea únicamente `unlimited` |
| `name` | texto | |
| `max_domains` | entero | nulo significa sin límite |
| `max_monitored_urls` | entero | nulo significa sin límite |
| `is_default` | booleano | exactamente un plan lo tiene |

### ConsumptionRecord

Se escribe **siempre**, aunque no se cobre. Es el insumo de SC-011 para dimensionar los planes.

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `account_id` | fk → Account | obligatorio |
| `domain_id` | fk → Domain | |
| `period` | texto | año y mes |
| `metric` | enum | `INSPECTIONS` \| `SITEMAP_SUBMITS` \| `MONITORED_URLS` |
| `amount` | entero | acumulado del período |

**Reglas**: `limits.check(account, action, amount)` se invoca en el alta de dominio, en el alta
de URLs monitoreadas y en el encolado de lotes. Con `BILLING_ENABLED` apagado siempre autoriza,
pero registra el consumo igual. Ningún serializador ni plantilla expone `Plan` ni sus límites
mientras la capacidad esté apagada (FR-036).

---

## audit

### AuditLog

| Campo | Tipo | Reglas |
|---|---|---|
| `id` | uuid | clave primaria |
| `actor_id` | fk → Account | quien ejecutó la acción |
| `target_account_id` | fk → Account | cuenta afectada |
| `action` | texto | identificador estable de la acción |
| `reason` | texto | obligatorio en acciones administrativas (FR-031) |
| `metadata` | json | |

---

## Relaciones

```text
Account 1──n GoogleProject 1──n Credential
Account 1──n ModuleCredential n──1 Module
ModuleCredential n──1 Credential
Account 1──1 OnboardingProgress
Account 1──n Domain
Account 1──n ApiKey
Account n──1 Plan
Domain  1──n Sitemap 1──n Url 1──n CoverageRecord
Domain  1──n Batch 1──n CoverageRecord
Domain  1──n QuotaBudget            (una por día)
Account 1──n Notification
Account 1──n ConsumptionRecord
```

## Zona horaria

Todo instante se almacena en UTC. La **zona de presentación** es una configuración única del
sistema, declarada explícitamente y visible junto a los datos fechados (FR-065). El corte del día
del presupuesto de cuota usa esa misma zona, de modo que "el cupo de hoy" signifique lo mismo en
la pantalla que en la base.

Es una decisión con consecuencias observables, no un detalle de formato: la fecha de obtención de
un estado de cobertura es el dato sobre el que se apoya el principio I, y una fecha sin zona es
una fecha ambigua.

## Estado derivado de nivel cuenta

No es una tabla: se calcula a partir del estado de la credencial activa del módulo y de la
cantidad de dominios en `ACCESS_LOST`. Alimenta el aviso permanente de la interfaz (FR-064).

**Regla**: si la credencial del módulo está en `INVALID` o `REVOKED`, ningún dominio puede
presentarse como operativo, aunque su propio `access_state` siga en `OPERATIONAL`. El estado del
dominio describe su propiedad en Search Console; el de la cuenta describe si tenemos con qué
consultarla. Los dos tienen que mirarse juntos antes de decir que algo funciona.

## Invariantes transversales

1. Ninguna fila de `CoverageRecord` existe sin `fetched_at` (principio I).
2. Ninguna llamada a Google ocurre sin una reserva concedida en `QuotaBudget` (principio II).
3. `Url.coverage_state = UNKNOWN` ⟺ `Url.last_checked_at` es nulo.
4. Un `Batch` interrumpido por cuota termina en `PARTIAL`, jamás en `COMPLETED`.
5. `Credential.encrypted_key` nunca se devuelve por ninguna superficie: ni respuesta de API, ni
   pantalla, ni correo, ni registro, ni exportación (principio III, SC-012).
6. Con la facturación apagada, ninguna respuesta de la API ni plantilla de correo menciona
   `Plan`, límites, precios ni verificación TXT (principio VI).
7. La credencial que se usa para consultar un dominio se resuelve por módulo en tiempo de uso,
   nunca se copia en `Domain`: reemplazar la credencial no debe tocar ninguna fila de dominios
   (FR-012).
8. Un paso de `OnboardingProgress` sólo se marca cumplido tras comprobar su requisito de forma
   efectiva, jamás por haber sido visitado (FR-017).
