erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-MAIL-THREAD-TRACKING.md

45 KiB

SPEC-MAIL-THREAD-TRACKING

Metadatos

Campo Valor
Código SPEC-TRANS-018
Versión 1.0.0
Fecha 2025-01-15
Autor Requirements-Analyst Agent
Estado DRAFT
Prioridad P0
Módulos Afectados Todos (patrón transversal)
Gaps Cubiertos Patrón mail.thread

Resumen Ejecutivo

Esta especificación define el sistema de mensajería y tracking de cambios para ERP Core:

  1. Mail Thread Mixin: Herencia para agregar chatter a cualquier entidad
  2. Tracking de Campos: Registro automático de cambios en campos marcados
  3. Sistema de Mensajes: Comunicación interna y externa en contexto
  4. Followers: Suscriptores con notificaciones personalizadas
  5. Activities: Tareas programadas asociadas a registros
  6. Chatter UI: Interfaz unificada de comunicación

Referencia Odoo 18

Basado en análisis del sistema de mensajería de Odoo 18:

  • mail.thread: Mixin base para tracking y mensajes
  • mail.message: Modelo de mensajes
  • mail.followers: Sistema de suscriptores
  • mail.activity.mixin: Actividades programadas

Parte 1: Arquitectura del Sistema

1.1 Visión General

┌─────────────────────────────────────────────────────────────────────────┐
│                    SISTEMA DE MENSAJERÍA Y TRACKING                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    ENTIDAD DE NEGOCIO                         │       │
│  │        (Sale Order, Invoice, Task, Lead, etc.)                │       │
│  │                                                                │       │
│  │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │       │
│  │   │ Tracked     │  │ @Trackable  │  │ Activities  │          │       │
│  │   │ Fields      │  │ Mixin       │  │ Mixin       │          │       │
│  │   └──────┬──────┘  └──────┬──────┘  └──────┬──────┘          │       │
│  └──────────┼────────────────┼────────────────┼─────────────────┘       │
│             │                │                │                          │
│             ▼                ▼                ▼                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                      CHATTER SYSTEM                           │       │
│  │  ┌────────────┐  ┌────────────┐  ┌────────────┐              │       │
│  │  │  Messages  │  │ Followers  │  │ Activities │              │       │
│  │  │            │  │            │  │            │              │       │
│  │  └──────┬─────┘  └──────┬─────┘  └──────┬─────┘              │       │
│  └─────────┼───────────────┼───────────────┼────────────────────┘       │
│            │               │               │                             │
│            ▼               ▼               ▼                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                   │
│  │Notifications │  │   Email      │  │   In-App     │                   │
│  │   Queue      │  │  Delivery    │  │ Notifications│                   │
│  └──────────────┘  └──────────────┘  └──────────────┘                   │
└─────────────────────────────────────────────────────────────────────────┘

1.2 Flujo de Tracking

1. CAMPO CON TRACKING
   ├─ Campo marcado con @Tracked
   ├─ Usuario modifica valor
   └─ Sistema detecta cambio
        │
        ▼
2. CREAR MENSAJE DE TRACKING
   ├─ Registrar valor anterior
   ├─ Registrar valor nuevo
   └─ Crear mail_message
        │
        ▼
3. NOTIFICAR FOLLOWERS
   ├─ Filtrar por subtype subscriptions
   ├─ Crear notifications
   └─ Encolar emails
        │
        ▼
4. MOSTRAR EN CHATTER
   ├─ Agregar al timeline
   └─ Actualizar UI en tiempo real

Parte 2: Modelo de Datos

2.1 Diagrama Entidad-Relación

┌─────────────────────────┐       ┌─────────────────────────┐
│   mail_messages         │       │   mail_message_subtypes │
│─────────────────────────│       │─────────────────────────│
│ id (PK)                 │       │ id (PK)                 │
│ res_model               │       │ name                    │
│ res_id                  │       │ code                    │
│ message_type            │       │ description             │
│ subtype_id (FK)         │──────▶│ internal                │
│ author_id (FK)          │       │ default                 │
│ body                    │       │ sequence                │
│ subject                 │       └─────────────────────────┘
│ parent_id (FK)          │
│ tracking_values (JSON)  │       ┌─────────────────────────┐
│ created_at              │       │   mail_followers        │
└─────────────────────────┘       │─────────────────────────│
                                  │ id (PK)                 │
┌─────────────────────────┐       │ res_model               │
│ mail_message_attachments│       │ res_id                  │
│─────────────────────────│       │ partner_id (FK)         │
│ message_id (FK)         │       │ subtype_ids (M2M)       │
│ attachment_id (FK)      │       └─────────────────────────┘
└─────────────────────────┘
                                  ┌─────────────────────────┐
┌─────────────────────────┐       │   mail_activities       │
│ mail_notifications      │       │─────────────────────────│
│─────────────────────────│       │ id (PK)                 │
│ id (PK)                 │       │ res_model               │
│ message_id (FK)         │       │ res_id                  │
│ partner_id (FK)         │       │ activity_type_id (FK)   │
│ notification_type       │       │ user_id (FK)            │
│ notification_status     │       │ date_deadline           │
│ read_date               │       │ summary                 │
│ email_status            │       │ state                   │
└─────────────────────────┘       └─────────────────────────┘

