erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-INTEGRACION-CALENDAR.md

53 KiB

SPEC-INTEGRACION-CALENDAR

Metadatos

Campo Valor
Código SPEC-TRANS-017
Versión 1.0.0
Fecha 2025-01-15
Autor Requirements-Analyst Agent
Estado DRAFT
Prioridad P0
Módulos Afectados MGN-014 (Calendario)
Gaps Cubiertos GAP-MGN-014-001

Resumen Ejecutivo

Esta especificación define el sistema de calendario e integración con servicios externos:

  1. Eventos de Calendario: Modelo core con recurrencia y asistentes
  2. Google Calendar Sync: Sincronización bidireccional OAuth 2.0
  3. Outlook/Microsoft Sync: Integración vía Microsoft Graph API
  4. Sistema de Citas: Agendamiento online con disponibilidad
  5. Actividades: Vinculación de activities con eventos de calendario
  6. Recordatorios: Notificaciones automáticas por email/SMS

Referencia Odoo 18

Basado en análisis de módulos de Odoo 18:

  • calendar: Módulo base de calendario
  • google_calendar: Sincronización con Google
  • microsoft_calendar: Sincronización con Outlook
  • appointment: Sistema de citas online

Parte 1: Arquitectura del Sistema

1.1 Visión General

┌─────────────────────────────────────────────────────────────────────────┐
│                    SISTEMA DE CALENDARIO                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    CALENDAR EVENTS                            │       │
│  │  ┌────────────┐  ┌────────────┐  ┌────────────┐              │       │
│  │  │   Event    │  │ Attendees  │  │  Alarms    │              │       │
│  │  │            │  │            │  │ (Reminders)│              │       │
│  │  └────────────┘  └────────────┘  └────────────┘              │       │
│  └──────────────────────────────────────────────────────────────┘       │
│                                │                                         │
│         ┌──────────────────────┼──────────────────────┐                 │
│         │                      │                      │                 │
│         ▼                      ▼                      ▼                 │
│  ┌─────────────┐       ┌─────────────┐       ┌─────────────┐            │
│  │   Google    │       │  Microsoft  │       │ Activities  │            │
│  │  Calendar   │       │   Outlook   │       │  (CRM, etc) │            │
│  │   Sync      │       │    Sync     │       │             │            │
│  └──────┬──────┘       └──────┬──────┘       └─────────────┘            │
│         │                     │                                          │
│         │  OAuth 2.0          │  OAuth 2.0                              │
│         ▼                     ▼                                          │
│  ┌─────────────┐       ┌─────────────┐                                  │
│  │  Google     │       │  Microsoft  │                                  │
│  │  Calendar   │       │   Graph     │                                  │
│  │  API        │       │   API       │                                  │
│  └─────────────┘       └─────────────┘                                  │
│                                                                          │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                    APPOINTMENT BOOKING                        │       │
│  │  ┌────────────┐  ┌────────────┐  ┌────────────┐              │       │
│  │  │ Appointment│  │Availability│  │  Booking   │              │       │
│  │  │   Types    │  │   Slots    │  │   Portal   │              │       │
│  │  └────────────┘  └────────────┘  └────────────┘              │       │
│  └──────────────────────────────────────────────────────────────┘       │
└─────────────────────────────────────────────────────────────────────────┘

1.2 Flujo de Sincronización

1. CONFIGURAR INTEGRACIÓN
   ├─ Admin configura OAuth credentials
   ├─ Usuario conecta su cuenta (Google/Outlook)
   └─ Sistema obtiene access_token + refresh_token
        │
        ▼
2. SINCRONIZACIÓN INICIAL
   ├─ Fetch eventos externos → crear en ERP
   ├─ Push eventos ERP → crear en externo
   └─ Establecer mapeo de IDs
        │
        ▼
3. SYNC CONTINUA (bidireccional)
   ├─ Webhook recibe cambios externos
   ├─ Cron job detecta cambios locales
   └─ Resolver conflictos (last-write-wins)
        │
        ▼
4. NOTIFICACIONES
   ├─ Crear evento → invitación a asistentes
   ├─ Modificar → actualización
   └─ Eliminar → cancelación

Parte 2: Modelo de Datos

2.1 Diagrama Entidad-Relación

┌─────────────────────────┐       ┌─────────────────────────┐
│   calendar_events       │       │   calendar_attendees    │
│─────────────────────────│       │─────────────────────────│
│ id (PK)                 │       │ id (PK)                 │
│ name                    │──┐    │ event_id (FK)           │
│ description             │  │    │ partner_id (FK)         │
│ start_datetime          │  │    │ user_id (FK)            │
│ end_datetime            │  │    │ email                   │
│ all_day                 │  │    │ state                   │
│ location                │  └───▶│ role                    │
│ privacy                 │       └─────────────────────────┘
│ show_as                 │
│ organizer_id (FK)       │       ┌─────────────────────────┐
│ recurrence_id (FK)      │       │   calendar_alarms       │
│ recurring               │       │─────────────────────────│
│ external_id             │       │ id (PK)                 │
│ sync_provider           │       │ event_id (FK)           │
└─────────────────────────┘       │ alarm_type              │
                                  │ interval                │
┌─────────────────────────┐       │ interval_unit           │
│ calendar_recurrences    │       │ triggered               │
│─────────────────────────│       └─────────────────────────┘
│ id (PK)                 │
│ rrule_type              │       ┌─────────────────────────┐
│ interval                │       │  calendar_sync_tokens   │
│ end_type                │       │─────────────────────────│
│ count                   │       │ id (PK)                 │
│ until                   │       │ user_id (FK)            │
│ weekdays                │       │ provider                │
│ month_by                │       │ access_token (encrypted)│
└─────────────────────────┘       │ refresh_token           │
                                  │ expires_at              │
┌─────────────────────────┐       │ sync_token              │
│  appointment_types      │       │ last_sync               │
│─────────────────────────│       └─────────────────────────┘
│ id (PK)                 │
│ name                    │
│ duration_minutes        │
│ schedule_range_days     │
│ min_booking_hours       │
│ assignee_ids            │
│ availability (JSON)     │
└─────────────────────────┘

2.2 Definición de Tablas

calendar_events (Eventos)

