# ET-NOTIF-BACKEND: Servicios y API REST

## Identificacion

| Campo | Valor |
|-------|-------|
| **ID** | ET-NOTIF-BACKEND |
| **Modulo** | MGN-008 Notifications |
| **Version** | 1.0 |
| **Estado** | En Diseno |
| **Framework** | NestJS |
| **Autor** | Requirements-Analyst |
| **Fecha** | 2025-12-05 |

---

## Estructura de Archivos

```
apps/backend/src/modules/notifications/
├── notifications.module.ts
├── controllers/
│   ├── notifications.controller.ts
│   ├── email-templates.controller.ts
│   ├── push.controller.ts
│   └── preferences.controller.ts
├── services/
│   ├── notifications.service.ts
│   ├── email.service.ts
│   ├── email-templates.service.ts
│   ├── push.service.ts
│   └── preferences.service.ts
├── gateways/
│   └── notifications.gateway.ts
├── entities/
│   ├── notification.entity.ts
│   ├── email-template.entity.ts
│   ├── email-job.entity.ts
│   ├── push-subscription.entity.ts
│   ├── push-job.entity.ts
│   └── notification-preference.entity.ts
├── dto/
│   ├── send-notification.dto.ts
│   ├── send-email.dto.ts
│   ├── create-template.dto.ts
│   ├── subscribe-push.dto.ts
│   └── update-preferences.dto.ts
├── processors/
│   ├── email.processor.ts
│   └── push.processor.ts
└── templates/
    └── base.hbs
```

---

## Entidades

### Notification Entity

```typescript
@Entity('notifications', { schema: 'core_notifications' })
export class Notification {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  @Column({ length: 50 })
  type: NotificationType;

  @Column({ length: 255 })
  title: string;

  @Column({ type: 'text' })
  body: string;

  @Column({ type: 'jsonb', default: {} })
  data: Record<string, any>;

  @Column({ name: 'action_url', length: 500, nullable: true })
  actionUrl: string;

  @Column({ name: 'action_label', length: 100, nullable: true })
  actionLabel: string;

  @Column({ length: 50, nullable: true })
  icon: string;

  @Column({ type: 'varchar', array: true, default: ['in_app'] })
  channels: NotificationChannel[];

  @Column({ name: 'is_read', default: false })
  isRead: boolean;

  @Column({ name: 'read_at', type: 'timestamptz', nullable: true })
  readAt: Date;

  @Column({ name: 'is_archived', default: false })
  isArchived: boolean;

  @Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
  expiresAt: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;
}

export type NotificationChannel = 'in_app' | 'email' | 'push';
export type NotificationType =
  | 'security.login_new_device'
  | 'security.password_changed'
  | 'user.mention'
  | 'user.assignment'
  | 'task.assigned'
  | 'task.completed'
  | 'document.shared'
  | 'payment.received'
  | 'report.ready';
```

### EmailTemplate Entity

```typescript
@Entity('email_templates', { schema: 'core_notifications' })
export class EmailTemplate {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid', nullable: true })
  tenantId: string;

  @Column({ length: 100, unique: true })
  key: string;

  @Column({ length: 255 })
  name: string;

  @Column({ length: 255 })
  subject: string;

  @Column({ name: 'body_html', type: 'text' })
  bodyHtml: string;

  @Column({ name: 'body_text', type: 'text', nullable: true })
  bodyText: string;

  @Column({ type: 'jsonb', default: [] })
  variables: TemplateVariable[];

  @Column({ length: 50, default: 'transactional' })
  category: EmailCategory;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}
```

### PushSubscription Entity

```typescript
@Entity('push_subscriptions', { schema: 'core_notifications' })
export class PushSubscription {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  @Column({ length: 500, unique: true })
  endpoint: string;

  @Column({ length: 255 })
  p256dh: string;

  @Column({ length: 255 })
  auth: string;

  @Column({ name: 'device_type', length: 20 })
  deviceType: 'web' | 'android' | 'ios';

  @Column({ name: 'device_name', length: 100, nullable: true })
  deviceName: string;

  @Column({ length: 50, nullable: true })
  browser: string;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
  lastUsedAt: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}
```