2.2 Definición de Tablas

mail_messages (Mensajes)

-- Mensajes del sistema (chatter, emails, notas)
CREATE TABLE mail_messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Referencia al documento
    res_model VARCHAR(100) NOT NULL, -- ej: 'sale_orders'
    res_id UUID NOT NULL,

    -- Tipo y subtipo
    message_type message_type NOT NULL DEFAULT 'notification',
    subtype_id UUID REFERENCES mail_message_subtypes(id),

    -- Autor
    author_id UUID REFERENCES partners(id),
    email_from VARCHAR(255),

    -- Contenido
    subject VARCHAR(500),
    body TEXT,
    body_html TEXT,

    -- Threading
    parent_id UUID REFERENCES mail_messages(id),

    -- Tracking de cambios
    tracking_values JSONB DEFAULT '[]',
    -- Estructura: [{"field": "state", "field_label": "Estado",
    --               "old_value": "draft", "new_value": "confirmed",
    --               "old_display": "Borrador", "new_display": "Confirmado"}]

    -- Metadata
    is_internal BOOLEAN NOT NULL DEFAULT false,
    starred BOOLEAN NOT NULL DEFAULT false,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by UUID REFERENCES users(id),

    -- Índices compuestos para búsqueda rápida
    CONSTRAINT valid_reference CHECK (res_model IS NOT NULL AND res_id IS NOT NULL)
);

CREATE TYPE message_type AS ENUM (
    'comment',      -- Mensaje de usuario (Send message)
    'notification', -- Notificación automática del sistema
    'email',        -- Email entrante/saliente
    'note'          -- Nota interna (Log note)
);

CREATE INDEX idx_mail_messages_resource ON mail_messages(res_model, res_id);
CREATE INDEX idx_mail_messages_author ON mail_messages(author_id);
CREATE INDEX idx_mail_messages_parent ON mail_messages(parent_id);
CREATE INDEX idx_mail_messages_created ON mail_messages(created_at DESC);

mail_message_subtypes (Subtipos de Mensaje)

-- Subtipos de mensajes para filtrado de notificaciones
CREATE TABLE mail_message_subtypes (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    code VARCHAR(50) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    description TEXT,

    -- Configuración
    internal BOOLEAN NOT NULL DEFAULT false,
    -- true = solo usuarios internos pueden ver

    default_subscription BOOLEAN NOT NULL DEFAULT true,
    -- true = followers se suscriben por defecto

    sequence INTEGER NOT NULL DEFAULT 10,

    -- Modelo específico (null = global)
    res_model VARCHAR(100),

    -- Para tracking automático
    track_field VARCHAR(100), -- Campo que dispara este subtype
    track_value VARCHAR(100), -- Valor específico que dispara

    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Subtipos predeterminados
INSERT INTO mail_message_subtypes (code, name, internal, default_subscription) VALUES
('mt_comment', 'Discussions', false, true),
('mt_note', 'Note', true, false),
('mt_activities', 'Activities', false, true);

mail_followers (Seguidores)

-- Seguidores de documentos
CREATE TABLE mail_followers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Documento seguido
    res_model VARCHAR(100) NOT NULL,
    res_id UUID NOT NULL,

    -- Seguidor
    partner_id UUID NOT NULL REFERENCES partners(id),

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    UNIQUE(res_model, res_id, partner_id)
);

-- Suscripciones a subtipos por follower
CREATE TABLE mail_follower_subtypes (
    follower_id UUID NOT NULL REFERENCES mail_followers(id) ON DELETE CASCADE,
    subtype_id UUID NOT NULL REFERENCES mail_message_subtypes(id),
    PRIMARY KEY (follower_id, subtype_id)
);

CREATE INDEX idx_mail_followers_resource ON mail_followers(res_model, res_id);
CREATE INDEX idx_mail_followers_partner ON mail_followers(partner_id);

mail_notifications (Notificaciones)