-- Eventos de calendario
CREATE TABLE calendar_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Información básica
    name VARCHAR(255) NOT NULL,
    description TEXT,
    location VARCHAR(500),

    -- Fechas y duración
    start_datetime TIMESTAMPTZ NOT NULL,
    end_datetime TIMESTAMPTZ NOT NULL,
    all_day BOOLEAN NOT NULL DEFAULT false,
    timezone VARCHAR(50) DEFAULT 'America/Mexico_City',

    -- Organizador
    organizer_id UUID NOT NULL REFERENCES users(id),
    organizer_email VARCHAR(255),

    -- Privacidad y disponibilidad
    privacy event_privacy NOT NULL DEFAULT 'public',
    show_as event_availability NOT NULL DEFAULT 'busy',

    -- Recurrencia
    recurring BOOLEAN NOT NULL DEFAULT false,
    recurrence_id UUID REFERENCES calendar_recurrences(id),

    -- Vinculación a otros modelos
    res_model VARCHAR(100), -- ej: 'crm.lead', 'project.task'
    res_id UUID,

    -- Sincronización externa
    external_id VARCHAR(255), -- ID en Google/Outlook
    sync_provider sync_provider_type,
    last_synced_at TIMESTAMPTZ,
    sync_hash VARCHAR(64), -- Hash para detectar cambios

    -- Estado
    active BOOLEAN NOT NULL DEFAULT true,
    company_id UUID NOT NULL REFERENCES companies(id),

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

    CONSTRAINT valid_dates CHECK (end_datetime >= start_datetime)
);

CREATE TYPE event_privacy AS ENUM ('public', 'private', 'confidential');
CREATE TYPE event_availability AS ENUM ('free', 'busy', 'tentative');
CREATE TYPE sync_provider_type AS ENUM ('google', 'microsoft', 'ical');

CREATE INDEX idx_calendar_events_dates ON calendar_events(start_datetime, end_datetime);
CREATE INDEX idx_calendar_events_organizer ON calendar_events(organizer_id);
CREATE INDEX idx_calendar_events_external ON calendar_events(external_id, sync_provider);
CREATE INDEX idx_calendar_events_resource ON calendar_events(res_model, res_id);

calendar_attendees (Asistentes)

-- Asistentes a eventos
CREATE TABLE calendar_attendees (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,

    -- Asistente (usuario interno o externo)
    partner_id UUID REFERENCES partners(id),
    user_id UUID REFERENCES users(id),
    email VARCHAR(255) NOT NULL,
    display_name VARCHAR(255),

    -- Estado de participación
    state attendee_state NOT NULL DEFAULT 'needs_action',
    -- needs_action: No ha respondido
    -- tentative: Tentativo
    -- accepted: Aceptado
    -- declined: Rechazado

    -- Rol
    role attendee_role NOT NULL DEFAULT 'req_participant',
    -- req_participant: Participante requerido
    -- opt_participant: Participante opcional
    -- chair: Organizador

    -- Notificaciones enviadas
    invitation_sent BOOLEAN NOT NULL DEFAULT false,
    invitation_sent_at TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    UNIQUE(event_id, email)
);

CREATE TYPE attendee_state AS ENUM ('needs_action', 'tentative', 'accepted', 'declined');
CREATE TYPE attendee_role AS ENUM ('req_participant', 'opt_participant', 'chair');

CREATE INDEX idx_calendar_attendees_event ON calendar_attendees(event_id);
CREATE INDEX idx_calendar_attendees_user ON calendar_attendees(user_id);

calendar_recurrences (Reglas de Recurrencia)

-- Reglas de recurrencia para eventos
CREATE TABLE calendar_recurrences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Tipo de recurrencia
    rrule_type recurrence_rrule_type NOT NULL,

    -- Intervalo
    interval INTEGER NOT NULL DEFAULT 1,

    -- Fin de recurrencia
    end_type recurrence_end_type NOT NULL DEFAULT 'forever',
    count INTEGER, -- Para end_type = 'count'
    until DATE, -- Para end_type = 'until'

    -- Días de la semana (para weekly)
    weekdays SMALLINT DEFAULT 0, -- Bitmask

    -- Configuración mensual
    month_by month_recurrence_type DEFAULT 'date',
    -- 'date': mismo día del mes
    -- 'day': mismo día de la semana (ej: 2do martes)

    day_of_month INTEGER CHECK (day_of_month BETWEEN 1 AND 31),
    week_of_month INTEGER CHECK (week_of_month BETWEEN 1 AND 5),

    -- Base event
    base_event_id UUID REFERENCES calendar_events(id),

    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TYPE recurrence_rrule_type AS ENUM ('daily', 'weekly', 'monthly', 'yearly');
CREATE TYPE recurrence_end_type AS ENUM ('forever', 'count', 'until');
CREATE TYPE month_recurrence_type AS ENUM ('date', 'day');

calendar_alarms (Recordatorios)

-- Recordatorios/Alarmas de eventos
CREATE TABLE calendar_alarms (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,

    -- Tipo de alarma
    alarm_type alarm_type NOT NULL DEFAULT 'notification',

    -- Anticipación
    interval_value INTEGER NOT NULL DEFAULT 30,
    interval_unit interval_unit NOT NULL DEFAULT 'minutes',

    -- Estado
    triggered BOOLEAN NOT NULL DEFAULT false,
    triggered_at TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TYPE alarm_type AS ENUM ('notification', 'email', 'sms');
CREATE TYPE interval_unit AS ENUM ('minutes', 'hours', 'days', 'weeks');

CREATE INDEX idx_calendar_alarms_event ON calendar_alarms(event_id);
CREATE INDEX idx_calendar_alarms_pending ON calendar_alarms(triggered)
    WHERE triggered = false;

calendar_sync_tokens (Tokens de Sincronización)

-- Tokens OAuth para sincronización
CREATE TABLE calendar_sync_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    user_id UUID NOT NULL REFERENCES users(id),
    provider sync_provider_type NOT NULL,

    -- Tokens (encriptados)
    access_token TEXT NOT NULL,
    refresh_token TEXT,
    token_type VARCHAR(50) DEFAULT 'Bearer',

    -- Expiración
    expires_at TIMESTAMPTZ,

    -- Sync tracking
    sync_token VARCHAR(255), -- Token incremental de Google/Microsoft
    last_sync_at TIMESTAMPTZ,
    sync_status sync_status DEFAULT 'active',

    -- Configuración
    sync_enabled BOOLEAN NOT NULL DEFAULT true,
    sync_direction sync_direction NOT NULL DEFAULT 'both',

    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    UNIQUE(user_id, provider)
);

