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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface CreateSeatChangeLogData {
userId?: number;
triggeredBy?: number;
monthKey: string;
operationId?: string;
metadata?: Prisma.InputJsonValue;
teamBillingId: string | null;
organizationBillingId: string | null;
Expand All @@ -28,6 +29,20 @@ export class SeatChangeLogRepository {
}

async create(data: CreateSeatChangeLogData): Promise<SeatChangeLog> {
// If operationId is provided, use upsert to prevent duplicates
if (data.operationId) {
return await this.prisma.seatChangeLog.upsert({
where: {
teamId_operationId: {
teamId: data.teamId,
operationId: data.operationId,
},
},
create: data,
update: {}, // No update needed - if it exists, we skip
});
}

return await this.prisma.seatChangeLog.create({
data,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Logger } from "tslog";

import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key";
import { SeatChangeLogRepository } from "@calcom/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository";
import logger from "@calcom/lib/logger";
import type { Prisma } from "@calcom/prisma/client";
import type { SeatChangeType } from "@calcom/prisma/enums";
import type { Logger } from "tslog";

const log = logger.getSubLogger({ prefix: ["SeatChangeTrackingService"] });

Expand All @@ -14,6 +14,9 @@ export interface SeatChangeLogParams {
seatCount?: number;
metadata?: Prisma.InputJsonValue;
monthKey?: string;
// Idempotency key to prevent duplicate seat change logs from race conditions
// Format: "{source}-{uniqueId}" e.g., "membership-123" or "invite-abc"
operationId?: string;
}

export interface MonthlyChanges {
Expand All @@ -32,8 +35,16 @@ export class SeatChangeTrackingService {
}

async logSeatAddition(params: SeatChangeLogParams): Promise<void> {
const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params;
const monthKey = providedMonthKey || this.calculateMonthKey(new Date());
const {
teamId,
userId,
triggeredBy,
seatCount = 1,
metadata,
monthKey: providedMonthKey,
operationId,
} = params;
const monthKey = providedMonthKey || formatMonthKey(new Date());

const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId);

Expand All @@ -44,15 +55,24 @@ export class SeatChangeTrackingService {
userId,
triggeredBy,
monthKey,
operationId,
metadata: (metadata || {}) as Prisma.InputJsonValue,
teamBillingId,
organizationBillingId,
});
}

async logSeatRemoval(params: SeatChangeLogParams): Promise<void> {
const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params;
const monthKey = providedMonthKey || this.calculateMonthKey(new Date());
const {
teamId,
userId,
triggeredBy,
seatCount = 1,
metadata,
monthKey: providedMonthKey,
operationId,
} = params;
const monthKey = providedMonthKey || formatMonthKey(new Date());

const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId);

Expand All @@ -63,6 +83,7 @@ export class SeatChangeTrackingService {
userId,
triggeredBy,
monthKey,
operationId,
metadata: (metadata || {}) as Prisma.InputJsonValue,
teamBillingId,
organizationBillingId,
Expand Down Expand Up @@ -94,10 +115,4 @@ export class SeatChangeTrackingService {

return await this.repository.markAsProcessed({ teamId, monthKey, prorationId });
}

private calculateMonthKey(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import prisma from "@calcom/prisma";
import type { IdentityProvider } from "@calcom/prisma/enums";
Expand Down Expand Up @@ -69,7 +70,7 @@ export const createUsersAndConnectToOrg = async ({
});

// Create memberships for new members
await MembershipRepository.createMany(
const membershipResult = await MembershipRepository.createMany(
users.map((user) => ({
userId: user.id,
teamId: org.id,
Expand All @@ -78,6 +79,14 @@ export const createUsersAndConnectToOrg = async ({
}))
);

if (membershipResult.count > 0) {
const seatTracker = new SeatChangeTrackingService();
await seatTracker.logSeatAddition({
teamId: org.id,
seatCount: membershipResult.count,
});
}

return users;
};

Expand Down
27 changes: 19 additions & 8 deletions packages/features/ee/teams/services/teamService.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService";
import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import type { Membership, Team, User, VerificationToken, Profile } from "@calcom/prisma/client";
import type { Membership, Profile, Team, User, VerificationToken } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { TeamService } from "./teamService";

const { MockSeatChangeTrackingService } = vi.hoisted(() => {
class MockSeatChangeTrackingService {
logSeatAddition = vi.fn().mockResolvedValue(undefined);
logSeatRemoval = vi.fn().mockResolvedValue(undefined);
}
return { MockSeatChangeTrackingService };
});

vi.mock("@calcom/ee/billing/di/containers/Billing");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService");
vi.mock("@calcom/lib/domainManager/organization");
vi.mock("@calcom/features/ee/teams/lib/removeMember");
vi.mock("@calcom/features/profile/lib/createAProfileForAnExistingUser");
vi.mock("@calcom/features/ee/teams/lib/queries");
vi.mock("@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService", () => ({
SeatChangeTrackingService: MockSeatChangeTrackingService,
}));

const mockTeamBilling = {
cancel: vi.fn(),
Expand All @@ -39,7 +48,7 @@ describe("TeamService", () => {
vi.resetAllMocks();
mockTeamBillingFactory.findAndInit.mockResolvedValue(mockTeamBilling);
mockTeamBillingFactory.findAndInitMany.mockResolvedValue([mockTeamBilling]);

const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing");
vi.mocked(getTeamBillingServiceFactory).mockReturnValue(mockTeamBillingFactory);
});
Expand All @@ -57,11 +66,13 @@ describe("TeamService", () => {
slug: "deleted-team",
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
const mockTeamRepo = {
deleteById: vi.fn().mockResolvedValue(mockDeletedTeam),
} as Pick<TeamRepository, "deleteById">;
vi.mocked(TeamRepository).mockImplementation(function() { return mockTeamRepo; });
vi.mocked(TeamRepository).mockImplementation(function () {
return mockTeamRepo;
});

const result = await TeamService.delete({ id: 1 });

Expand Down
28 changes: 28 additions & 0 deletions packages/features/ee/teams/services/teamService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomBytes } from "node:crypto";

import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing";
import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
Expand Down Expand Up @@ -188,6 +189,7 @@ export class TeamService {
team: {
select: {
name: true,
parentId: true,
},
},
},
Expand Down Expand Up @@ -218,6 +220,15 @@ export class TeamService {
} else throw e;
}

if (!verificationToken.team.parentId) {
const seatTracker = new SeatChangeTrackingService();
await seatTracker.logSeatAddition({
teamId: verificationToken.teamId,
userId,
triggeredBy: userId,
});
}

const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId);
await teamBillingService.updateQuantity();
Expand Down Expand Up @@ -296,6 +307,15 @@ export class TeamService {
},
});
}

if (!membership.team.parentId) {
const seatTracker = new SeatChangeTrackingService();
await seatTracker.logSeatRemoval({
teamId,
userId,
triggeredBy: userId,
});
}
} catch (e) {
console.log(e);
}
Expand Down Expand Up @@ -375,6 +395,14 @@ export class TeamService {

await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg);

if (!team.parentId) {
const seatTracker = new SeatChangeTrackingService();
await seatTracker.logSeatRemoval({
teamId,
userId,
});
}

return { membership };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
Warnings:

- A unique constraint covering the columns `[teamId,operationId]` on the table `SeatChangeLog` will be added. If there are existing duplicate values, this will fail.

*/
-- AlterTable
ALTER TABLE "public"."SeatChangeLog" ADD COLUMN "operationId" TEXT;

-- CreateIndex
CREATE UNIQUE INDEX "SeatChangeLog_teamId_operationId_key" ON "public"."SeatChangeLog"("teamId", "operationId");
4 changes: 4 additions & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2989,6 +2989,9 @@ model SeatChangeLog {
changeDate DateTime @default(now())
monthKey String

// Idempotency key to prevent duplicate seat change logs from race conditions
operationId String?

processedInProrationId String?
proration MonthlyProration? @relation(fields: [processedInProrationId], references: [id])

Expand All @@ -3000,6 +3003,7 @@ model SeatChangeLog {
organizationBillingId String?
organizationBilling OrganizationBilling? @relation(fields: [organizationBillingId], references: [id])

@@unique([teamId, operationId])
@@index([teamId, monthKey])
@@index([teamId, processedInProrationId])
@@index([monthKey])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing";
import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
Expand Down Expand Up @@ -56,19 +57,22 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
throw new TRPCError({ code: "UNAUTHORIZED" });
}

// Loop over all users in input.userIds and remove all memberships for the organization including child teams
const deleteMany = prisma.membership.deleteMany({
const deleteOrganizationMemberships = prisma.membership.deleteMany({
where: {
teamId: currentUserOrgId,
userId: {
in: input.userIds,
},
},
});

const deleteSubteamMemberships = prisma.membership.deleteMany({
where: {
userId: {
in: input.userIds,
},
team: {
OR: [
{
parentId: currentUserOrgId,
},
{ id: currentUserOrgId },
],
parentId: currentUserOrgId,
},
},
});
Expand Down Expand Up @@ -130,14 +134,24 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand

// We do this in a transaction to make sure that all memberships are removed before we remove the organization relation from the user
// We also do this to make sure that if one of the queries fail, the whole transaction fails
await prisma.$transaction([
const [, { count: orgMembershipRemovalCount }] = await prisma.$transaction([
removeProfiles,
deleteMany,
deleteOrganizationMemberships,
deleteSubteamMemberships,
removeOrgrelation,
removeManagedEventTypes,
removeHostAssignment,
]);

if (orgMembershipRemovalCount > 0) {
const seatTracker = new SeatChangeTrackingService();
await seatTracker.logSeatRemoval({
teamId: currentUserOrgId,
seatCount: orgMembershipRemovalCount,
triggeredBy: currentUser.id,
});
}

const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId);
await teamBillingService.updateQuantity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe("addMembersToTeams", () => {
mockCheckPermission.mockResolvedValue(true);
mockUpdateNewTeamMemberEventTypes.mockResolvedValue(undefined);

prisma.team.findMany.mockResolvedValue([]);
prisma.membership.findMany.mockResolvedValue([]);
prisma.membership.createMany.mockResolvedValue({ count: 0 });
});
Expand Down
Loading
Loading