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:
co-authored by
morgan@cal.com
Devin AI
cubic-dev-ai[bot]
parent
f5cd2342c7
commit
dd7b647715
+1
@@ -34,5 +34,6 @@ import { OrganizationsDelegationCredentialService } from "./services/organizatio
|
||||
CalendarsProcessor,
|
||||
],
|
||||
controllers: [OrganizationsDelegationCredentialController],
|
||||
exports: [OrganizationsDelegationCredentialRepository, OrganizationsDelegationCredentialService, BullModule],
|
||||
})
|
||||
export class OrganizationsDelegationCredentialModule {}
|
||||
|
||||
+11
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
@@ -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,
|
||||
|
||||
+48
-13
@@ -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),
|
||||
|
||||
+26
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user