chore: ensure default calendars with trigger.dev apiv2 (#27058)

* chore: ensure default calendars with trigger.dev apiv2

* test: add unit and e2e tests for CalendarsTasker integration

- Add unit tests for CalendarsTasker.dispatch when enableAsyncTasker is true
- Add e2e test to verify ensureDefaultCalendarsForUser is called when creating membership
- Mock CalendarsTasker and ConfigService in unit tests
- Test both async (Trigger.dev) and sync (Bull queue) paths
- Fix missing return types on helper functions in e2e tests
- Add *.spec.ts to biome test file exceptions for noExcessiveLinesPerFunction rule

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2026-01-21 16:03:30 +02:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent f44ee33c6c
commit f00f80ed26
11 changed files with 462 additions and 289 deletions
@@ -1,5 +1,9 @@
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
import { OrganizationsDelegationCredentialService } from "./services/organizations-delegation-credential.service";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
import { CALENDARS_QUEUE, CalendarsProcessor } from "@/ee/calendars/processors/calendars.processor";
import { CalendarsTaskerModule } from "@/lib/modules/calendars-tasker.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsDelegationCredentialController } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.controller";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
@@ -7,10 +11,6 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
import { OrganizationsDelegationCredentialService } from "./services/organizations-delegation-credential.service";
@Module({
imports: [
@@ -26,6 +26,7 @@ import { OrganizationsDelegationCredentialService } from "./services/organizatio
duration: 1000,
},
}),
CalendarsTaskerModule,
],
providers: [
OrganizationsDelegationCredentialService,
@@ -34,6 +35,10 @@ import { OrganizationsDelegationCredentialService } from "./services/organizatio
CalendarsProcessor,
],
controllers: [OrganizationsDelegationCredentialController],
exports: [OrganizationsDelegationCredentialRepository, OrganizationsDelegationCredentialService, BullModule],
exports: [
OrganizationsDelegationCredentialRepository,
OrganizationsDelegationCredentialService,
BullModule,
],
})
export class OrganizationsDelegationCredentialModule {}
@@ -2,9 +2,11 @@ import {
CALENDARS_QUEUE,
DEFAULT_CALENDARS_JOB,
} from "@/ee/calendars/processors/calendars.processor";
import { CalendarsTasker } from "@/lib/services/tasker/calendars-tasker.service";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getQueueToken } from "@nestjs/bull";
import { Test, TestingModule } from "@nestjs/testing";
@@ -12,6 +14,8 @@ describe("OrganizationsDelegationCredentialService", () => {
let service: OrganizationsDelegationCredentialService;
let mockRepository: OrganizationsDelegationCredentialRepository;
let mockQueue: { getJob: jest.Mock; add: jest.Mock };
let mockCalendarsTasker: { dispatch: jest.Mock };
let mockConfigService: { get: jest.Mock };
const orgId = 1;
const domain = "example.com";
@@ -22,6 +26,14 @@ describe("OrganizationsDelegationCredentialService", () => {
add: jest.fn().mockResolvedValue(undefined),
};
mockCalendarsTasker = {
dispatch: jest.fn().mockResolvedValue({ runId: "test-run-id" }),
};
mockConfigService = {
get: jest.fn().mockReturnValue(false),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
OrganizationsDelegationCredentialService,
@@ -35,6 +47,14 @@ describe("OrganizationsDelegationCredentialService", () => {
provide: getQueueToken(CALENDARS_QUEUE),
useValue: mockQueue,
},
{
provide: CalendarsTasker,
useValue: mockCalendarsTasker,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
@@ -215,5 +235,54 @@ describe("OrganizationsDelegationCredentialService", () => {
await expect(service.ensureDefaultCalendarsForUser(orgId, userId, userEmail)).resolves.toBeUndefined();
});
describe("when enableAsyncTasker is true", () => {
beforeEach(() => {
mockConfigService.get.mockReturnValue(true);
});
it("uses CalendarsTasker.dispatch instead of Bull queue", async () => {
(mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue({
id: "cred-1",
});
await service.ensureDefaultCalendarsForUser(orgId, userId, userEmail);
expect(mockConfigService.get).toHaveBeenCalledWith("enableAsyncTasker");
expect(mockCalendarsTasker.dispatch).toHaveBeenCalledWith(
"ensureDefaultCalendars",
{ userId },
{ idempotencyKey: `${DEFAULT_CALENDARS_JOB}_${userId}`, idempotencyKeyTTL: "1h" }
);
expect(mockQueue.add).not.toHaveBeenCalled();
expect(mockQueue.getJob).not.toHaveBeenCalled();
});
it("does not call CalendarsTasker when no delegation credential exists", async () => {
(mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue(null);
await service.ensureDefaultCalendarsForUser(orgId, userId, userEmail);
expect(mockCalendarsTasker.dispatch).not.toHaveBeenCalled();
expect(mockQueue.add).not.toHaveBeenCalled();
});
it("does not throw when CalendarsTasker.dispatch fails", async () => {
(mockRepository.findEnabledByOrgIdAndDomain as jest.Mock).mockResolvedValue({
id: "cred-1",
});
mockCalendarsTasker.dispatch.mockRejectedValue(new Error("Tasker error"));
await expect(service.ensureDefaultCalendarsForUser(orgId, userId, userEmail)).resolves.toBeUndefined();
});
it("returns early for invalid email without calling tasker", async () => {
await service.ensureDefaultCalendarsForUser(orgId, userId, "invalidemail");
expect(Logger.prototype.warn).toHaveBeenCalledWith(`Invalid email format for user ${userId}: missing domain`);
expect(mockCalendarsTasker.dispatch).not.toHaveBeenCalled();
expect(mockRepository.findEnabledByOrgIdAndDomain).not.toHaveBeenCalled();
});
});
});
});
@@ -1,8 +1,21 @@
import { encryptServiceAccountKey } from "@calcom/platform-libraries";
import {
addDelegationCredential,
type TServiceAccountKeySchema,
toggleDelegationCredentialEnabled,
} from "@calcom/platform-libraries/app-store";
import type { DelegationCredential, Prisma, User } from "@calcom/prisma/client";
import { InjectQueue } from "@nestjs/bull";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue } from "bull";
import { AppConfig } from "@/config/type";
import {
CALENDARS_QUEUE,
DEFAULT_CALENDARS_JOB,
DefaultCalendarsJobDataType,
} from "@/ee/calendars/processors/calendars.processor";
import { CalendarsTasker } from "@/lib/services/tasker/calendars-tasker.service";
import { CreateDelegationCredentialInput } from "@/modules/organizations/delegation-credentials/inputs/create-delegation-credential.input";
import {
GoogleServiceAccountKeyInput,
@@ -10,17 +23,30 @@ import {
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
import { UpdateDelegationCredentialInput } from "@/modules/organizations/delegation-credentials/inputs/update-delegation-credential.input";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
import { InjectQueue } from "@nestjs/bull";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { Queue } from "bull";
import { encryptServiceAccountKey } from "@calcom/platform-libraries";
import {
addDelegationCredential,
toggleDelegationCredentialEnabled,
type TServiceAccountKeySchema,
} from "@calcom/platform-libraries/app-store";
import type { User } from "@calcom/prisma/client";
type DelegationCredentialWithWorkspacePlatform = {
workspacePlatform: {
name: string;
id: number;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
slug: string;
description: string;
defaultServiceAccountKey: Prisma.JsonValue;
};
} & {
id: string;
organizationId: number;
serviceAccountKey: Prisma.JsonValue;
enabled: boolean;
lastEnabledAt: Date | null;
lastDisabledAt: Date | null;
domain: string;
createdAt: Date;
updatedAt: Date;
workspacePlatformId: number;
};
@Injectable()
export class OrganizationsDelegationCredentialService {
@@ -28,13 +54,15 @@ export class OrganizationsDelegationCredentialService {
constructor(
private readonly organizationsDelegationCredentialRepository: OrganizationsDelegationCredentialRepository,
private readonly calendarsTasker: CalendarsTasker,
private readonly configService: ConfigService<AppConfig>,
@InjectQueue(CALENDARS_QUEUE) private readonly calendarsQueue: Queue
) {}
async createDelegationCredential(
orgId: number,
delegatedServiceAccountUser: User,
body: CreateDelegationCredentialInput
) {
): Promise<Partial<Omit<DelegationCredential, "serviceAccountKey">> | null | undefined> {
const delegationCredential = await addDelegationCredential({
input: body,
ctx: { user: { id: delegatedServiceAccountUser.id, organizationId: orgId } },
@@ -47,7 +75,7 @@ export class OrganizationsDelegationCredentialService {
delegationCredentialId: string,
delegatedServiceAccountUser: User,
body: UpdateDelegationCredentialInput
) {
): Promise<DelegationCredentialWithWorkspacePlatform> {
let delegationCredential =
await this.organizationsDelegationCredentialRepository.findByIdWithWorkspacePlatform(
delegationCredentialId
@@ -82,7 +110,7 @@ export class OrganizationsDelegationCredentialService {
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential.enabled };
}
async ensureDefaultCalendars(orgId: number, domain: string) {
async ensureDefaultCalendars(orgId: number, domain: string): Promise<void> {
try {
const delegatedUserProfiles =
await this.organizationsDelegationCredentialRepository.findDelegatedUserProfiles(orgId, domain);
@@ -123,7 +151,7 @@ export class OrganizationsDelegationCredentialService {
}
}
async ensureDefaultCalendarsForUser(orgId: number, userId: number, userEmail: string) {
async ensureDefaultCalendarsForUser(orgId: number, userId: number, userEmail: string): Promise<void> {
try {
const emailParts = userEmail.split("@");
if (emailParts.length < 2 || !emailParts[1]) {
@@ -133,23 +161,37 @@ export class OrganizationsDelegationCredentialService {
const emailDomain = `@${emailParts[1]}`;
const delegationCredential =
await this.organizationsDelegationCredentialRepository.findEnabledByOrgIdAndDomain(orgId, emailDomain);
await this.organizationsDelegationCredentialRepository.findEnabledByOrgIdAndDomain(
orgId,
emailDomain
);
if (!delegationCredential) {
return;
}
if (this.configService.get("enableAsyncTasker")) {
this.logger.log(`Adding default calendar job for user with id: ${userId}`);
await this.calendarsTasker.dispatch(
"ensureDefaultCalendars",
{
userId,
},
{ idempotencyKey: `${DEFAULT_CALENDARS_JOB}_${userId}`, idempotencyKeyTTL: "1h" }
);
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 }
);
await this.calendarsQueue.add(DEFAULT_CALENDARS_JOB, { userId } satisfies DefaultCalendarsJobDataType, {
jobId: `${DEFAULT_CALENDARS_JOB}_${userId}`,
removeOnComplete: true,
});
} catch (err) {
this.logger.error(
err,
@@ -163,7 +205,7 @@ export class OrganizationsDelegationCredentialService {
delegationCredentialId: string,
delegatedServiceAccountUser: User,
enabled: boolean
) {
): Promise<Partial<Omit<DelegationCredential, "serviceAccountKey">> | null | undefined> {
const handlerUser = {
id: delegatedServiceAccountUser.id,
email: delegatedServiceAccountUser.email,
@@ -177,7 +219,7 @@ export class OrganizationsDelegationCredentialService {
async updateDelegationCredentialServiceAccountKey(
delegationCredentialId: string,
serviceAccountKey: GoogleServiceAccountKeyInput | MicrosoftServiceAccountKeyInput
) {
): Promise<DelegationCredentialWithWorkspacePlatform> {
// First encrypt the service account key
const encryptedServiceAccountKey = encryptServiceAccountKey(
serviceAccountKey as TServiceAccountKeySchema
@@ -20,6 +20,7 @@ import { GetAllOrgMemberships } from "@/modules/organizations/memberships/output
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgUserAttribute } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -146,7 +147,7 @@ describe("Organizations Memberships Endpoints", () => {
await app.init();
});
async function setupAttributes() {
async function setupAttributes(): Promise<void> {
textAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "TEXT",
@@ -261,7 +262,7 @@ describe("Organizations Memberships Endpoints", () => {
});
});
function userHasCorrectAttributes(attributes: OrgUserAttribute[]) {
function userHasCorrectAttributes(attributes: OrgUserAttribute[]): void {
expect(attributes.length).toEqual(4);
const responseNumberAttribute = attributes.find((attr) => attr.type === "number");
const responseSingleSelectAttribute = attributes.find((attr) => attr.type === "singleSelect");
@@ -387,6 +388,39 @@ describe("Organizations Memberships Endpoints", () => {
});
});
it("should call ensureDefaultCalendarsForUser when creating membership", async () => {
const newUserEmail = `organizations-memberships-calendars-${randomString()}@api.com`;
const newUser = await userRepositoryFixture.create({
email: newUserEmail,
username: newUserEmail,
bio,
metadata,
});
const ensureDefaultCalendarsSpy = jest
.spyOn(OrganizationsDelegationCredentialService.prototype, "ensureDefaultCalendarsForUser")
.mockResolvedValue(undefined);
try {
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
.send({
userId: newUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgMembershipDto)
.expect(201);
expect(ensureDefaultCalendarsSpy).toHaveBeenCalledWith(org.id, newUser.id, newUserEmail);
} finally {
ensureDefaultCalendarsSpy.mockRestore();
await membershipRepositoryFixture.delete(
(await membershipRepositoryFixture.getUserMembershipByTeamId(newUser.id, org.id))?.id ?? 0
);
await userRepositoryFixture.deleteByEmail(newUserEmail);
}
});
it("should update the membership of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
@@ -1,6 +1,7 @@
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
import { CALENDARS_QUEUE } from "@/ee/calendars/processors/calendars.processor";
import { CalendarsTaskerModule } from "@/lib/modules/calendars-tasker.module";
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";
@@ -35,6 +36,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
duration: 1000,
},
}),
CalendarsTaskerModule,
],
providers: [
ManagedOrganizationsService,
@@ -4,6 +4,7 @@ import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
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 { CalendarsTaskerModule } from "@/lib/modules/calendars-tasker.module";
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";
@@ -29,6 +30,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
duration: 1000,
},
}),
CalendarsTaskerModule,
],
providers: [
SelectedCalendarsRepository,
+2 -1
View File
@@ -32,7 +32,8 @@
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"],
"@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"],
"@calcom/platform-libraries/errors": ["../../../packages/platform/libraries/errors.ts"]
"@calcom/platform-libraries/errors": ["../../../packages/platform/libraries/errors.ts"],
"@calcom/platform-libraries/calendars": ["../../../packages/platform/libraries/calendars.ts"]
},
"incremental": true,
"skipLibCheck": true,
+1 -1
View File
@@ -212,7 +212,7 @@
}
},
{
"includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.e2e-spec.ts", "**/*.integration-test.ts"],
"includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.e2e-spec.ts", "**/*.integration-test.ts"],
"linter": {
"rules": {
"complexity": {
File diff suppressed because it is too large Load Diff
+8
View File
@@ -104,6 +104,11 @@
"import": "./dist/errors.js",
"require": "./dist/errors.cjs",
"types": "./dist/errors.d.ts"
},
"./calendars": {
"import": "./dist/calendars.js",
"require": "./dist/calendars.cjs",
"types": "./dist/calendars.d.ts"
}
},
"typesVersions": {
@@ -146,6 +151,9 @@
],
"errors": [
"dist/errors.d.ts"
],
"calendars": [
"dist/calendars.d.ts"
]
}
}
@@ -43,6 +43,7 @@ export default defineConfig({
"private-links": resolve(__dirname, "./private-links.ts"),
pbac: resolve(__dirname, "./pbac.ts"),
errors: resolve(__dirname, "./errors.ts"),
calendars: resolve(__dirname, "./calendars.ts"),
},
name: "calcom-lib",
fileName: "calcom-lib",