---

## Servicios

### NotificationsService

```typescript
@Injectable()
export class NotificationsService {
  constructor(
    @InjectRepository(Notification)
    private readonly repo: Repository<Notification>,
    private readonly gateway: NotificationsGateway,
    private readonly emailService: EmailService,
    private readonly pushService: PushService,
    private readonly preferencesService: PreferencesService,
  ) {}

  async send(dto: SendNotificationDto): Promise<Notification> {
    // Get user preferences
    const preferences = await this.preferencesService.get(dto.userId, dto.type);

    // Filter channels based on preferences
    const effectiveChannels = dto.channels.filter(channel => {
      switch (channel) {
        case 'in_app': return preferences.inApp;
        case 'email': return preferences.email;
        case 'push': return preferences.push;
        default: return false;
      }
    });

    if (effectiveChannels.length === 0) {
      return null; // User disabled all channels for this type
    }

    // Create in-app notification
    let notification: Notification = null;
    if (effectiveChannels.includes('in_app')) {
      notification = this.repo.create({
        ...dto,
        channels: effectiveChannels,
      });
      await this.repo.save(notification);

      // Send via WebSocket
      await this.gateway.sendToUser(dto.userId, {
        type: 'notification',
        payload: notification,
      });
    }

    // Queue email
    if (effectiveChannels.includes('email')) {
      await this.emailService.queue({
        tenantId: dto.tenantId,
        userId: dto.userId,
        template: this.getEmailTemplate(dto.type),
        variables: { title: dto.title, body: dto.body, ...dto.data },
        frequency: preferences.emailFrequency,
      });
    }

    // Queue push
    if (effectiveChannels.includes('push')) {
      await this.pushService.queue({
        userId: dto.userId,
        title: dto.title,
        body: dto.body,
        data: dto.data,
        actionUrl: dto.actionUrl,
      });
    }

    return notification;
  }

  async sendBulk(
    userIds: string[],
    dto: Omit<SendNotificationDto, 'userId'>
  ): Promise<number> {
    let count = 0;
    for (const userId of userIds) {
      const notification = await this.send({ ...dto, userId });
      if (notification) count++;
    }
    return count;
  }

  async findByUser(
    userId: string,
    query: QueryNotificationsDto
  ): Promise<PaginatedResult<Notification>> {
    const qb = this.repo.createQueryBuilder('n')
      .where('n.user_id = :userId', { userId });

    if (query.isRead !== undefined) {
      qb.andWhere('n.is_read = :isRead', { isRead: query.isRead });
    }

    if (query.type) {
      qb.andWhere('n.type = :type', { type: query.type });
    }

    if (!query.includeArchived) {
      qb.andWhere('n.is_archived = false');
    }

    // Exclude expired
    qb.andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())');

    qb.orderBy('n.created_at', 'DESC');

    return paginate(qb, query);
  }

  async markAsRead(
    userId: string,
    ids?: string[]
  ): Promise<{ affected: number }> {
    const qb = this.repo.createQueryBuilder()
      .update()
      .set({ isRead: true, readAt: new Date() })
      .where('user_id = :userId', { userId })
      .andWhere('is_read = false');

    if (ids?.length) {
      qb.andWhere('id IN (:...ids)', { ids });
    }

    const result = await qb.execute();

    // Notify via WebSocket
    await this.gateway.sendToUser(userId, {
      type: 'notifications_read',
      payload: { ids, all: !ids },
    });

    return { affected: result.affected };
  }

  async getUnreadCount(userId: string): Promise<UnreadCounts> {
    const result = await this.repo
      .createQueryBuilder('n')
      .select('n.type', 'type')
      .addSelect('COUNT(*)', 'count')
      .where('n.user_id = :userId', { userId })
      .andWhere('n.is_read = false')
      .andWhere('n.is_archived = false')
      .andWhere('(n.expires_at IS NULL OR n.expires_at > NOW())')
      .groupBy('n.type')
      .getRawMany();

    const total = result.reduce((sum, r) => sum + parseInt(r.count), 0);
    const byType = result.reduce((acc, r) => {
      acc[r.type] = parseInt(r.count);
      return acc;
    }, {});

    return { total, byType };
  }

  async archive(userId: string, ids: string[]): Promise<void> {
    await this.repo.update(
      { userId, id: In(ids) },
      { isArchived: true }
    );
  }
}
```

