diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index 586adbb500..1b332ae113 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -280,7 +280,7 @@ export class BookingsController_2024_08_13 { const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, { email: user.email, id: user.id, - orgId: profile?.organizationId, + orgId: profile?.organizationId ?? undefined, }); return { diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index 44a1bde054..44e9ddef62 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -635,6 +635,7 @@ describe("Event types Endpoints", () => { expect(createdEventType.bookingFields).toEqual(expectedBookingFields); expect(createdEventType.bookingRequiresAuthentication).toEqual(true); + expect(createdEventType.bookingUrl).toContain(`/${user.username}/${body.slug}`); expect(createdEventType.disableCancelling).toEqual({ disabled: true }); expect(createdEventType.disableRescheduling).toEqual({ disabled: true }); expect(createdEventType.calVideoSettings?.sendTranscriptionEmails).toEqual(true); @@ -731,6 +732,7 @@ describe("Event types Endpoints", () => { ); expect(fetchedEventType?.color).toEqual(eventType.color); expect(fetchedEventType?.hidden).toEqual(false); + expect(fetchedEventType?.bookingUrl).toContain(`/${user.username}/${eventType.slug}`); expect(fetchedEventType?.disableCancelling).toEqual(eventType.disableCancelling); expect(fetchedEventType?.disableRescheduling).toEqual(eventType.disableRescheduling); expect(fetchedEventType?.calVideoSettings?.sendTranscriptionEmails).toEqual( @@ -1551,6 +1553,7 @@ describe("Event types Endpoints", () => { eventType.lockTimeZoneToggleOnBookingPage ); expect(fetchedEventType.color).toEqual(eventType.color); + expect(fetchedEventType.bookingUrl).toContain(`/${user.username}/${eventType.slug}`); }); it("system admin can access another user's event type by id", async () => { @@ -2677,12 +2680,19 @@ describe("Event types Endpoints", () => { expect(createdEventType.title).toEqual(body.title); secondCreatedEventType = responseBody.data; - const { id, title, slug, ...restFirst } = firstCreatedEventType; - const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType; + const { id, title, slug, bookingUrl, ...restFirst } = firstCreatedEventType; + const { + id: id2, + title: title2, + slug: slug2, + bookingUrl: bookingUrl2, + ...restSecond + } = secondCreatedEventType; expect(restFirst).toEqual(restSecond); expect(id2).not.toEqual(id); expect(title2).not.toEqual(title); expect(slug2).not.toEqual(slug); + expect(bookingUrl2).not.toEqual(bookingUrl); }); }); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts index 63af9164fd..f81c81d7dc 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -1,14 +1,16 @@ +import type { SortOrderType } from "@calcom/platform-types"; +import type { Prisma } from "@calcom/prisma/client"; +import { Injectable } from "@nestjs/common"; import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; -import { Injectable } from "@nestjs/common"; - -import type { SortOrderType } from "@calcom/platform-types"; -import type { Prisma } from "@calcom/prisma/client"; @Injectable() export class EventTypesRepository_2024_06_14 { - constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService + ) {} async createUserEventType( userId: number, @@ -58,13 +60,58 @@ export class EventTypesRepository_2024_06_14 { }); } + private readonly usersInclude = { + select: { + id: true, + name: true, + username: true, + avatarUrl: true, + brandColor: true, + darkBrandColor: true, + weekStart: true, + metadata: true, + organizationId: true, + organization: { + select: { slug: true }, + }, + movedToProfile: { + select: { + id: true, + username: true, + organizationId: true, + organization: { + select: { + id: true, + slug: true, + isPlatform: true, + }, + }, + }, + }, + profiles: { + select: { + id: true, + username: true, + organizationId: true, + organization: { + select: { + id: true, + slug: true, + isPlatform: true, + }, + }, + }, + }, + }, + }; + async getUserEventType(userId: number, eventTypeId: number) { return this.dbRead.prisma.eventType.findFirst({ where: { id: eventTypeId, userId, }, - include: { users: true, schedule: true, destinationCalendar: true }, + include: { users: this.usersInclude, schedule: true, destinationCalendar: true }, }); } @@ -74,7 +121,7 @@ export class EventTypesRepository_2024_06_14 { userId, }, ...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }), - include: { users: true, schedule: true, destinationCalendar: true }, + include: { users: this.usersInclude, schedule: true, destinationCalendar: true }, }); } @@ -85,14 +132,19 @@ export class EventTypesRepository_2024_06_14 { hidden: false, }, ...(sortCreatedAt && { orderBy: { id: sortCreatedAt } }), - include: { users: true, schedule: true, destinationCalendar: true }, + include: { users: this.usersInclude, schedule: true, destinationCalendar: true }, }); } async getEventTypeById(eventTypeId: number) { return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId }, - include: { users: true, schedule: true, destinationCalendar: true, calVideoSettings: true }, + include: { + users: this.usersInclude, + schedule: true, + destinationCalendar: true, + calVideoSettings: true, + }, }); } @@ -100,7 +152,7 @@ export class EventTypesRepository_2024_06_14 { return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId }, include: { - users: true, + users: this.usersInclude, schedule: true, destinationCalendar: true, calVideoSettings: true, @@ -140,7 +192,7 @@ export class EventTypesRepository_2024_06_14 { slug: slug, }, }, - include: { users: true, schedule: true, destinationCalendar: true }, + include: { users: this.usersInclude, schedule: true, destinationCalendar: true }, }); } diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.spec.ts new file mode 100644 index 0000000000..503642631b --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.spec.ts @@ -0,0 +1,229 @@ +import { ConfigService } from "@nestjs/config"; + +import { OutputEventTypesService_2024_06_14 } from "./output-event-types.service"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository } from "@/modules/users/users.repository"; + +jest.mock("@calcom/platform-libraries/organizations", () => ({ + getBookerBaseUrlSync: jest.fn((slug: string | null) => { + if (!slug) return "https://cal.com"; + return `https://${slug}.cal.com`; + }), +})); + +describe("OutputEventTypesService_2024_06_14", () => { + let service: OutputEventTypesService_2024_06_14; + let configService: jest.Mocked; + let usersService: UsersService; + + beforeEach(() => { + configService = { + get: jest.fn((key: string, defaultValue?: string) => { + if (key === "app.baseUrl") return "https://cal.com"; + return defaultValue; + }), + } as any; + + const usersRepository = {} as UsersRepository; + usersService = new UsersService(usersRepository); + + service = new OutputEventTypesService_2024_06_14(configService, usersService); + }); + + describe("buildBookingUrl", () => { + it("should return correct URL for user without organization", () => { + const user = { + id: 1, + name: "John Doe", + username: "john-doe", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: null, + organization: null, + movedToProfile: null, + profiles: [], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe("https://cal.com/john-doe/30min"); + }); + + it("should use profile username for org users", () => { + const user = { + id: 1, + name: "Owner", + username: "owner1-acme", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: 1, + organization: { slug: "acme" }, + movedToProfile: null, + profiles: [ + { + id: 100, + username: "owner1", + organizationId: 1, + organization: { id: 1, slug: "acme", isPlatform: false }, + }, + ], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe("https://acme.cal.com/owner1/30min"); + }); + + it("should fall back to user username when profile has no username", () => { + const user = { + id: 1, + name: "Keith", + username: "keith", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: 2, + organization: { slug: "i" }, + movedToProfile: null, + profiles: [ + { + id: 200, + username: null, + organizationId: 2, + organization: { id: 2, slug: "i", isPlatform: false }, + }, + ], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe("https://i.cal.com/keith/30min"); + }); + + it("should return empty string when user is undefined", () => { + const user = undefined; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe(""); + }); + + it("should handle organization with null slug", () => { + const user = { + id: 1, + name: "User", + username: "user", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: 3, + organization: { slug: null }, + movedToProfile: null, + profiles: [ + { + id: 300, + username: "user", + organizationId: 3, + organization: { id: 3, slug: null, isPlatform: false }, + }, + ], + }; + const slug = "consultation"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe("https://cal.com/user/consultation"); + }); + + it("should handle base URL with trailing slash", () => { + const { getBookerBaseUrlSync } = require("@calcom/platform-libraries/organizations"); + getBookerBaseUrlSync.mockReturnValueOnce("https://acme.cal.com/"); + + const user = { + id: 1, + name: "John", + username: "john-acme", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: 1, + organization: { slug: "acme" }, + movedToProfile: null, + profiles: [ + { + id: 400, + username: "john", + organizationId: 1, + organization: { id: 1, slug: "acme", isPlatform: false }, + }, + ], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe("https://acme.cal.com/john/30min"); + }); + + it("should return empty string when username is empty", () => { + const user = { + id: 1, + name: "User", + username: "", + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: null, + organization: null, + movedToProfile: null, + profiles: [], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe(""); + }); + + it("should return empty string when username is null", () => { + const user = { + id: 1, + name: "User", + username: null, + avatarUrl: null, + brandColor: null, + darkBrandColor: null, + weekStart: "Monday", + metadata: {}, + organizationId: null, + organization: null, + movedToProfile: null, + profiles: [], + }; + const slug = "30min"; + + const result = service.buildBookingUrl(user, slug); + + expect(result).toBe(""); + }); + }); +}); + diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index ea7207a8b6..1719913757 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -1,41 +1,65 @@ import { - transformLocationsInternalToApi, - transformBookingFieldsInternalToApi, - InternalLocationSchema, - SystemField, - CustomField, - transformIntervalLimitsInternalToApi, - transformFutureBookingLimitsInternalToApi, - transformRecurrenceInternalToApi, - transformBookerLayoutsInternalToApi, - transformRequiresConfirmationInternalToApi, - transformEventTypeColorsInternalToApi, - transformSeatsInternalToApi, - InternalLocation, - BookingFieldSchema, -} from "@/ee/event-types/event-types_2024_06_14/transformers"; -import { Injectable } from "@nestjs/common"; - -import { - userMetadata, + getBookingFieldsWithSystemFields, parseBookingLimit, parseRecurringEvent, - getBookingFieldsWithSystemFields, + userMetadata, } from "@calcom/platform-libraries"; import { EventTypeMetaDataSchema, parseEventTypeColor } from "@calcom/platform-libraries/event-types"; +import { getBookerBaseUrlSync } from "@calcom/platform-libraries/organizations"; import type { - TransformFutureBookingsLimitSchema_2024_06_14, BookerLayoutsTransformedSchema, - NoticeThresholdTransformedSchema, EventTypeOutput_2024_06_14, - OutputUnknownLocation_2024_06_14, - OutputUnknownBookingField_2024_06_14, + NoticeThresholdTransformedSchema, OutputBookingField_2024_06_14, + OutputUnknownBookingField_2024_06_14, + OutputUnknownLocation_2024_06_14, + TransformFutureBookingsLimitSchema_2024_06_14, } from "@calcom/platform-types"; -import type { EventType, User, Schedule, DestinationCalendar, CalVideoSettings } from "@calcom/prisma/client"; +import type { + CalVideoSettings, + DestinationCalendar, + EventType, + Prisma, + Schedule, + Team, +} from "@calcom/prisma/client"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + BookingFieldSchema, + CustomField, + InternalLocation, + InternalLocationSchema, + SystemField, + transformBookerLayoutsInternalToApi, + transformBookingFieldsInternalToApi, + transformEventTypeColorsInternalToApi, + transformFutureBookingLimitsInternalToApi, + transformIntervalLimitsInternalToApi, + transformLocationsInternalToApi, + transformRecurrenceInternalToApi, + transformRequiresConfirmationInternalToApi, + transformSeatsInternalToApi, +} from "@/ee/event-types/event-types_2024_06_14/transformers"; +import { ProfileMinimal, UsersService } from "@/modules/users/services/users.service"; + +type EventTypeUser = { + id: number; + name: string | null; + username: string | null; + avatarUrl: string | null; + brandColor: string | null; + darkBrandColor: string | null; + weekStart: string; + metadata: Prisma.JsonValue; + organizationId: number | null; + organization?: { slug: string | null } | null; + movedToProfile?: ProfileMinimal | null; + profiles?: ProfileMinimal[]; +}; type EventTypeRelations = { - users: User[]; + users: EventTypeUser[]; schedule: Schedule | null; destinationCalendar?: DestinationCalendar | null; calVideoSettings?: CalVideoSettings | null; @@ -106,6 +130,11 @@ type Input = Pick< @Injectable() export class OutputEventTypesService_2024_06_14 { + constructor( + private readonly configService: ConfigService, + private readonly usersService: UsersService + ) {} + getResponseEventType( ownerId: number, databaseEventType: Input, @@ -184,6 +213,7 @@ export class OutputEventTypesService_2024_06_14 { } as TransformFutureBookingsLimitSchema_2024_06_14); const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar); const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType); + const bookingUrl = this.buildBookingUrl(databaseEventType.users[0], slug); const disableReschedulingOutput = this.transformDisableRescheduling( disableRescheduling, minimumRescheduleNotice @@ -239,6 +269,7 @@ export class OutputEventTypesService_2024_06_14 { hidden, bookingRequiresAuthentication, bookerActiveBookingsLimit, + bookingUrl, disableCancelling: disableCancellingOutput, disableRescheduling: disableReschedulingOutput, interfaceLanguage, @@ -342,7 +373,7 @@ export class OutputEventTypesService_2024_06_14 { return EventTypeMetaDataSchema.parse(metadata); } - transformUsers(users: User[]) { + transformUsers(users: EventTypeUser[]) { return users.map((user) => { const metadata = user.metadata ? userMetadata.parse(user.metadata) : {}; return { @@ -405,6 +436,26 @@ export class OutputEventTypesService_2024_06_14 { }); } + buildBookingUrl(user: EventTypeUser | undefined, slug: string): string { + if (!user) { + return ""; + } + + const profile = this.usersService.getUserMainProfile(user); + const username = profile?.username ?? user.username; + + if (!username) { + return ""; + } + + const orgSlug = profile?.organization?.slug ?? null; + const webAppUrl = this.configService.get("app.baseUrl", "https://app.cal.com"); + const baseUrl = orgSlug ? getBookerBaseUrlSync(orgSlug) : webAppUrl; + const normalizedBaseUrl = baseUrl.replace(/\/$/, ""); + + return `${normalizedBaseUrl}/${username}/${slug}`; + } + getResponseEventTypesWithoutHiddenFields( eventTypes: EventTypeOutput_2024_06_14[] ): EventTypeOutput_2024_06_14[] { diff --git a/apps/api/v2/src/modules/users/services/users.service.ts b/apps/api/v2/src/modules/users/services/users.service.ts index e66e2ff281..3a1e3cc9a3 100644 --- a/apps/api/v2/src/modules/users/services/users.service.ts +++ b/apps/api/v2/src/modules/users/services/users.service.ts @@ -3,6 +3,23 @@ import { Injectable } from "@nestjs/common"; import type { User } from "@calcom/prisma/client"; +export type ProfileMinimal = { + id: number; + username?: string | null; + organizationId: number | null; + organization: { + id: number; + slug?: string | null; + isPlatform: boolean; + } | null; +}; + +export type UserWithProfileMinimal = { + organizationId?: number | null; + movedToProfile?: ProfileMinimal | null; + profiles?: ProfileMinimal[]; +}; + @Injectable() export class UsersService { constructor(private readonly usersRepository: UsersRepository) {} @@ -22,7 +39,7 @@ export class UsersService { return users; } - getUserMainProfile(user: UserWithProfile) { + getUserMainProfile(user: UserWithProfileMinimal): ProfileMinimal | undefined { return ( user?.movedToProfile || user.profiles?.find((p) => p.organizationId === user.organizationId) || @@ -30,8 +47,8 @@ export class UsersService { ); } - getUserMainOrgId(user: UserWithProfile) { - return this.getUserMainProfile(user)?.organizationId ?? user.organizationId; + getUserMainOrgId(user: UserWithProfileMinimal): number | null { + return this.getUserMainProfile(user)?.organizationId ?? user.organizationId ?? null; } getUserProfileByOrgId(user: UserWithProfile, organizationId: number) { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 6965a18763..0e5ab88b51 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -21003,6 +21003,12 @@ "items": { "type": "string" } + }, + "bookingUrl": { + "type": "string", + "description": "Full URL to the booking page for this event type", + "example": "https://cal.com/john-doe/30min", + "format": "uri" } }, "required": [ @@ -21026,7 +21032,8 @@ "hidden", "bookingRequiresAuthentication", "ownerId", - "users" + "users", + "bookingUrl" ] }, "CreateEventTypeOutput_2024_06_14": { diff --git a/packages/platform/libraries/organizations.ts b/packages/platform/libraries/organizations.ts index 62ed49528c..0d46ef8949 100644 --- a/packages/platform/libraries/organizations.ts +++ b/packages/platform/libraries/organizations.ts @@ -2,3 +2,6 @@ export { getOrganizationRepository } from "@calcom/features/ee/organizations/di/ export { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository"; export { OrganizationMembershipService } from "@calcom/features/ee/organizations/lib/service/OrganizationMembershipService"; export type { IOrganizationRepository } from "@calcom/features/ee/organizations/lib/repository/IOrganizationRepository"; + +export { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; +export { getBookerBaseUrlSync } from "@calcom/features/ee/organizations/lib/getBookerBaseUrlSync"; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index 04efab7be6..e8748beed4 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -546,6 +546,14 @@ export class EventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_14 { @IsArray() @DocsProperty() users!: User_2024_06_14[]; + + @IsString() + @DocsProperty({ + description: "Full URL to the booking page for this event type", + example: "https://cal.com/john-doe/30min", + format: "uri", + }) + bookingUrl!: string; } export class TeamEventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_14 {