# Implementation Plan: Sincronización de sitemaps y monitoreo de cobertura de indexación

**Branch**: `001-gsc-sitemap-coverage` | **Date**: 2026-08-10 | **Spec**: [spec.md](./spec.md)

**Input**: Feature specification from `/specs/001-gsc-sitemap-coverage/spec.md`

## Summary

Servicio que mantiene sincronizados los sitemaps de un sitio con Google Search Console y
monitorea a diario el estado de indexación de sus URLs, para que el operador no tenga que entrar
a la interfaz de Google. El acceso a cada propiedad se obtiene con una cuenta de servicio
agregada como propietario delegado. El trabajo pesado ocurre en tareas asíncronas que consumen
un presupuesto de cuota explícito por dominio y por día, y todo estado mostrado conserva su dato
crudo de origen y su fecha de obtención. Planes, cobro y verificación por TXT se construyen
cableados y apagados.

## Technical Context

**Language/Version**: Python — mínimo declarado 3.12; el entorno resuelto por uv corre 3.13.13

**Primary Dependencies**: versiones **verificadas por instalación el 2026-08-17**, no declaradas
de memoria. Quedan fijadas en `uv.lock` y `package-lock.json`.

| Python | | Navegador | |
|---|---|---|---|
| Django | 6.1 | React | 19.2.8 |
| djangorestframework | 3.18.0 | @inertiajs/react | 3.6.1 |
| drf-spectacular | 0.30.0 | Vite | 8.2.1 |
| inertia-django | 2.0.0 | @vitejs/plugin-react | 6.0.5 |
| django-vite | 3.1.0 | Tailwind | 4.3.3 |
| django-environ | 0.14.0 | shadcn/ui | 4.18.0 (Radix 1.6.7) |
| celery | 5.6.3 | lucide-react | 1.31.0 |
| redis | 8.1.0 | | |
| psycopg[binary] | 3.3.4 | | |
| google-api-python-client | 2.198.0 | | |
| google-auth | 2.56.3 | | |
| cryptography | 50.0.0 | | |
| httpx | 0.28.1 | | |
| pytest / pytest-django | 9.1.1 / 4.14.0 | | |
| ruff | 0.16.3 | | |

`httpx` se sumó durante la implementación: hace falta para **leer los sitemaps del sitio del
usuario**, que es tráfico hacia su propio servidor y no hacia Google. Por eso no pasa por
`apps/gsc/` ni consume presupuesto de cuota: el límite de Google es sobre sus APIs.

**Herramientas**: `uv` para el entorno y las dependencias de Python, `npm` para las del
navegador, `django-admin startproject` y `manage.py startapp` para el andamiaje, y el
inicializador de shadcn para la biblioteca de componentes.

**Storage**: PostgreSQL 16 como almacén principal. Redis como broker de Celery y caché del
limitador por minuto — nunca como fuente de verdad del consumo de cuota.

**Testing**: pytest + pytest-django. Dobles de prueba a nivel del cliente HTTP con respuestas de
Google grabadas. La suite no accede a la red.

**Target Platform**: Linux en contenedores. Tres procesos en producción —servidor web, worker de
Celery y programador de Celery— más una etapa de compilación de assets con Vite. En desarrollo se
suma el servidor de Vite con recarga en caliente.

En desarrollo local el proyecto corre sin contenedores: `uv` para Python, `npm` para los assets y
SQLite como respaldo cuando no hay PostgreSQL levantada. PostgreSQL sigue siendo el almacén
definitivo y la configuración de producción la exige de forma explícita.

**Project Type**: Servicio web con interfaz Inertia sobre React y API pública

**Performance Goals**: sincronización de sitemaps por debajo de 30 s para un índice de hasta 50
archivos (SC-005). Un ciclo diario de 2.000 inspecciones por dominio completado dentro de la
ventana diaria sin superar 600 llamadas por minuto.

**Constraints**: 2.000 inspecciones por día por propiedad y 600 por minuto, impuestas por
Google. Ninguna llamada externa fuera del control de presupuesto. Sin acceso a la red en la
suite de pruebas.

**Scale/Scope**: decenas de dominios propios, cientos de miles de URLs acumuladas, una sola
cuenta en uso durante el primer corte con el modelo ya preparado para varias.

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

Evaluado contra [constitution.md](../../.specify/memory/constitution.md) v1.1.0.