-- Notificaciones individuales por mensaje
CREATE TABLE mail_notifications (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    message_id UUID NOT NULL REFERENCES mail_messages(id) ON DELETE CASCADE,
    partner_id UUID NOT NULL REFERENCES partners(id),

    -- Tipo de notificación
    notification_type notification_type NOT NULL DEFAULT 'inbox',

    -- Estado
    notification_status notification_status NOT NULL DEFAULT 'ready',

    -- Lectura
    is_read BOOLEAN NOT NULL DEFAULT false,
    read_date TIMESTAMPTZ,

    -- Email
    email_status email_status,
    failure_reason TEXT,

    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TYPE notification_type AS ENUM ('inbox', 'email');
CREATE TYPE notification_status AS ENUM ('ready', 'sent', 'bounce', 'exception', 'canceled');
CREATE TYPE email_status AS ENUM ('ready', 'sent', 'bounce', 'exception');

CREATE INDEX idx_mail_notifications_message ON mail_notifications(message_id);
CREATE INDEX idx_mail_notifications_partner ON mail_notifications(partner_id);
CREATE INDEX idx_mail_notifications_unread ON mail_notifications(partner_id, is_read)
    WHERE is_read = false;

mail_activities (Actividades)

-- Actividades programadas
CREATE TABLE mail_activities (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Documento relacionado
    res_model VARCHAR(100) NOT NULL,
    res_id UUID NOT NULL,

    -- Tipo de actividad
    activity_type_id UUID NOT NULL REFERENCES mail_activity_types(id),

    -- Asignación
    user_id UUID NOT NULL REFERENCES users(id),
    request_partner_id UUID REFERENCES partners(id), -- Quien solicitó

    -- Contenido
    summary VARCHAR(500),
    note TEXT,

    -- Fechas
    date_deadline DATE NOT NULL,
    date_done TIMESTAMPTZ,

    -- Estado (calculado)
    state activity_state NOT NULL DEFAULT 'planned',
    -- planned: fecha futura
    -- today: vence hoy
    -- overdue: vencida

    -- Calendario (opcional)
    calendar_event_id UUID REFERENCES calendar_events(id),

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by UUID NOT NULL REFERENCES users(id)
);

CREATE TYPE activity_state AS ENUM ('planned', 'today', 'overdue', 'done', 'canceled');

-- Tipos de actividad
CREATE TABLE mail_activity_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    code VARCHAR(50) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    icon VARCHAR(50) DEFAULT 'fa-tasks',
    category activity_category NOT NULL DEFAULT 'default',

    -- Comportamiento
    default_user_type default_user_type DEFAULT 'current',
    -- current: usuario actual
    -- specific: usuario específico
    -- none: sin asignar

    default_days INTEGER DEFAULT 0, -- Días hasta deadline por defecto

    -- Modelo específico (null = global)
    res_model VARCHAR(100),

    -- Encadenamiento
    chained_activity_type_id UUID REFERENCES mail_activity_types(id),
    -- Al completar, crear automáticamente otra actividad

    sequence INTEGER NOT NULL DEFAULT 10,
    active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TYPE activity_category AS ENUM ('default', 'upload_file', 'phonecall', 'meeting');
CREATE TYPE default_user_type AS ENUM ('current', 'specific', 'none');

-- Tipos predeterminados
INSERT INTO mail_activity_types (code, name, icon, category) VALUES
('mail', 'Email', 'fa-envelope', 'default'),
('call', 'Call', 'fa-phone', 'phonecall'),
('meeting', 'Meeting', 'fa-users', 'meeting'),
('todo', 'To Do', 'fa-tasks', 'default'),
('upload_file', 'Upload Document', 'fa-upload', 'upload_file');

CREATE INDEX idx_mail_activities_resource ON mail_activities(res_model, res_id);
CREATE INDEX idx_mail_activities_user ON mail_activities(user_id);
CREATE INDEX idx_mail_activities_deadline ON mail_activities(date_deadline);
CREATE INDEX idx_mail_activities_state ON mail_activities(state)
    WHERE state NOT IN ('done', 'canceled');

mail_message_attachments (Adjuntos)

-- Relación mensajes-adjuntos
CREATE TABLE mail_message_attachments (
    message_id UUID NOT NULL REFERENCES mail_messages(id) ON DELETE CASCADE,
    attachment_id UUID NOT NULL REFERENCES attachments(id),
    PRIMARY KEY (message_id, attachment_id)
);

Parte 3: Decoradores y Mixins

3.1 Decorador @Tracked

// src/common/decorators/tracked.decorator.ts

import 'reflect-metadata';

const TRACKED_METADATA_KEY = 'tracked:fields';

/**
 * Decorador para marcar campos que deben registrar cambios
 * Equivalente a tracking=True en Odoo
 */
export function Tracked(options?: TrackedOptions): PropertyDecorator {
  return (target: object, propertyKey: string | symbol) => {
    const existingTracked = Reflect.getMetadata(TRACKED_METADATA_KEY, target) || [];
    existingTracked.push({
      field: propertyKey,
      label: options?.label || propertyKey,
      subtypeCode: options?.subtypeCode,
    });
    Reflect.defineMetadata(TRACKED_METADATA_KEY, existingTracked, target);
  };
}

interface TrackedOptions {
  label?: string;       // Nombre para mostrar en tracking
  subtypeCode?: string; // Subtype específico para este campo
}

/**
 * Obtener campos tracked de una clase
 */
export function getTrackedFields(target: object): TrackedField[] {
  return Reflect.getMetadata(TRACKED_METADATA_KEY, target) || [];
}

interface TrackedField {
  field: string;
  label: string;
  subtypeCode?: string;
}

3.2 TrackableMixin

// src/common/mixins/trackable.mixin.ts

import { BeforeUpdate, AfterUpdate, Column, OneToMany } from 'typeorm';
import { getTrackedFields } from '../decorators/tracked.decorator';

export interface ITrackable {
  id: string;
  messages?: MailMessage[];
  followers?: MailFollower[];
  activities?: MailActivity[];
}

/**
 * Mixin que agrega capacidades de tracking a una entidad
 * Equivalente a _inherit = ['mail.thread'] en Odoo
 */
export function TrackableMixin<T extends Constructor>(Base: T) {
  abstract class TrackableClass extends Base implements ITrackable {
    abstract id: string;

    @OneToMany(() => MailMessage, msg => msg.resId, { lazy: true })
    messages?: MailMessage[];

    @OneToMany(() => MailFollower, f => f.resId, { lazy: true })
    followers?: MailFollower[];

    @OneToMany(() => MailActivity, a => a.resId, { lazy: true })
    activities?: MailActivity[];

    // Valores originales antes de update
    private _originalValues: Record<string, any> = {};

    @BeforeUpdate()
    captureOriginalValues() {
      const trackedFields = getTrackedFields(this);
      this._originalValues = {};

      for (const field of trackedFields) {
        this._originalValues[field.field] = (this as any)[field.field];
      }
    }

    @AfterUpdate()
    async trackChanges() {
      const trackedFields = getTrackedFields(this);
      const trackingValues: TrackingValue[] = [];

      for (const field of trackedFields) {
        const oldValue = this._originalValues[field.field];
        const newValue = (this as any)[field.field];

        if (oldValue !== newValue) {
          trackingValues.push({
            field: field.field,
            fieldLabel: field.label,
            oldValue: oldValue,
            newValue: newValue,
            oldDisplay: await this.formatTrackingValue(field.field, oldValue),
            newDisplay: await this.formatTrackingValue(field.field, newValue),
          });
        }
      }

      if (trackingValues.length > 0) {
        await this.createTrackingMessage(trackingValues);
      }
    }

    /**
     * Formatear valor para display
     */
    protected async formatTrackingValue(field: string, value: any): Promise<string> {
      if (value === null || value === undefined) return '';
      if (typeof value === 'boolean') return value ? 'Sí' : 'No';
      if (value instanceof Date) return value.toLocaleDateString();
      // Para relaciones, cargar nombre
      return String(value);
    }

    /**
     * Crear mensaje de tracking
     */
    protected async createTrackingMessage(trackingValues: TrackingValue[]): Promise<void> {
      // Inyectar servicio de mensajería
      const messageService = getMailMessageService();

      await messageService.createTrackingMessage({
        resModel: this.constructor.name.toLowerCase(),
        resId: this.id,
        trackingValues,
        authorId: getCurrentUserId(),
      });
    }

    /**
     * Publicar mensaje en el chatter
     */
    async messagePost(options: MessagePostOptions): Promise<MailMessage> {
      const messageService = getMailMessageService();

      return messageService.postMessage({
        resModel: this.constructor.name.toLowerCase(),
        resId: this.id,
        ...options,
      });
    }

    /**
     * Agregar follower
     */
    async messageSubscribe(partnerIds: string[], subtypeCodes?: string[]): Promise<void> {
      const followerService = getMailFollowerService();

      for (const partnerId of partnerIds) {
        await followerService.subscribe({
          resModel: this.constructor.name.toLowerCase(),
          resId: this.id,
          partnerId,
          subtypeCodes,
        });
      }
    }

    /**
     * Remover follower
     */
    async messageUnsubscribe(partnerIds: string[]): Promise<void> {
      const followerService = getMailFollowerService();

      for (const partnerId of partnerIds) {
        await followerService.unsubscribe({
          resModel: this.constructor.name.toLowerCase(),
          resId: this.id,
          partnerId,
        });
      }
    }

    /**
     * Programar actividad
     */
    async activitySchedule(options: ScheduleActivityOptions): Promise<MailActivity> {
      const activityService = getMailActivityService();

      return activityService.schedule({
        resModel: this.constructor.name.toLowerCase(),
        resId: this.id,
        ...options,
      });
    }
  }

  return TrackableClass;
}

interface TrackingValue {
  field: string;
  fieldLabel: string;
  oldValue: any;
  newValue: any;
  oldDisplay: string;
  newDisplay: string;
}

interface MessagePostOptions {
  body: string;
  subject?: string;
  messageType?: 'comment' | 'note' | 'notification';
  subtypeCode?: string;
  partnerIds?: string[];
  attachmentIds?: string[];
  parentId?: string;
}

interface ScheduleActivityOptions {
  activityTypeCode: string;
  userId: string;
  dateDeadline: Date;
  summary?: string;
  note?: string;
}

3.3 Ejemplo de Uso

// src/modules/sales/entities/sale-order.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { TrackableMixin } from '@common/mixins/trackable.mixin';
import { Tracked } from '@common/decorators/tracked.decorator';

@Entity('sale_orders')
export class SaleOrder extends TrackableMixin(BaseEntity) {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  @Tracked({ label: 'Número' })
  name: string;

  @Column()
  @Tracked({ label: 'Cliente' })
  partnerId: string;

  @Column()
  @Tracked({ label: 'Estado' })
  state: string;

  @Column({ type: 'decimal' })
  @Tracked({ label: 'Total' })
  amountTotal: number;

  @Column()
  @Tracked({ label: 'Vendedor' })
  userId: string;

  // Campo sin tracking
  @Column({ nullable: true })
  internalNote: string;
}

Parte 4: Servicios de Aplicación

4.1 MailMessageService

// src/modules/mail/services/mail-message.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { MailMessage, MessageType } from '../entities/mail-message.entity';
import { MailFollower } from '../entities/mail-follower.entity';
import { MailNotification } from '../entities/mail-notification.entity';

@Injectable()
export class MailMessageService {
  constructor(
    @InjectRepository(MailMessage)
    private readonly messageRepo: Repository<MailMessage>,
    @InjectRepository(MailFollower)
    private readonly followerRepo: Repository<MailFollower>,
    @InjectRepository(MailNotification)
    private readonly notificationRepo: Repository<MailNotification>,
    private readonly dataSource: DataSource,
    private readonly emailService: EmailService,
  ) {}

  /**
   * Publicar mensaje en chatter
   */
  async postMessage(dto: PostMessageDto): Promise<MailMessage> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Obtener subtype
      const subtype = dto.subtypeCode
        ? await this.subtypeRepo.findOne({ where: { code: dto.subtypeCode } })
        : await this.subtypeRepo.findOne({ where: { code: 'mt_comment' } });

      // Crear mensaje
      const message = await queryRunner.manager.save(
        this.messageRepo.create({
          resModel: dto.resModel,
          resId: dto.resId,
          messageType: dto.messageType || MessageType.COMMENT,
          subtypeId: subtype?.id,
          authorId: dto.authorId,
          subject: dto.subject,
          body: dto.body,
          bodyHtml: dto.bodyHtml || dto.body,
          parentId: dto.parentId,
          isInternal: dto.messageType === MessageType.NOTE,
          createdBy: dto.authorId,
        })
      );

      // Adjuntar archivos
      if (dto.attachmentIds?.length > 0) {
        await this.attachFiles(queryRunner, message.id, dto.attachmentIds);
      }

      // Procesar @menciones
      const mentionedPartners = this.extractMentions(dto.body);
      const allPartnerIds = [...new Set([
        ...(dto.partnerIds || []),
        ...mentionedPartners,
      ])];

      // Auto-suscribir mencionados
      for (const partnerId of mentionedPartners) {
        await this.subscribeIfNotFollower(queryRunner, dto.resModel, dto.resId, partnerId);
      }

      // Crear notificaciones para followers
      await this.createNotifications(queryRunner, message, subtype, allPartnerIds);

      await queryRunner.commitTransaction();

      // Enviar emails en background
      this.sendEmailNotifications(message.id);

      return message;
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Crear mensaje de tracking automático
   */
  async createTrackingMessage(dto: CreateTrackingMessageDto): Promise<MailMessage> {
    const trackingSubtype = await this.subtypeRepo.findOne({
      where: { code: 'mt_comment' }
    });

    // Construir body del mensaje
    const bodyLines = dto.trackingValues.map(tv =>
      `<li><strong>${tv.fieldLabel}</strong>: ${tv.oldDisplay || '(vacío)'}${tv.newDisplay || '(vacío)'}</li>`
    );

    const body = `<ul>${bodyLines.join('')}</ul>`;

    return this.postMessage({
      resModel: dto.resModel,
      resId: dto.resId,
      messageType: MessageType.NOTIFICATION,
      subtypeCode: 'mt_comment',
      body,
      bodyHtml: body,
      authorId: dto.authorId,
      trackingValues: dto.trackingValues,
    });
  }

  /**
   * Crear notificaciones para followers
   */
  private async createNotifications(
    queryRunner: any,
    message: MailMessage,
    subtype: MailMessageSubtype,
    additionalPartnerIds: string[]
  ): Promise<void> {
    // Obtener followers suscritos al subtype
    const followers = await this.followerRepo
      .createQueryBuilder('f')
      .innerJoin('f.subtypes', 's', 's.id = :subtypeId', { subtypeId: subtype.id })
      .where('f.res_model = :resModel', { resModel: message.resModel })
      .andWhere('f.res_id = :resId', { resId: message.resId })
      .getMany();

    const partnerIds = new Set([
      ...followers.map(f => f.partnerId),
      ...additionalPartnerIds,
    ]);

    // No notificar al autor
    partnerIds.delete(message.authorId);

    // Filtrar por visibilidad (internal)
    if (subtype.internal) {
      // Solo usuarios internos
      const internalPartners = await this.getInternalPartners([...partnerIds]);
      partnerIds.clear();
      internalPartners.forEach(id => partnerIds.add(id));
    }

    // Crear notificaciones
    for (const partnerId of partnerIds) {
      await queryRunner.manager.save(
        this.notificationRepo.create({
          messageId: message.id,
          partnerId,
          notificationType: 'inbox',
          notificationStatus: 'ready',
        })
      );
    }
  }

  /**
   * Extraer @menciones del body
   */
  private extractMentions(body: string): string[] {
    const mentionRegex = /@\[([^\]]+)\]\(partner:([a-f0-9-]+)\)/g;
    const mentions: string[] = [];
    let match;

    while ((match = mentionRegex.exec(body)) !== null) {
      mentions.push(match[2]); // UUID del partner
    }

    return mentions;
  }

  /**
   * Obtener mensajes de un recurso
   */
  async getMessages(
    resModel: string,
    resId: string,
    options?: GetMessagesOptions
  ): Promise<MailMessage[]> {
    const qb = this.messageRepo.createQueryBuilder('m')
      .leftJoinAndSelect('m.author', 'author')
      .leftJoinAndSelect('m.attachments', 'attachments')
      .leftJoinAndSelect('m.subtype', 'subtype')
      .where('m.res_model = :resModel', { resModel })
      .andWhere('m.res_id = :resId', { resId })
      .orderBy('m.created_at', 'DESC');

    // Filtrar por tipo
    if (options?.messageTypes) {
      qb.andWhere('m.message_type IN (:...types)', { types: options.messageTypes });
    }

    // Filtrar internos si no es usuario interno
    if (!options?.includeInternal) {
      qb.andWhere('m.is_internal = :internal', { internal: false });
    }

    // Paginación
    if (options?.limit) {
      qb.take(options.limit);
    }
    if (options?.offset) {
      qb.skip(options.offset);
    }

    return qb.getMany();
  }

  /**
   * Marcar como leído
   */
  async markAsRead(messageId: string, partnerId: string): Promise<void> {
    await this.notificationRepo.update(
      { messageId, partnerId },
      { isRead: true, readDate: new Date() }
    );
  }
}

