first commit

This commit is contained in:
alexandrump
2026-02-09 01:02:53 +01:00
commit 82f3464565
90 changed files with 4788 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.turbo
.env
.env.*
**/node_modules

11
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View 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 });
}
}

View 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();
}
}

View 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,
};
}
}

View 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);
}
}

View 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 };
}
}

View 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 });
}
}

View 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 {}

View 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);
}
}

View 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 {}

View 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);
}
}
}

View 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;
}
}

View 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,
});
}
}

View 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;
}
}
}

View 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' };
}
}

View 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 {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello from gob-alert API';
}
}

View 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');
}
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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 };
}
}

View 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;
}

View 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 {}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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 {}

View 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 };
}
}
}

View File

@@ -0,0 +1,9 @@
export class CatalogItemDto {
id: string;
title: string;
description?: string;
sourceUrl?: string;
updatedAt?: string;
format?: string;
publisher?: string;
}

View File

@@ -0,0 +1,8 @@
export class CreateCatalogItemDto {
title: string;
description?: string;
sourceUrl?: string;
updatedAt?: string; // ISO
format?: string;
publisher?: string;
}

View 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 {}

View 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);
}
}

View 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;
}
}

View 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 {}

View 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;
}
}

View 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 {}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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 };
}
}

View 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 {}

View 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();
}
}

View 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);
}
}
}

View 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
View 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();

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { NormalizerService } from './normalizer.service'
@Module({
providers: [NormalizerService],
exports: [NormalizerService],
})
export class NormalizerModule {}

View 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
}
}

View 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
}
}

View 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();
}
}

View 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 {}

View 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
View 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
View 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
View 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.

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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
View 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
View 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 MiniPC 1 o 2 según recursos).
- Backups periódicos en `BACKUP_DIR`.

46
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,46 @@
# Despliegue en miniPCs
## 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 (todoenuno)
```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 miniPCs)
1. **MiniPC 1**: ejecutar Postgres + Redis + jobs de ingesta.
2. **MiniPC 2**: ejecutar API + web + backups + alertas.
3. Definir `DATABASE_URL` apuntando al Postgres del MiniPC 1.
Sugerencia de flags:
- MiniPC 1: `INGEST_ENABLED=true`, `ALERTS_ENABLED=false`, `BACKUP_ENABLED=false`.
- MiniPC 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
View 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
View 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
View 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"
}
}

View 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
View File

@@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'packages/*'

16
turbo.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"dev": {
"dependsOn": [],
"cache": false
},
"build": {
"dependsOn": ["^build"],
"outputs": []
},
"start": {
"dependsOn": ["build"]
}
}
}