workspace-v1/shared/catalog/notifications/IMPLEMENTATION.md
rckrdmrd cb4c0681d3 feat(workspace): Add new projects and update architecture
New projects created:
- michangarrito (marketplace mobile)
- template-saas (SaaS template)
- clinica-dental (dental ERP)
- clinica-veterinaria (veterinary ERP)

Architecture updates:
- Move catalog from core/ to shared/
- Add MCP servers structure and templates
- Add git management scripts
- Update SUBREPOSITORIOS.md with 15 new repos
- Update .gitignore for new projects

Repository infrastructure:
- 4 main repositories
- 11 subrepositorios
- Gitea remotes configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:43:28 -06:00

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