workspace-v1/shared/libs/notifications/IMPLEMENTATION.md
Adrian Flores Cortes 967ab360bb Initial commit: Workspace v1 with 3-layer architecture
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>
2025-12-23 00:35:19 -06:00

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