first commit
This commit is contained in:
20
apps/api/src/admin/admin-alerts.controller.ts
Normal file
20
apps/api/src/admin/admin-alerts.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
|
||||
@Controller('admin/alerts')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminAlertsController {
|
||||
constructor(
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly runs: Repository<AlertRunEntity>,
|
||||
) {}
|
||||
|
||||
@Get('runs')
|
||||
async listRuns(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.runs.find({ order: { startedAt: 'DESC' }, take });
|
||||
}
|
||||
}
|
||||
19
apps/api/src/admin/admin-backup.controller.ts
Normal file
19
apps/api/src/admin/admin-backup.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { BackupService } from '../backup/backup.service';
|
||||
|
||||
@Controller('admin/backup')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminBackupController {
|
||||
constructor(private readonly backup: BackupService) {}
|
||||
|
||||
@Post('run')
|
||||
async run() {
|
||||
return this.backup.runBackup();
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
async list() {
|
||||
return this.backup.listBackups();
|
||||
}
|
||||
}
|
||||
65
apps/api/src/admin/admin-monitor.controller.ts
Normal file
65
apps/api/src/admin/admin-monitor.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
|
||||
@Controller('admin/monitor')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminMonitorController {
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly items: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly versions: Repository<CatalogItemVersionEntity>,
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly profiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly deliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly ingestRuns: Repository<IngestRunEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly alertRuns: Repository<AlertRunEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async status() {
|
||||
const [
|
||||
items,
|
||||
versions,
|
||||
profiles,
|
||||
deliveries,
|
||||
users,
|
||||
lastIngest,
|
||||
lastAlerts,
|
||||
] = await Promise.all([
|
||||
this.items.count(),
|
||||
this.versions.count(),
|
||||
this.profiles.count(),
|
||||
this.deliveries.count(),
|
||||
this.users.count(),
|
||||
this.ingestRuns.find({ order: { startedAt: 'DESC' }, take: 1 }),
|
||||
this.alertRuns.find({ order: { startedAt: 'DESC' }, take: 1 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
counts: {
|
||||
catalogItems: items,
|
||||
catalogVersions: versions,
|
||||
alertProfiles: profiles,
|
||||
alertDeliveries: deliveries,
|
||||
users,
|
||||
},
|
||||
lastIngest: lastIngest[0] || null,
|
||||
lastAlerts: lastAlerts[0] || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
apps/api/src/admin/admin-plans.controller.ts
Normal file
24
apps/api/src/admin/admin-plans.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { PlansService } from '../plans/plans.service';
|
||||
|
||||
@Controller('admin/plans')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminPlansController {
|
||||
constructor(private readonly plans: PlansService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.plans.listAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() body: any) {
|
||||
return this.plans.create(body);
|
||||
}
|
||||
|
||||
@Post(':id')
|
||||
async update(@Param('id') id: string, @Body() body: any) {
|
||||
return this.plans.update(id, body);
|
||||
}
|
||||
}
|
||||
42
apps/api/src/admin/admin-users.controller.ts
Normal file
42
apps/api/src/admin/admin-users.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
|
||||
@Controller('admin/users')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminUsersController {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 50, 200) : 50;
|
||||
return this.users.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take,
|
||||
relations: ['plan'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/plan')
|
||||
async assignPlan(@Param('id') id: string, @Body() body: any) {
|
||||
const user = await this.users.findOne({ where: { id } });
|
||||
if (!user) return { ok: false, error: 'User not found' };
|
||||
user.planId = body.planId || null;
|
||||
await this.users.save(user);
|
||||
return { ok: true, userId: user.id, planId: user.planId };
|
||||
}
|
||||
|
||||
@Post(':id/role')
|
||||
async updateRole(@Param('id') id: string, @Body() body: any) {
|
||||
const user = await this.users.findOne({ where: { id } });
|
||||
if (!user) return { ok: false, error: 'User not found' };
|
||||
if (body.role) user.role = body.role;
|
||||
await this.users.save(user);
|
||||
return { ok: true, userId: user.id, role: user.role };
|
||||
}
|
||||
}
|
||||
38
apps/api/src/admin/admin.controller.ts
Normal file
38
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Controller, Post, HttpCode, UseGuards, Get, Query } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Controller('admin/ingest')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
export class AdminIngestController {
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly runs: Repository<IngestRunEntity>,
|
||||
) {}
|
||||
|
||||
@Post('pause')
|
||||
@HttpCode(200)
|
||||
pause() {
|
||||
const job = this.schedulerRegistry.getCronJob('ingest_job');
|
||||
job.stop();
|
||||
return { ok: true, paused: true };
|
||||
}
|
||||
|
||||
@Post('resume')
|
||||
@HttpCode(200)
|
||||
resume() {
|
||||
const job = this.schedulerRegistry.getCronJob('ingest_job');
|
||||
job.start();
|
||||
return { ok: true, resumed: true };
|
||||
}
|
||||
|
||||
@Get('runs')
|
||||
async listRuns(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.runs.find({ order: { startedAt: 'DESC' }, take });
|
||||
}
|
||||
}
|
||||
46
apps/api/src/admin/admin.module.ts
Normal file
46
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AdminIngestController } from './admin.controller';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { AdminAlertsController } from './admin-alerts.controller';
|
||||
import { AdminPlansController } from './admin-plans.controller';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlansModule } from '../plans/plans.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { AdminBackupController } from './admin-backup.controller';
|
||||
import { AdminMonitorController } from './admin-monitor.controller';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
PlansModule,
|
||||
BackupModule,
|
||||
TypeOrmModule.forFeature([
|
||||
IngestRunEntity,
|
||||
AlertRunEntity,
|
||||
UserEntity,
|
||||
CatalogItemEntity,
|
||||
CatalogItemVersionEntity,
|
||||
AlertProfileEntity,
|
||||
AlertDeliveryEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
AdminIngestController,
|
||||
AdminAlertsController,
|
||||
AdminPlansController,
|
||||
AdminUsersController,
|
||||
AdminBackupController,
|
||||
AdminMonitorController,
|
||||
],
|
||||
providers: [AdminAuthGuard],
|
||||
})
|
||||
export class AdminModule {}
|
||||
44
apps/api/src/alerts/alerts.controller.ts
Normal file
44
apps/api/src/alerts/alerts.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
|
||||
import { AlertsService } from './alerts.service';
|
||||
|
||||
@Controller('alerts')
|
||||
export class AlertsController {
|
||||
constructor(private readonly alerts: AlertsService) {}
|
||||
|
||||
private getUserId() {
|
||||
return process.env.DEFAULT_ALERT_USER || '00000000-0000-0000-0000-000000000001';
|
||||
}
|
||||
|
||||
@Get('profiles')
|
||||
async listProfiles() {
|
||||
return this.alerts.listProfiles(this.getUserId());
|
||||
}
|
||||
|
||||
@Post('profiles')
|
||||
async createProfile(@Body() body: any) {
|
||||
return this.alerts.createProfile(this.getUserId(), body);
|
||||
}
|
||||
|
||||
@Post('profiles/:id')
|
||||
async updateProfile(@Param('id') id: string, @Body() body: any) {
|
||||
return this.alerts.updateProfile(this.getUserId(), id, body);
|
||||
}
|
||||
|
||||
@Delete('profiles/:id')
|
||||
async deleteProfile(@Param('id') id: string) {
|
||||
return this.alerts.deleteProfile(this.getUserId(), id);
|
||||
}
|
||||
|
||||
@Post('run')
|
||||
async runAll() {
|
||||
return this.alerts.runAllActive();
|
||||
}
|
||||
|
||||
@Post('run/:id')
|
||||
async runProfile(@Param('id') id: string) {
|
||||
const profiles = await this.alerts.listProfiles(this.getUserId());
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return { ok: false, error: 'Profile not found' };
|
||||
return this.alerts.runProfile(profile);
|
||||
}
|
||||
}
|
||||
23
apps/api/src/alerts/alerts.module.ts
Normal file
23
apps/api/src/alerts/alerts.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { AlertsController } from './alerts.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TelegramService } from './telegram.service';
|
||||
import { AlertsScheduler } from './alerts.scheduler';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AlertProfileEntity, AlertDeliveryEntity, AlertRunEntity, ClassificationTagEntity]),
|
||||
CatalogModule,
|
||||
],
|
||||
providers: [AlertsService, MailerService, TelegramService, AlertsScheduler],
|
||||
controllers: [AlertsController],
|
||||
exports: [AlertsService],
|
||||
})
|
||||
export class AlertsModule {}
|
||||
25
apps/api/src/alerts/alerts.scheduler.ts
Normal file
25
apps/api/src/alerts/alerts.scheduler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { AlertsService } from './alerts.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlertsScheduler {
|
||||
private readonly logger = new Logger(AlertsScheduler.name);
|
||||
|
||||
constructor(private readonly alerts: AlertsService) {}
|
||||
|
||||
@Cron(process.env.ALERTS_CRON || CronExpression.EVERY_DAY_AT_8AM, { name: 'alerts_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.ALERTS_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled alerts run');
|
||||
try {
|
||||
const res = await this.alerts.runAllActive();
|
||||
const total = res?.results?.reduce((acc: number, r: any) => acc + (r.sent || 0), 0) || 0;
|
||||
this.logger.log(`Alerts sent: ${total}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error('Alerts scheduler failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
apps/api/src/alerts/alerts.service.ts
Normal file
210
apps/api/src/alerts/alerts.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { TelegramService } from './telegram.service';
|
||||
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
private readonly logger = new Logger(AlertsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly profiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly deliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly runs: Repository<AlertRunEntity>,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly mailer: MailerService,
|
||||
private readonly telegram: TelegramService,
|
||||
) {}
|
||||
|
||||
async listProfiles(userId: string) {
|
||||
return this.profiles.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async createProfile(userId: string, payload: Partial<AlertProfileEntity>) {
|
||||
const profile = this.profiles.create({
|
||||
userId,
|
||||
name: payload.name || 'Perfil',
|
||||
isActive: payload.isActive ?? true,
|
||||
frequency: payload.frequency || 'daily',
|
||||
channel: payload.channel || 'email',
|
||||
channelTarget: payload.channelTarget,
|
||||
queryText: payload.queryText,
|
||||
rules: payload.rules,
|
||||
});
|
||||
return this.profiles.save(profile);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, id: string, payload: Partial<AlertProfileEntity>) {
|
||||
const profile = await this.profiles.findOne({ where: { id, userId } });
|
||||
if (!profile) return null;
|
||||
Object.assign(profile, payload);
|
||||
return this.profiles.save(profile);
|
||||
}
|
||||
|
||||
async deleteProfile(userId: string, id: string) {
|
||||
const profile = await this.profiles.findOne({ where: { id, userId } });
|
||||
if (!profile) return null;
|
||||
await this.profiles.delete({ id });
|
||||
return profile;
|
||||
}
|
||||
|
||||
async runProfile(profile: AlertProfileEntity) {
|
||||
const days = profile.rules?.updatedWithinDays;
|
||||
const changes = days ? await this.catalog.listChangesSince(this.startOfDayOffset(days)) : await this.catalog.listChanges(50);
|
||||
const itemIds = Array.from(new Set(changes.map((c) => c.itemId).filter(Boolean)));
|
||||
const tags = itemIds.length
|
||||
? await this.tags.find({ where: { itemId: In(itemIds) } })
|
||||
: [];
|
||||
const tagsByItem = new Map<string, ClassificationTagEntity[]>();
|
||||
for (const tag of tags) {
|
||||
const list = tagsByItem.get(tag.itemId) || [];
|
||||
list.push(tag);
|
||||
tagsByItem.set(tag.itemId, list);
|
||||
}
|
||||
|
||||
const matches = changes.filter((c) => this.matchProfile(profile, c, tagsByItem.get(c.itemId) || []));
|
||||
|
||||
if (!matches.length) return { ok: true, sent: 0 };
|
||||
|
||||
let sent = 0;
|
||||
for (const change of matches) {
|
||||
const already = await this.deliveries.findOne({ where: { profileId: profile.id, itemId: change.itemId } });
|
||||
if (already) continue;
|
||||
|
||||
const message = this.formatMessage(profile, change, tagsByItem.get(change.itemId) || []);
|
||||
let status: 'sent' | 'failed' = 'sent';
|
||||
let details = '';
|
||||
try {
|
||||
if (profile.channel === 'telegram') {
|
||||
await this.telegram.send(profile.channelTarget, message);
|
||||
} else {
|
||||
await this.mailer.send(profile.channelTarget, `gob-alert: ${profile.name}`, message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
status = 'failed';
|
||||
details = err?.message || String(err);
|
||||
this.logger.error('Alert delivery failed', details);
|
||||
}
|
||||
|
||||
await this.deliveries.save(this.deliveries.create({
|
||||
profileId: profile.id,
|
||||
itemId: change.itemId,
|
||||
channel: profile.channel,
|
||||
status,
|
||||
details,
|
||||
}));
|
||||
if (status === 'sent') sent++;
|
||||
}
|
||||
|
||||
return { ok: true, sent };
|
||||
}
|
||||
|
||||
async runAllActive() {
|
||||
const runStart = Date.now();
|
||||
const run = await this.runs.save(this.runs.create({ status: 'running' }));
|
||||
const profiles = await this.profiles.find({ where: { isActive: true } });
|
||||
const results = [] as any[];
|
||||
let sentTotal = 0;
|
||||
let failed = 0;
|
||||
for (const profile of profiles) {
|
||||
try {
|
||||
const res = await this.runProfile(profile);
|
||||
results.push({ profileId: profile.id, ...res });
|
||||
sentTotal += res.sent || 0;
|
||||
} catch (err: any) {
|
||||
failed += 1;
|
||||
results.push({ profileId: profile.id, ok: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
run.status = failed ? 'failed' : 'success';
|
||||
run.profiles = profiles.length;
|
||||
run.sent = sentTotal;
|
||||
run.failed = failed;
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
return { ok: true, runId: run.id, results };
|
||||
}
|
||||
|
||||
private matchProfile(profile: AlertProfileEntity, change: any, tags: ClassificationTagEntity[]) {
|
||||
if (!change || !change.snapshot) return false;
|
||||
const snapshot = change.snapshot;
|
||||
|
||||
if (profile.queryText) {
|
||||
const text = `${snapshot.title || ''} ${snapshot.description || ''}`.toLowerCase();
|
||||
if (!text.includes(profile.queryText.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
const rules = profile.rules || {};
|
||||
if (rules.publishers && rules.publishers.length) {
|
||||
if (!snapshot.publisher) return false;
|
||||
if (!rules.publishers.some((p) => snapshot.publisher.toLowerCase().includes(p.toLowerCase()))) return false;
|
||||
}
|
||||
if (rules.formats && rules.formats.length) {
|
||||
if (!snapshot.format) return false;
|
||||
const formatValue = String(snapshot.format).toLowerCase();
|
||||
if (!rules.formats.some((f) => formatValue.includes(f.toLowerCase()))) return false;
|
||||
}
|
||||
if (rules.updatedWithinDays) {
|
||||
if (!snapshot.updatedAt) return false;
|
||||
const updated = new Date(snapshot.updatedAt);
|
||||
const limit = new Date();
|
||||
limit.setDate(limit.getDate() - rules.updatedWithinDays);
|
||||
if (updated < limit) return false;
|
||||
}
|
||||
|
||||
if (rules.topics && rules.topics.length) {
|
||||
if (!this.matchTags(tags, 'topic', rules.topics)) return false;
|
||||
}
|
||||
|
||||
if (rules.territories && rules.territories.length) {
|
||||
if (!this.matchTags(tags, 'territory', rules.territories)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private formatMessage(profile: AlertProfileEntity, change: any, tags: ClassificationTagEntity[]) {
|
||||
const snap = change.snapshot || {};
|
||||
const topics = this.collectTags(tags, 'topic');
|
||||
const territories = this.collectTags(tags, 'territory');
|
||||
return [
|
||||
`Perfil: ${profile.name}`,
|
||||
`Evento: ${change.eventType}`,
|
||||
`Título: ${snap.title || 'sin título'}`,
|
||||
snap.publisher ? `Publicador: ${snap.publisher}` : null,
|
||||
topics.length ? `Temas: ${topics.join(', ')}` : null,
|
||||
territories.length ? `Territorios: ${territories.join(', ')}` : null,
|
||||
snap.format ? `Formato: ${snap.format}` : null,
|
||||
snap.updatedAt ? `Actualizado: ${snap.updatedAt}` : null,
|
||||
snap.sourceUrl ? `Fuente: ${snap.sourceUrl}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private matchTags(tags: ClassificationTagEntity[], type: ClassificationTagEntity['type'], rules: string[]) {
|
||||
const values = tags.filter((t) => t.type === type).map((t) => t.value.toLowerCase());
|
||||
if (!values.length) return false;
|
||||
return rules.some((rule) => values.some((value) => value.includes(rule.toLowerCase())));
|
||||
}
|
||||
|
||||
private collectTags(tags: ClassificationTagEntity[], type: ClassificationTagEntity['type']) {
|
||||
return tags.filter((t) => t.type === type).map((t) => t.value);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
28
apps/api/src/alerts/mailer.service.ts
Normal file
28
apps/api/src/alerts/mailer.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
private readonly logger = new Logger(MailerService.name);
|
||||
private readonly transport = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 25,
|
||||
secure: false,
|
||||
auth: process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || '' }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
async send(to: string | undefined, subject: string, text: string) {
|
||||
if (!to) {
|
||||
this.logger.warn('Mailer target missing');
|
||||
return;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: process.env.SMTP_FROM || 'gob-alert@localhost',
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
apps/api/src/alerts/telegram.service.ts
Normal file
25
apps/api/src/alerts/telegram.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class TelegramService {
|
||||
private readonly logger = new Logger(TelegramService.name);
|
||||
|
||||
async send(target: string | undefined, text: string) {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = target || process.env.TELEGRAM_DEFAULT_CHAT;
|
||||
if (!token || !chatId) {
|
||||
this.logger.warn('Telegram not configured');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.post(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error('Telegram send failed', err?.message || err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/api/src/app.controller.ts
Normal file
12
apps/api/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHealth() {
|
||||
return { ok: true, service: 'gob-alert-api' };
|
||||
}
|
||||
}
|
||||
40
apps/api/src/app.module.ts
Normal file
40
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { NormalizerModule } from './normalizer/normalizer.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AlertsModule } from './alerts/alerts.module';
|
||||
import { ClassifierModule } from './classifier/classifier.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { PlansModule } from './plans/plans.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { DiscoveryModule } from './discovery/discovery.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
url: process.env.DATABASE_URL || 'postgres://user:pass@localhost:5432/gob_alert',
|
||||
synchronize: true,
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
CatalogModule,
|
||||
NormalizerModule,
|
||||
AdminModule,
|
||||
IngestModule,
|
||||
AuthModule,
|
||||
AlertsModule,
|
||||
ClassifierModule,
|
||||
DashboardModule,
|
||||
PlansModule,
|
||||
BackupModule,
|
||||
DiscoveryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
apps/api/src/app.service.ts
Normal file
8
apps/api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello from gob-alert API';
|
||||
}
|
||||
}
|
||||
44
apps/api/src/auth/admin.guard.ts
Normal file
44
apps/api/src/auth/admin.guard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwt: JwtService, private readonly config: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const authHeader = req.headers['authorization'];
|
||||
const apiKeyHeader = req.headers['x-api-key'];
|
||||
const envKey = this.config.get('API_ADMIN_KEY') || process.env.API_ADMIN_KEY || 'dev-admin-key';
|
||||
|
||||
let token: string | undefined;
|
||||
if (authHeader) {
|
||||
const raw = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
if (typeof raw === 'string' && raw.startsWith('Bearer ')) token = raw.slice(7).trim();
|
||||
else if (typeof raw === 'string' && !raw.startsWith('Bearer ')) token = raw.trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const secret = this.config.get('JWT_SECRET') || process.env.JWT_SECRET || 'dev-secret';
|
||||
const payload: any = this.jwt.verify(token, { secret });
|
||||
if (payload && payload.role === 'admin') {
|
||||
req.user = { id: payload.sub, email: payload.email, role: payload.role };
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through to api key check
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to API key header (x-api-key) or plain authorization header value
|
||||
let provided: string | undefined;
|
||||
if (apiKeyHeader) provided = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
|
||||
if (!provided && authHeader && typeof authHeader === 'string' && !authHeader.startsWith('Bearer ')) provided = authHeader;
|
||||
|
||||
if (provided && provided === envKey) return true;
|
||||
|
||||
throw new UnauthorizedException('Invalid admin credentials');
|
||||
}
|
||||
}
|
||||
23
apps/api/src/auth/auth.controller.ts
Normal file
23
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Post, Body, BadRequestException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
class RegisterDto { email: string; password: string }
|
||||
class LoginDto { email: string; password: string }
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() body: RegisterDto) {
|
||||
if (!body.email || !body.password) throw new BadRequestException('email and password required');
|
||||
return this.auth.register(body.email, body.password);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: LoginDto) {
|
||||
const user = await this.auth.validateUser(body.email, body.password);
|
||||
if (!user) throw new BadRequestException('invalid credentials');
|
||||
return this.auth.login(user);
|
||||
}
|
||||
}
|
||||
31
apps/api/src/auth/auth.module.ts
Normal file
31
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { PlansModule } from '../plans/plans.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TypeOrmModule.forFeature([UserEntity]),
|
||||
PlansModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) => ({
|
||||
secret: cfg.get('JWT_SECRET') || 'dev-secret',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
46
apps/api/src/auth/auth.service.ts
Normal file
46
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PlansService } from '../plans/plans.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly plans: PlansService,
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, role = 'user') {
|
||||
const existing = await this.users.findOne({ where: { email } });
|
||||
if (existing) throw new ConflictException('Email already registered');
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const defaultPlan = await this.plans.getDefaultPlan();
|
||||
const user = this.users.create({ email, passwordHash: hash, role, planId: defaultPlan?.id });
|
||||
await this.users.save(user);
|
||||
return { id: user.id, email: user.email, role: user.role };
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string) {
|
||||
const user = await this.users.findOne({
|
||||
where: { email },
|
||||
select: ['id', 'email', 'passwordHash', 'role', 'planId'],
|
||||
});
|
||||
if (!user) return null;
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: UserEntity) {
|
||||
const payload = { sub: user.id, email: user.email, role: user.role };
|
||||
return { access_token: this.jwtService.sign(payload) };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.users.findOne({ where: { id } });
|
||||
}
|
||||
}
|
||||
19
apps/api/src/auth/jwt.strategy.ts
Normal file
19
apps/api/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get('JWT_SECRET') || 'dev-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return { userId: payload.sub, email: payload.email, role: payload.role };
|
||||
}
|
||||
}
|
||||
30
apps/api/src/auth/user.entity.ts
Normal file
30
apps/api/src/auth/user.entity.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class UserEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ nullable: false, select: false })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ default: 'user' })
|
||||
role: string; // 'user' | 'admin'
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
planId?: string | null;
|
||||
|
||||
@ManyToOne(() => PlanEntity, { nullable: true })
|
||||
@JoinColumn({ name: 'planId' })
|
||||
plan?: PlanEntity | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
32
apps/api/src/backup/backup.module.ts
Normal file
32
apps/api/src/backup/backup.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupScheduler } from './backup.scheduler';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CatalogItemEntity,
|
||||
CatalogItemVersionEntity,
|
||||
AlertProfileEntity,
|
||||
AlertDeliveryEntity,
|
||||
AlertRunEntity,
|
||||
IngestRunEntity,
|
||||
UserEntity,
|
||||
PlanEntity,
|
||||
ClassificationTagEntity,
|
||||
]),
|
||||
],
|
||||
providers: [BackupService, BackupScheduler],
|
||||
exports: [BackupService],
|
||||
})
|
||||
export class BackupModule {}
|
||||
24
apps/api/src/backup/backup.scheduler.ts
Normal file
24
apps/api/src/backup/backup.scheduler.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { BackupService } from './backup.service';
|
||||
|
||||
@Injectable()
|
||||
export class BackupScheduler {
|
||||
private readonly logger = new Logger(BackupScheduler.name);
|
||||
|
||||
constructor(private readonly backup: BackupService) {}
|
||||
|
||||
@Cron(process.env.BACKUP_CRON || CronExpression.EVERY_DAY_AT_2AM, { name: 'backup_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.BACKUP_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled backup triggered');
|
||||
try {
|
||||
await this.backup.runBackup();
|
||||
this.logger.log('Backup completed');
|
||||
} catch (err: any) {
|
||||
this.logger.error('Backup failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
apps/api/src/backup/backup.service.ts
Normal file
140
apps/api/src/backup/backup.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { AlertProfileEntity } from '../entities/alert-profile.entity';
|
||||
import { AlertDeliveryEntity } from '../entities/alert-delivery.entity';
|
||||
import { AlertRunEntity } from '../entities/alert-run.entity';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
private readonly logger = new Logger(BackupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly catalogItems: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly catalogVersions: Repository<CatalogItemVersionEntity>,
|
||||
@InjectRepository(AlertProfileEntity)
|
||||
private readonly alertProfiles: Repository<AlertProfileEntity>,
|
||||
@InjectRepository(AlertDeliveryEntity)
|
||||
private readonly alertDeliveries: Repository<AlertDeliveryEntity>,
|
||||
@InjectRepository(AlertRunEntity)
|
||||
private readonly alertRuns: Repository<AlertRunEntity>,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly ingestRuns: Repository<IngestRunEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly users: Repository<UserEntity>,
|
||||
@InjectRepository(PlanEntity)
|
||||
private readonly plans: Repository<PlanEntity>,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
async runBackup() {
|
||||
const dir = await this.ensureDir();
|
||||
const timestamp = this.formatTimestamp(new Date());
|
||||
const filename = `gob-alert-backup-${timestamp}.json`;
|
||||
const filepath = path.join(dir, filename);
|
||||
|
||||
const limit = this.getLimit();
|
||||
const [
|
||||
catalogItems,
|
||||
catalogVersions,
|
||||
alertProfiles,
|
||||
alertDeliveries,
|
||||
alertRuns,
|
||||
ingestRuns,
|
||||
users,
|
||||
plans,
|
||||
tags,
|
||||
] = await Promise.all([
|
||||
this.catalogItems.find({ take: limit }),
|
||||
this.catalogVersions.find({ take: limit }),
|
||||
this.alertProfiles.find({ take: limit }),
|
||||
this.alertDeliveries.find({ take: limit }),
|
||||
this.alertRuns.find({ take: limit }),
|
||||
this.ingestRuns.find({ take: limit }),
|
||||
this.users.find({ take: limit, relations: ['plan'] }),
|
||||
this.plans.find({ take: limit }),
|
||||
this.tags.find({ take: limit }),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
meta: {
|
||||
createdAt: new Date().toISOString(),
|
||||
limit,
|
||||
},
|
||||
catalogItems,
|
||||
catalogVersions,
|
||||
alertProfiles,
|
||||
alertDeliveries,
|
||||
alertRuns,
|
||||
ingestRuns,
|
||||
users,
|
||||
plans,
|
||||
tags,
|
||||
};
|
||||
|
||||
await fs.writeFile(filepath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.logger.log(`Backup written: ${filepath}`);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
file: filepath,
|
||||
counts: {
|
||||
catalogItems: catalogItems.length,
|
||||
catalogVersions: catalogVersions.length,
|
||||
alertProfiles: alertProfiles.length,
|
||||
alertDeliveries: alertDeliveries.length,
|
||||
alertRuns: alertRuns.length,
|
||||
ingestRuns: ingestRuns.length,
|
||||
users: users.length,
|
||||
plans: plans.length,
|
||||
tags: tags.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listBackups() {
|
||||
const dir = await this.ensureDir();
|
||||
const entries = await fs.readdir(dir);
|
||||
return entries
|
||||
.filter((name) => name.startsWith('gob-alert-backup-') && name.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
private async ensureDir() {
|
||||
const dir = path.resolve(process.env.BACKUP_DIR || './backups');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
private formatTimestamp(date: Date) {
|
||||
const pad = (v: number) => String(v).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private getLimit() {
|
||||
const raw = process.env.BACKUP_LIMIT;
|
||||
if (!raw) return undefined;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) return undefined;
|
||||
return Math.min(value, 50000);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/catalog/catalog.controller.ts
Normal file
34
apps/api/src/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CreateCatalogItemDto } from './dto/create-catalog-item.dto';
|
||||
|
||||
@Controller('catalog')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalogService: CatalogService) {}
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return { ok: true, service: 'gob-alert-catalog' };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.catalogService.list();
|
||||
}
|
||||
|
||||
@Get('changes')
|
||||
async changes(@Query('limit') limit?: string) {
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
return this.catalogService.listChanges(take);
|
||||
}
|
||||
|
||||
@Post('ingest')
|
||||
async ingest(@Body() dto: CreateCatalogItemDto) {
|
||||
return this.catalogService.upsertWithVersion(dto);
|
||||
}
|
||||
|
||||
@Get(':id/versions')
|
||||
async versions(@Param('id') id: string) {
|
||||
return this.catalogService.getVersions(id);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/catalog/catalog.module.ts
Normal file
14
apps/api/src/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([CatalogItemEntity, CatalogItemVersionEntity])],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
143
apps/api/src/catalog/catalog.service.ts
Normal file
143
apps/api/src/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { CatalogItemVersionEntity } from '../entities/catalog-item-version.entity';
|
||||
import { CreateCatalogItemDto } from './dto/create-catalog-item.dto';
|
||||
|
||||
export type CatalogUpsertResult = {
|
||||
item: CatalogItemEntity;
|
||||
created: boolean;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
@InjectRepository(CatalogItemEntity)
|
||||
private readonly repo: Repository<CatalogItemEntity>,
|
||||
@InjectRepository(CatalogItemVersionEntity)
|
||||
private readonly versions: Repository<CatalogItemVersionEntity>,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CatalogItemEntity[]> {
|
||||
return this.repo.find({ order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async listChanges(limit = 20) {
|
||||
const rows = await this.versions.find({ order: { createdAt: 'DESC' }, take: limit });
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async listChangesSince(since: Date) {
|
||||
const rows = await this.versions.find({
|
||||
where: { createdAt: MoreThanOrEqual(since) },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(dto: CreateCatalogItemDto): Promise<CatalogItemEntity> {
|
||||
const item = this.repo.create({
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
sourceUrl: dto.sourceUrl,
|
||||
format: dto.format,
|
||||
publisher: dto.publisher,
|
||||
updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : undefined,
|
||||
});
|
||||
const saved = await this.repo.save(item);
|
||||
await this.saveVersion(saved, 'created', this.toSnapshot(saved));
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Upsert with versioning: if an existing item is found and differs, save a snapshot and update
|
||||
async upsertWithVersion(dto: CreateCatalogItemDto): Promise<CatalogUpsertResult> {
|
||||
const where: Array<Partial<CatalogItemEntity>> = [{ title: dto.title }];
|
||||
if (dto.sourceUrl) where.unshift({ sourceUrl: dto.sourceUrl });
|
||||
const existing = await this.repo.findOne({ where });
|
||||
|
||||
if (!existing) {
|
||||
const created = await this.create(dto);
|
||||
return { item: created, created: true, updated: false };
|
||||
}
|
||||
|
||||
const changed = (
|
||||
existing.title !== dto.title ||
|
||||
(existing.description || '') !== (dto.description || '') ||
|
||||
(existing.format || '') !== (dto.format || '') ||
|
||||
(existing.publisher || '') !== (dto.publisher || '') ||
|
||||
((existing.updatedAt && dto.updatedAt)
|
||||
? existing.updatedAt.toISOString() !== new Date(dto.updatedAt).toISOString()
|
||||
: Boolean(existing.updatedAt) !== Boolean(dto.updatedAt))
|
||||
);
|
||||
|
||||
if (!changed) return { item: existing, created: false, updated: false };
|
||||
|
||||
await this.saveVersion(existing, 'updated', this.toSnapshot(existing));
|
||||
|
||||
existing.title = dto.title;
|
||||
existing.description = dto.description;
|
||||
existing.sourceUrl = dto.sourceUrl;
|
||||
existing.format = dto.format;
|
||||
existing.publisher = dto.publisher;
|
||||
existing.updatedAt = dto.updatedAt ? new Date(dto.updatedAt) : undefined;
|
||||
|
||||
const saved = await this.repo.save(existing);
|
||||
return { item: saved, created: false, updated: true };
|
||||
}
|
||||
|
||||
async getVersions(itemId: string) {
|
||||
const rows = await this.versions.find({ where: { itemId }, order: { createdAt: 'DESC' } });
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
itemId: r.itemId,
|
||||
eventType: r.eventType,
|
||||
snapshot: this.parseSnapshot(r.snapshot),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
private async saveVersion(item: CatalogItemEntity, eventType: 'created' | 'updated', snapshot: Record<string, unknown>) {
|
||||
const row = this.versions.create({
|
||||
itemId: item.id,
|
||||
eventType,
|
||||
snapshot: JSON.stringify(snapshot),
|
||||
});
|
||||
await this.versions.save(row);
|
||||
}
|
||||
|
||||
private toSnapshot(item: CatalogItemEntity) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
sourceUrl: item.sourceUrl,
|
||||
format: item.format,
|
||||
publisher: item.publisher,
|
||||
updatedAt: item.updatedAt ? item.updatedAt.toISOString() : null,
|
||||
createdAt: item.createdAt ? item.createdAt.toISOString() : null,
|
||||
modifiedAt: item.modifiedAt ? item.modifiedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private parseSnapshot(snapshot: string) {
|
||||
try {
|
||||
return JSON.parse(snapshot);
|
||||
} catch {
|
||||
return { raw: snapshot };
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/api/src/catalog/dto/catalog-item.dto.ts
Normal file
9
apps/api/src/catalog/dto/catalog-item.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class CatalogItemDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
sourceUrl?: string;
|
||||
updatedAt?: string;
|
||||
format?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
8
apps/api/src/catalog/dto/create-catalog-item.dto.ts
Normal file
8
apps/api/src/catalog/dto/create-catalog-item.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class CreateCatalogItemDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
sourceUrl?: string;
|
||||
updatedAt?: string; // ISO
|
||||
format?: string;
|
||||
publisher?: string;
|
||||
}
|
||||
11
apps/api/src/classifier/classifier.module.ts
Normal file
11
apps/api/src/classifier/classifier.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { ClassifierService } from './classifier.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
providers: [ClassifierService],
|
||||
exports: [ClassifierService],
|
||||
})
|
||||
export class ClassifierModule {}
|
||||
77
apps/api/src/classifier/classifier.service.ts
Normal file
77
apps/api/src/classifier/classifier.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CatalogItemEntity } from '../entities/catalog-item.entity';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
export type ClassificationResult = {
|
||||
publisher?: string;
|
||||
format?: string[];
|
||||
territory?: string[];
|
||||
topics?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ClassifierService {
|
||||
constructor(
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
async classify(item: CatalogItemEntity): Promise<ClassificationResult> {
|
||||
const res: ClassificationResult = {
|
||||
publisher: item.publisher || undefined,
|
||||
format: this.splitFormats(item.format),
|
||||
};
|
||||
|
||||
const text = `${item.title || ''} ${item.description || ''}`.toLowerCase();
|
||||
|
||||
const territory: string[] = [];
|
||||
const topics: string[] = [];
|
||||
|
||||
if (text.includes('madrid')) territory.push('Madrid');
|
||||
if (text.includes('andaluc')) territory.push('Andalucía');
|
||||
if (text.includes('catalu')) territory.push('Cataluña');
|
||||
|
||||
if (text.includes('subvenc')) topics.push('Subvenciones');
|
||||
if (text.includes('licit')) topics.push('Licitaciones');
|
||||
if (text.includes('contrat')) topics.push('Contratación');
|
||||
if (text.includes('presupuesto')) topics.push('Presupuestos');
|
||||
|
||||
res.territory = territory.length ? territory : undefined;
|
||||
res.topics = topics.length ? topics : undefined;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async applyTags(item: CatalogItemEntity, classification: ClassificationResult) {
|
||||
await this.tags.delete({ itemId: item.id });
|
||||
const next: ClassificationTagEntity[] = [];
|
||||
|
||||
if (classification.publisher) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'publisher', value: classification.publisher }));
|
||||
}
|
||||
|
||||
for (const format of classification.format || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'format', value: format }));
|
||||
}
|
||||
|
||||
for (const topic of classification.topics || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'topic', value: topic }));
|
||||
}
|
||||
|
||||
for (const territory of classification.territory || []) {
|
||||
next.push(this.tags.create({ itemId: item.id, type: 'territory', value: territory }));
|
||||
}
|
||||
|
||||
if (next.length) await this.tags.save(next);
|
||||
}
|
||||
|
||||
private splitFormats(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
104
apps/api/src/dashboard/dashboard.controller.ts
Normal file
104
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { AlertsService } from '../alerts/alerts.service';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly alerts: AlertsService,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
async summary(@Query('days') days?: string) {
|
||||
const daysCount = this.clampDays(days ? Number(days) : 14);
|
||||
const items = await this.catalog.list();
|
||||
const changes = await this.catalog.listChanges(20);
|
||||
const since = this.startOfDayOffset(daysCount - 1);
|
||||
const trendChanges = await this.catalog.listChangesSince(since);
|
||||
const profiles = await this.alerts.listProfiles(process.env.DEFAULT_ALERT_USER || '00000000-0000-0000-0000-000000000001');
|
||||
const tags = await this.tags.find();
|
||||
|
||||
const perPublisher: Record<string, number> = {};
|
||||
const perFormat: Record<string, number> = {};
|
||||
const perTopic: Record<string, number> = {};
|
||||
const perTerritory: Record<string, number> = {};
|
||||
const trend = this.buildTrend(daysCount);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.publisher) perPublisher[item.publisher] = (perPublisher[item.publisher] || 0) + 1;
|
||||
if (item.format) {
|
||||
for (const format of String(item.format).split(',')) {
|
||||
const value = format.trim();
|
||||
if (!value) continue;
|
||||
perFormat[value] = (perFormat[value] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.type === 'topic') perTopic[tag.value] = (perTopic[tag.value] || 0) + 1;
|
||||
if (tag.type === 'territory') perTerritory[tag.value] = (perTerritory[tag.value] || 0) + 1;
|
||||
}
|
||||
|
||||
for (const change of trendChanges) {
|
||||
const key = this.formatDateKey(change.createdAt);
|
||||
const bucket = trend[key];
|
||||
if (!bucket) continue;
|
||||
if (change.eventType === 'created') bucket.created += 1;
|
||||
else bucket.updated += 1;
|
||||
bucket.total += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totals: {
|
||||
items: items.length,
|
||||
changes: changes.length,
|
||||
alertProfiles: profiles.length,
|
||||
},
|
||||
latestChanges: changes.slice(0, 8),
|
||||
trend: Object.values(trend),
|
||||
topPublishers: this.toSorted(perPublisher).slice(0, 6),
|
||||
topFormats: this.toSorted(perFormat).slice(0, 6),
|
||||
topTopics: this.toSorted(perTopic).slice(0, 6),
|
||||
topTerritories: this.toSorted(perTerritory).slice(0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
private toSorted(input: Record<string, number>) {
|
||||
return Object.entries(input)
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
private clampDays(value: number) {
|
||||
if (!value || Number.isNaN(value)) return 14;
|
||||
return Math.min(Math.max(value, 7), 60);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
|
||||
private formatDateKey(value: Date) {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private buildTrend(days: number) {
|
||||
const out: Record<string, { date: string; created: number; updated: number; total: number }> = {};
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const d = this.startOfDayOffset(i);
|
||||
const key = this.formatDateKey(d);
|
||||
out[key] = { date: key, created: 0, updated: 0, total: 0 };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/dashboard/dashboard.module.ts
Normal file
12
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { AlertsModule } from '../alerts/alerts.module';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, AlertsModule, TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
controllers: [DashboardController],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
63
apps/api/src/discovery/discovery.controller.ts
Normal file
63
apps/api/src/discovery/discovery.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
|
||||
@Controller('discover')
|
||||
export class DiscoveryController {
|
||||
constructor(
|
||||
private readonly catalog: CatalogService,
|
||||
@InjectRepository(ClassificationTagEntity)
|
||||
private readonly tags: Repository<ClassificationTagEntity>,
|
||||
) {}
|
||||
|
||||
@Get('changes')
|
||||
async changes(@Query('days') days?: string, @Query('limit') limit?: string) {
|
||||
const daysCount = this.clampDays(days ? Number(days) : 7);
|
||||
const take = limit ? Math.min(Number(limit) || 20, 200) : 20;
|
||||
const since = this.startOfDayOffset(daysCount - 1);
|
||||
|
||||
let changes = await this.catalog.listChangesSince(since);
|
||||
changes = changes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
changes = changes.slice(0, take);
|
||||
|
||||
const itemIds = Array.from(new Set(changes.map((c) => c.itemId).filter(Boolean)));
|
||||
const tags = itemIds.length
|
||||
? await this.tags.find({ where: { itemId: In(itemIds) } })
|
||||
: [];
|
||||
|
||||
const tagsByItem = new Map<string, ClassificationTagEntity[]>();
|
||||
for (const tag of tags) {
|
||||
const list = tagsByItem.get(tag.itemId) || [];
|
||||
list.push(tag);
|
||||
tagsByItem.set(tag.itemId, list);
|
||||
}
|
||||
|
||||
return changes.map((change) => ({
|
||||
...change,
|
||||
tags: this.groupTags(tagsByItem.get(change.itemId) || []),
|
||||
}));
|
||||
}
|
||||
|
||||
private groupTags(tags: ClassificationTagEntity[]) {
|
||||
return {
|
||||
topics: tags.filter((t) => t.type === 'topic').map((t) => t.value),
|
||||
territories: tags.filter((t) => t.type === 'territory').map((t) => t.value),
|
||||
formats: tags.filter((t) => t.type === 'format').map((t) => t.value),
|
||||
publishers: tags.filter((t) => t.type === 'publisher').map((t) => t.value),
|
||||
};
|
||||
}
|
||||
|
||||
private clampDays(value: number) {
|
||||
if (!value || Number.isNaN(value)) return 7;
|
||||
return Math.min(Math.max(value, 1), 90);
|
||||
}
|
||||
|
||||
private startOfDayOffset(offsetDays: number) {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetDays);
|
||||
return date;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/discovery/discovery.module.ts
Normal file
11
apps/api/src/discovery/discovery.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { ClassificationTagEntity } from '../entities/classification-tag.entity';
|
||||
import { DiscoveryController } from './discovery.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, TypeOrmModule.forFeature([ClassificationTagEntity])],
|
||||
controllers: [DiscoveryController],
|
||||
})
|
||||
export class DiscoveryModule {}
|
||||
25
apps/api/src/entities/alert-delivery.entity.ts
Normal file
25
apps/api/src/entities/alert-delivery.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('alert_deliveries')
|
||||
export class AlertDeliveryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
profileId: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
channel: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'sent' })
|
||||
status: 'sent' | 'failed' | 'skipped';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
details?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
45
apps/api/src/entities/alert-profile.entity.ts
Normal file
45
apps/api/src/entities/alert-profile.entity.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export type AlertChannel = 'email' | 'telegram';
|
||||
|
||||
@Entity('alert_profiles')
|
||||
export class AlertProfileEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'daily' })
|
||||
frequency: 'instant' | 'daily';
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'email' })
|
||||
channel: AlertChannel;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
channelTarget?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
queryText?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
rules?: {
|
||||
publishers?: string[];
|
||||
formats?: string[];
|
||||
territories?: string[];
|
||||
topics?: string[];
|
||||
updatedWithinDays?: number;
|
||||
};
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
28
apps/api/src/entities/alert-run.entity.ts
Normal file
28
apps/api/src/entities/alert-run.entity.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('alert_runs')
|
||||
export class AlertRunEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||
status: 'running' | 'success' | 'failed';
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
profiles: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sent: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
failed: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMs?: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
finishedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
startedAt: Date;
|
||||
}
|
||||
19
apps/api/src/entities/catalog-item-version.entity.ts
Normal file
19
apps/api/src/entities/catalog-item-version.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('catalog_item_versions')
|
||||
export class CatalogItemVersionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'updated' })
|
||||
eventType: 'created' | 'updated';
|
||||
|
||||
@Column({ type: 'text' })
|
||||
snapshot: string; // JSON snapshot of previous state
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
31
apps/api/src/entities/catalog-item.entity.ts
Normal file
31
apps/api/src/entities/catalog-item.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('catalog_items')
|
||||
export class CatalogItemEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 2000, nullable: true })
|
||||
sourceUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
format?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
publisher?: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
updatedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
modifiedAt: Date;
|
||||
}
|
||||
19
apps/api/src/entities/classification-tag.entity.ts
Normal file
19
apps/api/src/entities/classification-tag.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('classification_tags')
|
||||
export class ClassificationTagEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
itemId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
type: 'publisher' | 'format' | 'topic' | 'territory';
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
value: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
31
apps/api/src/entities/ingest-run.entity.ts
Normal file
31
apps/api/src/entities/ingest-run.entity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('ingest_runs')
|
||||
export class IngestRunEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||
status: 'running' | 'success' | 'partial' | 'failed';
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
imported: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
updated: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
errorCount: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
errors?: string[];
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMs?: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
finishedAt?: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
startedAt: Date;
|
||||
}
|
||||
34
apps/api/src/entities/plan.entity.ts
Normal file
34
apps/api/src/entities/plan.entity.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('plans')
|
||||
export class PlanEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
priceMonthly: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'EUR' })
|
||||
currency: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
features?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
25
apps/api/src/ingest/ingest.controller.ts
Normal file
25
apps/api/src/ingest/ingest.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { AdminAuthGuard } from '../auth/admin.guard';
|
||||
|
||||
@Controller('ingest')
|
||||
export class IngestController {
|
||||
constructor(
|
||||
private readonly ingestService: IngestService,
|
||||
@InjectQueue('ingest') private readonly ingestQueue: Queue,
|
||||
) {}
|
||||
|
||||
@Get('run')
|
||||
async run() {
|
||||
return this.ingestService.runOnce();
|
||||
}
|
||||
|
||||
@Post('queue')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
async queue() {
|
||||
const job = await this.ingestQueue.add({}, { removeOnComplete: true, removeOnFail: false });
|
||||
return { ok: true, jobId: job.id };
|
||||
}
|
||||
}
|
||||
30
apps/api/src/ingest/ingest.module.ts
Normal file
30
apps/api/src/ingest/ingest.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
import { IngestController } from './ingest.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { NormalizerModule } from '../normalizer/normalizer.module';
|
||||
import { IngestScheduler } from './ingest.scheduler';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { IngestProcessor } from './ingest.processor';
|
||||
import { ClassifierModule } from '../classifier/classifier.module';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CatalogModule,
|
||||
NormalizerModule,
|
||||
ClassifierModule,
|
||||
TypeOrmModule.forFeature([IngestRunEntity]),
|
||||
BullModule.registerQueue({
|
||||
name: 'ingest',
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'redis',
|
||||
port: Number(process.env.REDIS_PORT || 6379),
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [IngestService, IngestScheduler, IngestProcessor],
|
||||
controllers: [IngestController],
|
||||
})
|
||||
export class IngestModule {}
|
||||
18
apps/api/src/ingest/ingest.processor.ts
Normal file
18
apps/api/src/ingest/ingest.processor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Processor, Process } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Processor('ingest')
|
||||
@Injectable()
|
||||
export class IngestProcessor {
|
||||
private readonly logger = new Logger(IngestProcessor.name);
|
||||
|
||||
constructor(private readonly ingestService: IngestService) {}
|
||||
|
||||
@Process()
|
||||
async handle(job: Job) {
|
||||
this.logger.log(`Processing ingest job id=${job.id}`);
|
||||
return this.ingestService.runOnce();
|
||||
}
|
||||
}
|
||||
25
apps/api/src/ingest/ingest.scheduler.ts
Normal file
25
apps/api/src/ingest/ingest.scheduler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Injectable()
|
||||
export class IngestScheduler {
|
||||
private readonly logger = new Logger(IngestScheduler.name);
|
||||
|
||||
constructor(private readonly ingestService: IngestService) {}
|
||||
|
||||
// Cron expression configurable via env `INGEST_CRON` (default: every hour)
|
||||
@Cron(process.env.INGEST_CRON || CronExpression.EVERY_HOUR, { name: 'ingest_job' })
|
||||
async handleCron() {
|
||||
if (String(process.env.INGEST_ENABLED || 'true').toLowerCase() === 'false') {
|
||||
return;
|
||||
}
|
||||
this.logger.log('Scheduled ingest triggered');
|
||||
try {
|
||||
const res = await this.ingestService.runOnce();
|
||||
this.logger.log(`Ingest finished: imported=${res.imported} errors=${res.errors.length}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error('Scheduled ingest failed', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
apps/api/src/ingest/ingest.service.ts
Normal file
105
apps/api/src/ingest/ingest.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import axios from 'axios';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { CreateCatalogItemDto } from '../catalog/dto/create-catalog-item.dto';
|
||||
import { NormalizerService } from '../normalizer/normalizer.service';
|
||||
import { ClassifierService } from '../classifier/classifier.service';
|
||||
import { IngestRunEntity } from '../entities/ingest-run.entity';
|
||||
|
||||
@Injectable()
|
||||
export class IngestService {
|
||||
private readonly logger = new Logger(IngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly normalizer: NormalizerService,
|
||||
private readonly classifier: ClassifierService,
|
||||
@InjectRepository(IngestRunEntity)
|
||||
private readonly runs: Repository<IngestRunEntity>,
|
||||
) {}
|
||||
|
||||
async runOnce(): Promise<{ runId: string; imported: number; updated: number; errors: string[] }> {
|
||||
const apiBase = process.env.DATOS_API_BASE || 'https://datos.gob.es/apidata/3/action/package_search';
|
||||
this.logger.log(`Running ingest against ${apiBase}`);
|
||||
const runStart = Date.now();
|
||||
const run = await this.runs.save(this.runs.create({ status: 'running' }));
|
||||
try {
|
||||
const res = await axios.get(apiBase, { params: { q: '', rows: 20 } });
|
||||
const data = res.data;
|
||||
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(data)) rows = data;
|
||||
else if (data?.result?.results) rows = data.result.results;
|
||||
else if (data?.result && Array.isArray(data.result)) rows = data.result;
|
||||
else if (data?.items) rows = data.items;
|
||||
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const dto: CreateCatalogItemDto = {
|
||||
title: r.title || r.name || r.id || 'sin-titulo',
|
||||
description: r.notes || r.description || r.title || '',
|
||||
sourceUrl: undefined,
|
||||
format: undefined,
|
||||
publisher: (r.organization && (r.organization.title || r.organization.name)) || r.owner_org || r.author || undefined,
|
||||
updatedAt: r.metadata_modified || r.last_modified || r.updated || undefined,
|
||||
};
|
||||
|
||||
if (Array.isArray(r.resources) && r.resources.length > 0) {
|
||||
dto.sourceUrl = r.resources[0].url || r.resources[0].access_url || dto.sourceUrl;
|
||||
const formats = Array.from(new Set(
|
||||
r.resources
|
||||
.map((x: any) => (x.format || x.format_description || '').toString().toUpperCase())
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (formats.length) dto.format = formats.join(',');
|
||||
}
|
||||
|
||||
if (!dto.sourceUrl && (r.url || r.link)) dto.sourceUrl = r.url || r.link;
|
||||
if (!dto.sourceUrl && r.id) dto.sourceUrl = `urn:dataset:${r.id}`;
|
||||
|
||||
const normalized = await this.normalizer.normalize(dto);
|
||||
const result = await this.catalogService.upsertWithVersion(normalized);
|
||||
if (result.created) imported++;
|
||||
if (result.updated) updated++;
|
||||
|
||||
if (result.item) {
|
||||
const classification = await this.classifier.classify(result.item);
|
||||
await this.classifier.applyTags(result.item, classification);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error('Error ingesting row', err?.message || err);
|
||||
errors.push(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
run.status = errors.length ? 'partial' : 'success';
|
||||
run.imported = imported;
|
||||
run.updated = updated;
|
||||
run.errorCount = errors.length;
|
||||
run.errors = errors.slice(0, 50);
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
|
||||
return { runId: run.id, imported, updated, errors };
|
||||
} catch (err: any) {
|
||||
this.logger.error('Ingest request failed', err?.message || err);
|
||||
const errors = [err?.message || String(err)];
|
||||
run.status = 'failed';
|
||||
run.imported = 0;
|
||||
run.updated = 0;
|
||||
run.errorCount = errors.length;
|
||||
run.errors = errors;
|
||||
run.finishedAt = new Date();
|
||||
run.durationMs = Date.now() - runStart;
|
||||
await this.runs.save(run);
|
||||
return { runId: run.id, imported: 0, updated: 0, errors };
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/api/src/main.ts
Normal file
14
apps/api/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API listening on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
apps/api/src/normalizer/normalizer.module.ts
Normal file
8
apps/api/src/normalizer/normalizer.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { NormalizerService } from './normalizer.service'
|
||||
|
||||
@Module({
|
||||
providers: [NormalizerService],
|
||||
exports: [NormalizerService],
|
||||
})
|
||||
export class NormalizerModule {}
|
||||
46
apps/api/src/normalizer/normalizer.service.ts
Normal file
46
apps/api/src/normalizer/normalizer.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { CreateCatalogItemDto } from '../catalog/dto/create-catalog-item.dto'
|
||||
import { parseDate, parseAmount, normalizeProvince, verifyUrl } from './utils'
|
||||
|
||||
@Injectable()
|
||||
export class NormalizerService {
|
||||
private readonly logger = new Logger(NormalizerService.name)
|
||||
|
||||
async normalize(dto: CreateCatalogItemDto): Promise<CreateCatalogItemDto> {
|
||||
const out: CreateCatalogItemDto = { ...dto }
|
||||
|
||||
// Normalize dates
|
||||
try {
|
||||
if (dto.updatedAt) {
|
||||
const d = parseDate(dto.updatedAt)
|
||||
if (d) out.updatedAt = d
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.debug('Date normalization failed', e)
|
||||
}
|
||||
|
||||
// Example: normalise publisher/province if present
|
||||
if (dto.publisher) {
|
||||
out.publisher = normalizeProvince(dto.publisher)
|
||||
}
|
||||
|
||||
// Verify sourceUrl
|
||||
if (dto.sourceUrl) {
|
||||
try {
|
||||
const ok = await verifyUrl(dto.sourceUrl)
|
||||
if (!ok) {
|
||||
this.logger.warn(`Source URL not reachable: ${dto.sourceUrl}`)
|
||||
// keep it but flag by appending note
|
||||
out.sourceUrl = dto.sourceUrl
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.debug('verifyUrl error', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Amounts are not part of the DTO now, but kept for extension
|
||||
// if ((dto as any).amount) (dto as any).amount = parseAmount((dto as any).amount)
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
51
apps/api/src/normalizer/utils.ts
Normal file
51
apps/api/src/normalizer/utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export function parseDate(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
const d = new Date(value)
|
||||
if (!isNaN(d.getTime())) return d.toISOString()
|
||||
|
||||
// try common formats dd/mm/yyyy or dd-mm-yyyy
|
||||
const m = value.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/)
|
||||
if (m) {
|
||||
const day = parseInt(m[1], 10)
|
||||
const month = parseInt(m[2], 10) - 1
|
||||
const year = parseInt(m[3], 10)
|
||||
const d2 = new Date(year, month, day)
|
||||
if (!isNaN(d2.getTime())) return d2.toISOString()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseAmount(value?: string | number): number | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'number') return value
|
||||
// remove currency symbols and thousands separators
|
||||
const cleaned = String(value).replace(/[^0-9,\.\-]/g, '').replace(/\./g, '').replace(/,/g, '.')
|
||||
const n = parseFloat(cleaned)
|
||||
return isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
export function normalizeProvince(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
// simple normalizations
|
||||
const v = value.trim().toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
'andalucía': 'Andalucía', 'andalucia': 'Andalucía', 'andaluzia': 'Andalucía',
|
||||
'madrid': 'Madrid', 'comunidad de madrid': 'Madrid',
|
||||
'cataluña': 'Cataluña', 'cataluna': 'Cataluña', 'barcelona': 'Barcelona'
|
||||
}
|
||||
if (map[v]) return map[v]
|
||||
// Title case fallback
|
||||
return value.replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
}
|
||||
|
||||
export async function verifyUrl(url?: string): Promise<boolean> {
|
||||
if (!url) return false
|
||||
try {
|
||||
const res = await axios.head(url, { timeout: 5000, maxRedirects: 3 })
|
||||
return res.status >= 200 && res.status < 400
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
12
apps/api/src/plans/plans.controller.ts
Normal file
12
apps/api/src/plans/plans.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { PlansService } from './plans.service';
|
||||
|
||||
@Controller('plans')
|
||||
export class PlansController {
|
||||
constructor(private readonly plans: PlansService) {}
|
||||
|
||||
@Get()
|
||||
async listActive() {
|
||||
return this.plans.listActive();
|
||||
}
|
||||
}
|
||||
13
apps/api/src/plans/plans.module.ts
Normal file
13
apps/api/src/plans/plans.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
import { PlansService } from './plans.service';
|
||||
import { PlansController } from './plans.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([PlanEntity])],
|
||||
providers: [PlansService],
|
||||
controllers: [PlansController],
|
||||
exports: [PlansService],
|
||||
})
|
||||
export class PlansModule {}
|
||||
80
apps/api/src/plans/plans.service.ts
Normal file
80
apps/api/src/plans/plans.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlanEntity } from '../entities/plan.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PlansService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PlansService.name);
|
||||
private initialized = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlanEntity)
|
||||
private readonly plans: Repository<PlanEntity>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureDefaults();
|
||||
}
|
||||
|
||||
async ensureDefaults() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
const defaults = [
|
||||
{
|
||||
code: 'basic',
|
||||
name: 'Plan Básico',
|
||||
priceMonthly: 15,
|
||||
description: 'Alertas esenciales y dashboard básico',
|
||||
features: { alertProfiles: 3, refresh: 'daily' },
|
||||
},
|
||||
{
|
||||
code: 'pro',
|
||||
name: 'Plan Pro',
|
||||
priceMonthly: 39,
|
||||
description: 'Más alertas, filtros avanzados y tendencias',
|
||||
features: { alertProfiles: 10, refresh: 'hourly' },
|
||||
},
|
||||
{
|
||||
code: 'empresa',
|
||||
name: 'Plan Empresa',
|
||||
priceMonthly: 99,
|
||||
description: 'Personalización y soporte dedicado',
|
||||
features: { alertProfiles: 50, refresh: 'custom' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of defaults) {
|
||||
const existing = await this.plans.findOne({ where: { code: entry.code } });
|
||||
if (!existing) {
|
||||
await this.plans.save(this.plans.create(entry));
|
||||
this.logger.log(`Seeded plan ${entry.code}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listActive() {
|
||||
return this.plans.find({ where: { isActive: true }, order: { priceMonthly: 'ASC' } });
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
return this.plans.find({ order: { priceMonthly: 'ASC' } });
|
||||
}
|
||||
|
||||
async getDefaultPlan() {
|
||||
await this.ensureDefaults();
|
||||
return this.plans.findOne({ where: { code: 'basic' } });
|
||||
}
|
||||
|
||||
async create(payload: Partial<PlanEntity>) {
|
||||
const plan = this.plans.create(payload);
|
||||
return this.plans.save(plan);
|
||||
}
|
||||
|
||||
async update(id: string, payload: Partial<PlanEntity>) {
|
||||
const plan = await this.plans.findOne({ where: { id } });
|
||||
if (!plan) return null;
|
||||
Object.assign(plan, payload);
|
||||
return this.plans.save(plan);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user