4.2 MailFollowerService

// src/modules/mail/services/mail-follower.service.ts

@Injectable()
export class MailFollowerService {
  /**
   * Suscribir partner a documento
   */
  async subscribe(dto: SubscribeDto): Promise<MailFollower> {
    // Verificar si ya es follower
    let follower = await this.followerRepo.findOne({
      where: {
        resModel: dto.resModel,
        resId: dto.resId,
        partnerId: dto.partnerId,
      },
    });

    if (!follower) {
      follower = await this.followerRepo.save({
        resModel: dto.resModel,
        resId: dto.resId,
        partnerId: dto.partnerId,
      });
    }

    // Agregar subtypes
    if (dto.subtypeCodes?.length > 0) {
      const subtypes = await this.subtypeRepo.find({
        where: { code: In(dto.subtypeCodes) }
      });

      await this.followerSubtypeRepo.upsert(
        subtypes.map(s => ({ followerId: follower.id, subtypeId: s.id })),
        ['followerId', 'subtypeId']
      );
    } else {
      // Agregar subtypes por defecto
      const defaultSubtypes = await this.subtypeRepo.find({
        where: { defaultSubscription: true }
      });

      await this.followerSubtypeRepo.upsert(
        defaultSubtypes.map(s => ({ followerId: follower.id, subtypeId: s.id })),
        ['followerId', 'subtypeId']
      );
    }

    return follower;
  }