CREATE TYPE sync_status AS ENUM ('active', 'paused', 'error', 'disconnected');
CREATE TYPE sync_direction AS ENUM ('import', 'export', 'both');

appointment_types (Tipos de Cita)

-- Tipos de citas para agendamiento online
CREATE TABLE appointment_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Información
    name VARCHAR(255) NOT NULL,
    description TEXT,

    -- Duración
    duration_minutes INTEGER NOT NULL DEFAULT 30,

    -- Ventana de agendamiento
    schedule_range_days INTEGER DEFAULT 30, -- Hasta X días en el futuro
    min_booking_hours INTEGER DEFAULT 24, -- Mínimo horas antes

    -- Horarios de disponibilidad (JSON)
    availability JSONB NOT NULL DEFAULT '{}',
    -- Estructura: { "monday": [{"from": "09:00", "to": "12:00"}, {"from": "14:00", "to": "18:00"}], ... }

    -- Asignados
    assignment_mode assignment_mode NOT NULL DEFAULT 'auto',
    -- auto: Sistema asigna automáticamente
    -- choice: Cliente elige

    -- Opciones
    allow_reschedule BOOLEAN NOT NULL DEFAULT true,
    allow_cancel BOOLEAN NOT NULL DEFAULT true,
    cancel_hours_before INTEGER DEFAULT 24,

    -- Confirmación
    confirmation_email BOOLEAN NOT NULL DEFAULT true,
    reminder_email BOOLEAN NOT NULL DEFAULT true,
    reminder_hours_before INTEGER DEFAULT 24,

    -- Integración CRM
    create_opportunity BOOLEAN NOT NULL DEFAULT false,

    -- Publicación
    is_published BOOLEAN NOT NULL DEFAULT false,
    slug VARCHAR(100) UNIQUE, -- URL amigable

    -- Estado
    active BOOLEAN NOT NULL DEFAULT true,
    company_id UUID NOT NULL REFERENCES companies(id),

    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TYPE assignment_mode AS ENUM ('auto', 'choice');

-- Usuarios asignados a tipo de cita
CREATE TABLE appointment_type_users (
    appointment_type_id UUID NOT NULL REFERENCES appointment_types(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id),
    PRIMARY KEY (appointment_type_id, user_id)
);

Parte 3: Servicios de Aplicación

3.1 CalendarService

// src/modules/calendar/services/calendar.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, Between, LessThanOrEqual } from 'typeorm';
import { CalendarEvent, EventPrivacy } from '../entities/calendar-event.entity';
import { CalendarAttendee, AttendeeState } from '../entities/calendar-attendee.entity';
import { CalendarRecurrence } from '../entities/calendar-recurrence.entity';

@Injectable()
export class CalendarService {
  constructor(
    @InjectRepository(CalendarEvent)
    private readonly eventRepo: Repository<CalendarEvent>,
    @InjectRepository(CalendarAttendee)
    private readonly attendeeRepo: Repository<CalendarAttendee>,
    @InjectRepository(CalendarRecurrence)
    private readonly recurrenceRepo: Repository<CalendarRecurrence>,
    private readonly dataSource: DataSource,
    private readonly emailService: EmailService,
  ) {}

  /**
   * Crear evento de calendario
   */
  async createEvent(
    dto: CreateCalendarEventDto,
    userId: string
  ): Promise<CalendarEvent> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Crear recurrencia si aplica
      let recurrence: CalendarRecurrence | null = null;
      if (dto.recurring && dto.recurrenceConfig) {
        recurrence = await queryRunner.manager.save(
          this.recurrenceRepo.create({
            rruleType: dto.recurrenceConfig.rruleType,
            interval: dto.recurrenceConfig.interval,
            endType: dto.recurrenceConfig.endType,
            count: dto.recurrenceConfig.count,
            until: dto.recurrenceConfig.until,
            weekdays: dto.recurrenceConfig.weekdays,
            monthBy: dto.recurrenceConfig.monthBy,
            dayOfMonth: dto.recurrenceConfig.dayOfMonth,
          })
        );
      }

      // Crear evento
      const event = await queryRunner.manager.save(
        this.eventRepo.create({
          name: dto.name,
          description: dto.description,
          location: dto.location,
          startDatetime: dto.startDatetime,
          endDatetime: dto.endDatetime,
          allDay: dto.allDay || false,
          timezone: dto.timezone || 'America/Mexico_City',
          organizerId: userId,
          privacy: dto.privacy || EventPrivacy.PUBLIC,
          showAs: dto.showAs || 'busy',
          recurring: !!recurrence,
          recurrenceId: recurrence?.id,
          resModel: dto.resModel,
          resId: dto.resId,
          companyId: dto.companyId,
          createdBy: userId,
          updatedBy: userId,
        })
      );

      // Vincular recurrencia con evento base
      if (recurrence) {
        await queryRunner.manager.update(CalendarRecurrence,
          { id: recurrence.id },
          { baseEventId: event.id }
        );
      }

      // Agregar organizador como asistente
      await this.addAttendee(queryRunner, event.id, {
        userId,
        role: 'chair',
        state: AttendeeState.ACCEPTED,
      });

      // Agregar otros asistentes
      if (dto.attendees?.length > 0) {
        for (const attendee of dto.attendees) {
          await this.addAttendee(queryRunner, event.id, attendee);
        }
      }

      // Agregar alarmas
      if (dto.alarms?.length > 0) {
        for (const alarm of dto.alarms) {
          await queryRunner.manager.save({
            eventId: event.id,
            alarmType: alarm.type,
            intervalValue: alarm.intervalValue,
            intervalUnit: alarm.intervalUnit,
          });
        }
      }

      await queryRunner.commitTransaction();

      // Enviar invitaciones
      await this.sendInvitations(event.id);

