feat: update v2 calendar services logic to handle multiple calendars (#16478)

* update calendar logic to handle multiple calendars

* add method to link calendar to credential

* remove logs

* update platform libraries

* update calendar logic to handle multiple calendars

* update credentials repository

* add selected calendars repository to destination calendars module

* add method to get user selected calendars and rename fn

* chore: PR feedback

* resolve merge conflicts

* fixup

* make sure we cover all edge cases for apple calendar

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2024-09-12 06:44:01 +00:00
committed by GitHub
co-authored by Morgan
parent 55e1e0fa3d
commit 91f5d49cd4
9 changed files with 200 additions and 35 deletions
@@ -5,7 +5,8 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries";
import { symmetricEncrypt, CalendarService, symmetricDecrypt } from "@calcom/platform-libraries";
import { Credential } from "@calcom/prisma/client";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
@@ -61,26 +62,70 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
APPLE_CALENDAR_TYPE,
userId
);
let hasMatchingUsernameAndPassword = false;
if (existingAppleCalendarCredentials.length > 0) {
const hasCalendarWithGivenCredentials = existingAppleCalendarCredentials.find(
(calendarCredential: Credential) => {
const decryptedKey = JSON.parse(
symmetricDecrypt(calendarCredential.key as string, process.env.CALENDSO_ENCRYPTION_KEY || "")
);
if (decryptedKey.username === username) {
if (decryptedKey.password === password) {
hasMatchingUsernameAndPassword = true;
}
return true;
}
}
);
if (!!hasCalendarWithGivenCredentials && hasMatchingUsernameAndPassword) {
return {
status: SUCCESS_STATUS,
};
}
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
await this.credentialRepository.upsertAppCredential(
APPLE_CALENDAR_TYPE,
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
userId,
hasCalendarWithGivenCredentials.id
);
return {
status: SUCCESS_STATUS,
};
}
}
try {
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
@@ -6,6 +6,7 @@ import {
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
@@ -15,9 +16,11 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -33,7 +36,8 @@ export class CalendarsService {
private readonly calendarsRepository: CalendarsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
async getCalendars(userId: number) {
@@ -145,4 +149,32 @@ export class CalendarsService {
throw new NotFoundException("Calendar credentials not found");
}
}
async createAndLinkCalendarEntry(
userId: number,
externalId: string,
key: Prisma.InputJsonValue,
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
credentialId?: number | null
) {
const credential = await this.credentialsRepository.upsertAppCredential(
calendarType,
key,
userId,
credentialId
);
await this.selectedCalendarsRepository.upsertSelectedCalendar(
externalId,
credential.id,
userId,
calendarType
);
}
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
const credential = await this.credentialsRepository.getUserCredentialById(userId, credentialId, type);
return !credential?.invalid;
}
}
@@ -131,11 +131,6 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
@@ -149,10 +144,39 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
ownerId,
GOOGLE_CALENDAR_TYPE,
primaryCal.id
);
if (alreadyExistingSelectedCalendar) {
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
ownerId,
alreadyExistingSelectedCalendar.credentialId ?? 0,
GOOGLE_CALENDAR_TYPE
);
// user credential probably got expired in this case
if (!isCredentialValid) {
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
alreadyExistingSelectedCalendar.externalId,
key as Prisma.InputJsonValue,
GOOGLE_CALENDAR_TYPE,
alreadyExistingSelectedCalendar.credentialId
);
}
return {
url: redir || origin,
};
}
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
primaryCal.id,
key as Prisma.InputJsonValue,
GOOGLE_CALENDAR_TYPE
);
}
@@ -168,16 +168,39 @@ export class OutlookService implements OAuthCalendarApp {
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
if (defaultCalendar?.id) {
const credential = await this.credentialRepository.createAppCredential(
const alreadyExistingSelectedCalendar = await this.selectedCalendarsRepository.getUserSelectedCalendar(
ownerId,
OFFICE_365_CALENDAR_TYPE,
office365OAuthCredentials,
ownerId
defaultCalendar.id
);
await this.selectedCalendarsRepository.createSelectedCalendar(
defaultCalendar.id,
credential.id,
if (alreadyExistingSelectedCalendar) {
const isCredentialValid = await this.calendarsService.checkCalendarCredentialValidity(
ownerId,
alreadyExistingSelectedCalendar.credentialId ?? 0,
OFFICE_365_CALENDAR_TYPE
);
// user credential probably got expired in this case
if (!isCredentialValid) {
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
alreadyExistingSelectedCalendar.externalId,
office365OAuthCredentials,
OFFICE_365_CALENDAR_TYPE,
alreadyExistingSelectedCalendar.credentialId
);
}
return {
url: redir || origin,
};
}
await this.calendarsService.createAndLinkCalendarEntry(
ownerId,
defaultCalendar.id,
office365OAuthCredentials,
OFFICE_365_CALENDAR_TYPE
);
}
@@ -9,12 +9,12 @@ import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
export class CredentialsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createAppCredential(
async upsertAppCredential(
type: keyof typeof APPS_TYPE_ID_MAPPING,
key: Prisma.InputJsonValue,
userId: number
userId: number,
credentialId?: number | null
) {
const credential = await this.getByTypeAndUserId(type, userId);
return this.dbWrite.prisma.credential.upsert({
create: {
type,
@@ -27,7 +27,7 @@ export class CredentialsRepository {
invalid: false,
},
where: {
id: credential?.id ?? 0,
id: credentialId ?? 0,
},
});
}
@@ -36,6 +36,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
getUserCredentialsByIds(userId: number, credentialIds: number[]) {
return this.dbRead.prisma.credential.findMany({
where: {
@@ -60,6 +64,30 @@ export class CredentialsRepository {
},
});
}
async getUserCredentialById(userId: number, credentialId: number, type: string) {
return await this.dbRead.prisma.credential.findUnique({
where: {
userId,
type,
id: credentialId,
},
select: {
id: true,
type: true,
key: true,
userId: true,
teamId: true,
appId: true,
invalid: true,
user: {
select: {
email: true,
},
},
},
});
}
}
export type CredentialsWithUserEmail = Awaited<
@@ -6,6 +6,7 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
UsersRepository,
CredentialsRepository,
AppsRepository,
SelectedCalendarsRepository,
],
controllers: [DestinationCalendarsController],
exports: [DestinationCalendarsRepository],
@@ -6,7 +6,7 @@ import { Injectable } from "@nestjs/common";
export class SelectedCalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
return this.dbWrite.prisma.selectedCalendar.upsert({
create: {
userId,
@@ -38,6 +38,18 @@ export class SelectedCalendarsRepository {
});
}
getUserSelectedCalendar(userId: number, integration: string, externalId: string) {
return this.dbRead.prisma.selectedCalendar.findUnique({
where: {
userId_integration_externalId: {
userId,
externalId,
integration,
},
},
});
}
async addUserSelectedCalendar(
userId: number,
integration: string,
@@ -68,7 +68,6 @@ export class UsersRepository {
}
async findByIdWithProfile(userId: number): Promise<UserWithProfile | null> {
console.log("findByIdWithProfile");
return this.dbRead.prisma.user.findUnique({
where: {
id: userId,
@@ -76,7 +76,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar"
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}