| Principio | Gate | Estado inicial | Estado post-diseño |
|---|---|---|---|
| **I. Honestidad en la Promesa** | Ningún estado de indexación se guarda ni se muestra sin su dato crudo de origen y su fecha de obtención; el mapeo a estado interno falla hacia el lado pesimista; `UNKNOWN` sólo para URLs jamás consultadas | ✅ | ✅ R4 fija la regla de traducción; el modelo guarda los campos crudos junto al derivado |
| **I. Honestidad — Indexing API** | No se implementa ni se referencia | ✅ | ✅ Ausente del modelo, del contrato y de las tareas |
| **II. La Cuota es del Usuario** | Toda llamada a Google pasa por el presupuesto; hay reserva para uso manual; retroceso exponencial ante `429`/`503`; consumo visible | ✅ | ✅ R5; el cliente de Search Console es el único punto de salida y exige reserva previa |
| **III. Custodia de Credenciales** | Secreto fuera del repositorio y de la imagen; filtrado en logs; rotación sin cambios de código; detección de pérdida de acceso | ✅ | ✅ R12 y R14; la clave se guarda cifrada y jamás se devuelve; el estado de acceso por dominio es un campo del modelo, no un fallo silencioso |
| **IV. La API es el Producto** | Paridad entre interfaz y API; test de contrato por endpoint; documentación generada del esquema | ⚠ ver nota | ✅ R9 y R11 |
| **V. MVP Delgado** | Sin infraestructura especulativa; tareas asíncronas idempotentes | ✅ | ✅ R6, R8, R13 |
| **VI. Cableado sin Encender** | Modelo creado, punto de decisión invocado, apagado por configuración, invisible en toda superficie, registrado en el spec | ✅ | ✅ R7; el spec tiene su apartado *Alcance postergado* |

**Nota sobre el principio IV**: la interfaz usa Inertia, así que recibe sus datos como props
desde las vistas de Django en lugar de consumir la API pública por HTTP. Se considera cumplido
porque ambas superficies —vistas Inertia y endpoints de la API— delegan en la misma capa de
servicios y ninguna vista contiene lógica de negocio propia, y porque un test de paridad
verifica que toda operación disponible en la interfaz existe también en la API. La alternativa
literal (una aplicación de página única separada que consuma la API por HTTP) se descarta por el
costo de dos despliegues, CORS y sesión compartida; queda registrada en Complexity Tracking.

**Resultado del gate**: pasa. Sin violaciones que requieran excepción.

## Project Structure

### Documentation (this feature)

```text
specs/001-gsc-sitemap-coverage/
├── plan.md              # Este archivo
├── spec.md              # Especificación funcional
├── research.md          # Fase 0 — decisiones técnicas
├── data-model.md        # Fase 1 — entidades y transiciones
├── quickstart.md        # Fase 1 — cómo levantarlo y validarlo
├── contracts/
│   └── openapi.yaml     # Fase 1 — contrato de la API pública
├── checklists/
│   └── requirements.md  # Validación de calidad del spec
└── tasks.md             # Fase 2 — generado por /speckit-tasks
```

### Source Code (repository root)

> Lo marcado con ✅ ya existe y está verificado; el resto se construye en las fases siguientes.