      return this.findOneWithRelations(event.id);
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  /**
   * Agregar asistente a evento
   */
  private async addAttendee(
    queryRunner: any,
    eventId: string,
    dto: AddAttendeeDto
  ): Promise<CalendarAttendee> {
    let email: string;
    let displayName: string;

    if (dto.userId) {
      const user = await this.userRepo.findOne({ where: { id: dto.userId } });
      email = user.email;
      displayName = user.name;
    } else if (dto.partnerId) {
      const partner = await this.partnerRepo.findOne({ where: { id: dto.partnerId } });
      email = partner.email;
      displayName = partner.name;
    } else {
      email = dto.email;
      displayName = dto.displayName || dto.email;
    }

    return queryRunner.manager.save(
      this.attendeeRepo.create({
        eventId,
        userId: dto.userId,
        partnerId: dto.partnerId,
        email,
        displayName,
        role: dto.role || 'req_participant',
        state: dto.state || AttendeeState.NEEDS_ACTION,
      })
    );
  }

  /**
   * Obtener eventos en rango de fechas
   */
  async getEventsInRange(
    userId: string,
    dateFrom: Date,
    dateTo: Date,
    options?: EventQueryOptions
  ): Promise<CalendarEvent[]> {
    const qb = this.eventRepo.createQueryBuilder('e')
      .leftJoinAndSelect('e.attendees', 'a')
      .leftJoinAndSelect('e.alarms', 'al')
      .leftJoinAndSelect('e.recurrence', 'r')
      .where('e.active = :active', { active: true })
      .andWhere('e.start_datetime < :dateTo', { dateTo })
      .andWhere('e.end_datetime > :dateFrom', { dateFrom });

    // Filtrar por usuario (organizador o asistente)
    qb.andWhere(
      '(e.organizer_id = :userId OR a.user_id = :userId)',
      { userId }
    );

    // Filtrar por privacidad
    if (!options?.includePrivate) {
      qb.andWhere('e.privacy != :private', { private: 'confidential' });
    }

    const events = await qb.getMany();

    // Expandir eventos recurrentes
    return this.expandRecurringEvents(events, dateFrom, dateTo);
  }

  /**
   * Expandir eventos recurrentes en instancias virtuales
   */
  private expandRecurringEvents(
    events: CalendarEvent[],
    dateFrom: Date,
    dateTo: Date
  ): CalendarEvent[] {
    const expanded: CalendarEvent[] = [];

    for (const event of events) {
      if (!event.recurring || !event.recurrence) {
        expanded.push(event);
        continue;
      }

      // Generar instancias virtuales
      const instances = this.generateRecurrenceInstances(
        event,
        event.recurrence,
        dateFrom,
        dateTo
      );

      expanded.push(...instances);
    }

    return expanded.sort((a, b) =>
      a.startDatetime.getTime() - b.startDatetime.getTime()
    );
  }

  /**
   * Generar instancias de evento recurrente
   */
  private generateRecurrenceInstances(
    baseEvent: CalendarEvent,
    recurrence: CalendarRecurrence,
    dateFrom: Date,
    dateTo: Date
  ): CalendarEvent[] {
    const instances: CalendarEvent[] = [];
    const duration = baseEvent.endDatetime.getTime() - baseEvent.startDatetime.getTime();

    let current = new Date(baseEvent.startDatetime);
    let count = 0;
    const maxIterations = 365; // Límite de seguridad

    while (current < dateTo && count < maxIterations) {
      // Verificar condiciones de fin
      if (recurrence.endType === 'count' && count >= recurrence.count) break;
      if (recurrence.endType === 'until' && current > recurrence.until) break;

      // Si está en el rango, crear instancia virtual
      if (current >= dateFrom) {
        instances.push({
          ...baseEvent,
          id: `${baseEvent.id}_${current.toISOString()}`, // ID virtual
          startDatetime: new Date(current),
          endDatetime: new Date(current.getTime() + duration),
          isVirtualInstance: true,
          baseEventId: baseEvent.id,
        } as CalendarEvent);
      }

      // Avanzar a siguiente ocurrencia
      current = this.getNextOccurrence(current, recurrence);
      count++;
    }

    return instances;
  }

  /**
   * Enviar invitaciones a asistentes
   */
  async sendInvitations(eventId: string): Promise<void> {
    const event = await this.findOneWithRelations(eventId);

    for (const attendee of event.attendees) {
      if (attendee.invitationSent) continue;
      if (attendee.role === 'chair') continue; // No enviar al organizador

      await this.emailService.sendTemplate('calendar_invitation', {
        to: attendee.email,
        context: {
          event,
          attendee,
          acceptUrl: this.generateResponseUrl(eventId, attendee.id, 'accept'),
          declineUrl: this.generateResponseUrl(eventId, attendee.id, 'decline'),
          tentativeUrl: this.generateResponseUrl(eventId, attendee.id, 'tentative'),
        },
      });

      await this.attendeeRepo.update(attendee.id, {
        invitationSent: true,
        invitationSentAt: new Date(),
      });
    }
  }

  /**
   * Responder a invitación
   */
  async respondToInvitation(
    eventId: string,
    attendeeId: string,
    response: AttendeeState
  ): Promise<void> {
    await this.attendeeRepo.update(attendeeId, { state: response });

    // Notificar al organizador
    const event = await this.eventRepo.findOne({
      where: { id: eventId },
      relations: ['organizer']
    });

    const attendee = await this.attendeeRepo.findOne({
      where: { id: attendeeId }
    });

    await this.emailService.sendTemplate('calendar_response', {
      to: event.organizer.email,
      context: {
        event,
        attendee,
        response,
      },
    });
  }
}

3.2 GoogleCalendarSyncService

// src/modules/calendar/services/google-calendar-sync.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { google, calendar_v3 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { CalendarSyncToken } from '../entities/calendar-sync-token.entity';

@Injectable()
export class GoogleCalendarSyncService {
  private readonly logger = new Logger(GoogleCalendarSyncService.name);

  constructor(
    @InjectRepository(CalendarSyncToken)
    private readonly tokenRepo: Repository<CalendarSyncToken>,
    private readonly calendarService: CalendarService,
    private readonly configService: ConfigService,
  ) {}

