Compare commits

...
6 Commits
Author SHA1 Message Date
emrysal 0cbc8a3816 chore: release v5.5.4
Post release / release (push) Canceled after 0s
2025-07-22 11:58:37 +00:00
sean-brydon 4e1048b221 fix: issues found in PBAC creation (#22659) 2025-07-22 10:01:41 +01:00
cb78692347 feat: add calendar cache status and actions (#22532)
* feat: add calendar cache status dropdown

- Add updatedAt field to CalendarCache schema with migration
- Create tRPC cacheStatus endpoint for fetching cache timestamps
- Add action dropdown to CalendarSwitch for Google Calendar entries
- Display formatted last updated timestamp in dropdown
- Add placeholder for cache deletion functionality
- Include translation strings for dropdown content

The dropdown only appears for Google Calendar integrations that have
active cache entries and provides cache management options for future
extensibility.

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities in repository files

- Remove problematic satisfies clause in selectedCalendar.ts
- Add missing cacheStatus parameter to ConnectedCalendarList component
- Fixes type errors that were preventing CI from passing

Co-Authored-By: zomars@cal.com <zomars@me.com>

* refactor: integrate cache status into connectedCalendars handler

- Remove separate cacheStatus tRPC endpoint as requested
- Return cache status as separate field in connectedCalendars response
- Update UI components to use cache data from connectedCalendars
- Fix Prisma type incompatibilities in repository files

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities and fix data flow for cache status

- Fix Prisma.SortOrder usage in membership.ts orderBy clauses
- Remove problematic satisfies clause in selectedCalendar.ts
- Fix TeamSelect type reference in team.ts
- Update SelectedCalendarsSettingsWebWrapper to properly pass cacheStatus data flow

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Discard changes to packages/lib/server/repository/membership.ts

* Discard changes to packages/lib/server/repository/team.ts

* fix: improve calendar cache dropdown with proper formatting and subscription logic

- Fix timestamp HTML entity encoding with interpolation escapeValue: false
- Only show dropdown for subscribed Google calendars (googleChannelId exists)
- Hide delete option when no cache data exists
- Include updatedAt and googleChannelId fields upstream in user repository
- Update data flow to pass subscription status through components

Co-Authored-By: zomars@cal.com <zomars@me.com>

* feat: update SelectedCalendar.updatedAt when Google webhooks trigger cache refresh

- Add updateManyByCredentialId method to SelectedCalendarRepository
- Update fetchAvailabilityAndSetCache to refresh SelectedCalendar timestamps
- Ensure webhook flow updates both CalendarCache and SelectedCalendar records
- Maintain proper timestamp tracking for calendar cache operations

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Add script to automate Tunnelmole webhook setup

Introduces test-gcal-webhooks.sh to start Tunnelmole, extract the public URL, and update GOOGLE_WEBHOOK_URL in the .env file. Handles process management, rate limits, and ensures environment configuration for Google Calendar webhooks.

* Update dev:cron script to use npx tsx

Replaces 'ts-node' with 'npx tsx' in the dev:cron script for running cron-tester.ts, likely to improve compatibility or leverage tsx features.

* Update cache status string and improve CalendarSwitch UI

Renamed 'last_updated' to 'cache_last_updated' in locale file for clarity and updated CalendarSwitch to use the new string. Also added dark mode text color support for cache status display.

* refactor: move cache management to credential-level dropdown with Remove App

- Create CredentialActionsDropdown component consolidating cache and app removal actions
- Add deleteCache tRPC mutation for credential-level cache deletion
- Update connectedCalendars handler to include cacheUpdatedAt at credential level
- Move dropdown from individual CalendarSwitch to credential level in SelectedCalendarsSettingsWebWrapper
- Remove cache-related props from CalendarSwitch component
- Add translation strings for cache management actions
- Consolidate all credential-level actions (cache management + Remove App) in one dropdown

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate translation keys in common.json

- Remove duplicate cache-related keys at lines 51-56
- Keep properly positioned keys later in file
- Addresses GitHub comment from zomars about duplicate keys

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: rename translation key to cache_last_updated

- Address GitHub comment from zomars
- Rename 'last_updated' to 'cache_last_updated' for specificity
- Update usage in CredentialActionsDropdown component

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate last_updated translation key

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: add confirmation dialog for cache deletion and use repository pattern

- Add confirmation dialog for destructive cache deletion action
- Replace direct Prisma calls with CalendarCacheRepository pattern
- Add getCacheStatusByCredentialIds method to repository interface
- Fix import paths for UI components
- Address GitHub review comments from zomars

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Update CredentialActionsDropdown.tsx

* Update common.json

* Update common.json

* fix: remove nested div wrapper to resolve HTML structure error

- Remove wrapping div around DisconnectIntegration component
- Fixes nested <p> tag validation error preventing Remove App functionality
- Maintains existing confirmation dialog patterns

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Fix API handler response termination logic

Removed unnecessary return values after setting status in the integrations API handler. This clarifies response handling and prevents returning the response object when not needed. Resolves "API handler should not return a value, received object".

* fix: 400 is correct error code for computing slot for past booking (#22574)

* fix

* add test

* chore: release v5.5.1

* Refactor credential disconnect to use confirmation dialog

Replaces the DisconnectIntegration component with an inline confirmation dialog for removing app credentials. Adds disconnect mutation logic and updates UI to improve user experience and consistency.

* Set default value for CalendarCache.updatedAt

Added a default value of NOW() for the updatedAt column in the CalendarCache table to ensure existing and future rows have a valid timestamp. Updated the Prisma schema to reflect this change and provide compatibility for legacy data and raw inserts.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: emrysal <me@alexvanandel.com>
2025-07-22 03:57:57 +01:00
3784421f02 feat: include record IDs in Salesforce assignment reason strings (#22561)
* feat: include record IDs in Salesforce assignment reason strings

- Add recordId parameter to assignmentReasonHandler function
- Include Contact ID, Lead ID, and Account ID in assignment reason strings
- Update entire call chain to pass record IDs from CRM service
- Maintain backward compatibility with optional recordId parameter

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: resolve lint warnings in assignment reason handler implementation

- Change Record<string, any> to Record<string, unknown> in BookingHandlerInput type
- Remove unused eventTypeId variable in getAttributeRoutingConfig function

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: revert to Record<string, any> with ESLint disable for BookingHandlerInput

- Revert from Record<string, unknown> to Record<string, any> to maintain type compatibility
- Add ESLint disable comment to suppress no-explicit-any warning
- Maintains consistency with handleNewRecurringBooking.ts pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: pass CRM record ID from booker state to handleNewBooking

- Add crmRecordId field to booker store interface and initialization
- Update mapBookingToMutationInput to include record ID from booker state
- Modify handleNewBooking to extract record ID from bookingData parameter
- Add crmRecordId to BookingCreateBody schema in Prisma layer
- Follow existing pattern for CRM fields (teamMemberEmail, crmOwnerRecordType, crmAppSlug)
- Ensures record ID flows: booker store → booking form → mapBookingToMutationInput → handleNewBooking

This replaces the previous backend CRM service extraction approach with
frontend booker state approach as requested by the user.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Pass crmRecordId as prop

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-21 14:31:31 +00:00
Eunjae Lee e7535b9db3 feat: add hourly booking charts on /insights (#22619)
* feat: add hourly booking charts on /insights

* safe guard

* rename

* update styles

* fix data

* clean up

* clean up

* re-order charts

* update style

* apply feedback

* rename

* update query
2025-07-21 13:52:56 +00:00
bb4260c98b feat: add test to verify unpublished platform orgs are excluded from credit usage (#22567)
* feat: add test to verify unpublished platform orgs are excluded from credit usage

- Add filtering logic in _getTeamWithAvailableCredits to skip unpublished platform organizations
- Add comprehensive test case that verifies user with memberships in both unpublished platform org and regular team
- Ensure only regular team is selected for credit usage, not unpublished platform org
- Test validates team.findUnique calls and credit balance checks for correct team filtering

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update credit-service.ts

* Update credit-service.test.ts

* fix: update test to work with repository-level filtering for unpublished platform orgs

- Remove obsolete team.findUnique mocking since filtering moved to repository layer
- Update test to mock findAllAcceptedPublishedTeamMemberships correctly
- Test now validates integration with repository-level filtering
- Maintains core validation that unpublished platform orgs are excluded from credit usage

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* docs: improve test comments to clarify repository-level filtering scenario

- Add clearer comments explaining user has memberships in both teams
- Clarify that repository method filters out unpublished platform orgs
- Better document the architectural change from PR #22600

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* docs: clarify test demonstrates user with memberships in both teams

- Add detailed comments explaining user has memberships in TWO teams initially
- Clarify that unpublished platform org (teamId: 1) and regular team (teamId: 2) scenario
- Explain repository-level filtering removes unpublished platform orgs before credit service
- Address user feedback about demonstrating two-team membership scenario

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* docs: add comprehensive comments explaining two-team filtering scenario

- Clarify that user has memberships in BOTH teams initially in database
- Explain that repository method filters out unpublished platform org (teamId: 1)
- Document that only regular team (teamId: 2) is returned to credit service
- Address user feedback about demonstrating the filtering behavior clearly
- Test validates integration with repository-level filtering from PR #22600

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: update test to properly demonstrate filtering behavior

- Mock repository to return only regular team (teamId: 2) as expected after filtering
- Update credit balance mock and expectations to match teamId: 2
- Test now passes and validates that unpublished platform org is filtered out
- Address user feedback about showing correct filtering behavior in test logic

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* feat: demonstrate two-team membership scenario in test logic

- Mock repository to return both membership objects: teamId 1 and teamId 2
- Show user has memberships in both unpublished platform org and regular team
- Mock different credit states: teamId 1 has no credits/limit reached, teamId 2 has available credits
- Verify both teams are processed but only regular team (teamId 2) is selected
- Test now demonstrates actual filtering behavior in test logic, not just comments
- Addresses user feedback: 'create two membership with those two teams in that test'

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* feat: demonstrate two-team membership scenario in test logic

- Mock repository to return both membership objects: teamId 1 and teamId 2
- Show user has memberships in both unpublished platform org and regular team
- Mock different credit states: teamId 1 has no credits/limit reached, teamId 2 has available credits
- Verify both teams are processed but only regular team (teamId 2) is selected
- Test now demonstrates actual filtering behavior in test logic, not just comments
- Addresses user feedback: 'create two membership with those two teams in that test'

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Revert "feat: demonstrate two-team membership scenario in test logic"

This reverts commit d7c4beb2a40179fb5ad14421563626a3bf287b45.

* Update credit-service.test.ts

* Update credit-service.test.ts

* Update credit-service.test.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-19 14:45:19 +00:00
59 changed files with 980 additions and 110 deletions
View File
@@ -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,
};
}
@@ -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() {
@@ -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} />
</>
);
}
@@ -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,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;
}
@@ -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,
},
};
+19 -6
View File
@@ -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
+2
View File
@@ -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>
+2 -2
View File
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -3
View File
@@ -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>
</>
);
}
+9 -1
View File
@@ -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,
+2
View File
@@ -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();
+47 -3
View File
@@ -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,
@@ -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();
+2
View File
@@ -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)
+1
View File
@@ -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 };
};
+79
View File
@@ -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