@lautmaler/crm-connectors
Version:
Provides connectors to various CRM systems and calendar services.
509 lines • 18.6 kB
JavaScript
import axios from "axios";
import winston from "winston";
import fs from "fs";
import { google } from "googleapis";
import { OAuth2Client } from "google-auth-library";
// 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 {
// Constants
SIPGATE_BASE_URL = 'https://api.sipgate.com/v2';
DEFAULT_TIMEZONE = 'Europe/Berlin';
APPOINTMENT_DURATION_MS = 60 * 60 * 1000; // 1 hour in milliseconds
DEFAULT_DELEGATION_EMAIL = "konrad@lautmaler.de";
// Services
logger = winston.createLogger();
googleAuth = new OAuth2Client();
calendarService = google.calendar({ version: "v3" });
meetService = google.meet({ version: "v2" });
// Configuration
calendarOwnerId;
userDelegationEmail;
googleMeetUrl = "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
*/
initializeLogger() {
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
*/
initializeGoogleServices() {
// 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
*/
async initializeGoogleAuth() {
const credentials = this.parseCredentials();
this.setupCalendarService(credentials);
this.setupMeetService(credentials);
}
/**
* Parse the Google service account credentials
*/
parseCredentials() {
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
*/
setupCalendarService(credentials) {
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
*/
async setupCalendarAcl() {
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
*/
setupMeetService(credentials) {
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, attendees) {
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
*/
async freeBusyApiCall(payload) {
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;
this.logger.info("FreeBusy API response received successfully");
return freeBusyResponse.calendars[this.calendarOwnerId].busy;
}
catch (error) {
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
*/
generateAvailableSlots(timeMin, timeMax, busySlots) {
const availableSlots = [];
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() {
return ["Consultation", "Meeting", "Demo"];
}
/**
* Book a new appointment
*/
async bookAppointment(appointment) {
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, updatedInfo) {
// 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 = {
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) {
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) {
throw new Error("Method not implemented.");
}
/**
* Find an appointment by ID
*/
async findAppointmentById(id) {
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
*/
mapEventToAppointment(event) {
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",
timestamp: event.start.dateTime,
contactEmail: attendees[0]?.email || "",
contactName: attendees[0]?.displayName || attendees[0]?.email || "",
employeeEmail: attendees[1]?.email || "",
status: "Scheduled",
};
}
/**
* Create a Google Calendar event
*/
async createEvent(appointment) {
return this.createConference(appointment);
}
/**
* Create a conference event with Google Meet
*/
async createConference(appointment) {
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 = 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) {
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) {
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
*/
formatSmsMessage(appointment, meetSpaceUrl) {
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
*/
async sendSipgateSms(message, recipient) {
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) {
this.logger.error(`SIPGATE:: error ${error.message}`);
throw new Error(`Failed to send SMS: ${error.message}`);
}
}
}
export default GoogleCalendarBackend;
//# sourceMappingURL=GoogleCalendarBackend.js.map