  /**
   * Desuscribir partner
   */
  async unsubscribe(dto: UnsubscribeDto): Promise<void> {
    await this.followerRepo.delete({
      resModel: dto.resModel,
      resId: dto.resId,
      partnerId: dto.partnerId,
    });
  }

  /**
   * Obtener followers de un documento
   */
  async getFollowers(resModel: string, resId: string): Promise<FollowerWithSubtypes[]> {
    return this.followerRepo
      .createQueryBuilder('f')
      .leftJoinAndSelect('f.partner', 'partner')
      .leftJoinAndSelect('f.subtypes', 'subtypes')
      .where('f.res_model = :resModel', { resModel })
      .andWhere('f.res_id = :resId', { resId })
      .getMany();
  }

  /**
   * Auto-suscribir creador
   */
  async autoSubscribeCreator(
    resModel: string,
    resId: string,
    userId: string
  ): Promise<void> {
    const user = await this.userRepo.findOne({
      where: { id: userId },
      relations: ['partner'],
    });

    if (user?.partnerId) {
      await this.subscribe({
        resModel,
        resId,
        partnerId: user.partnerId,
      });
    }
  }
}

4.3 MailActivityService

// src/modules/mail/services/mail-activity.service.ts

@Injectable()
export class MailActivityService {
  constructor(
    @InjectRepository(MailActivity)
    private readonly activityRepo: Repository<MailActivity>,
    private readonly calendarService: CalendarService,
  ) {}

