first commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.env
|
||||
.env.*
|
||||
**/node_modules
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.env
|
||||
/.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.DS_Store
|
||||
/.turbo
|
||||
/.nuxt
|
||||
251
PLAN.md
Normal file
251
PLAN.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# PLAN - Radar automático de datos públicos (gob-alert)
|
||||
|
||||
## Resumen
|
||||
- **Idea**: Radar automático sobre datos.gob.es — detección, curación y normalización.
|
||||
- **Stack elegido**: Nuxt (frontend), Nest (API), con turborepo monorepo.
|
||||
- **Clientes objetivo**: consultoras/gestorías, pymes, universidades.
|
||||
|
||||
## Objetivo del MVP
|
||||
- Indexar catálogo y guardar historial.
|
||||
- Detectar nuevos/actualizados datasets.
|
||||
- Normalización básica y perfiles de alerta.
|
||||
- Dashboard simple y alertas por Telegram/email.
|
||||
|
||||
## MVP 30 días
|
||||
|
||||
### Semana 1 — Catálogo e historización
|
||||
- Indexar `datos.gob.es` (API + SPARQL).
|
||||
- Guardar metadatos y versiones en Postgres.
|
||||
|
||||
### Semana 2 — Clasificación y normalización
|
||||
- Clasificadores (organismo, territorio, tema).
|
||||
- Normalizadores CSV/JSON → esquema común.
|
||||
|
||||
### Semana 3 — Alertas y perfiles
|
||||
- Perfiles no técnicos, motor de reglas, entrega (Telegram/email).
|
||||
|
||||
### Semana 4 — Dashboard y pilotos
|
||||
- Dashboard: novedades, cambios, tendencias.
|
||||
- Onboard 3 pilotos y recoger feedback.
|
||||
|
||||
## Arquitectura (2 mini-PCs)
|
||||
|
||||
- **Mini-PC 1 — Ingesta & procesamiento**: workers Python (cron/queues), ingesta API/SPARQL, normalización, Postgres.
|
||||
- **Mini-PC 2 — Producto**: `Nest` API, `Nuxt` dashboard, alertas (Telegram/SMTP), backups.
|
||||
- **Comunicación**: REST + colas ligeras (Redis opcional).
|
||||
|
||||
## Componentes clave
|
||||
- Ingestor, Normalizador, Clasificador, Sistema de Alertas, Dashboard, Admin (planes/usuarios).
|
||||
|
||||
## Monetización
|
||||
- Plan Básico 15€/mes, Pro 39€/mes, Empresa 99€/mes.
|
||||
|
||||
## KPIs iniciales
|
||||
- Tiempo medio de detección, cobertura del catálogo, tasa de alertas útiles, conversión piloto→cliente.
|
||||
|
||||
## Roadmap 6 meses
|
||||
- Integrar BOE y portales autonómicos, ML para clasificación, exportes e integraciones B2B.
|
||||
|
||||
---
|
||||
|
||||
Este documento complementa la `TODO` principal y sirve como referencia rápida para el MVP.
|
||||
|
||||
# PLAN — Radar automático de datos públicos
|
||||
|
||||
## Resumen
|
||||
- Idea: Radar automático de datos públicos — alertas y normalización sobre datos.gob.es.
|
||||
- Valor: Detección temprana + curación + normalización; entregas accionables, no datos crudos.
|
||||
- Clientes objetivo: consultoras/gestorías, pymes, universidades/proyectos.
|
||||
|
||||
## Objetivo del MVP
|
||||
- Indexar catálogo y guardar histórico.
|
||||
- Detectar nuevos/actualizados datasets.
|
||||
- Normalización básica y perfiles de alertas.
|
||||
- Dashboard simple + alertas Telegram/email.
|
||||
- Piloto con 3 clientes en 30 días.
|
||||
|
||||
## MVP 30 Días (entregables por semana)
|
||||
- Semana 1 — Catálogo e historización: indexar datos.gob.es (API + SPARQL), almacenar metadatos y versiones en Postgres, endpoint interno para consultar catálogo.
|
||||
- Semana 2 — Clasificación y normalización básica: clasificadores por organismo/territorio/tema, normalizadores CSV→esquema común (fechas, importes, provincias).
|
||||
- Semana 3 — Alertas y perfiles: UI mínima para definir perfiles, motor de reglas, entrega por Telegram/email.
|
||||
- Semana 4 — Dashboard y pilotos: dashboard con novedades/cambios/tendencias, onboarding 3 clientes piloto, ajustes según feedback.
|
||||
|
||||
## Arquitectura (2 mini-PCs)
|
||||
- Mini-PC 1 — Ingesta y procesamiento: Python workers (cron/queues), consumidores API+SPARQL, normalizadores, Postgres (principal), SQLite auxiliar para caches.
|
||||
- Mini-PC 2 — Producto y entrega: Nest (API pública), frontend (Nuxt) dashboard, servicios de alertas (Telegram bot, SMTP), backups y monitorización.
|
||||
- Comunicación: API HTTP/REST + colas ligeras (Redis opcional).
|
||||
- Stack recomendado: Nuxt (frontend), Nest (backend), Python para ingestion, Playwright solo si PDF scraping necesario.
|
||||
|
||||
## Componentes clave
|
||||
- Ingestor: jobs programados, detección de cambios, versionado.
|
||||
- Normalizador: mapeos configurables, transformaciones reutilizables.
|
||||
- Clasificador: reglas + etiquetas (organismo, tema, territorio).
|
||||
- Alerta: perfiles de usuario, reglas de entrega, deduplicación.
|
||||
- Dashboard: novedades, cambios, tendencias, histórico.
|
||||
- Admin: usuarios, planes, facturación, logs.
|
||||
|
||||
## Monetización
|
||||
- Plan Básico – 15€/mes
|
||||
- Plan Pro – 39€/mes
|
||||
- Plan Empresa – 99€/mes
|
||||
|
||||
## KPIs iniciales
|
||||
- Tiempo medio hasta detectar un nuevo dataset.
|
||||
- Cobertura del catálogo (% datasets indexados).
|
||||
- Tasa de alertas útiles (feedback piloto).
|
||||
- Conversiones piloto→pagos.
|
||||
|
||||
## Riesgos y mitigaciones
|
||||
- Datos heterogéneos: empezar con CSV/JSON.
|
||||
- Dependencia API: historizar metadatos y tener plan B.
|
||||
- Escalabilidad en mini-PCs: diseñar componentes desacoplados.
|
||||
|
||||
## Roadmap 6 meses
|
||||
- Integrar BOE y portales autonómicos.
|
||||
- ML para clasificación automática de temas.
|
||||
- Exportes y reportes automáticos.
|
||||
- Integraciones B2B (SFTP, webhooks).
|
||||
|
||||
---
|
||||
|
||||
Archivo creado automáticamente: estructura inicial de monorepo sugerida en `README.md`.
|
||||
|
||||
## Estado de tareas (resumen)
|
||||
|
||||
- [x] Definir alcance y métricas
|
||||
- [x] Diseñar arquitectura 2 mini-PCs
|
||||
- [x] Indexar catálogo datos.gob.es
|
||||
- [x] Normalización y enriquecimiento (en progreso)
|
||||
- [x] Implementar motor de descubrimiento
|
||||
- [x] Sistema de alertas profesionales
|
||||
- [x] Panel MVP (Dashboard)
|
||||
- [x] Auth, usuarios y planes (en progreso)
|
||||
- [ ] Piloto con clientes
|
||||
- [x] Monetización y política de precios
|
||||
- [x] Monitorización, logs y backups
|
||||
- [x] Documentación y landing
|
||||
- [ ] Pruebas y despliegue en mini-PCs
|
||||
- [ ] Iteración y roadmap 6 meses
|
||||
|
||||
### Tareas ya realizadas
|
||||
|
||||
- [x] Esqueleto Nest en `apps/api`
|
||||
- [x] Dev `docker-compose.override.yml` (desarrollo)
|
||||
- [x] Ingestor básico (parcial)
|
||||
- [x] Normalización y enriquecimiento (parcial)
|
||||
- [x] Versionado / Histórico
|
||||
- [x] Programar ingesta periódica (scheduler)
|
||||
- [x] Admin endpoints for scheduler (pause/resume)
|
||||
- [x] Queue-based ingestion (Bull + Redis)
|
||||
- [x] Secure admin endpoints and frontend integration (API key + JWT fallback)
|
||||
|
||||
``` # PLAN - Radar automático de datos públicos (gob-alert)
|
||||
|
||||
## Resumen
|
||||
- **Idea**: Radar automático sobre datos.gob.es — detección, curación y normalización.
|
||||
- **Stack elegido**: Nuxt (frontend), Nest (API), con turborepo monorepo.
|
||||
- **Clientes objetivo**: consultoras/gestorías, pymes, universidades.
|
||||
|
||||
## Objetivo del MVP
|
||||
- Indexar catálogo y guardar historial.
|
||||
- Detectar nuevos/actualizados datasets.
|
||||
- Normalización básica y perfiles de alerta.
|
||||
- Dashboard simple y alertas por Telegram/email.
|
||||
|
||||
## MVP 30 días
|
||||
|
||||
### Semana 1 — Catálogo e historización
|
||||
- Indexar `datos.gob.es` (API + SPARQL).
|
||||
- Guardar metadatos y versiones en Postgres.
|
||||
|
||||
### Semana 2 — Clasificación y normalización
|
||||
- Clasificadores (organismo, territorio, tema).
|
||||
- Normalizadores CSV/JSON → esquema común.
|
||||
|
||||
### Semana 3 — Alertas y perfiles
|
||||
- Perfiles no técnicos, motor de reglas, entrega (Telegram/email).
|
||||
|
||||
### Semana 4 — Dashboard y pilotos
|
||||
- Dashboard: novedades, cambios, tendencias.
|
||||
- Onboard 3 pilotos y recoger feedback.
|
||||
|
||||
## Arquitectura (2 mini-PCs)
|
||||
|
||||
- **Mini-PC 1 — Ingesta & procesamiento**: workers Python (cron/queues), ingesta API/SPARQL, normalización, Postgres.
|
||||
- **Mini-PC 2 — Producto**: `Nest` API, `Nuxt` dashboard, alertas (Telegram/SMTP), backups.
|
||||
- **Comunicación**: REST + colas ligeras (Redis opcional).
|
||||
|
||||
## Componentes clave
|
||||
- Ingestor, Normalizador, Clasificador, Sistema de Alertas, Dashboard, Admin (planes/usuarios).
|
||||
|
||||
## Monetización
|
||||
- Plan Básico 15€/mes, Pro 39€/mes, Empresa 99€/mes.
|
||||
|
||||
## KPIs iniciales
|
||||
- Tiempo medio de detección, cobertura del catálogo, tasa de alertas útiles, conversión piloto→cliente.
|
||||
|
||||
## Roadmap 6 meses
|
||||
- Integrar BOE y portales autonómicos, ML para clasificación, exportes e integraciones B2B.
|
||||
|
||||
---
|
||||
|
||||
Este documento complementa la `TODO` principal y sirve como referencia rápida para el MVP.
|
||||
# PLAN — Radar automático de datos públicos
|
||||
|
||||
## Resumen
|
||||
- Idea: Radar automático de datos públicos — alertas y normalización sobre datos.gob.es.
|
||||
- Valor: Detección temprana + curación + normalización; entregas accionables, no datos crudos.
|
||||
- Clientes objetivo: consultoras/gestorías, pymes, universidades/proyectos.
|
||||
|
||||
## Objetivo del MVP
|
||||
- Indexar catálogo y guardar histórico.
|
||||
- Detectar nuevos/actualizados datasets.
|
||||
- Normalización básica y perfiles de alertas.
|
||||
- Dashboard simple + alertas Telegram/email.
|
||||
- Piloto con 3 clientes en 30 días.
|
||||
|
||||
## MVP 30 Días (entregables por semana)
|
||||
- Semana 1 — Catálogo e historización: indexar datos.gob.es (API + SPARQL), almacenar metadatos y versiones en Postgres, endpoint interno para consultar catálogo.
|
||||
- Semana 2 — Clasificación y normalización básica: clasificadores por organismo/territorio/tema, normalizadores CSV→esquema común (fechas, importes, provincias).
|
||||
- Semana 3 — Alertas y perfiles: UI mínima para definir perfiles, motor de reglas, entrega por Telegram/email.
|
||||
- Semana 4 — Dashboard y pilotos: dashboard con novedades/cambios/tendencias, onboarding 3 clientes piloto, ajustes según feedback.
|
||||
|
||||
## Arquitectura (2 mini-PCs)
|
||||
- Mini-PC 1 — Ingesta y procesamiento: Python workers (cron/queues), consumidores API+SPARQL, normalizadores, Postgres (principal), SQLite auxiliar para caches.
|
||||
- Mini-PC 2 — Producto y entrega: Nest (API pública), frontend (Nuxt) dashboard, servicios de alertas (Telegram bot, SMTP), backups y monitorización.
|
||||
- Comunicación: API HTTP/REST + colas ligeras (Redis opcional).
|
||||
- Stack recomendado: Nuxt (frontend), Nest (backend), Python para ingestion, Playwright solo si PDF scraping necesario.
|
||||
|
||||
## Componentes clave
|
||||
- Ingestor: jobs programados, detección de cambios, versionado.
|
||||
- Normalizador: mapeos configurables, transformaciones reutilizables.
|
||||
- Clasificador: reglas + etiquetas (organismo, tema, territorio).
|
||||
- Alerta: perfiles de usuario, reglas de entrega, deduplicación.
|
||||
- Dashboard: novedades, cambios, tendencias, histórico.
|
||||
- Admin: usuarios, planes, facturación, logs.
|
||||
|
||||
## Monetización
|
||||
- Plan Básico – 15€/mes
|
||||
- Plan Pro – 39€/mes
|
||||
- Plan Empresa – 99€/mes
|
||||
|
||||
## KPIs iniciales
|
||||
- Tiempo medio hasta detectar un nuevo dataset.
|
||||
- Cobertura del catálogo (% datasets indexados).
|
||||
- Tasa de alertas útiles (feedback piloto).
|
||||
- Conversiones piloto→pagos.
|
||||
|
||||
## Riesgos y mitigaciones
|
||||
- Datos heterogéneos: empezar con CSV/JSON.
|
||||
- Dependencia API: historizar metadatos y tener plan B.
|
||||
- Escalabilidad en mini-PCs: diseñar componentes desacoplados.
|
||||
|
||||
## Roadmap 6 meses
|
||||
- Integrar BOE y portales autonómicos.
|
||||
- ML para clasificación automática de temas.
|
||||
- Exportes y reportes automáticos.
|
||||
- Integraciones B2B (SFTP, webhooks).
|
||||
|
||||
---
|
||||
|
||||
Archivo creado automáticamente: estructura inicial de monorepo sugerida en `README.md`.
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# gob-alert
|
||||
|
||||
Monorepo inicial para el proyecto "gob-alert".
|
||||
|
||||
Stack: `Nuxt` (frontend), `Nest` (API), `turborepo`.
|
||||
|
||||
Instrucciones rápidas:
|
||||
|
||||
1. Instalar `pnpm` (recomendado) y `turbo` si no están instalados.
|
||||
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. Desarrollo: ejecuta `pnpm run dev` (lanza `turbo run dev`).
|
||||
|
||||
3. Docker / despliegue rápido (útil para Coolify o despliegue local):
|
||||
|
||||
```bash
|
||||
# Desde la raíz del repo
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Coolify: este `docker-compose.yml` incluye `api`, `web`, `postgres`, `redis` y `adminer` — Coolify puede usar las imágenes construidas aquí o construir directamente desde el contexto del repo.
|
||||
|
||||
Notas:
|
||||
- Las apps `apps/web` y `apps/api` contienen paquetes iniciales (placeholders).
|
||||
- Si quieres que genere el esqueleto completo de `Nuxt` y `Nest`, lo hago en el siguiente paso.
|
||||
# gob-alert
|
||||
|
||||
Monorepo skeleton for the "Radar automático de datos públicos" project.
|
||||
|
||||
Structure:
|
||||
- `apps/api` — minimal Nest backend
|
||||
- `apps/web` — minimal Nuxt frontend
|
||||
- `packages/shared` — shared types/utilities
|
||||
|
||||
Quick dev (root):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will run `turbo run dev` and start both `api` and `web` in dev mode (if dependencies installed).
|
||||
|
||||
See `PLAN.md` for project plan and MVP milestones.
|
||||
|
||||
Documentation:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/DEPLOYMENT.md`
|
||||
- `docs/OPERATIONS.md`
|
||||
- `docs/KPIS.md`
|
||||
4
apps/api/.env.example
Normal file
4
apps/api/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
PORT=3000
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/gob_alert
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
15
apps/api/Dockerfile
Normal file
15
apps/api/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# install deps
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# copy sources and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
5
apps/api/README.md
Normal file
5
apps/api/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# apps/api
|
||||
|
||||
Directorio para la API `Nest`.
|
||||
|
||||
Contiene los scripts básicos; puedo generar el esqueleto de `Nest` (controladores, módulos, servicios) si confirmas.
|
||||
37
apps/api/package.json
Normal file
37
apps/api/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bull": "^0.5.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.0.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"axios": "^1.4.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bull": "^3.29.4",
|
||||
"nodemailer": "^6.9.13",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.10.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0",
|
||||
"typeorm": "^0.3.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
||||
20
apps/api/src/admin/admin-alerts.controller.ts
Normal file
20
apps/api/src/admin/admin-alerts.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
|
||||
@Controller('admin/alerts')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminAlertsController {
|
||||
constructor(
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly runs: Repository<AlertRunEntity>,
|
||||
) {}
|
||||
|
||||
@Get('runs')
|
||||
async listRuns(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.runs.find({ order: { startedAt: 'DESC' }, take });
|
||||
}
|
||||
}
|
||||
19
apps/api/src/admin/admin-backup.controller.ts
Normal file
19
apps/api/src/admin/admin-backup.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { BackupService } from '../backup/backup.service';
|
||||
|
||||
@Controller('admin/backup')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminBackupController {
|
||||
constructor(private readonly backup: BackupService) {}
|
||||
|
||||
@Post('run')
|
||||
async run() {
|
||||
return this.backup.runBackup();
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
async list() {
|
||||
return this.backup.listBackups();
|
||||
}
|
||||
}
|
||||
65
apps/api/src/admin/admin-monitor.controller.ts
Normal file
65
apps/api/src/admin/admin-monitor.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
|
||||
@Controller('admin/monitor')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminMonitorController {
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly items: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly versions: Repository<CatalogItemVersionEntity>,
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly profiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly deliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly ingestRuns: Repository<IngestRunEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly alertRuns: Repository<AlertRunEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async status() {
|
||||
const [
|
||||
items,
|
||||
versions,
|
||||
profiles,
|
||||
deliveries,
|
||||
users,
|
||||
lastIngest,
|
||||
lastAlerts,
|
||||
] = await Promise.all([
|
||||
this.items.count(),
|
||||
this.versions.count(),
|
||||
this.profiles.count(),
|
||||
this.deliveries.count(),
|
||||
this.users.count(),
|
||||
this.ingestRuns.find({ order: { startedAt: 'DESC' }, take: 1 }),
|
||||
this.alertRuns.find({ order: { startedAt: 'DESC' }, take: 1 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
counts: {
|
||||
catalogItems: items,
|
||||
catalogVersions: versions,
|
||||
alertProfiles: profiles,
|
||||
alertDeliveries: deliveries,
|
||||
users,
|
||||
},
|
||||
lastIngest: lastIngest[0] || null,
|
||||
lastAlerts: lastAlerts[0] || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
apps/api/src/admin/admin-plans.controller.ts
Normal file
24
apps/api/src/admin/admin-plans.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { PlansService } from '../plans/plans.service';
|
||||
|
||||
@Controller('admin/plans')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminPlansController {
|
||||
constructor(private readonly plans: PlansService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.plans.listAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: any) {
|
||||
return this.plans.create(body);
|
||||
}
|
||||
|
||||
@Post(':id')
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.plans.update(id, body);
|
||||
}
|
||||
}
|
||||
42
apps/api/src/admin/admin-users.controller.ts
Normal file
42
apps/api/src/admin/admin-users.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminUsersController {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 50, 200) : 50;
|
||||
return this.users.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take,
|
||||
relations: ['plan'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/plan')
|
||||
async assignPlan(@Param('id') id: string, @Body() body: any) {
|
||||
const user = await this.users.findOne({ where: { id } });
|
||||
if (!user) return { ok: false, error: 'User not found' };
|
||||
user.planId = body.planId || null;
|
||||
await this.users.save(user);
|
||||
return { ok: true, userId: user.id, planId: user.planId };
|
||||
}
|
||||
|
||||
@Post(':id/role')
|
||||
async updateRole(@Param('id') id: string, @Body() body: any) {
|
||||
const user = await this.users.findOne({ where: { id } });
|
||||
if (!user) return { ok: false, error: 'User not found' };
|
||||
if (body.role) user.role = body.role;
|
||||
await this.users.save(user);
|
||||
return { ok: true, userId: user.id, role: user.role };
|
||||
}
|
||||
}
|
||||
38
apps/api/src/admin/admin.controller.ts
Normal file
38
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Controller, Post, HttpCode, UseGuards, Get, Query } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Controller('admin/ingest')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminIngestController {
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly runs: Repository<IngestRunEntity>,
|
||||
) {}
|
||||
|
||||
@Post('pause')
|
||||
@HttpCode(200)
|
||||
pause() {
|
||||
const job = this.schedulerRegistry.getCronJob('ingest_job');
|
||||
job.stop();
|
||||
return { ok: true, paused: true };
|
||||
}
|
||||
|
||||
@Post('resume')
|
||||
@HttpCode(200)
|
||||
resume() {
|
||||
const job = this.schedulerRegistry.getCronJob('ingest_job');
|
||||
job.start();
|
||||
return { ok: true, resumed: true };
|
||||
}
|
||||
|
||||
@Get('runs')
|
||||
async listRuns(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.runs.find({ order: { startedAt: 'DESC' }, take });
|
||||
}
|
||||
}
|
||||
46
apps/api/src/admin/admin.module.ts
Normal file
46
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AdminIngestController } from './admin.controller';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { AdminAlertsController } from './admin-alerts.controller';
|
||||
import { AdminPlansController } from './admin-plans.controller';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlansModule } from '../plans/plans.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { AdminBackupController } from './admin-backup.controller';
|
||||
import { AdminMonitorController } from './admin-monitor.controller';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
PlansModule,
|
||||
BackupModule,
|
||||
TypeOrmModule.forFeature([
|
||||
IngestRunEntity,
|
||||
AlertRunEntity,
|
||||
UserEntity,
|
||||
CatalogItemEntity,
|
||||
CatalogItemVersionEntity,
|
||||
AlertProfileEntity,
|
||||
AlertDeliveryEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
AdminIngestController,
|
||||
AdminAlertsController,
|
||||
AdminPlansController,
|
||||
AdminUsersController,
|
||||
AdminBackupController,
|
||||
AdminMonitorController,
|
||||
],
|
||||
providers: [AdminAuthGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
44
apps/api/src/alerts/alerts.controller.ts
Normal file
44
apps/api/src/alerts/alerts.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
|
||||
import { AlertsService } from './alerts.service';
|
||||
|
||||
@Controller('alerts')
|
||||
export class AlertsController {
|
||||
constructor(private readonly alerts: AlertsService) {}
|
||||
|
||||
private getUserId() {
|
||||
return process.env.DEFAULT_ALERT_USER || '00000000-0000-0000-0000-000000000001';
|
||||
}
|
||||
|
||||
@Get('profiles')
|
||||
async listProfiles() {
|
||||
return this.alerts.listProfiles(this.getUserId());
|
||||
}
|
||||
|
||||
@Post('profiles')
|
||||
async createProfile(@Body() body: any) {
|
||||
return this.alerts.createProfile(this.getUserId(), body);
|
||||
}
|
||||
|
||||
@Post('profiles/:id')
|
||||
async updateProfile(@Param('id') id: string, @Body() body: any) {
|
||||
return this.alerts.updateProfile(this.getUserId(), id, body);
|
||||
}
|
||||
|
||||
@Delete('profiles/:id')
|
||||
async deleteProfile(@Param('id') id: string) {
|
||||
return this.alerts.deleteProfile(this.getUserId(), id);
|
||||
}
|
||||
|
||||
@Post('run')
|
||||
async runAll() {
|
||||
return this.alerts.runAllActive();
|
||||
}
|
||||
|
||||
@Post('run/:id')
|
||||
async runProfile(@Param('id') id: string) {
|
||||
const profiles = await this.alerts.listProfiles(this.getUserId());
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return { ok: false, error: 'Profile not found' };
|
||||
return this.alerts.runProfile(profile);
|
||||
}
|
||||
}
|
||||
23
apps/api/src/alerts/alerts.module.ts
Normal file
23
apps/api/src/alerts/alerts.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertsController } from './alerts.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TelegramService } from './telegram.service';
|
||||
import { AlertsScheduler } from './alerts.scheduler';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AlertProfileEntity, AlertDeliveryEntity, AlertRunEntity, ClassificationTagEntity]),
|
||||
CatalogModule,
|
||||
],
|
||||
providers: [AlertsService, MailerService, TelegramService, AlertsScheduler],
|
||||
controllers: [AlertsController],
|
||||
exports: [AlertsService],
|
||||
})
|
||||
export class AlertsModule {}
|
||||
25
apps/api/src/alerts/alerts.scheduler.ts
Normal file
25
apps/api/src/alerts/alerts.scheduler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { AlertsService } from './alerts.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlertsScheduler {
|
||||
private readonly logger = new Logger(AlertsScheduler.name);
|
||||
|
||||
constructor(private readonly alerts: AlertsService) {}
|
||||
|
||||
@Cron(process.env.ALERTS_CRON || CronExpression.EVERY_DAY_AT_8AM, { name: 'alerts_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.ALERTS_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled alerts run');
|
||||
try {
|
||||
const res = await this.alerts.runAllActive();
|
||||
const total = res?.results?.reduce((acc: number, r: any) => acc + (r.sent || 0), 0) || 0;
|
||||
this.logger.log(`Alerts sent: ${total}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error('Alerts scheduler failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
apps/api/src/alerts/alerts.service.ts
Normal file
210
apps/api/src/alerts/alerts.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TelegramService } from './telegram.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly profiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly deliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly runs: Repository<AlertRunEntity>,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly mailer: MailerService,
|
||||
private readonly telegram: TelegramService,
|
||||
) {}
|
||||
|
||||
async listProfiles(userId: string) {
|
||||
return this.profiles.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async createProfile(userId: string, payload: Partial<AlertProfileEntity>) {
|
||||
const profile = this.profiles.create({
|
||||
userId,
|
||||
name: payload.name || 'Perfil',
|
||||
isActive: payload.isActive ?? true,
|
||||
frequency: payload.frequency || 'daily',
|
||||
channel: payload.channel || 'email',
|
||||
channelTarget: payload.channelTarget,
|
||||
queryText: payload.queryText,
|
||||
rules: payload.rules,
|
||||
});
|
||||
return this.profiles.save(profile);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, id: string, payload: Partial<AlertProfileEntity>) {
|
||||
const profile = await this.profiles.findOne({ where: { id, userId } });
|
||||
if (!profile) return null;
|
||||
Object.assign(profile, payload);
|
||||
return this.profiles.save(profile);
|
||||
}
|
||||
|
||||
async deleteProfile(userId: string, id: string) {
|
||||
const profile = await this.profiles.findOne({ where: { id, userId } });
|
||||
if (!profile) return null;
|
||||
await this.profiles.delete({ id });
|
||||
return profile;
|
||||
}
|
||||
|
||||
async runProfile(profile: AlertProfileEntity) {
|
||||
const days = profile.rules?.updatedWithinDays;
|
||||
const changes = days ? await this.catalog.listChangesSince(this.startOfDayOffset(days)) : await this.catalog.listChanges(50);
|
||||
const itemIds = Array.from(new Set(changes.map((c) => c.itemId).filter(Boolean)));
|
||||
const tags = itemIds.length
|
||||
? await this.tags.find({ where: { itemId: In(itemIds) } })
|
||||
: [];
|
||||
const tagsByItem = new Map<string, ClassificationTagEntity[]>();
|
||||
for (const tag of tags) {
|
||||
const list = tagsByItem.get(tag.itemId) || [];
|
||||
list.push(tag);
|
||||
tagsByItem.set(tag.itemId, list);
|
||||
}
|
||||
|
||||
const matches = changes.filter((c) => this.matchProfile(profile, c, tagsByItem.get(c.itemId) || []));
|
||||
|
||||
if (!matches.length) return { ok: true, sent: 0 };
|
||||
|
||||
let sent = 0;
|
||||
for (const change of matches) {
|
||||
const already = await this.deliveries.findOne({ where: { profileId: profile.id, itemId: change.itemId } });
|
||||
if (already) continue;
|
||||
|
||||
const message = this.formatMessage(profile, change, tagsByItem.get(change.itemId) || []);
|
||||
let status: 'sent' | 'failed' = 'sent';
|
||||
let details = '';
|
||||
try {
|
||||
if (profile.channel === 'telegram') {
|
||||
await this.telegram.send(profile.channelTarget, message);
|
||||
} else {
|
||||
await this.mailer.send(profile.channelTarget, `gob-alert: ${profile.name}`, message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
status = 'failed';
|
||||
details = err?.message || String(err);
|
||||
this.logger.error('Alert delivery failed', details);
|
||||
}
|
||||
|
||||
await this.deliveries.save(this.deliveries.create({
|
||||
profileId: profile.id,
|
||||
itemId: change.itemId,
|
||||
channel: profile.channel,
|
||||
status,
|
||||
details,
|
||||
}));
|
||||
if (status === 'sent') sent++;
|
||||
}
|
||||
|
||||
return { ok: true, sent };
|
||||
}
|
||||
|
||||
async runAllActive() {
|
||||
const runStart = Date.now();
|
||||
const run = await this.runs.save(this.runs.create({ status: 'running' }));
|
||||
const profiles = await this.profiles.find({ where: { isActive: true } });
|
||||
const results = [] as any[];
|
||||
let sentTotal = 0;
|
||||
let failed = 0;
|
||||
for (const profile of profiles) {
|
||||
try {
|
||||
const res = await this.runProfile(profile);
|
||||
results.push({ profileId: profile.id, ...res });
|
||||
sentTotal += res.sent || 0;
|
||||
} catch (err: any) {
|
||||
failed += 1;
|
||||
results.push({ profileId: profile.id, ok: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
run.status = failed ? 'failed' : 'success';
|
||||
run.profiles = profiles.length;
|
||||
run.sent = sentTotal;
|
||||
run.failed = failed;
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
return { ok: true, runId: run.id, results };
|
||||
}
|
||||
|
||||
private matchProfile(profile: AlertProfileEntity, change: any, tags: ClassificationTagEntity[]) {
|
||||
if (!change || !change.snapshot) return false;
|
||||
const snapshot = change.snapshot;
|
||||
|
||||
if (profile.queryText) {
|
||||
const text = `${snapshot.title || ''} ${snapshot.description || ''}`.toLowerCase();
|
||||
if (!text.includes(profile.queryText.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
const rules = profile.rules || {};
|
||||
if (rules.publishers && rules.publishers.length) {
|
||||
if (!snapshot.publisher) return false;
|
||||
if (!rules.publishers.some((p) => snapshot.publisher.toLowerCase().includes(p.toLowerCase()))) return false;
|
||||
}
|
||||
if (rules.formats && rules.formats.length) {
|
||||
if (!snapshot.format) return false;
|
||||
const formatValue = String(snapshot.format).toLowerCase();
|
||||
if (!rules.formats.some((f) => formatValue.includes(f.toLowerCase()))) return false;
|
||||
}
|
||||
if (rules.updatedWithinDays) {
|
||||
if (!snapshot.updatedAt) return false;
|
||||
const updated = new Date(snapshot.updatedAt);
|
||||
const limit = new Date();
|
||||
limit.setDate(limit.getDate() - rules.updatedWithinDays);
|
||||
if (updated < limit) return false;
|
||||
}
|
||||
|
||||
if (rules.topics && rules.topics.length) {
|
||||
if (!this.matchTags(tags, 'topic', rules.topics)) return false;
|
||||
}
|
||||
|
||||
if (rules.territories && rules.territories.length) {
|
||||
if (!this.matchTags(tags, 'territory', rules.territories)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private formatMessage(profile: AlertProfileEntity, change: any, tags: ClassificationTagEntity[]) {
|
||||
const snap = change.snapshot || {};
|
||||
const topics = this.collectTags(tags, 'topic');
|
||||
const territories = this.collectTags(tags, 'territory');
|
||||
return [
|
||||
`Perfil: ${profile.name}`,
|
||||
`Evento: ${change.eventType}`,
|
||||
`Título: ${snap.title || 'sin título'}`,
|
||||
snap.publisher ? `Publicador: ${snap.publisher}` : null,
|
||||
topics.length ? `Temas: ${topics.join(', ')}` : null,
|
||||
territories.length ? `Territorios: ${territories.join(', ')}` : null,
|
||||
snap.format ? `Formato: ${snap.format}` : null,
|
||||
snap.updatedAt ? `Actualizado: ${snap.updatedAt}` : null,
|
||||
snap.sourceUrl ? `Fuente: ${snap.sourceUrl}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private matchTags(tags: ClassificationTagEntity[], type: ClassificationTagEntity['type'], rules: string[]) {
|
||||
const values = tags.filter((t) => t.type === type).map((t) => t.value.toLowerCase());
|
||||
if (!values.length) return false;
|
||||
return rules.some((rule) => values.some((value) => value.includes(rule.toLowerCase())));
|
||||
}
|
||||
|
||||
private collectTags(tags: ClassificationTagEntity[], type: ClassificationTagEntity['type']) {
|
||||
return tags.filter((t) => t.type === type).map((t) => t.value);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
28
apps/api/src/alerts/mailer.service.ts
Normal file
28
apps/api/src/alerts/mailer.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
private readonly logger = new Logger(MailerService.name);
|
||||
private readonly transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 25,
|
||||
secure: false,
|
||||
auth: process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || '' }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
async send(to: string | undefined, subject: string, text: string) {
|
||||
if (!to) {
|
||||
this.logger.warn('Mailer target missing');
|
||||
return;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: process.env.SMTP_FROM || 'gob-alert@localhost',
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
apps/api/src/alerts/telegram.service.ts
Normal file
25
apps/api/src/alerts/telegram.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class TelegramService {
|
||||
private readonly logger = new Logger(TelegramService.name);
|
||||
|
||||
async send(target: string | undefined, text: string) {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = target || process.env.TELEGRAM_DEFAULT_CHAT;
|
||||
if (!token || !chatId) {
|
||||
this.logger.warn('Telegram not configured');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.post(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error('Telegram send failed', err?.message || err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/api/src/app.controller.ts
Normal file
12
apps/api/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHealth() {
|
||||
return { ok: true, service: 'gob-alert-api' };
|
||||
}
|
||||
}
|
||||
40
apps/api/src/app.module.ts
Normal file
40
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { NormalizerModule } from './normalizer/normalizer.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AlertsModule } from './alerts/alerts.module';
|
||||
import { ClassifierModule } from './classifier/classifier.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { PlansModule } from './plans/plans.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { DiscoveryModule } from './discovery/discovery.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL || 'postgres://user:pass@localhost:5432/gob_alert',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
CatalogModule,
|
||||
NormalizerModule,
|
||||
AdminModule,
|
||||
IngestModule,
|
||||
AuthModule,
|
||||
AlertsModule,
|
||||
ClassifierModule,
|
||||
DashboardModule,
|
||||
PlansModule,
|
||||
BackupModule,
|
||||
DiscoveryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
apps/api/src/app.service.ts
Normal file
8
apps/api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello from gob-alert API';
|
||||
}
|
||||
}
|
||||
44
apps/api/src/auth/admin.guard.ts
Normal file
44
apps/api/src/auth/admin.guard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwt: JwtService, private readonly config: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers['authorization'];
|
||||
const apiKeyHeader = req.headers['x-api-key'];
|
||||
const envKey = this.config.get('API_ADMIN_KEY') || process.env.API_ADMIN_KEY || 'dev-admin-key';
|
||||
|
||||
let token: string | undefined;
|
||||
if (authHeader) {
|
||||
const raw = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
if (typeof raw === 'string' && raw.startsWith('Bearer ')) token = raw.slice(7).trim();
|
||||
else if (typeof raw === 'string' && !raw.startsWith('Bearer ')) token = raw.trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const secret = this.config.get('JWT_SECRET') || process.env.JWT_SECRET || 'dev-secret';
|
||||
const payload: any = this.jwt.verify(token, { secret });
|
||||
if (payload && payload.role === 'admin') {
|
||||
req.user = { id: payload.sub, email: payload.email, role: payload.role };
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through to api key check
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to API key header (x-api-key) or plain authorization header value
|
||||
let provided: string | undefined;
|
||||
if (apiKeyHeader) provided = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
|
||||
if (!provided && authHeader && typeof authHeader === 'string' && !authHeader.startsWith('Bearer ')) provided = authHeader;
|
||||
|
||||
if (provided && provided === envKey) return true;
|
||||
|
||||
throw new UnauthorizedException('Invalid admin credentials');
|
||||
}
|
||||
}
|
||||
23
apps/api/src/auth/auth.controller.ts
Normal file
23
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Post, Body, BadRequestException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
class RegisterDto { email: string; password: string }
|
||||
class LoginDto { email: string; password: string }
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() body: RegisterDto) {
|
||||
if (!body.email || !body.password) throw new BadRequestException('email and password required');
|
||||
return this.auth.register(body.email, body.password);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: LoginDto) {
|
||||
const user = await this.auth.validateUser(body.email, body.password);
|
||||
if (!user) throw new BadRequestException('invalid credentials');
|
||||
return this.auth.login(user);
|
||||
}
|
||||
}
|
||||
31
apps/api/src/auth/auth.module.ts
Normal file
31
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { PlansModule } from '../plans/plans.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TypeOrmModule.forFeature([UserEntity]),
|
||||
PlansModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
secret: cfg.get('JWT_SECRET') || 'dev-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
46
apps/api/src/auth/auth.service.ts
Normal file
46
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PlansService } from '../plans/plans.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly plans: PlansService,
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, role = 'user') {
|
||||
const existing = await this.users.findOne({ where: { email } });
|
||||
if (existing) throw new ConflictException('Email already registered');
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const defaultPlan = await this.plans.getDefaultPlan();
|
||||
const user = this.users.create({ email, passwordHash: hash, role, planId: defaultPlan?.id });
|
||||
await this.users.save(user);
|
||||
return { id: user.id, email: user.email, role: user.role };
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string) {
|
||||
const user = await this.users.findOne({
|
||||
where: { email },
|
||||
select: ['id', 'email', 'passwordHash', 'role', 'planId'],
|
||||
});
|
||||
if (!user) return null;
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: UserEntity) {
|
||||
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||
return { access_token: this.jwtService.sign(payload) };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.users.findOne({ where: { id } });
|
||||
}
|
||||
}
|
||||
19
apps/api/src/auth/jwt.strategy.ts
Normal file
19
apps/api/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get('JWT_SECRET') || 'dev-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return { userId: payload.sub, email: payload.email, role: payload.role };
|
||||
}
|
||||
}
|
||||
30
apps/api/src/auth/user.entity.ts
Normal file
30
apps/api/src/auth/user.entity.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class UserEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ nullable: false, select: false })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ default: 'user' })
|
||||
role: string; // 'user' | 'admin'
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
planId?: string | null;
|
||||
|
||||
@ManyToOne(() => PlanEntity, { nullable: true })
|
||||
@JoinColumn({ name: 'planId' })
|
||||
plan?: PlanEntity | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
32
apps/api/src/backup/backup.module.ts
Normal file
32
apps/api/src/backup/backup.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupScheduler } from './backup.scheduler';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CatalogItemEntity,
|
||||
CatalogItemVersionEntity,
|
||||
AlertProfileEntity,
|
||||
AlertDeliveryEntity,
|
||||
AlertRunEntity,
|
||||
IngestRunEntity,
|
||||
UserEntity,
|
||||
PlanEntity,
|
||||
ClassificationTagEntity,
|
||||
]),
|
||||
],
|
||||
providers: [BackupService, BackupScheduler],
|
||||
exports: [BackupService],
|
||||
})
|
||||
export class BackupModule {}
|
||||
24
apps/api/src/backup/backup.scheduler.ts
Normal file
24
apps/api/src/backup/backup.scheduler.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { BackupService } from './backup.service';
|
||||
|
||||
@Injectable()
|
||||
export class BackupScheduler {
|
||||
private readonly logger = new Logger(BackupScheduler.name);
|
||||
|
||||
constructor(private readonly backup: BackupService) {}
|
||||
|
||||
@Cron(process.env.BACKUP_CRON || CronExpression.EVERY_DAY_AT_2AM, { name: 'backup_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.BACKUP_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled backup triggered');
|
||||
try {
|
||||
await this.backup.runBackup();
|
||||
this.logger.log('Backup completed');
|
||||
} catch (err: any) {
|
||||
this.logger.error('Backup failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
apps/api/src/backup/backup.service.ts
Normal file
140
apps/api/src/backup/backup.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
private readonly logger = new Logger(BackupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly catalogItems: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly catalogVersions: Repository<CatalogItemVersionEntity>,
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly alertProfiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly alertDeliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly alertRuns: Repository<AlertRunEntity>,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly ingestRuns: Repository<IngestRunEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
@InjectRepository(PlanEntity)
|
||||
private readonly plans: Repository<PlanEntity>,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
async runBackup() {
|
||||
const dir = await this.ensureDir();
|
||||
const timestamp = this.formatTimestamp(new Date());
|
||||
const filename = `gob-alert-backup-${timestamp}.json`;
|
||||
const filepath = path.join(dir, filename);
|
||||
|
||||
const limit = this.getLimit();
|
||||
const [
|
||||
catalogItems,
|
||||
catalogVersions,
|
||||
alertProfiles,
|
||||
alertDeliveries,
|
||||
alertRuns,
|
||||
ingestRuns,
|
||||
users,
|
||||
plans,
|
||||
tags,
|
||||
] = await Promise.all([
|
||||
this.catalogItems.find({ take: limit }),
|
||||
this.catalogVersions.find({ take: limit }),
|
||||
this.alertProfiles.find({ take: limit }),
|
||||
this.alertDeliveries.find({ take: limit }),
|
||||
this.alertRuns.find({ take: limit }),
|
||||
this.ingestRuns.find({ take: limit }),
|
||||
this.users.find({ take: limit, relations: ['plan'] }),
|
||||
this.plans.find({ take: limit }),
|
||||
this.tags.find({ take: limit }),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
meta: {
|
||||
createdAt: new Date().toISOString(),
|
||||
limit,
|
||||
},
|
||||
catalogItems,
|
||||
catalogVersions,
|
||||
alertProfiles,
|
||||
alertDeliveries,
|
||||
alertRuns,
|
||||
ingestRuns,
|
||||
users,
|
||||
plans,
|
||||
tags,
|
||||
};
|
||||
|
||||
await fs.writeFile(filepath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.logger.log(`Backup written: ${filepath}`);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
file: filepath,
|
||||
counts: {
|
||||
catalogItems: catalogItems.length,
|
||||
catalogVersions: catalogVersions.length,
|
||||
alertProfiles: alertProfiles.length,
|
||||
alertDeliveries: alertDeliveries.length,
|
||||
alertRuns: alertRuns.length,
|
||||
ingestRuns: ingestRuns.length,
|
||||
users: users.length,
|
||||
plans: plans.length,
|
||||
tags: tags.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listBackups() {
|
||||
const dir = await this.ensureDir();
|
||||
const entries = await fs.readdir(dir);
|
||||
return entries
|
||||
.filter((name) => name.startsWith('gob-alert-backup-') && name.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
private async ensureDir() {
|
||||
const dir = path.resolve(process.env.BACKUP_DIR || './backups');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
private formatTimestamp(date: Date) {
|
||||
const pad = (v: number) => String(v).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private getLimit() {
|
||||
const raw = process.env.BACKUP_LIMIT;
|
||||
if (!raw) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return Math.min(value, 50000);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/catalog/catalog.controller.ts
Normal file
34
apps/api/src/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CreateCatalogItemDto } from './dto/create-catalog-item.dto';
|
||||
|
||||
@Controller('catalog')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalogService: CatalogService) {}
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return { ok: true, service: 'gob-alert-catalog' };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.catalogService.list();
|
||||
}
|
||||
|
||||
@Get('changes')
|
||||
async changes(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.catalogService.listChanges(take);
|
||||
}
|
||||
|
||||
@Post('ingest')
|
||||
async ingest(@Body() dto: CreateCatalogItemDto) {
|
||||
return this.catalogService.upsertWithVersion(dto);
|
||||
}
|
||||
|
||||
@Get(':id/versions')
|
||||
async versions(@Param('id') id: string) {
|
||||
return this.catalogService.getVersions(id);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/catalog/catalog.module.ts
Normal file
14
apps/api/src/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([CatalogItemEntity, CatalogItemVersionEntity])],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
143
apps/api/src/catalog/catalog.service.ts
Normal file
143
apps/api/src/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { CreateCatalogItemDto } from './dto/create-catalog-item.dto';
|
||||
|
||||
export type CatalogUpsertResult = {
|
||||
item: CatalogItemEntity;
|
||||
created: boolean;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly repo: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly versions: Repository<CatalogItemVersionEntity>,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CatalogItemEntity[]> {
|
||||
return this.repo.find({ order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async listChanges(limit = 20) {
|
||||
const rows = await this.versions.find({ order: { createdAt: 'DESC' }, take: limit });
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async listChangesSince(since: Date) {
|
||||
const rows = await this.versions.find({
|
||||
where: { createdAt: MoreThanOrEqual(since) },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(dto: CreateCatalogItemDto): Promise<CatalogItemEntity> {
|
||||
const item = this.repo.create({
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
sourceUrl: dto.sourceUrl,
|
||||
format: dto.format,
|
||||
publisher: dto.publisher,
|
||||
updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : undefined,
|
||||
});
|
||||
const saved = await this.repo.save(item);
|
||||
await this.saveVersion(saved, 'created', this.toSnapshot(saved));
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Upsert with versioning: if an existing item is found and differs, save a snapshot and update
|
||||
async upsertWithVersion(dto: CreateCatalogItemDto): Promise<CatalogUpsertResult> {
|
||||
const where: Array<Partial<CatalogItemEntity>> = [{ title: dto.title }];
|
||||
if (dto.sourceUrl) where.unshift({ sourceUrl: dto.sourceUrl });
|
||||
const existing = await this.repo.findOne({ where });
|
||||
|
||||
if (!existing) {
|
||||
const created = await this.create(dto);
|
||||
return { item: created, created: true, updated: false };
|
||||
}
|
||||
|
||||
const changed = (
|
||||
existing.title !== dto.title ||
|
||||
(existing.description || '') !== (dto.description || '') ||
|
||||
(existing.format || '') !== (dto.format || '') ||
|
||||
(existing.publisher || '') !== (dto.publisher || '') ||
|
||||
((existing.updatedAt && dto.updatedAt)
|
||||
? existing.updatedAt.toISOString() !== new Date(dto.updatedAt).toISOString()
|
||||
: Boolean(existing.updatedAt) !== Boolean(dto.updatedAt))
|
||||
);
|
||||
|
||||
if (!changed) return { item: existing, created: false, updated: false };
|
||||
|
||||
await this.saveVersion(existing, 'updated', this.toSnapshot(existing));
|
||||
|
||||
existing.title = dto.title;
|
||||
existing.description = dto.description;
|
||||
existing.sourceUrl = dto.sourceUrl;
|
||||
existing.format = dto.format;
|
||||
existing.publisher = dto.publisher;
|
||||
existing.updatedAt = dto.updatedAt ? new Date(dto.updatedAt) : undefined;
|
||||
|
||||
const saved = await this.repo.save(existing);
|
||||
return { item: saved, created: false, updated: true };
|
||||
}
|
||||
|
||||
async getVersions(itemId: string) {
|
||||
const rows = await this.versions.find({ where: { itemId }, order: { createdAt: 'DESC' } });
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private async saveVersion(item: CatalogItemEntity, eventType: 'created' | 'updated', snapshot: Record<string, unknown>) {
|
||||
const row = this.versions.create({
|
||||
itemId: item.id,
|
||||
eventType,
|
||||
snapshot: JSON.stringify(snapshot),
|
||||
});
|
||||
await this.versions.save(row);
|
||||
}
|
||||
|
||||
private toSnapshot(item: CatalogItemEntity) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
sourceUrl: item.sourceUrl,
|
||||
format: item.format,
|
||||
publisher: item.publisher,
|
||||
updatedAt: item.updatedAt ? item.updatedAt.toISOString() : null,
|
||||
createdAt: item.createdAt ? item.createdAt.toISOString() : null,
|
||||
modifiedAt: item.modifiedAt ? item.modifiedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private parseSnapshot(snapshot: string) {
|
||||
try {
|
||||
return JSON.parse(snapshot);
|
||||
} catch {
|
||||
return { raw: snapshot };
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/api/src/catalog/dto/catalog-item.dto.ts
Normal file
9
apps/api/src/catalog/dto/catalog-item.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class CatalogItemDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
sourceUrl?: string;
|
||||
updatedAt?: string;
|
||||
format?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
8
apps/api/src/catalog/dto/create-catalog-item.dto.ts
Normal file
8
apps/api/src/catalog/dto/create-catalog-item.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class CreateCatalogItemDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
sourceUrl?: string;
|
||||
updatedAt?: string; // ISO
|
||||
format?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
11
apps/api/src/classifier/classifier.module.ts
Normal file
11
apps/api/src/classifier/classifier.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { ClassifierService } from './classifier.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
providers: [ClassifierService],
|
||||
exports: [ClassifierService],
|
||||
})
|
||||
export class ClassifierModule {}
|
||||
77
apps/api/src/classifier/classifier.service.ts
Normal file
77
apps/api/src/classifier/classifier.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
export type ClassificationResult = {
|
||||
publisher?: string;
|
||||
format?: string[];
|
||||
territory?: string[];
|
||||
topics?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ClassifierService {
|
||||
constructor(
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
async classify(item: CatalogItemEntity): Promise<ClassificationResult> {
|
||||
const res: ClassificationResult = {
|
||||
publisher: item.publisher || undefined,
|
||||
format: this.splitFormats(item.format),
|
||||
};
|
||||
|
||||
const text = `${item.title || ''} ${item.description || ''}`.toLowerCase();
|
||||
|
||||
const territory: string[] = [];
|
||||
const topics: string[] = [];
|
||||
|
||||
if (text.includes('madrid')) territory.push('Madrid');
|
||||
if (text.includes('andaluc')) territory.push('Andalucía');
|
||||
if (text.includes('catalu')) territory.push('Cataluña');
|
||||
|
||||
if (text.includes('subvenc')) topics.push('Subvenciones');
|
||||
if (text.includes('licit')) topics.push('Licitaciones');
|
||||
if (text.includes('contrat')) topics.push('Contratación');
|
||||
if (text.includes('presupuesto')) topics.push('Presupuestos');
|
||||
|
||||
res.territory = territory.length ? territory : undefined;
|
||||
res.topics = topics.length ? topics : undefined;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async applyTags(item: CatalogItemEntity, classification: ClassificationResult) {
|
||||
await this.tags.delete({ itemId: item.id });
|
||||
const next: ClassificationTagEntity[] = [];
|
||||
|
||||
if (classification.publisher) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'publisher', value: classification.publisher }));
|
||||
}
|
||||
|
||||
for (const format of classification.format || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'format', value: format }));
|
||||
}
|
||||
|
||||
for (const topic of classification.topics || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'topic', value: topic }));
|
||||
}
|
||||
|
||||
for (const territory of classification.territory || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'territory', value: territory }));
|
||||
}
|
||||
|
||||
if (next.length) await this.tags.save(next);
|
||||
}
|
||||
|
||||
private splitFormats(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
104
apps/api/src/dashboard/dashboard.controller.ts
Normal file
104
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { AlertsService } from '../alerts/alerts.service';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly alerts: AlertsService,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
async summary(@Query('days') days?: string) {
|
||||
const daysCount = this.clampDays(days ? Number(days) : 14);
|
||||
const items = await this.catalog.list();
|
||||
const changes = await this.catalog.listChanges(20);
|
||||
const since = this.startOfDayOffset(daysCount - 1);
|
||||
const trendChanges = await this.catalog.listChangesSince(since);
|
||||
const profiles = await this.alerts.listProfiles(process.env.DEFAULT_ALERT_USER || '00000000-0000-0000-0000-000000000001');
|
||||
const tags = await this.tags.find();
|
||||
|
||||
const perPublisher: Record<string, number> = {};
|
||||
const perFormat: Record<string, number> = {};
|
||||
const perTopic: Record<string, number> = {};
|
||||
const perTerritory: Record<string, number> = {};
|
||||
const trend = this.buildTrend(daysCount);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.publisher) perPublisher[item.publisher] = (perPublisher[item.publisher] || 0) + 1;
|
||||
if (item.format) {
|
||||
for (const format of String(item.format).split(',')) {
|
||||
const value = format.trim();
|
||||
if (!value) continue;
|
||||
perFormat[value] = (perFormat[value] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.type === 'topic') perTopic[tag.value] = (perTopic[tag.value] || 0) + 1;
|
||||
if (tag.type === 'territory') perTerritory[tag.value] = (perTerritory[tag.value] || 0) + 1;
|
||||
}
|
||||
|
||||
for (const change of trendChanges) {
|
||||
const key = this.formatDateKey(change.createdAt);
|
||||
const bucket = trend[key];
|
||||
if (!bucket) continue;
|
||||
if (change.eventType === 'created') bucket.created += 1;
|
||||
else bucket.updated += 1;
|
||||
bucket.total += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totals: {
|
||||
items: items.length,
|
||||
changes: changes.length,
|
||||
alertProfiles: profiles.length,
|
||||
},
|
||||
latestChanges: changes.slice(0, 8),
|
||||
trend: Object.values(trend),
|
||||
topPublishers: this.toSorted(perPublisher).slice(0, 6),
|
||||
topFormats: this.toSorted(perFormat).slice(0, 6),
|
||||
topTopics: this.toSorted(perTopic).slice(0, 6),
|
||||
topTerritories: this.toSorted(perTerritory).slice(0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
private toSorted(input: Record<string, number>) {
|
||||
return Object.entries(input)
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
private clampDays(value: number) {
|
||||
if (!value || Number.isNaN(value)) return 14;
|
||||
return Math.min(Math.max(value, 7), 60);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
|
||||
private formatDateKey(value: Date) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private buildTrend(days: number) {
|
||||
const out: Record<string, { date: string; created: number; updated: number; total: number }> = {};
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const d = this.startOfDayOffset(i);
|
||||
const key = this.formatDateKey(d);
|
||||
out[key] = { date: key, created: 0, updated: 0, total: 0 };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/dashboard/dashboard.module.ts
Normal file
12
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { AlertsModule } from '../alerts/alerts.module';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, AlertsModule, TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
controllers: [DashboardController],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
63
apps/api/src/discovery/discovery.controller.ts
Normal file
63
apps/api/src/discovery/discovery.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Controller('discover')
|
||||
export class DiscoveryController {
|
||||
constructor(
|
||||
private readonly catalog: CatalogService,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
@Get('changes')
|
||||
async changes(@Query('days') days?: string, @Query('limit') limit?: string) {
|
||||
const daysCount = this.clampDays(days ? Number(days) : 7);
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
const since = this.startOfDayOffset(daysCount - 1);
|
||||
|
||||
let changes = await this.catalog.listChangesSince(since);
|
||||
changes = changes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
changes = changes.slice(0, take);
|
||||
|
||||
const itemIds = Array.from(new Set(changes.map((c) => c.itemId).filter(Boolean)));
|
||||
const tags = itemIds.length
|
||||
? await this.tags.find({ where: { itemId: In(itemIds) } })
|
||||
: [];
|
||||
|
||||
const tagsByItem = new Map<string, ClassificationTagEntity[]>();
|
||||
for (const tag of tags) {
|
||||
const list = tagsByItem.get(tag.itemId) || [];
|
||||
list.push(tag);
|
||||
tagsByItem.set(tag.itemId, list);
|
||||
}
|
||||
|
||||
return changes.map((change) => ({
|
||||
...change,
|
||||
tags: this.groupTags(tagsByItem.get(change.itemId) || []),
|
||||
}));
|
||||
}
|
||||
|
||||
private groupTags(tags: ClassificationTagEntity[]) {
|
||||
return {
|
||||
topics: tags.filter((t) => t.type === 'topic').map((t) => t.value),
|
||||
territories: tags.filter((t) => t.type === 'territory').map((t) => t.value),
|
||||
formats: tags.filter((t) => t.type === 'format').map((t) => t.value),
|
||||
publishers: tags.filter((t) => t.type === 'publisher').map((t) => t.value),
|
||||
};
|
||||
}
|
||||
|
||||
private clampDays(value: number) {
|
||||
if (!value || Number.isNaN(value)) return 7;
|
||||
return Math.min(Math.max(value, 1), 90);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/discovery/discovery.module.ts
Normal file
11
apps/api/src/discovery/discovery.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { DiscoveryController } from './discovery.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
controllers: [DiscoveryController],
|
||||
})
|
||||
export class DiscoveryModule {}
|
||||
25
apps/api/src/entities/alert-delivery.entity.ts
Normal file
25
apps/api/src/entities/alert-delivery.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('alert_deliveries')
|
||||
export class AlertDeliveryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
profileId: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
channel: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'sent' })
|
||||
status: 'sent' | 'failed' | 'skipped';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
details?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
45
apps/api/src/entities/alert-profile.entity.ts
Normal file
45
apps/api/src/entities/alert-profile.entity.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export type AlertChannel = 'email' | 'telegram';
|
||||
|
||||
@Entity('alert_profiles')
|
||||
export class AlertProfileEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'daily' })
|
||||
frequency: 'instant' | 'daily';
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'email' })
|
||||
channel: AlertChannel;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
channelTarget?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
queryText?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
rules?: {
|
||||
publishers?: string[];
|
||||
formats?: string[];
|
||||
territories?: string[];
|
||||
topics?: string[];
|
||||
updatedWithinDays?: number;
|
||||
};
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
28
apps/api/src/entities/alert-run.entity.ts
Normal file
28
apps/api/src/entities/alert-run.entity.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('alert_runs')
|
||||
export class AlertRunEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||
status: 'running' | 'success' | 'failed';
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
profiles: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sent: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
failed: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMs?: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
finishedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
startedAt: Date;
|
||||
}
|
||||
19
apps/api/src/entities/catalog-item-version.entity.ts
Normal file
19
apps/api/src/entities/catalog-item-version.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('catalog_item_versions')
|
||||
export class CatalogItemVersionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'updated' })
|
||||
eventType: 'created' | 'updated';
|
||||
|
||||
@Column({ type: 'text' })
|
||||
snapshot: string; // JSON snapshot of previous state
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
31
apps/api/src/entities/catalog-item.entity.ts
Normal file
31
apps/api/src/entities/catalog-item.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('catalog_items')
|
||||
export class CatalogItemEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 2000, nullable: true })
|
||||
sourceUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
format?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
publisher?: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
updatedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
modifiedAt: Date;
|
||||
}
|
||||
19
apps/api/src/entities/classification-tag.entity.ts
Normal file
19
apps/api/src/entities/classification-tag.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('classification_tags')
|
||||
export class ClassificationTagEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
type: 'publisher' | 'format' | 'topic' | 'territory';
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
value: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
31
apps/api/src/entities/ingest-run.entity.ts
Normal file
31
apps/api/src/entities/ingest-run.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('ingest_runs')
|
||||
export class IngestRunEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||
status: 'running' | 'success' | 'partial' | 'failed';
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
imported: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
updated: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
errorCount: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
errors?: string[];
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMs?: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
finishedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
startedAt: Date;
|
||||
}
|
||||
34
apps/api/src/entities/plan.entity.ts
Normal file
34
apps/api/src/entities/plan.entity.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('plans')
|
||||
export class PlanEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priceMonthly: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'EUR' })
|
||||
currency: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
features?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
25
apps/api/src/ingest/ingest.controller.ts
Normal file
25
apps/api/src/ingest/ingest.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
|
||||
@Controller('ingest')
|
||||
export class IngestController {
|
||||
constructor(
|
||||
private readonly ingestService: IngestService,
|
||||
@InjectQueue('ingest') private readonly ingestQueue: Queue,
|
||||
) {}
|
||||
|
||||
@Get('run')
|
||||
async run() {
|
||||
return this.ingestService.runOnce();
|
||||
}
|
||||
|
||||
@Post('queue')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
async queue() {
|
||||
const job = await this.ingestQueue.add({}, { removeOnComplete: true, removeOnFail: false });
|
||||
return { ok: true, jobId: job.id };
|
||||
}
|
||||
}
|
||||
30
apps/api/src/ingest/ingest.module.ts
Normal file
30
apps/api/src/ingest/ingest.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
import { IngestController } from './ingest.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { NormalizerModule } from '../normalizer/normalizer.module';
|
||||
import { IngestScheduler } from './ingest.scheduler';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { IngestProcessor } from './ingest.processor';
|
||||
import { ClassifierModule } from '../classifier/classifier.module';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CatalogModule,
|
||||
NormalizerModule,
|
||||
ClassifierModule,
|
||||
TypeOrmModule.forFeature([IngestRunEntity]),
|
||||
BullModule.registerQueue({
|
||||
name: 'ingest',
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [IngestService, IngestScheduler, IngestProcessor],
|
||||
controllers: [IngestController],
|
||||
})
|
||||
export class IngestModule {}
|
||||
18
apps/api/src/ingest/ingest.processor.ts
Normal file
18
apps/api/src/ingest/ingest.processor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Processor, Process } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Processor('ingest')
|
||||
@Injectable()
|
||||
export class IngestProcessor {
|
||||
private readonly logger = new Logger(IngestProcessor.name);
|
||||
|
||||
constructor(private readonly ingestService: IngestService) {}
|
||||
|
||||
@Process()
|
||||
async handle(job: Job) {
|
||||
this.logger.log(`Processing ingest job id=${job.id}`);
|
||||
return this.ingestService.runOnce();
|
||||
}
|
||||
}
|
||||
25
apps/api/src/ingest/ingest.scheduler.ts
Normal file
25
apps/api/src/ingest/ingest.scheduler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Injectable()
|
||||
export class IngestScheduler {
|
||||
private readonly logger = new Logger(IngestScheduler.name);
|
||||
|
||||
constructor(private readonly ingestService: IngestService) {}
|
||||
|
||||
// Cron expression configurable via env `INGEST_CRON` (default: every hour)
|
||||
@Cron(process.env.INGEST_CRON || CronExpression.EVERY_HOUR, { name: 'ingest_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.INGEST_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled ingest triggered');
|
||||
try {
|
||||
const res = await this.ingestService.runOnce();
|
||||
this.logger.log(`Ingest finished: imported=${res.imported} errors=${res.errors.length}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error('Scheduled ingest failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
apps/api/src/ingest/ingest.service.ts
Normal file
105
apps/api/src/ingest/ingest.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import axios from 'axios';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { CreateCatalogItemDto } from '../catalog/dto/create-catalog-item.dto';
|
||||
import { NormalizerService } from '../normalizer/normalizer.service';
|
||||
import { ClassifierService } from '../classifier/classifier.service';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Injectable()
|
||||
export class IngestService {
|
||||
private readonly logger = new Logger(IngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly normalizer: NormalizerService,
|
||||
private readonly classifier: ClassifierService,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly runs: Repository<IngestRunEntity>,
|
||||
) {}
|
||||
|
||||
async runOnce(): Promise<{ runId: string; imported: number; updated: number; errors: string[] }> {
|
||||
const apiBase = process.env.DATOS_API_BASE || 'https://datos.gob.es/apidata/3/action/package_search';
|
||||
this.logger.log(`Running ingest against ${apiBase}`);
|
||||
const runStart = Date.now();
|
||||
const run = await this.runs.save(this.runs.create({ status: 'running' }));
|
||||
try {
|
||||
const res = await axios.get(apiBase, { params: { q: '', rows: 20 } });
|
||||
const data = res.data;
|
||||
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(data)) rows = data;
|
||||
else if (data?.result?.results) rows = data.result.results;
|
||||
else if (data?.result && Array.isArray(data.result)) rows = data.result;
|
||||
else if (data?.items) rows = data.items;
|
||||
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const dto: CreateCatalogItemDto = {
|
||||
title: r.title || r.name || r.id || 'sin-titulo',
|
||||
description: r.notes || r.description || r.title || '',
|
||||
sourceUrl: undefined,
|
||||
format: undefined,
|
||||
publisher: (r.organization && (r.organization.title || r.organization.name)) || r.owner_org || r.author || undefined,
|
||||
updatedAt: r.metadata_modified || r.last_modified || r.updated || undefined,
|
||||
};
|
||||
|
||||
if (Array.isArray(r.resources) && r.resources.length > 0) {
|
||||
dto.sourceUrl = r.resources[0].url || r.resources[0].access_url || dto.sourceUrl;
|
||||
const formats = Array.from(new Set(
|
||||
r.resources
|
||||
.map((x: any) => (x.format || x.format_description || '').toString().toUpperCase())
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (formats.length) dto.format = formats.join(',');
|
||||
}
|
||||
|
||||
if (!dto.sourceUrl && (r.url || r.link)) dto.sourceUrl = r.url || r.link;
|
||||
if (!dto.sourceUrl && r.id) dto.sourceUrl = `urn:dataset:${r.id}`;
|
||||
|
||||
const normalized = await this.normalizer.normalize(dto);
|
||||
const result = await this.catalogService.upsertWithVersion(normalized);
|
||||
if (result.created) imported++;
|
||||
if (result.updated) updated++;
|
||||
|
||||
if (result.item) {
|
||||
const classification = await this.classifier.classify(result.item);
|
||||
await this.classifier.applyTags(result.item, classification);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error('Error ingesting row', err?.message || err);
|
||||
errors.push(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
run.status = errors.length ? 'partial' : 'success';
|
||||
run.imported = imported;
|
||||
run.updated = updated;
|
||||
run.errorCount = errors.length;
|
||||
run.errors = errors.slice(0, 50);
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
|
||||
return { runId: run.id, imported, updated, errors };
|
||||
} catch (err: any) {
|
||||
this.logger.error('Ingest request failed', err?.message || err);
|
||||
const errors = [err?.message || String(err)];
|
||||
run.status = 'failed';
|
||||
run.imported = 0;
|
||||
run.updated = 0;
|
||||
run.errorCount = errors.length;
|
||||
run.errors = errors;
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
return { runId: run.id, imported: 0, updated: 0, errors };
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/api/src/main.ts
Normal file
14
apps/api/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API listening on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
apps/api/src/normalizer/normalizer.module.ts
Normal file
8
apps/api/src/normalizer/normalizer.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { NormalizerService } from './normalizer.service'
|
||||
|
||||
@Module({
|
||||
providers: [NormalizerService],
|
||||
exports: [NormalizerService],
|
||||
})
|
||||
export class NormalizerModule {}
|
||||
46
apps/api/src/normalizer/normalizer.service.ts
Normal file
46
apps/api/src/normalizer/normalizer.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { CreateCatalogItemDto } from '../catalog/dto/create-catalog-item.dto'
|
||||
import { parseDate, parseAmount, normalizeProvince, verifyUrl } from './utils'
|
||||
|
||||
@Injectable()
|
||||
export class NormalizerService {
|
||||
private readonly logger = new Logger(NormalizerService.name)
|
||||
|
||||
async normalize(dto: CreateCatalogItemDto): Promise<CreateCatalogItemDto> {
|
||||
const out: CreateCatalogItemDto = { ...dto }
|
||||
|
||||
// Normalize dates
|
||||
try {
|
||||
if (dto.updatedAt) {
|
||||
const d = parseDate(dto.updatedAt)
|
||||
if (d) out.updatedAt = d
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.debug('Date normalization failed', e)
|
||||
}
|
||||
|
||||
// Example: normalise publisher/province if present
|
||||
if (dto.publisher) {
|
||||
out.publisher = normalizeProvince(dto.publisher)
|
||||
}
|
||||
|
||||
// Verify sourceUrl
|
||||
if (dto.sourceUrl) {
|
||||
try {
|
||||
const ok = await verifyUrl(dto.sourceUrl)
|
||||
if (!ok) {
|
||||
this.logger.warn(`Source URL not reachable: ${dto.sourceUrl}`)
|
||||
// keep it but flag by appending note
|
||||
out.sourceUrl = dto.sourceUrl
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.debug('verifyUrl error', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Amounts are not part of the DTO now, but kept for extension
|
||||
// if ((dto as any).amount) (dto as any).amount = parseAmount((dto as any).amount)
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
51
apps/api/src/normalizer/utils.ts
Normal file
51
apps/api/src/normalizer/utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export function parseDate(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
if (!isNaN(d.getTime())) return d.toISOString()
|
||||
|
||||
// try common formats dd/mm/yyyy or dd-mm-yyyy
|
||||
const m = value.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/)
|
||||
if (m) {
|
||||
const day = parseInt(m[1], 10)
|
||||
const month = parseInt(m[2], 10) - 1
|
||||
const year = parseInt(m[3], 10)
|
||||
const d2 = new Date(year, month, day)
|
||||
if (!isNaN(d2.getTime())) return d2.toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseAmount(value?: string | number): number | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'number') return value
|
||||
// remove currency symbols and thousands separators
|
||||
const cleaned = String(value).replace(/[^0-9,\.\-]/g, '').replace(/\./g, '').replace(/,/g, '.')
|
||||
const n = parseFloat(cleaned)
|
||||
return isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
export function normalizeProvince(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
// simple normalizations
|
||||
const v = value.trim().toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
'andalucía': 'Andalucía', 'andalucia': 'Andalucía', 'andaluzia': 'Andalucía',
|
||||
'madrid': 'Madrid', 'comunidad de madrid': 'Madrid',
|
||||
'cataluña': 'Cataluña', 'cataluna': 'Cataluña', 'barcelona': 'Barcelona'
|
||||
}
|
||||
if (map[v]) return map[v]
|
||||
// Title case fallback
|
||||
return value.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
}
|
||||
|
||||
export async function verifyUrl(url?: string): Promise<boolean> {
|
||||
if (!url) return false
|
||||
try {
|
||||
const res = await axios.head(url, { timeout: 5000, maxRedirects: 3 })
|
||||
return res.status >= 200 && res.status < 400
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
12
apps/api/src/plans/plans.controller.ts
Normal file
12
apps/api/src/plans/plans.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
@Controller('plans')
|
||||
export class PlansController {
|
||||
constructor(private readonly plans: PlansService) {}
|
||||
|
||||
@Get()
|
||||
async listActive() {
|
||||
return this.plans.listActive();
|
||||
}
|
||||
}
|
||||
13
apps/api/src/plans/plans.module.ts
Normal file
13
apps/api/src/plans/plans.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { PlansService } from './plans.service';
|
||||
import { PlansController } from './plans.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlanEntity])],
|
||||
providers: [PlansService],
|
||||
controllers: [PlansController],
|
||||
exports: [PlansService],
|
||||
})
|
||||
export class PlansModule {}
|
||||
80
apps/api/src/plans/plans.service.ts
Normal file
80
apps/api/src/plans/plans.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PlansService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PlansService.name);
|
||||
private initialized = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlanEntity)
|
||||
private readonly plans: Repository<PlanEntity>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureDefaults();
|
||||
}
|
||||
|
||||
async ensureDefaults() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
const defaults = [
|
||||
{
|
||||
code: 'basic',
|
||||
name: 'Plan Básico',
|
||||
priceMonthly: 15,
|
||||
description: 'Alertas esenciales y dashboard básico',
|
||||
features: { alertProfiles: 3, refresh: 'daily' },
|
||||
},
|
||||
{
|
||||
code: 'pro',
|
||||
name: 'Plan Pro',
|
||||
priceMonthly: 39,
|
||||
description: 'Más alertas, filtros avanzados y tendencias',
|
||||
features: { alertProfiles: 10, refresh: 'hourly' },
|
||||
},
|
||||
{
|
||||
code: 'empresa',
|
||||
name: 'Plan Empresa',
|
||||
priceMonthly: 99,
|
||||
description: 'Personalización y soporte dedicado',
|
||||
features: { alertProfiles: 50, refresh: 'custom' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of defaults) {
|
||||
const existing = await this.plans.findOne({ where: { code: entry.code } });
|
||||
if (!existing) {
|
||||
await this.plans.save(this.plans.create(entry));
|
||||
this.logger.log(`Seeded plan ${entry.code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listActive() {
|
||||
return this.plans.find({ where: { isActive: true }, order: { priceMonthly: 'ASC' } });
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
return this.plans.find({ order: { priceMonthly: 'ASC' } });
|
||||
}
|
||||
|
||||
async getDefaultPlan() {
|
||||
await this.ensureDefaults();
|
||||
return this.plans.findOne({ where: { code: 'basic' } });
|
||||
}
|
||||
|
||||
async create(payload: Partial<PlanEntity>) {
|
||||
const plan = this.plans.create(payload);
|
||||
return this.plans.save(plan);
|
||||
}
|
||||
|
||||
async update(id: string, payload: Partial<PlanEntity>) {
|
||||
const plan = await this.plans.findOne({ where: { id } });
|
||||
if (!plan) return null;
|
||||
Object.assign(plan, payload);
|
||||
return this.plans.save(plan);
|
||||
}
|
||||
}
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2020",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
13
apps/web/Dockerfile
Normal file
13
apps/web/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
5
apps/web/README.md
Normal file
5
apps/web/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# apps/web
|
||||
|
||||
Directorio para la aplicación Nuxt (frontend dashboard).
|
||||
|
||||
Para crear el esqueleto de Nuxt (si deseas que lo genere automáticamente), puedo ejecutar los comandos del creador de Nuxt y añadir la configuración inicial.
|
||||
90
apps/web/components/Header.vue
Normal file
90
apps/web/components/Header.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<div class="logo">ga</div>
|
||||
<div>
|
||||
<h2>gob-alert</h2>
|
||||
<span>Radar de datos públicos</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<NuxtLink to="/landing">Inicio</NuxtLink>
|
||||
<NuxtLink to="/">Dashboard</NuxtLink>
|
||||
<NuxtLink to="/discover">Descubrir</NuxtLink>
|
||||
<NuxtLink to="/catalog">Catálogo</NuxtLink>
|
||||
<NuxtLink to="/alerts">Alertas</NuxtLink>
|
||||
<NuxtLink to="/plans">Planes</NuxtLink>
|
||||
<NuxtLink to="/admin">Admin</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-header {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 1.2rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.brand h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.brand span {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
nav a {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a.router-link-active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.wrap {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
apps/web/layouts/default.vue
Normal file
12
apps/web/layouts/default.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
</style>
|
||||
21
apps/web/nuxt.config.ts
Normal file
21
apps/web/nuxt.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineNuxtConfig } from 'nuxt'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
app: {
|
||||
head: {
|
||||
title: 'gob-alert',
|
||||
link: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.API_URL || 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
})
|
||||
13
apps/web/package.json
Normal file
13
apps/web/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start"
|
||||
},
|
||||
"dependencies": {
|
||||
"nuxt": "^3.8.0"
|
||||
}
|
||||
}
|
||||
549
apps/web/pages/admin.vue
Normal file
549
apps/web/pages/admin.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Admin — Control de ingesta</h1>
|
||||
<p>Gestiona el scheduler y encola la ingesta manualmente.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Email (admin)</label>
|
||||
<input v-model="email" placeholder="admin@example.com" />
|
||||
<label>Password</label>
|
||||
<input type="password" v-model="password" placeholder="password" />
|
||||
<div class="row">
|
||||
<button class="btn" @click="login">Login</button>
|
||||
<button class="btn ghost" @click="logout">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>API Key (fallback)</label>
|
||||
<input v-model="key" placeholder="Introduce API key" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="row">
|
||||
<button class="btn" @click="pause">Pause scheduler</button>
|
||||
<button class="btn ghost" @click="resume">Resume scheduler</button>
|
||||
<button class="btn" @click="queue">Enqueue ingest</button>
|
||||
<button class="btn ghost" @click="run">Run now (sync)</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Monitorización</h2>
|
||||
<button class="btn ghost" @click="loadStatus">Refresh</button>
|
||||
</header>
|
||||
<div v-if="status" class="status-grid">
|
||||
<div class="status-card">
|
||||
<strong>Catálogo</strong>
|
||||
<span>Items: {{ status.counts.catalogItems }}</span>
|
||||
<span>Versiones: {{ status.counts.catalogVersions }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Alertas</strong>
|
||||
<span>Perfiles: {{ status.counts.alertProfiles }}</span>
|
||||
<span>Entregas: {{ status.counts.alertDeliveries }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Usuarios</strong>
|
||||
<span>Total: {{ status.counts.users }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Última ingesta</strong>
|
||||
<span>{{ status.lastIngest?.status || '—' }}</span>
|
||||
<span>{{ formatDate(status.lastIngest?.startedAt) }}</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<strong>Últimas alertas</strong>
|
||||
<span>{{ status.lastAlerts?.status || '—' }}</span>
|
||||
<span>{{ formatDate(status.lastAlerts?.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin datos de monitorización.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Backups</h2>
|
||||
<div class="row">
|
||||
<button class="btn" @click="runBackup">Run backup</button>
|
||||
<button class="btn ghost" @click="loadBackups">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
<ul v-if="backups.length" class="backup-list">
|
||||
<li v-for="file in backups" :key="file">{{ file }}</li>
|
||||
</ul>
|
||||
<div v-else class="muted">No hay backups aún.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Ingest runs</h2>
|
||||
<button class="btn ghost" @click="loadRuns">Refresh</button>
|
||||
</header>
|
||||
<div v-if="runs.length" class="runs">
|
||||
<div v-for="run in runs" :key="run.id" class="run-row">
|
||||
<div>
|
||||
<strong>{{ run.status }}</strong>
|
||||
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
|
||||
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span>Importados: {{ run.imported }}</span>
|
||||
<span>Actualizados: {{ run.updated }}</span>
|
||||
<span>Errores: {{ run.errorCount }}</span>
|
||||
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin ejecuciones recientes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Planes</h2>
|
||||
<button class="btn ghost" @click="loadPlans">Refresh</button>
|
||||
</header>
|
||||
<div v-if="plans.length" class="plans">
|
||||
<div v-for="plan in plans" :key="plan.id" class="plan-row">
|
||||
<div>
|
||||
<strong>{{ plan.name }}</strong>
|
||||
<div class="muted">{{ plan.code }} · {{ plan.priceMonthly }} {{ plan.currency }}/mes</div>
|
||||
</div>
|
||||
<div class="muted">{{ plan.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin planes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Usuarios</h2>
|
||||
<button class="btn ghost" @click="loadUsers">Refresh</button>
|
||||
</header>
|
||||
<div v-if="users.length" class="users">
|
||||
<div v-for="user in users" :key="user.id" class="user-row">
|
||||
<div>
|
||||
<strong>{{ user.email }}</strong>
|
||||
<div class="muted">{{ user.role }} · {{ formatDate(user.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<select v-model="user.planId" @change="updateUserPlan(user)">
|
||||
<option value="">Sin plan</option>
|
||||
<option v-for="plan in plans" :key="plan.id" :value="plan.id">
|
||||
{{ plan.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="user.role" @change="updateUserRole(user)">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin usuarios.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header class="section-head">
|
||||
<h2>Alert runs</h2>
|
||||
<button class="btn ghost" @click="loadAlertRuns">Refresh</button>
|
||||
</header>
|
||||
<div v-if="alertRuns.length" class="runs">
|
||||
<div v-for="run in alertRuns" :key="run.id" class="run-row">
|
||||
<div>
|
||||
<strong>{{ run.status }}</strong>
|
||||
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
|
||||
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span>Perfiles: {{ run.profiles }}</span>
|
||||
<span>Enviadas: {{ run.sent }}</span>
|
||||
<span>Fallidas: {{ run.failed }}</span>
|
||||
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">Sin ejecuciones recientes.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" v-if="msg">
|
||||
<pre>{{ msg }}</pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
import { ref } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const key = ref(localStorage.getItem('admin_key') || '')
|
||||
const token = ref(localStorage.getItem('admin_token') || '')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const msg = ref('')
|
||||
const runs = ref([])
|
||||
const alertRuns = ref([])
|
||||
const plans = ref([])
|
||||
const users = ref([])
|
||||
const status = ref(null)
|
||||
const backups = ref([])
|
||||
|
||||
function saveKey() {
|
||||
localStorage.setItem('admin_key', key.value)
|
||||
}
|
||||
|
||||
function saveToken(t) {
|
||||
token.value = t
|
||||
if (t) localStorage.setItem('admin_token', t)
|
||||
else localStorage.removeItem('admin_token')
|
||||
}
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/auth/login`, { method: 'POST', body: { email: email.value, password: password.value } })
|
||||
saveToken(res.access_token)
|
||||
msg.value = 'Logged in'
|
||||
await loadStatus()
|
||||
await loadBackups()
|
||||
await loadRuns()
|
||||
await loadAlertRuns()
|
||||
await loadPlans()
|
||||
await loadUsers()
|
||||
} catch (e) { msg.value = 'Login failed: ' + String(e) }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
saveToken('')
|
||||
msg.value = 'Logged out'
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/pause`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/resume`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function queue() {
|
||||
saveKey()
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
const res = await $fetch(`${config.public.apiBase}/ingest/queue`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/ingest/run`)
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadRuns()
|
||||
} catch (e) { msg.value = String(e) }
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/monitor/status`, { headers })
|
||||
status.value = res
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/backup/list`, { headers })
|
||||
backups.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/backup/run`, { method: 'POST', headers })
|
||||
msg.value = JSON.stringify(res, null, 2)
|
||||
await loadBackups()
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/ingest/runs`, { headers })
|
||||
runs.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/plans`, { headers })
|
||||
plans.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/users`, { headers })
|
||||
users.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserPlan(user) {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/plan`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: { planId: user.planId }
|
||||
})
|
||||
messageToast('Plan actualizado')
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserRole(user) {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/role`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: { role: user.role }
|
||||
})
|
||||
messageToast('Rol actualizado')
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlertRuns() {
|
||||
try {
|
||||
const headers = {}
|
||||
if (token.value) headers['authorization'] = `Bearer ${token.value}`
|
||||
else if (key.value) headers['x-api-key'] = key.value
|
||||
else return
|
||||
const res = await $fetch(`${config.public.apiBase}/admin/alerts/runs`, { headers })
|
||||
alertRuns.value = res || []
|
||||
} catch (e) {
|
||||
msg.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleString('es-ES')
|
||||
}
|
||||
|
||||
function messageToast(text) {
|
||||
msg.value = text
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
loadBackups()
|
||||
loadRuns()
|
||||
loadAlertRuns()
|
||||
loadPlans()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.runs {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.run-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.plans,
|
||||
.users {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.backup-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.plan-row,
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
align-items: center;
|
||||
}
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
background: #fff;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
margin-top: 0.6rem;
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
width: 100%;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
290
apps/web/pages/alerts.vue
Normal file
290
apps/web/pages/alerts.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Perfiles de alerta</h1>
|
||||
<p>Define reglas para recibir novedades por email o Telegram.</p>
|
||||
</div>
|
||||
<button class="btn" @click="openCreate">Nuevo perfil</button>
|
||||
</header>
|
||||
|
||||
<section class="profiles">
|
||||
<article v-for="profile in profiles" :key="profile.id" class="profile-card">
|
||||
<div>
|
||||
<h3>{{ profile.name }}</h3>
|
||||
<p class="muted">{{ profile.channel }} · {{ profile.frequency }}</p>
|
||||
<p v-if="profile.queryText" class="rule">Texto: {{ profile.queryText }}</p>
|
||||
<p v-if="profile.rules?.publishers?.length" class="rule">Publicadores: {{ profile.rules.publishers.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.formats?.length" class="rule">Formatos: {{ profile.rules.formats.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.topics?.length" class="rule">Temas: {{ profile.rules.topics.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.territories?.length" class="rule">Territorios: {{ profile.rules.territories.join(', ') }}</p>
|
||||
<p v-if="profile.rules?.updatedWithinDays" class="rule">Actualizados en {{ profile.rules.updatedWithinDays }} días</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn ghost" @click="runProfile(profile.id)">Probar</button>
|
||||
<button class="btn" @click="editProfile(profile)">Editar</button>
|
||||
<button class="btn danger" @click="removeProfile(profile.id)">Eliminar</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<dialog ref="dialog" class="dialog">
|
||||
<form method="dialog" @submit.prevent="save">
|
||||
<h2>{{ editing ? 'Editar perfil' : 'Nuevo perfil' }}</h2>
|
||||
<label>Nombre</label>
|
||||
<input v-model="form.name" required />
|
||||
<label>Canal</label>
|
||||
<select v-model="form.channel">
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<label>Destino (email o chat id)</label>
|
||||
<input v-model="form.channelTarget" />
|
||||
<label>Frecuencia</label>
|
||||
<select v-model="form.frequency">
|
||||
<option value="instant">Instant</option>
|
||||
<option value="daily">Daily</option>
|
||||
</select>
|
||||
<label>Texto libre</label>
|
||||
<input v-model="form.queryText" placeholder="subvenciones, licitaciones..." />
|
||||
<label>Publicadores (coma)</label>
|
||||
<input v-model="publishers" />
|
||||
<label>Formatos (coma)</label>
|
||||
<input v-model="formats" />
|
||||
<label>Temas (coma)</label>
|
||||
<input v-model="topics" />
|
||||
<label>Territorios (coma)</label>
|
||||
<input v-model="territories" />
|
||||
<label>Actualizados en los últimos días</label>
|
||||
<input v-model.number="updatedWithinDays" type="number" min="1" />
|
||||
<div class="dialog-actions">
|
||||
<button class="btn ghost" @click="closeDialog">Cancelar</button>
|
||||
<button class="btn" type="submit">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div v-if="message" class="toast">{{ message }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const dialog = ref(null)
|
||||
const message = ref('')
|
||||
const editing = ref(false)
|
||||
const profiles = ref([])
|
||||
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
channel: 'email',
|
||||
channelTarget: '',
|
||||
frequency: 'daily',
|
||||
queryText: '',
|
||||
rules: { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
})
|
||||
const publishers = ref('')
|
||||
const formats = ref('')
|
||||
const topics = ref('')
|
||||
const territories = ref('')
|
||||
const updatedWithinDays = ref(null)
|
||||
|
||||
async function load() {
|
||||
const res = await $fetch(`${config.public.apiBase}/alerts/profiles`)
|
||||
profiles.value = res || []
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = false
|
||||
resetForm()
|
||||
dialog.value?.showModal()
|
||||
}
|
||||
|
||||
function editProfile(profile) {
|
||||
editing.value = true
|
||||
form.id = profile.id
|
||||
form.name = profile.name
|
||||
form.channel = profile.channel
|
||||
form.channelTarget = profile.channelTarget
|
||||
form.frequency = profile.frequency
|
||||
form.queryText = profile.queryText
|
||||
form.rules = profile.rules || { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
publishers.value = (form.rules.publishers || []).join(', ')
|
||||
formats.value = (form.rules.formats || []).join(', ')
|
||||
topics.value = (form.rules.topics || []).join(', ')
|
||||
territories.value = (form.rules.territories || []).join(', ')
|
||||
updatedWithinDays.value = form.rules.updatedWithinDays ?? null
|
||||
dialog.value?.showModal()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.id = ''
|
||||
form.name = ''
|
||||
form.channel = 'email'
|
||||
form.channelTarget = ''
|
||||
form.frequency = 'daily'
|
||||
form.queryText = ''
|
||||
form.rules = { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
|
||||
publishers.value = ''
|
||||
formats.value = ''
|
||||
topics.value = ''
|
||||
territories.value = ''
|
||||
updatedWithinDays.value = null
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialog.value?.close()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const days = Number(updatedWithinDays.value)
|
||||
form.rules = {
|
||||
publishers: splitList(publishers.value),
|
||||
formats: splitList(formats.value),
|
||||
topics: splitList(topics.value),
|
||||
territories: splitList(territories.value),
|
||||
updatedWithinDays: Number.isFinite(days) && days > 0 ? days : undefined
|
||||
}
|
||||
|
||||
if (editing.value) {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles/${form.id}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} else {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
}
|
||||
message.value = 'Perfil guardado'
|
||||
dialog.value?.close()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function runProfile(id) {
|
||||
const res = await $fetch(`${config.public.apiBase}/alerts/run/${id}`, { method: 'POST' })
|
||||
message.value = `Alertas enviadas: ${res.sent || 0}`
|
||||
}
|
||||
|
||||
async function removeProfile(id) {
|
||||
await $fetch(`${config.public.apiBase}/alerts/profiles/${id}`, { method: 'DELETE' })
|
||||
message.value = 'Perfil eliminado'
|
||||
await load()
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.btn.danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
.profiles {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rule {
|
||||
color: #475569;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.dialog {
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
width: min(420px, 90vw);
|
||||
}
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.dialog input,
|
||||
.dialog select {
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.toast {
|
||||
margin-top: 1.5rem;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 0.8rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
.actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
129
apps/web/pages/catalog.vue
Normal file
129
apps/web/pages/catalog.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Catálogo</h1>
|
||||
<p>Datasets ingestados desde datos.gob.es con clasificación básica.</p>
|
||||
</div>
|
||||
<button class="btn" @click="refresh">Actualizar</button>
|
||||
</header>
|
||||
|
||||
<div v-if="pending" class="state">Cargando...</div>
|
||||
<div v-else-if="error" class="state error">Error: {{ error.message }}</div>
|
||||
<section v-else class="grid">
|
||||
<article v-for="item in items" :key="item.id" class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<span class="pill">{{ item.format || 'Sin formato' }}</span>
|
||||
</div>
|
||||
<p class="publisher">{{ item.publisher || 'Sin publicador' }}</p>
|
||||
<p class="desc" v-if="item.description">{{ item.description }}</p>
|
||||
<div class="card-footer">
|
||||
<small>Actualizado: {{ formatDate(item.updatedAt) }}</small>
|
||||
<a v-if="item.sourceUrl" :href="item.sourceUrl" target="_blank">Fuente</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data, pending, error, refresh } = await useFetch(`${config.public.apiBase}/catalog`)
|
||||
const items = computed(() => data.value ?? [])
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString('es-ES')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #0f172a;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.state {
|
||||
padding: 1.5rem;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.state.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.publisher {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.desc {
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.card-footer a {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
150
apps/web/pages/discover.vue
Normal file
150
apps/web/pages/discover.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Descubrimiento</h1>
|
||||
<p>Últimos cambios detectados en el catálogo.</p>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label>Días</label>
|
||||
<select v-model.number="days" @change="refresh">
|
||||
<option :value="3">3</option>
|
||||
<option :value="7">7</option>
|
||||
<option :value="14">14</option>
|
||||
<option :value="30">30</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="pending" class="panel">Cargando...</div>
|
||||
<div v-else-if="error" class="panel error">Error: {{ error.message }}</div>
|
||||
<section v-else class="grid">
|
||||
<article v-for="change in changes" :key="change.id" class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3>{{ change.snapshot?.title || 'Sin título' }}</h3>
|
||||
<p class="muted">{{ change.snapshot?.publisher || 'Sin publicador' }}</p>
|
||||
</div>
|
||||
<span class="pill">{{ change.eventType }}</span>
|
||||
</div>
|
||||
<p class="desc" v-if="change.snapshot?.description">{{ change.snapshot.description }}</p>
|
||||
<div class="tags">
|
||||
<span v-for="tag in change.tags?.topics || []" :key="tag" class="tag">{{ tag }}</span>
|
||||
<span v-for="tag in change.tags?.territories || []" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small>{{ formatDate(change.createdAt) }}</small>
|
||||
<a v-if="change.snapshot?.sourceUrl" :href="change.snapshot.sourceUrl" target="_blank">Fuente</a>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const days = ref(7)
|
||||
const { data, pending, error, refresh } = await useFetch(() => `${config.public.apiBase}/discover/changes?days=${days.value}&limit=30`)
|
||||
const changes = computed(() => data.value ?? [])
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleString('es-ES')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #eef2f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
background: #fff;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
height: fit-content;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.tag {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.panel.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
.desc {
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
271
apps/web/pages/index.vue
Normal file
271
apps/web/pages/index.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="kicker">Radar automático de datos públicos</p>
|
||||
<h1>Detecta cambios en datos.gob.es antes que nadie</h1>
|
||||
<p class="lead">Monitoriza datasets, normaliza formatos y recibe alertas por email o Telegram. Configura perfiles sin complicaciones.</p>
|
||||
<div class="hero-actions">
|
||||
<NuxtLink to="/catalog" class="btn primary">Ver catálogo</NuxtLink>
|
||||
<NuxtLink to="/alerts" class="btn ghost">Crear alertas</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card" v-if="summary">
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.items }}</span>
|
||||
<span class="metric-label">Datasets</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.changes }}</span>
|
||||
<span class="metric-label">Cambios recientes</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value">{{ summary.totals.alertProfiles }}</span>
|
||||
<span class="metric-label">Perfiles activos</span>
|
||||
</div>
|
||||
<div class="card-footer">Actualizado automáticamente</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trend" v-if="summary?.trend?.length">
|
||||
<h2>Tendencia (últimos {{ summary.trend.length }} días)</h2>
|
||||
<div class="trend-chart">
|
||||
<div v-for="point in summary.trend" :key="point.date" class="trend-bar">
|
||||
<span class="bar-fill" :style="{ height: barHeight(point.total) }"></span>
|
||||
<small>{{ formatShortDate(point.date) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="panel">
|
||||
<h2>Cambios recientes</h2>
|
||||
<ul>
|
||||
<li v-for="change in summary?.latestChanges || []" :key="change.id">
|
||||
<strong>{{ change.snapshot?.title || 'Sin título' }}</strong>
|
||||
<span class="muted">{{ change.snapshot?.publisher || '—' }}</span>
|
||||
<small class="muted">{{ formatDate(change.createdAt) }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top publicadores</h2>
|
||||
<ul>
|
||||
<li v-for="pub in summary?.topPublishers || []" :key="pub.label">
|
||||
<span>{{ pub.label }}</span>
|
||||
<strong>{{ pub.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Formatos más comunes</h2>
|
||||
<ul>
|
||||
<li v-for="fmt in summary?.topFormats || []" :key="fmt.label">
|
||||
<span>{{ fmt.label }}</span>
|
||||
<strong>{{ fmt.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top temas</h2>
|
||||
<ul>
|
||||
<li v-for="topic in summary?.topTopics || []" :key="topic.label">
|
||||
<span>{{ topic.label }}</span>
|
||||
<strong>{{ topic.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Top territorios</h2>
|
||||
<ul>
|
||||
<li v-for="territory in summary?.topTerritories || []" :key="territory.label">
|
||||
<span>{{ territory.label }}</span>
|
||||
<strong>{{ territory.value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data: summary } = await useFetch(`${config.public.apiBase}/dashboard/summary`)
|
||||
const maxTrend = computed(() => {
|
||||
const values = summary.value?.trend?.map((p) => p.total) || []
|
||||
return Math.max(...values, 1)
|
||||
})
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleDateString('es-ES')
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
function barHeight(value) {
|
||||
return `${Math.round((value / maxTrend.value) * 100)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #f5f7ff 0%, #eef2f9 45%, #e5ecf5 100%);
|
||||
color: #0f172a;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
.kicker {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.2rem, 3vw, 3rem);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.lead {
|
||||
font-size: 1.05rem;
|
||||
color: #334155;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.hero-card {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 24px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.card-footer {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.grid {
|
||||
margin-top: 3rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.trend {
|
||||
margin-top: 2.5rem;
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.trend h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.trend-chart {
|
||||
margin-top: 1.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(32px, 1fr));
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
}
|
||||
.trend-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
height: 140px;
|
||||
}
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
max-width: 18px;
|
||||
background: linear-gradient(180deg, #38bdf8 0%, #0f172a 100%);
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
min-height: 8px;
|
||||
}
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.panel h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.panel ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.panel li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
apps/web/pages/ingest.vue
Normal file
110
apps/web/pages/ingest.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Ingesta manual</h1>
|
||||
<p>Crea un item de catálogo para pruebas rápidas.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form class="panel" @submit.prevent="submit">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Título</label>
|
||||
<input v-model="form.title" required />
|
||||
</div>
|
||||
<div>
|
||||
<label>Publisher</label>
|
||||
<input v-model="form.publisher" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Source URL</label>
|
||||
<input v-model="form.sourceUrl" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Formato</label>
|
||||
<input v-model="form.format" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="submit">Ingestar</button>
|
||||
</form>
|
||||
<div v-if="result" class="panel">Creado: {{ result.item?.id || result.id }}</div>
|
||||
<div v-if="err" class="panel error">Error: {{ err }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const form = reactive({ title: '', publisher: '', sourceUrl: '', format: '' })
|
||||
const result = ref(null)
|
||||
const err = ref(null)
|
||||
|
||||
async function submit() {
|
||||
err.value = null
|
||||
result.value = null
|
||||
try {
|
||||
const res = await $fetch(`${config.public.apiBase}/catalog/ingest`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
result.value = res
|
||||
} catch (e) {
|
||||
err.value = e.message || String(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.panel.error {
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cbd5f5;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 1.2rem;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
163
apps/web/pages/landing.vue
Normal file
163
apps/web/pages/landing.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="kicker">Radar automático</p>
|
||||
<h1>Convierte datos públicos en señales accionables</h1>
|
||||
<p class="lead">gob-alert detecta cambios en datos.gob.es, normaliza formatos y alerta a tu equipo en minutos.</p>
|
||||
<div class="hero-actions">
|
||||
<NuxtLink to="/plans" class="btn primary">Ver planes</NuxtLink>
|
||||
<NuxtLink to="/discover" class="btn ghost">Explorar cambios</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Detección</span>
|
||||
<span class="metric-value">< 1h</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Formatos</span>
|
||||
<span class="metric-value">CSV · JSON</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Alertas</span>
|
||||
<span class="metric-value">Email · Telegram</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<article>
|
||||
<h3>Ingesta automatizada</h3>
|
||||
<p>Agenda ingestas periódicas y guarda histórico de cambios por dataset.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Clasificación inteligente</h3>
|
||||
<p>Detecta organismos, territorios y temas para segmentar alertas.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Panel de control</h3>
|
||||
<p>Visualiza novedades, tendencias y métricas clave en tiempo real.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div>
|
||||
<h2>Listo para tu piloto en 30 días</h2>
|
||||
<p>Integra alertas personalizadas para consultoras, pymes y universidades.</p>
|
||||
</div>
|
||||
<NuxtLink to="/admin" class="btn primary">Ver demo</NuxtLink>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #f4f7ff 0%, #eef2f9 40%, #e2e8f0 100%);
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
.kicker {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.4rem, 3vw, 3.2rem);
|
||||
margin: 0.6rem 0 1rem;
|
||||
}
|
||||
.lead {
|
||||
color: #334155;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid #0f172a;
|
||||
color: #0f172a;
|
||||
}
|
||||
.hero-card {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 1.8rem;
|
||||
border-radius: 24px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.features {
|
||||
margin-top: 3rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.features article {
|
||||
background: #fff;
|
||||
padding: 1.4rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.cta {
|
||||
margin-top: 3rem;
|
||||
background: #38bdf8;
|
||||
padding: 2rem;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.cta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
apps/web/pages/plans.vue
Normal file
95
apps/web/pages/plans.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<Header />
|
||||
<main class="container">
|
||||
<header class="page-header">
|
||||
<h1>Planes y precios</h1>
|
||||
<p>Elige el plan que mejor se adapta a tu equipo.</p>
|
||||
</header>
|
||||
<section class="grid">
|
||||
<article v-for="plan in plans" :key="plan.id" class="card">
|
||||
<h2>{{ plan.name }}</h2>
|
||||
<p class="muted">{{ plan.description }}</p>
|
||||
<div class="price">
|
||||
<span class="value">{{ plan.priceMonthly }}</span>
|
||||
<span class="currency">{{ plan.currency }}/mes</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="(value, key) in plan.features || {}" :key="key">
|
||||
<strong>{{ key }}</strong>: {{ value }}
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn">Solicitar acceso</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '~/components/Header.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const { data } = await useFetch(`${config.public.apiBase}/plans`)
|
||||
const plans = computed(() => data.value ?? [])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #eef2f9;
|
||||
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
|
||||
}
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 4rem;
|
||||
}
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 1.8rem;
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.currency {
|
||||
color: #64748b;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
margin-top: auto;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
35
coolify.yml
Normal file
35
coolify.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
apps:
|
||||
- name: gob-alert-postgres
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_USER: gob
|
||||
POSTGRES_PASSWORD: gobpass
|
||||
POSTGRES_DB: gob_alert
|
||||
|
||||
- name: gob-alert-redis
|
||||
image: redis:7-alpine
|
||||
|
||||
- name: gob-alert-api
|
||||
context: ./apps/api
|
||||
dockerfile: Dockerfile
|
||||
env:
|
||||
- key: DATABASE_URL
|
||||
value: postgres://gob:gobpass@localhost:5432/gob_alert
|
||||
- key: API_ADMIN_KEY
|
||||
value: changeme
|
||||
ports:
|
||||
- 3000
|
||||
|
||||
- name: gob-alert-web
|
||||
context: ./apps/web
|
||||
dockerfile: Dockerfile
|
||||
env:
|
||||
- key: API_URL
|
||||
value: http://gob-alert-api:3000
|
||||
ports:
|
||||
- 3001
|
||||
|
||||
notes: |
|
||||
This file provides a simple mapping for Coolify to build and deploy services from this repository.
|
||||
Adjust `DATABASE_URL` host when Coolify provides internal network hostnames. Use the Coolify UI
|
||||
to set secrets/variables in the target environment.
|
||||
31
docker-compose.override.yml
Normal file
31
docker-compose.override.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./apps/api
|
||||
volumes:
|
||||
- ./apps/api:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
DATABASE_URL: postgres://gob:gobpass@postgres:5432/gob_alert
|
||||
NODE_ENV: development
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./apps/web
|
||||
volumes:
|
||||
- ./apps/web:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
environment:
|
||||
API_URL: http://api:3000
|
||||
NODE_ENV: development
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3001:3000"
|
||||
depends_on:
|
||||
- api
|
||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: gob
|
||||
POSTGRES_PASSWORD: gobpass
|
||||
POSTGRES_DB: gob_alert
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./apps/api
|
||||
environment:
|
||||
DATABASE_URL: postgres://gob:gobpass@postgres:5432/gob_alert
|
||||
API_ADMIN_KEY: "changeme"
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./apps/web
|
||||
environment:
|
||||
API_URL: http://api:3000
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "3001:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
29
docs/ARCHITECTURE.md
Normal file
29
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Arquitectura (2 mini-PCs)
|
||||
|
||||
## Objetivo
|
||||
Separar la ingesta/normalización del producto (API + dashboard) para reducir carga y permitir escalado gradual.
|
||||
|
||||
## Mini-PC 1 — Ingesta y procesamiento
|
||||
- **Servicios**: workers de ingesta, normalizadores, colas ligeras.
|
||||
- **Responsabilidad**: consultar datos.gob.es, normalizar y guardar en Postgres.
|
||||
- **Componentes clave**:
|
||||
- Scheduler de ingesta (`INGEST_CRON`)
|
||||
- Bull + Redis (opcional)
|
||||
- Normalizador y clasificador
|
||||
|
||||
## Mini-PC 2 — Producto y entrega
|
||||
- **Servicios**: API Nest, Nuxt dashboard, alertas Telegram/SMTP, backups.
|
||||
- **Responsabilidad**: servir datos, dashboards y notificaciones.
|
||||
- **Componentes clave**:
|
||||
- API `apps/api`
|
||||
- Dashboard `apps/web`
|
||||
- Scheduler alertas (`ALERTS_CRON`) y backups (`BACKUP_CRON`)
|
||||
|
||||
## Comunicación
|
||||
- API HTTP/REST entre servicios.
|
||||
- Redis opcional si se utiliza colas de ingestión.
|
||||
|
||||
## Datos
|
||||
- Postgres centralizado (en Mini‑PC 1 o 2 según recursos).
|
||||
- Backups periódicos en `BACKUP_DIR`.
|
||||
|
||||
46
docs/DEPLOYMENT.md
Normal file
46
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Despliegue en mini‑PCs
|
||||
|
||||
## Requisitos
|
||||
- Docker + Docker Compose
|
||||
- Postgres y Redis (opcional)
|
||||
|
||||
## Variables de entorno recomendadas
|
||||
- `DATABASE_URL`
|
||||
- `API_ADMIN_KEY`
|
||||
- `JWT_SECRET`
|
||||
- `INGEST_CRON`
|
||||
- `ALERTS_CRON`
|
||||
- `BACKUP_CRON`
|
||||
- `BACKUP_DIR`
|
||||
- `INGEST_ENABLED`
|
||||
- `ALERTS_ENABLED`
|
||||
- `BACKUP_ENABLED`
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`
|
||||
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_DEFAULT_CHAT`
|
||||
|
||||
## Arranque rápido (todo‑en‑uno)
|
||||
```bash
|
||||
# Desde la raíz del repo
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Arranque con Docker Compose
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Estrategia recomendada (2 mini‑PCs)
|
||||
1. **Mini‑PC 1**: ejecutar Postgres + Redis + jobs de ingesta.
|
||||
2. **Mini‑PC 2**: ejecutar API + web + backups + alertas.
|
||||
3. Definir `DATABASE_URL` apuntando al Postgres del Mini‑PC 1.
|
||||
|
||||
Sugerencia de flags:
|
||||
- Mini‑PC 1: `INGEST_ENABLED=true`, `ALERTS_ENABLED=false`, `BACKUP_ENABLED=false`.
|
||||
- Mini‑PC 2: `INGEST_ENABLED=false`, `ALERTS_ENABLED=true`, `BACKUP_ENABLED=true`.
|
||||
|
||||
## Backups
|
||||
- Programados por `BACKUP_CRON`.
|
||||
- Manual: `POST /admin/backup/run`.
|
||||
17
docs/KPIS.md
Normal file
17
docs/KPIS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# KPIs y métricas
|
||||
|
||||
## KPIs iniciales
|
||||
- **Tiempo medio de detección**: minutos desde la publicación hasta la ingesta.
|
||||
- **Cobertura del catálogo**: % de datasets detectados vs. catálogo total.
|
||||
- **Tasa de alertas útiles**: % de alertas calificadas como relevantes en pilotos.
|
||||
- **Conversión piloto → cliente**: ratio de pilotos que pagan al mes 2.
|
||||
|
||||
## Métricas operativas (hoy)
|
||||
- `ingest_runs`: duración, errores, datasets nuevos/actualizados.
|
||||
- `alert_runs`: perfiles procesados, alertas enviadas/fallidas.
|
||||
- `/admin/monitor/status`: conteos globales.
|
||||
|
||||
## Recolección recomendada
|
||||
- Log de timestamps de ingesta por dataset (para medir detección).
|
||||
- Feedback por alerta (útil/no útil) en UI de piloto.
|
||||
|
||||
19
docs/OPERATIONS.md
Normal file
19
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Operaciones y monitorización
|
||||
|
||||
## Endpoints admin
|
||||
- `POST /admin/ingest/pause` / `resume`
|
||||
- `POST /admin/ingest/queue`
|
||||
- `GET /admin/ingest/runs`
|
||||
- `POST /admin/backup/run`
|
||||
- `GET /admin/backup/list`
|
||||
- `GET /admin/alerts/runs`
|
||||
- `GET /admin/monitor/status`
|
||||
|
||||
## Checks rápidos
|
||||
- `/dashboard/summary` (salud del dashboard)
|
||||
- `/catalog/health` (API catálogo)
|
||||
|
||||
## Logs
|
||||
- Logs de ingesta y alertas quedan en stdout.
|
||||
- `ingest_runs` y `alert_runs` guardan historial resumido.
|
||||
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "gob-alert",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"bootstrap": "pnpm install",
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^1.10.11"
|
||||
}
|
||||
}
|
||||
7
packages/shared/package.json
Normal file
7
packages/shared/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@gob-alert/shared",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"files": []
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
16
turbo.json
Normal file
16
turbo.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"dev": {
|
||||
"dependsOn": [],
|
||||
"cache": false
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user