53 KiB
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:
- Eventos de Calendario: Modelo core con recurrencia y asistentes
- Google Calendar Sync: Sincronización bidireccional OAuth 2.0
- Outlook/Microsoft Sync: Integración vía Microsoft Graph API
- Sistema de Citas: Agendamiento online con disponibilidad
- Actividades: Vinculación de activities con eventos de calendario
- 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