@lautmaler/crm-connectors
Version:
Provides connectors to various CRM systems and calendar services.
608 lines (527 loc) • 17.9 kB
text/typescript
import axios from "axios";
import winston from "winston";
import fs from "fs";
import { google, calendar_v3, meet_v2 } from "googleapis";
import { OAuth2Client } from "google-auth-library";
import {
Appointment,
CRMBackend,
FreeBusyResponse,
GoogleCalendarEvent,
} from "../crm.interfaces.js";
import { appointmentType, appointmentStatus } from "../crm.types.js";
// Environment variables
const GOOGLE_CALENDAR_OWNER = process.env.GOOGLE_CALENDAR_OWNER;
const SIPGATE_TOKEN_ID = process.env.SIPGATE_TOKEN_ID;
const SIPGATE_TOKEN = process.env.SIPGATE_TOKEN;
const GOOGLE_APPLICATION_CREDENTIALS = process.env.GOOGLE_APPLICATION_CREDENTIALS;
export class GoogleCalendarBackend implements CRMBackend {
// Constants
private readonly SIPGATE_BASE_URL = 'https://api.sipgate.com/v2';
private readonly DEFAULT_TIMEZONE = 'Europe/Berlin';
private readonly APPOINTMENT_DURATION_MS = 60 * 60 * 1000; // 1 hour in milliseconds
private readonly DEFAULT_DELEGATION_EMAIL = "konrad@lautmaler.de";
// Services
private logger: winston.Logger = winston.createLogger();
private googleAuth: OAuth2Client = new OAuth2Client();
private calendarService: calendar_v3.Calendar = google.calendar({ version: "v3" });
private meetService: meet_v2.Meet = google.meet({ version: "v2" });
// Configuration
private calendarOwnerId: string;
private userDelegationEmail: string;
private googleMeetUrl: string = "https://meet.google.com";
constructor() {
this.userDelegationEmail = this.DEFAULT_DELEGATION_EMAIL;
this.calendarOwnerId = GOOGLE_CALENDAR_OWNER || "";
this.googleAuth = new OAuth2Client();
// Initialize services
this.initializeLogger();
this.initializeGoogleServices();
}
/**
* Initialize the logger
*/
private initializeLogger(): void {
this.logger = winston.createLogger({
level: "info",
format: winston.format.json(),
defaultMeta: { service: "crm-service" },
transports: [
new winston.transports.Console({
format: winston.format.simple(),
}),
],
});
}
/**
* Initialize Google services if not in test environment
*/
private initializeGoogleServices(): void {
// Skip initialization in test environment
if (process.env.APP_ENV === "test") {
this.logger.info("Google services not initialized in test environment");
return;
}
try {
this.initializeGoogleAuth();
} catch (error) {
this.logger.error("Error initializing Google services:", error);
}
}
/**
* Authenticate with Google services
*/
private async initializeGoogleAuth(): Promise<void> {
const credentials = this.parseCredentials();
this.setupCalendarService(credentials);
this.setupMeetService(credentials);
}
/**
* Parse the Google service account credentials
*/
private parseCredentials(): { client_email: string; private_key: string } {
if (!GOOGLE_APPLICATION_CREDENTIALS) {
throw new Error("Service account key path is not specified.");
}
try {
const fileContents = fs.readFileSync(GOOGLE_APPLICATION_CREDENTIALS, "utf8");
const credentials = JSON.parse(fileContents);
if (!credentials.client_email || !credentials.private_key) {
throw new Error("Invalid credentials format: missing client_email or private_key");
}
return credentials;
} catch (error) {
this.logger.error("Error parsing credentials:", error);
throw new Error("Failed to parse service account credentials");
}
}
/**
* Set up the Google Calendar service
*/
private setupCalendarService(credentials: { client_email: string; private_key: string }): void {
const auth = new google.auth.JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
subject: this.userDelegationEmail,
});
this.googleAuth = auth;
this.calendarService = google.calendar({
version: "v3",
auth: this.googleAuth,
});
// Set up ACL rules
this.setupCalendarAcl();
this.logger.info("Google Calendar service initialized");
}
/**
* Set up ACL rules for calendar sharing
*/
private async setupCalendarAcl(): Promise<void> {
try {
const aclRules = [
{
role: "reader",
scope: {
type: "default",
},
},
];
for (const rule of aclRules) {
await this.calendarService.acl.insert({
calendarId: "primary",
requestBody: rule,
});
}
} catch (error) {
this.logger.error("Error setting up calendar ACL:", error);
}
}
/**
* Set up the Google Meet service
*/
private setupMeetService(credentials: { client_email: string; private_key: string }): void {
const meetServiceAuth = new google.auth.JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/meetings.space.created"
],
subject: this.userDelegationEmail,
});
this.meetService = google.meet({
version: "v2",
auth: meetServiceAuth,
});
}
/**
* Fetch available time slots based on calendar availability
*/
async fetchAvailableSlots(
timestamp: string,
attendees?: string[],
): Promise<string[]> {
const timeMin = new Date(timestamp).toISOString();
// set timeMax to 6 hours from the timestamp
const timeMax = new Date(
new Date(timestamp).getTime() + 6 * 60 * 60 * 1000
).toISOString();
if (!attendees || attendees.length === 0) {
attendees = [];
}
const payload = {
timeMin,
timeMax,
timeZone: this.DEFAULT_TIMEZONE,
items: [{ id: this.calendarOwnerId }].concat(
attendees.map((email) => ({ id: email })),
),
};
const busySlots = await this.freeBusyApiCall(payload);
const totalSlots = this.generateAvailableSlots(timeMin, timeMax, busySlots);
// return 3 slots
return totalSlots.slice(0, 3);
}
/**
* Call the FreeBusy API to get busy time slots
*/
private async freeBusyApiCall(payload: any): Promise<string[]> {
const freeBusyEndpoint = "https://www.googleapis.com/calendar/v3/freeBusy";
// Ensure we have an access token
const accessToken = await this.googleAuth.getAccessToken();
if (!accessToken) {
throw new Error("Failed to get access token");
}
// this.logger.info(`Access token: ${accessToken.token}`);
try {
const response = await axios.post(freeBusyEndpoint, payload, {
headers: {
Authorization: `Bearer ${accessToken.token}`,
"Content-Type": "application/json",
},
});
const freeBusyResponse = response.data as FreeBusyResponse;
this.logger.info("FreeBusy API response received successfully");
return freeBusyResponse.calendars[this.calendarOwnerId].busy as string[];
} catch (error: any) {
this.logger.error(
"Error fetching available slots:",
error.response?.data || error,
);
throw new Error("Failed to fetch available slots");
}
}
/**
* Generate available time slots based on busy times
*/
private generateAvailableSlots(timeMin: string, timeMax: string, busySlots: any[]): string[] {
const availableSlots: string[] = [];
let currentSlot = new Date(timeMin);
const endTime = new Date(timeMax);
while (currentSlot < endTime) {
const slotEnd = new Date(currentSlot.getTime() + this.APPOINTMENT_DURATION_MS);
const isSlotBusy = busySlots.some(
(busySlot) =>
busySlot.start <= slotEnd.toISOString() &&
busySlot.end >= currentSlot.toISOString(),
);
if (!isSlotBusy) {
availableSlots.push(currentSlot.toISOString());
}
currentSlot = slotEnd;
}
return availableSlots;
}
/**
* Fetch available appointment types
*/
async fetchAppointmentTypes(): Promise<string[]> {
return ["Consultation", "Meeting", "Demo"];
}
/**
* Book a new appointment
*/
async bookAppointment(appointment: Appointment): Promise<string> {
try {
return this.createEvent(appointment);
} catch (error) {
this.logger.error("Error booking appointment:", error);
throw new Error("Failed to book appointment");
}
}
/**
* Modify an existing appointment
*/
async modifyAppointment(
id: string,
updatedInfo: Partial<Appointment>,
): Promise<Appointment> {
// Fetch the original appointment
const originalAppointment = await this.findAppointmentById(id);
if (!originalAppointment) {
throw new Error("Appointment not found");
}
// Merge original and updated info
const updatedEmployeeEmail = updatedInfo.employeeEmail ?? originalAppointment.employeeEmail;
const updatedContactEmail = updatedInfo.contactEmail ?? originalAppointment.contactEmail;
const updatedTimestamp = updatedInfo.timestamp ?? originalAppointment.timestamp;
if (!updatedEmployeeEmail || !updatedContactEmail) {
throw new Error("Email cannot be undefined");
}
// Create updated event data
const requestBody: GoogleCalendarEvent = {
summary: "Meeting",
start: {
dateTime: new Date(updatedTimestamp).toISOString(),
timeZone: "UTC",
},
end: {
dateTime: new Date(
new Date(updatedTimestamp).getTime() + this.APPOINTMENT_DURATION_MS,
).toISOString(),
timeZone: "UTC",
},
attendees: [
{ email: updatedEmployeeEmail },
{ email: updatedContactEmail },
],
conferenceData: {
createRequest: {
requestId: updatedTimestamp,
conferenceSolutionKey: { type: "hangoutsMeet" },
},
},
};
// Update the event in Google Calendar
await this.calendarService.events.update({
calendarId: "primary",
eventId: id,
requestBody: requestBody,
});
return { ...originalAppointment, ...updatedInfo };
}
/**
* Find an appointment by contact name (email)
*/
async findAppointmentByContactName(name: string): Promise<Appointment> {
const response = await this.calendarService.events.list({
calendarId: "primary",
});
const events = response.data.items ?? [];
const appointments = events
.filter((event) =>
event.attendees?.find((attendee) => attendee.email === name),
)
.map((event) => this.mapEventToAppointment(event));
if (appointments.length === 0) {
throw new Error("No appointments found for this contact");
}
return appointments[0];
}
/**
* Find an appointment by timestamp
*/
async findAppointmentByTimestamp(timestamp: string): Promise<Appointment | null> {
throw new Error("Method not implemented.");
}
/**
* Find an appointment by ID
*/
async findAppointmentById(id: string): Promise<Appointment | null> {
try {
const response = await this.calendarService.events.get({
calendarId: "primary",
eventId: id,
});
const event = response.data;
return this.mapEventToAppointment(event);
} catch (error) {
this.logger.error("Error finding appointment by ID:", error);
return null;
}
}
/**
* Map a Google Calendar event to an Appointment object
*/
private mapEventToAppointment(event: any): Appointment {
if (!event.id || !event.start?.dateTime) {
throw new Error("Invalid event data");
}
const attendees = event.attendees || [];
if (attendees.length < 2) {
throw new Error("Event has insufficient attendees");
}
return {
id: event.id,
type: "Meeting" as appointmentType,
timestamp: event.start.dateTime,
contactEmail: attendees[0]?.email || "",
contactName: attendees[0]?.displayName || attendees[0]?.email || "",
employeeEmail: attendees[1]?.email || "",
status: "Scheduled" as appointmentStatus,
};
}
/**
* Create a Google Calendar event
*/
async createEvent(appointment: Appointment): Promise<string> {
return this.createConference(appointment);
}
/**
* Create a conference event with Google Meet
*/
private async createConference(appointment: Appointment): Promise<string> {
try {
let attendeeEmails = [{ email: appointment.employeeEmail }];
if (appointment.contactEmail) {
attendeeEmails.push({ email: appointment.contactEmail });
}
// Create the calendar event with conference data
const startTime = new Date(appointment.timestamp).toISOString();
const endTime = new Date(
new Date(appointment.timestamp).getTime() + this.APPOINTMENT_DURATION_MS
).toISOString();
// @ts-expect-error overload conflict
const conference: any = await this.calendarService.events.insert({
calendarId: "primary",
anyoneCanAddSelf: true,
visibility: "public",
description: this.googleMeetUrl,
requestBody: {
summary: `${appointment.employeeName || ""} & ${appointment.contactName || ""}`,
start: {
dateTime: startTime,
timeZone: "UTC",
},
end: {
dateTime: endTime,
timeZone: "UTC",
},
attendees: attendeeEmails,
},
conferenceDataVersion: 1,
sendUpdates: "all",
});
this.logger.info("Event created: %s", conference.data.htmlLink);
// Extract event ID from HTML link
const htmlLink = conference.data.htmlLink;
const eventId = htmlLink.split("?eid=")[1];
return eventId;
} catch (error) {
this.logger.error("Error creating conference event:", error);
throw new Error("Failed to create conference event");
}
}
/**
* Create an event and send an SMS notification
*/
async createEventToSms(appointment: Appointment): Promise<string> {
try {
// Create Google Meet space
const conferenceData = await this.meetService.spaces.create({
requestBody: {
config: {
accessType: "OPEN",
entryPointAccess: "ALL",
}
}
});
const meetingUrl = conferenceData.data.meetingUri || "";
this.googleMeetUrl = meetingUrl;
// Format SMS message
const messageContent = this.formatSmsMessage(appointment, meetingUrl);
// Validate phone number
if (!appointment.contactPhone) {
throw new Error("Contact phone number is missing");
}
// Send SMS
await this.sendSipgateSms(messageContent, appointment.contactPhone);
return "success";
} catch (error) {
this.logger.error("Error creating event and sending SMS:", error);
throw new Error("Failed to create event and send SMS");
}
}
/**
* Create a calendar event and send an SMS invitation
*/
async createCalendarEventToSms(appointment: Appointment): Promise<string> {
try {
// Create the event
const eventId = await this.createEvent(appointment);
this.logger.info(`Event created with ID: ${eventId}`);
// Generate invitation message
const message = this.formatSmsMessage(appointment, this.googleMeetUrl);
// Validate phone number
if (!appointment.contactPhone) {
throw new Error("Contact phone number is missing");
}
// Send SMS
await this.sendSipgateSms(message, appointment.contactPhone);
this.logger.info(`SMS sent to ${appointment.contactPhone}`);
return "success";
} catch (error) {
this.logger.error("Error creating calendar event and sending SMS:", error);
throw new Error("Failed to create calendar event and send SMS");
}
}
/**
* Format an SMS message for appointment notification
*/
private formatSmsMessage(appointment: Appointment, meetSpaceUrl: string): string {
const formattedDate = new Date(appointment.timestamp).toLocaleString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
return [
`Hallo ${appointment.contactName},`,
`Vielen Dank für Ihren Anruf.`,
`Sie haben einen Termin mit ${appointment.employeeName}`,
`am ${formattedDate}.`,
`Dem Meeting können Sie über folgenden Link beitreten:`,
`${meetSpaceUrl}`,
`Wir freuen uns auf Sie!`,
`Die Lautmaler GmbH`
].join(' ');
}
/**
* Send an SMS via Sipgate API
*/
private async sendSipgateSms(message: string, recipient: string): Promise<any> {
this.logger.info('SIPGATE:: verifying credentials');
if (!SIPGATE_TOKEN || !SIPGATE_TOKEN_ID) {
throw new Error('ERROR: SIPGATE CREDENTIALS MISSING');
}
const headers = {
'Content-Type': 'application/json',
};
const requestBody = {
smsId: 's9',
recipient: recipient,
message: message,
};
try {
this.logger.info('SIPGATE:: sending SMS');
const response = await axios.post(`${this.SIPGATE_BASE_URL}/sessions/sms`, requestBody, {
headers: headers,
auth: {
username: SIPGATE_TOKEN_ID,
password: SIPGATE_TOKEN,
},
});
return response.data;
} catch (error: any) {
this.logger.error(`SIPGATE:: error ${error.message}`);
throw new Error(`Failed to send SMS: ${error.message}`);
}
}
}
export default GoogleCalendarBackend;