# 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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) ```sql -- 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 ```typescript // 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, @InjectRepository(CalendarAttendee) private readonly attendeeRepo: Repository, @InjectRepository(CalendarRecurrence) private readonly recurrenceRepo: Repository, private readonly dataSource: DataSource, private readonly emailService: EmailService, ) {} /** * Crear evento de calendario */ async createEvent( dto: CreateCalendarEventDto, userId: string ): Promise { 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 { 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 { 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 { 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 { 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 ```typescript // 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, 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 { 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 { 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 { 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 { // 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 { // 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 { 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 ```typescript // 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, private readonly calendarService: CalendarService, ) {} /** * Obtener slots disponibles para tipo de cita */ async getAvailableSlots( appointmentTypeId: string, dateFrom: Date, dateTo: Date ): Promise { 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 { 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 { 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 ```typescript // 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 { 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 { 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 { 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 { 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 { 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 { 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 { return this.googleCalendarService.handleCallback(dto.code, user.id); } @Post('sync/google/sync') @ApiOperation({ summary: 'Sincronizar con Google Calendar' }) async syncGoogle( @CurrentUser() user: User, ): Promise { return this.googleCalendarService.performFullSync(user.id); } @Post('sync/google/disconnect') @ApiOperation({ summary: 'Desconectar Google Calendar' }) async disconnectGoogle( @CurrentUser() user: User, ): Promise { 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 { 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 { 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 { return this.appointmentService.bookAppointment(dto.appointmentTypeId, dto); } } ``` --- ## Parte 5: Webhooks y Notificaciones ### 5.1 Webhook Handler para Google ```typescript // 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 { 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) ```typescript // src/modules/calendar/services/alarm.service.ts @Injectable() export class AlarmService { @Cron(CronExpression.EVERY_MINUTE) async processAlarms(): Promise { 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 { 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 ```env # 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 ```markdown ## 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) ```typescript // 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*