  /**
   * Obtener URL de autorización OAuth
   */
  getAuthUrl(userId: string, redirectUri: string): string {
    const oauth2Client = this.createOAuthClient(redirectUri);

    return oauth2Client.generateAuthUrl({
      access_type: 'offline',
      scope: [
        'https://www.googleapis.com/auth/calendar',
        'https://www.googleapis.com/auth/calendar.events',
      ],
      state: userId, // Para identificar usuario al retornar
      prompt: 'consent', // Forzar refresh_token
    });
  }

  /**
   * Intercambiar código por tokens
   */
  async handleCallback(
    code: string,
    userId: string,
    redirectUri: string
  ): Promise<void> {
    const oauth2Client = this.createOAuthClient(redirectUri);
    const { tokens } = await oauth2Client.getToken(code);

    // Guardar tokens
    await this.tokenRepo.upsert({
      userId,
      provider: 'google',
      accessToken: this.encrypt(tokens.access_token),
      refreshToken: tokens.refresh_token ? this.encrypt(tokens.refresh_token) : null,
      expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
      syncEnabled: true,
      syncStatus: 'active',
    }, ['userId', 'provider']);

    // Iniciar sincronización inicial
    await this.performFullSync(userId);
  }

  /**
   * Sincronización completa
   */
  async performFullSync(userId: string): Promise<SyncResult> {
    const token = await this.getValidToken(userId);
    if (!token) throw new Error('No hay token válido');

    const oauth2Client = this.createOAuthClient();
    oauth2Client.setCredentials({
      access_token: this.decrypt(token.accessToken),
      refresh_token: token.refreshToken ? this.decrypt(token.refreshToken) : null,
    });

    const calendar = google.calendar({ version: 'v3', auth: oauth2Client });

    const result: SyncResult = {
      imported: 0,
      exported: 0,
      updated: 0,
      errors: [],
    };

    try {
      // 1. Importar eventos de Google
      await this.importFromGoogle(calendar, userId, result);

      // 2. Exportar eventos locales a Google
      await this.exportToGoogle(calendar, userId, result);

      // Actualizar último sync
      await this.tokenRepo.update(
        { userId, provider: 'google' },
        { lastSyncAt: new Date(), syncStatus: 'active' }
      );

    } catch (error) {
      this.logger.error(`Error en sync para usuario ${userId}:`, error);
      result.errors.push(error.message);

      await this.tokenRepo.update(
        { userId, provider: 'google' },
        { syncStatus: 'error' }
      );
    }

    return result;
  }

  /**
   * Importar eventos de Google Calendar
   */
  private async importFromGoogle(
    calendar: calendar_v3.Calendar,
    userId: string,
    result: SyncResult
  ): Promise<void> {
    const token = await this.tokenRepo.findOne({
      where: { userId, provider: 'google' }
    });

    // Usar syncToken para sync incremental
    const params: calendar_v3.Params$Resource$Events$List = {
      calendarId: 'primary',
      maxResults: 100,
      singleEvents: false, // Obtener eventos recurrentes como serie
    };

    if (token.syncToken) {
      params.syncToken = token.syncToken;
    } else {
      // Primera sync: últimos 30 días + próximos 90 días
      const now = new Date();
      params.timeMin = new Date(now.setDate(now.getDate() - 30)).toISOString();
      params.timeMax = new Date(now.setDate(now.getDate() + 120)).toISOString();
    }

    let pageToken: string | undefined;

    do {
      const response = await calendar.events.list({
        ...params,
        pageToken,
      });

      for (const gEvent of response.data.items || []) {
        try {
          await this.importGoogleEvent(gEvent, userId);
          result.imported++;
        } catch (error) {
          result.errors.push(`Error importando ${gEvent.id}: ${error.message}`);
        }
      }

      pageToken = response.data.nextPageToken;

      // Guardar nuevo syncToken
      if (response.data.nextSyncToken) {
        await this.tokenRepo.update(
          { userId, provider: 'google' },
          { syncToken: response.data.nextSyncToken }
        );
      }

    } while (pageToken);
  }

  /**
   * Importar evento individual de Google
   */
  private async importGoogleEvent(
    gEvent: calendar_v3.Schema$Event,
    userId: string
  ): Promise<void> {
    // Buscar si ya existe
    const existing = await this.calendarService.findByExternalId(
      gEvent.id,
      'google'
    );

    const eventData = this.mapGoogleEventToLocal(gEvent, userId);

    if (existing) {
      // Comparar hash para detectar cambios
      const newHash = this.calculateHash(eventData);
      if (existing.syncHash !== newHash) {
        await this.calendarService.updateEvent(existing.id, eventData, userId);
      }
    } else {
      await this.calendarService.createEvent({
        ...eventData,
        externalId: gEvent.id,
        syncProvider: 'google',
      }, userId);
    }
  }

  /**
   * Exportar eventos locales a Google
   */
  private async exportToGoogle(
    calendar: calendar_v3.Calendar,
    userId: string,
    result: SyncResult
  ): Promise<void> {
    // Obtener eventos sin external_id o modificados después de último sync
    const localEvents = await this.calendarService.getEventsForExport(userId, 'google');

    for (const event of localEvents) {
      try {
        const gEvent = this.mapLocalEventToGoogle(event);

        if (event.externalId) {
          // Actualizar existente
          await calendar.events.update({
            calendarId: 'primary',
            eventId: event.externalId,
            requestBody: gEvent,
          });
          result.updated++;
        } else {
          // Crear nuevo
          const response = await calendar.events.insert({
            calendarId: 'primary',
            requestBody: gEvent,
            sendNotifications: true,
          });

          // Guardar external_id
          await this.calendarService.updateEvent(event.id, {
            externalId: response.data.id,
            syncProvider: 'google',
            lastSyncedAt: new Date(),
          }, userId);

          result.exported++;
        }
      } catch (error) {
        result.errors.push(`Error exportando ${event.id}: ${error.message}`);
      }
    }
  }

