diff --git a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx index b4dfcd5bbb..99e86c6692 100644 --- a/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/d/[link]/[slug]/getServerSideProps.tsx @@ -6,9 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { shouldHideBrandingForTeamEvent, shouldHideBrandingForUserEvent } from "@calcom/lib/hideBranding"; import { EventRepository } from "@calcom/lib/server/repository/event"; import { UserRepository } from "@calcom/lib/server/repository/user"; +import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { RedirectType } from "@calcom/prisma/enums"; @@ -25,43 +27,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req); const org = isValidOrgDomain ? currentOrgDomain : null; - const hashedLink = await prisma.hashedLink.findUnique({ - where: { - link, - }, - select: { - eventTypeId: true, - eventType: { - select: { - users: { - select: { - username: true, - profiles: { - select: { - id: true, - organizationId: true, - username: true, - }, - }, - }, - }, - team: { - select: { - id: true, - slug: true, - hideBranding: true, - parent: { - select: { - hideBranding: true, - }, - }, - }, - }, - }, - }, - }, - }); - let name: string; let hideBranding = false; @@ -69,9 +34,22 @@ async function getUserPageProps(context: GetServerSidePropsContext) { notFound: true, } as const; + // Use centralized validation logic to avoid duplication + const hashedLinkService = new HashedLinkService(); + try { + await hashedLinkService.validate(link); + } catch (error) { + // Link is expired, invalid, or doesn't exist + return notFound; + } + + // If validation passes, fetch the complete data needed for rendering + const hashedLink = await hashedLinkService.findLinkWithDetails(link); + if (!hashedLink) { return notFound; } + const username = hashedLink.eventType.users[0]?.username; const profileUsername = hashedLink.eventType.users[0]?.profiles[0]?.username; @@ -139,8 +117,20 @@ async function getUserPageProps(context: GetServerSidePropsContext) { return notFound; } + // Check if team has API v2 feature flag enabled (same logic as team pages) + let useApiV2 = false; + if (isTeamEvent && hashedLink.eventType.team?.id) { + const featureRepo = new FeaturesRepository(); + const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature( + hashedLink.eventType.team.id, + "use-api-v2-for-team-slots" + ); + useApiV2 = teamHasApiV2Route; + } + return { props: { + useApiV2, eventData, entity: eventData.entity, duration: getMultipleDurationValue( @@ -156,7 +146,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) { // Sending the team event from the server, because this template file // is reused for both team and user events. isTeamEvent, - hashedLink: link, + hashedLink: hashedLink?.link, }, }; } diff --git a/apps/web/modules/d/[link]/d-type-view.tsx b/apps/web/modules/d/[link]/d-type-view.tsx index 9d1d75e0d0..b733cba23d 100644 --- a/apps/web/modules/d/[link]/d-type-view.tsx +++ b/apps/web/modules/d/[link]/d-type-view.tsx @@ -19,6 +19,7 @@ export default function Type({ hashedLink, durationConfig, eventData, + useApiV2, }: PageProps) { return ( @@ -34,6 +35,7 @@ export default function Type({ duration={duration} hashedLink={hashedLink} durationConfig={durationConfig} + useApiV2={useApiV2} /> diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index cb9bb9b347..b26652ca87 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -16,6 +16,8 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader"; import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants"; +import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils"; +import { filterActiveLinks } from "@calcom/lib/hashedLinksUtils"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver"; @@ -467,6 +469,14 @@ export const InfiniteEventTypeList = ({ return deleteDialogTypeSchedulingType === SchedulingType.MANAGED ? "_managed" : ""; }; + const userTimezone = extractHostTimezone({ + userId: firstItem.userId, + teamId: firstItem?.teamId, + hosts: firstItem?.hosts, + owner: firstItem?.owner, + team: firstItem?.team, + }); + return (
+ +
+ {linkDescription} +
); })} + + + ); + }} + /> + + + +
+ { + setSelectedType(value); + }}> + + {t("usage_based_expiration")} +

+ {selectedType !== "usage" + ? t("usage_based_generic_expiration_description") + : t( + maxUsageCount === 1 + ? "usage_based_expiration_description" + : "usage_based_expiration_description_plural", + { count: maxUsageCount || 0 } + )} +

