# 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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 ```typescript // 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 ```typescript // 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(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 = {}; @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 { 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 { // 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 { const messageService = getMailMessageService(); return messageService.postMessage({ resModel: this.constructor.name.toLowerCase(), resId: this.id, ...options, }); } /** * Agregar follower */ async messageSubscribe(partnerIds: string[], subtypeCodes?: string[]): Promise { 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 { 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 { 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 ```typescript // 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 ```typescript // 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, @InjectRepository(MailFollower) private readonly followerRepo: Repository, @InjectRepository(MailNotification) private readonly notificationRepo: Repository, private readonly dataSource: DataSource, private readonly emailService: EmailService, ) {} /** * Publicar mensaje en chatter */ async postMessage(dto: PostMessageDto): Promise { 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 { const trackingSubtype = await this.subtypeRepo.findOne({ where: { code: 'mt_comment' } }); // Construir body del mensaje const bodyLines = dto.trackingValues.map(tv => `
  • ${tv.fieldLabel}: ${tv.oldDisplay || '(vacío)'} → ${tv.newDisplay || '(vacío)'}
  • ` ); const body = `
      ${bodyLines.join('')}
    `; 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 { // 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 { 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 { await this.notificationRepo.update( { messageId, partnerId }, { isRead: true, readDate: new Date() } ); } } ``` ### 4.2 MailFollowerService ```typescript // src/modules/mail/services/mail-follower.service.ts @Injectable() export class MailFollowerService { /** * Suscribir partner a documento */ async subscribe(dto: SubscribeDto): Promise { // 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 { 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 { 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 { 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 ```typescript // src/modules/mail/services/mail-activity.service.ts @Injectable() export class MailActivityService { constructor( @InjectRepository(MailActivity) private readonly activityRepo: Repository, private readonly calendarService: CalendarService, ) {} /** * Programar actividad */ async schedule(dto: ScheduleActivityDto): Promise { 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 { 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 { 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 { 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 { 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 ```typescript // 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return this.activityService.markAsDone(id, dto.feedback, user.id); } @Delete('activities/:id') @ApiOperation({ summary: 'Cancelar actividad' }) async cancelActivity( @Param('id', ParseUUIDPipe) id: string, ): Promise { return this.activityService.cancel(id); } // === MIS NOTIFICACIONES === @Get('inbox') @ApiOperation({ summary: 'Mis notificaciones' }) async getInbox( @Query() query: InboxQueryDto, @CurrentUser() user: User, ): Promise> { return this.notificationService.getInbox(user.partnerId, query); } @Get('activities/my') @ApiOperation({ summary: 'Mis actividades pendientes' }) async getMyActivities( @CurrentUser() user: User, ): Promise { return this.activityService.getMyActivities(user.id); } } ``` --- ## Parte 6: Componente Chatter (Frontend) ### 6.1 Estructura del Componente ```typescript // Frontend: Chatter.tsx interface ChatterProps { resModel: string; resId: string; canSendMessage?: boolean; canLogNote?: boolean; canScheduleActivity?: boolean; } export const Chatter: React.FC = ({ 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 (
    {/* Header con tabs */}
    }> Mensajes ({messages?.length || 0}) }> Actividades ({activities?.length || 0})
    {canSendMessage && ( )} {canLogNote && ( )} {canScheduleActivity && ( )}
    {/* Composer */} {composerMode && ( { refetchMessages(); setComposerMode(null); }} onCancel={() => setComposerMode(null)} /> )} {/* Content */} {activeTab === 'messages' ? ( ) : ( )} {/* Followers sidebar */}
    ); }; ``` --- ## 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*