refactor: platform managed user org admin access (#22597)

* refactor: examples app setup

* wip: view org event types as org administrator

* fix: create event type has org admin

* fix: create event type has org admin

* fix: allow org admin team event type access

* fix: uncaught error

* chore: bump platform libs

* refactor: getting event type for org admin

* add test

* chore: libraries
This commit is contained in:
Lauris Skraucis
2025-07-28 13:50:12 +02:00
committed by GitHub
parent 30061c0d58
commit 19d7764e9d
15 changed files with 863 additions and 234 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
providers: [
OrganizationsTeamsRepository,
EventTypesAtomService,
ConferencingAtomsService,
AttributesAtomsService,
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
@@ -53,7 +54,8 @@ export class EventTypesAtomService {
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: TeamsEventTypesService
private readonly teamEventTypeService: TeamsEventTypesService,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
) {}
private async getTeamSlug(teamId: number): Promise<string> {
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
if (!isUserOrganizationAdmin) {
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
}
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
@@ -115,7 +119,8 @@ export class EventTypesAtomService {
user: UserWithProfile,
teamId: number
) {
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
}
async checkCanUpdateTeamEventType(
userId: number,
user: UserWithProfile,
eventTypeId: number,
teamId: number,
scheduleId: number | null | undefined
) {
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
const organizationId = this.usersService.getUserMainOrgId(user);
if (organizationId) {
const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin(
user.id,
organizationId
);
if (isUserOrganizationAdmin) {
const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId);
if (orgTeam) {
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
return;
}
}
}
await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId);
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId);
}
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
@@ -27,6 +27,17 @@ export class OrganizationsMembershipService {
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async isOrgAdminOrOwner(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
userId
);
if (!membership) {
return false;
}
return membership.role === "ADMIN" || membership.role === "OWNER";
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
@@ -14,6 +14,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 { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
import {
@@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
constructor(
private organizationsTeamsService: OrganizationsTeamsService,
private organizationsMembershipService: OrganizationsMembershipService
) {}
@Get()
@ApiOperation({ summary: "Get all teams" })
@@ -84,12 +88,11 @@ export class OrganizationsTeamsController {
@GetUser() user: UserWithProfile
): Promise<OrgMeTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
orgId,
user.id,
skip ?? 0,
take ?? 250
);
const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id);
const teams = isOrgAdminOrOwner
? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250)
: await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: teams.map((team) => {
@@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository {
take,
});
}
async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
include: {
members: { select: { accepted: true, userId: true, role: true } },
},
skip,
take,
});
}
}
@@ -24,6 +24,15 @@ export class OrganizationsTeamsService {
return teams;
}
async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers(
organizationId,
skip,
take
);
return teams;
}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
@@ -0,0 +1,327 @@
import prismock from "../../../tests/libs/__mocks__/prisma";
import { mockNoTranslations } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { describe, test, expect, beforeEach, vi } from "vitest";
import { getRawEventType } from "./getEventTypeById";
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: (key: string) => () => key,
}));
describe("getRawEventType", () => {
beforeEach(() => {
mockNoTranslations();
});
describe("Regular user access", () => {
test("should fetch event type when user owns it", async () => {
const user = await prismock.user.create({
data: {
username: "testuser",
email: "testuser@example.com",
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event",
length: 30,
userId: user.id,
users: {
connect: [{ id: user.id }],
},
},
include: {
users: true,
},
});
const result = await getRawEventType({
userId: user.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: false,
currentOrganizationId: null,
prisma: prismock as any,
});
expect(result).toBeDefined();
expect(result?.id).toBe(eventType.id);
expect(result?.title).toBe("Test Event Type");
expect(result?.userId).toBe(user.id);
});
test.skip("should return null when user doesn't have access to event type", async () => {
// note(Lauris): test skipped because somehow when creating event type eventType.users includes otherUser
const owner = await prismock.user.create({
data: {
username: "owner",
email: "owner1@example.com",
},
});
const otherUser = await prismock.user.create({
data: {
username: "otheruser",
email: "otheruser@example.com",
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Owner's Event Type",
slug: "owner-event",
length: 30,
userId: owner.id,
users: {
connect: [{ id: owner.id }],
},
},
select: {
id: true,
userId: true,
users: true,
},
});
await prismock.user.update({
where: {
id: otherUser.id,
},
data: {
eventTypes: {
disconnect: [{ id: eventType.id }],
},
},
});
const result = await getRawEventType({
userId: otherUser.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: false,
currentOrganizationId: null,
prisma: prismock as any,
});
expect(result).toBeNull();
});
});
describe("Organization admin access", () => {
test("should fetch team event type when user is org admin", async () => {
const organization = await prismock.team.create({
data: {
id: 100,
name: "Test Organization",
slug: "test-org",
isOrganization: true,
},
});
const team = await prismock.team.create({
data: {
id: 200,
name: "Test Team",
slug: "test-team",
parentId: organization.id,
},
});
const orgAdmin = await prismock.user.create({
data: {
username: "orgadmin",
email: "orgadmin@example.com",
organizationId: organization.id,
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Team Event Type",
slug: "team-event",
length: 30,
teamId: team.id,
},
include: {
team: true,
users: true,
},
});
const result = await getRawEventType({
userId: orgAdmin.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: true,
currentOrganizationId: organization.id,
prisma: prismock as any,
});
expect(result).toBeDefined();
expect(result?.id).toBe(eventType.id);
expect(result?.title).toBe("Team Event Type");
expect(result?.teamId).toBe(team.id);
});
test("should fetch user event type when user is org admin and user belongs to org", async () => {
const organization = await prismock.team.create({
data: {
id: 101,
name: "Test Organization 2",
slug: "test-org-2",
isOrganization: true,
},
});
const orgAdmin = await prismock.user.create({
data: {
username: "orgadmin2",
email: "orgadmin2@example.com",
organizationId: organization.id,
},
});
const orgUser = await prismock.user.create({
data: {
username: "orguser",
email: "orguser@example.com",
organizationId: organization.id,
profiles: {
create: {
organizationId: organization.id,
uid: "orguser",
username: "orguser",
},
},
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Org User Event Type",
slug: "org-user-event",
length: 30,
userId: orgUser.id,
users: {
connect: [{ id: orgUser.id }],
},
},
include: {
users: true,
owner: true,
},
});
const result = await getRawEventType({
userId: orgAdmin.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: true,
currentOrganizationId: organization.id,
prisma: prismock as any,
});
expect(result).toBeDefined();
expect(result?.id).toBe(eventType.id);
expect(result?.title).toBe("Org User Event Type");
expect(result?.userId).toBe(orgUser.id);
});
test("should return null when org admin tries to access event type from different org", async () => {
const org1 = await prismock.team.create({
data: {
id: 102,
name: "Organization 1",
slug: "org-1",
isOrganization: true,
},
});
const org2 = await prismock.team.create({
data: {
id: 103,
name: "Organization 2",
slug: "org-2",
isOrganization: true,
},
});
const team1 = await prismock.team.create({
data: {
id: 201,
name: "Team in Org 1",
slug: "team-org-1",
parentId: org1.id,
},
});
const org2Admin = await prismock.user.create({
data: {
username: "org2admin",
email: "org2admin@example.com",
organizationId: org2.id,
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Org 1 Team Event",
slug: "org1-team-event",
length: 30,
teamId: team1.id,
},
include: {
team: true,
users: true,
},
});
const result = await getRawEventType({
userId: org2Admin.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: true,
currentOrganizationId: org2.id,
prisma: prismock as any,
});
expect(result).toBeNull();
});
test("should fallback to regular user access when org admin flag is true but no organizationId", async () => {
const user = await prismock.user.create({
data: {
username: "regularuser",
email: "regularuser@example.com",
},
});
const eventType = await prismock.eventType.create({
data: {
title: "Regular User Event",
slug: "regular-user-event",
length: 30,
userId: user.id,
users: {
connect: [{ id: user.id }],
},
},
include: {
users: true,
owner: true,
},
});
const result = await getRawEventType({
userId: user.id,
eventTypeId: eventType.id,
isUserOrganizationAdmin: true,
currentOrganizationId: null,
prisma: prismock as any,
});
expect(result).toBeDefined();
expect(result?.id).toBe(eventType.id);
expect(result?.userId).toBe(user.id);
});
});
});
+29 -2
View File
@@ -53,8 +53,13 @@ export const getEventTypeById = async ({
timeZone: true,
} satisfies Prisma.UserSelect;
const eventTypeRepo = new EventTypeRepository(prisma);
const rawEventType = await eventTypeRepo.findById({ id: eventTypeId, userId });
const rawEventType = await getRawEventType({
userId,
eventTypeId,
isUserOrganizationAdmin,
currentOrganizationId,
prisma,
});
if (!rawEventType) {
if (isTrpcCall) {
@@ -260,4 +265,26 @@ export const getEventTypeById = async ({
return finalObj;
};
export async function getRawEventType({
userId,
eventTypeId,
isUserOrganizationAdmin,
currentOrganizationId,
prisma,
}: Omit<getEventTypeByIdProps, "isTrpcCall">) {
const eventTypeRepo = new EventTypeRepository(prisma);
if (isUserOrganizationAdmin && currentOrganizationId) {
return await eventTypeRepo.findByIdForOrgAdmin({
id: eventTypeId,
organizationId: currentOrganizationId,
});
}
return await eventTypeRepo.findById({
id: eventTypeId,
userId,
});
}
export default getEventTypeById;
@@ -799,6 +799,282 @@ export class EventTypeRepository {
});
}
async findByIdForOrgAdmin({ id, organizationId }: { id: number; organizationId: number }) {
const userSelect = {
name: true,
avatarUrl: true,
username: true,
id: true,
email: true,
locale: true,
defaultScheduleId: true,
isPlatformManaged: true,
timeZone: true,
} satisfies Prisma.UserSelect;
const CompleteEventTypeSelect = {
id: true,
title: true,
slug: true,
description: true,
interfaceLanguage: true,
length: true,
isInstantEvent: true,
instantMeetingExpiryTimeOffsetInSeconds: true,
instantMeetingParameters: true,
aiPhoneCallConfig: true,
offsetStart: true,
hidden: true,
locations: true,
eventName: true,
customInputs: true,
timeZone: true,
periodType: true,
metadata: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresConfirmationForFreeEmail: true,
canSendCalVideoTranscriptionEmails: true,
requiresConfirmationWillBlockSlot: true,
requiresBookerEmailVerification: true,
autoTranslateDescriptionEnabled: true,
fieldTranslations: {
select: {
translatedText: true,
targetLocale: true,
field: true,
},
},
recurringEvent: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
disableGuests: true,
disableCancelling: true,
disableRescheduling: true,
allowReschedulingCancelledBookings: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
hashedLink: hashedLinkSelect,
eventTypeColor: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
maxActiveBookingsPerBooker: true,
maxActiveBookingPerBookerOfferReschedule: true,
assignAllTeamMembers: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
rescheduleWithSameRoundRobinHost: true,
successRedirectUrl: true,
forwardParamsSuccessRedirect: true,
currency: true,
bookingFields: true,
useEventTypeDestinationCalendarEmail: true,
customReplyToEmail: true,
owner: {
select: {
id: true,
timeZone: true,
},
},
parent: {
select: {
id: true,
teamId: true,
},
},
teamId: true,
team: {
select: {
id: true,
name: true,
slug: true,
parentId: true,
rrTimestampBasis: true,
parent: {
select: {
slug: true,
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,
},
},
},
},
members: {
select: {
role: true,
accepted: true,
user: {
select: {
...userSelect,
eventTypes: {
select: {
slug: true,
},
},
},
},
},
},
},
},
restrictionScheduleId: true,
useBookerTimezone: true,
users: {
select: userSelect,
},
schedulingType: true,
schedule: {
select: {
id: true,
name: true,
},
},
instantMeetingSchedule: {
select: {
id: true,
name: true,
},
},
restrictionSchedule: {
select: {
id: true,
name: true,
},
},
hosts: {
select: {
isFixed: true,
userId: true,
priority: true,
weight: true,
scheduleId: true,
user: {
select: {
timeZone: true,
},
},
},
},
userId: true,
price: true,
children: {
select: {
owner: {
select: {
avatarUrl: true,
name: true,
username: true,
email: true,
id: true,
},
},
hidden: true,
slug: true,
},
},
destinationCalendar: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
webhooks: {
select: {
id: true,
subscriberUrl: true,
payloadTemplate: true,
active: true,
eventTriggers: true,
secret: true,
eventTypeId: true,
},
},
workflows: {
include: {
workflow: {
select: {
name: true,
id: true,
trigger: true,
time: true,
timeUnit: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
},
},
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
},
},
},
},
secondaryEmailId: true,
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
useEventLevelSelectedCalendars: true,
calVideoSettings: {
select: {
disableRecordingForGuests: true,
disableRecordingForOrganizer: true,
enableAutomaticTranscription: true,
enableAutomaticRecordingForOrganizer: true,
disableTranscriptionForGuests: true,
disableTranscriptionForOrganizer: true,
redirectUrlOnExit: true,
},
},
} satisfies Prisma.EventTypeSelect;
const orgUserEventTypeQuery = {
AND: [{ userId: { not: null } }, { owner: { profiles: { some: { organizationId } } } }],
};
const orgTeamEventTypeQuery = {
AND: [{ teamId: { not: null } }, { team: { parentId: organizationId } }],
};
return await this.prismaClient.eventType.findFirst({
where: {
AND: [
{ id },
{
OR: [orgUserEventTypeQuery, orgTeamEventTypeQuery],
},
],
},
select: CompleteEventTypeSelect,
});
}
async findByIdMinimal({ id }: { id: number }) {
return await this.prismaClient.eventType.findUnique({
where: {
@@ -13,8 +13,8 @@ import "@calcom/atoms/globals.min.css";
const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] });
type TUser = Data["users"][0];
function generateRandomEmail() {
const localPartLength = 10;
function generateRandomEmail(name: string) {
const localPartLength = 5;
const domain = ["example.com", "example.net", "example.org"];
const randomLocalPart = Array.from({ length: localPartLength }, () =>
@@ -23,7 +23,7 @@ function generateRandomEmail() {
const randomDomain = domain[Math.floor(Math.random() * domain.length)];
return `${randomLocalPart}@${randomDomain}`;
return `${name}-${randomLocalPart}@${randomDomain}`;
}
// note(Lauris): needed because useEffect kicks in twice creating 2 parallel requests
@@ -50,11 +50,11 @@ export default function App({ Component, pageProps }: AppProps) {
}, []);
useEffect(() => {
const randomEmailOne = generateRandomEmail();
const randomEmailTwo = generateRandomEmail();
const randomEmailThree = generateRandomEmail();
const randomEmailFour = generateRandomEmail();
const randomEmailFive = generateRandomEmail();
const randomEmailOne = generateRandomEmail("keith");
const randomEmailTwo = generateRandomEmail("somay");
const randomEmailThree = generateRandomEmail("rajiv");
const randomEmailFour = generateRandomEmail("morgan");
const randomEmailFive = generateRandomEmail("lauris");
if (!seeding) {
seeding = true;
@@ -12,6 +12,49 @@ type Data = {
accessToken: string;
};
async function createUserWithDefaultSchedule(email: string, name: string, avatarUrl: string) {
const localUser = await prisma.user.create({
data: {
email,
},
});
const managedUserResponse = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email,
name,
avatarUrl,
}),
}
);
const managedUserResponseBody = await managedUserResponse.json();
await prisma.user.update({
data: {
refreshToken: (managedUserResponseBody.data?.refreshToken as string) ?? "",
accessToken: (managedUserResponseBody.data?.accessToken as string) ?? "",
calcomUserId: managedUserResponseBody.data?.user.id,
calcomUsername: (managedUserResponseBody.data?.user.username as string) ?? "",
},
where: { id: localUser.id },
});
await createDefaultSchedule(managedUserResponseBody.data?.accessToken as string);
return managedUserResponseBody.data;
}
// example endpoint to create a managed cal.com user
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const { emails } = JSON.parse(req.body);
@@ -31,195 +74,31 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
});
}
const localUserOne = await prisma.user.create({
data: {
email: emailOne,
},
});
const localUserTwo = await prisma.user.create({
data: {
email: emailTwo,
},
});
const localUserThree = await prisma.user.create({
data: {
email: emailThree,
},
});
const localUserFour = await prisma.user.create({
data: {
email: emailFour,
},
});
const localUserFive = await prisma.user.create({
data: {
email: emailFive,
},
});
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailOne,
name: "John Jones",
avatarUrl:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
const managedUserResponseOne = await createUserWithDefaultSchedule(
emailOne,
"Keith",
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
const body = await response.json();
await prisma.user.update({
data: {
refreshToken: (body.data?.refreshToken as string) ?? "",
accessToken: (body.data?.accessToken as string) ?? "",
calcomUserId: body.data?.user.id,
calcomUsername: (body.data?.user.username as string) ?? "",
},
where: { id: localUserOne.id },
});
const responseTwo = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailTwo,
name: "Jane Doe",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
const managedUserResponseTwo = await createUserWithDefaultSchedule(
emailTwo,
"Somay",
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
const bodyTwo = await responseTwo.json();
await prisma.user.update({
data: {
refreshToken: (bodyTwo.data?.refreshToken as string) ?? "",
accessToken: (bodyTwo.data?.accessToken as string) ?? "",
calcomUserId: bodyTwo.data?.user.id,
calcomUsername: (bodyTwo.data?.user.username as string) ?? "",
},
where: { id: localUserTwo.id },
});
const responseThree = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailThree,
name: "Rajiv",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
const managedUserResponseThree = await createUserWithDefaultSchedule(
emailThree,
"Rajiv",
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
const bodyThree = await responseThree.json();
await prisma.user.update({
data: {
refreshToken: (bodyThree.data?.refreshToken as string) ?? "",
accessToken: (bodyThree.data?.accessToken as string) ?? "",
calcomUserId: bodyThree.data?.user.id,
calcomUsername: (bodyThree.data?.user.username as string) ?? "",
},
where: { id: localUserThree.id },
});
const responseFour = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailFour,
name: "Morgan",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
const managedUserResponseFour = await createUserWithDefaultSchedule(
emailFour,
"Morgan",
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
const bodyFour = await responseFour.json();
await prisma.user.update({
data: {
refreshToken: (bodyFour.data?.refreshToken as string) ?? "",
accessToken: (bodyFour.data?.accessToken as string) ?? "",
calcomUserId: bodyFour.data?.user.id,
calcomUsername: (bodyFour.data?.user.username as string) ?? "",
},
where: { id: localUserFour.id },
});
const responseFive = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailFive,
name: "Lauris",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
const managedUserResponseFive = await createUserWithDefaultSchedule(
emailFive,
"Lauris",
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
);
const bodyFive = await responseFive.json();
await prisma.user.update({
data: {
refreshToken: (bodyFive.data?.refreshToken as string) ?? "",
accessToken: (bodyFive.data?.accessToken as string) ?? "",
calcomUserId: bodyFive.data?.user.id,
calcomUsername: (bodyFive.data?.user.username as string) ?? "",
},
where: { id: localUserFive.id },
});
await createDefaultSchedule(body.data?.accessToken as string);
await createDefaultSchedule(bodyTwo.data?.accessToken as string);
await createDefaultSchedule(bodyThree.data?.accessToken as string);
await createDefaultSchedule(bodyFour.data?.accessToken as string);
await createDefaultSchedule(bodyFive.data?.accessToken as string);
// eslint-disable-next-line turbo/no-undeclared-env-vars
const organizationId = process.env.ORGANIZATION_ID;
@@ -227,27 +106,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
throw new Error("Organization ID is not set");
}
const team = await createTeam(+organizationId, "Team Doe");
const team = await createTeam(+organizationId, `Platform devs - ${Date.now()}`);
if (!team) {
throw new Error("Failed to create team. Probably your platform team does not have required plan.");
}
await createMembership(+organizationId, team.id, body.data?.user.id);
await createMembership(+organizationId, team.id, bodyTwo.data?.user.id);
await createMembership(+organizationId, team.id, bodyThree.data?.user.id);
await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseOne.user.id);
await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseTwo.user.id);
await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseThree.user.id);
await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseFour.user.id);
await createCollectiveEventType(+organizationId, team.id, [
body.data?.user.id,
bodyTwo.data?.user.id,
bodyThree.data?.user.id,
bodyFour.data?.user.id,
bodyFive.data?.user.id,
managedUserResponseOne.user.id,
managedUserResponseTwo.user.id,
managedUserResponseThree.user.id,
managedUserResponseFour.user.id,
]);
await createRoundRobinEventType(+organizationId, team.id, [
managedUserResponseOne.user.id,
managedUserResponseTwo.user.id,
managedUserResponseThree.user.id,
managedUserResponseFour.user.id,
]);
await createOrgMembershipAdmin(+organizationId, managedUserResponseFive.user.id);
return res.status(200).json({
id: body?.data?.user?.id,
email: (body.data?.user.email as string) ?? "",
username: (body.data?.username as string) ?? "",
accessToken: (body.data?.accessToken as string) ?? "",
id: managedUserResponseOne?.user?.id,
email: (managedUserResponseOne.user.email as string) ?? "",
username: (managedUserResponseOne.user.username as string) ?? "",
accessToken: (managedUserResponseOne.accessToken as string) ?? "",
});
}
@@ -276,10 +165,33 @@ async function createTeam(orgId: number, name: string) {
return body.data;
}
async function createMembership(orgId: number, teamId: number, userId: number) {
async function createOrgTeamMembershipMember(orgId: number, teamId: number, userId: number) {
await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
userId,
accepted: true,
role: "MEMBER",
}),
}
);
}
async function createOrgMembershipAdmin(orgId: number, userId: number) {
await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`,
{
method: "POST",
headers: {
@@ -315,9 +227,34 @@ async function createCollectiveEventType(orgId: number, teamId: number, userIds:
},
body: JSON.stringify({
lengthInMinutes: 60,
title: "Doe collective",
slug: "doe-collective",
schedulingType: "COLLECTIVE",
title: "Platform example collective",
slug: "platform-example-collective",
schedulingType: "collective",
hosts: userIds.map((userId) => ({ userId })),
}),
}
);
}
async function createRoundRobinEventType(orgId: number, teamId: number, userIds: number[]) {
await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
lengthInMinutes: 60,
title: "Platform example round robin",
slug: "platform-example-round-robin",
schedulingType: "roundRobin",
hosts: userIds.map((userId) => ({ userId })),
}),
}
@@ -743,6 +743,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
onSuccess={(eventType) => {
setEventTypeId(null);
refetch();
refetchTeamEvents();
}}
onError={(eventType, error) => {
console.log(eventType);
@@ -93,7 +93,8 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
if (
!isSystemAdmin &&
(!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || isOrgAdmin))
!isOrgAdmin &&
(!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role))
) {
console.warn(`User ${userId} does not have permission to create this new event type`);
throw new TRPCError({ code: "UNAUTHORIZED" });
+5 -5
View File
@@ -2519,7 +2519,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3566,13 +3566,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.267":
version: 0.0.267
resolution: "@calcom/platform-libraries@npm:0.0.267"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.268":
version: 0.0.268
resolution: "@calcom/platform-libraries@npm:0.0.268"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 0c385514a04ddbd96ab3277774815233801a6eaf9a0d406b473eb7a446602f6f7d98de7f16747893b9fe1d50257db13b050a5726c8e7fe9500750d3ebf7be23a
checksum: d361c067dd33e3807c3ba965b3582eadaeec2ef1e2a3114c68e0c2ab1d7149fcca2c72ce00f8f3f341bdf3caeca578898d3345d8615c58da3a66e541e62d2ca5
languageName: node
linkType: hard