13. Anexos
13.1 Código relevante
El código fuente completo del proyecto está disponible en el repositorio público: https://github.com/Fraancoboss/Quetzy-ERP
A continuación se incluyen tres fragmentos seleccionados por su valor pedagógico: representan patrones arquitectónicos centrales del sistema que justifican decisiones documentadas a lo largo de la memoria.
Máquina de estados de tickets — transitionPolicies
Archivo: src/features/tickets/model/ticket-state-machine.ts:144-202
Este fragmento define la totalidad de transiciones permitidas del pipeline de 14 estados. Cada entrada del Record mapea un estado origen a sus transiciones válidas, con guards funcionales opcionales que validan precondiciones sobre el snapshot del ticket. La implementación es pura y funcional: no depende de efectos secundarios ni de base de datos.
const transitionPolicies: Record<TicketState, TransitionPolicy[]> = {
received: [
{ to: "classified", guard: requireComplexity },
{ to: "cancelled", requireReason: true },
],
classified: [
{ to: "discovering" },
{ to: "cancelled", requireReason: true },
],
discovering: [
{ to: "ambiguous" },
{ to: "cancelled", requireReason: true },
],
ambiguous: [
{ to: "discovering", requireReason: true },
{ to: "pending_client_review", guard: requireClarification },
{ to: "cancelled", requireReason: true },
],
pending_client_review: [
{ to: "designed" },
{ to: "ambiguous", requireReason: true },
{ to: "paused_awaiting_client" },
{ to: "cancelled", requireReason: true },
],
designed: [
{ to: "in_progress" },
{ to: "discovering", requireReason: true },
{ to: "ambiguous", requireReason: true },
{ to: "cancelled", requireReason: true },
],
in_progress: [
{ to: "pr_open", guard: requirePrUrl },
{ to: "cancelled", requireReason: true },
],
pr_open: [
{ to: "validated" },
{ to: "in_progress", requireReason: true },
{ to: "cancelled", requireReason: true },
],
validated: [
{ to: "deployed", guard: requireDeployData },
{ to: "cancelled", requireReason: true },
],
deployed: [
{ to: "learned" },
{ to: "cancelled", requireReason: true },
],
learned: [
{ to: "closed", guard: requireCloseEvidence },
{ to: "cancelled", requireReason: true },
],
closed: [],
paused_awaiting_client: [
{ to: "pending_client_review", requireReason: true },
{ to: "ambiguous", requireReason: true },
{ to: "cancelled", requireReason: true },
],
cancelled: [],
};Patrón “Plan B” de cookie httpOnly
Archivo: src/app/(auth)/actions.ts:16-48
Demuestra la solución adoptada ante la limitación del SDK de Insforge en server mode: los tokens se devuelven en el body JSON y la cookie se persiste manualmente. El comentario Decision/Reason/Alternative documenta la justificación para futuros mantenedores.
export async function signinAction(
_prev: SigninResult,
formData: FormData,
): Promise<SigninResult> {
const raw = {
email: formData.get("email"),
password: formData.get("password"),
};
const parsed = signinSchema.safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
const insforge = getInsforgeClient();
const { data, error } = await insforge.auth.signInWithPassword({
email: parsed.data.email,
password: parsed.data.password,
});
if (error || !data) {
return { error: "Credenciales incorrectas." };
}
// Decision: Plan B — manually persist session cookie.
// Reason: isServerMode uses mobile flow (client_type=mobile) which returns
// accessToken + refreshToken in the JSON body. The SDK does NOT set httpOnly
// cookies in server mode. We write the cookie ourselves.
await setSessionCookie({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
});
return { redirectTo: "/" };
}Patrón de compensación manual en sendMessage
Archivo: src/lib/repositories/insforge/insforge-chat-repository.ts:202-255
Ilustra cómo se resuelve la ausencia de transacciones multi-tabla en el SDK de Insforge. Si el INSERT de attachments falla tras un INSERT exitoso del mensaje, se ejecuta compensación: DELETE del mensaje huérfano y cleanup de archivos en storage.
async sendMessage(input: SendMessageInput): Promise<ChatMessage> {
// 1. Insert message
const { data: msgData, error: msgError } = await db()
.from("chat_message")
.insert({
channel_id: input.channelId,
sender_email: input.senderEmail,
content: input.content,
reply_to_id: input.replyToId ?? null,
type: input.type ?? "text",
})
.select("*")
.single();
throwIfError(msgError, "sendMessage:insertMessage");
const msgRow = msgData as ChatMessageRow;
// 2. Insert file attachments if present (with compensation on failure)
let attRows: ChatAttachmentRow[] = [];
if (input.attachments && input.attachments.length > 0) {
const attInserts = input.attachments.map((a) => ({
message_id: msgRow.id,
file_name: a.fileName,
file_type: a.fileType,
file_size: a.fileSize,
storage_key: a.storageKey,
url: a.url,
thumbnail_url: a.thumbnailUrl ?? null,
}));
const { data: attData, error: attError } = await db()
.from("chat_attachment")
.insert(attInserts)
.select("*");
if (attError) {
// Compensation: delete the orphaned message (CASCADE cleans up)
await db().from("chat_message").delete().eq("id", msgRow.id);
// Defensive cleanup: remove uploaded files from storage
const bucket = getInsforgeClient().storage.from("chat-attachments");
for (const a of input.attachments) {
if (a.storageKey) {
try {
await bucket.remove(a.storageKey);
} catch {
// Non-critical: orphaned files are tolerable
}
}
}
throw new RepositoryError(
500,
`sendMessage:insertAttachments: ${attError.message}`,
);
}
attRows = (attData ?? []) as ChatAttachmentRow[];
}
// ... (continua con project_attachments y update de channel.updated_at)
}13.2 Documentación adicional
Microsite TFG (tfg-site/)
Aplicación web estática construida con Nextra 4 (framework de documentación sobre Next.js) que presenta la memoria del TFG en formato navegable. Se encuentra en la carpeta tfg-site/ del repositorio. Incluye:
- Navegación lateral por capítulos.
- Renderizado de Markdown con soporte para diagramas Mermaid.
- Diseño visual con efecto glass-morphism inspirado en la estética del ERP.
- Despliegue independiente del ERP principal.
DESIGN.md — Sistema visual
Archivo DESIGN.md en la raíz del repositorio (367 líneas). Documenta el sistema de diseño del ERP inspirado en Linear:
- Tokens de color (paleta oscura con acentos azules).
- Tipografía y escala de espaciado.
- Componentes base (botones, inputs, modales, toasts).
- Convenciones CSS (CSS Modules + Custom Properties, sin Tailwind).
- Modo oscuro como única opción (
color-scheme: dark).
Este archivo actúa como fuente de verdad para las decisiones visuales y es referenciado por Claude Code durante la implementación de componentes para mantener coherencia estética.
Carpeta tfg/
Contiene todos los capítulos de la memoria en formato Markdown, versionados dentro del propio repositorio del proyecto. Esta decisión permite:
- Historial de cambios de la memoria con el mismo flujo Git del código.
- PRs específicas para capítulos (
docs/tfg-cap-X). - Revisión por pares de la redacción técnica.
- Generación del
.docxfinal desde los.mdcomo paso de build.
Diagramas
Todos los diagramas de la memoria están escritos en formato Mermaid embebido en los propios archivos Markdown (Cap 5 contiene 8 diagramas: casos de uso, secuencia, clases, ER, máquina de estados, arquitectura de despliegue, capas y red). Se renderizan directamente en GitHub y en el microsite. No se usan herramientas externas de diagramación.
13.3 Planificación temporal
Diagrama de Gantt (representación textual)
Fase 0: Investigación previa
|========================|
23 enero 2026 ────────── 15 abril 2026
Fase 1: Bootstrap CI/CD + primer deploy producción
|X|
16 abril 2026
Fase 2: Desarrollo intensivo (iteración vía PRs sobre producción)
|================|
17 abril ──── 5 mayo 2026
Fase 3: Estabilización + onboarding Manuel + incidente BSI
|==|
6-8 mayo 2026
Fase 4: Memoria TFG (en paralelo y luego dedicado)
|=========|
5 mayo ── 17 mayo 2026
Fase 5: Defensa
|X|
Última semana mayo 2026Desglose por fases
| Fase | Descripción | Periodo | Hitos clave |
|---|---|---|---|
| 0 | Investigación previa: flujos de trabajo con IA generativa, evaluación de tooling (Claude Code, SDKs BaaS), definición de convenciones Git, análisis de stacks, diseño del modelo de datos | 23 enero - 15 abril 2026 | Documento de convenciones Git, schema data_model.sql v1, selección stack definitivo |
| 1 | Bootstrap: primer commit, CI/CD operativo, deploy a producción en VPS Hetzner antes de cualquier feature | 16 abril 2026 | Primer commit (Actualizar contrato de datos v1), pipeline GitHub Actions activo, dominio erp.quetzy.eu en línea |
| 2 | Desarrollo intensivo: features core implementadas iterativamente con merge a main y deploy automático. Chat completo, tickets con máquina de estados, llamadas WebRTC, contexto, clientes, proyectos, horas, notificaciones, presencia | 17 abril - 5 mayo 2026 | M1-M6.5 completados, 10 features funcionales, 827 tests, 82 % coverage |
| 3 | Estabilización: corrección de bugs críticos, onboarding de Manuel Grande Écija (PR #70), incidente seguridad BSI y corrección inmediata, apertura de 15 issues de feedback | 6-8 mayo 2026 | PR #70 de Manuel, reglas UFW corregidas, 15 issues triageadas informalmente |
| 4 | Memoria TFG: redacción de los 13 capítulos, scaffold del microsite, capturas de producción | 5-17 mayo 2026 | Caps 1-9 completados (7-8 mayo), Caps 10-13 completados (10 mayo), microsite operativo |
| 5 | Defensa del TFG | Última semana mayo 2026 | Entrega 17 mayo, defensa oral |
Nota metodológica sobre la concentración temporal. El proyecto se ejecuta en un modelo de dos fases claramente diferenciadas: cuatro meses de investigación y diseño seguidos de tres semanas de implementación intensiva. Este no es un accidente de planificación sino una consecuencia directa de la metodología: la fase de investigación previa define convenciones, contratos y modelo de datos con suficiente profundidad como para que la fase de implementación se ejecute sin bloqueos de diseño. Toda la iteración se realiza contra producción real (no existe staging), lo que fuerza calidad desde el primer deploy.
13.4 Manual de usuario
Acceso e inicio de sesión
El acceso al sistema se realiza a través del navegador en la URL erp.quetzy.eu. El sistema requiere autenticación con email y contraseña; no existe registro público. Las credenciales las proporciona el administrador del equipo.
- Abrir
erp.quetzy.euen un navegador compatible (Chromium >= 100, Firefox >= 100, Safari >= 16). - Introducir email y contraseña en el formulario de login.
- Pulsar “Iniciar sesión” o Enter.
- Si las credenciales son correctas, el sistema redirige a la vista principal del ERP.
- Si son incorrectas, se muestra el mensaje «Credenciales incorrectas.» sin revelar la causa.
Vista general del shell ERP
Tras autenticarse, el usuario accede al shell principal del ERP. La cabecera (AppHeader) proporciona:
- Navegación global: Chat, Tickets, Clientes, Proyectos, Horas, Contexto.
- Indicador de notificaciones con contador de no leídas.
- Botón de usuario con opción de cerrar sesión.
Chat: enviar mensajes, audio, adjuntos, reacciones y reply-to
Enviar mensaje de texto:
- Seleccionar un canal en la lista lateral.
- Escribir el mensaje en el input inferior.
- Pulsar Enter o el botón de enviar.
Responder a un mensaje (reply-to):
- Pasar el cursor sobre un mensaje.
- Pulsar el icono de respuesta.
- El input muestra una referencia al mensaje original.
- Escribir la respuesta y enviar.
Adjuntar imagen o PDF:
- Arrastrar archivo al area de chat, o
- Pulsar el icono de adjuntar, o
- Pegar directamente con Ctrl+V (imágenes del portapapeles).
- Máximo 10 MB por archivo, 5 archivos por mensaje.
Enviar mensaje de audio:
- Pulsar el icono de micrófono.
- Grabar el mensaje.
- Pulsar detener. El audio se envía automáticamente.
Reaccionar a un mensaje:
- Pasar el cursor sobre un mensaje.
- Pulsar el icono de reaccion.
- Seleccionar emoji. Toggle: pulsar de nuevo retira la reacción.
Llamadas: iniciar, unirse y compartir pantalla
Iniciar llamada:
- Dentro de un canal de chat, pulsar el icono de teléfono en la cabecera.
- El sistema conecta vía WebRTC. El micrófono se activa automáticamente.
- Los demas miembros del canal ven un banner de “llamada activa”.
Unirse a llamada existente:
- Pulsar el banner de “llamada activa” que aparece en el canal.
- El sistema conecta al room existente.
Compartir pantalla:
- Durante una llamada, pulsar el botón de compartir pantalla.
- Seleccionar la ventana o pantalla a compartir en el diálogo del navegador.
- Los demas participantes ven la pantalla compartida en un panel dedicado.
Finalizar:
- Pulsar el botón rojo de colgar. Si es el último participante, la llamada se cierra automáticamente.
Tickets: crear y gestionar con state rail
Crear ticket:
- Ir a Tickets en la navegación.
- Pulsar “Nuevo ticket”.
- Rellenar: titulo, tipo, proyecto asociado.
- El ticket se crea en estado
received.
Transicionar ticket:
- Abrir el detalle de un ticket.
- En el rail lateral se muestra el estado actual y los estados siguientes disponibles.
- Pulsar la transicion deseada.
- Si tiene guard (ej.
requireComplexity), rellenar el campo obligatorio. - Si requiere razón (
requireReason), introducir justificación. - Confirmar. El sistema registra la transición con timestamp y autor.
Clientes y proyectos
Gestionar clientes:
- Ir a Clientes. Se muestra la lista con nombre, tier y estado.
- Pulsar “Nuevo cliente” para crear.
- Pulsar un cliente para ver detalle con proyectos asociados.
Gestionar proyectos:
- Ir a Proyectos. Se muestra la lista con cliente asociado y metadatos.
- Pulsar “Nuevo proyecto” para crear (requiere seleccionar cliente).
- Al crear un proyecto se genera automáticamente un canal de chat vinculado.
Control horario con timer
Iniciar timer:
- Ir a Horas.
- Pulsar “Iniciar timer”.
- Seleccionar scope: cliente, proyecto y opcionalmente ticket.
- El timer comienza a contar. Se muestra en la cabecera del ERP.
Pausar / reanudar:
- Pulsar el botón de pausa en el timer activo. Pulsar de nuevo para reanudar.
Detener y guardar:
- Pulsar el botón de detener.
- Opcionalmente añadir notas sobre el trabajo realizado.
- La entrada de tiempo se guarda con duración calculada.
Contexto técnico (context_items y raw_context)
Consultar context_items:
- Ir a Contexto. Se muestra el bucket con items filtrables por estado (propuesto, aprobado, obsoleto).
Aprobar / rechazar item propuesto:
- En un item con estado
proposed, pulsar Aprobar o Rechazar. - Si se rechaza, introducir razón de rechazo.
Ingestar raw_context:
- Pulsar “Añadir contexto crudo”.
- Pegar texto (notas de reunión, emails, documentación).
- Pulsar “Extraer” para invocar la skill de IA
context_extractor. - Revisar los items extraidos y aprobar/rechazar individualmente.
Notificaciones
- El contador en la cabecera muestra notificaciones no leidas.
- Pulsar el icono para abrir la lista.
- Marcar como leída individualmente o en bloque con «Marcar todas como leídas».
Cerrar sesión (logout)
- Pulsar el botón de usuario en la cabecera.
- Seleccionar «Cerrar sesión».
- El sistema limpia la presencia, destruye la cookie de sesión y redirige a
/login.
13.5 Manual técnico
Despliegue desde cero en VPS nuevo
Resumen expandido del proceso documentado en Cap 9.1. Comandos exactos para reproducir el entorno.
1. Provisión del VPS:
# Hetzner Cloud: crear CX33 con Ubuntu LTS, clave SSH pública del operador
ssh deploy@<IP_VPS>2. Endurecimiento básico:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 50000:50100/udp
sudo ufw deny 5432/tcp
sudo ufw deny 5430/tcp
sudo ufw enable3. Instalación de Docker:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy
# Reconectar SSH para aplicar grupo4. Clonación y configuración:
sudo mkdir -p /opt/quetzy
sudo chown deploy:deploy /opt/quetzy
git clone https://github.com/Fraancoboss/Quetzy-ERP.git /opt/quetzy/erp
cd /opt/quetzy/erp5. Crear .env.production:
nano .env.production
# Contenido (valores reales, no commitear):
# INSFORGE_URL=http://host.docker.internal:7130
# INSFORGE_ANON_KEY=<anon_key_del_dashboard>
# INSFORGE_JWT_SECRET=<jwt_secret>
# LIVEKIT_API_KEY=<api_key>
# LIVEKIT_API_SECRET=<api_secret>
# LIVEKIT_URL=wss://livekit.quetzy.eu
# NEXT_PUBLIC_INSFORGE_URL=https://internal.quetzy.eu
# NEXT_PUBLIC_INSFORGE_ANON_KEY=<anon_key>
# NEXT_PUBLIC_LIVEKIT_URL=wss://livekit.quetzy.eu
# CRON_SECRET=<secreto_alta_entropia>6. Primer despliegue:
docker compose -f docker-compose.prod.yml up -d
# Caddy provisiona certificados TLS automáticamente en el primer arranque
curl --fail https://erp.quetzy.eu/api/healthVariables de entorno
| Variable | Descripción | Donde se usa |
|---|---|---|
INSFORGE_URL | URL interna del backend Insforge | Server-side (SDK singleton) |
INSFORGE_ANON_KEY | Clave anónima para el SDK | Server-side + client browser |
INSFORGE_JWT_SECRET | Secreto para verificar JWT con jose | Server-side (session.ts) |
LIVEKIT_API_KEY | Clave API de LiveKit server | Server-side (generación tokens) |
LIVEKIT_API_SECRET | Secreto API de LiveKit server | Server-side (firma JWT llamadas) |
LIVEKIT_URL | URL WebSocket del servidor LiveKit | Client browser (conexión room) |
NEXT_PUBLIC_INSFORGE_URL | URL publica de Insforge (via Caddy) | Client browser |
NEXT_PUBLIC_INSFORGE_ANON_KEY | Anon key pública | Client browser |
NEXT_PUBLIC_LIVEKIT_URL | URL pública de LiveKit (vía Caddy) | Client browser |
ERP_DATA_SOURCE | Selector: mock o insforge | Factory de repositorios |
CRON_SECRET | Secreto del endpoint cron | API route /api/cron/notifications |
ENABLE_TEST_HARNESS | Habilita endpoints de test e2e | Middleware |
LIVEKIT_CONFIG | YAML en base64 de configuración LiveKit | Contenedor livekit |
Comandos operativos
# Ver logs en tiempo real de todos los servicios
docker compose -f docker-compose.prod.yml logs -f
# Ver logs solo del ERP
docker compose -f docker-compose.prod.yml logs -f erp
# Reiniciar LiveKit (ej. tras cambio de config)
docker compose -f docker-compose.prod.yml restart livekit
# Reiniciar ERP sin afectar otros servicios
docker compose -f docker-compose.prod.yml restart erp
# Rebuild y redeploy del ERP
docker compose -f docker-compose.prod.yml build erp && docker compose -f docker-compose.prod.yml up -d erp
# Health check manual
curl --fail https://erp.quetzy.eu/api/health
# Ver estado de todos los contenedores
docker compose -f docker-compose.prod.yml psBackup de base de datos
# Backup completo de PostgreSQL (ejecutar en el VPS)
docker exec insforge-postgres-1 pg_dump -U postgres -d postgres --clean --if-exists > /opt/quetzy/backups/backup_$(date +%Y%m%d_%H%M%S).sql
# Restaurar desde backup
docker exec -i insforge-postgres-1 psql -U postgres -d postgres < /opt/quetzy/backups/backup_YYYYMMDD_HHMMSS.sqlRollback de deploy
# 1. Identificar commit anterior funcional
git log --oneline -5
# 2. Revertir al commit anterior
git revert HEAD --no-edit
git push origin main
# 3. GitHub Actions desplegará automáticamente el revert
# 4. Verificar health check tras deploy
curl --fail --retry 5 --retry-delay 3 https://erp.quetzy.eu/api/healthAlternativa manual (sin esperar CI/CD):
ssh deploy@178.104.179.244
cd /opt/quetzy/erp
git pull origin main
docker compose -f docker-compose.prod.yml build erp
docker compose -f docker-compose.prod.yml up -d erpAcceso SSH y políticas
- Autenticación exclusivamente por clave pública (contraseña deshabilitada).
- Usuario de deploy:
deploycon permisossudo. - Clave SSH almacenada como GitHub Secret (
SSH_PRIVATE_KEY) para CI/CD. - Acceso humano: clave personal del operador añadida a
~/.ssh/authorized_keys.
Renovación PAT GitHub
El token de acceso personal (PAT) fine-grained de GitHub tiene expiración configurable. Para renovar:
- GitHub Settings > Developer settings > Fine-grained personal access tokens.
- Regenerar con permisos
repo:read(minimo necesario para deploy). - Actualizar el secreto
GH_PATen el VPS si aplica.
Troubleshooting común
| Problema | Diagnóstico | Solución |
|---|---|---|
| TLS no renueva | docker compose logs caddy buscar errores ACME | Verificar puertos 80/443 abiertos en UFW; Caddy necesita responder al challenge HTTP-01 |
| Health check falla tras deploy | docker compose logs erp --tail 50 | Verificar que .env.production tiene todas las vars; rebuild imagen si cambió package.json |
| LiveKit puerto bloqueado | sudo ufw status + nc -zvu <IP> 50000 desde exterior | Confirmar regla 50000:50100/udp ALLOW en UFW |
| Insforge no alcanzable desde ERP | docker compose exec erp curl http://host.docker.internal:7130/health | Verificar que Insforge está corriendo fuera de Compose y el puerto 7130 está bind a host |
| WebRTC sin audio en red corporativa | Logs LiveKit: no ICE candidates | Red con NAT estricto bloquea UDP. Fallback TCP 7881 o desplegar servidor TURN (deuda) |
| Base de datos llena | docker exec insforge-postgres-1 psql -U postgres -c "SELECT pg_size_pretty(pg_database_size('postgres'))" | Limpieza de tablas de log, upgrade de disco en Hetzner |
Cron jobs configurados
| Job | Frecuencia | Endpoint/Comando | Propósito |
|---|---|---|---|
| Notificaciones de VPS | Manual / cron VPS | curl -H "x-cron-secret: $CRON_SECRET" https://erp.quetzy.eu/api/cron/notifications | Generar alertas de renovación, mantenimiento |
En v1 no hay cron automatizado configurado en el sistema operativo. La ejecución es manual o bajo demanda. La automatización con crontab del VPS queda como mejora post-TFG.
Estructura del repositorio (alto nivel)
ia-erp/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Login, logout (Server Actions)
│ │ ├── (erp)/ # Shell principal + páginas protegidas
│ │ └── api/ # Route Handlers REST (44 endpoints)
│ ├── features/ # Módulos por dominio funcional
│ │ ├── calls/ # WebRTC / LiveKit
│ │ ├── chat/ # Mensajería en tiempo real
│ │ ├── clients/ # Gestión de clientes
│ │ ├── context/ # context_items + raw_context
│ │ ├── erp/ # Shell, header, componentes compartidos
│ │ ├── hours/ # Control horario
│ │ ├── notifications/ # Notificaciones internas
│ │ ├── presence/ # Presencia online
│ │ ├── projects/ # Gestión de proyectos
│ │ └── tickets/ # Máquina de estados + pipeline
│ ├── lib/ # Capas transversales
│ │ ├── ai/ # AIProvider + Skills
│ │ ├── auth/ # Session, JWT, Insforge client
│ │ ├── contract/ # 18 schemas Zod
│ │ └── repositories/ # Interfaces + implementación Insforge
│ └── mocks/ # Implementacion mock + seeds
├── e2e/ # Tests Playwright (5 specs)
├── tfg/ # Memoria TFG (este documento)
├── tfg-site/ # Microsite documentación (Nextra 4)
├── docker-compose.prod.yml # Orquestación producción
├── Caddyfile # Configuracion reverse proxy
├── DESIGN.md # Sistema visual (367 líneas)
├── data_model.sql # Schema SQL (20 tablas)
└── .github/workflows/ # CI/CD GitHub Actions