  /**
   * Programar actividad
   */
  async schedule(dto: ScheduleActivityDto): Promise<MailActivity> {
    const activityType = await this.activityTypeRepo.findOneOrFail({
      where: { code: dto.activityTypeCode }
    });

    const state = this.calculateState(dto.dateDeadline);

    const activity = await this.activityRepo.save({
      resModel: dto.resModel,
      resId: dto.resId,
      activityTypeId: activityType.id,
      userId: dto.userId,
      dateDeadline: dto.dateDeadline,
      summary: dto.summary,
      note: dto.note,
      state,
      createdBy: dto.createdBy,
    });

    // Crear evento de calendario si es meeting
    if (activityType.category === 'meeting' && dto.createCalendarEvent) {
      const calendarEvent = await this.calendarService.createEvent({
        name: dto.summary || activityType.name,
        startDatetime: dto.dateDeadline,
        endDatetime: new Date(dto.dateDeadline.getTime() + 60 * 60 * 1000), // 1 hora
        organizerId: dto.userId,
      }, dto.userId);

      await this.activityRepo.update(activity.id, {
        calendarEventId: calendarEvent.id,
      });
    }

    // Notificar al usuario asignado
    await this.notifyAssignedUser(activity);

    return activity;
  }

  /**
   * Completar actividad
   */
  async markAsDone(
    activityId: string,
    feedback?: string,
    userId?: string
  ): Promise<MailActivity> {
    const activity = await this.activityRepo.findOneOrFail({
      where: { id: activityId },
      relations: ['activityType'],
    });

    activity.state = 'done';
    activity.dateDone = new Date();

    await this.activityRepo.save(activity);

    // Publicar mensaje de completado
    await this.messageService.postMessage({
      resModel: activity.resModel,
      resId: activity.resId,
      messageType: MessageType.NOTIFICATION,
      subtypeCode: 'mt_activities',
      body: `Actividad "${activity.summary || activity.activityType.name}" completada.${feedback ? ` Feedback: ${feedback}` : ''}`,
      authorId: userId,
    });

    // Crear siguiente actividad encadenada
    if (activity.activityType.chainedActivityTypeId) {
      await this.scheduleChainedActivity(activity);
    }

    return activity;
  }