### EmailService

```typescript
@Injectable()
export class EmailService {
  constructor(
    @InjectRepository(EmailJob)
    private readonly jobRepo: Repository<EmailJob>,
    @InjectRepository(EmailTemplate)
    private readonly templateRepo: Repository<EmailTemplate>,
    @InjectQueue('email')
    private readonly queue: Queue,
    private readonly configService: ConfigService,
  ) {}

  async queue(dto: QueueEmailDto): Promise<EmailJob> {
    // Get template
    const template = await this.templateRepo.findOne({
      where: [
        { key: dto.template, tenantId: dto.tenantId },
        { key: dto.template, tenantId: IsNull() },
      ],
      order: { tenantId: 'DESC' }, // Prefer tenant-specific
    });

    if (!template) {
      throw new NotFoundException(`Email template ${dto.template} not found`);
    }

    // Get user email
    const user = await this.usersService.findById(dto.userId);

    // Render template
    const subject = this.render(template.subject, dto.variables);
    const bodyHtml = this.render(template.bodyHtml, dto.variables);
    const bodyText = template.bodyText
      ? this.render(template.bodyText, dto.variables)
      : null;

    // Calculate scheduled time based on frequency
    const scheduledAt = this.calculateScheduledTime(dto.frequency);

    // Create job
    const job = this.jobRepo.create({
      tenantId: dto.tenantId,
      templateId: template.id,
      toEmail: user.email,
      toName: user.fullName,
      subject,
      bodyHtml,
      bodyText,
      variables: dto.variables,
      scheduledAt,
    });

    await this.jobRepo.save(job);

    // Add to Bull queue
    await this.queue.add('send', { jobId: job.id }, {
      delay: scheduledAt.getTime() - Date.now(),
      attempts: 3,
      backoff: { type: 'exponential', delay: 60000 },
    });

    return job;
  }

  async sendDirect(dto: SendEmailDirectDto): Promise<void> {
    const transporter = nodemailer.createTransport({
      host: this.configService.get('SMTP_HOST'),
      port: this.configService.get('SMTP_PORT'),
      secure: this.configService.get('SMTP_SECURE', false),
      auth: {
        user: this.configService.get('SMTP_USER'),
        pass: this.configService.get('SMTP_PASS'),
      },
    });

    await transporter.sendMail({
      from: this.configService.get('EMAIL_FROM'),
      to: dto.to,
      subject: dto.subject,
      html: dto.bodyHtml,
      text: dto.bodyText,
      attachments: dto.attachments,
    });
  }

  private render(template: string, variables: Record<string, any>): string {
    return Handlebars.compile(template)(variables);
  }

  private calculateScheduledTime(frequency: EmailFrequency): Date {
    const now = new Date();

    switch (frequency) {
      case 'instant':
        return now;
      case 'hourly':
        return startOfHour(addHours(now, 1));
      case 'daily':
        return startOfDay(addDays(now, 1));
      case 'weekly':
        return startOfWeek(addWeeks(now, 1));
      default:
        return now;
    }
  }
}
```

### PushService