  /**
   * Mapear evento de Google a formato local
   */
  private mapGoogleEventToLocal(
    gEvent: calendar_v3.Schema$Event,
    userId: string
  ): Partial<CreateCalendarEventDto> {
    const isAllDay = !!gEvent.start?.date;

    return {
      name: gEvent.summary || 'Sin título',
      description: gEvent.description,
      location: gEvent.location,
      startDatetime: new Date(gEvent.start?.dateTime || gEvent.start?.date),
      endDatetime: new Date(gEvent.end?.dateTime || gEvent.end?.date),
      allDay: isAllDay,
      timezone: gEvent.start?.timeZone,
      privacy: this.mapGoogleVisibility(gEvent.visibility),
      showAs: gEvent.transparency === 'transparent' ? 'free' : 'busy',
      attendees: gEvent.attendees?.map(a => ({
        email: a.email,
        displayName: a.displayName,
        state: this.mapGoogleResponseStatus(a.responseStatus),
        role: a.organizer ? 'chair' : 'req_participant',
      })),
    };
  }

  /**
   * Mapear evento local a formato Google
   */
  private mapLocalEventToGoogle(
    event: CalendarEvent
  ): calendar_v3.Schema$Event {
    const gEvent: calendar_v3.Schema$Event = {
      summary: event.name,
      description: event.description,
      location: event.location,
      visibility: this.mapLocalPrivacy(event.privacy),
      transparency: event.showAs === 'free' ? 'transparent' : 'opaque',
    };

    if (event.allDay) {
      gEvent.start = { date: event.startDatetime.toISOString().split('T')[0] };
      gEvent.end = { date: event.endDatetime.toISOString().split('T')[0] };
    } else {
      gEvent.start = {
        dateTime: event.startDatetime.toISOString(),
        timeZone: event.timezone,
      };
      gEvent.end = {
        dateTime: event.endDatetime.toISOString(),
        timeZone: event.timezone,
      };
    }

    if (event.attendees?.length > 0) {
      gEvent.attendees = event.attendees.map(a => ({
        email: a.email,
        displayName: a.displayName,
        responseStatus: this.mapLocalResponseStatus(a.state),
      }));
    }

    return gEvent;
  }

  /**
   * Crear cliente OAuth
   */
  private createOAuthClient(redirectUri?: string): OAuth2Client {
    return new google.auth.OAuth2(
      this.configService.get('GOOGLE_CLIENT_ID'),
      this.configService.get('GOOGLE_CLIENT_SECRET'),
      redirectUri || this.configService.get('GOOGLE_REDIRECT_URI'),
    );
  }
}

3.3 AppointmentService

// src/modules/calendar/services/appointment.service.ts

import { Injectable } from '@nestjs/common';
import { AppointmentType } from '../entities/appointment-type.entity';

@Injectable()
export class AppointmentService {
  constructor(
    @InjectRepository(AppointmentType)
    private readonly appointmentTypeRepo: Repository<AppointmentType>,
    private readonly calendarService: CalendarService,
  ) {}

  /**
   * Obtener slots disponibles para tipo de cita
   */
  async getAvailableSlots(
    appointmentTypeId: string,
    dateFrom: Date,
    dateTo: Date
  ): Promise<AvailableSlot[]> {
    const appointmentType = await this.appointmentTypeRepo.findOne({
      where: { id: appointmentTypeId },
      relations: ['users'],
    });

    if (!appointmentType || !appointmentType.isPublished) {
      throw new NotFoundException('Tipo de cita no encontrado');
    }

    // Validar rango de fechas
    const now = new Date();
    const maxDate = new Date();
    maxDate.setDate(maxDate.getDate() + appointmentType.scheduleRangeDays);

    if (dateFrom < now || dateTo > maxDate) {
      throw new BadRequestException('Rango de fechas fuera de límites permitidos');
    }

    const slots: AvailableSlot[] = [];
    const duration = appointmentType.durationMinutes;
    const availability = appointmentType.availability as WeekAvailability;

    // Iterar por cada día en el rango
    let current = new Date(dateFrom);
    while (current <= dateTo) {
      const dayOfWeek = this.getDayName(current.getDay());
      const daySlots = availability[dayOfWeek];

      if (daySlots?.length > 0) {
        // Obtener eventos existentes de usuarios asignados
        const busySlots = await this.getBusySlots(
          appointmentType.users.map(u => u.id),
          current
        );

        // Generar slots disponibles
        for (const period of daySlots) {
          const periodSlots = this.generateSlotsForPeriod(
            current,
            period.from,
            period.to,
            duration,
            busySlots
          );
          slots.push(...periodSlots);
        }
      }

      current.setDate(current.getDate() + 1);
    }

    return slots.filter(slot => {
      // Filtrar slots que no cumplen tiempo mínimo de anticipación
      const hoursUntil = (slot.start.getTime() - now.getTime()) / (1000 * 60 * 60);
      return hoursUntil >= appointmentType.minBookingHours;
    });
  }

  /**
   * Reservar cita
   */
  async bookAppointment(
    appointmentTypeId: string,
    dto: BookAppointmentDto
  ): Promise<CalendarEvent> {
    const appointmentType = await this.appointmentTypeRepo.findOne({
      where: { id: appointmentTypeId, isPublished: true },
      relations: ['users'],
    });

    if (!appointmentType) {
      throw new NotFoundException('Tipo de cita no disponible');
    }

    // Verificar que el slot sigue disponible
    const isAvailable = await this.verifySlotAvailable(
      appointmentType,
      dto.startDatetime
    );

    if (!isAvailable) {
      throw new ConflictException('El horario seleccionado ya no está disponible');
    }

    // Asignar usuario
    let assigneeId: string;
    if (appointmentType.assignmentMode === 'auto') {
      assigneeId = await this.autoAssignUser(appointmentType, dto.startDatetime);
    } else {
      assigneeId = dto.assigneeId;
    }

    // Calcular fecha fin
    const endDatetime = new Date(dto.startDatetime);
    endDatetime.setMinutes(endDatetime.getMinutes() + appointmentType.durationMinutes);

    // Crear evento
    const event = await this.calendarService.createEvent({
      name: `${appointmentType.name} - ${dto.customerName}`,
      description: dto.notes,
      startDatetime: dto.startDatetime,
      endDatetime,
      organizerId: assigneeId,
      attendees: [{
        email: dto.customerEmail,
        displayName: dto.customerName,
        role: 'req_participant',
      }],
      resModel: 'appointment.booking',
      companyId: appointmentType.companyId,
    }, assigneeId);

    // Crear oportunidad CRM si está configurado
    if (appointmentType.createOpportunity) {
      await this.crmService.createLead({
        name: `Cita: ${appointmentType.name}`,
        contactName: dto.customerName,
        email: dto.customerEmail,
        phone: dto.customerPhone,
        sourceId: 'appointment',
        calendarEventId: event.id,
      });
    }

    // Enviar confirmación
    if (appointmentType.confirmationEmail) {
      await this.emailService.sendTemplate('appointment_confirmation', {
        to: dto.customerEmail,
        context: {
          appointmentType,
          event,
          customer: dto,
        },
      });
    }

    return event;
  }

