feat(api-v2): ensure default calendars for delegation credentials when user added to org (#26800)

* feat(api-v2): ensure default calendars for delegation credentials when user added to org

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make calendars queue optional in OrganizationsMembershipService

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make delegation credential repository and users repository optional

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: move calendar setup logic to delegation credential service

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: make usersRepository optional for E2E tests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: module deps

* Update apps/api/v2/src/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* test: add unit tests for ensureDefaultCalendarsForUser method

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: address Cubic AI feedback (confidence 9/10)

- Repository query now uses select: { id: true } for existence check
- Add job removal before adding new job (consistent with ensureDefaultCalendars)
- Fix test spy reuse issue

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2026-01-14 16:13:25 -03:00
committed by GitHub
co-authored by morgan@cal.com Devin AI cubic-dev-ai[bot]
parent f5cd2342c7
commit dd7b647715
7 changed files with 220 additions and 22 deletions
@@ -34,5 +34,6 @@ import { OrganizationsDelegationCredentialService } from "./services/organizatio
CalendarsProcessor,
],
controllers: [OrganizationsDelegationCredentialController],
exports: [OrganizationsDelegationCredentialRepository, OrganizationsDelegationCredentialService, BullModule],
})
export class OrganizationsDelegationCredentialModule {}
@@ -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 },
});
}
}
@@ -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();
});
});
});
@@ -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,
@@ -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<OrganizationMembershipOutput> {
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<boolean> {
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<OrganizationMembershipOutput> {
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<OrganizationMembershipOutput[]> {
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<OrganizationMembershipOutput> {
// 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<OrganizationMembershipOutput> {
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<OrganizationMembershipOutput> {
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<boolean> {
const [userOAuthClient, orgOAuthClients] = await Promise.all([
this.oAuthClientsRepository.getByUserId(userId),
this.oAuthClientsRepository.getByOrgId(orgId),
@@ -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,
@@ -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,