  /**
   * Obtener actividades de un documento
   */
  async getActivities(
    resModel: string,
    resId: string
  ): Promise<MailActivity[]> {
    return this.activityRepo.find({
      where: {
        resModel,
        resId,
        state: Not(In(['done', 'canceled'])),
      },
      relations: ['activityType', 'user'],
      order: { dateDeadline: 'ASC' },
    });
  }

  /**
   * Obtener actividades pendientes del usuario
   */
  async getMyActivities(userId: string): Promise<ActivitySummary> {
    const activities = await this.activityRepo.find({
      where: {
        userId,
        state: Not(In(['done', 'canceled'])),
      },
      relations: ['activityType'],
      order: { dateDeadline: 'ASC' },
    });

    const today = new Date();
    today.setHours(0, 0, 0, 0);

    return {
      overdue: activities.filter(a => new Date(a.dateDeadline) < today),
      today: activities.filter(a => {
        const deadline = new Date(a.dateDeadline);
        deadline.setHours(0, 0, 0, 0);
        return deadline.getTime() === today.getTime();
      }),
      planned: activities.filter(a => new Date(a.dateDeadline) > today),
    };
  }

  /**
   * Calcular estado basado en fecha
   */
  private calculateState(dateDeadline: Date): ActivityState {
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    const deadline = new Date(dateDeadline);
    deadline.setHours(0, 0, 0, 0);

    if (deadline < today) return 'overdue';
    if (deadline.getTime() === today.getTime()) return 'today';
    return 'planned';
  }

  /**
   * Actualizar estados de actividades (cron)
   */
  @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
  async updateActivityStates(): Promise<void> {
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    // Marcar vencidas
    await this.activityRepo.update(
      {
        dateDeadline: LessThan(today),
        state: Not(In(['done', 'canceled', 'overdue'])),
      },
      { state: 'overdue' }
    );

    // Marcar "hoy"
    await this.activityRepo.update(
      {
        dateDeadline: today,
        state: 'planned',
      },
      { state: 'today' }
    );
  }
}

Parte 5: API REST

5.1 Endpoints

// src/modules/mail/controllers/mail.controller.ts

@Controller('api/v1/mail')
@UseGuards(JwtAuthGuard)
@ApiTags('Mail & Chatter')
export class MailController {
  // === MENSAJES ===

  @Get(':model/:id/messages')
  @ApiOperation({ summary: 'Obtener mensajes de un documento' })
  async getMessages(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Query() query: GetMessagesQueryDto,
    @CurrentUser() user: User,
  ): Promise<PaginatedResponse<MailMessageResponseDto>> {
    return this.messageService.getMessages(model, id, {
      ...query,
      includeInternal: user.isInternal,
    });
  }

  @Post(':model/:id/messages')
  @ApiOperation({ summary: 'Publicar mensaje' })
  async postMessage(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: PostMessageDto,
    @CurrentUser() user: User,
  ): Promise<MailMessageResponseDto> {
    return this.messageService.postMessage({
      resModel: model,
      resId: id,
      authorId: user.partnerId,
      ...dto,
    });
  }

  @Post(':model/:id/messages/:messageId/read')
  @ApiOperation({ summary: 'Marcar mensaje como leído' })
  async markAsRead(
    @Param('messageId', ParseUUIDPipe) messageId: string,
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.messageService.markAsRead(messageId, user.partnerId);
  }

  // === FOLLOWERS ===

