Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbc8a3816 | ||
|
|
4e1048b221 | ||
|
|
cb78692347 | ||
|
|
3784421f02 | ||
|
|
e7535b9db3 | ||
|
|
bb4260c98b |
Vendored
@@ -122,12 +122,14 @@ export async function getCRMData(
|
||||
const crmContactOwnerEmail = query["cal.crmContactOwnerEmail"];
|
||||
const crmContactOwnerRecordType = query["cal.crmContactOwnerRecordType"];
|
||||
const crmAppSlugParam = query["cal.crmAppSlug"];
|
||||
const crmRecordIdParam = query["cal.crmRecordId"];
|
||||
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||
? crmContactOwnerRecordType[0]
|
||||
: crmContactOwnerRecordType;
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
let crmRecordId = Array.isArray(crmRecordIdParam) ? crmRecordIdParam[0] : crmRecordIdParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
@@ -137,6 +139,7 @@ export async function getCRMData(
|
||||
email,
|
||||
recordType,
|
||||
crmAppSlug: crmAppSlugQuery,
|
||||
recordId: crmRecordIdQuery,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventData,
|
||||
@@ -145,11 +148,13 @@ export async function getCRMData(
|
||||
teamMemberEmail = email ?? undefined;
|
||||
crmOwnerRecordType = recordType ?? undefined;
|
||||
crmAppSlug = crmAppSlugQuery ?? undefined;
|
||||
crmRecordId = crmRecordIdQuery ?? undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
};
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Sample data for playground testing
|
||||
const sampleBookingsByHourStats = [
|
||||
{ hour: 0, count: 4 },
|
||||
{ hour: 1, count: 10 },
|
||||
{ hour: 2, count: 3 },
|
||||
{ hour: 3, count: 12 },
|
||||
{ hour: 4, count: 3 },
|
||||
{ hour: 5, count: 7 },
|
||||
{ hour: 6, count: 6 },
|
||||
{ hour: 7, count: 4 },
|
||||
{ hour: 8, count: 9 },
|
||||
{ hour: 9, count: 7 },
|
||||
{ hour: 10, count: 6 },
|
||||
{ hour: 11, count: 5 },
|
||||
{ hour: 12, count: 8 },
|
||||
{ hour: 13, count: 5 },
|
||||
{ hour: 14, count: 9 },
|
||||
{ hour: 15, count: 9 },
|
||||
{ hour: 16, count: 4 },
|
||||
{ hour: 17, count: 5 },
|
||||
{ hour: 18, count: 4 },
|
||||
{ hour: 19, count: 6 },
|
||||
{ hour: 20, count: 6 },
|
||||
{ hour: 21, count: 12 },
|
||||
{ hour: 22, count: 4 },
|
||||
{ hour: 23, count: 10 },
|
||||
];
|
||||
|
||||
export default function BookingsByHourPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
This page demonstrates the BookingsByHourChartContent component with sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
<ChartCard title={t("bookings_by_hour")}>
|
||||
<BookingsByHourChartContent data={sampleBookingsByHourStats} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
|
||||
<pre className="overflow-auto text-sm text-gray-700">
|
||||
{JSON.stringify(sampleBookingsByHourStats, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ const LINKS = [
|
||||
title: "Routing Funnel",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
},
|
||||
{
|
||||
title: "Bookings by Hour",
|
||||
href: "/settings/admin/playground/bookings-by-hour",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
|
||||
+19
-3
@@ -184,9 +184,11 @@ const organizationAdminKeys = [
|
||||
const useTabs = ({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
canViewRoles,
|
||||
}: {
|
||||
isDelegationCredentialEnabled: boolean;
|
||||
isPbacEnabled: boolean;
|
||||
canViewRoles?: boolean;
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
@@ -223,8 +225,9 @@ const useTabs = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled
|
||||
if (isPbacEnabled) {
|
||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
if (isPbacEnabled && canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
@@ -291,6 +294,7 @@ interface SettingsSidebarContainerProps {
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
}
|
||||
|
||||
const TeamRolesNavItem = ({
|
||||
@@ -483,6 +487,7 @@ const SettingsSidebarContainer = ({
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -512,6 +517,7 @@ const SettingsSidebarContainer = ({
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
canViewRoles,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
@@ -786,9 +792,15 @@ export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({
|
||||
children,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
...rest
|
||||
}: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -821,6 +833,7 @@ export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
teamFeatures={teamFeatures}
|
||||
canViewRoles={canViewRoles}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
@@ -843,6 +856,7 @@ type SidebarContainerElementProps = {
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
};
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
@@ -850,6 +864,7 @@ const SidebarContainerElement = ({
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -866,6 +881,7 @@ const SidebarContainerElement = ({
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
teamFeatures={teamFeatures}
|
||||
canViewRoles={canViewRoles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,9 @@ import React from "react";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -23,6 +26,15 @@ const getTeamFeatures = unstable_cache(
|
||||
}
|
||||
);
|
||||
|
||||
const getCachedResourcePermissions = unstable_cache(
|
||||
async (userId: number, teamId: number, resource: Resource) => {
|
||||
const permissionService = new PermissionCheckService();
|
||||
return permissionService.getResourcePermissions({ userId, teamId, resource });
|
||||
},
|
||||
["resource-permissions"],
|
||||
{ revalidate: 120 }
|
||||
);
|
||||
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userId = session?.user?.id;
|
||||
@@ -31,20 +43,30 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
}
|
||||
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
let canViewRoles = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const features = await getTeamFeatures(orgId);
|
||||
const [features, rolePermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
]);
|
||||
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[orgId]: features,
|
||||
};
|
||||
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+30
-11
@@ -54,6 +54,12 @@ export function AdvancedPermissionGroup({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckedChange = (checked: boolean | string) => {
|
||||
if (!disabled) {
|
||||
onChange(toggleResourcePermissionLevel(resource, checked ? "all" : "none", selectedPermissions));
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if read permission is auto-enabled
|
||||
const isReadAutoEnabled = (action: string) => {
|
||||
if (action === CrudAction.Read) return false;
|
||||
@@ -68,25 +74,38 @@ export function AdvancedPermissionGroup({
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<Icon
|
||||
name={isAllResources ? "chevron-right" : "chevron-down"}
|
||||
className={classNames(
|
||||
"h-4 w-4 transition-transform",
|
||||
isExpanded && !isAllResources ? "rotate-180" : ""
|
||||
)}
|
||||
/>
|
||||
onClick={(e) => {
|
||||
// Only toggle expansion if clicking on the button itself, not child elements
|
||||
if (e.target === e.currentTarget) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}>
|
||||
<div className="flex items-center gap-1.5" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
className={classNames(
|
||||
"h-4 w-4 transition-transform",
|
||||
isExpanded && !isAllResources ? "rotate-90" : ""
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={() => handleToggleAll}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
onClick={handleToggleAll}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-default text-sm font-medium leading-none">
|
||||
<span
|
||||
className="text-default cursor-pointer text-sm font-medium leading-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{t(resourceConfig._resource?.i18nKey || "")}
|
||||
</span>
|
||||
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
|
||||
<span
|
||||
className="text-muted cursor-pointer text-sm font-medium leading-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{t("all_permissions")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && !isAllResources && (
|
||||
|
||||
+19
-7
@@ -19,9 +19,11 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
const permissions: string[] = [];
|
||||
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
|
||||
if (resource !== "*") {
|
||||
Object.keys(config).forEach((action) => {
|
||||
permissions.push(`${resource}.${action}`);
|
||||
});
|
||||
Object.keys(config)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.forEach((action) => {
|
||||
permissions.push(`${resource}.${action}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
return permissions;
|
||||
@@ -30,7 +32,9 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
const hasAllPermissions = (permissions: string[]) => {
|
||||
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
|
||||
if (resource === "*") return true;
|
||||
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
|
||||
return Object.keys(config)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.every((action) => permissions.includes(`${resource}.${action}`));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -42,7 +46,10 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||
if (!resourceConfig) return "none";
|
||||
|
||||
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
|
||||
// Filter out internal keys like _resource when checking permissions
|
||||
const allResourcePerms = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.map((action) => `${resource}.${action}`);
|
||||
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
|
||||
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
||||
|
||||
@@ -73,6 +80,9 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
|
||||
if (!resourceConfig) return currentPermissions;
|
||||
|
||||
// Declare variable before switch to avoid scope issues
|
||||
let allResourcePerms: string[];
|
||||
|
||||
switch (level) {
|
||||
case "none":
|
||||
// No permissions to add, just keep other permissions
|
||||
@@ -82,8 +92,10 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
newPermissions.push(`${resource}.${CrudAction.Read}`);
|
||||
break;
|
||||
case "all":
|
||||
// Add all permissions for this resource
|
||||
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
|
||||
// Add all permissions for this resource (excluding internal keys)
|
||||
allResourcePerms = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.map((action) => `${resource}.${action}`);
|
||||
newPermissions.push(...allResourcePerms);
|
||||
break;
|
||||
}
|
||||
|
||||
+6
-1
@@ -9,7 +9,12 @@ export async function revalidateTeamRoles(teamId: number) {
|
||||
// Revalidate team roles paths (dynamic routes)
|
||||
revalidatePath("/settings/teams/[id]/roles", "page");
|
||||
|
||||
// Invalidate team-specific cache tags
|
||||
// Invalidate cache tags that match the unstable_cache keys
|
||||
revalidateTag("team-roles");
|
||||
revalidateTag("resource-permissions");
|
||||
revalidateTag("team-feature");
|
||||
|
||||
// Also invalidate team-specific cache tags for completeness
|
||||
revalidateTag(`team-roles-${teamId}`);
|
||||
revalidateTag(`resource-permissions-${teamId}`);
|
||||
revalidateTag(`team-members-${teamId}`);
|
||||
|
||||
@@ -29,9 +29,9 @@ function mockGetCRMContactOwnerForRRLeadSkip({
|
||||
}) {
|
||||
vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => {
|
||||
if (_bookerEmail === bookerEmail) {
|
||||
return Promise.resolve({ email: teamMemberEmail, recordType: null, crmAppSlug: null });
|
||||
return Promise.resolve({ email: teamMemberEmail, recordType: null, crmAppSlug: null, recordId: null });
|
||||
}
|
||||
return Promise.resolve({ email: null, recordType: null, crmAppSlug: null });
|
||||
return Promise.resolve({ email: null, recordType: null, crmAppSlug: null, recordId: null });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@ function mockBookingFormHandler({
|
||||
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
|
||||
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
|
||||
if (_bookerEmail === bookerEmail) {
|
||||
return Promise.resolve({ email: teamMemberEmail, recordType: null });
|
||||
return Promise.resolve({ email: teamMemberEmail, recordType: null, recordId: null });
|
||||
}
|
||||
return Promise.resolve({ email: null, recordType: null });
|
||||
return Promise.resolve({ email: null, recordType: null, recordId: null });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const crmContactOwnerEmail = query["cal.crmContactOwnerEmail"];
|
||||
const crmContactOwnerRecordType = query["cal.crmContactOwnerRecordType"];
|
||||
const crmAppSlugParam = query["cal.crmAppSlug"];
|
||||
const crmRecordIdParam = query["cal.crmRecordId"];
|
||||
|
||||
// Handle string[] type from query params
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
@@ -101,6 +102,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
: crmContactOwnerRecordType;
|
||||
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
let crmRecordId = Array.isArray(crmRecordIdParam) ? crmRecordIdParam[0] : crmRecordIdParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
@@ -110,6 +112,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
email,
|
||||
recordType,
|
||||
crmAppSlug: crmAppSlugQuery,
|
||||
recordId,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventData,
|
||||
@@ -118,6 +121,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
teamMemberEmail = email ?? undefined;
|
||||
crmOwnerRecordType = recordType ?? undefined;
|
||||
crmAppSlug = crmAppSlugQuery ?? undefined;
|
||||
crmRecordId = recordId ?? undefined;
|
||||
}
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
@@ -167,6 +171,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isSEOIndexable: allowSEOIndexing,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookingStatusLineChart,
|
||||
HighestNoShowHostTable,
|
||||
HighestRatedMembersTable,
|
||||
BookingsByHourChart,
|
||||
LeastBookedTeamMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
MostBookedTeamMembersTable,
|
||||
@@ -71,23 +72,35 @@ function InsightsPageContent() {
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<BookingsByHourChart />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<PopularEventsTable />
|
||||
</div>
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<MostCancelledBookingsTables />
|
||||
<HighestNoShowHostTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<HighestRatedMembersTable />
|
||||
<LowestRatedMembersTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<RecentFeedbackTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
<a
|
||||
|
||||
@@ -34,6 +34,7 @@ function Type({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isEmbed,
|
||||
useApiV2,
|
||||
}: PageProps) {
|
||||
@@ -64,6 +65,7 @@ function Type({
|
||||
teamMemberEmail={teamMemberEmail}
|
||||
crmOwnerRecordType={crmOwnerRecordType}
|
||||
crmAppSlug={crmAppSlug}
|
||||
crmRecordId={crmRecordId}
|
||||
/>
|
||||
</main>
|
||||
</BookingPageErrorBoundary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.3",
|
||||
"version": "5.5.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -8,7 +8,7 @@
|
||||
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "yarn copy-static && next dev --turbopack",
|
||||
"dev:cron": "ts-node cron-tester.ts",
|
||||
"dev:cron": "npx tsx cron-tester.ts",
|
||||
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
|
||||
"dx": "yarn dev",
|
||||
"test-codegen": "yarn playwright codegen http://localhost:3000",
|
||||
|
||||
@@ -2168,6 +2168,7 @@
|
||||
"popular_events": "Popular Events",
|
||||
"no_event_types_found": "No event types found",
|
||||
"average_event_duration": "Average Event Duration",
|
||||
"bookings_by_hour": "Bookings by Hour",
|
||||
"most_booked_members": "Most Booked",
|
||||
"least_booked_members": "Least Booked",
|
||||
"events_created": "Events Created",
|
||||
@@ -3378,5 +3379,12 @@
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"cache_status": "Cache Status",
|
||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||
"delete_cached_data": "Delete cached data",
|
||||
"cache_deleted_successfully": "Cache deleted successfully",
|
||||
"error_deleting_cache": "Error deleting cache",
|
||||
"confirm_delete_cache": "Are you sure you want to delete the cached data? This action cannot be undone.",
|
||||
"yes_delete_cache": "Yes, delete cache",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,13 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
export async function getCRMContactOwnerForRRLeadSkip(
|
||||
bookerEmail: string,
|
||||
eventTypeMetadata: Prisma.JsonValue
|
||||
): Promise<{ email: string | null; recordType: string | null; crmAppSlug: string | null }> {
|
||||
const nullReturnValue = { email: null, recordType: null, crmAppSlug: "" };
|
||||
): Promise<{
|
||||
email: string | null;
|
||||
recordType: string | null;
|
||||
crmAppSlug: string | null;
|
||||
recordId: string | null;
|
||||
}> {
|
||||
const nullReturnValue = { email: null, recordType: null, crmAppSlug: "", recordId: null };
|
||||
const parsedEventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventTypeMetadata);
|
||||
if (!parsedEventTypeMetadata.success || !parsedEventTypeMetadata.data?.apps) return nullReturnValue;
|
||||
|
||||
@@ -24,7 +29,12 @@ export async function getCRMContactOwnerForRRLeadSkip(
|
||||
const endTime = performance.now();
|
||||
logger.info(`Fetching from CRM took ${endTime - startTime}ms`);
|
||||
if (!contact?.length || !contact[0].ownerEmail) return nullReturnValue;
|
||||
return { email: contact[0].ownerEmail ?? null, recordType: contact[0].recordType ?? null, crmAppSlug };
|
||||
return {
|
||||
email: contact[0].ownerEmail ?? null,
|
||||
recordType: contact[0].recordType ?? null,
|
||||
crmAppSlug,
|
||||
recordId: contact[0].id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCRMManagerWithRRLeadSkip(apps: z.infer<typeof EventTypeAppMetadataSchema>) {
|
||||
|
||||
@@ -1019,6 +1019,9 @@ export default class GoogleCalendarService implements Calendar {
|
||||
const data = await this.fetchAvailability(parsedArgs);
|
||||
await this.setAvailabilityInCache(parsedArgs, data);
|
||||
}
|
||||
|
||||
// Update SelectedCalendar.updatedAt for all calendars under this credential
|
||||
await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {});
|
||||
}
|
||||
|
||||
async createSelectedCalendar(
|
||||
|
||||
@@ -5,7 +5,7 @@ type AppBookingFormHandler = (
|
||||
attendeeEmail: string,
|
||||
attributeRoutingConfig: AttributeRoutingConfig,
|
||||
eventTypeId: number
|
||||
) => Promise<{ email: string | null; recordType: string | null }>;
|
||||
) => Promise<{ email: string | null; recordType: string | null; recordId: string | null }>;
|
||||
|
||||
const appBookingFormHandler: Record<string, AppBookingFormHandler> = {
|
||||
salesforce: routingFormBookingFormHandler,
|
||||
|
||||
@@ -40,10 +40,16 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
const eventTypeMetadata = eventType.metadata;
|
||||
if (!eventTypeMetadata) return null;
|
||||
|
||||
let contactOwner: { email: string | null; recordType: string | null; crmAppSlug: string | null } = {
|
||||
let contactOwner: {
|
||||
email: string | null;
|
||||
recordType: string | null;
|
||||
crmAppSlug: string | null;
|
||||
recordId: string | null;
|
||||
} = {
|
||||
email: null,
|
||||
recordType: null,
|
||||
crmAppSlug: null,
|
||||
recordId: null,
|
||||
};
|
||||
// Determine if there is a CRM option enabled in the chosen route
|
||||
for (const appSlug of enabledAppSlugs) {
|
||||
|
||||
@@ -267,6 +267,7 @@ describe("handleResponse", () => {
|
||||
email: "owner@example.com",
|
||||
recordType: "contact",
|
||||
crmAppSlug: "hubspot",
|
||||
recordId: "123",
|
||||
});
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
||||
|
||||
@@ -96,6 +96,7 @@ const _handleResponse = async ({
|
||||
let crmContactOwnerEmail: string | null = null;
|
||||
let crmContactOwnerRecordType: string | null = null;
|
||||
let crmAppSlug: string | null = null;
|
||||
let crmRecordId: string | null = null;
|
||||
let timeTaken: Record<string, number | null> = {};
|
||||
if (chosenRoute) {
|
||||
if (isRouter(chosenRoute)) {
|
||||
@@ -119,6 +120,7 @@ const _handleResponse = async ({
|
||||
crmContactOwnerEmail = contactOwnerQuery?.email ?? null;
|
||||
crmContactOwnerRecordType = contactOwnerQuery?.recordType ?? null;
|
||||
crmAppSlug = contactOwnerQuery?.crmAppSlug ?? null;
|
||||
crmRecordId = contactOwnerQuery?.recordId ?? null;
|
||||
})(),
|
||||
(async () => {
|
||||
const teamMembersMatchingAttributeLogicWithResult =
|
||||
@@ -212,6 +214,7 @@ const _handleResponse = async ({
|
||||
crmContactOwnerEmail,
|
||||
crmContactOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
attributeRoutingConfig: chosenRoute
|
||||
? "attributeRoutingConfig" in chosenRoute
|
||||
? chosenRoute.attributeRoutingConfig
|
||||
|
||||
@@ -8,27 +8,52 @@ export async function assignmentReasonHandler({
|
||||
recordType,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
recordId,
|
||||
}: {
|
||||
recordType: string;
|
||||
teamMemberEmail: string;
|
||||
routingFormResponseId: number;
|
||||
recordId?: string;
|
||||
}) {
|
||||
const returnObject = { reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT };
|
||||
|
||||
switch (recordType) {
|
||||
case SalesforceRecordEnum.CONTACT:
|
||||
return { ...returnObject, assignmentReason: `Salesforce contact owner: ${teamMemberEmail}` };
|
||||
return {
|
||||
...returnObject,
|
||||
assignmentReason: `Salesforce contact owner: ${teamMemberEmail}${
|
||||
recordId ? ` (Contact ID: ${recordId})` : ""
|
||||
}`,
|
||||
};
|
||||
case SalesforceRecordEnum.LEAD:
|
||||
return { ...returnObject, assignmentReason: `Salesforce lead owner: ${teamMemberEmail}` };
|
||||
return {
|
||||
...returnObject,
|
||||
assignmentReason: `Salesforce lead owner: ${teamMemberEmail}${
|
||||
recordId ? ` (Lead ID: ${recordId})` : ""
|
||||
}`,
|
||||
};
|
||||
case SalesforceRecordEnum.ACCOUNT:
|
||||
return { ...returnObject, assignmentReason: `Salesforce account owner: ${teamMemberEmail}` };
|
||||
return {
|
||||
...returnObject,
|
||||
assignmentReason: `Salesforce account owner: ${teamMemberEmail}${
|
||||
recordId ? ` (Account ID: ${recordId})` : ""
|
||||
}`,
|
||||
};
|
||||
case RoutingReasons.ACCOUNT_LOOKUP_FIELD:
|
||||
const assignmentReason = await handleAccountLookupFieldReason(routingFormResponseId, teamMemberEmail);
|
||||
const assignmentReason = await handleAccountLookupFieldReason(
|
||||
routingFormResponseId,
|
||||
teamMemberEmail,
|
||||
recordId
|
||||
);
|
||||
return { ...returnObject, assignmentReason };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccountLookupFieldReason(routingFormResponseId: number, teamMemberEmail: string) {
|
||||
async function handleAccountLookupFieldReason(
|
||||
routingFormResponseId: number,
|
||||
teamMemberEmail: string,
|
||||
recordId?: string
|
||||
) {
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
@@ -67,6 +92,8 @@ async function handleAccountLookupFieldReason(routingFormResponseId: number, tea
|
||||
const accountLookupFieldName = salesforceConfig?.rrSKipToAccountLookupFieldName;
|
||||
|
||||
return accountLookupFieldName
|
||||
? `Salesforce account lookup field: ${accountLookupFieldName} - ${teamMemberEmail}`
|
||||
? `Salesforce account lookup field: ${accountLookupFieldName} - ${teamMemberEmail}${
|
||||
recordId ? ` (Account ID: ${recordId})` : ""
|
||||
}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const routingFormBookingFormHandler = async (
|
||||
!salesforceSettings.rrSkipToAccountLookupField ||
|
||||
!salesforceSettings.rrSKipToAccountLookupFieldName
|
||||
)
|
||||
return { email: null, recordType: null };
|
||||
return { email: null, recordType: null, recordId: null };
|
||||
|
||||
const appData = await EventTypeService.getEventTypeAppDataFromId(eventTypeId, "salesforce");
|
||||
|
||||
@@ -24,7 +24,7 @@ const routingFormBookingFormHandler = async (
|
||||
|
||||
const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({ id: credentialId });
|
||||
|
||||
if (!credential) return { email: null, recordType: null };
|
||||
if (!credential) return { email: null, recordType: null, recordId: null };
|
||||
|
||||
const crm = new SalesforceCRMService(credential, {});
|
||||
|
||||
@@ -34,7 +34,11 @@ const routingFormBookingFormHandler = async (
|
||||
SalesforceRecordEnum.ACCOUNT
|
||||
);
|
||||
|
||||
return { email: userLookupEmail?.email ?? null, recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD as string };
|
||||
return {
|
||||
email: userLookupEmail?.email ?? null,
|
||||
recordType: RoutingReasons.ACCOUNT_LOOKUP_FIELD as string,
|
||||
recordId: null,
|
||||
};
|
||||
};
|
||||
|
||||
export default routingFormBookingFormHandler;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Dialog } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface CredentialActionsDropdownProps {
|
||||
credentialId: number;
|
||||
integrationType: string;
|
||||
cacheUpdatedAt?: Date | null;
|
||||
onSuccess?: () => void;
|
||||
delegationCredentialId?: string | null;
|
||||
disableConnectionModification?: boolean;
|
||||
}
|
||||
|
||||
export default function CredentialActionsDropdown({
|
||||
credentialId,
|
||||
integrationType,
|
||||
cacheUpdatedAt,
|
||||
onSuccess,
|
||||
delegationCredentialId,
|
||||
disableConnectionModification,
|
||||
}: CredentialActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
|
||||
|
||||
const deleteCacheMutation = trpc.viewer.calendars.deleteCache.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("cache_deleted_successfully"), "success");
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_deleting_cache"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const disconnectMutation = trpc.viewer.credentials.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("app_removed_successfully"), "success");
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_removing_app"), "error");
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.calendars.connectedCalendars.invalidate();
|
||||
await utils.viewer.apps.integrations.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const isGoogleCalendar = integrationType === GOOGLE_CALENDAR_TYPE;
|
||||
const canDisconnect = !delegationCredentialId && !disableConnectionModification;
|
||||
const hasCache = isGoogleCalendar && cacheUpdatedAt;
|
||||
|
||||
if (!canDisconnect && !hasCache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{hasCache && (
|
||||
<>
|
||||
<DropdownMenuItem className="focus:ring-muted">
|
||||
<div className="px-2 py-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{t("cache_status")}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-white">
|
||||
{t("cache_last_updated", {
|
||||
timestamp: new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(cacheUpdatedAt)),
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
onClick={() => {
|
||||
setDeleteModalOpen(true);
|
||||
setDropdownOpen(false);
|
||||
}}>
|
||||
{t("delete_cached_data")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canDisconnect && hasCache && <hr className="my-1" />}
|
||||
{canDisconnect && (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
onClick={() => {
|
||||
setDisconnectModalOpen(true);
|
||||
setDropdownOpen(false);
|
||||
}}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
|
||||
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_cached_data")}
|
||||
confirmBtnText={t("yes_delete_cache")}
|
||||
onConfirm={() => {
|
||||
deleteCacheMutation.mutate({ credentialId });
|
||||
setDeleteModalOpen(false);
|
||||
}}>
|
||||
{t("confirm_delete_cache")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={disconnectModalOpen} onOpenChange={setDisconnectModalOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_app")}
|
||||
confirmBtnText={t("yes_remove_app")}
|
||||
onConfirm={() => {
|
||||
disconnectMutation.mutate({ id: credentialId });
|
||||
setDisconnectModalOpen(false);
|
||||
}}>
|
||||
{t("are_you_sure_you_want_to_remove_this_app")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type StoreInitializeType = {
|
||||
teamMemberEmail?: string | null;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
isPlatform?: boolean;
|
||||
allowUpdatingUrlParams?: boolean;
|
||||
};
|
||||
@@ -163,6 +164,7 @@ export type BookerStore = {
|
||||
teamMemberEmail?: string | null;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
isPlatform?: boolean;
|
||||
allowUpdatingUrlParams?: boolean;
|
||||
};
|
||||
@@ -306,6 +308,7 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isPlatform = false,
|
||||
allowUpdatingUrlParams = true,
|
||||
}: StoreInitializeType) => {
|
||||
@@ -324,7 +327,8 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
|
||||
get().rescheduledBy === rescheduledBy &&
|
||||
get().teamMemberEmail === teamMemberEmail &&
|
||||
get().crmOwnerRecordType === crmOwnerRecordType &&
|
||||
get().crmAppSlug
|
||||
get().crmAppSlug === crmAppSlug &&
|
||||
get().crmRecordId === crmRecordId
|
||||
)
|
||||
return;
|
||||
set({
|
||||
@@ -347,6 +351,7 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
});
|
||||
@@ -456,6 +461,7 @@ export const useInitializeBookerStore = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isPlatform = false,
|
||||
allowUpdatingUrlParams = true,
|
||||
}: StoreInitializeType) => {
|
||||
@@ -479,6 +485,7 @@ export const useInitializeBookerStore = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
});
|
||||
@@ -501,6 +508,7 @@ export const useInitializeBookerStore = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
]);
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface BookerProps {
|
||||
teamMemberEmail?: string | null;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
areInstantMeetingParametersSet?: boolean | null;
|
||||
userLocale?: string | null;
|
||||
hasValidLicense?: boolean;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type BookingOptions = {
|
||||
teamMemberEmail?: string | null;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
orgSlug?: string;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
isDryRunProp?: boolean;
|
||||
@@ -49,6 +50,7 @@ export const mapBookingToMutationInput = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
orgSlug,
|
||||
routingFormSearchParams,
|
||||
isDryRunProp,
|
||||
@@ -86,6 +88,7 @@ export const mapBookingToMutationInput = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
orgSlug,
|
||||
routedTeamMemberIds,
|
||||
routingFormResponseId,
|
||||
|
||||
@@ -378,6 +378,7 @@ export type PlatformParams = {
|
||||
};
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
bookingData: Record<string, any>;
|
||||
userId?: number;
|
||||
// These used to come from headers but now we're passing them as params
|
||||
@@ -597,6 +598,7 @@ async function handler(
|
||||
});
|
||||
|
||||
const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq;
|
||||
const crmRecordId: string | undefined = reqBody.crmRecordId ?? undefined;
|
||||
|
||||
let routingFormResponse = null;
|
||||
|
||||
@@ -1397,6 +1399,7 @@ async function handler(
|
||||
teamMemberEmail: contactOwnerEmail,
|
||||
recordType: reqBody.crmOwnerRecordType,
|
||||
routingFormResponseId,
|
||||
recordId: crmRecordId,
|
||||
});
|
||||
} else if (routingFormResponseId && teamId) {
|
||||
assignmentReason = await AssignmentReasonRecorder.routingFormRoute({
|
||||
|
||||
@@ -27,4 +27,7 @@ export interface ICalendarCacheRepository {
|
||||
userId: number | null;
|
||||
args: FreeBusyArgs;
|
||||
}): Promise<CalendarCache | null>;
|
||||
getCacheStatusByCredentialIds(
|
||||
credentialIds: number[]
|
||||
): Promise<{ credentialId: number; updatedAt: Date | null }[]>;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,9 @@ export class CalendarCacheRepositoryMock implements ICalendarCacheRepository {
|
||||
async deleteManyByCredential() {
|
||||
log.info(`Skipping deleteManyByCredential due to calendar-cache being disabled`);
|
||||
}
|
||||
|
||||
async getCacheStatusByCredentialIds() {
|
||||
log.info(`Skipping getCacheStatusByCredentialIds due to calendar-cache being disabled`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,4 +169,21 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getCacheStatusByCredentialIds(credentialIds: number[]) {
|
||||
const cacheStatuses = await prisma.calendarCache.groupBy({
|
||||
by: ["credentialId"],
|
||||
where: {
|
||||
credentialId: { in: credentialIds },
|
||||
},
|
||||
_max: {
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return cacheStatuses.map((cache) => ({
|
||||
credentialId: cache.credentialId,
|
||||
updatedAt: cache._max.updatedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,5 +644,33 @@ describe("CreditService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip unpublished platform organizations and return regular team with credits", async () => {
|
||||
vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
|
||||
{ teamId: 2 },
|
||||
]);
|
||||
|
||||
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
|
||||
id: "2",
|
||||
additionalCredits: 100,
|
||||
limitReachedAt: null,
|
||||
warningSentAt: null,
|
||||
});
|
||||
vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 200,
|
||||
additionalCredits: 100,
|
||||
});
|
||||
const result = await creditService.getTeamWithAvailableCredits(1);
|
||||
expect(result).toEqual({
|
||||
teamId: 2,
|
||||
availableCredits: 300,
|
||||
creditType: CreditType.MONTHLY,
|
||||
});
|
||||
|
||||
expect(MembershipRepository.findAllAcceptedPublishedTeamMemberships).toHaveBeenCalledWith(1, MOCK_TX);
|
||||
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledTimes(1);
|
||||
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledWith({ teamId: 2 }, MOCK_TX);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,18 +149,25 @@ export default class AssignmentReasonRecorder {
|
||||
teamMemberEmail,
|
||||
recordType,
|
||||
routingFormResponseId,
|
||||
recordId,
|
||||
}: {
|
||||
bookingId: number;
|
||||
crmAppSlug: string;
|
||||
teamMemberEmail: string;
|
||||
recordType: string;
|
||||
routingFormResponseId: number;
|
||||
recordId?: string;
|
||||
}) {
|
||||
const appAssignmentReasonHandler = (await import("./appAssignmentReasonHandler")).default;
|
||||
const appHandler = appAssignmentReasonHandler[crmAppSlug];
|
||||
if (!appHandler) return;
|
||||
|
||||
const crmRoutingReason = await appHandler({ recordType, teamMemberEmail, routingFormResponseId });
|
||||
const crmRoutingReason = await appHandler({
|
||||
recordType,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
if (!crmRoutingReason || !crmRoutingReason.assignmentReason) return;
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ type AppAssignmentReasonHandler = ({
|
||||
recordType,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
recordId,
|
||||
}: {
|
||||
recordType: string;
|
||||
teamMemberEmail: string;
|
||||
routingFormResponseId: number;
|
||||
recordId?: string;
|
||||
}) => Promise<{ assignmentReason: string | undefined; reasonEnum: AssignmentReasonEnum } | undefined>;
|
||||
|
||||
const appBookingFormHandler: Record<string, AppAssignmentReasonHandler> = {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
|
||||
import { useDataTable } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsParameters } from "../hooks/useInsightsParameters";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
type BookingsByHourData = {
|
||||
hour: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
hour: `${item.hour.toString().padStart(2, "0")}:00`,
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
const maxBookings = Math.max(...data.map((item) => item.count));
|
||||
const isEmpty = maxBookings === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="text-default flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 0, left: -10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={false} content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--cal-bg-subtle)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: { hour: string; count: number };
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{label}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index}>
|
||||
{t("bookings")}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BookingsByHourChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { timeZone } = useDataTable();
|
||||
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("bookings_by_hour")}>
|
||||
<BookingsByHourChartContent data={data} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -51,9 +51,21 @@ export function ChartCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCardItem({ count, children }: { count?: number | string; children: ReactNode }) {
|
||||
export function ChartCardItem({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
count?: number | string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0",
|
||||
className
|
||||
)}>
|
||||
<div className="text-sm font-medium">{children}</div>
|
||||
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { CardInsights } from "./Card";
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const TotalBookingUsersTable = ({
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<ChartCardItem key={item.userId} count={item.count}>
|
||||
filteredData.map((item, index) => (
|
||||
<ChartCardItem key={index} count={item.count} className="py-3">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt={item.user.name || ""}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AverageEventDurationChart } from "./AverageEventDurationChart";
|
||||
export { BookingKPICards } from "./BookingKPICards";
|
||||
export { BookingsByHourChart } from "./BookingsByHourChart";
|
||||
|
||||
export { BookingStatusLineChart } from "./BookingStatusLineChart";
|
||||
export { FailedBookingsByField } from "./FailedBookingsByField";
|
||||
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
|
||||
|
||||
@@ -69,3 +69,13 @@ export const routingRepositoryBaseInputSchema = z.object({
|
||||
endDate: z.string(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
export const bookingRepositoryBaseInputSchema = z.object({
|
||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||
selectedTeamId: z.number().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
timeZone: z.string(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
routingRepositoryBaseInputSchema,
|
||||
bookingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
|
||||
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRouting";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -1760,6 +1762,35 @@ export const insightsRouter = router({
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
bookingsByHourStats: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
|
||||
|
||||
const insightsBookingService = new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
return await insightsBookingService.getBookingsByHourStats({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
async function getEventTypeList({
|
||||
|
||||
@@ -18,8 +18,14 @@ type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCal
|
||||
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];
|
||||
|
||||
export type UserWithCalendars = Pick<User, "id" | "email"> & {
|
||||
allSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
|
||||
userLevelSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
|
||||
allSelectedCalendars: Pick<
|
||||
SelectedCalendar,
|
||||
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
|
||||
>[];
|
||||
userLevelSelectedCalendars: Pick<
|
||||
SelectedCalendar,
|
||||
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
|
||||
>[];
|
||||
destinationCalendar: DestinationCalendar | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface EventData {
|
||||
length: number;
|
||||
}
|
||||
|
||||
const returnNullValue = { email: null, recordType: null, crmAppSlug: null };
|
||||
const returnNullValue = { email: null, recordType: null, crmAppSlug: null, recordId: null };
|
||||
|
||||
async function findUserByEmailWhoIsAHostOfEventType({
|
||||
email,
|
||||
@@ -57,7 +57,7 @@ async function getAttributeRoutingConfig(
|
||||
if ("route" in data) {
|
||||
return data.route.attributeRoutingConfig ?? null;
|
||||
}
|
||||
const { routingFormResponseId, eventTypeId } = data;
|
||||
const { routingFormResponseId } = data;
|
||||
|
||||
const routingFormResponseQuery = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
@@ -117,7 +117,12 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
|
||||
async function getOwnerEmailFromCrm(
|
||||
eventData: EventData,
|
||||
email: string
|
||||
): Promise<{ email: string | null; recordType: string | null; crmAppSlug: string | null }> {
|
||||
): Promise<{
|
||||
email: string | null;
|
||||
recordType: string | null;
|
||||
crmAppSlug: string | null;
|
||||
recordId: string | null;
|
||||
}> {
|
||||
const crmContactOwner = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
|
||||
|
||||
if (!crmContactOwner?.email) return returnNullValue;
|
||||
@@ -159,7 +164,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
attributeRoutingConfig: AttributeRoutingConfig | null;
|
||||
crmAppSlug: string;
|
||||
}) {
|
||||
const nullReturnValue = { email: null, skipContactOwner: false, recordType: "" };
|
||||
const nullReturnValue = { email: null, skipContactOwner: false, recordType: "", recordId: "" };
|
||||
|
||||
if (!attributeRoutingConfig) return nullReturnValue;
|
||||
|
||||
@@ -171,7 +176,11 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
const appHandler = appBookingFormHandler[crmAppSlug];
|
||||
if (!appHandler) return nullReturnValue;
|
||||
|
||||
const { email: userEmail, recordType } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
|
||||
const {
|
||||
email: userEmail,
|
||||
recordType,
|
||||
recordId,
|
||||
} = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
|
||||
|
||||
if (!userEmail) return nullReturnValue;
|
||||
|
||||
@@ -180,7 +189,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
|
||||
if (!userQuery) return nullReturnValue;
|
||||
|
||||
return { ...nullReturnValue, email: userEmail, recordType };
|
||||
return { ...nullReturnValue, email: userEmail, recordType, recordId };
|
||||
}
|
||||
|
||||
async function getTeamMemberEmailForResponseOrContact({
|
||||
@@ -198,7 +207,12 @@ async function getTeamMemberEmailForResponseOrContact({
|
||||
*/
|
||||
chosenRoute?: LocalRoute;
|
||||
crmAppSlug?: string;
|
||||
}) {
|
||||
}): Promise<{
|
||||
email: string | null;
|
||||
recordType: string | null;
|
||||
crmAppSlug: string | null;
|
||||
recordId: string | null;
|
||||
}> {
|
||||
const eventTypeId = eventData.id;
|
||||
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return returnNullValue;
|
||||
|
||||
@@ -215,15 +229,17 @@ async function getTeamMemberEmailForResponseOrContact({
|
||||
safeStringify({ attributeRoutingConfigGetterData, crmAppSlug })
|
||||
);
|
||||
const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData);
|
||||
const { email, skipContactOwner, recordType } = await getTeamMemberEmailUsingRoutingFormHandler({
|
||||
bookerEmail,
|
||||
eventTypeId,
|
||||
attributeRoutingConfig,
|
||||
crmAppSlug,
|
||||
});
|
||||
const { email, skipContactOwner, recordType, recordId } = await getTeamMemberEmailUsingRoutingFormHandler(
|
||||
{
|
||||
bookerEmail,
|
||||
eventTypeId,
|
||||
attributeRoutingConfig,
|
||||
crmAppSlug,
|
||||
}
|
||||
);
|
||||
|
||||
if (skipContactOwner) return returnNullValue;
|
||||
if (email) return { email, recordType, crmAppSlug };
|
||||
if (email) return { email, recordType, crmAppSlug, recordId };
|
||||
} else {
|
||||
log.debug("Getting the contact owner email from CRM");
|
||||
return await getOwnerEmailFromCrm(eventData, bookerEmail);
|
||||
@@ -240,7 +256,12 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: ParsedUrlQuery;
|
||||
eventData: EventData;
|
||||
chosenRoute?: LocalRoute;
|
||||
}) {
|
||||
}): Promise<{
|
||||
email: string | null;
|
||||
recordType: string | null;
|
||||
crmAppSlug: string | null;
|
||||
recordId: string | null;
|
||||
}> {
|
||||
// Without email no lookup is possible
|
||||
if (!query.email || typeof query.email !== "string") {
|
||||
return returnNullValue;
|
||||
|
||||
@@ -257,8 +257,7 @@ export class SelectedCalendarRepository {
|
||||
}
|
||||
|
||||
static async findMany({ where, select, orderBy }: FindManyArgs) {
|
||||
const args = { where, select, orderBy } satisfies Prisma.SelectedCalendarFindManyArgs;
|
||||
return await prisma.selectedCalendar.findMany(args);
|
||||
return await prisma.selectedCalendar.findMany({ where, select, orderBy });
|
||||
}
|
||||
|
||||
static async findUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
|
||||
@@ -398,6 +397,13 @@ export class SelectedCalendarRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async updateManyByCredentialId(credentialId: number, data: Prisma.SelectedCalendarUpdateInput) {
|
||||
return await prisma.selectedCalendar.updateMany({
|
||||
where: { credentialId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
static async setErrorInWatching({ id, error }: { id: string; error: string }) {
|
||||
await SelectedCalendarRepository.updateById(id, {
|
||||
error,
|
||||
|
||||
@@ -896,6 +896,8 @@ export class UserRepository {
|
||||
eventTypeId: true,
|
||||
externalId: true,
|
||||
integration: true,
|
||||
updatedAt: true,
|
||||
googleChannelId: true,
|
||||
},
|
||||
},
|
||||
completedOnboarding: true,
|
||||
|
||||
@@ -554,7 +554,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
|
||||
const results = await service.getBaseConditions();
|
||||
expect(results).toEqual(
|
||||
Prisma.sql`("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition})`
|
||||
Prisma.sql`(("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition}))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
|
||||
@@ -65,16 +65,60 @@ export class InsightsBookingService {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async getBookingsByHourStats({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeZone: string;
|
||||
}) {
|
||||
// Validate date formats
|
||||
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
|
||||
throw new Error(`Invalid date format: ${startDate} - ${endDate}`);
|
||||
}
|
||||
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
const results = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
hour: string;
|
||||
count: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone}))::int as "hour",
|
||||
COUNT(*)::int as "count"
|
||||
FROM "BookingTimeStatusDenormalized"
|
||||
WHERE ${baseConditions}
|
||||
AND "startTime" >= ${startDate}::timestamp
|
||||
AND "startTime" <= ${endDate}::timestamp
|
||||
AND "status" = 'accepted'
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`;
|
||||
|
||||
// Create a map of results by hour for easy lookup
|
||||
const resultsMap = new Map(results.map((row) => [Number(row.hour), row.count]));
|
||||
|
||||
// Return all 24 hours (0-23), filling with 0 values for missing data
|
||||
return Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
count: resultsMap.get(hour) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
return Prisma.sql`(${authConditions})`;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
return Prisma.sql`(${filterConditions})`;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
@@ -169,11 +169,11 @@ export class InsightsRoutingService {
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
return Prisma.sql`(${authConditions})`;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
return Prisma.sql`(${filterConditions})`;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ export const BookerPlatformWrapper = (
|
||||
teamMemberEmail,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
crmRecordId: props.crmRecordId,
|
||||
eventId: event?.data?.id,
|
||||
rescheduleUid: props.rescheduleUid ?? null,
|
||||
bookingUid: props.bookingUid ?? null,
|
||||
|
||||
@@ -64,7 +64,6 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
|
||||
// This event isn't processed by BookingPageTagManager because BookingPageTagManager hasn't loaded when it is fired. I think we should have a queue in fire method to handle this.
|
||||
sdkActionManager?.fire("navigatedToBooker", {});
|
||||
}, []);
|
||||
|
||||
useInitializeBookerStore({
|
||||
...props,
|
||||
eventId: props.entity.eventTypeId ?? event?.data?.id,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const useHandleBookEvent = ({
|
||||
const teamMemberEmail = useBookerStore((state) => state.teamMemberEmail);
|
||||
const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType);
|
||||
const crmAppSlug = useBookerStore((state) => state.crmAppSlug);
|
||||
const crmRecordId = useBookerStore((state) => state.crmRecordId);
|
||||
const handleError = (err: any) => {
|
||||
const errorMessage = err?.message ? t(err.message) : t("can_you_try_again");
|
||||
showToast(errorMessage, "error");
|
||||
@@ -108,6 +109,7 @@ export const useHandleBookEvent = ({
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
crmRecordId,
|
||||
orgSlug: orgSlug ? orgSlug : undefined,
|
||||
routingFormSearchParams,
|
||||
isDryRunProp: isBookingDryRun,
|
||||
|
||||
+23
-25
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import AppListCard from "@calcom/features/apps/components/AppListCard";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import CredentialActionsDropdown from "@calcom/features/apps/components/CredentialActionsDropdown";
|
||||
import AdditionalCalendarSelector from "@calcom/features/calendars/AdditionalCalendarSelector";
|
||||
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -67,18 +67,16 @@ const ConnectedCalendarList = ({
|
||||
description={connectedCalendar.primary?.email ?? connectedCalendar.integration.description}
|
||||
className="border-subtle mt-4 rounded-lg border"
|
||||
actions={
|
||||
// Delegation credential can't be disconnected
|
||||
!connectedCalendar.delegationCredentialId &&
|
||||
!disableConnectionModification && (
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={connectedCalendar.credentialId}
|
||||
trashIcon
|
||||
onSuccess={onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
<div className="flex w-32 justify-end">
|
||||
<CredentialActionsDropdown
|
||||
credentialId={connectedCalendar.credentialId}
|
||||
integrationType={connectedCalendar.integration.type}
|
||||
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
|
||||
onSuccess={onChanged}
|
||||
delegationCredentialId={connectedCalendar.delegationCredentialId}
|
||||
disableConnectionModification={disableConnectionModification}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="border-subtle border-t">
|
||||
{!fromOnboarding && (
|
||||
@@ -97,7 +95,7 @@ const ConnectedCalendarList = ({
|
||||
destination={cal.externalId === destinationCalendarId}
|
||||
credentialId={cal.credentialId}
|
||||
eventTypeId={shouldUseEventTypeScope ? eventTypeId : null}
|
||||
delegationCredentialId={connectedCalendar.delegationCredentialId}
|
||||
delegationCredentialId={connectedCalendar.delegationCredentialId || null}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -122,17 +120,16 @@ const ConnectedCalendarList = ({
|
||||
}
|
||||
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||
actions={
|
||||
// Delegation credential can't be disconnected
|
||||
!connectedCalendar.delegationCredentialId && (
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={connectedCalendar.credentialId}
|
||||
trashIcon
|
||||
onSuccess={onChanged}
|
||||
buttonProps={{ className: "border border-default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
<div className="flex w-32 justify-end">
|
||||
<CredentialActionsDropdown
|
||||
credentialId={connectedCalendar.credentialId}
|
||||
integrationType={connectedCalendar.integration.type}
|
||||
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
|
||||
onSuccess={onChanged}
|
||||
delegationCredentialId={connectedCalendar.delegationCredentialId}
|
||||
disableConnectionModification={disableConnectionModification}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -162,6 +159,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { isPending } = props;
|
||||
const showScopeSelector = !!props.eventTypeId;
|
||||
const isDisabled = disabledScope ? disabledScope === scope : false;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `CalendarCache` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
-- Add the column with a default value to safely handle existing rows
|
||||
ALTER TABLE "CalendarCache" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
|
||||
@@ -1715,6 +1715,8 @@ model CalendarCache {
|
||||
key String
|
||||
value Json
|
||||
expiresAt DateTime
|
||||
// Provide an initial value for legacy rows and future raw inserts
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
credentialId Int
|
||||
userId Int?
|
||||
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -23,6 +23,7 @@ export const bookingCreateBodySchema = z.object({
|
||||
orgSlug: z.string().optional(),
|
||||
teamMemberEmail: z.string().nullish(),
|
||||
crmOwnerRecordType: z.string().nullish(),
|
||||
crmRecordId: z.string().nullish(),
|
||||
routedTeamMemberIds: z.array(z.number()).nullish(),
|
||||
routingFormResponseId: z.number().optional(),
|
||||
skipContactOwner: z.boolean().optional(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
|
||||
@@ -22,4 +24,11 @@ export const calendarsRouter = router({
|
||||
|
||||
return setDestinationCalendarHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
deleteCache: authedProcedure
|
||||
.input(z.object({ credentialId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { deleteCacheHandler } = await import("./deleteCache.handler");
|
||||
return deleteCacheHandler({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
|
||||
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
@@ -23,8 +24,19 @@ export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalenda
|
||||
prisma,
|
||||
});
|
||||
|
||||
const credentialIds = connectedCalendars.map((cal) => cal.credentialId);
|
||||
const cacheRepository = new CalendarCacheRepository();
|
||||
const cacheStatuses = await cacheRepository.getCacheStatusByCredentialIds(credentialIds);
|
||||
|
||||
const cacheStatusMap = new Map(cacheStatuses.map((cache) => [cache.credentialId, cache.updatedAt]));
|
||||
|
||||
const enrichedConnectedCalendars = connectedCalendars.map((calendar) => ({
|
||||
...calendar,
|
||||
cacheUpdatedAt: cacheStatusMap.get(calendar.credentialId) || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
connectedCalendars,
|
||||
connectedCalendars: enrichedConnectedCalendars,
|
||||
destinationCalendar,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
type DeleteCacheOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: {
|
||||
credentialId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteCacheHandler = async ({ ctx, input }: DeleteCacheOptions) => {
|
||||
const { user } = ctx;
|
||||
const { credentialId } = input;
|
||||
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
id: credentialId,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Credential not found or access denied");
|
||||
}
|
||||
|
||||
await prisma.calendarCache.deleteMany({
|
||||
where: { credentialId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
LOG_FILE="/tmp/tmole.log"
|
||||
ENV_FILE="../.env"
|
||||
TM_PORT=3000
|
||||
TM_KEYWORD="https://.*\.tunnelmole\.net"
|
||||
TMOLE_RUNNING=$(pgrep -f "tmole $TM_PORT")
|
||||
|
||||
# Clean exit function
|
||||
cleanup() {
|
||||
if [ "$OWNED_PID" = true ]; then
|
||||
echo -e "\nStopping Tunnelmole (PID: $TMOLE_PID)..."
|
||||
kill "$TMOLE_PID" 2>/dev/null
|
||||
wait "$TMOLE_PID" 2>/dev/null
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Function to extract URL from log
|
||||
extract_url_from_log() {
|
||||
grep -oE "$TM_KEYWORD" "$LOG_FILE" | head -n 1
|
||||
}
|
||||
|
||||
# If tmole is already running
|
||||
if [ -n "$TMOLE_RUNNING" ]; then
|
||||
echo "Tunnelmole already running (PID: $TMOLE_RUNNING), reusing..."
|
||||
TUNNEL_URL=$(extract_url_from_log)
|
||||
OWNED_PID=false
|
||||
else
|
||||
echo "Starting a new Tunnelmole session..."
|
||||
rm -f "$LOG_FILE"
|
||||
tmole $TM_PORT > "$LOG_FILE" 2>&1 &
|
||||
TMOLE_PID=$!
|
||||
OWNED_PID=true
|
||||
|
||||
# Wait for URL or error
|
||||
echo "Waiting for tmole to initialize..."
|
||||
for i in {1..20}; do
|
||||
if grep -q "$TM_KEYWORD" "$LOG_FILE"; then
|
||||
TUNNEL_URL=$(extract_url_from_log)
|
||||
break
|
||||
fi
|
||||
if grep -q "limited to 10 tunnels per hour" "$LOG_FILE"; then
|
||||
echo "❌ Rate limit hit: You've used your 10 free tunnels/hour. Sign up at:"
|
||||
echo "👉 https://dashboard.tunnelmole.com/upgrade/run-more-tunnels-faster"
|
||||
cleanup
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
# Check that we actually got a URL
|
||||
if [ -z "$TUNNEL_URL" ]; then
|
||||
echo "❌ Failed to extract Tunnelmole URL."
|
||||
cleanup
|
||||
fi
|
||||
|
||||
# Update env file
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "⚠️ $ENV_FILE not found. Creating new file."
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if grep -q '^GOOGLE_WEBHOOK_URL=' "$ENV_FILE"; then
|
||||
sed -i '' -E "s|^GOOGLE_WEBHOOK_URL=.*|GOOGLE_WEBHOOK_URL=$TUNNEL_URL|" "$ENV_FILE"
|
||||
else
|
||||
echo "GOOGLE_WEBHOOK_URL=$TUNNEL_URL" >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
echo "✅ Updated GOOGLE_WEBHOOK_URL in $ENV_FILE to $TUNNEL_URL"
|
||||
echo "Tunnelmole is running. Press Ctrl+C to stop."
|
||||
|
||||
# Keep script alive only if we started tmole
|
||||
if [ "$OWNED_PID" = true ]; then
|
||||
wait "$TMOLE_PID"
|
||||
fi
|
||||
Reference in New Issue
Block a user