+ {selectedType === "usage" && ( +
+ ) => { + const value = e.target.value === "" ? null : parseInt(e.target.value); + if (e.target.value === "") { + setMaxUsageCount(null); + } else if (!isNaN(Number(value)) && Number(value) > 0) { + setMaxUsageCount(value); + } + }} + /> +
+ )} +
+ + {t("time_based_expiration")} + {selectedType !== "time" + ? t("time_based_generic_expiration_description") + : t("time_based_expiration_description", { + date: dayjs(expiryDate).format("MMM DD, YYYY"), + })} + {selectedType === "time" && ( +
+ { + setExpiryDate(newDate); + }} + /> +
+ )} +
+
+
+ +
- - ); - }} - /> +
+
+
+ ); }; diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 253d12e3cd..bfb21d5644 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -40,6 +40,7 @@ import type { EventNameObjectType } from "@calcom/lib/event"; import { getEventName } from "@calcom/lib/event"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; +import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -108,7 +109,10 @@ type BookingField = z.infer; export type EventAdvancedBaseProps = Pick & { user?: Partial< - Pick + Pick< + RouterOutputs["viewer"]["me"]["get"], + "email" | "secondaryEmails" | "theme" | "defaultBookerLayouts" | "timeZone" + > >; isUserLoading?: boolean; showToast: (message: string, variant: "success" | "warning" | "error") => void; @@ -529,6 +533,14 @@ export const EventAdvancedTab = ({ } ); + const userTimeZone = extractHostTimezone({ + userId: eventType.userId, + teamId: eventType.teamId, + hosts: eventType.hosts, + owner: eventType.owner, + team: eventType.team, + }); + let verifiedSecondaryEmails = [ { label: user?.email || "", @@ -855,7 +867,12 @@ export const EventAdvancedTab = ({ }}> {!isManagedEventType && (
- +
)} diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 1e244ffbfa..be6600c70d 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -66,6 +66,13 @@ type PhoneCallConfig = { schedulerName?: string; }; +export type PrivateLinkWithOptions = { + link: string; + expiresAt?: Date | null; + maxUsageCount?: number | null; + usageCount?: number; +}; + export type FormValues = { id: number; title: string; @@ -89,7 +96,7 @@ export type FormValues = { schedulingType: SchedulingType | null; hidden: boolean; hideCalendarNotes: boolean; - multiplePrivateLinks: string[] | undefined; + multiplePrivateLinks: (string | PrivateLinkWithOptions)[] | undefined; eventTypeColor: z.infer; customReplyToEmail: string | null; locations: EventLocation[]; diff --git a/packages/lib/errorCodes.ts b/packages/lib/errorCodes.ts index b96691491e..1ea098ec78 100644 --- a/packages/lib/errorCodes.ts +++ b/packages/lib/errorCodes.ts @@ -24,4 +24,5 @@ export enum ErrorCode { BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error", RestrictionScheduleNotFound = "restriction_schedule_not_found_error", EventTypeNoHosts = "event_type_no_hosts", + PrivateLinkExpired = "private_link_expired", } diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 229e2798ff..ab9f901576 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -50,6 +50,7 @@ export const getEventTypeById = async ({ locale: true, defaultScheduleId: true, isPlatformManaged: true, + timeZone: true, } satisfies Prisma.UserSelect; const eventTypeRepo = new EventTypeRepository(prisma); diff --git a/packages/lib/hashedLinksUtils.ts b/packages/lib/hashedLinksUtils.ts new file mode 100644 index 0000000000..2a93780240 --- /dev/null +++ b/packages/lib/hashedLinksUtils.ts @@ -0,0 +1,145 @@ +import dayjs from "@calcom/dayjs"; +import { ErrorCode } from "@calcom/lib/errorCodes"; + +export type EventTypeForTimezone = { + userId?: number | null; + teamId?: number | null; + hosts?: Array<{ + user: { + timeZone: string | null; + } | null; + }> | null; + owner?: { + timeZone: string | null; + } | null; + team?: { + members?: Array<{ + user?: { + timeZone: string | null; + } | null; + }> | null; + } | null; +}; + +export type HashedLinkData = { + id: number; + expiresAt: Date | null; + maxUsageCount: number | null; + usageCount: number; + eventType: EventTypeForTimezone; +}; + +/** + * Extracts the host timezone from event type data + * @param eventType - Event type data with potential timezone information + * @returns Host timezone string or null if not found + */ +export function extractHostTimezone(eventType: EventTypeForTimezone): string { + if (eventType?.userId && eventType?.owner?.timeZone) { + // Personal event type - use owner's timezone + return eventType.owner.timeZone; + } else if (eventType?.teamId) { + // Team event type - try hosts first, then team members + if (eventType.hosts && eventType.hosts.length > 0 && eventType.hosts[0]?.user?.timeZone) { + return eventType.hosts[0].user.timeZone; + } else if ( + eventType.team?.members && + eventType.team.members.length > 0 && + eventType.team.members[0]?.user?.timeZone + ) { + return eventType.team.members[0]?.user?.timeZone; + } + } + return dayjs.tz.guess(); +} + +/** + * Core time-based expiration logic + * @param expiresAt - The expiration date + * @param timezone - Optional timezone for comparison + * @returns true if expired, false if valid + */ +function hasExpiryTimePassed(expiresAt: Date | string | null, timezone?: string | null): boolean { + if (!expiresAt) return false; + + if (timezone) { + const now = dayjs().tz(timezone); + const expiration = dayjs(expiresAt).tz(timezone); + return expiration.isBefore(now); + } else { + const now = dayjs(); + const expiration = dayjs(expiresAt); + return expiration.isBefore(now); + } +} + +/** + * Validates if a hashed link has expired based on time + * @param expiresAt - The expiration date + * @param eventType - Event type data for timezone extraction + * @returns true if expired, false if valid + */ +export function isTimeBasedExpired(expiresAt: Date | null, eventType: EventTypeForTimezone): boolean { + const hostTimezone = extractHostTimezone(eventType); + return hasExpiryTimePassed(expiresAt, hostTimezone); +} + +/** + * Validates if a hashed link has exceeded its usage limit + * @param usageCount - Current usage count + * @param maxUsageCount - Maximum allowed usage count + * @returns true if usage exceeded, false if valid + */ +export function isUsageBasedExpired(usageCount: number, maxUsageCount?: number | null): boolean { + if (!maxUsageCount || maxUsageCount <= 0) return false; + return usageCount >= maxUsageCount; +} + +/** + * Validates hashed link data for expiration + * @param linkData - The hashed link data to validate + * @throws Error with ErrorCode.PrivateLinkExpired if link is expired + */ +export function validateHashedLinkData(linkData: HashedLinkData): void { + if (isTimeBasedExpired(linkData.expiresAt, linkData.eventType)) { + throw new Error(ErrorCode.PrivateLinkExpired); + } + + if (isUsageBasedExpired(linkData.usageCount, linkData.maxUsageCount)) { + throw new Error(ErrorCode.PrivateLinkExpired); + } +} + +/** + * Checks if a link has expired + * @param link - Link object with expiration and usage data + * @param timezone - Optional timezone for time-based expiration + * @returns true if expired, false if valid + */ +export function isLinkExpired( + link: { + expiresAt?: Date | string | null; + maxUsageCount?: number | null; + usageCount?: number | null; + }, + timezone?: string | null +): boolean { + if (link.expiresAt) return hasExpiryTimePassed(link.expiresAt, timezone); + return isUsageBasedExpired(link.usageCount || 0, link.maxUsageCount); +} + +/** + * filters out expired links + * @param links - Array of links to filter + * @param timezone - Optional timezone for time-based expiration + * @returns Array of active (non-expired) links + */ +export function filterActiveLinks< + T extends { + expiresAt?: Date | null; + maxUsageCount?: number | null; + usageCount?: number | null; + } +>(links: T[], timezone?: string | null): T[] { + return links.filter((link) => !isLinkExpired(link, timezone)); +} diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index e4870aeae0..06c20e1f57 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -19,6 +19,11 @@ export const eventTypeSelect = { autoTranslateDescriptionEnabled: true, position: true, offsetStart: true, + owner: { + select: { + timeZone: true, + }, + }, profileId: true, eventName: true, parentId: true, diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index a88a230d02..fa3f8ff80b 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -19,6 +19,17 @@ import type { UserWithLegacySelectedCalendars } from "./user"; import { withSelectedCalendars } from "./user"; const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); + +const hashedLinkSelect = { + select: { + id: true, + link: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + }, +}; + type NotSupportedProps = "locations"; type IEventType = Ensure< Partial< @@ -50,6 +61,7 @@ const userSelect = { avatarUrl: true, username: true, id: true, + timeZone: true, } satisfies Prisma.UserSelect; function hostsWithSelectedCalendars( @@ -159,7 +171,7 @@ export class EventTypeRepository { const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, users: { select: userSelect }, children: { include: { @@ -171,6 +183,21 @@ export class EventTypeRepository { user: { select: userSelect }, }, }, + team: { + select: { + id: true, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, }; log.debug( @@ -274,7 +301,7 @@ export class EventTypeRepository { const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, }; log.debug( @@ -381,11 +408,12 @@ export class EventTypeRepository { avatarUrl: true, username: true, id: true, + timeZone: true, } satisfies Prisma.UserSelect; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, users: { select: userSelect, take: 5 }, children: { include: { @@ -398,6 +426,21 @@ export class EventTypeRepository { }, take: 5, }, + team: { + select: { + id: true, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, }; const teamMembership = await this.prismaClient.membership.findFirst({ @@ -478,6 +521,7 @@ export class EventTypeRepository { locale: true, defaultScheduleId: true, isPlatformManaged: true, + timeZone: true, } satisfies Prisma.UserSelect; const CompleteEventTypeSelect = { @@ -528,7 +572,7 @@ export class EventTypeRepository { beforeEventBuffer: true, afterEventBuffer: true, slotInterval: true, - hashedLink: true, + hashedLink: hashedLinkSelect, eventTypeColor: true, bookingLimits: true, onlyShowFirstAvailableSlot: true, @@ -551,6 +595,7 @@ export class EventTypeRepository { owner: { select: { id: true, + timeZone: true, }, }, parent: { @@ -626,6 +671,11 @@ export class EventTypeRepository { priority: true, weight: true, scheduleId: true, + user: { + select: { + timeZone: true, + }, + }, }, }, userId: true, diff --git a/packages/lib/server/repository/hashedLinkRepository.ts b/packages/lib/server/repository/hashedLinkRepository.ts new file mode 100644 index 0000000000..6220891bb0 --- /dev/null +++ b/packages/lib/server/repository/hashedLinkRepository.ts @@ -0,0 +1,262 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +export type HashedLinkInputType = { + link: string; + expiresAt?: Date | null; + maxUsageCount?: number | null; +}; + +export const hashedLinkSelect = { + id: true, + link: true, + eventTypeId: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + eventType: { + select: { + userId: true, + teamId: true, + users: { + select: { + username: true, + profiles: { + select: { + id: true, + organizationId: true, + username: true, + }, + }, + }, + }, + team: { + select: { + id: true, + slug: true, + hideBranding: true, + parent: { + select: { + hideBranding: true, + }, + }, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, + hosts: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + }, + profile: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + }, + owner: { + select: { + timeZone: true, + }, + }, + }, + }, +} satisfies Prisma.HashedLinkSelect; + +export class HashedLinkRepository { + constructor(private readonly prismaClient: PrismaClient) {} + + static create() { + return new HashedLinkRepository(prisma); + } + + async deleteLinks(eventTypeId: number, linksToDelete: string[]) { + if (linksToDelete.length === 0) return; + + return await this.prismaClient.hashedLink.deleteMany({ + where: { + eventTypeId, + link: { in: linksToDelete }, + }, + }); + } + + async createLink( + eventTypeId: number, + linkData: { link: string; expiresAt: Date | null; maxUsageCount?: number | null } + ) { + const data: Prisma.HashedLinkCreateManyInput = { + eventTypeId, + link: linkData.link, + expiresAt: linkData.expiresAt, + }; + + if (linkData.maxUsageCount && Number.isFinite(linkData.maxUsageCount)) { + data.maxUsageCount = linkData.maxUsageCount; + } + + return await this.prismaClient.hashedLink.create({ data }); + } + + async updateLink( + eventTypeId: number, + linkData: { link: string; expiresAt: Date | null; maxUsageCount?: number | null } + ) { + const updateData: Prisma.HashedLinkUpdateManyMutationInput = { + expiresAt: linkData.expiresAt, + }; + + if (typeof linkData.maxUsageCount === "number" && linkData.maxUsageCount !== null) { + updateData.maxUsageCount = linkData.maxUsageCount; + } + + return await this.prismaClient.hashedLink.updateMany({ + where: { + eventTypeId, + link: linkData.link, + }, + data: updateData, + }); + } + + async findLinksByEventTypeId(eventTypeId: number) { + return await this.prismaClient.hashedLink.findMany({ + where: { + eventTypeId, + }, + select: { + link: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + }, + }); + } + + async findLinkWithEventTypeDetails(linkId: string) { + return await this.prismaClient.hashedLink.findUnique({ + where: { + link: linkId, + }, + select: { + id: true, + link: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + eventTypeId: true, + eventType: { + select: { + teamId: true, + userId: true, + }, + }, + }, + }); + } + + async findLinkWithDetails(linkId: string) { + return await this.prismaClient.hashedLink.findUnique({ + where: { + link: linkId, + }, + select: hashedLinkSelect, + }); + } + + async findLinksWithEventTypeDetails(linkIds: string[]) { + return await this.prismaClient.hashedLink.findMany({ + where: { + link: { + in: linkIds, + }, + }, + select: { + id: true, + link: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + eventTypeId: true, + eventType: { + select: { + teamId: true, + userId: true, + }, + }, + }, + }); + } + + async findLinkWithValidationData(linkId: string) { + return await this.prismaClient.hashedLink.findUnique({ + where: { + link: linkId, + }, + select: { + id: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + eventType: { + select: { + userId: true, + teamId: true, + hosts: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + }, + profile: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + }, + owner: { + select: { + timeZone: true, + }, + }, + }, + }, + }, + }); + } + + async incrementUsage(linkId: number, maxUsageCount: number) { + return await this.prismaClient.hashedLink.update({ + where: { + id: linkId, + usageCount: { lt: maxUsageCount }, + }, + data: { + usageCount: { increment: 1 }, + }, + }); + } +} diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 01b4021d82..895475f51e 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -41,6 +41,7 @@ const userSelect = { avatarUrl: true, username: true, id: true, + timeZone: true, } satisfies Prisma.UserSelect; const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipWhereInput) => { @@ -164,6 +165,21 @@ export class MembershipRepository { user: { select: userSelect }, }, }, + team: { + select: { + id: true, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, }, // As required by getByViewHandler - Make it configurable orderBy: [ diff --git a/packages/lib/server/service/hashedLinkService.ts b/packages/lib/server/service/hashedLinkService.ts new file mode 100644 index 0000000000..4d55e196b8 --- /dev/null +++ b/packages/lib/server/service/hashedLinkService.ts @@ -0,0 +1,159 @@ +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { validateHashedLinkData } from "@calcom/lib/hashedLinksUtils"; + +import { HashedLinkRepository, type HashedLinkInputType } from "../repository/hashedLinkRepository"; +import { MembershipService } from "./membershipService"; + +type NormalizedLink = { + link: string; + expiresAt: Date | null; + maxUsageCount?: number | null; +}; + +export class HashedLinkService { + constructor( + private readonly hashedLinkRepository: HashedLinkRepository = HashedLinkRepository.create(), + private readonly membershipService: MembershipService = new MembershipService() + ) {} + + /** + * Normalizes link input to a consistent format + * @param input - String link or object with link and options + * @returns Normalized link object + */ + private normalizeLinkInput(input: string | HashedLinkInputType): NormalizedLink { + return typeof input === "string" + ? { link: input, expiresAt: null } + : { + link: input.link, + expiresAt: input.expiresAt ?? null, + maxUsageCount: input.maxUsageCount, + }; + } + + /** + * Handles multiple private links operations - orchestrates create, update, and delete + * @param eventTypeId - The ID of the event type + * @param multiplePrivateLinks - Array of links to create/update (can be strings or objects with expiration) + * @param connectedMultiplePrivateLinks - Array of existing link IDs for this event type + */ + async handleMultiplePrivateLinks({ + eventTypeId, + multiplePrivateLinks, + connectedMultiplePrivateLinks, + }: { + eventTypeId: number; + multiplePrivateLinks?: (string | HashedLinkInputType)[]; + connectedMultiplePrivateLinks: string[]; + }) { + if (!eventTypeId || eventTypeId <= 0) { + throw new Error("Invalid event type ID"); + } + + if (!multiplePrivateLinks || multiplePrivateLinks.length === 0) { + await this.hashedLinkRepository.deleteLinks(eventTypeId, connectedMultiplePrivateLinks); + return; + } + + const normalizedLinks = multiplePrivateLinks.map((input) => this.normalizeLinkInput(input)); + const currentLinks = normalizedLinks.map((l) => l.link); + + const currentLinksSet = new Set(currentLinks); + + const linksToDelete = connectedMultiplePrivateLinks.filter((link) => !currentLinksSet.has(link)); + await this.hashedLinkRepository.deleteLinks(eventTypeId, linksToDelete); + + const existingLinksSet = new Set(connectedMultiplePrivateLinks); + + for (const linkData of normalizedLinks) { + const exists = existingLinksSet.has(linkData.link); + if (!exists) { + await this.hashedLinkRepository.createLink(eventTypeId, linkData); + } else { + await this.hashedLinkRepository.updateLink(eventTypeId, linkData); + } + } + } + + /** + * Validates a link without incrementing usage count + * Handles both time-based and usage-based expiration with timezone awareness + * @param linkId - The hashed link ID to validate + * @returns The validated link data + * @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid + */ + async validate(linkId: string) { + if (!linkId || typeof linkId !== "string") { + throw new Error("Invalid link ID"); + } + + const hashedLink = await this.hashedLinkRepository.findLinkWithValidationData(linkId); + + if (!hashedLink) { + throw new Error(ErrorCode.PrivateLinkExpired); + } + + validateHashedLinkData(hashedLink); + + return hashedLink; + } + + /** + * Validates a link and increments usage count if valid + * Handles both time-based and usage-based expiration with timezone awareness + * @param linkId - The hashed link ID to validate + * @returns The validated link data + * @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid + */ + async validateAndIncrementUsage(linkId: string) { + const hashedLink = await this.validate(linkId); + if (hashedLink.expiresAt) return hashedLink; // Time-based links don't need usage count increment + + if (hashedLink.maxUsageCount && hashedLink.maxUsageCount > 0) { + try { + await this.hashedLinkRepository.incrementUsage(hashedLink.id, hashedLink.maxUsageCount); + } catch (updateError) { + throw new Error(ErrorCode.PrivateLinkExpired); + } + } + + return hashedLink; + } + + async findLinkWithDetails(linkId: string) { + const hashedLink = await this.hashedLinkRepository.findLinkWithDetails(linkId); + if (!hashedLink) { + return null; + } + + return hashedLink; + } + + /** + * Checks if a user has permission to access a link + * @param link - Link object with event type information + * @param userId - The user ID to check permissions for + * @returns true if user has permission, false otherwise + */ + async checkUserPermissionForLink( + link: { eventType: { teamId?: number | null; userId?: number | null } }, + userId: number + ): Promise { + if (!link?.eventType || !userId || userId <= 0) { + return false; + } + + // If it's a user event type, check if user owns it + if (link.eventType.userId) { + return link.eventType.userId === userId; + } + + //No user ownership and no team - deny access + if (!link.eventType.teamId) { + return false; + } + + const membership = await this.membershipService.checkMembership(link.eventType.teamId, userId); + return membership.isMember; + } +} diff --git a/packages/lib/server/service/membershipService.ts b/packages/lib/server/service/membershipService.ts new file mode 100644 index 0000000000..99a24ca64d --- /dev/null +++ b/packages/lib/server/service/membershipService.ts @@ -0,0 +1,41 @@ +import { MembershipRole } from "@calcom/prisma/enums"; + +import { MembershipRepository } from "../repository/membership"; + +export type MembershipCheckResult = { + isMember: boolean; + isAdmin: boolean; + isOwner: boolean; + role?: MembershipRole; +}; + +export class MembershipService { + constructor(private readonly membershipRepository: MembershipRepository = new MembershipRepository()) {} + + /** + * Checks the membership status of a user within a specific team. + */ + async checkMembership(teamId: number, userId: number): Promise { + const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ teamId, userId }); + + if (!membership || !membership.accepted) { + return { + isMember: false, + isAdmin: false, + isOwner: false, + role: undefined, + }; + } + + const { role } = membership; + const isOwner = role === MembershipRole.OWNER; + const isAdmin = isOwner || role === MembershipRole.ADMIN; + + return { + isMember: true, + isAdmin, + isOwner, + role, + }; + } +} diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 6abcfc9244..94fcb98228 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -73,7 +73,11 @@ export const useEventTypeForm = ({ durationLimits: eventType.durationLimits || undefined, length: eventType.length, hidden: eventType.hidden, - multiplePrivateLinks: eventType.hashedLink.map((link) => link.link), + multiplePrivateLinks: eventType.hashedLink.map((link) => ({ + link: link.link, + expiresAt: link.expiresAt, + maxUsageCount: link.maxUsageCount, + })), eventTypeColor: eventType.eventTypeColor || null, periodDates: { startDate: periodDates.startDate, diff --git a/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql b/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql new file mode 100644 index 0000000000..85d675067d --- /dev/null +++ b/packages/prisma/migrations/20250707145503_add_private_links_expiration_capability/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "HashedLink" ADD COLUMN "expiresAt" TIMESTAMP(3), +ADD COLUMN "maxUsageCount" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "usageCount" INTEGER NOT NULL DEFAULT 0; + +-- CreateIndex +CREATE INDEX "HashedLink_eventTypeId_idx" ON "HashedLink"("eventTypeId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9da776099e..8298a97a79 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1061,10 +1061,15 @@ model RateLimit { } model HashedLink { - id Int @id @default(autoincrement()) - link String @unique() - eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) - eventTypeId Int + id Int @id @default(autoincrement()) + link String @unique + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + expiresAt DateTime? + maxUsageCount Int @default(1) + usageCount Int @default(0) + + @@index([eventTypeId]) } model Account { diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index bb233e11b5..e563453285 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -8,6 +8,8 @@ import { ZCreateInputSchema } from "./create.schema"; import { ZDeleteInputSchema } from "./delete.schema"; import { ZDuplicateInputSchema } from "./duplicate.schema"; import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema"; +import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema"; +import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema"; import { ZGetTeamAndEventTypeOptionsSchema } from "./getTeamAndEventTypeOptions.schema"; import { get } from "./procedures/get"; import { ZUpdateInputSchema } from "./update.schema"; @@ -29,6 +31,9 @@ type BookingsRouterHandlerCache = { bulkUpdateToDefaultLocation?: typeof import("./bulkUpdateToDefaultLocation.handler").bulkUpdateToDefaultLocationHandler; }; +// Init the handler cache +const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {}; + export const eventTypesRouter = router({ // REVIEW: What should we name this procedure? getByViewer: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => { @@ -170,4 +175,22 @@ export const eventTypesRouter = router({ input, }); }), + + getHashedLink: authedProcedure.input(ZGetHashedLinkInputSchema).query(async ({ ctx, input }) => { + const { getHashedLinkHandler } = await import("./getHashedLink.handler"); + + return getHashedLinkHandler({ + ctx, + input, + }); + }), + + getHashedLinks: authedProcedure.input(ZGetHashedLinksInputSchema).query(async ({ ctx, input }) => { + const { getHashedLinksHandler } = await import("./getHashedLinks.handler"); + + return getHashedLinksHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts index 81dc2db169..f9f63577b2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/duplicate.handler.ts @@ -181,14 +181,18 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => { data: customInputsData, }); } + if (hashedLink.length > 0) { - await prisma.hashedLink.create({ - data: { - link: generateHashedLink(users[0]?.id ?? newEventType.teamId), - eventType: { - connect: { id: newEventType.id }, - }, - }, + const newHashedLinksData = hashedLink.map((originalLink, index) => ({ + link: generateHashedLink( + `${users[0]?.id ?? newEventType.teamId ?? originalLink.eventTypeId}-${index}` + ), + eventTypeId: newEventType.id, + expiresAt: originalLink.expiresAt, + maxUsageCount: originalLink.maxUsageCount, + })); + await prisma.hashedLink.createMany({ + data: newHashedLinksData, }); } diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts new file mode 100644 index 0000000000..c81374fd11 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.handler.ts @@ -0,0 +1,57 @@ +import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository"; +import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; +import type { PrismaClient } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import { TRPCError } from "@trpc/server"; + +type GetHashedLinkOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: { + linkId: string; + }; +}; + +export const getHashedLinkHandler = async ({ ctx, input }: GetHashedLinkOptions) => { + const { linkId } = input; + + if (!linkId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Missing link ID", + }); + } + + const hashedLinkRepository = HashedLinkRepository.create(); + // Get the hashed link with usage data + const hashedLink = await hashedLinkRepository.findLinkWithEventTypeDetails(linkId); + + if (!hashedLink) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hashed link not found", + }); + } + + // Check if the user has permission to access this hashed link + const hashedLinkService = new HashedLinkService(); + const hasPermission = await hashedLinkService.checkUserPermissionForLink(hashedLink, ctx.user.id); + + if (!hasPermission) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have permission to access this link", + }); + } + + return { + id: hashedLink.id, + link: hashedLink.link, + expiresAt: hashedLink.expiresAt, + maxUsageCount: hashedLink.maxUsageCount, + usageCount: hashedLink.usageCount, + }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts new file mode 100644 index 0000000000..5366046553 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLink.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetHashedLinkInputSchema = z.object({ + linkId: z.string(), +}); + +export type TGetHashedLinkInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts new file mode 100644 index 0000000000..53996fa30a --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.handler.ts @@ -0,0 +1,49 @@ +import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository"; +import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; +import type { PrismaClient } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import type { TGetHashedLinksInputSchema } from "./getHashedLinks.schema"; + +type GetHashedLinksOptions = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TGetHashedLinksInputSchema; +}; + +export const getHashedLinksHandler = async ({ ctx, input }: GetHashedLinksOptions) => { + const { linkIds } = input; + + if (!linkIds.length) { + return []; + } + + // Get all hashed links with usage data + const privateLinksRepo = HashedLinkRepository.create(); + const hashedLinks = await privateLinksRepo.findLinksWithEventTypeDetails(linkIds); + + // Check if the user has permission to access these hashed links + const privateLinkService = new HashedLinkService(); + const validLinks = await Promise.all( + hashedLinks.map(async (link) => { + const hasPermission = await privateLinkService.checkUserPermissionForLink(link, ctx.user.id); + + if (!hasPermission) { + return null; + } + + return { + id: link.id, + linkId: link.link, + expiresAt: link.expiresAt, + maxUsageCount: link.maxUsageCount, + usageCount: link.usageCount, + }; + }) + ); + + // Filter out null values (unauthorized links) + return validLinks.filter((link): link is NonNullable => link !== null); +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts new file mode 100644 index 0000000000..3e9e24ee71 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/getHashedLinks.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetHashedLinksInputSchema = z.object({ + linkIds: z.array(z.string()), +}); + +export type TGetHashedLinksInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 2731d60b66..c0cdedec03 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -11,6 +11,15 @@ import { } from "@calcom/prisma/zod-utils"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +const hashedLinkInputSchema = z + .object({ + link: z.string(), + expiresAt: z.date().nullish(), + maxUsageCount: z.number().nullish(), + usageCount: z.number().nullish(), + }) + .strict(); + const aiPhoneCallConfig = z .object({ generalPrompt: z.string(), @@ -78,7 +87,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel hosts: z.array(hostSchema), schedule: z.number().nullable(), instantMeetingSchedule: z.number().nullable(), - multiplePrivateLinks: z.array(z.string()), + multiplePrivateLinks: z.array(z.union([z.string(), hashedLinkInputSchema])), assignAllTeamMembers: z.boolean(), isRRWeightsEnabled: z.boolean(), metadata: EventTypeMetaDataSchema, diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index ecc5644f41..260a830cd2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -13,8 +13,10 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import { CalVideoSettingsRepository } from "@calcom/lib/server/repository/calVideoSettings"; +import { HashedLinkRepository } from "@calcom/lib/server/repository/hashedLinkRepository"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { ScheduleRepository } from "@calcom/lib/server/repository/schedule"; +import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService"; import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { PrismaClient } from "@calcom/prisma"; import { WorkflowTriggerEvents } from "@calcom/prisma/client"; @@ -35,6 +37,7 @@ import { } from "./util"; type SessionUser = NonNullable; + type User = { id: SessionUser["id"]; username: SessionUser["username"]; @@ -491,58 +494,19 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { break; } } - const connectedLinks = await ctx.prisma.hashedLink.findMany({ - where: { - eventTypeId: input.id, - }, - select: { - id: true, - link: true, - }, - }); - + console.log("multiplePrivateLinks", multiplePrivateLinks); + // Handle multiple private links using the service + const privateLinksRepo = HashedLinkRepository.create(); + const connectedLinks = await privateLinksRepo.findLinksByEventTypeId(input.id); + console.log("connectedLinks", connectedLinks); const connectedMultiplePrivateLinks = connectedLinks.map((link) => link.link); - if (multiplePrivateLinks && multiplePrivateLinks.length > 0) { - const multiplePrivateLinksToBeInserted = multiplePrivateLinks.filter( - (link) => !connectedMultiplePrivateLinks.includes(link) - ); - const singleLinksToBeDeleted = connectedMultiplePrivateLinks.filter( - (link) => !multiplePrivateLinks.includes(link) - ); - if (singleLinksToBeDeleted.length > 0) { - await ctx.prisma.hashedLink.deleteMany({ - where: { - eventTypeId: input.id, - link: { - in: singleLinksToBeDeleted, - }, - }, - }); - } - if (multiplePrivateLinksToBeInserted.length > 0) { - await ctx.prisma.hashedLink.createMany({ - data: multiplePrivateLinksToBeInserted.map((link) => { - return { - link: link, - eventTypeId: input.id, - }; - }), - }); - } - } else { - // Delete all the single-use links for this event. - if (connectedMultiplePrivateLinks.length > 0) { - await ctx.prisma.hashedLink.deleteMany({ - where: { - eventTypeId: input.id, - link: { - in: connectedMultiplePrivateLinks, - }, - }, - }); - } - } + const privateLinksService = new HashedLinkService(); + await privateLinksService.handleMultiplePrivateLinks({ + eventTypeId: input.id, + multiplePrivateLinks, + connectedMultiplePrivateLinks, + }); if (assignAllTeamMembers !== undefined) { data.assignAllTeamMembers = assignAllTeamMembers;