  @Get(':model/:id/followers')
  @ApiOperation({ summary: 'Obtener followers de documento' })
  async getFollowers(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<FollowerResponseDto[]> {
    return this.followerService.getFollowers(model, id);
  }

  @Post(':model/:id/followers')
  @ApiOperation({ summary: 'Suscribir followers' })
  async subscribe(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: SubscribeFollowersDto,
  ): Promise<void> {
    for (const partnerId of dto.partnerIds) {
      await this.followerService.subscribe({
        resModel: model,
        resId: id,
        partnerId,
        subtypeCodes: dto.subtypeCodes,
      });
    }
  }

  @Delete(':model/:id/followers/:partnerId')
  @ApiOperation({ summary: 'Desuscribir follower' })
  async unsubscribe(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Param('partnerId', ParseUUIDPipe) partnerId: string,
  ): Promise<void> {
    return this.followerService.unsubscribe({
      resModel: model,
      resId: id,
      partnerId,
    });
  }

  // === ACTIVIDADES ===

  @Get(':model/:id/activities')
  @ApiOperation({ summary: 'Obtener actividades de documento' })
  async getActivities(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<MailActivityResponseDto[]> {
    return this.activityService.getActivities(model, id);
  }

  @Post(':model/:id/activities')
  @ApiOperation({ summary: 'Programar actividad' })
  async scheduleActivity(
    @Param('model') model: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: ScheduleActivityDto,
    @CurrentUser() user: User,
  ): Promise<MailActivityResponseDto> {
    return this.activityService.schedule({
      resModel: model,
      resId: id,
      createdBy: user.id,
      ...dto,
    });
  }

  @Post('activities/:id/done')
  @ApiOperation({ summary: 'Completar actividad' })
  async markActivityDone(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: CompleteActivityDto,
    @CurrentUser() user: User,
  ): Promise<MailActivityResponseDto> {
    return this.activityService.markAsDone(id, dto.feedback, user.id);
  }

  @Delete('activities/:id')
  @ApiOperation({ summary: 'Cancelar actividad' })
  async cancelActivity(
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<void> {
    return this.activityService.cancel(id);
  }

  // === MIS NOTIFICACIONES ===

  @Get('inbox')
  @ApiOperation({ summary: 'Mis notificaciones' })
  async getInbox(
    @Query() query: InboxQueryDto,
    @CurrentUser() user: User,
  ): Promise<PaginatedResponse<NotificationResponseDto>> {
    return this.notificationService.getInbox(user.partnerId, query);
  }

  @Get('activities/my')
  @ApiOperation({ summary: 'Mis actividades pendientes' })
  async getMyActivities(
    @CurrentUser() user: User,
  ): Promise<ActivitySummaryDto> {
    return this.activityService.getMyActivities(user.id);
  }
}

Parte 6: Componente Chatter (Frontend)

6.1 Estructura del Componente

// Frontend: Chatter.tsx

interface ChatterProps {
  resModel: string;
  resId: string;
  canSendMessage?: boolean;
  canLogNote?: boolean;
  canScheduleActivity?: boolean;
}

export const Chatter: React.FC<ChatterProps> = ({
  resModel,
  resId,
  canSendMessage = true,
  canLogNote = true,
  canScheduleActivity = true,
}) => {
  const [activeTab, setActiveTab] = useState<'messages' | 'activities'>('messages');
  const [composerMode, setComposerMode] = useState<'message' | 'note' | null>(null);

  const { data: messages, refetch: refetchMessages } = useMessages(resModel, resId);
  const { data: activities, refetch: refetchActivities } = useActivities(resModel, resId);
  const { data: followers } = useFollowers(resModel, resId);

  return (
    <div className="chatter">
      {/* Header con tabs */}
      <div className="chatter-header">
        <Tabs value={activeTab} onChange={setActiveTab}>
          <Tab value="messages" icon={<MessageIcon />}>
            Mensajes ({messages?.length || 0})
          </Tab>
          <Tab value="activities" icon={<ActivityIcon />}>
            Actividades ({activities?.length || 0})
          </Tab>
        </Tabs>

        <div className="chatter-actions">
          {canSendMessage && (
            <Button onClick={() => setComposerMode('message')}>
              Enviar mensaje
            </Button>
          )}
          {canLogNote && (
            <Button variant="text" onClick={() => setComposerMode('note')}>
              Nota interna
            </Button>
          )}
          {canScheduleActivity && (
            <Button variant="text" onClick={() => openActivityDialog()}>
              Programar actividad
            </Button>
          )}
        </div>
      </div>

      {/* Composer */}
      {composerMode && (
        <MessageComposer
          mode={composerMode}
          resModel={resModel}
          resId={resId}
          onSend={() => {
            refetchMessages();
            setComposerMode(null);
          }}
          onCancel={() => setComposerMode(null)}
        />
      )}

      {/* Content */}
      {activeTab === 'messages' ? (
        <MessageList messages={messages || []} />
      ) : (
        <ActivityList
          activities={activities || []}
          onComplete={refetchActivities}
        />
      )}

      {/* Followers sidebar */}
      <FollowersSidebar
        followers={followers || []}
        resModel={resModel}
        resId={resId}
      />
    </div>
  );
};

Apéndice A: Checklist de Implementación

  • Modelo mail_messages y migraciones
  • Modelo mail_message_subtypes
  • Modelo mail_followers
  • Modelo mail_notifications
  • Modelo mail_activities
  • Modelo mail_activity_types
  • Decorador @Tracked
  • TrackableMixin
  • MailMessageService
  • MailFollowerService
  • MailActivityService
  • API REST endpoints
  • Componente Chatter (frontend)
  • Componente MessageComposer
  • Componente ActivityDialog
  • WebSocket para actualizaciones en tiempo real
  • Integración con email (entrante/saliente)
  • Cron para actualización de estados de actividades
  • Tests unitarios
  • Tests de integración

Documento generado como parte del análisis de gaps ERP Core vs Odoo 18 Referencia: Patrón mail.thread - Sistema de Mensajería y Tracking