diff --git a/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.module.ts b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.module.ts index 93bd58dcdd..bc382e97ea 100644 --- a/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.module.ts +++ b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.module.ts @@ -34,5 +34,6 @@ import { OrganizationsDelegationCredentialService } from "./services/organizatio CalendarsProcessor, ], controllers: [OrganizationsDelegationCredentialController], + exports: [OrganizationsDelegationCredentialRepository, OrganizationsDelegationCredentialService, BullModule], }) export class OrganizationsDelegationCredentialModule {} diff --git a/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.repository.ts b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.repository.ts index 8c275cb8ad..540e8aca9b 100644 --- a/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.repository.ts +++ b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.repository.ts @@ -41,4 +41,15 @@ export class OrganizationsDelegationCredentialRepository { }, }); } + + async findEnabledByOrgIdAndDomain(orgId: number, domain: string) { + return this.dbRead.prisma.delegationCredential.findFirst({ + where: { + organizationId: orgId, + domain, + enabled: true, + }, + select: { id: true }, + }); + } } diff --git a/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.spec.ts b/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.spec.ts index 31841d4669..3c23938d68 100644 --- a/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.spec.ts +++ b/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.spec.ts @@ -135,4 +135,85 @@ describe("OrganizationsDelegationCredentialService", () => { expect(mockQueue.add).not.toHaveBeenCalled(); }); }); + + describe("ensureDefaultCalendarsForUser", () => { + const userId = 123; + const userEmail = "user@example.com"; + + beforeEach(() => { + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock) = jest.fn(); + jest.spyOn(Logger.prototype, "warn").mockImplementation(); + }); + + it("adds calendar job when delegation credential exists for user domain", async () => { + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue({ + id: "cred-1", + }); + + await service.ensureDefaultCalendarsForUser(orgId, userId, userEmail); + + expect(mockRepository.findEnabledByOrgIdAndDomain).toHaveBeenCalledWith(orgId, "@example.com"); + expect(mockQueue.add).toHaveBeenCalledWith( + DEFAULT_CALENDARS_JOB, + { userId }, + { jobId: `${DEFAULT_CALENDARS_JOB}_${userId}`, removeOnComplete: true } + ); + }); + + it("removes existing job before adding new one", async () => { + const mockExistingJob = { remove: jest.fn().mockResolvedValue(undefined) }; + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue({ + id: "cred-1", + }); + mockQueue.getJob.mockResolvedValue(mockExistingJob); + + await service.ensureDefaultCalendarsForUser(orgId, userId, userEmail); + + expect(mockExistingJob.remove).toHaveBeenCalled(); + expect(mockQueue.add).toHaveBeenCalledTimes(1); + }); + + it("does not add job when no delegation credential exists for domain", async () => { + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue(null); + + await service.ensureDefaultCalendarsForUser(orgId, userId, userEmail); + + expect(mockRepository.findEnabledByOrgIdAndDomain).toHaveBeenCalledWith(orgId, "@example.com"); + expect(mockQueue.add).not.toHaveBeenCalled(); + }); + + it("returns early and logs warning for invalid email without @ symbol", async () => { + await service.ensureDefaultCalendarsForUser(orgId, userId, "invalidemail"); + + expect(Logger.prototype.warn).toHaveBeenCalledWith(`Invalid email format for user ${userId}: missing domain`); + expect(mockRepository.findEnabledByOrgIdAndDomain).not.toHaveBeenCalled(); + expect(mockQueue.add).not.toHaveBeenCalled(); + }); + + it("returns early for email with @ but no domain part", async () => { + await service.ensureDefaultCalendarsForUser(orgId, userId, "user@"); + + expect(Logger.prototype.warn).toHaveBeenCalledWith(`Invalid email format for user ${userId}: missing domain`); + expect(mockRepository.findEnabledByOrgIdAndDomain).not.toHaveBeenCalled(); + expect(mockQueue.add).not.toHaveBeenCalled(); + }); + + it("does not throw when repository fails", async () => { + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockRejectedValue( + new Error("Database error") + ); + + await expect(service.ensureDefaultCalendarsForUser(orgId, userId, userEmail)).resolves.toBeUndefined(); + expect(mockQueue.add).not.toHaveBeenCalled(); + }); + + it("does not throw when queue.add fails", async () => { + (mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue({ + id: "cred-1", + }); + mockQueue.add.mockRejectedValue(new Error("Queue error")); + + await expect(service.ensureDefaultCalendarsForUser(orgId, userId, userEmail)).resolves.toBeUndefined(); + }); + }); }); diff --git a/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.ts b/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.ts index 6ddd955147..de4e065211 100644 --- a/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.ts +++ b/apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.ts @@ -123,6 +123,41 @@ export class OrganizationsDelegationCredentialService { } } + async ensureDefaultCalendarsForUser(orgId: number, userId: number, userEmail: string) { + try { + const emailParts = userEmail.split("@"); + if (emailParts.length < 2 || !emailParts[1]) { + this.logger.warn(`Invalid email format for user ${userId}: missing domain`); + return; + } + const emailDomain = `@${emailParts[1]}`; + + const delegationCredential = + await this.organizationsDelegationCredentialRepository.findEnabledByOrgIdAndDomain(orgId, emailDomain); + + if (!delegationCredential) { + return; + } + + const existingJob = await this.calendarsQueue.getJob(`${DEFAULT_CALENDARS_JOB}_${userId}`); + if (existingJob) { + await existingJob.remove(); + this.logger.log(`Removed existing default calendar job for user with id: ${userId}`); + } + this.logger.log(`Adding default calendar job for user with id: ${userId}`); + await this.calendarsQueue.add( + DEFAULT_CALENDARS_JOB, + { userId } satisfies DefaultCalendarsJobDataType, + { jobId: `${DEFAULT_CALENDARS_JOB}_${userId}`, removeOnComplete: true } + ); + } catch (err) { + this.logger.error( + err, + `Could not ensure default calendars for user with id: ${userId} in org with id: ${orgId}` + ); + } + } + async updateDelegationCredentialEnabled( orgId: number, delegationCredentialId: string, diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index ab89e115ae..4208099944 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -1,10 +1,11 @@ +import { TeamService } from "@calcom/platform-libraries"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service"; import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input"; import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; - -import { TeamService } from "@calcom/platform-libraries"; - +import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output"; +import { UsersRepository } from "@/modules/users/users.repository"; import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service"; @@ -17,10 +18,15 @@ export class OrganizationsMembershipService { constructor( private readonly organizationsMembershipRepository: OrganizationsMembershipRepository, private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService, - private readonly oAuthClientsRepository: OAuthClientRepository + private readonly oAuthClientsRepository: OAuthClientRepository, + private readonly usersRepository: UsersRepository, + private readonly delegationCredentialService: OrganizationsDelegationCredentialService ) {} - async getOrgMembership(organizationId: number, membershipId: number) { + async getOrgMembership( + organizationId: number, + membershipId: number + ): Promise { const membership = await this.organizationsMembershipRepository.findOrgMembership( organizationId, membershipId @@ -35,7 +41,7 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } - async isOrgAdminOrOwner(organizationId: number, userId: number) { + async isOrgAdminOrOwner(organizationId: number, userId: number): Promise { const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( organizationId, userId @@ -46,7 +52,10 @@ export class OrganizationsMembershipService { return membership.role === "ADMIN" || membership.role === "OWNER"; } - async getOrgMembershipByUserId(organizationId: number, userId: number) { + async getOrgMembershipByUserId( + organizationId: number, + userId: number + ): Promise { const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( organizationId, userId @@ -60,7 +69,11 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } - async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) { + async getPaginatedOrgMemberships( + organizationId: number, + skip = 0, + take = 250 + ): Promise { const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated( organizationId, skip, @@ -69,7 +82,10 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships); } - async deleteOrgMembership(organizationId: number, membershipId: number) { + async deleteOrgMembership( + organizationId: number, + membershipId: number + ): Promise { // Get the membership first to get the userId const membership = await this.organizationsMembershipRepository.findOrgMembership( organizationId, @@ -91,7 +107,11 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } - async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) { + async updateOrgMembership( + organizationId: number, + membershipId: number, + data: UpdateOrgMembershipDto + ): Promise { const membership = await this.organizationsMembershipRepository.updateOrgMembership( organizationId, membershipId, @@ -100,13 +120,28 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } - async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) { + async createOrgMembership( + organizationId: number, + data: CreateOrgMembershipDto + ): Promise { await this.canUserBeAddedToOrg(data.userId, organizationId); const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data); + + if (this.delegationCredentialService && this.usersRepository) { + const user = await this.usersRepository.findById(data.userId); + if (user?.email) { + await this.delegationCredentialService.ensureDefaultCalendarsForUser( + organizationId, + data.userId, + user.email + ); + } + } + return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } - async canUserBeAddedToOrg(userId: number, orgId: number) { + async canUserBeAddedToOrg(userId: number, orgId: number): Promise { const [userOAuthClient, orgOAuthClients] = await Promise.all([ this.oAuthClientsRepository.getByUserId(userId), this.oAuthClientsRepository.getByOrgId(orgId), diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.module.ts index 77c6c2cf5e..4fd946b560 100644 --- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.module.ts @@ -1,28 +1,49 @@ +import { BullModule } from "@nestjs/bull"; +import { Module } from "@nestjs/common"; +import { CALENDARS_QUEUE } from "@/ee/calendars/processors/calendars.processor"; import { ApiKeysModule } from "@/modules/api-keys/api-keys.module"; import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; -import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository"; +import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; -import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service"; import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; +import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service"; import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository"; import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller"; -import { ManagedOrganizationsOutputService } from "@/modules/organizations/organizations/services/managed-organizations-output.service"; import { ManagedOrganizationsService } from "@/modules/organizations/organizations/services/managed-organizations.service"; +import { ManagedOrganizationsOutputService } from "@/modules/organizations/organizations/services/managed-organizations-output.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { ProfilesModule } from "@/modules/profiles/profiles.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; -import { Module } from "@nestjs/common"; +import { UsersRepository } from "@/modules/users/users.repository"; @Module({ - imports: [PrismaModule, RedisModule, StripeModule, MembershipsModule, ApiKeysModule, ProfilesModule], + imports: [ + PrismaModule, + RedisModule, + StripeModule, + MembershipsModule, + ApiKeysModule, + ProfilesModule, + BullModule.registerQueue({ + name: CALENDARS_QUEUE, + limiter: { + max: 1, + duration: 1000, + }, + }), + ], providers: [ ManagedOrganizationsService, ManagedOrganizationsRepository, ManagedOrganizationsBillingService, OrganizationsRepository, + UsersRepository, + OrganizationsDelegationCredentialRepository, + OrganizationsDelegationCredentialService, OrganizationsMembershipService, OrganizationsMembershipOutputService, OrganizationsMembershipRepository, diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts index 10b1d37e40..70c226ee8f 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -1,22 +1,35 @@ +import { BullModule } from "@nestjs/bull"; +import { Module } from "@nestjs/common"; import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; -import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service"; +import { CALENDARS_QUEUE } from "@/ee/calendars/processors/calendars.processor"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository"; +import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service"; import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; -import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service"; import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; +import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller"; import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; import { SelectedCalendarsService } from "@/modules/selected-calendars/services/selected-calendars.service"; import { UsersRepository } from "@/modules/users/users.repository"; -import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, RedisModule], + imports: [ + PrismaModule, + RedisModule, + BullModule.registerQueue({ + name: CALENDARS_QUEUE, + limiter: { + max: 1, + duration: 1000, + }, + }), + ], providers: [ SelectedCalendarsRepository, CalendarsRepository, @@ -25,6 +38,7 @@ import { Module } from "@nestjs/common"; UsersRepository, CredentialsRepository, AppsRepository, + OrganizationsDelegationCredentialService, OrganizationsMembershipService, OrganizationsMembershipOutputService, OrganizationsDelegationCredentialRepository,