```text
pyproject.toml + uv.lock     # ✅ Dependencias de Python, fijadas
package.json + package-lock  # ✅ Dependencias del navegador, en la raíz como en Laravel
vite.config.ts               # ✅ Alias @, salida a static/dist con manifiesto
components.json              # ✅ Configuración de shadcn
tsconfig.json                # ✅ Resolución del alias para el editor
manage.py                    # ✅

config/                      # Proyecto Django: settings, urls, celery
├── settings/
│   ├── base.py              # ✅
│   ├── dev.py               # ✅
│   └── prod.py              # ✅
├── api_urls.py              # Router único de la API pública v1
├── celery.py
└── urls.py                  # ✅

apps/
├── accounts/                # Cuenta, sesiones, claves de API
│   ├── models.py
│   ├── services.py
│   ├── authentication.py    # Autenticación por clave de API para DRF
│   └── api.py
├── credentials/             # Proyecto de Google, módulos y claves de cuenta de servicio
│   ├── models.py
│   ├── crypto.py            # Cifrado reversible y rotación de clave (R14)
│   ├── validation.py        # Validación estructural del archivo de clave, con mensaje accionable
│   ├── services.py          # Carga, comprobación, reemplazo, resolución por módulo (R15)
│   └── api.py
├── onboarding/              # Recorrido guiado de primera vez
│   ├── models.py            # Progreso, pasos cumplidos, contexto
│   ├── steps.py             # Definición de los pasos y su comprobación efectiva (R16)
│   ├── content.py           # Texto de la guía y ejemplos, separado del flujo (R16, R17)
│   ├── services.py
│   └── api.py
├── domains/                 # Dominio y estado de acceso
│   ├── models.py
│   ├── services.py          # Alta, comprobación de acceso, revalidación
│   ├── tasks.py
│   └── api.py
├── sitemaps/                # Sitemap, URL, descubrimiento y envío
│   ├── models.py
│   ├── parser.py            # Lectura de sitemap índice y de URLs, gzip
│   ├── services.py
│   ├── tasks.py
│   └── api.py
├── coverage/                # Registro de cobertura e historial
│   ├── models.py
│   ├── mapping.py           # Traducción crudo → estado interno (R4)
│   ├── services.py
│   ├── tasks.py
│   └── api.py
├── jobs/                    # Lotes y presupuesto de cuota
│   ├── models.py
│   ├── budget.py            # Reserva y liberación atómica del cupo (R5)
│   ├── services.py
│   └── api.py
├── notifications/           # Correos y agrupación de alertas
│   ├── models.py
│   ├── services.py
│   ├── tasks.py
│   └── templates/
├── billing/                 # Cableado y apagado (principio VI)
│   ├── models.py            # Plan, asignación, registro de consumo
│   └── limits.py            # limits.check(...) — hoy siempre autoriza
├── gsc/                     # Único punto de salida hacia Google
│   ├── client.py            # Envuelve sitemaps e inspección; exige reserva
│   ├── credentials.py
│   └── errors.py
└── web/                     # ✅ Vistas Inertia
    ├── views.py             # Sin lógica de negocio: arma props y delega en services
    └── templates/
        └── base.html        # ✅ Único documento; define el bloque que rellena Inertia

frontend/                    # En la raíz, no como sub-proyecto
├── app.tsx                  # ✅ createInertiaApp; resuelve páginas bajo demanda
├── layouts/
├── hooks/
├── lib/utils.ts             # ✅ Generado por shadcn
├── styles/app.css           # ✅ Tailwind y el tema
├── components/
│   ├── ui/                  # ✅ Generado por shadcn; no se edita a mano
│   ├── GuideStep.tsx        # Paso de guía: objetivo, ruta en Google y ejemplo del dato
│   └── KeyFileExample.tsx   # Ejemplo anonimizado del archivo de cuenta de servicio
└── pages/                   # Una página por vista Inertia
    ├── Login.tsx            # ✅ Hoy es prueba del cableado; se reemplaza en T019
    ├── Settings/
    ├── Onboarding/
    ├── Domains/
    ├── Sitemaps/
    └── Coverage/

static/dist/                 # Salida de la compilación; no se versiona

tests/
├── contract/                # Un test por endpoint del contrato
├── integration/             # Flujos completos con Google simulado
├── unit/                    # Mapeo, presupuesto, análisis de sitemaps
└── fixtures/gsc/            # Respuestas de Google grabadas
```

### Inventario de vistas

Doce páginas de Inertia. El panel interno (US6) **no** suma ninguna: usa el admin de Django, lo
que ahorra alrededor de cuatro pantallas de gestión.

| # | Ruta | Página | Historia | Qué resuelve |
|---|---|---|---|---|
| 1 | `/login` | `Login` | — | Ingreso con correo y contraseña |
| 2 | `/settings` | `Settings/Index` | US1 | Estado de la conexión con Google, guía paso a paso, carga y comprobación de la credencial |
| 3 | `/onboarding` | `Onboarding/Wizard` | US4 | Asistente de siete pasos con comprobación efectiva en cada uno |
| 4 | `/domains` | `Domains/Index` | US2 | Listado de dominios con su estado de acceso |
| 5 | `/domains/new` | `Domains/Create` | US2 | Alta de dominio y forma de la propiedad |
| 6 | `/domains/{id}` | `Domains/Show` | US2 | Ficha: acceso, presupuesto de cuota, notificaciones, acciones |
| 7 | `/domains/{id}/coverage` | `Coverage/Index` | US2 | Tablero por estado, filtros, exportación y estimación del ciclo |
| 8 | `/domains/{id}/sitemaps` | `Sitemaps/Index` | US3 | Sitemaps del dominio con su estado de envío |
| 9 | `/domains/{id}/batches` | `Batches/Index` | US5 | Lotes del dominio con su progreso |
| 10 | `/batches/{id}` | `Batches/Show` | US5 | Detalle de un lote y su consumo de cupo |
| 11 | `/api-keys` | `ApiKeys` | US3 | Claves de integración: crear, ver último uso, revocar |
| 12 | `/sessions` | `Sessions` | US6 | Sesiones activas propias y cierre de sesión |