```typescript
@Injectable()
export class PushService {
  private readonly webpush: typeof webpush;

  constructor(
    @InjectRepository(PushSubscription)
    private readonly subscriptionRepo: Repository<PushSubscription>,
    @InjectRepository(PushJob)
    private readonly jobRepo: Repository<PushJob>,
    @InjectQueue('push')
    private readonly queue: Queue,
    private readonly configService: ConfigService,
  ) {
    webpush.setVapidDetails(
      this.configService.get('PUSH_SUBJECT'),
      this.configService.get('VAPID_PUBLIC_KEY'),
      this.configService.get('VAPID_PRIVATE_KEY'),
    );
    this.webpush = webpush;
  }

  async subscribe(
    userId: string,
    dto: SubscribePushDto
  ): Promise<PushSubscription> {
    // Check if already exists
    const existing = await this.subscriptionRepo.findOne({
      where: { endpoint: dto.endpoint },
    });

    if (existing) {
      // Update and return
      existing.isActive = true;
      existing.lastUsedAt = new Date();
      return this.subscriptionRepo.save(existing);
    }

    const subscription = this.subscriptionRepo.create({
      userId,
      endpoint: dto.endpoint,
      p256dh: dto.keys.p256dh,
      auth: dto.keys.auth,
      deviceType: dto.deviceType,
      deviceName: dto.deviceName,
      browser: dto.browser,
    });

    return this.subscriptionRepo.save(subscription);
  }

  async unsubscribe(userId: string, endpoint: string): Promise<void> {
    await this.subscriptionRepo.update(
      { userId, endpoint },
      { isActive: false }
    );
  }

  async queue(dto: QueuePushDto): Promise<void> {
    // Get all active subscriptions for user
    const subscriptions = await this.subscriptionRepo.find({
      where: { userId: dto.userId, isActive: true },
    });

    for (const subscription of subscriptions) {
      const job = this.jobRepo.create({
        subscriptionId: subscription.id,
        title: dto.title,
        body: dto.body,
        icon: dto.icon,
        data: dto.data,
        actionUrl: dto.actionUrl,
      });

      await this.jobRepo.save(job);

      await this.queue.add('send', { jobId: job.id }, {
        attempts: 3,
        backoff: { type: 'exponential', delay: 30000 },
      });
    }
  }

  async send(job: PushJob): Promise<void> {
    const subscription = await this.subscriptionRepo.findOne({
      where: { id: job.subscriptionId },
    });

    if (!subscription || !subscription.isActive) {
      return;
    }

    const payload = JSON.stringify({
      title: job.title,
      body: job.body,
      icon: job.icon,
      data: job.data,
      actionUrl: job.actionUrl,
    });

    try {
      await this.webpush.sendNotification(
        {
          endpoint: subscription.endpoint,
          keys: {
            p256dh: subscription.p256dh,
            auth: subscription.auth,
          },
        },
        payload
      );

      subscription.lastUsedAt = new Date();
      await this.subscriptionRepo.save(subscription);
    } catch (error) {
      if (error.statusCode === 410) {
        // Subscription expired
        subscription.isActive = false;
        await this.subscriptionRepo.save(subscription);
      }
      throw error;
    }
  }
}
```

### PreferencesService

```typescript
@Injectable()
export class PreferencesService {
  constructor(
    @InjectRepository(NotificationPreference)
    private readonly repo: Repository<NotificationPreference>,
  ) {}

  async get(
    userId: string,
    type?: string
  ): Promise<NotificationPreference | NotificationPreferences> {
    if (type) {
      const pref = await this.repo.findOne({
        where: { userId, notificationType: type },
      });

      return pref || this.getDefaults(type);
    }

    const prefs = await this.repo.find({ where: { userId } });
    return this.mergeWithDefaults(prefs);
  }

  async update(
    userId: string,
    updates: UpdatePreferencesDto
  ): Promise<NotificationPreference[]> {
    const results: NotificationPreference[] = [];

    for (const [type, settings] of Object.entries(updates)) {
      await this.repo.upsert(
        {
          userId,
          notificationType: type,
          ...settings,
          updatedAt: new Date(),
        },
        ['userId', 'notificationType']
      );

      const updated = await this.repo.findOne({
        where: { userId, notificationType: type },
      });
      results.push(updated);
    }

    return results;
  }

  async reset(userId: string, types?: string[]): Promise<void> {
    const qb = this.repo.createQueryBuilder()
      .delete()
      .where('user_id = :userId', { userId });

    if (types?.length) {
      qb.andWhere('notification_type IN (:...types)', { types });
    }

    await qb.execute();
  }

  private getDefaults(type: string): NotificationPreference {
    return {
      inApp: true,
      email: true,
      push: false,
      emailFrequency: 'instant',
    } as NotificationPreference;
  }
}
```

---

## WebSocket Gateway

