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

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"]
}