Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/api/v2/src/lib/services/organization-membership.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable } from "@nestjs/common";

import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations";

@Injectable()
export class OrganizationMembershipService extends BaseOrganizationMembershipService {
constructor(organizationsRepository: OrganizationsRepository) {
super({ organizationRepository: organizationsRepository });
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
}
}

const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });

if (org?.isOrganization && !org?.isPlatform) {
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate {
}
}

const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });

if (org?.isOrganization) {
canAccess = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate {
}
}

const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });

if (org?.isOrganization) {
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";

import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
import { Prisma } from "@calcom/prisma/client";

@Injectable()
export class OrganizationsRepository {
export class OrganizationsRepository extends OrganizationRepository {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}

async findById(organizationId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: organizationId,
isOrganization: true,
},
});
) {
super({ prismaClient: dbWrite.prisma });
}

async findByIds(organizationIds: number[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class OrganizationsService {
constructor(private readonly organizationsRepository: OrganizationsRepository) {}

async isPlatform(organizationId: number) {
const organization = await this.organizationsRepository.findById(organizationId);
const organization = await this.organizationsRepository.findById({ id: organizationId });
return organization?.isPlatform;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
Expand Down Expand Up @@ -108,6 +109,7 @@ import { Module } from "@nestjs/common";
],
providers: [
OrganizationsRepository,
OrganizationMembershipService,
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
Expand Down Expand Up @@ -200,4 +202,4 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesPrivateLinksController,
],
})
export class OrganizationsModule {}
export class OrganizationsModule { }
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
}

private async isManagerOrganizationPlatform(managerOrganizationId: number) {
const organization = await this.organizationsRepository.findById(managerOrganizationId);
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
return !!organization?.isPlatform;
}

async getManagedOrganization(managedOrganizationId: number) {
const organization = await this.organizationsRepository.findById(managedOrganizationId);
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
if (!organization) {
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});

// Auto-accept tests
describe("auto-accept based on email domain", () => {
let orgWithAutoAccept: Team;
let subteamWithAutoAccept: Team;
let userWithMatchingEmail: User;
let userWithUppercaseEmail: User;
let userWithMatchingEmailForOverride: User;
let userWithNonMatchingEmail: User;

beforeAll(async () => {
// Create org with auto-accept settings
orgWithAutoAccept = await organizationsRepositoryFixture.create({
name: `auto-accept-org-${randomString()}`,
isOrganization: true,
});

// Update organizationSettings with orgAutoAcceptEmail
await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, {
orgAutoAcceptEmail: "acme.com",
isOrganizationVerified: true,
isOrganizationConfigured: true,
});

// Create subteam
subteamWithAutoAccept = await teamsRepositoryFixture.create({
name: `auto-accept-subteam-${randomString()}`,
isOrganization: false,
parent: { connect: { id: orgWithAutoAccept.id } },
});

// Create event type with assignAllTeamMembers
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: { connect: { id: subteamWithAutoAccept.id } },
title: "Auto Accept Event Type",
slug: "auto-accept-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});

// Create users
userWithMatchingEmail = await userRepositoryFixture.create({
email: `alice@acme.com`,
username: `alice-${randomString()}`,
});

userWithUppercaseEmail = await userRepositoryFixture.create({
email: `bob@ACME.COM`,
username: `bob-${randomString()}`,
});

userWithMatchingEmailForOverride = await userRepositoryFixture.create({
email: `david@acme.com`,
username: `david-${randomString()}`,
});

userWithNonMatchingEmail = await userRepositoryFixture.create({
email: `charlie@external.com`,
username: `charlie-${randomString()}`,
});

// Add users to org
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});

await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithUppercaseEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});

await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmailForOverride.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});

await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithNonMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});

// Create profiles for users
await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmail.id}`,
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmail.id } },
});

await profileRepositoryFixture.create({
uid: `usr-${userWithUppercaseEmail.id}`,
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithUppercaseEmail.id } },
});

await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmailForOverride.id}`,
username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmailForOverride.id } },
});

await profileRepositoryFixture.create({
uid: `usr-${userWithNonMatchingEmail.id}`,
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithNonMatchingEmail.id } },
});

// Make user an admin of the org for API access
await membershipsRepositoryFixture.create({
role: "ADMIN",
accepted: true,
user: { connect: { id: user.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
});

it("should auto-accept when email matches orgAutoAcceptEmail", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);

const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(true);

// Verify EventTypes assignment
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(
subteamWithAutoAccept.id
);
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
expect(eventTypeWithAssignAll).toBeTruthy();
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
expect(userIsHost).toBe(true);
});

it("should handle case-insensitive email domain matching", async () => {
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithUppercaseEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);

const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(true);
});

it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmailForOverride.id,
role: "MEMBER",
accepted: false,
} satisfies CreateOrgTeamMembershipDto)
.expect(201);

const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
// Should override to true because email matches
expect(responseBody.data.accepted).toBe(true);
});

it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithNonMatchingEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);

const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(false);
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
await organizationsRepositoryFixture.delete(orgWithAutoAccept.id);
});
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
Expand Down Expand Up @@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController {

constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private readonly organizationsRepository: OrganizationsRepository
) {}
private readonly organizationsRepository: OrganizationsRepository,
private readonly orgMembershipService: OrganizationMembershipService
) { }

@Get("/")
@ApiOperation({ summary: "Get all memberships" })
Expand Down Expand Up @@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController {
};
}


// TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service
// See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
Expand All @@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController {
throw new UnprocessableEntityException("User is not part of the Organization");
}

const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
organizationId: orgId,
userEmail: user.email,
});

// ALWAYS override when email matches - prevents pending memberships
// Remember organizations expect added team member to automatically start receiving bookings for the team event
const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false);

const membershipData = { ...data, accepted: acceptedStatus };
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(
teamId,
membershipData
);

if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(user.id, teamId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export class OrganizationRepositoryFixture {
});
}

async updateSettings(
teamId: Team["id"],
settings: {
orgAutoAcceptEmail?: string;
isOrganizationVerified?: boolean;
isOrganizationConfigured?: boolean;
}
) {
return this.prismaWriteClient.organizationSettings.update({
where: { organizationId: teamId },
data: settings,
});
}

async delete(teamId: Team["id"]) {
return await this.prismaWriteClient.$transaction(async (prisma) => {
await prisma.organizationSettings.delete({
Expand Down
Loading
Loading