361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { db } from "../../db/index";
|
|
import { eq, and, gte, lte } from "drizzle-orm";
|
|
import { crmEvents, crmGoogleTokens, type CrmEvent } from "@shared/schema";
|
|
|
|
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
|
|
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
|
|
const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || "http://localhost:5000/api/crm/google/callback";
|
|
|
|
interface GoogleTokens {
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
expires_in: number;
|
|
scope: string;
|
|
token_type: string;
|
|
}
|
|
|
|
interface GoogleCalendarEvent {
|
|
id?: string;
|
|
summary: string;
|
|
description?: string;
|
|
location?: string;
|
|
start: { dateTime?: string; date?: string; timeZone?: string };
|
|
end: { dateTime?: string; date?: string; timeZone?: string };
|
|
attendees?: Array<{ email: string; displayName?: string }>;
|
|
conferenceData?: { createRequest?: { requestId: string } };
|
|
reminders?: { useDefault: boolean };
|
|
}
|
|
|
|
export class GoogleCalendarService {
|
|
getAuthUrl(userId: string): string {
|
|
if (!GOOGLE_CLIENT_ID) {
|
|
throw new Error("Google Client ID not configured");
|
|
}
|
|
|
|
const scopes = [
|
|
"https://www.googleapis.com/auth/calendar",
|
|
"https://www.googleapis.com/auth/calendar.events"
|
|
];
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: GOOGLE_CLIENT_ID,
|
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
|
response_type: "code",
|
|
scope: scopes.join(" "),
|
|
access_type: "offline",
|
|
prompt: "consent",
|
|
state: userId
|
|
});
|
|
|
|
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
}
|
|
|
|
async handleCallback(code: string, userId: string): Promise<void> {
|
|
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
|
throw new Error("Google OAuth not configured");
|
|
}
|
|
|
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
code,
|
|
client_id: GOOGLE_CLIENT_ID,
|
|
client_secret: GOOGLE_CLIENT_SECRET,
|
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
|
grant_type: "authorization_code"
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Failed to exchange code: ${error}`);
|
|
}
|
|
|
|
const tokens: GoogleTokens = await response.json();
|
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
|
|
|
const [existing] = await db.select().from(crmGoogleTokens).where(eq(crmGoogleTokens.userId, userId));
|
|
|
|
if (existing) {
|
|
await db.update(crmGoogleTokens)
|
|
.set({
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || existing.refreshToken,
|
|
expiresAt,
|
|
scope: tokens.scope,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(crmGoogleTokens.userId, userId));
|
|
} else {
|
|
await db.insert(crmGoogleTokens).values({
|
|
userId,
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || null,
|
|
expiresAt,
|
|
scope: tokens.scope
|
|
});
|
|
}
|
|
}
|
|
|
|
private async getValidAccessToken(userId: string): Promise<string> {
|
|
const [token] = await db.select().from(crmGoogleTokens).where(eq(crmGoogleTokens.userId, userId));
|
|
|
|
if (!token) {
|
|
throw new Error("Google Calendar not connected");
|
|
}
|
|
|
|
if (token.expiresAt && token.expiresAt > new Date()) {
|
|
return token.accessToken;
|
|
}
|
|
|
|
if (!token.refreshToken || !GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
|
throw new Error("Token expired and cannot be refreshed");
|
|
}
|
|
|
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
client_id: GOOGLE_CLIENT_ID,
|
|
client_secret: GOOGLE_CLIENT_SECRET,
|
|
refresh_token: token.refreshToken,
|
|
grant_type: "refresh_token"
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to refresh token");
|
|
}
|
|
|
|
const tokens: GoogleTokens = await response.json();
|
|
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
|
|
|
await db.update(crmGoogleTokens)
|
|
.set({
|
|
accessToken: tokens.access_token,
|
|
expiresAt,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(crmGoogleTokens.userId, userId));
|
|
|
|
return tokens.access_token;
|
|
}
|
|
|
|
async isConnected(userId: string): Promise<boolean> {
|
|
const [token] = await db.select().from(crmGoogleTokens).where(eq(crmGoogleTokens.userId, userId));
|
|
return !!token;
|
|
}
|
|
|
|
async disconnect(userId: string): Promise<void> {
|
|
await db.delete(crmGoogleTokens).where(eq(crmGoogleTokens.userId, userId));
|
|
}
|
|
|
|
async createEvent(userId: string, event: CrmEvent): Promise<string | null> {
|
|
try {
|
|
const accessToken = await this.getValidAccessToken(userId);
|
|
|
|
const isAllDay = event.allDay === "true";
|
|
const startDate = event.startAt instanceof Date ? event.startAt : new Date(event.startAt);
|
|
const endDate = event.endAt ? (event.endAt instanceof Date ? event.endAt : new Date(event.endAt)) : null;
|
|
|
|
const googleEvent: GoogleCalendarEvent = {
|
|
summary: event.title,
|
|
description: event.description || undefined,
|
|
location: event.location || undefined,
|
|
start: isAllDay
|
|
? { date: startDate.toISOString().split("T")[0] }
|
|
: { dateTime: startDate.toISOString(), timeZone: "America/Sao_Paulo" },
|
|
end: endDate
|
|
? (isAllDay
|
|
? { date: endDate.toISOString().split("T")[0] }
|
|
: { dateTime: endDate.toISOString(), timeZone: "America/Sao_Paulo" })
|
|
: (isAllDay
|
|
? { date: startDate.toISOString().split("T")[0] }
|
|
: { dateTime: new Date(startDate.getTime() + 3600000).toISOString(), timeZone: "America/Sao_Paulo" }),
|
|
attendees: event.attendees?.map(email => ({ email })) || undefined,
|
|
reminders: { useDefault: true }
|
|
};
|
|
|
|
const response = await fetch(
|
|
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error("Failed to create Google Calendar event:", error);
|
|
return null;
|
|
}
|
|
|
|
const created = await response.json();
|
|
return created.id;
|
|
} catch (error) {
|
|
console.error("Error creating Google Calendar event:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async updateEvent(userId: string, googleEventId: string, event: CrmEvent): Promise<boolean> {
|
|
try {
|
|
const accessToken = await this.getValidAccessToken(userId);
|
|
|
|
const isAllDay = event.allDay === "true";
|
|
const startDate = event.startAt instanceof Date ? event.startAt : new Date(event.startAt);
|
|
const endDate = event.endAt ? (event.endAt instanceof Date ? event.endAt : new Date(event.endAt)) : null;
|
|
|
|
const googleEvent: GoogleCalendarEvent = {
|
|
summary: event.title,
|
|
description: event.description || undefined,
|
|
location: event.location || undefined,
|
|
start: isAllDay
|
|
? { date: startDate.toISOString().split("T")[0] }
|
|
: { dateTime: startDate.toISOString(), timeZone: "America/Sao_Paulo" },
|
|
end: endDate
|
|
? (isAllDay
|
|
? { date: endDate.toISOString().split("T")[0] }
|
|
: { dateTime: endDate.toISOString(), timeZone: "America/Sao_Paulo" })
|
|
: (isAllDay
|
|
? { date: startDate.toISOString().split("T")[0] }
|
|
: { dateTime: new Date(startDate.getTime() + 3600000).toISOString(), timeZone: "America/Sao_Paulo" }),
|
|
attendees: event.attendees?.map(email => ({ email })) || undefined
|
|
};
|
|
|
|
const response = await fetch(
|
|
`https://www.googleapis.com/calendar/v3/calendars/primary/events/${googleEventId}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
}
|
|
);
|
|
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error("Error updating Google Calendar event:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async deleteEvent(userId: string, googleEventId: string): Promise<boolean> {
|
|
try {
|
|
const accessToken = await this.getValidAccessToken(userId);
|
|
|
|
const response = await fetch(
|
|
`https://www.googleapis.com/calendar/v3/calendars/primary/events/${googleEventId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
}
|
|
);
|
|
|
|
return response.ok || response.status === 404;
|
|
} catch (error) {
|
|
console.error("Error deleting Google Calendar event:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async syncEvents(userId: string, startDate: Date, endDate: Date): Promise<void> {
|
|
try {
|
|
const accessToken = await this.getValidAccessToken(userId);
|
|
|
|
const params = new URLSearchParams({
|
|
timeMin: startDate.toISOString(),
|
|
timeMax: endDate.toISOString(),
|
|
singleEvents: "true",
|
|
orderBy: "startTime"
|
|
});
|
|
|
|
const response = await fetch(
|
|
`https://www.googleapis.com/calendar/v3/calendars/primary/events?${params.toString()}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch Google Calendar events");
|
|
}
|
|
|
|
const data = await response.json();
|
|
const googleEvents = data.items || [];
|
|
|
|
for (const gEvent of googleEvents) {
|
|
const [existing] = await db.select().from(crmEvents)
|
|
.where(and(
|
|
eq(crmEvents.userId, userId),
|
|
eq(crmEvents.googleEventId, gEvent.id)
|
|
));
|
|
|
|
const startAt = gEvent.start.dateTime
|
|
? new Date(gEvent.start.dateTime)
|
|
: new Date(gEvent.start.date);
|
|
const endAt = gEvent.end?.dateTime
|
|
? new Date(gEvent.end.dateTime)
|
|
: gEvent.end?.date
|
|
? new Date(gEvent.end.date)
|
|
: null;
|
|
const allDay = !gEvent.start.dateTime ? "true" : "false";
|
|
|
|
if (existing) {
|
|
await db.update(crmEvents)
|
|
.set({
|
|
title: gEvent.summary || "Untitled",
|
|
description: gEvent.description || null,
|
|
location: gEvent.location || null,
|
|
startAt,
|
|
endAt,
|
|
allDay,
|
|
attendees: gEvent.attendees?.map((a: any) => a.email) || null,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(crmEvents.id, existing.id));
|
|
} else {
|
|
await db.insert(crmEvents).values({
|
|
userId,
|
|
title: gEvent.summary || "Untitled",
|
|
description: gEvent.description || null,
|
|
location: gEvent.location || null,
|
|
startAt,
|
|
endAt,
|
|
allDay,
|
|
googleEventId: gEvent.id,
|
|
attendees: gEvent.attendees?.map((a: any) => a.email) || null,
|
|
type: "meeting",
|
|
status: "scheduled"
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error syncing Google Calendar events:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getEvents(userId: string, startDate?: Date, endDate?: Date): Promise<CrmEvent[]> {
|
|
const conditions = [eq(crmEvents.userId, userId)];
|
|
|
|
if (startDate) {
|
|
conditions.push(gte(crmEvents.startAt, startDate));
|
|
}
|
|
if (endDate) {
|
|
conditions.push(lte(crmEvents.startAt, endDate));
|
|
}
|
|
|
|
return db.select().from(crmEvents).where(and(...conditions));
|
|
}
|
|
}
|
|
|
|
export const googleCalendarService = new GoogleCalendarService();
|