Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a161fe35 | ||
|
|
eb1ed107ba | ||
|
|
13e2e340ac | ||
|
|
0af62da16b | ||
|
|
9b323cbcc8 | ||
|
|
18a0e2d75c | ||
|
|
87c89a9968 | ||
|
|
e237749abd | ||
|
|
994a36d039 | ||
|
|
c989817373 |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
@@ -12,6 +13,7 @@ import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
|
||||
PrismaTeamRepository,
|
||||
RedisService,
|
||||
PrismaFeaturesRepository,
|
||||
PrismaMembershipRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
NoSlotsNotificationService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaMembershipRepository as PrismaMembershipRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaMembershipRepository extends PrismaMembershipRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
checkBookingLimitsService: CheckBookingLimitsService,
|
||||
cacheService: CacheService,
|
||||
userAvailabilityService: UserAvailabilityService,
|
||||
busyTimesService: BusyTimesService
|
||||
busyTimesService: BusyTimesService,
|
||||
noSlotsNotificationService: NoSlotsNotificationService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
busyTimesService,
|
||||
qualifiedHostsService,
|
||||
featuresRepo: featuresRepository,
|
||||
noSlotsNotificationService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { NoSlotsNotificationService as BaseNoSlotsNotificationService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class NoSlotsNotificationService extends BaseNoSlotsNotificationService {
|
||||
constructor(
|
||||
teamRepository: PrismaTeamRepository,
|
||||
membershipRepository: PrismaMembershipRepository,
|
||||
redisService: RedisService
|
||||
) {
|
||||
super({
|
||||
teamRepo: teamRepository,
|
||||
membershipRepo: membershipRepository,
|
||||
redisClient: redisService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -31,6 +35,11 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
|
||||
redirect("/apps/installed/calendar");
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const [calendarsCaller, appsCaller] = await Promise.all([
|
||||
createRouterCaller(calendarsRouter),
|
||||
createRouterCaller(appsRouter),
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/about-view";
|
||||
|
||||
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new/about"
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LegacyPage />
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/add-teams-view";
|
||||
|
||||
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new/add-teams"
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LegacyPage />
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies } from "next/headers";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboarding-handover";
|
||||
|
||||
@@ -18,10 +11,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new/handover"
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LegacyPage />
|
||||
|
||||
+7
-12
@@ -1,21 +1,16 @@
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
// Check if organizations are enabled
|
||||
|
||||
if (!organizations) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
isOrg: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
return children;
|
||||
}
|
||||
+1
-10
@@ -1,10 +1,4 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboard-members-view";
|
||||
|
||||
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new/onboard-members"
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LegacyPage />
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/create-new-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -19,14 +13,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new"
|
||||
);
|
||||
|
||||
type Props = {
|
||||
isOrg: boolean;
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<Props>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LicenseRequired>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/payment-status-view";
|
||||
|
||||
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/new/status"
|
||||
);
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LegacyPage />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.17",
|
||||
"version": "5.5.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -4264,9 +4264,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Event Types / Private Links"
|
||||
]
|
||||
"tags": ["Orgs / Teams / Event Types / Private Links"]
|
||||
},
|
||||
"get": {
|
||||
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
|
||||
@@ -4328,9 +4326,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Event Types / Private Links"
|
||||
]
|
||||
"tags": ["Orgs / Teams / Event Types / Private Links"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
|
||||
@@ -4402,9 +4398,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Event Types / Private Links"
|
||||
]
|
||||
"tags": ["Orgs / Teams / Event Types / Private Links"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
|
||||
@@ -4474,9 +4468,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Teams / Event Types / Private Links"
|
||||
]
|
||||
"tags": ["Orgs / Teams / Event Types / Private Links"]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
|
||||
@@ -22121,13 +22113,7 @@
|
||||
"example": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"expiresAt"
|
||||
]
|
||||
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
|
||||
},
|
||||
"UsageBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
@@ -22164,14 +22150,7 @@
|
||||
"example": 3
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"linkId",
|
||||
"eventTypeId",
|
||||
"isExpired",
|
||||
"bookingUrl",
|
||||
"maxUsageCount",
|
||||
"usageCount"
|
||||
]
|
||||
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
|
||||
},
|
||||
"CreatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
@@ -22193,10 +22172,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"GetPrivateLinksOutput": {
|
||||
"type": "object",
|
||||
@@ -22221,10 +22197,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"UpdatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
@@ -22246,10 +22219,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"DeletePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
@@ -22274,10 +22244,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"StripConnectOutputDto": {
|
||||
"type": "object",
|
||||
@@ -26267,205 +26234,6 @@
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"CreatePrivateLinkInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for time-based links",
|
||||
"format": "date-time",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
|
||||
"example": 10,
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"TimeBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "string",
|
||||
"description": "Expiration date for this time-based link",
|
||||
"format": "date-time",
|
||||
"example": "2025-12-31T23:59:59.000Z"
|
||||
}
|
||||
},
|
||||
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
|
||||
},
|
||||
"UsageBasedPrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"description": "The private link ID",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event type ID this link belongs to",
|
||||
"example": 123
|
||||
},
|
||||
"isExpired": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the link is currently expired",
|
||||
"example": false
|
||||
},
|
||||
"bookingUrl": {
|
||||
"type": "string",
|
||||
"description": "Full booking URL for this private link",
|
||||
"format": "uri",
|
||||
"example": "https://cal.com/d/abc123def456/30min"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of times this link can be used",
|
||||
"example": 10
|
||||
},
|
||||
"usageCount": {
|
||||
"type": "number",
|
||||
"description": "Current usage count for this link",
|
||||
"example": 3
|
||||
}
|
||||
},
|
||||
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
|
||||
},
|
||||
"CreatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Created private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"GetPrivateLinksOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "Array of private links for the event type (mix of time-based and usage-based)",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"UpdatePrivateLinkBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiresAt": {
|
||||
"format": "date-time",
|
||||
"type": "string",
|
||||
"description": "New expiration date for time-based links",
|
||||
"example": "2024-12-31T23:59:59.000Z"
|
||||
},
|
||||
"maxUsageCount": {
|
||||
"type": "number",
|
||||
"description": "New maximum number of times the link can be used",
|
||||
"example": 10,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdatePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"description": "Updated private link data (either time-based or usage-based)",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"DeletePrivateLinkOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Response status",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Deleted link information",
|
||||
"properties": {
|
||||
"linkId": {
|
||||
"type": "string",
|
||||
"example": "abc123def456"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Private link deleted successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"UserWebhookOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -128,6 +128,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
type BaseBookingEventPayload = {
|
||||
title: string | undefined;
|
||||
startTime: string | undefined;
|
||||
endTime: string | undefined;
|
||||
eventTypeId: number | null | undefined;
|
||||
status: string | undefined;
|
||||
paymentRequired: boolean;
|
||||
isRecurring: boolean;
|
||||
/**
|
||||
* This is only used for recurring bookings
|
||||
*/
|
||||
allBookings?: { startTime: string; endTime: string }[];
|
||||
videoCallUrl?: string;
|
||||
};
|
||||
|
||||
export type EventDataMap = {
|
||||
eventTypeSelected: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -24,20 +39,8 @@ export type EventDataMap = {
|
||||
linkReady: Record<string, never>;
|
||||
__connectInitiated: Record<string, never>;
|
||||
__connectCompleted: Record<string, never>;
|
||||
bookingSuccessfulV2: {
|
||||
bookingSuccessfulV2: BaseBookingEventPayload & {
|
||||
uid: string | undefined;
|
||||
title: string | undefined;
|
||||
startTime: string | undefined;
|
||||
endTime: string | undefined;
|
||||
eventTypeId: number | null | undefined;
|
||||
status: string | undefined;
|
||||
paymentRequired: boolean;
|
||||
isRecurring: boolean;
|
||||
/**
|
||||
* This is only used for recurring bookings
|
||||
*/
|
||||
allBookings?: { startTime: string; endTime: string }[];
|
||||
videoCallUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,20 +59,11 @@ export type EventDataMap = {
|
||||
};
|
||||
confirmed: boolean;
|
||||
};
|
||||
rescheduleBookingSuccessfulV2: {
|
||||
rescheduleBookingSuccessfulV2: BaseBookingEventPayload & {
|
||||
uid: string | undefined;
|
||||
title: string | undefined;
|
||||
startTime: string | undefined;
|
||||
endTime: string | undefined;
|
||||
eventTypeId: number | null | undefined;
|
||||
status: string | undefined;
|
||||
paymentRequired: boolean;
|
||||
isRecurring: boolean;
|
||||
/**
|
||||
* This is only used for recurring bookings
|
||||
*/
|
||||
allBookings?: { startTime: string; endTime: string }[];
|
||||
};
|
||||
dryRunBookingSuccessfulV2: BaseBookingEventPayload;
|
||||
dryRunRescheduleBookingSuccessfulV2: BaseBookingEventPayload;
|
||||
/**
|
||||
* @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,28 @@ export interface IUseBookings {
|
||||
isBookingDryRun?: boolean;
|
||||
}
|
||||
|
||||
const getBaseBookingEventPayload = (booking: {
|
||||
title?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
eventTypeId?: number | null;
|
||||
status?: BookingStatus;
|
||||
paymentRequired: boolean;
|
||||
isRecurring: boolean;
|
||||
videoCallUrl?: string;
|
||||
}) => {
|
||||
return {
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
status: booking.status,
|
||||
paymentRequired: booking.paymentRequired,
|
||||
isRecurring: booking.isRecurring,
|
||||
videoCallUrl: booking.videoCallUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const getBookingSuccessfulEventPayload = (booking: {
|
||||
title?: string;
|
||||
startTime: string;
|
||||
@@ -70,18 +92,15 @@ const getBookingSuccessfulEventPayload = (booking: {
|
||||
}) => {
|
||||
return {
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
status: booking.status,
|
||||
paymentRequired: booking.paymentRequired,
|
||||
isRecurring: booking.isRecurring,
|
||||
videoCallUrl: booking.videoCallUrl,
|
||||
...getBaseBookingEventPayload(booking),
|
||||
};
|
||||
};
|
||||
|
||||
const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload;
|
||||
|
||||
export const getDryRunBookingSuccessfulEventPayload = getBaseBookingEventPayload;
|
||||
|
||||
export const getDryRunRescheduleBookingSuccessfulEventPayload = getDryRunBookingSuccessfulEventPayload;
|
||||
export interface IUseBookingLoadingStates {
|
||||
creatingBooking: boolean;
|
||||
creatingRecurringBooking: boolean;
|
||||
@@ -196,6 +215,30 @@ export const useBookings = ({
|
||||
mutationFn: createBooking,
|
||||
onSuccess: (booking) => {
|
||||
if (booking.isDryRun) {
|
||||
const validDuration = event.data?.isDynamic
|
||||
? duration || event.data?.length
|
||||
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
|
||||
? duration
|
||||
: event.data?.length;
|
||||
|
||||
if (isRescheduling) {
|
||||
sdkActionManager?.fire(
|
||||
"dryRunRescheduleBookingSuccessfulV2",
|
||||
getDryRunRescheduleBookingSuccessfulEventPayload({
|
||||
...booking,
|
||||
isRecurring: false,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
sdkActionManager?.fire(
|
||||
"dryRunBookingSuccessfulV2",
|
||||
getDryRunBookingSuccessfulEventPayload({
|
||||
...booking,
|
||||
isRecurring: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
router.push("/booking/dry-run-successful");
|
||||
return;
|
||||
}
|
||||
@@ -343,6 +386,30 @@ export const useBookings = ({
|
||||
const booking = bookings[0] || {};
|
||||
|
||||
if (booking.isDryRun) {
|
||||
if (isRescheduling) {
|
||||
sdkActionManager?.fire("dryRunRescheduleBookingSuccessfulV2", {
|
||||
...getDryRunRescheduleBookingSuccessfulEventPayload({
|
||||
...booking,
|
||||
isRecurring: true,
|
||||
}),
|
||||
allBookings: bookings.map((booking) => ({
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
sdkActionManager?.fire("dryRunBookingSuccessfulV2", {
|
||||
...getDryRunBookingSuccessfulEventPayload({
|
||||
...booking,
|
||||
isRecurring: true,
|
||||
}),
|
||||
allBookings: bookings.map((booking) => ({
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
router.push("/booking/dry-run-successful");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1430,6 +1430,8 @@ async function handler(
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
originalRescheduledBooking.userId !== evt.organizer.id;
|
||||
|
||||
const skipDeleteEventsAndMeetings = changedOrganizer;
|
||||
|
||||
const isBookingRequestedReschedule =
|
||||
!!originalRescheduledBooking &&
|
||||
!!originalRescheduledBooking.rescheduled &&
|
||||
@@ -1702,7 +1704,8 @@ async function handler(
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
previousHostDestinationCalendar,
|
||||
isBookingRequestedReschedule
|
||||
isBookingRequestedReschedule,
|
||||
skipDeleteEventsAndMeetings
|
||||
);
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tests for calendar event deletion during round robin organizer change operations.
|
||||
* Covers scenarios where events need to be deleted from original hosts and
|
||||
* credential handling during organizer changes.
|
||||
*/
|
||||
import {
|
||||
getDate,
|
||||
createBookingScenario,
|
||||
getScenarioData,
|
||||
getMockBookingAttendee,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
getGoogleCalendarCredential,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import {
|
||||
expectBookingToBeInDatabase,
|
||||
expectSuccessfulRoundRobinReschedulingEmails,
|
||||
} from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { SchedulingType, BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
describe("roundRobinReassignment test", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
test("should delete calendar events from original host when round robin reassignment changes organizer", async ({
|
||||
emails,
|
||||
}) => {
|
||||
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
|
||||
|
||||
const originalHost = getOrganizer({
|
||||
name: "Original Host",
|
||||
email: "originalhost@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const newHost = getOrganizer({
|
||||
name: "New Host",
|
||||
email: "newhost@example.com",
|
||||
id: 102,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
const bookingToReassignUid = "booking-to-reassign-with-deletion";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: [{ id: 101 }, { id: 102 }],
|
||||
hosts: [
|
||||
{ userId: 101, isFixed: false },
|
||||
{ userId: 102, isFixed: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 123,
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:45:00.000Z`,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "attendee",
|
||||
email: "attendee@test.com",
|
||||
locale: "en",
|
||||
timeZone: "Asia/Kolkata",
|
||||
}),
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 1,
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "ORIGINAL_EVENT_ID",
|
||||
meetingId: "ORIGINAL_EVENT_ID",
|
||||
meetingPassword: null,
|
||||
meetingUrl: null,
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: 1,
|
||||
deleted: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: [newHost],
|
||||
apps: [TestData.apps["google-calendar"]],
|
||||
})
|
||||
);
|
||||
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: { uid: "NEW_EVENT_ID" },
|
||||
update: { uid: "UPDATED_EVENT_ID" },
|
||||
});
|
||||
|
||||
await roundRobinReassignment({
|
||||
bookingId: 123,
|
||||
});
|
||||
|
||||
// Verify that calendar deletion occurred (may be called multiple times due to duplicate references)
|
||||
expect(calendarMock.deleteEventCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const deleteCall = calendarMock.deleteEventCalls[0];
|
||||
expect(deleteCall.args.uid).toBe("ORIGINAL_EVENT_ID");
|
||||
expect(deleteCall.args.externalCalendarId).toBe("MOCK_EXTERNAL_CALENDAR_ID");
|
||||
expect(deleteCall.args.event.organizer.email).toBe(newHost.email); // Current implementation uses new host credentials
|
||||
expect(deleteCall.args.event.uid).toBe(bookingToReassignUid);
|
||||
|
||||
// Verify that creation occurred with new host credentials
|
||||
expect(calendarMock.createEventCalls.length).toBe(1);
|
||||
const createCall = calendarMock.createEventCalls[0];
|
||||
expect(createCall.args.calEvent.organizer.email).toBe(newHost.email);
|
||||
|
||||
// Verify the booking was reassigned to the new host
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: newHost.id,
|
||||
});
|
||||
|
||||
expectSuccessfulRoundRobinReschedulingEmails({
|
||||
prevOrganizer: originalHost,
|
||||
newOrganizer: newHost,
|
||||
emails,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -570,7 +570,8 @@ export default class EventManager {
|
||||
newBookingId?: number,
|
||||
changedOrganizer?: boolean,
|
||||
previousHostDestinationCalendar?: DestinationCalendar[] | null,
|
||||
isBookingRequestedReschedule?: boolean
|
||||
isBookingRequestedReschedule?: boolean,
|
||||
skipDeleteEventsAndMeetings?: boolean
|
||||
): Promise<CreateUpdateResult> {
|
||||
const originalEvt = processLocation(event);
|
||||
const evt = cloneDeep(originalEvt);
|
||||
@@ -637,7 +638,7 @@ export default class EventManager {
|
||||
const shouldUpdateBookingReferences =
|
||||
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
|
||||
|
||||
if (evt.requiresConfirmation && !changedOrganizer) {
|
||||
if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) {
|
||||
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
|
||||
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
|
||||
await this.deleteEventsAndMeetings({
|
||||
@@ -646,6 +647,14 @@ export default class EventManager {
|
||||
});
|
||||
} else {
|
||||
if (changedOrganizer) {
|
||||
if (!skipDeleteEventsAndMeetings) {
|
||||
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
|
||||
await this.deleteEventsAndMeetings({
|
||||
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
|
||||
bookingReferences: booking.references,
|
||||
});
|
||||
}
|
||||
|
||||
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
|
||||
const createdEvent = await this.create(originalEvt);
|
||||
results.push(...createdEvent.results);
|
||||
|
||||
@@ -14,6 +14,8 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
|
||||
import { featuresRepositoryModule } from "../modules/Features";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
|
||||
import { membershipRepositoryModule } from "../modules/Membership";
|
||||
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
|
||||
import { oooRepositoryModule } from "../modules/Ooo";
|
||||
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
|
||||
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
|
||||
@@ -29,6 +31,7 @@ container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
|
||||
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
|
||||
container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule);
|
||||
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
|
||||
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
|
||||
container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
|
||||
@@ -42,6 +45,7 @@ container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
|
||||
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
|
||||
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
|
||||
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
|
||||
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
|
||||
|
||||
export function getAvailableSlotsService() {
|
||||
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { redisModule } from "@calcom/features/redis/di/redisModule";
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
import type { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
|
||||
|
||||
import { membershipRepositoryModule } from "../modules/Membership";
|
||||
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
|
||||
import { teamRepositoryModule } from "../modules/Team";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
|
||||
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
|
||||
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
|
||||
|
||||
export function getNoSlotsNotificationService() {
|
||||
return container.get<NoSlotsNotificationService>(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE);
|
||||
}
|
||||
@@ -22,4 +22,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
|
||||
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
|
||||
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
|
||||
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
|
||||
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
|
||||
} satisfies Record<keyof IAvailableSlotsService, symbol>);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
|
||||
export const membershipRepositoryModule = createModule();
|
||||
membershipRepositoryModule
|
||||
.bind(DI_TOKENS.MEMBERSHIP_REPOSITORY)
|
||||
.toClass(MembershipRepository, [DI_TOKENS.PRISMA_CLIENT]);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import type { INoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
|
||||
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
|
||||
|
||||
import { DI_TOKENS } from "../tokens";
|
||||
|
||||
export const noSlotsNotificationModule = createModule();
|
||||
noSlotsNotificationModule.bind(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE).toClass(NoSlotsNotificationService, {
|
||||
teamRepo: DI_TOKENS.TEAM_REPOSITORY,
|
||||
membershipRepo: DI_TOKENS.MEMBERSHIP_REPOSITORY,
|
||||
redisClient: DI_TOKENS.REDIS_CLIENT,
|
||||
} satisfies Record<keyof INoSlotsNotificationService, symbol>);
|
||||
@@ -41,4 +41,8 @@ export const DI_TOKENS = {
|
||||
QUALIFIED_HOSTS_SERVICE_MODULE: Symbol("QualifiedHostsServiceModule"),
|
||||
FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"),
|
||||
FILTER_HOSTS_SERVICE_MODULE: Symbol("FilterHostsServiceModule"),
|
||||
NO_SLOTS_NOTIFICATION_SERVICE: Symbol("NoSlotsNotificationService"),
|
||||
NO_SLOTS_NOTIFICATION_SERVICE_MODULE: Symbol("NoSlotsNotificationServiceModule"),
|
||||
MEMBERSHIP_REPOSITORY: Symbol("MembershipRepository"),
|
||||
MEMBERSHIP_REPOSITORY_MODULE: Symbol("MembershipRepositoryModule"),
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ export class BusyTimesService {
|
||||
})[]
|
||||
| null;
|
||||
bypassBusyCalendarTimes: boolean;
|
||||
silentlyHandleCalendarFailures?: boolean;
|
||||
shouldServeCache?: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -70,6 +71,7 @@ export class BusyTimesService {
|
||||
rescheduleUid,
|
||||
duration,
|
||||
bypassBusyCalendarTimes = false,
|
||||
silentlyHandleCalendarFailures = false,
|
||||
shouldServeCache,
|
||||
} = params;
|
||||
|
||||
@@ -198,63 +200,71 @@ export class BusyTimesService {
|
||||
);
|
||||
|
||||
if (!calendarBusyTimesQuery.success) {
|
||||
throw new Error(
|
||||
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
|
||||
(calendar) => calendar.id
|
||||
)}`
|
||||
if (silentlyHandleCalendarFailures) {
|
||||
logger.warn(
|
||||
`Calendar busy times fetch failed but handling silently due to silentlyHandleCalendarFailures flag for user ${username}`,
|
||||
{
|
||||
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.id),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
|
||||
(calendar) => calendar.id
|
||||
)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const calendarBusyTimes = calendarBusyTimesQuery.data;
|
||||
const endConnectedCalendarsGet = performance.now();
|
||||
logger.debug(
|
||||
`Connected Calendars get took ${
|
||||
endConnectedCalendarsGet - startConnectedCalendarsGet
|
||||
} ms for user ${username}`,
|
||||
JSON.stringify({
|
||||
eventTypeId,
|
||||
startTimeDate,
|
||||
endTimeDate,
|
||||
calendarBusyTimes,
|
||||
})
|
||||
);
|
||||
|
||||
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
|
||||
const [start, end] = key.split("<>");
|
||||
return {
|
||||
start: dayjs(start),
|
||||
end: dayjs(end),
|
||||
};
|
||||
});
|
||||
|
||||
if (rescheduleUid) {
|
||||
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
|
||||
if (originalRescheduleBooking) {
|
||||
openSeatsDateRanges.push({
|
||||
start: dayjs(originalRescheduleBooking.startTime),
|
||||
end: dayjs(originalRescheduleBooking.endTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = subtract(
|
||||
calendarBusyTimes.map((value) => ({
|
||||
...value,
|
||||
end: dayjs(value.end),
|
||||
start: dayjs(value.start),
|
||||
})),
|
||||
openSeatsDateRanges
|
||||
);
|
||||
|
||||
busyTimes.push(
|
||||
...result.map((busyTime) => ({
|
||||
...busyTime,
|
||||
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
|
||||
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const calendarBusyTimes = calendarBusyTimesQuery.data;
|
||||
const endConnectedCalendarsGet = performance.now();
|
||||
logger.debug(
|
||||
`Connected Calendars get took ${
|
||||
endConnectedCalendarsGet - startConnectedCalendarsGet
|
||||
} ms for user ${username}`,
|
||||
JSON.stringify({
|
||||
eventTypeId,
|
||||
startTimeDate,
|
||||
endTimeDate,
|
||||
calendarBusyTimes,
|
||||
})
|
||||
);
|
||||
|
||||
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
|
||||
const [start, end] = key.split("<>");
|
||||
return {
|
||||
start: dayjs(start),
|
||||
end: dayjs(end),
|
||||
};
|
||||
});
|
||||
|
||||
if (rescheduleUid) {
|
||||
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
|
||||
// calendar busy time from original rescheduled booking should not be blocked
|
||||
if (originalRescheduleBooking) {
|
||||
openSeatsDateRanges.push({
|
||||
start: dayjs(originalRescheduleBooking.startTime),
|
||||
end: dayjs(originalRescheduleBooking.endTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = subtract(
|
||||
calendarBusyTimes.map((value) => ({
|
||||
...value,
|
||||
end: dayjs(value.end),
|
||||
start: dayjs(value.start),
|
||||
})),
|
||||
openSeatsDateRanges
|
||||
);
|
||||
|
||||
busyTimes.push(
|
||||
...result.map((busyTime) => ({
|
||||
...busyTime,
|
||||
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
|
||||
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
|
||||
}))
|
||||
);
|
||||
|
||||
/*
|
||||
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
|
||||
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
|
||||
|
||||
@@ -54,6 +54,7 @@ const availabilitySchema = z
|
||||
withSource: z.boolean().optional(),
|
||||
returnDateOverrides: z.boolean(),
|
||||
bypassBusyCalendarTimes: z.boolean().optional(),
|
||||
silentlyHandleCalendarFailures: z.boolean().optional(),
|
||||
shouldServeCache: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
|
||||
@@ -110,6 +111,7 @@ type GetUserAvailabilityQuery = {
|
||||
duration?: number;
|
||||
returnDateOverrides: boolean;
|
||||
bypassBusyCalendarTimes: boolean;
|
||||
silentlyHandleCalendarFailures?: boolean;
|
||||
shouldServeCache?: boolean;
|
||||
};
|
||||
|
||||
@@ -295,6 +297,7 @@ export class UserAvailabilityService {
|
||||
duration,
|
||||
returnDateOverrides,
|
||||
bypassBusyCalendarTimes = false,
|
||||
silentlyHandleCalendarFailures = false,
|
||||
shouldServeCache,
|
||||
} = availabilitySchema.parse(query);
|
||||
|
||||
@@ -457,6 +460,7 @@ export class UserAvailabilityService {
|
||||
duration,
|
||||
currentBookings: initialData?.currentBookings,
|
||||
bypassBusyCalendarTimes,
|
||||
silentlyHandleCalendarFailures,
|
||||
shouldServeCache,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -460,4 +460,28 @@ export class MembershipRepository {
|
||||
select: select ?? { userId: true },
|
||||
})) as unknown as Promise<MembershipDTOFromSelect<TSelect>[]>;
|
||||
}
|
||||
|
||||
async findTeamAdminsByTeamId({ teamId }: { teamId: number }) {
|
||||
return await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,4 +350,31 @@ export class TeamRepository {
|
||||
|
||||
return team?.parent;
|
||||
}
|
||||
|
||||
async findOrganizationSettingsBySlug({ slug }: { slug: string }) {
|
||||
return await this.prismaClient.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
isOrganization: true,
|
||||
},
|
||||
select: {
|
||||
organizationSettings: {
|
||||
select: {
|
||||
adminGetsNoSlotsNotification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamSlugById({ id }: { id: number }) {
|
||||
return await this.prismaClient.team.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
## 1.1.2
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#23119](https://github.com/calcom/cal.com/pull/23119) [`eb1ed10`](https://github.com/calcom/cal.com/commit/eb1ed107ba54a4ae99d0b3aa741ae3076d0ec629) Thanks [@ThyMinimalDev](https://github.com/ThyMinimalDev)! - Added Booker atom prop silentlyHandleCalendarFailures, which ensure the booker still displays slots when the third party calendars credentials are invalid or expired
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -64,6 +64,7 @@ const BookerPlatformWrapperComponent = (
|
||||
onTimeslotsLoaded,
|
||||
startTime: customStartTime,
|
||||
showNoAvailabilityDialog,
|
||||
silentlyHandleCalendarFailures = false,
|
||||
} = props;
|
||||
const layout = BookerLayouts[view];
|
||||
|
||||
@@ -310,6 +311,7 @@ const BookerPlatformWrapperComponent = (
|
||||
Boolean(event?.data?.id),
|
||||
orgSlug: props.entity?.orgSlug ?? undefined,
|
||||
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
|
||||
_silentCalendarFailures: silentlyHandleCalendarFailures,
|
||||
...routingParams,
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
|
||||
onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void;
|
||||
startTime?: string | Date;
|
||||
roundRobinHideOrgAndTeam?: boolean;
|
||||
silentlyHandleCalendarFailures?: boolean;
|
||||
};
|
||||
|
||||
type VIEW_TYPE = keyof typeof BookerLayouts;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
: { username: props.calUsername })}
|
||||
hostsLimit={3}
|
||||
allowUpdatingUrlParams={true}
|
||||
silentlyHandleCalendarFailures={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,3 +7,4 @@ export { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/Pris
|
||||
export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team";
|
||||
export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user";
|
||||
export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
export { MembershipRepository as PrismaMembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
|
||||
import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
|
||||
import { BusyTimesService } from "@calcom/lib/getBusyTimes";
|
||||
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
|
||||
import { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
export { AvailableSlotsService };
|
||||
@@ -10,3 +11,4 @@ export { BusyTimesService };
|
||||
export { QualifiedHostsService };
|
||||
|
||||
export { FilterHostsService };
|
||||
export { NoSlotsNotificationService };
|
||||
|
||||
@@ -194,6 +194,12 @@ export class GetAvailableSlotsInput_2024_04_15 {
|
||||
@ApiHideProperty()
|
||||
_bypassCalendarBusyTimes?: boolean;
|
||||
|
||||
@Transform(({ value }) => value && value.toLowerCase() === "true")
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
_silentCalendarFailures?: boolean;
|
||||
|
||||
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { duplicateHandler } from "./duplicate.handler";
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/repository/eventTypeRepository");
|
||||
|
||||
describe("duplicateHandler", () => {
|
||||
const ctx = { user: { id: 1, profile: { id: 1 } } } as any;
|
||||
const input = { id: 123, slug: "test-event", title: "Test", description: "Test", length: 30, teamId: null };
|
||||
const eventType = { id: 123, userId: 1, teamId: null, users: [{ id: 1 }] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
prismaMock.eventType.findUnique.mockResolvedValue(eventType);
|
||||
});
|
||||
|
||||
it("should throw BAD_REQUEST in case of unique constraint violation", async () => {
|
||||
const { EventTypeRepository } = await import("@calcom/lib/server/repository/eventTypeRepository");
|
||||
vi.mocked(EventTypeRepository).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
create: vi.fn().mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "mockedVersion",
|
||||
})
|
||||
),
|
||||
} as any)
|
||||
);
|
||||
|
||||
await expect(duplicateHandler({ ctx, input })).rejects.toThrow(
|
||||
new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Unique constraint violation while creating a duplicate event.",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
|
||||
eventType: newEventType,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// unique constraint violation
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Unique constraint violation while creating a duplicate event.",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Error duplicating event type ${error}` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@ import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach }
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import * as CalcomEmails from "@calcom/emails";
|
||||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||||
|
||||
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
|
||||
import { getNoSlotsNotificationService } from "@calcom/lib/di/containers/NoSlotsNotification";
|
||||
|
||||
vi.mock("@calcom/features/redis/RedisService", () => {
|
||||
const mockedRedis = vi.fn();
|
||||
@@ -84,14 +83,16 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
};
|
||||
|
||||
// Call the function with teamId
|
||||
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
|
||||
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
|
||||
|
||||
// Mock length to be one then recall to trigger email
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
|
||||
});
|
||||
it("Should not send a notification if the org has them disabled", async () => {
|
||||
@@ -113,7 +114,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
isValidOrgDomain: true,
|
||||
};
|
||||
|
||||
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
|
||||
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -153,7 +155,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // This will trigger email sending
|
||||
|
||||
// Call with specific teamId
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123, // specific teamId
|
||||
@@ -205,7 +208,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
// teamId intentionally omitted
|
||||
@@ -233,7 +237,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -263,7 +268,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
isValidOrgDomain: true,
|
||||
};
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -318,7 +324,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -375,7 +382,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
// First notification cycle - simulate having one previous occurrence
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -391,7 +399,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
// This better simulates the real Redis state after the first notification
|
||||
mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -436,7 +445,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
// First event type notification
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails: { ...baseEventDetails, eventSlug: "event1" },
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -451,7 +461,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
// For the second event type, also simulate one previous occurrence
|
||||
// This needs to be a separate mock since it's a different key in Redis
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for second event
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({
|
||||
eventDetails: { ...baseEventDetails, eventSlug: "event2" },
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails";
|
||||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||||
import type { IRedisService } from "@calcom/features/redis/IRedisService";
|
||||
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import type { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
|
||||
type EventDetails = {
|
||||
username: string;
|
||||
@@ -38,107 +39,83 @@ const constructDataHash = (eventDetails: EventDetails) => {
|
||||
return JSON.stringify(obj);
|
||||
};
|
||||
|
||||
export const handleNotificationWhenNoSlots = async ({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId,
|
||||
}: {
|
||||
eventDetails: EventDetails;
|
||||
orgDetails: { currentOrgDomain: string | null };
|
||||
teamId?: number;
|
||||
}) => {
|
||||
// Check for org
|
||||
if (!orgDetails.currentOrgDomain || !teamId) return;
|
||||
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
|
||||
if (!UPSTASH_ENV_FOUND) return;
|
||||
export interface INoSlotsNotificationService {
|
||||
teamRepo: TeamRepository;
|
||||
membershipRepo: MembershipRepository;
|
||||
redisClient: IRedisService;
|
||||
}
|
||||
|
||||
// Check org has this setting enabled
|
||||
const orgSettings = await prisma.team.findFirst({
|
||||
where: {
|
||||
export class NoSlotsNotificationService {
|
||||
constructor(public readonly dependencies: INoSlotsNotificationService) {}
|
||||
|
||||
async handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId,
|
||||
}: {
|
||||
eventDetails: EventDetails;
|
||||
orgDetails: { currentOrgDomain: string | null };
|
||||
teamId?: number;
|
||||
}) {
|
||||
// Check for org
|
||||
if (!orgDetails.currentOrgDomain || !teamId) return;
|
||||
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
|
||||
if (!UPSTASH_ENV_FOUND) return;
|
||||
|
||||
// Check org has this setting enabled
|
||||
const orgSettings = await this.dependencies.teamRepo.findOrganizationSettingsBySlug({
|
||||
slug: orgDetails.currentOrgDomain,
|
||||
isOrganization: true,
|
||||
},
|
||||
select: {
|
||||
organizationSettings: {
|
||||
select: {
|
||||
adminGetsNoSlotsNotification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
|
||||
|
||||
const redis = new RedisService();
|
||||
|
||||
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
|
||||
// Get only the required amount of data so the request is as small as possible
|
||||
// We may need to get more data and check the startDate occurrence of this
|
||||
// Not trigger email if the start months are the same
|
||||
const usersExistingNoSlots =
|
||||
(await redis.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ?? [];
|
||||
await redis.lpush(usersUniqueKey, constructDataHash(eventDetails));
|
||||
|
||||
if (!usersExistingNoSlots.length) {
|
||||
await redis.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
|
||||
}
|
||||
|
||||
// We add one as we know we just added one to the list - saves us re-fetching the data
|
||||
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
|
||||
// Get all team admins to send the email too
|
||||
const foundAdmins = await prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: teamId,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamSlug = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
|
||||
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
|
||||
const emailsToSend: Array<Promise<void>> = [];
|
||||
// const tasker = getTasker();
|
||||
for (const admin of foundAdmins) {
|
||||
const translation = await getTranslation(admin.user.locale ?? "en", "common");
|
||||
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
|
||||
|
||||
const payload = {
|
||||
language: translation,
|
||||
to: {
|
||||
email: admin.user.email,
|
||||
},
|
||||
user: eventDetails.username,
|
||||
slug: eventDetails.eventSlug,
|
||||
startTime: eventDetails.startTime.format("YYYY-MM-DD"),
|
||||
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
|
||||
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
|
||||
editLink: `${WEBAPP_URL}/availability?type=team`,
|
||||
teamSlug: teamSlug?.slug ?? "",
|
||||
};
|
||||
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
|
||||
// Get only the required amount of data so the request is as small as possible
|
||||
// We may need to get more data and check the startDate occurrence of this
|
||||
// Not trigger email if the start months are the same
|
||||
const usersExistingNoSlots =
|
||||
(await this.dependencies.redisClient.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ??
|
||||
[];
|
||||
await this.dependencies.redisClient.lpush(usersUniqueKey, constructDataHash(eventDetails));
|
||||
|
||||
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
|
||||
if (!usersExistingNoSlots.length) {
|
||||
await this.dependencies.redisClient.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
|
||||
}
|
||||
|
||||
// We add one as we know we just added one to the list - saves us re-fetching the data
|
||||
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
|
||||
// Get all team admins to send the email too
|
||||
const foundAdmins = await this.dependencies.membershipRepo.findTeamAdminsByTeamId({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const teamSlug = await this.dependencies.teamRepo.findTeamSlugById({
|
||||
id: teamId,
|
||||
});
|
||||
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
|
||||
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
|
||||
const emailsToSend: Array<Promise<void>> = [];
|
||||
// const tasker = getTasker();
|
||||
for (const admin of foundAdmins) {
|
||||
const translation = await getTranslation(admin.user.locale ?? "en", "common");
|
||||
|
||||
const payload = {
|
||||
language: translation,
|
||||
to: {
|
||||
email: admin.user.email,
|
||||
},
|
||||
user: eventDetails.username,
|
||||
slug: eventDetails.eventSlug,
|
||||
startTime: eventDetails.startTime.format("YYYY-MM-DD"),
|
||||
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
|
||||
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
|
||||
editLink: `${WEBAPP_URL}/availability?type=team`,
|
||||
teamSlug: teamSlug?.slug ?? "",
|
||||
};
|
||||
|
||||
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
|
||||
}
|
||||
await Promise.all(emailsToSend);
|
||||
}
|
||||
await Promise.all(emailsToSend);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const getScheduleSchemaObject = z.object({
|
||||
skipContactOwner: z.boolean().nullish(),
|
||||
_enableTroubleshooter: z.boolean().optional(),
|
||||
_bypassCalendarBusyTimes: z.boolean().optional(),
|
||||
_silentCalendarFailures: z.boolean().optional(),
|
||||
_shouldServeCache: z.boolean().optional(),
|
||||
routingFormResponseId: z.number().optional(),
|
||||
queuedFormResponseId: z.string().nullish(),
|
||||
|
||||
@@ -61,7 +61,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
|
||||
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
|
||||
import type { NoSlotsNotificationService } from "./handleNotificationWhenNoSlots";
|
||||
import type { GetScheduleOptions } from "./types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
|
||||
@@ -108,6 +108,7 @@ export interface IAvailableSlotsService {
|
||||
redisClient: IRedisService;
|
||||
featuresRepo: FeaturesRepository;
|
||||
qualifiedHostsService: QualifiedHostsService;
|
||||
noSlotsNotificationService: NoSlotsNotificationService;
|
||||
}
|
||||
|
||||
function withSlotsCache(
|
||||
@@ -737,6 +738,7 @@ export class AvailableSlotsService {
|
||||
startTime,
|
||||
endTime,
|
||||
bypassBusyCalendarTimes,
|
||||
silentCalendarFailures,
|
||||
shouldServeCache,
|
||||
}: {
|
||||
input: TGetScheduleInputSchema;
|
||||
@@ -753,6 +755,7 @@ export class AvailableSlotsService {
|
||||
startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>;
|
||||
endTime: Dayjs;
|
||||
bypassBusyCalendarTimes: boolean;
|
||||
silentCalendarFailures: boolean;
|
||||
shouldServeCache?: boolean;
|
||||
}) {
|
||||
const usersWithCredentials = this.getUsersWithCredentials({
|
||||
@@ -887,6 +890,7 @@ export class AvailableSlotsService {
|
||||
duration: input.duration || 0,
|
||||
returnDateOverrides: false,
|
||||
bypassBusyCalendarTimes,
|
||||
silentlyHandleCalendarFailures: silentCalendarFailures,
|
||||
shouldServeCache,
|
||||
},
|
||||
initialData: {
|
||||
@@ -958,6 +962,7 @@ export class AvailableSlotsService {
|
||||
const {
|
||||
_enableTroubleshooter: enableTroubleshooter = false,
|
||||
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
|
||||
_silentCalendarFailures: silentCalendarFailures = false,
|
||||
_shouldServeCache,
|
||||
routingFormResponseId,
|
||||
queuedFormResponseId,
|
||||
@@ -1069,6 +1074,7 @@ export class AvailableSlotsService {
|
||||
? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
|
||||
: endTime,
|
||||
bypassBusyCalendarTimes,
|
||||
silentCalendarFailures,
|
||||
shouldServeCache,
|
||||
});
|
||||
|
||||
@@ -1095,6 +1101,7 @@ export class AvailableSlotsService {
|
||||
startTime: dayjs(),
|
||||
endTime: twoWeeksFromNow,
|
||||
bypassBusyCalendarTimes,
|
||||
silentCalendarFailures,
|
||||
shouldServeCache,
|
||||
});
|
||||
if (
|
||||
@@ -1129,6 +1136,7 @@ export class AvailableSlotsService {
|
||||
startTime,
|
||||
endTime,
|
||||
bypassBusyCalendarTimes,
|
||||
silentCalendarFailures,
|
||||
shouldServeCache,
|
||||
}));
|
||||
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
|
||||
@@ -1431,8 +1439,7 @@ export class AvailableSlotsService {
|
||||
// We only want to run this on single targeted events and not dynamic
|
||||
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
|
||||
try {
|
||||
// TODO: DI handleNotificationWhenNoSlots
|
||||
await handleNotificationWhenNoSlots({
|
||||
await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
|
||||
eventDetails: {
|
||||
username: input.usernameList?.[0],
|
||||
startTime: startTime,
|
||||
|
||||
@@ -2500,7 +2500,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
"@calcom/prisma": "*"
|
||||
@@ -3559,13 +3559,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.306":
|
||||
version: 0.0.306
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.306"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.312":
|
||||
version: 0.0.312
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.312"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 602b8b446508429096b8e03e9edbe1ff744cf7c7af3fbe8a683aaaf0d3c96d1cd30ff356f72682449ca072beadaae95fdaa5c44b26e30cfce3bb838f519216f9
|
||||
checksum: 46520737404a61f3aa96c6f1adfa81e67f645fd984f495662ca7c63184b0310117a1dad57ddbe7ea487f2e5a0c9cbe62749a9a43b3197a021b19e3ccadc1f5b2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user