```typescript
@WebSocketGateway({
  namespace: '/notifications',
  cors: { origin: '*' },
})
export class NotificationsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  private server: Server;

  private userSockets: Map<string, Set<string>> = new Map();

  constructor(
    private readonly jwtService: JwtService,
    private readonly notificationsService: NotificationsService,
  ) {}

  async handleConnection(client: Socket): Promise<void> {
    try {
      const token = client.handshake.auth.token;
      const payload = this.jwtService.verify(token);
      const userId = payload.sub;

      client.data.userId = userId;

      if (!this.userSockets.has(userId)) {
        this.userSockets.set(userId, new Set());
      }
      this.userSockets.get(userId).add(client.id);

      client.join(`user:${userId}`);

      // Send initial unread count
      const count = await this.notificationsService.getUnreadCount(userId);
      client.emit('unread_count', count);
    } catch (error) {
      client.disconnect();
    }
  }

  handleDisconnect(client: Socket): void {
    const userId = client.data.userId;
    if (userId && this.userSockets.has(userId)) {
      this.userSockets.get(userId).delete(client.id);
      if (this.userSockets.get(userId).size === 0) {
        this.userSockets.delete(userId);
      }
    }
  }

  async sendToUser(userId: string, message: WsMessage): Promise<void> {
    this.server.to(`user:${userId}`).emit(message.type, message.payload);
  }

  @SubscribeMessage('mark_read')
  async handleMarkRead(
    client: Socket,
    payload: { ids?: string[] }
  ): Promise<void> {
    const userId = client.data.userId;
    await this.notificationsService.markAsRead(userId, payload.ids);
  }

  @SubscribeMessage('ping')
  handlePing(): string {
    return 'pong';
  }
}
```

---

## Job Processors

### EmailProcessor

```typescript
@Processor('email')
export class EmailProcessor {
  constructor(
    @InjectRepository(EmailJob)
    private readonly jobRepo: Repository<EmailJob>,
    private readonly emailService: EmailService,
  ) {}

  @Process('send')
  async handleSend(job: Job<{ jobId: string }>): Promise<void> {
    const emailJob = await this.jobRepo.findOne({
      where: { id: job.data.jobId },
    });

    if (!emailJob || emailJob.status === 'sent') {
      return;
    }

    emailJob.status = 'processing';
    emailJob.attempts += 1;
    await this.jobRepo.save(emailJob);

    try {
      await this.emailService.sendDirect({
        to: emailJob.toEmail,
        toName: emailJob.toName,
        subject: emailJob.subject,
        bodyHtml: emailJob.bodyHtml,
        bodyText: emailJob.bodyText,
      });

      emailJob.status = 'sent';
      emailJob.sentAt = new Date();
      emailJob.error = null;
    } catch (error) {
      emailJob.error = error.message;

      if (emailJob.attempts >= emailJob.maxAttempts) {
        emailJob.status = 'failed';
      } else {
        emailJob.status = 'pending';
      }

      throw error; // Retry
    } finally {
      await this.jobRepo.save(emailJob);
    }
  }
}
```

### PushProcessor

```typescript
@Processor('push')
export class PushProcessor {
  constructor(
    @InjectRepository(PushJob)
    private readonly jobRepo: Repository<PushJob>,
    private readonly pushService: PushService,
  ) {}

  @Process('send')
  async handleSend(job: Job<{ jobId: string }>): Promise<void> {
    const pushJob = await this.jobRepo.findOne({
      where: { id: job.data.jobId },
    });

    if (!pushJob || pushJob.status === 'sent') {
      return;
    }

    pushJob.status = 'processing';
    pushJob.attempts += 1;
    await this.jobRepo.save(pushJob);

    try {
      await this.pushService.send(pushJob);

      pushJob.status = 'sent';
      pushJob.sentAt = new Date();
      pushJob.error = null;
    } catch (error) {
      pushJob.error = error.message;

      if (pushJob.attempts >= 3) {
        pushJob.status = 'failed';
      } else {
        pushJob.status = 'pending';
        throw error; // Retry
      }
    } finally {
      await this.jobRepo.save(pushJob);
    }
  }
}
```

---

## Controladores

### NotificationsController

