Structure: - control-plane/: Registries, SIMCO directives, CI/CD templates - projects/: Gamilit, ERP-Suite, Trading-Platform, Betting-Analytics - shared/: Libs catalog, knowledge-base Key features: - Centralized port, domain, database, and service registries - 23 SIMCO directives + 6 fundamental principles - NEXUS agent profiles with delegation rules - Validation scripts for workspace integrity - Dockerfiles for all services - Path aliases for quick reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
643 lines
17 KiB
Markdown
643 lines
17 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```env
|
|
# 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
|
|
|
|
```typescript
|
|
// 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
|