  /**
   * Auto-asignar usuario con menos carga
   */
  private async autoAssignUser(
    appointmentType: AppointmentType,
    datetime: Date
  ): Promise<string> {
    const userIds = appointmentType.users.map(u => u.id);

    // Contar eventos de cada usuario para el día
    const counts = await this.calendarService.countEventsByUsers(userIds, datetime);

    // Seleccionar el que tiene menos eventos
    const sorted = [...counts].sort((a, b) => a.count - b.count);
    return sorted[0].userId;
  }

  /**
   * Generar slots para un período
   */
  private generateSlotsForPeriod(
    date: Date,
    from: string,
    to: string,
    durationMinutes: number,
    busySlots: TimeRange[]
  ): AvailableSlot[] {
    const slots: AvailableSlot[] = [];
    const [fromHour, fromMin] = from.split(':').map(Number);
    const [toHour, toMin] = to.split(':').map(Number);

    let current = new Date(date);
    current.setHours(fromHour, fromMin, 0, 0);

    const endTime = new Date(date);
    endTime.setHours(toHour, toMin, 0, 0);

    while (current < endTime) {
      const slotEnd = new Date(current);
      slotEnd.setMinutes(slotEnd.getMinutes() + durationMinutes);

      if (slotEnd > endTime) break;

      // Verificar si no colisiona con eventos existentes
      const isBusy = busySlots.some(busy =>
        (current >= busy.start && current < busy.end) ||
        (slotEnd > busy.start && slotEnd <= busy.end)
      );

      if (!isBusy) {
        slots.push({
          start: new Date(current),
          end: new Date(slotEnd),
          available: true,
        });
      }

      // Siguiente slot
      current.setMinutes(current.getMinutes() + durationMinutes);
    }

    return slots;
  }
}

Parte 4: API REST

4.1 Endpoints de Calendario

// src/modules/calendar/controllers/calendar.controller.ts

@Controller('api/v1/calendar')
@UseGuards(JwtAuthGuard)
@ApiTags('Calendar')
export class CalendarController {
  @Post('events')
  @ApiOperation({ summary: 'Crear evento' })
  async createEvent(
    @Body() dto: CreateCalendarEventDto,
    @CurrentUser() user: User,
  ): Promise<CalendarEventResponseDto> {
    return this.calendarService.createEvent(dto, user.id);
  }

  @Get('events')
  @ApiOperation({ summary: 'Listar eventos en rango' })
  async getEvents(
    @Query('from') from: string,
    @Query('to') to: string,
    @CurrentUser() user: User,
  ): Promise<CalendarEventResponseDto[]> {
    return this.calendarService.getEventsInRange(
      user.id,
      new Date(from),
      new Date(to)
    );
  }

  @Get('events/:id')
  @ApiOperation({ summary: 'Obtener evento' })
  async getEvent(
    @Param('id') id: string,
  ): Promise<CalendarEventResponseDto> {
    return this.calendarService.findOne(id);
  }

  @Patch('events/:id')
  @ApiOperation({ summary: 'Actualizar evento' })
  async updateEvent(
    @Param('id') id: string,
    @Body() dto: UpdateCalendarEventDto,
    @CurrentUser() user: User,
  ): Promise<CalendarEventResponseDto> {
    return this.calendarService.updateEvent(id, dto, user.id);
  }

  @Delete('events/:id')
  @ApiOperation({ summary: 'Eliminar evento' })
  async deleteEvent(
    @Param('id') id: string,
    @Query('notify') notify: boolean = true,
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.calendarService.deleteEvent(id, user.id, notify);
  }

  @Post('events/:id/respond')
  @ApiOperation({ summary: 'Responder a invitación' })
  async respondToInvitation(
    @Param('id') id: string,
    @Body() dto: RespondInvitationDto,
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.calendarService.respondToInvitation(id, user.id, dto.response);
  }

  // === SINCRONIZACIÓN ===

  @Get('sync/google/auth-url')
  @ApiOperation({ summary: 'Obtener URL de autorización Google' })
  async getGoogleAuthUrl(
    @CurrentUser() user: User,
  ): Promise<{ url: string }> {
    const url = this.googleCalendarService.getAuthUrl(user.id);
    return { url };
  }

  @Post('sync/google/callback')
  @ApiOperation({ summary: 'Callback OAuth Google' })
  async handleGoogleCallback(
    @Body() dto: OAuthCallbackDto,
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.googleCalendarService.handleCallback(dto.code, user.id);
  }

  @Post('sync/google/sync')
  @ApiOperation({ summary: 'Sincronizar con Google Calendar' })
  async syncGoogle(
    @CurrentUser() user: User,
  ): Promise<SyncResultDto> {
    return this.googleCalendarService.performFullSync(user.id);
  }

  @Post('sync/google/disconnect')
  @ApiOperation({ summary: 'Desconectar Google Calendar' })
  async disconnectGoogle(
    @CurrentUser() user: User,
  ): Promise<void> {
    return this.googleCalendarService.disconnect(user.id);
  }

  // === CITAS ===

  @Get('appointments/types')
  @Public() // Endpoint público
  @ApiOperation({ summary: 'Listar tipos de cita publicados' })
  async getAppointmentTypes(
    @Query('companySlug') companySlug: string,
  ): Promise<AppointmentTypeResponseDto[]> {
    return this.appointmentService.getPublishedTypes(companySlug);
  }

  @Get('appointments/types/:id/slots')
  @Public()
  @ApiOperation({ summary: 'Obtener slots disponibles' })
  async getAvailableSlots(
    @Param('id') id: string,
    @Query('from') from: string,
    @Query('to') to: string,
  ): Promise<AvailableSlotDto[]> {
    return this.appointmentService.getAvailableSlots(
      id,
      new Date(from),
      new Date(to)
    );
  }