Rutas que no son páginas de Inertia:

| Ruta | Qué es |
|---|---|
| `/` | Redirección: al recorrido guiado si la cuenta no lo completó, si no a `/domains` |
| `/logout` | Cierre de sesión por envío de formulario |
| `/admin/` | Panel interno de Django (US6) |
| `/api/v1/…` | API pública |
| `/api/docs` | Documentación navegable de la API |

**Resolución de rutas en el frontend**: no existe equivalente de Ziggy para Django, y las dos
librerías del ecosistema quedaron descartadas —una excluye la versión de Django que usa el
proyecto, la otra está sin publicar desde 2021—. Se resuelve con un helper propio: las rutas
publicadas viajan como prop compartida de Inertia y se resuelven con `route(name, params)`,
tipado por nombre de ruta. El detalle y su justificación están en R19 de
[research.md](./research.md); se implementa en T039 y T040.

**Convención de rutas**: sin prefijos de agrupación artificial. No hay `/auth/login` ni
`/account/api-keys`: los recursos de primer nivel viven en la raíz y sólo se anidan cuando la
pertenencia es real, como los sitemaps o la cobertura de un dominio concreto. Las carpetas de
`frontend/pages/` siguen la misma forma que la ruta, para que ubicar una página no requiera
consultar el enrutador.

Las páginas 9 y 10 pueden fusionarse en una sola con detalle expandible si al construirlas el
listado resulta liviano.

El **historial de una URL** (FR-060) no suma una decimotercera vista: se resuelve como panel
lateral dentro de `Coverage/Index`, para no perder la tabla y sus filtros al consultarlo.

**Componentes propios** que no vienen de la biblioteca de interfaz: la disposición general con
navegación, el **aviso permanente de nivel cuenta** (FR-064, presente en todas las vistas),
`GuideStep` (paso de guía con objetivo, ruta en Google y comprobación), `KeyFileExample` (ejemplo
anonimizado del archivo de cuenta de servicio), la etiqueta de estado de cobertura —que recibe el
par estado y fecha de obtención, y no puede dibujarse sin ambos—, la tabla de URLs con paginación
y el aviso de error del servidor.

El inventario detallado de componentes, con su contrato y sus estados, está en
[ux-checklist.md](./ux-checklist.md).

**Biblioteca de interfaz**: Tailwind más shadcn/ui, instalados a mano — a diferencia de Laravel y
Rails, `inertia-django` no trae ningún starter kit con la interfaz ya montada. shadcn no depende
del backend: su herramienta copia los componentes al proyecto y funciona con cualquier aplicación
de React sobre Vite.

**Structure Decision**: proyecto Django único con aplicaciones por dominio funcional bajo
`apps/`. No hay separación de frontend y backend porque la interfaz se renderiza en el servidor.
La frontera importante no es entre capas de presentación sino alrededor de `apps/gsc/`: es el
único módulo autorizado a hablar con Google, y exige una reserva de presupuesto vigente en cada
llamada, de modo que el principio II se cumple por construcción y no por disciplina. `billing/`
existe desde el primer día aunque esté apagada, según el principio VI.

## Complexity Tracking

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Interfaz que no consume la API por HTTP (principio IV, lectura literal) | Inertia entrega los datos como props desde la vista, que es justamente lo que elimina la necesidad de una API paralela para la interfaz. Separarlas obligaría a dos despliegues, CORS y sesión compartida, sin usuario que lo justifique todavía | Se conserva la exigencia real —cero lógica de negocio en la interfaz y paridad total con la API— mediante una capa de servicios compartida y un test de paridad automatizado. Migrar a una aplicación separada más adelante no requiere tocar la capa de servicios |
| Cadena de compilación de assets en un MVP | La interfaz tiene un asistente por pasos con comprobaciones en vivo, carga de archivo y estados de error accionables; construirlo con recarga completa de página degradaría la historia que más importa (US1) | Las plantillas puras evitan el build pero convierten cada comprobación en una recarga. Inertia además aprovecha la experiencia previa del equipo con el mismo patrón en Laravel, así que la curva se limita al backend |
| Ocho aplicaciones Django para un MVP | Cada una corresponde a un grupo de requisitos del spec y a una frontera de responsabilidad real; `gsc/` en particular debe ser aislable para garantizar que ninguna llamada externa esquive el presupuesto | Una sola aplicación monolítica haría imposible verificar por estructura que nadie llama a Google fuera del control de cuota, que es el riesgo operativo más caro del producto |
