Files
Hariom BalharaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Morgan
bc665cbeab fix: APIV2 team membership - Member not getting added to event-type automatically (#24780)
* fix: APIV2 team membership addition

* feat: Add trimming for email domain and orgAutoAcceptEmail in auto-accept logic

- Trim whitespace from both user email domain and orgAutoAcceptEmail
- Ensures consistent matching even with accidental whitespace
- Addresses feedback from PR review

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

* simplify

* feat: Use shared OrganizationMembershipService in TRPC for consistent auto-accept logic

- Create OrganizationMembershipService.container.ts for DI in TRPC
- Update getOrgConnectionInfo to apply trimming + case-insensitive comparison
- Precompute auto-accept decisions in createNewUsersConnectToOrgIfExists using the service
- Use service in handleNewUsersInvites for consistent auto-accept determination
- Ensures both API v2 and TRPC paths use identical trimming and normalization logic

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

* Revert "feat: Use shared OrganizationMembershipService in TRPC for consistent auto-accept logic"

This reverts commit 0b2bd28b6e.

* refactor: Unify OrganizationRepository and remove duplicate PrismaOrganizationRepository (#24869)

* refactor: Convert OrganizationRepository from static to instance methods

- Add constructor accepting deps object with prismaClient
- Convert all static methods to instance methods
- Add getOrganizationAutoAcceptSettings method
- Create singleton instance export in repository barrel file
- Update API v2 OrganizationsRepository to extend from OrganizationRepository
- Update all call sites to use singleton instance
- Add platform-libraries organizations.ts export
- Fix mock imports to use repository barrel
- Fix unsafe optional chaining in next-auth-options.ts
- Fix any types in test files with proper type inference

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

* fix: Update all imports to use OrganizationRepository barrel export

- Update imports from direct OrganizationRepository file to barrel export
- This ensures mocks work correctly in tests
- Fixes 202 failing tests related to organizationRepository mock

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

* fix: Update test mocks to use partial mock pattern

- Convert organizationMock to partial mock that preserves real class
- Add proper prisma mocks to failing test files
- Remove old OrganizationRepository mocks from test files
- This fixes test failures related to mock interception

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

* fix: Export mocked singleton and update tests to use it directly

- Export mockedSingleton as organizationRepositoryMock from organizationMock
- Update delegationCredential.test.ts to import and use the exported mock
- This fixes 'vi.mocked(...).mockResolvedValue is not a function' errors

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

* fix: Use platform-libraries import for API v2 OrganizationRepository

API v2 should import shared features through @calcom/platform-libraries
instead of directly from @calcom/features to maintain proper architectural
boundaries and packaging/licensing separation.

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

* refactor: Implement DI pattern for OrganizationRepository

- Create OrganizationRepository.module.ts and .container.ts for DI
- Replace singleton pattern with getOrganizationRepository() across 20 files
- Update platform-libraries to export getOrganizationRepository
- Delete duplicate PrismaOrganizationRepository.ts
- Remove singleton export file (repositories/index.ts)
- Update test mocks to use DI container pattern
- All type checks and unit tests passing

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

* fix: Implement read/write client separation in OrganizationRepository

- Updated OrganizationRepository constructor to accept optional prismaWriteClient parameter
- Routed all write operations (create, update) through prismaWrite client
- Routed all read operations (find, get) through prismaRead client
- Updated API v2 OrganizationsRepository to pass both dbRead.prisma and dbWrite.prisma to super()
- Optimized getOrganizationRepository() calls by storing in local variables to avoid repeated function calls
- This fixes the critical issue where API v2 was passing read-only client to base class with write methods

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

* fix: Fix mock setup and optimize getOrganizationRepository() calls

- Fixed verify-email.test.ts mock to return mocked repository instance instead of scenario helper object
- Added mockReset to organizationMock.ts beforeEach to properly reset mock implementations between tests
- Added local variables in page.tsx to store getOrganizationRepository() result for consistency

This fixes the issue where getOrganizationRepository() was returning organizationScenarios.organizationRepository (scenario helper) instead of the actual mocked repository instance, causing findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail to be undefined.

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

* refactor: Simplify OrganizationRepository to use single prismaClient

- Updated base OrganizationRepository constructor to accept only { prismaClient } instead of { prismaClient, prismaWriteClient? }
- Replaced this.prismaRead and this.prismaWrite with single this.prismaClient property
- Updated API v2 OrganizationsRepository to pass only dbWrite.prisma as prismaClient
- Removed unused PrismaReadService import from API v2
- All read and write operations now use the same client instance

This simplifies the architecture as requested - API v2 uses write client for all operations, and apps/web uses the client from DI container.

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: Match OrganizationsRepository.findById signature with base class

The findById method in OrganizationsRepository was using a different signature
than the base OrganizationRepository class, causing type errors in CI.

Changed from: findById(organizationId: number)
Changed to: findById({ id }: { id: number })

This matches the base class signature and resolves the CI unit test failures.

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

* fix: Update all findById call sites to use object parameter

Fixed 6 call sites in API v2 that were calling findById with a number
instead of the required { id: number } object parameter:

- is-org.guard.ts
- is-admin-api-enabled.guard.ts
- is-webhook-in-org.guard.ts
- organizations.service.ts
- managed-organizations.service.ts (2 call sites)

This resolves the API v2 build failure caused by the signature change
in OrganizationsRepository.findById to match the base class.

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

* fix: Remove redundant findById override from OrganizationsRepository

The findById method was duplicating the base class OrganizationRepository
implementation. Both methods had identical logic (filtering by isOrganization: true),
so the override was unnecessary.

Since OrganizationsRepository extends OrganizationRepository and passes
dbWrite.prisma to the base constructor, the base class method already
provides the exact same functionality.

This resolves the API v2 build failure by eliminating the duplicate method
that was causing conflicts.

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

* Remove unncessary changes

* Store in variable

* Revert "Remove unncessary changes"

This reverts commit af9351786a21616c9508c441191c17f2374fb2cc.

* Revert dbRead/dbWrite changes

* Add organizations library to tsconfig.json

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <[email protected]>
2025-11-07 10:50:00 +00:00

335 lines
9.8 KiB
TypeScript

import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import logger from "@calcom/lib/logger";
import {
serviceAccountKeySchema,
type ServiceAccountKey,
type EncryptedServiceAccountKey,
encryptServiceAccountKey,
decryptServiceAccountKey,
} from "@calcom/lib/server/serviceAccountKey";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
export type { ServiceAccountKey };
const repositoryLogger = logger.getSubLogger({ prefix: ["DelegationCredentialRepository"] });
const delegationCredentialSafeSelect = {
id: true,
enabled: true,
domain: true,
createdAt: true,
updatedAt: true,
organizationId: true,
lastEnabledAt: true,
lastDisabledAt: true,
workspacePlatform: {
select: {
name: true,
slug: true,
},
},
};
const delegationCredentialSelectIncludesServiceAccountKey = {
...delegationCredentialSafeSelect,
serviceAccountKey: true,
};
function doesEmailMatchDelegationCredentialDomain({
memberEmail,
delegationCredentialEmailDomain,
}: {
memberEmail: string | null;
delegationCredentialEmailDomain: string;
}) {
if (!memberEmail) return false;
const memberEmailDomain = memberEmail.split("@")[1];
return memberEmailDomain === delegationCredentialEmailDomain;
}
export class DelegationCredentialRepository {
private static encryptServiceAccountKey(serviceAccountKey: ServiceAccountKey): EncryptedServiceAccountKey {
return encryptServiceAccountKey(serviceAccountKey);
}
private static decryptServiceAccountKey(encryptedServiceAccountKey: Prisma.JsonValue): ServiceAccountKey {
return decryptServiceAccountKey(encryptedServiceAccountKey);
}
private static withParsedServiceAccountKey<T extends { serviceAccountKey: Prisma.JsonValue } | null>(
delegationCredential: T
) {
if (!delegationCredential) {
return null;
}
const { serviceAccountKey, ...rest } = delegationCredential;
// Decrypt the service account key if it exists
const decryptedKey = this.decryptServiceAccountKey(serviceAccountKey);
const parsedServiceAccountKey = serviceAccountKeySchema.safeParse(decryptedKey);
return {
...rest,
serviceAccountKey: parsedServiceAccountKey.success ? parsedServiceAccountKey.data : null,
};
}
static async create(data: {
domain: string;
enabled: boolean;
organizationId: number;
workspacePlatformId: number;
serviceAccountKey: ServiceAccountKey;
}) {
const encryptedKey = this.encryptServiceAccountKey(data.serviceAccountKey);
return await prisma.delegationCredential.create({
data: {
workspacePlatform: {
connect: {
id: data.workspacePlatformId,
},
},
domain: data.domain,
enabled: data.enabled,
organization: {
connect: {
id: data.organizationId,
},
},
// z.passthrough() is not allowed in Prisma, but we know this is trusted.
serviceAccountKey: encryptedKey as unknown as Prisma.InputJsonValue,
},
select: delegationCredentialSafeSelect,
});
}
static async findById({ id }: { id: string }) {
return await prisma.delegationCredential.findUnique({
where: { id },
select: delegationCredentialSafeSelect,
});
}
static async findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey({
organizationId,
domain,
}: {
organizationId: number;
domain: string;
}) {
const delegationCredential = await prisma.delegationCredential.findUnique({
where: { organizationId_domain: { organizationId, domain } },
select: delegationCredentialSelectIncludesServiceAccountKey,
});
return DelegationCredentialRepository.withParsedServiceAccountKey(delegationCredential);
}
static async findByIdIncludeSensitiveServiceAccountKey({ id }: { id: string }) {
const delegationCredential = await prisma.delegationCredential.findUnique({
where: { id },
select: delegationCredentialSelectIncludesServiceAccountKey,
});
if (!delegationCredential) return null;
return DelegationCredentialRepository.withParsedServiceAccountKey(delegationCredential);
}
static async findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey({ email }: { email: string }) {
const log = repositoryLogger.getSubLogger({
prefix: ["findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey"],
});
log.debug("called with", { email });
const organizationRepository = getOrganizationRepository();
const organization = await organizationRepository.findByMemberEmail({ email });
if (!organization) {
log.debug("Email not found in any organization:", email);
return null;
}
const emailDomain = email.split("@")[1];
const delegationCredential = await prisma.delegationCredential.findUnique({
where: {
organizationId_domain: {
organizationId: organization.id,
domain: emailDomain,
},
},
select: delegationCredentialSelectIncludesServiceAccountKey,
});
return DelegationCredentialRepository.withParsedServiceAccountKey(delegationCredential);
}
static async findAllByDomain({ domain }: { domain: string }) {
return await prisma.delegationCredential.findMany({
where: { domain },
select: delegationCredentialSafeSelect,
});
}
static async updateById({
id,
data,
}: {
id: string;
data: Partial<{
workspacePlatformId: number;
domain: string;
enabled: boolean;
organizationId: number;
lastEnabledAt: Date;
lastDisabledAt: Date;
}>;
}) {
const { workspacePlatformId, organizationId, ...rest } = data;
return await prisma.delegationCredential.update({
where: { id },
data: {
...(workspacePlatformId && {
workspacePlatform: {
connect: {
id: workspacePlatformId,
},
},
}),
...(organizationId && {
organization: {
connect: {
id: organizationId,
},
},
}),
...rest,
},
select: delegationCredentialSafeSelect,
});
}
static async deleteById({ id }: { id: string }) {
return await prisma.delegationCredential.delete({
where: { id },
});
}
static async findByOrgIdIncludeSensitiveServiceAccountKey({ organizationId }: { organizationId: number }) {
return await prisma.delegationCredential.findMany({
where: { organizationId },
select: {
...delegationCredentialSelectIncludesServiceAccountKey,
workspacePlatform: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});
}
static async findAllEnabledIncludeDelegatedMembers() {
const delegationCredentials = await prisma.delegationCredential.findMany({
where: {
enabled: true,
},
include: {
workspacePlatform: {
select: {
slug: true,
},
},
organization: {
include: {
members: {
select: {
id: true,
userId: true,
user: {
select: {
id: true,
email: true,
},
},
accepted: true,
},
where: {
// Note: We don't maintain anything on Membership that can identify if a Membership has been processed, so we send all and let it be on the caller to filter out
accepted: true,
},
},
},
},
},
});
return delegationCredentials.map((delegationCredential) => {
// Members for whom delegation credential is applicable
const delegatedMembers = delegationCredential.organization.members.filter((member) => {
return doesEmailMatchDelegationCredentialDomain({
memberEmail: member.user.email,
delegationCredentialEmailDomain: delegationCredential.domain,
});
});
return {
...delegationCredential,
organization: {
...delegationCredential.organization,
delegatedMembers,
},
};
});
}
static async findAllDisabledAndIncludeNextBatchOfMembersToProcess() {
const delegationCredentials = await prisma.delegationCredential.findMany({
where: {
enabled: false,
},
include: {
workspacePlatform: {
select: {
slug: true,
},
},
organization: {
include: {
members: {
select: {
id: true,
userId: true,
user: {
select: {
id: true,
email: true,
},
},
accepted: true,
},
where: {
// Note: We don't maintain anything on Membership that can identify if a Membership has been processed, so we send all and let it be on the caller to filter out
accepted: true,
},
},
},
},
},
});
return delegationCredentials.map((delegationCredential) => {
const members = delegationCredential.organization.members;
const membersThatAreActuallyPartOfDelegationCredential = members.filter((member) => {
return doesEmailMatchDelegationCredentialDomain({
memberEmail: member.user.email,
delegationCredentialEmailDomain: delegationCredential.domain,
});
});
return {
...delegationCredential,
organization: {
...delegationCredential.organization,
members: membersThatAreActuallyPartOfDelegationCredential,
},
};
});
}
}