  @Post('appointments/book')
  @Public()
  @ApiOperation({ summary: 'Reservar cita' })
  async bookAppointment(
    @Body() dto: BookAppointmentDto,
  ): Promise<BookingConfirmationDto> {
    return this.appointmentService.bookAppointment(dto.appointmentTypeId, dto);
  }
}

Parte 5: Webhooks y Notificaciones

5.1 Webhook Handler para Google

// src/modules/calendar/controllers/webhook.controller.ts

@Controller('api/v1/calendar/webhooks')
export class CalendarWebhookController {
  @Post('google')
  @ApiOperation({ summary: 'Webhook Google Calendar' })
  async handleGoogleWebhook(
    @Headers('X-Goog-Channel-ID') channelId: string,
    @Headers('X-Goog-Resource-State') resourceState: string,
  ): Promise<void> {
    if (resourceState === 'sync') {
      // Sync message - ignorar
      return;
    }

    // Buscar usuario por channel
    const token = await this.tokenRepo.findOne({
      where: { googleChannelId: channelId }
    });

    if (!token) {
      this.logger.warn(`Channel no encontrado: ${channelId}`);
      return;
    }

    // Triggear sync incremental
    await this.googleCalendarService.performIncrementalSync(token.userId);
  }
}

5.2 AlarmService (Recordatorios)

// src/modules/calendar/services/alarm.service.ts

@Injectable()
export class AlarmService {
  @Cron(CronExpression.EVERY_MINUTE)
  async processAlarms(): Promise<void> {
    const now = new Date();

    // Buscar alarmas pendientes
    const pendingAlarms = await this.alarmRepo
      .createQueryBuilder('a')
      .innerJoinAndSelect('a.event', 'e')
      .where('a.triggered = :triggered', { triggered: false })
      .andWhere(`
        e.start_datetime - (a.interval_value * INTERVAL '1' || a.interval_unit) <= :now
      `, { now })
      .getMany();

    for (const alarm of pendingAlarms) {
      await this.triggerAlarm(alarm);
    }
  }

  private async triggerAlarm(alarm: CalendarAlarm): Promise<void> {
    const event = alarm.event;

    switch (alarm.alarmType) {
      case 'email':
        await this.emailService.sendTemplate('calendar_reminder', {
          to: event.organizer.email,
          context: { event, alarm },
        });
        break;

      case 'notification':
        await this.notificationService.send({
          userId: event.organizerId,
          title: 'Recordatorio de evento',
          body: `${event.name} comienza en ${alarm.intervalValue} ${alarm.intervalUnit}`,
          link: `/calendar/events/${event.id}`,
        });
        break;

      case 'sms':
        // Implementar si hay servicio SMS
        break;
    }

    // Marcar como disparada
    await this.alarmRepo.update(alarm.id, {
      triggered: true,
      triggeredAt: new Date(),
    });
  }
}

Parte 6: Configuración OAuth

6.1 Variables de Entorno

# Google Calendar
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=https://erp.example.com/api/v1/calendar/sync/google/callback

# Microsoft/Outlook
MICROSOFT_CLIENT_ID=your-azure-app-id
MICROSOFT_CLIENT_SECRET=your-azure-client-secret
MICROSOFT_REDIRECT_URI=https://erp.example.com/api/v1/calendar/sync/microsoft/callback
MICROSOFT_TENANT_ID=common

6.2 Guía de Configuración

## Configuración Google Calendar

1. Ir a Google Cloud Console (console.cloud.google.com)
2. Crear proyecto o seleccionar existente
3. Habilitar Google Calendar API
4. Configurar pantalla de consentimiento OAuth:
   - Tipo: Externo
   - Nombre: ERP Core Calendar
   - Agregar scopes: calendar, calendar.events
5. Crear credenciales OAuth 2.0:
   - Tipo: Aplicación web
   - URIs de redirección autorizados: [GOOGLE_REDIRECT_URI]
6. Copiar Client ID y Client Secret

## Configuración Microsoft/Outlook

1. Ir a Azure Portal (portal.azure.com)
2. Azure Active Directory → App registrations → New
3. Nombre: ERP Core Calendar
4. Tipo de cuenta: Cuentas en cualquier directorio
5. URI de redirección: [MICROSOFT_REDIRECT_URI]
6. Agregar permisos API:
   - Microsoft Graph → Delegated: Calendars.ReadWrite
7. Crear Client Secret (máximo 24 meses)
8. Copiar Application ID y Client Secret

Apéndice A: Formato iCalendar (ICS)

// Generar archivo ICS para exportar
function generateICS(event: CalendarEvent): string {
  return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//ERP Core//Calendar//EN
BEGIN:VEVENT
UID:${event.id}@erp-core
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(event.startDatetime)}
DTEND:${formatICSDate(event.endDatetime)}
SUMMARY:${escapeICS(event.name)}
DESCRIPTION:${escapeICS(event.description || '')}
LOCATION:${escapeICS(event.location || '')}
${event.attendees?.map(a => `ATTENDEE;CN=${a.displayName}:mailto:${a.email}`).join('\n')}
END:VEVENT
END:VCALENDAR`;
}

function formatICSDate(date: Date): string {
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}

Apéndice B: Checklist de Implementación

  • Modelo calendar_events y migraciones
  • Modelo calendar_attendees
  • Modelo calendar_recurrences
  • Modelo calendar_alarms
  • Modelo calendar_sync_tokens
  • Modelo appointment_types
  • CalendarService (CRUD eventos)
  • Expansión de eventos recurrentes
  • GoogleCalendarSyncService
  • MicrosoftCalendarSyncService
  • AppointmentService
  • AlarmService (cron)
  • API REST endpoints
  • Webhooks handlers
  • Email templates (invitación, recordatorio)
  • UI: Vista de calendario
  • UI: Formulario de evento
  • UI: Configuración de sync
  • UI: Portal de citas público
  • Tests unitarios
  • Tests de integración OAuth

Documento generado como parte del análisis de gaps ERP Core vs Odoo 18 Referencia: GAP-MGN-014-001 - Integración Calendar