45 KiB
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:
- Mail Thread Mixin: Herencia para agregar chatter a cualquier entidad
- Tracking de Campos: Registro automático de cambios en campos marcados
- Sistema de Mensajes: Comunicación interna y externa en contexto
- Followers: Suscriptores con notificaciones personalizadas
- Activities: Tareas programadas asociadas a registros
- 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