Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
17 KiB
17 KiB
Guía de Implementación: Sistema de Notificaciones
Versión: 1.0.0 Tiempo estimado: 4-8 horas Complejidad: Alta
Pre-requisitos
- NestJS con TypeORM configurado
- PostgreSQL
- Cuenta SMTP o SendGrid (para email)
- Claves VAPID (para push)
Paso 1: Instalar Dependencias
# Core
npm install nodemailer
npm install -D @types/nodemailer
# Push notifications
npm install web-push
npm install -D @types/web-push
# TypeORM (si no está instalado)
npm install typeorm @nestjs/typeorm
Paso 2: Crear DDL
2.1 Schema y tabla principal
CREATE SCHEMA IF NOT EXISTS notifications;
-- Tabla principal de notificaciones
CREATE TABLE notifications.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
data JSONB DEFAULT '{}',
priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
channels VARCHAR(20)[] DEFAULT ARRAY['in_app'],
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'read', 'failed')),
read_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE INDEX idx_notifications_user_id ON notifications.notifications(user_id);
CREATE INDEX idx_notifications_type ON notifications.notifications(type);
CREATE INDEX idx_notifications_status ON notifications.notifications(status);
CREATE INDEX idx_notifications_created_at ON notifications.notifications(created_at);
2.2 Preferencias
CREATE TABLE notifications.notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
notification_type VARCHAR(50) NOT NULL,
in_app_enabled BOOLEAN DEFAULT true,
email_enabled BOOLEAN DEFAULT true,
push_enabled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, notification_type)
);
CREATE INDEX idx_notification_preferences_user ON notifications.notification_preferences(user_id);
2.3 Cola de procesamiento
CREATE TABLE notifications.notification_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL CHECK (channel IN ('in_app', 'email', 'push')),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
last_attempt_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ DEFAULT NOW(),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notification_queue_status ON notifications.notification_queue(status);
CREATE INDEX idx_notification_queue_next_attempt ON notifications.notification_queue(next_attempt_at);
2.4 Dispositivos para push
CREATE TABLE notifications.user_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
device_type VARCHAR(20) NOT NULL CHECK (device_type IN ('web', 'ios', 'android')),
subscription JSONB NOT NULL, -- Web Push subscription object
browser VARCHAR(100),
os VARCHAR(100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_user_devices_user ON notifications.user_devices(user_id);
CREATE INDEX idx_user_devices_active ON notifications.user_devices(is_active);
Paso 3: Crear Entities
3.1 Notification Entity
// src/modules/notifications/entities/notification.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'notifications', name: 'notifications' })
@Index(['userId'])
@Index(['type'])
@Index(['status'])
export class Notification {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ type: 'varchar', length: 50 })
type!: string;
@Column({ type: 'varchar', length: 255 })
title!: string;
@Column({ type: 'text' })
message!: string;
@Column({ type: 'jsonb', default: {} })
data?: Record<string, any>;
@Column({ type: 'varchar', length: 20, default: 'normal' })
priority!: string;
@Column({ type: 'varchar', array: true, default: ['in_app'] })
channels!: string[];
@Column({ type: 'varchar', length: 20, default: 'pending' })
status!: string;
@Column({ name: 'read_at', type: 'timestamp', nullable: true })
readAt?: Date;
@Column({ name: 'sent_at', type: 'timestamp', nullable: true })
sentAt?: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@Column({ name: 'expires_at', type: 'timestamp', nullable: true })
expiresAt?: Date;
@Column({ type: 'jsonb', default: {} })
metadata?: Record<string, any>;
}
3.2 NotificationPreference Entity
// src/modules/notifications/entities/notification-preference.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'notifications', name: 'notification_preferences' })
@Index(['userId', 'notificationType'], { unique: true })
export class NotificationPreference {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ name: 'notification_type', type: 'varchar', length: 50 })
notificationType!: string;
@Column({ name: 'in_app_enabled', type: 'boolean', default: true })
inAppEnabled!: boolean;
@Column({ name: 'email_enabled', type: 'boolean', default: true })
emailEnabled!: boolean;
@Column({ name: 'push_enabled', type: 'boolean', default: false })
pushEnabled!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
Paso 4: Crear NotificationService
// src/modules/notifications/services/notification.service.ts
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Notification } from '../entities/notification.entity';
@Injectable()
export class NotificationService {
constructor(
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
) {}
async create(data: {
userId: string;
title: string;
message: string;
type: string;
data?: Record<string, any>;
priority?: string;
channels?: string[];
expiresAt?: Date;
}): Promise<Notification> {
const notification = this.notificationRepository.create({
userId: data.userId,
title: data.title,
message: data.message,
type: data.type,
data: data.data,
priority: data.priority || 'normal',
channels: data.channels || ['in_app'],
status: 'sent',
sentAt: new Date(),
expiresAt: data.expiresAt,
});
return this.notificationRepository.save(notification);
}
async findAllByUser(
userId: string,
filters?: {
status?: string;
type?: string;
limit?: number;
offset?: number;
},
): Promise<{ data: Notification[]; total: number }> {
const query = this.notificationRepository
.createQueryBuilder('n')
.where('n.user_id = :userId', { userId });
if (filters?.status) {
query.andWhere('n.status = :status', { status: filters.status });
}
if (filters?.type) {
query.andWhere('n.type = :type', { type: filters.type });
}
query.orderBy('n.created_at', 'DESC');
query.skip(filters?.offset || 0);
query.take(filters?.limit || 50);
const [data, total] = await query.getManyAndCount();
return { data, total };
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
const notification = await this.notificationRepository.findOne({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Notification not found');
}
if (notification.userId !== userId) {
throw new ForbiddenException('Access denied');
}
notification.status = 'read';
notification.readAt = new Date();
await this.notificationRepository.save(notification);
}
async markAllAsRead(userId: string): Promise<number> {
const result = await this.notificationRepository
.createQueryBuilder()
.update(Notification)
.set({ status: 'read', readAt: new Date() })
.where('user_id = :userId', { userId })
.andWhere('status != :status', { status: 'read' })
.execute();
return result.affected || 0;
}
async getUnreadCount(userId: string): Promise<number> {
return this.notificationRepository
.createQueryBuilder('n')
.where('n.user_id = :userId', { userId })
.andWhere('n.status IN (:...statuses)', { statuses: ['pending', 'sent'] })
.getCount();
}
async deleteNotification(notificationId: string, userId: string): Promise<void> {
const notification = await this.notificationRepository.findOne({
where: { id: notificationId, userId },
});
if (!notification) {
throw new NotFoundException('Notification not found');
}
await this.notificationRepository.remove(notification);
}
}
Paso 5: Crear MailService
// src/modules/mail/mail.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class MailService {
private transporter: nodemailer.Transporter | null = null;
private readonly logger = new Logger(MailService.name);
private readonly from: string;
constructor(private readonly configService: ConfigService) {
this.from = configService.get('SMTP_FROM', 'App <noreply@app.com>');
this.initializeTransporter();
}
private initializeTransporter() {
const sendgridKey = this.configService.get('SENDGRID_API_KEY');
if (sendgridKey) {
this.transporter = nodemailer.createTransport({
host: 'smtp.sendgrid.net',
port: 587,
auth: { user: 'apikey', pass: sendgridKey },
});
this.logger.log('Email initialized with SendGrid');
return;
}
const host = this.configService.get('SMTP_HOST');
const user = this.configService.get('SMTP_USER');
const pass = this.configService.get('SMTP_PASS');
if (host && user && pass) {
this.transporter = nodemailer.createTransport({
host,
port: this.configService.get('SMTP_PORT', 587),
secure: this.configService.get('SMTP_SECURE', false),
auth: { user, pass },
});
this.logger.log('Email initialized with SMTP');
} else {
this.logger.warn('Email not configured - emails will be logged only');
}
}
async sendEmail(
to: string | string[],
subject: string,
html: string,
): Promise<boolean> {
if (!this.transporter) {
this.logger.warn(`[MOCK EMAIL] To: ${to} | Subject: ${subject}`);
return false;
}
try {
await this.transporter.sendMail({
from: this.from,
to,
subject,
html,
});
this.logger.log(`Email sent to ${to}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email to ${to}`, error);
throw error;
}
}
async sendNotificationEmail(
to: string,
title: string,
message: string,
actionUrl?: string,
): Promise<boolean> {
const html = `
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>${title}</h2>
<p>${message}</p>
${actionUrl ? `<a href="${actionUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Ver detalles</a>` : ''}
</body>
</html>
`;
return this.sendEmail(to, title, html);
}
}
Paso 6: Crear NotificationsModule
// src/modules/notifications/notifications.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { NotificationService } from './services/notification.service';
import { NotificationsController } from './controllers/notifications.controller';
import { MailModule } from '../mail/mail.module';
@Module({
imports: [
TypeOrmModule.forFeature([Notification, NotificationPreference]),
MailModule,
],
controllers: [NotificationsController],
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationsModule {}
Paso 7: Crear Controller
// src/modules/notifications/controllers/notifications.controller.ts
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/modules/auth/guards';
import { NotificationService } from '../services/notification.service';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class NotificationsController {
constructor(private readonly notificationService: NotificationService) {}
@Get()
async findAll(
@Request() req,
@Query('status') status?: string,
@Query('type') type?: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
) {
return this.notificationService.findAllByUser(req.user.id, {
status,
type,
limit: limit || 50,
offset: offset || 0,
});
}
@Get('unread-count')
async getUnreadCount(@Request() req) {
const count = await this.notificationService.getUnreadCount(req.user.id);
return { count };
}
@Post(':id/read')
async markAsRead(@Param('id') id: string, @Request() req) {
await this.notificationService.markAsRead(id, req.user.id);
return { success: true };
}
@Post('read-all')
async markAllAsRead(@Request() req) {
const count = await this.notificationService.markAllAsRead(req.user.id);
return { success: true, count };
}
@Delete(':id')
async delete(@Param('id') id: string, @Request() req) {
await this.notificationService.deleteNotification(id, req.user.id);
return { success: true };
}
}
Paso 8: Configurar Variables de Entorno
# Email - SMTP
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user
SMTP_PASS=password
SMTP_SECURE=false
SMTP_FROM="App Name <noreply@app.com>"
# Email - SendGrid (alternativo)
SENDGRID_API_KEY=SG.xxxxx
# Push Notifications (generar con: npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=BN...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:admin@app.com
# Frontend
FRONTEND_URL=https://app.example.com
Checklist de Implementación
- Dependencias npm instaladas
- DDL creado (schema + tablas)
- Entities alineadas con DDL
- NotificationService implementado
- MailService implementado
- Controller con endpoints
- NotificationsModule configurado
- Variables de entorno configuradas
- Build pasa sin errores
- Test de envío de email funciona
Opcional: Push Notifications
// src/modules/notifications/services/push-notification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
@Injectable()
export class PushNotificationService {
private readonly logger = new Logger(PushNotificationService.name);
constructor(private readonly configService: ConfigService) {
const publicKey = configService.get('VAPID_PUBLIC_KEY');
const privateKey = configService.get('VAPID_PRIVATE_KEY');
const subject = configService.get('VAPID_SUBJECT');
if (publicKey && privateKey && subject) {
webpush.setVapidDetails(subject, publicKey, privateKey);
this.logger.log('Web Push initialized');
}
}
async sendPush(
subscription: webpush.PushSubscription,
payload: { title: string; body: string; url?: string },
): Promise<boolean> {
try {
await webpush.sendNotification(
subscription,
JSON.stringify(payload),
);
return true;
} catch (error) {
this.logger.error('Push notification failed', error);
return false;
}
}
}
Código de Referencia
Ver implementación completa en:
projects/gamilit/apps/backend/src/modules/notifications/projects/gamilit/apps/backend/src/modules/mail/
Versión: 1.0.0 Sistema: SIMCO Catálogo