```typescript
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationsController {
  constructor(private readonly service: NotificationsService) {}

  @Get()
  async findAll(
    @CurrentUser() user: User,
    @Query() query: QueryNotificationsDto
  ) {
    return this.service.findByUser(user.id, query);
  }

  @Get('count')
  async getUnreadCount(@CurrentUser() user: User) {
    return this.service.getUnreadCount(user.id);
  }

  @Put('read')
  async markAllAsRead(@CurrentUser() user: User) {
    return this.service.markAsRead(user.id);
  }

  @Put(':id/read')
  async markAsRead(
    @CurrentUser() user: User,
    @Param('id') id: string
  ) {
    return this.service.markAsRead(user.id, [id]);
  }

  @Delete(':id')
  async archive(
    @CurrentUser() user: User,
    @Param('id') id: string
  ) {
    return this.service.archive(user.id, [id]);
  }
}
```

### PushController

```typescript
@ApiTags('Push Notifications')
@Controller('notifications/push')
@UseGuards(JwtAuthGuard)
export class PushController {
  constructor(private readonly service: PushService) {}

  @Get('vapid-key')
  getVapidKey() {
    return {
      publicKey: this.configService.get('VAPID_PUBLIC_KEY'),
    };
  }

  @Post('subscribe')
  async subscribe(
    @CurrentUser() user: User,
    @Body() dto: SubscribePushDto
  ) {
    return this.service.subscribe(user.id, dto);
  }

  @Delete('unsubscribe')
  async unsubscribe(
    @CurrentUser() user: User,
    @Body('endpoint') endpoint: string
  ) {
    return this.service.unsubscribe(user.id, endpoint);
  }

  @Get('subscriptions')
  async getSubscriptions(@CurrentUser() user: User) {
    return this.service.getSubscriptions(user.id);
  }
}
```

### PreferencesController

```typescript
@ApiTags('Notification Preferences')
@Controller('notifications/preferences')
@UseGuards(JwtAuthGuard)
export class PreferencesController {
  constructor(private readonly service: PreferencesService) {}

  @Get()
  async getAll(@CurrentUser() user: User) {
    return this.service.get(user.id);
  }

  @Get(':type')
  async getOne(
    @CurrentUser() user: User,
    @Param('type') type: string
  ) {
    return this.service.get(user.id, type);
  }

  @Patch()
  async update(
    @CurrentUser() user: User,
    @Body() dto: UpdatePreferencesDto
  ) {
    return this.service.update(user.id, dto);
  }

  @Delete()
  async reset(
    @CurrentUser() user: User,
    @Body('types') types?: string[]
  ) {
    return this.service.reset(user.id, types);
  }
}
```

---

## API Endpoints Summary

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /notifications | User | List notifications |
| GET | /notifications/count | User | Get unread count |
| PUT | /notifications/read | User | Mark all as read |
| PUT | /notifications/:id/read | User | Mark one as read |
| DELETE | /notifications/:id | User | Archive notification |
| GET | /notifications/push/vapid-key | - | Get VAPID public key |
| POST | /notifications/push/subscribe | User | Subscribe to push |
| DELETE | /notifications/push/unsubscribe | User | Unsubscribe |
| GET | /notifications/push/subscriptions | User | List subscriptions |
| GET | /notifications/preferences | User | Get preferences |
| GET | /notifications/preferences/:type | User | Get by type |
| PATCH | /notifications/preferences | User | Update preferences |
| DELETE | /notifications/preferences | User | Reset to defaults |
| GET | /admin/notifications/templates | Admin | List templates |
| POST | /admin/notifications/templates | Admin | Create template |
| PATCH | /admin/notifications/templates/:id | Admin | Update template |
| DELETE | /admin/notifications/templates/:id | Admin | Delete template |
| POST | /admin/notifications/send | Admin | Send notification |

---

## WebSocket Events

| Event | Direction | Description |
|-------|-----------|-------------|
| `notification` | Server -> Client | New notification received |
| `unread_count` | Server -> Client | Updated unread count |
| `notifications_read` | Server -> Client | Notifications marked as read |
| `mark_read` | Client -> Server | Request to mark as read |
| `ping` | Client -> Server | Heartbeat |

---

## Historial

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |
