Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88be45457f | ||
|
|
90a42c37fd | ||
|
|
cf7430d531 | ||
|
|
105a82f97d | ||
|
|
fdd3d11111 | ||
|
|
e7ea1884b7 | ||
|
|
c04402e7ee | ||
|
|
075d209b6f | ||
|
|
26e8582302 | ||
|
|
9f277c5179 | ||
|
|
21411b8f93 | ||
|
|
0c160de404 | ||
|
|
a0eca13c18 | ||
|
|
5cda123a4c | ||
|
|
6169b92e98 | ||
|
|
cc1efe1298 | ||
|
|
7635d338c0 | ||
|
|
03ebabd6b4 | ||
|
|
0ab1bd0b6c | ||
|
|
908e5e05f2 | ||
|
|
3490272815 | ||
|
|
02dab51543 | ||
|
|
358b873aa7 | ||
|
|
44aa26292d | ||
|
|
b8530e2d78 | ||
|
|
7f1b94ad19 | ||
|
|
801ba50221 | ||
|
|
74af23136c | ||
|
|
da9ee5c186 | ||
|
|
037cb8ee2a | ||
|
|
f0e10129a1 | ||
|
|
7c5731e893 | ||
|
|
1012f2f153 | ||
|
|
d6f6be7d50 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||
- run: USE_POOL=true yarn db-seed
|
||||
- run: yarn db-seed
|
||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- name: Postgres Dump Backup
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
|
||||
@@ -5,6 +5,7 @@ permissions:
|
||||
contents: read
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
INTEGRATION_TESTS: "true"
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
repository: calcom/test-results-2
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Set Git User
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- name: Output Report URL as Workflow Annotation
|
||||
id: url
|
||||
run: |
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
|
||||
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results-2/$HTML_REPORT_URL_PATH
|
||||
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
||||
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
||||
- name: Find Comment
|
||||
|
||||
Vendored
Vendored
@@ -16,15 +16,15 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
where: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
},
|
||||
team: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
is: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
},
|
||||
OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }],
|
||||
},
|
||||
select: {
|
||||
team: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -11,6 +11,30 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("PATCH /api/bookings", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
it("Returns 403 when user has no permission to the booking", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
|
||||
@@ -16,14 +16,33 @@ const DefaultPagination = {
|
||||
skip: 0,
|
||||
};
|
||||
|
||||
describe("GET /api/bookings", () => {
|
||||
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
|
||||
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
|
||||
|
||||
describe("GET /api/bookings", async () => {
|
||||
beforeAll(async () => {
|
||||
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
||||
|
||||
it("Does not return bookings of other users when user has no permission", async () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -12,6 +12,53 @@ type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
describe("isAdmin guard", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dunderOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "dunder-mifflin",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (dunderOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: dunderOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: false,
|
||||
},
|
||||
create: {
|
||||
organizationId: dunderOrg.id,
|
||||
orgAutoAcceptEmail: "dunder-mifflin.com",
|
||||
isAdminAPIEnabled: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
it("Returns false when user does not exist in the system", async () => {
|
||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -8,6 +8,30 @@ import {
|
||||
} from "../../../lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
describe("retrieveScopedAccessibleUsers tests", () => {
|
||||
beforeAll(async () => {
|
||||
const acmeOrg = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (acmeOrg) {
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: {
|
||||
organizationId: acmeOrg.id,
|
||||
},
|
||||
update: {
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
create: {
|
||||
organizationId: acmeOrg.id,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isAdminAPIEnabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
describe("getAccessibleUsers", () => {
|
||||
it("Does not return members when only admin user ID is supplied", async () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
@@ -189,7 +189,7 @@ export class BookingsController_2024_08_13 {
|
||||
2. uid of one of the recurring booking recurrences
|
||||
|
||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
|
||||
|
||||
|
||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
@@ -213,7 +213,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Get all the recordings for the booking",
|
||||
description: `Fetches all the recordings for the booking \`:bookingUid\`
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -229,9 +229,9 @@ export class BookingsController_2024_08_13 {
|
||||
@Get("/:bookingUid/transcripts")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@ApiOperation({
|
||||
summary: "Get all the transcripts download links for the booking",
|
||||
summary: "Get Cal Video real time transcript download links for the booking",
|
||||
description: `Fetches all the transcripts download links for the booking \`:bookingUid\`
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -279,7 +279,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reschedule a booking",
|
||||
description: `Reschedule a booking or seated booking
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -321,7 +321,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
|
||||
|
||||
\nCancelling normal bookings:
|
||||
If the booking is not seated and not recurring, simply pass :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and optionally cancellationReason in the request body \`{"cancellationReason": "Will travel"}\`.
|
||||
|
||||
@@ -329,11 +329,11 @@ export class BookingsController_2024_08_13 {
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
|
||||
|
||||
|
||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
|
||||
@@ -374,7 +374,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -399,7 +399,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -423,7 +423,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description: `Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -454,7 +454,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Confirm a booking",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@@ -478,8 +478,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Decline a booking",
|
||||
description: `The provided authorization header refers to the owner of the booking.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async declineBooking(
|
||||
@@ -502,8 +502,8 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Get 'Add to Calendar' links for a booking",
|
||||
description: `Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -263,6 +263,10 @@ export class EventTypesService_2024_06_14 {
|
||||
return [dynamicEventType];
|
||||
}
|
||||
|
||||
if (authUser?.id) {
|
||||
return await this.getUserEventTypes(authUser.id);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { FieldTypes } from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
@@ -102,14 +103,25 @@ function Field({
|
||||
required
|
||||
{...hookForm.register(`${hookFieldNamespace}.label`)}
|
||||
onChange={(e) => {
|
||||
hookForm.setValue(`${hookFieldNamespace}.label`, e.target.value, { shouldDirty: true });
|
||||
const newLabel = e.target.value;
|
||||
// Use label from useWatch which is guaranteed to be the previous value
|
||||
// since useWatch updates reactively (after re-render), not synchronously
|
||||
const previousLabel = label || "";
|
||||
hookForm.setValue(`${hookFieldNamespace}.label`, newLabel, { shouldDirty: true });
|
||||
const currentIdentifier = hookForm.getValues(`${hookFieldNamespace}.identifier`);
|
||||
// Only auto-update identifier if it was auto-generated from the previous label
|
||||
// This preserves manual identifier changes
|
||||
const isIdentifierGeneratedFromPreviousLabel = currentIdentifier === getFieldIdentifier(previousLabel);
|
||||
if (!currentIdentifier || isIdentifierGeneratedFromPreviousLabel) {
|
||||
hookForm.setValue(`${hookFieldNamespace}.identifier`, getFieldIdentifier(newLabel), { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 w-full">
|
||||
<TextField
|
||||
disabled={!!router}
|
||||
label="Identifier"
|
||||
label={t("identifier_url_parameter")}
|
||||
name={`${hookFieldNamespace}.identifier`}
|
||||
required
|
||||
placeholder={t("identifies_name_field")}
|
||||
|
||||
+22
-18
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
@@ -101,8 +102,6 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
name: "privacy_and_security",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
|
||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||
{
|
||||
name: "SSO",
|
||||
href: "/settings/organizations/sso",
|
||||
@@ -173,7 +172,6 @@ const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"privacy_and_security",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
@@ -358,11 +356,12 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
const [teamMenuState, setTeamMenuState] =
|
||||
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const searchParamsId = searchParams?.get("id");
|
||||
useEffect(() => {
|
||||
if (teams) {
|
||||
const teamStates = teams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
teamMenuOpen: String(team.id) === searchParamsId,
|
||||
}));
|
||||
setTeamMenuState(teamStates);
|
||||
setTimeout(() => {
|
||||
@@ -372,7 +371,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), teams]);
|
||||
}, [searchParamsId, teams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -431,9 +430,11 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
)}
|
||||
</div>
|
||||
{!team.parentId && (
|
||||
<img
|
||||
<Image
|
||||
src={getPlaceholderAvatar(team.logoUrl, team.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
width={16}
|
||||
height={16}
|
||||
className="self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={team.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
@@ -554,12 +555,13 @@ const SettingsSidebarContainer = ({
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
const searchParamsId = searchParams?.get("id");
|
||||
// Same as above but for otherTeams
|
||||
useEffect(() => {
|
||||
if (otherTeams) {
|
||||
const otherTeamStates = otherTeams?.map((team) => ({
|
||||
teamId: team.id,
|
||||
teamMenuOpen: String(team.id) === searchParams?.get("id"),
|
||||
teamMenuOpen: String(team.id) === searchParamsId,
|
||||
}));
|
||||
setOtherTeamMenuState(otherTeamStates);
|
||||
setTimeout(() => {
|
||||
@@ -570,7 +572,7 @@ const SettingsSidebarContainer = ({
|
||||
tabMembers?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
}, [searchParamsId, otherTeams]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -599,8 +601,10 @@ const SettingsSidebarContainer = ({
|
||||
/>
|
||||
)}
|
||||
{!tab.icon && tab?.avatar && (
|
||||
<img
|
||||
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full ltr:mr-3 rtl:ml-3"
|
||||
src={tab?.avatar}
|
||||
alt="Organization Logo"
|
||||
/>
|
||||
@@ -750,9 +754,11 @@ const SettingsSidebarContainer = ({
|
||||
)}
|
||||
</div>
|
||||
{!otherTeam.parentId && (
|
||||
<img
|
||||
<Image
|
||||
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
|
||||
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
width={16}
|
||||
height={16}
|
||||
className="self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
|
||||
alt={otherTeam.name || "Team logo"}
|
||||
/>
|
||||
)}
|
||||
@@ -852,13 +858,11 @@ export default function SettingsLayoutAppDirClient({
|
||||
return () => {
|
||||
window.removeEventListener("resize", closeSideContainer);
|
||||
};
|
||||
}, []);
|
||||
}, [setSideContainerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sideContainerOpen) {
|
||||
setSideContainerOpen(!sideContainerOpen);
|
||||
}
|
||||
}, [pathname]);
|
||||
setSideContainerOpen((prev) => (prev ? false : prev));
|
||||
}, [pathname, setSideContainerOpen]);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
|
||||
@@ -23,7 +23,7 @@ const useRoutingFormNavigation = (
|
||||
) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const formContext = useFormContext<RoutingFormWithResponseCount>();
|
||||
const { isDirty } = useFormContext<RoutingFormWithResponseCount>().formState;
|
||||
|
||||
// Get the current page based on the pathname since we use a custom routing system
|
||||
const getCurrentPage = () => {
|
||||
@@ -39,7 +39,7 @@ const useRoutingFormNavigation = (
|
||||
|
||||
const baseUrl = `${appUrl}/${value}/${form.id}`;
|
||||
|
||||
if (value === "route-builder" && formContext.formState.isDirty) {
|
||||
if (value === "route-builder" && isDirty) {
|
||||
setShowInfoLostDialog(true);
|
||||
} else {
|
||||
router.push(baseUrl);
|
||||
|
||||
@@ -6,8 +6,6 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib/payment/shouldChargeNoShowCancellationFee";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
|
||||
@@ -123,7 +121,6 @@ export default function CancelBooking(props: Props) {
|
||||
eventTypeMetadata,
|
||||
} = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
|
||||
const [acknowledgeCancellationNoShowFee, setAcknowledgeCancellationNoShowFee] = useState(false);
|
||||
@@ -171,7 +168,7 @@ export default function CancelBooking(props: Props) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -264,7 +261,7 @@ export default function CancelBooking(props: Props) {
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
// telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
@@ -59,12 +59,6 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useTelemetry", () => ({
|
||||
useTelemetry: () => ({
|
||||
event: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useRefreshData", () => ({
|
||||
useRefreshData: () => vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -57,11 +57,11 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
|
||||
<div className="flex flex-row md:space-x-3">
|
||||
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
|
||||
<Icon name="user-plus" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full pt-1">
|
||||
<div className="w-full md:pt-1">
|
||||
<DialogHeader title={t("additional_guests")} />
|
||||
<div className="bg-default">
|
||||
<MultiEmail
|
||||
|
||||
@@ -21,7 +21,7 @@ import LocationSelect from "@calcom/features/form/components/LocationSelect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { Form, Input } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -202,7 +202,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
id="locationInput"
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
required
|
||||
|
||||
defaultValue={
|
||||
defaultLocation ? defaultLocation[eventLocationType.defaultValueVariable] : undefined
|
||||
}
|
||||
@@ -252,22 +251,17 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
"locationAddress",
|
||||
"locationPhoneNumber",
|
||||
]);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Let the user retry
|
||||
setIsLocationUpdating(false);
|
||||
}
|
||||
}}>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="flex flex-row md:space-x-3">
|
||||
<div className="bg-subtle mx-auto hidden h-12 w-12 flex-shrink-0 items-center justify-center rounded-full sm:mx-0 sm:h-10 sm:w-10 md:flex">
|
||||
<Icon name="map-pin" className="text-emphasis h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||
{t("edit_location")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
|
||||
<div className="w-full md:pt-1">
|
||||
<DialogHeader title={t("edit_location")} />
|
||||
|
||||
<p className="text-emphasis mb-2 ml-1 mt-6 text-sm font-bold">{t("current_location")}:</p>
|
||||
<p className="text-emphasis mb-2 ml-1 break-all text-sm">
|
||||
|
||||
@@ -37,11 +37,11 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<div className="flex flex-row md:space-x-3">
|
||||
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
|
||||
<Icon name="clock" className="m-auto h-6 w-6" />
|
||||
</div>
|
||||
<div className="w-full pt-1">
|
||||
<div className="w-full md:pt-1">
|
||||
<DialogHeader title={t("send_reschedule_request")} />
|
||||
<p className="text-subtle text-sm">{t("reschedule_modal_description")}</p>
|
||||
<p className="text-emphasis mb-2 mt-6 text-sm font-bold">
|
||||
|
||||
@@ -5,9 +5,7 @@ import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -39,7 +37,6 @@ const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
|
||||
const telemetry = useTelemetry();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
// Create a separate mutation for avatar updates
|
||||
@@ -81,7 +78,7 @@ const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const onSubmit = handleSubmit((data: { bio: string }) => {
|
||||
const { bio } = data;
|
||||
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
// telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
|
||||
mutation.mutate({
|
||||
bio,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -10,8 +9,6 @@ import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
@@ -29,7 +26,6 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep, user } = props;
|
||||
const { t } = useLocale();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
const userSettingsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -50,9 +46,9 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
resolver: zodResolver(userSettingsSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
/*useEffect(() => {
|
||||
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||
}, [telemetry]);
|
||||
}, [telemetry]);*/
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const onSuccess = async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
|
||||
@@ -42,9 +41,7 @@ export const UsernameAvailability = (props: ICustomUsernameProps) => {
|
||||
const { isPremium, disabled, ...otherProps } = props;
|
||||
const UsernameAvailabilityComponent = isPremium ? PremiumTextfield : UsernameTextfield;
|
||||
// PremiumTextfield uses `readonly` prop, UsernameTextfield uses `disabled` prop
|
||||
const componentProps = isPremium
|
||||
? { ...otherProps, readonly: disabled }
|
||||
: { ...otherProps, disabled };
|
||||
const componentProps = isPremium ? { ...otherProps, readonly: disabled } : { ...otherProps, disabled };
|
||||
return <UsernameAvailabilityComponent {...componentProps} />;
|
||||
};
|
||||
|
||||
@@ -63,7 +60,7 @@ export const UsernameAvailabilityField = ({
|
||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: slugify(currentUsername || user.username || ""),
|
||||
username: currentUsername,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,11 +82,7 @@ export const UsernameAvailabilityField = ({
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={(val) => {
|
||||
const displayValue = slugify(val, true);
|
||||
formMethods.setValue("username", displayValue);
|
||||
onChange?.(displayValue);
|
||||
}}
|
||||
setInputUsernameValue={onChange}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
disabled={disabled ?? !!user.organization?.id}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import type { Session } from "next-auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
@@ -113,27 +112,25 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
|
||||
const RemainingProviders = (
|
||||
<>
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</EventCollectionProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
@@ -279,29 +278,27 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
: props.Component.isBookingPage) || isBookingPage;
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<CustomI18nextProvider {...props}>
|
||||
<TooltipProvider>
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.pageProps.themeBasis}
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<CustomI18nextProvider {...props}>
|
||||
<TooltipProvider>
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.pageProps.themeBasis}
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<NuqsAdapter>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</EventCollectionProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</NuqsAdapter>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
);
|
||||
|
||||
if (isBookingPage) {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
|
||||
const assignmentReasonBadgeTitleMap = (assignmentReason: AssignmentReasonEnum) => {
|
||||
switch (assignmentReason) {
|
||||
case AssignmentReasonEnum.ROUTING_FORM_ROUTING || AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK:
|
||||
case AssignmentReasonEnum.ROUTING_FORM_ROUTING:
|
||||
case AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK:
|
||||
return "routed";
|
||||
case AssignmentReasonEnum.REASSIGNED:
|
||||
case AssignmentReasonEnum.RR_REASSIGNED:
|
||||
return "reassigned";
|
||||
case AssignmentReasonEnum.REROUTED:
|
||||
return "rerouted";
|
||||
|
||||
@@ -16,16 +16,6 @@ vi.mock("@vercel/edge-config", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-collect/server", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
collectEvents: vi.fn((config: any) => config.middleware),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/telemetry", () => ({
|
||||
extendEventData: vi.fn(),
|
||||
nextCollectBasicSettings: {},
|
||||
}));
|
||||
|
||||
// Mock NextResponse.json since it's not available in test environment
|
||||
vi.mock("next/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("next/server")>("next/server");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { collectEvents } from "next-collect/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
@@ -7,7 +6,6 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
|
||||
@@ -271,9 +269,4 @@ export const config = {
|
||||
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
middleware,
|
||||
...nextCollectBasicSettings,
|
||||
cookieName: "__clnds",
|
||||
extend: extendEventData,
|
||||
});
|
||||
export default middleware;
|
||||
@@ -17,8 +17,6 @@ import { emailRegex } from "@calcom/lib/emailSchema";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
@@ -82,8 +80,6 @@ export default function Login({
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
let callbackUrl = searchParams?.get("callbackUrl") || "";
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
@@ -147,7 +143,7 @@ export default function Login({
|
||||
|
||||
const onSubmit = async (values: LoginValues) => {
|
||||
setErrorMessage(null);
|
||||
telemetry.event(telemetryEventTypes.login, collectPageParameters());
|
||||
// telemetry.event(telemetryEventTypes.login, collectPageParameters());
|
||||
const res = await signIn<"credentials">("credentials", {
|
||||
...values,
|
||||
callbackUrl,
|
||||
|
||||
@@ -279,14 +279,6 @@ export default function Success(props: PageProps) {
|
||||
(!!seatReferenceUid &&
|
||||
!bookingInfo.seatsReferences.some((reference) => reference.referenceUid === seatReferenceUid));
|
||||
|
||||
// const telemetry = useTelemetry();
|
||||
/* useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/booking"));
|
||||
}
|
||||
}, [telemetry]); */
|
||||
|
||||
useEffect(() => {
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || CURRENT_TIMEZONE));
|
||||
setIs24h(props?.userTimeFormat ? props.userTimeFormat === 24 : !!getIs24hClockFromLocalStorage());
|
||||
@@ -1207,7 +1199,7 @@ function RecurringBookings({
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(0, 4).map((dateStr: string, idx: number) => (
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
{formatToLocalizedDate(dayjs.utc(dateStr), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr),
|
||||
@@ -1225,7 +1217,7 @@ function RecurringBookings({
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1240,7 +1232,7 @@ function RecurringBookings({
|
||||
{eventType.recurringEvent?.count &&
|
||||
recurringBookingsSorted.slice(4).map((dateStr: string, idx: number) => (
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
{formatToLocalizedDate(dayjs.utc(dateStr), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr),
|
||||
@@ -1256,7 +1248,7 @@ function RecurringBookings({
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from "@calcom/ui/components/form";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
|
||||
import { TestForm } from "../../components/apps/routing-forms/TestForm";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
export default function InsightsVirtualQueuesPage() {
|
||||
const { t } = useLocale();
|
||||
@@ -23,6 +24,16 @@ export default function InsightsVirtualQueuesPage() {
|
||||
setSelectedForm(routingForms[0]);
|
||||
}
|
||||
|
||||
if (routingForms && routingForms.length === 0) {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="split"
|
||||
headline={t("no_routing_forms")}
|
||||
description={t("empty_routing_forms_description")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>{t("routing_form")}</Label>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { setShowWelcomeToCalcomModalFlag } from "@calcom/features/shell/hooks/useWelcomeToCalcomModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { sessionStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
@@ -32,7 +30,6 @@ const DEFAULT_EVENT_TYPES = [
|
||||
export const useSubmitPersonalOnboarding = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const telemetry = useTelemetry();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
@@ -80,7 +77,7 @@ export const useSubmitPersonalOnboarding = () => {
|
||||
});
|
||||
|
||||
const submitPersonalOnboarding = () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
// telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
|
||||
@@ -22,16 +20,13 @@ const CreateNewTeamPage = () => {
|
||||
const params = useParamsWithFallback();
|
||||
const parsedQuery = querySchema.safeParse(params);
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
|
||||
const flag = isTeamBillingEnabledClient
|
||||
? {
|
||||
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
|
||||
submitLabel: "checkout",
|
||||
}
|
||||
: {
|
||||
telemetryEvent: telemetryEventTypes.team_created,
|
||||
submitLabel: "continue",
|
||||
};
|
||||
|
||||
@@ -39,7 +34,7 @@ const CreateNewTeamPage = () => {
|
||||
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
|
||||
|
||||
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
|
||||
telemetry.event(flag.telemetryEvent);
|
||||
// telemetry.event(flag.telemetryEvent);
|
||||
router.push(data.url);
|
||||
};
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ import { pushGTMEvent } from "@calcom/lib/gtm";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { IS_EUROPE } from "@calcom/lib/timezoneConstants";
|
||||
import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
@@ -193,7 +191,6 @@ export default function Signup({
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [displayEmailForm, setDisplayEmailForm] = useState(token);
|
||||
const searchParams = useCompatSearchParams();
|
||||
const telemetry = useTelemetry();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const formMethods = useForm<FormValues>({
|
||||
@@ -261,7 +258,7 @@ export default function Signup({
|
||||
if (process.env.NEXT_PUBLIC_GTM_ID)
|
||||
pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language });
|
||||
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
// telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
|
||||
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
|
||||
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
|
||||
|
||||
@@ -8,17 +8,13 @@
|
||||
// 2. org/[orgSlug]/[user]/[type]
|
||||
import classNames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { UserAvatarGroup } from "@calcom/ui/components/avatar";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -35,24 +31,15 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
function TeamPage({ team, considerUnpublished, isValidOrgDomain }: PageProps) {
|
||||
useTheme(team.theme);
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
const showMembers = useToggleQuery("members");
|
||||
const { t } = useLocale();
|
||||
const isEmbed = useIsEmbed();
|
||||
const telemetry = useTelemetry();
|
||||
const teamName = team.name || t("nameless_team");
|
||||
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
|
||||
const metadata = teamMetadataSchema.parse(team.metadata);
|
||||
|
||||
const teamOrOrgIsPrivate = team.isPrivate || (team?.parent?.isOrganization && team.parent?.isPrivate);
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(
|
||||
telemetryEventTypes.pageView,
|
||||
collectPageParameters("/team/[slug]", { isTeamBooking: true })
|
||||
);
|
||||
}, [telemetry, pathname]);
|
||||
|
||||
if (considerUnpublished) {
|
||||
const teamSlug = team.slug || metadata?.requestedSlug;
|
||||
const parentSlug = team.parent?.slug || team.parent?.requestedSlug;
|
||||
@@ -174,7 +161,7 @@ function TeamPage({ team, considerUnpublished, isValidOrgDomain }: PageProps) {
|
||||
<>
|
||||
<div
|
||||
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: team.safeBio }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -42,14 +42,6 @@ export function UserPage(props: PageProps) {
|
||||
...query
|
||||
} = useRouterQuery();
|
||||
|
||||
/*
|
||||
const telemetry = useTelemetry();
|
||||
useEffect(() => {
|
||||
if (top !== window) {
|
||||
//page_view will be collected automatically by _middleware.ts
|
||||
telemetry.event(telemetryEventTypes.embedView, collectPageParameters("/[user]"));
|
||||
}
|
||||
}, [telemetry, router.asPath]); */
|
||||
if (entity.considerUnpublished) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[calc(100dvh)] items-center justify-center">
|
||||
@@ -99,7 +91,7 @@ export function UserPage(props: PageProps) {
|
||||
<>
|
||||
<div
|
||||
className="text-default break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: props.safeBio }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.9.0",
|
||||
"version": "5.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -112,7 +112,6 @@
|
||||
"next": "15.5.4",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-seo": "^6.0.0",
|
||||
"next-themes": "^0.2.0",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { collectApiHandler } from "next-collect/server";
|
||||
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
export default collectApiHandler({
|
||||
...nextCollectBasicSettings,
|
||||
cookieName: "__clnds",
|
||||
extend: extendEventData,
|
||||
});
|
||||
@@ -118,6 +118,9 @@ test("multiple duration selection updates event length correctly", async ({ page
|
||||
|
||||
await page.goto(`/${user.username}/multiple-duration`);
|
||||
|
||||
// Wait for time slots to be visible and clickable
|
||||
await page.locator('[data-testid="time"]').nth(0).waitFor({ state: "visible" });
|
||||
|
||||
await page.locator('[data-testid="multiple-choice-30mins"]').waitFor({ state: "visible" });
|
||||
|
||||
await test.step("verify default 30min duration is selected", async () => {
|
||||
@@ -193,9 +196,9 @@ test.describe("Organization:", () => {
|
||||
});
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
// All the teammates should be in the booking
|
||||
|
||||
|
||||
await expect(page.getByText(user1.name!, { exact: true })).toBeVisible();
|
||||
|
||||
|
||||
await expect(page.getByText(user2.name!, { exact: true })).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -683,7 +683,7 @@
|
||||
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
|
||||
"in_person_meeting": "Vor-Ort-Termin",
|
||||
"in_person": "Vor Ort (Organisator-Adresse)",
|
||||
"link_meeting": "Termin verknüpfen",
|
||||
"link_meeting": "Online Termin",
|
||||
"phone_number": "Telefonnummer",
|
||||
"attendee_phone_number": "Telefonnummer",
|
||||
"organizer_phone_number": "Telefonnummer des Organisators",
|
||||
|
||||
@@ -1076,6 +1076,8 @@
|
||||
"warning_instant_meeting_experimental": "Experimental: Instant Meeting Events are currently experimental.",
|
||||
"starting": "Starting",
|
||||
"disable_guests": "Disable Guests",
|
||||
"disable_autofill_on_booking_page": "Disable autofill on booking pages",
|
||||
"disable_autofill_on_booking_page_description": "Prevent booking forms from being automatically filled with URL parameters or session-based prefill for all users in this organization",
|
||||
"disable_guests_description": "Disable adding additional guests while booking.",
|
||||
"private_link": "Generate private link",
|
||||
"enable_private_url": "Enable Private URL",
|
||||
@@ -2286,6 +2288,7 @@
|
||||
"responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.",
|
||||
"this_is_what_your_users_would_see": "This is what your users would see",
|
||||
"identifies_name_field": "Identifies field by this name.",
|
||||
"identifier_url_parameter": "Identifier (URL Parameter, i.e.: &what-is-your-name=john-doe)",
|
||||
"add_1_option_per_line": "Add 1 option per line",
|
||||
"select_a_router": "Select a router",
|
||||
"add_a_new_route": "Add a new Route",
|
||||
@@ -3274,6 +3277,8 @@
|
||||
"calibration": "Calibration",
|
||||
"shortfall": "Shortfall",
|
||||
"routing_form": "Routing Form",
|
||||
"no_routing_forms": "No Routing Forms created yet.",
|
||||
"empty_routing_forms_description": "Create routing forms to select projects to view their routing positions",
|
||||
"go_back_and_save": "Go back and save",
|
||||
"save_changes": "Save changes",
|
||||
"leave_without_saving": "Leave without saving",
|
||||
|
||||
@@ -9836,7 +9836,7 @@
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getBooking",
|
||||
"summary": "Get a booking",
|
||||
"description": "`:bookingUid` can be\n\n 1. uid of a normal booking\n\n 2. uid of one of the recurring booking recurrences\n\n 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).\n \n If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "`:bookingUid` can be\n\n 1. uid of a normal booking\n\n 2. uid of one of the recurring booking recurrences\n\n 3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).\n\n If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9903,7 +9903,7 @@
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getBookingRecordings",
|
||||
"summary": "Get all the recordings for the booking",
|
||||
"description": "Fetches all the recordings for the booking `:bookingUid`\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "Fetches all the recordings for the booking `:bookingUid`\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9942,8 +9942,8 @@
|
||||
"/v2/bookings/{bookingUid}/transcripts": {
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getBookingTranscripts",
|
||||
"summary": "Get all the transcripts download links for the booking",
|
||||
"description": "Fetches all the transcripts download links for the booking `:bookingUid`\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"summary": "Get Cal Video real time transcript download links for the booking",
|
||||
"description": "Fetches all the transcripts download links for the booking `:bookingUid`\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9983,7 +9983,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_rescheduleBooking",
|
||||
"summary": "Reschedule a booking",
|
||||
"description": "Reschedule a booking or seated booking\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "Reschedule a booking or seated booking\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10068,7 +10068,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling normal bookings:\n If the booking is not seated and not recurring, simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n \n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n\n \nCancelling normal bookings:\n If the booking is not seated and not recurring, simply pass :bookingUid in the request URL `/bookings/:bookingUid/cancel` and optionally cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}`.\n\n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host or org admin of host - host can cancel booking for all attendees aka for every seat, this also applies to org admins. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n\n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.\n\n If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or\n you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10153,7 +10153,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_markNoShow",
|
||||
"summary": "Mark a booking absence",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10212,7 +10212,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_reassignBooking",
|
||||
"summary": "Reassign a booking to auto-selected host",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10261,7 +10261,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
|
||||
"summary": "Reassign a booking to a specific host",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10328,7 +10328,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_confirmBooking",
|
||||
"summary": "Confirm a booking",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10377,7 +10377,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_declineBooking",
|
||||
"summary": "Decline a booking",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note> \n ",
|
||||
"description": "The provided authorization header refers to the owner of the booking.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10436,7 +10436,7 @@
|
||||
"get": {
|
||||
"operationId": "BookingsController_2024_08_13_getCalendarLinks",
|
||||
"summary": "Get 'Add to Calendar' links for a booking",
|
||||
"description": "Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.\n \n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note> \n ",
|
||||
"description": "Retrieve calendar links for a booking that can be used to add the event to various calendar services. Returns links for Google Calendar, Microsoft Office, Microsoft Outlook, and a downloadable ICS file.\n\n <Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -22678,7 +22678,8 @@
|
||||
"OOO_CREATED",
|
||||
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
|
||||
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
|
||||
"FORM_SUBMITTED_NO_EVENT"
|
||||
"FORM_SUBMITTED_NO_EVENT",
|
||||
"DELEGATION_CREDENTIAL_ERROR"
|
||||
]
|
||||
},
|
||||
"secret": {
|
||||
@@ -22745,7 +22746,8 @@
|
||||
"OOO_CREATED",
|
||||
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
|
||||
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
|
||||
"FORM_SUBMITTED_NO_EVENT"
|
||||
"FORM_SUBMITTED_NO_EVENT",
|
||||
"DELEGATION_CREDENTIAL_ERROR"
|
||||
]
|
||||
},
|
||||
"secret": {
|
||||
@@ -27394,6 +27396,10 @@
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"timeFormat": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -27419,6 +27425,7 @@
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"name",
|
||||
"timeFormat",
|
||||
"defaultScheduleId",
|
||||
"weekStart",
|
||||
|
||||
@@ -22,17 +22,40 @@ You can listen to an action that occurs in embedded cal link as follows. You can
|
||||
|
||||
## Following are the list of supported actions.
|
||||
|
||||
<Info>Event payloads may evolve over time. For the most reliable integration, you can listen to `action: "*"` to receive all events and inspect `e.detail.type` and `e.detail.data` at runtime.</Info>
|
||||
|
||||
### Public Events
|
||||
|
||||
These events are intended for external use and provide information about user interactions and booking lifecycle.
|
||||
|
||||
| Action | Description | Properties |
|
||||
|----------------------------|-------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| eventTypeSelected | When user chooses an event-type from the listing. | `eventType: object` // Event Type that has been selected |
|
||||
| bookingSuccessfulV2 | When a booking is successfully created. It might not be confirmed. | `uid: string` // Booking unique identifier <br /> `title: string` // Booking title <br /> `startTime: string` // Booking start time <br /> `endTime: string` // Booking end time <br /> `eventTypeId: number` // Event type ID <br /> `status: string` // Booking status <br /> `paymentRequired: boolean` // Whether payment is required <br /> `isRecurring: boolean` // Whether this is a recurring booking <br /> `allBookings: array` // For recurring bookings, array of all booking times <br /> `videoCallUrl: string` // Video call URL if available |
|
||||
| rescheduleBookingSuccessfulV2 | When a booking is successfully rescheduled. | Same properties as `bookingSuccessfulV2` |
|
||||
| dryRunBookingSuccessfulV2 | When a dry run booking is successfully created (test mode). | Same properties as `bookingSuccessfulV2` (without `uid`) |
|
||||
| dryRunRescheduleBookingSuccessfulV2 | When a dry run reschedule is successful (test mode). | Same properties as `bookingSuccessfulV2` (without `uid`) |
|
||||
| bookingCancelled | When a booking is cancelled. | `booking: object` // Booking details including cancellationReason <br /> `organizer: object` // Organizer details (name, email, timeZone) <br /> `eventType: object` // Event type details |
|
||||
| availabilityLoaded | When availability slots are successfully loaded. | `eventId: number` // Event type ID <br /> `eventSlug: string` // Event type slug |
|
||||
| routed | When a routing form routes to an action (event type, external URL, or custom page). | `actionType: string` // Type of action: "customPageMessage", "externalRedirectUrl", or "eventTypeRedirectUrl" <br /> `actionValue: string` // The value/URL of the action |
|
||||
| navigatedToBooker | When user navigates to the booker interface. | None |
|
||||
| linkReady | Tells that the link is ready to be shown now. | None |
|
||||
| linkFailed | Fired if link fails to load. | `code: string` // Error Code <br /> `msg: string` // Human Readable message <br /> `data: object` // More details to debug the error (includes url) |
|
||||
|
||||
### Internal Events
|
||||
|
||||
These events are used internally by the embed system for communication between the iframe and parent window. They are prefixed with `__` and are not intended for external use.
|
||||
| Action | Description | Properties |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| eventTypeSelected | When user chooses an event-type from the listing. | `eventType: object` // Event Type that has been selected |
|
||||
| bookingSuccessful | When the booking is successfully done. It might not be confirmed. | `confirmed: boolean` // Whether confirmation from organizer is pending or not <br /> `eventType: object` // Event Type that has been booked <br /> `date: string` // Date of Event <br /> `duration: number` // Duration of booked Event <br /> `organizer: object` // Organizer details like name, timezone, email |
|
||||
| linkReady | Tells that the link is ready to be shown now. | None |
|
||||
| linkFailed | Fired if link fails to load. | `code: number` // Error Code <br /> `msg: string` // Human Readable message <br /> `data: object` // More details to debug the error |
|
||||
| __iframeReady | It is fired when the embedded iframe is ready to communicate with parent snippet. This is mostly for internal use by Embed Snippet. | None |
|
||||
| __iframeReady | Fired when the embedded iframe is ready to communicate with parent snippet. | `isPrerendering: boolean` // Whether the iframe is in prerender mode |
|
||||
| __windowLoadComplete | Tells that window load for iframe is complete. | None |
|
||||
| __dimensionChanged | Tells that dimensions of the content inside the iframe changed. | `iframeWidth: number` <br /> `iframeHeight: number` |
|
||||
| __dimensionChanged | Tells that dimensions of the content inside the iframe changed. | `iframeWidth: number` <br /> `iframeHeight: number` <br /> `isFirstTime: boolean` // Whether this is the first dimension change |
|
||||
| __routeChanged | Fired when the route changes within the iframe. | None |
|
||||
| __closeIframe | Fired when the iframe should be closed. | None |
|
||||
| __connectInitiated | Fired when connection to a prerendered iframe is initiated. | None |
|
||||
| __connectCompleted | Fired when connection to a prerendered iframe is completed. | None |
|
||||
| __scrollByDistance | Instructs the parent to scroll by a specific distance. | `distance: number` // Distance in pixels to scroll by |
|
||||
|
||||
<Info>Events that start with `__` are internal and should not be relied upon for external integrations as they may change without notice.</Info>
|
||||
|
||||
<Info>Actions that start with __ are internal.</Info>
|
||||
|
||||
To get more details on how Embed actually works, you can refer to this [Embed Flowchart](https://www.figma.com/file/zZ5oaUpg12Fuu5mGZrPlP5/Embed-Flowchart).
|
||||
To get more details on how Embed actually works, you can refer to this [Embed Flowchart](https://www.figma.com/file/zZ5oaUpg12Fuu5mGZrPlP5/Embed-Flowchart).
|
||||
|
||||
@@ -131,6 +131,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
|
||||
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
|
||||
| defaultPhoneCountry | No | Sets the default country code for phone number inputs in the booking form. Accepts ISO 3166-1 alpha-2 country codes (e.g., `"us"`, `"gb"`, `"in"`, `"ee"`). When set, phone inputs will default to the specified country's dialing code. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getCalendar = async (
|
||||
featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
|
||||
),
|
||||
featuresRepository.checkIfUserHasFeature(
|
||||
featuresRepository.checkIfUserHasFeatureNonHierarchical(
|
||||
credential.userId as number,
|
||||
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE
|
||||
),
|
||||
|
||||
@@ -443,9 +443,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
},
|
||||
getAllTranscriptsAccessLinkFromRoomName: async (roomName: string): Promise<Array<string>> => {
|
||||
try {
|
||||
const res = await fetcher(`/rooms/${roomName}`).then(getRooms.parse);
|
||||
const roomId = res.id;
|
||||
const allTranscripts = await fetcher(`/transcript?roomId=${roomId}`).then(getTranscripts.parse);
|
||||
const allTranscripts = await fetcher(`/transcript?room_name=${roomName}`).then(getTranscripts.parse);
|
||||
|
||||
if (!allTranscripts.data.length) return [];
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ export class CalendarAuth {
|
||||
delegationError = new CalendarAppDelegationCredentialError("Error authorizing delegation credential");
|
||||
}
|
||||
|
||||
if (user && user.email && this.credential.appId) {
|
||||
if (user && user.email && this.credential.appId && this.credential.delegatedToId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error: delegationError,
|
||||
credential: {
|
||||
@@ -152,7 +152,7 @@ export class CalendarAuth {
|
||||
id: this.credential.userId ?? 0,
|
||||
email: user.email,
|
||||
},
|
||||
orgId: this.credential.teamId,
|
||||
delegationCredentialId: this.credential.delegatedToId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const featuresRepositoryModuleMock = {
|
||||
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -123,6 +123,10 @@ export default class HubspotCalendarService implements CRM {
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.update(uid, simplePublicObjectInput);
|
||||
};
|
||||
|
||||
private hubspotArchiveMeeting = async (uid: string) => {
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
||||
};
|
||||
|
||||
private hubspotAuth = async (credential: CredentialPayload) => {
|
||||
const appKeys = await getAppKeysFromSlug("hubspot");
|
||||
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
|
||||
@@ -212,9 +216,14 @@ export default class HubspotCalendarService implements CRM {
|
||||
return await this.hubspotUpdateMeeting(uid, event);
|
||||
}
|
||||
|
||||
async deleteEvent(uid: string): Promise<void> {
|
||||
async deleteEvent(uid: string, event: CalendarEvent): Promise<void> {
|
||||
const auth = await this.auth;
|
||||
await auth.getToken();
|
||||
|
||||
if (event?.hasOrganizerChanged) {
|
||||
await this.hubspotArchiveMeeting(uid);
|
||||
return;
|
||||
}
|
||||
await this.hubspotCancelMeeting(uid);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,29 @@ export default class Office365CalendarService implements Calendar {
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
}
|
||||
|
||||
private async triggerDelegationCredentialError(error: Error): Promise<void> {
|
||||
if (
|
||||
this.credential.userId &&
|
||||
this.credential.user &&
|
||||
this.credential.appId &&
|
||||
this.credential.delegatedToId
|
||||
) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: this.credential.id,
|
||||
type: this.credential.type,
|
||||
appId: this.credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: this.credential.userId,
|
||||
email: this.credential.user.email,
|
||||
},
|
||||
delegationCredentialId: this.credential.delegatedToId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getAuthUrl(delegatedTo: boolean, tenantId?: string): Promise<string> {
|
||||
if (delegatedTo) {
|
||||
if (!tenantId) {
|
||||
@@ -131,21 +154,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
"Invalid DelegationCredential Settings: tenantId is missing"
|
||||
);
|
||||
|
||||
if (this.credential.userId && this.credential.user && this.credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: this.credential.id,
|
||||
type: this.credential.type,
|
||||
appId: this.credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: this.credential.userId,
|
||||
email: this.credential.user.email,
|
||||
},
|
||||
orgId: this.credential.teamId,
|
||||
});
|
||||
}
|
||||
await this.triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -165,21 +174,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
"Delegation credential without clientId or Secret"
|
||||
);
|
||||
|
||||
if (this.credential.userId && this.credential.user && this.credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: this.credential.id,
|
||||
type: this.credential.type,
|
||||
appId: this.credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: this.credential.userId,
|
||||
email: this.credential.user.email,
|
||||
},
|
||||
orgId: this.credential.teamId,
|
||||
});
|
||||
}
|
||||
await this.triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -207,21 +202,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
"Delegation credential without clientId or Secret"
|
||||
);
|
||||
|
||||
if (this.credential.userId && this.credential.user && this.credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: this.credential.id,
|
||||
type: this.credential.type,
|
||||
appId: this.credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: this.credential.userId,
|
||||
email: this.credential.user.email,
|
||||
},
|
||||
orgId: this.credential.teamId,
|
||||
});
|
||||
}
|
||||
await this.triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -260,21 +241,7 @@ export default class Office365CalendarService implements Calendar {
|
||||
"User might not exist in Microsoft Azure Active Directory"
|
||||
);
|
||||
|
||||
if (this.credential.userId && this.credential.user && this.credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: this.credential.id,
|
||||
type: this.credential.type,
|
||||
appId: this.credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: this.credential.userId ?? 0,
|
||||
email: this.credential.user.email,
|
||||
},
|
||||
orgId: this.credential.teamId,
|
||||
});
|
||||
}
|
||||
await this.triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,24 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant
|
||||
let azureUserId: string | null;
|
||||
const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential);
|
||||
|
||||
async function triggerDelegationCredentialError(error: Error): Promise<void> {
|
||||
if (credential.userId && credential.user && credential.appId && credential.delegatedToId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: credential.userId ?? 0,
|
||||
email: credential.user.email,
|
||||
},
|
||||
delegationCredentialId: credential.delegatedToId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
|
||||
resourceOwner: {
|
||||
@@ -68,21 +86,7 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant
|
||||
"Delegation credential without clientId or Secret"
|
||||
);
|
||||
|
||||
if (credential.userId && credential.user && credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: credential.userId ?? 0,
|
||||
email: credential.user.email,
|
||||
},
|
||||
orgId: credential.teamId,
|
||||
});
|
||||
}
|
||||
await triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -130,21 +134,7 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant
|
||||
"Invalid DelegationCredential Settings: tenantId is missing"
|
||||
);
|
||||
|
||||
if (credential.userId && credential.user && credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: credential.userId ?? 0,
|
||||
email: credential.user.email,
|
||||
},
|
||||
orgId: credential.teamId,
|
||||
});
|
||||
}
|
||||
await triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -179,21 +169,7 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant
|
||||
"Delegation credential without clientId or Secret"
|
||||
);
|
||||
|
||||
if (credential.userId && credential.user && credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: credential.userId ?? 0,
|
||||
email: credential.user.email,
|
||||
},
|
||||
orgId: credential.teamId,
|
||||
});
|
||||
}
|
||||
await triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -231,21 +207,7 @@ const TeamsVideoApiAdapter = (credential: CredentialForCalendarServiceWithTenant
|
||||
"User might not exist in Microsoft Azure Active Directory"
|
||||
);
|
||||
|
||||
if (credential.userId && credential.user && credential.appId) {
|
||||
await triggerDelegationCredentialErrorWebhook({
|
||||
error,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId,
|
||||
},
|
||||
user: {
|
||||
id: credential.userId ?? 0,
|
||||
email: credential.user.email,
|
||||
},
|
||||
orgId: credential.teamId,
|
||||
});
|
||||
}
|
||||
await triggerDelegationCredentialError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
nonOrgTeamslug: null,
|
||||
userOrigin: "https://user.cal.com",
|
||||
teamOrigin: "https://team.cal.com",
|
||||
user: {
|
||||
username: null,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultParams = {
|
||||
@@ -23,14 +26,26 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
it("should return WEBAPP_URL for non-migrated user", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "user/event",
|
||||
form: { ...defaultForm, nonOrgUsername: "user" },
|
||||
eventTypeRedirectUrl: "old-user/event",
|
||||
form: {
|
||||
...defaultForm,
|
||||
nonOrgUsername: "old-user",
|
||||
user: { username: "new-user" },
|
||||
},
|
||||
});
|
||||
expect(result).toBe(`${WEBAPP_URL}/user/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/old-user/event?`);
|
||||
});
|
||||
|
||||
it("should return user origin for migrated user", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl(defaultParams);
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "user/event",
|
||||
form: {
|
||||
...defaultForm,
|
||||
nonOrgUsername: "user",
|
||||
user: { username: "user" },
|
||||
},
|
||||
});
|
||||
expect(result).toBe("https://user.cal.com/user/event?");
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,15 @@ export function getAbsoluteEventTypeRedirectUrl({
|
||||
* The origin for the team the form belongs to
|
||||
*/
|
||||
teamOrigin: string;
|
||||
/**
|
||||
* The profile user who owns the form
|
||||
*/
|
||||
user: {
|
||||
/**
|
||||
* Current username on the profile
|
||||
*/
|
||||
username: string | null;
|
||||
};
|
||||
};
|
||||
allURLSearchParams: URLSearchParams;
|
||||
isEmbed?: boolean;
|
||||
@@ -82,7 +91,9 @@ export function getAbsoluteEventTypeRedirectUrl({
|
||||
}
|
||||
|
||||
if (usernameInRedirectUrl && form.nonOrgUsername) {
|
||||
const isEventTypeRedirectToOldUser = usernameInRedirectUrl === form.nonOrgUsername;
|
||||
const hasSameProfileUsername = form.user?.username === form.nonOrgUsername;
|
||||
const isEventTypeRedirectToOldUser =
|
||||
!hasSameProfileUsername && usernameInRedirectUrl === form.nonOrgUsername;
|
||||
if (isEventTypeRedirectToOldUser) {
|
||||
return `${WEBAPP_URL}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
|
||||
}
|
||||
|
||||
@@ -271,21 +271,21 @@ test.describe("Routing Forms", () => {
|
||||
label: "Test Field",
|
||||
});
|
||||
const queryString =
|
||||
"firstField=456&Test Field Number=456&Test Field Single Selection=456&Test Field Multiple Selection=456&Test Field Multiple Selection=789&Test Field Phone=456&Test Field Email=456@example.com";
|
||||
"firstField=456&Test-Field-Number=456&Test-Field-Single-Selection=456&Test-Field-Multiple-Selection=456&Test-Field-Multiple-Selection=789&Test-Field-Phone=456&Test-Field-Email=456@example.com";
|
||||
|
||||
await gotoRoutingLink({ page, queryString });
|
||||
|
||||
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
|
||||
await page.fill('[data-testid="form-field-Test-Field-Long-Text"]', "manual-fill");
|
||||
|
||||
await expect(page.locator('[data-testid="form-field-firstField"]')).toHaveValue("456");
|
||||
await expect(page.locator('[data-testid="form-field-Test Field Number"]')).toHaveValue("456");
|
||||
await expect(page.locator('[data-testid="form-field-Test-Field-Number"]')).toHaveValue("456");
|
||||
|
||||
// TODO: Verify select and multiselect has prefilled values.
|
||||
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
|
||||
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
|
||||
// expect(await page.locator(`[data-testid="form-field-Test-Field-Select"]`).inputValue()).toBe("456");
|
||||
// expect(await page.locator(`[data-testid="form-field-Test-Field-MultiSelect"]`).inputValue()).toBe("456");
|
||||
|
||||
await expect(page.locator('[data-testid="form-field-Test Field Phone"]')).toHaveValue("456");
|
||||
await expect(page.locator('[data-testid="form-field-Test Field Email"]')).toHaveValue(
|
||||
await expect(page.locator('[data-testid="form-field-Test-Field-Phone"]')).toHaveValue("456");
|
||||
await expect(page.locator('[data-testid="form-field-Test-Field-Email"]')).toHaveValue(
|
||||
"456@example.com"
|
||||
);
|
||||
|
||||
@@ -300,12 +300,12 @@ test.describe("Routing Forms", () => {
|
||||
expect(url.searchParams.get("firstField")).toBe("456");
|
||||
|
||||
// All other params come from prefill URL
|
||||
expect(url.searchParams.get("Test Field Number")).toBe("456");
|
||||
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
|
||||
expect(url.searchParams.get("Test Field Multiple Selection")).toBe("456");
|
||||
expect(url.searchParams.getAll("Test Field Multiple Selection")).toMatchObject(["456", "789"]);
|
||||
expect(url.searchParams.get("Test Field Phone")).toBe("456");
|
||||
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
|
||||
expect(url.searchParams.get("Test-Field-Number")).toBe("456");
|
||||
expect(url.searchParams.get("Test-Field-Long-Text")).toBe("manual-fill");
|
||||
expect(url.searchParams.get("Test-Field-Multiple-Selection")).toBe("456");
|
||||
expect(url.searchParams.getAll("Test-Field-Multiple-Selection")).toMatchObject(["456", "789"]);
|
||||
expect(url.searchParams.get("Test-Field-Phone")).toBe("456");
|
||||
expect(url.searchParams.get("Test-Field-Email")).toBe("456@example.com");
|
||||
});
|
||||
|
||||
// TODO: How to install the app just once?
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
# Booking Audit System - Database Architecture
|
||||
Based on https://github.com/calcom/cal.com/pull/22817
|
||||
|
||||
Note: This architecture is not in production yet, so we can make any changes we want to it without worrying about backwards compatibility.
|
||||
|
||||
## Overview
|
||||
|
||||
The Booking Audit System tracks all actions and changes related to bookings in Cal.com. The architecture is built around two core tables (`AuditActor` and `BookingAudit`) that work together to maintain a complete, immutable audit trail.
|
||||
|
||||
## Database Tables
|
||||
|
||||
### 1. AuditActor Table
|
||||
|
||||
The `AuditActor` table stores information about entities that perform actions on bookings. It maintains historical records even after users are deleted.
|
||||
|
||||
```prisma
|
||||
model AuditActor {
|
||||
id String @id @default(uuid())
|
||||
type AuditActorType
|
||||
|
||||
// References for different actor types (soft references, no FK constraints)
|
||||
userUuid String? @db.Uuid // For USER type (nullable to allow user deletion)
|
||||
attendeeId Int? // For ATTENDEE type(nullable to allow attendee deletion)
|
||||
|
||||
// Identity fields - only for GUEST/SYSTEM(System too might not have all) type. Attendee and User maintain their own identity fields.
|
||||
// They could be set as anonymized for User/Attendee record as well when they are deleted to preserve the audit trail.
|
||||
email String?
|
||||
phone String?
|
||||
name String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
bookingAudits BookingAudit[]
|
||||
|
||||
@@unique([userUuid])
|
||||
@@unique([attendeeId])
|
||||
@@unique([email]) // Prevent duplicate email actors
|
||||
@@unique([phone]) // Prevent duplicate phone actors
|
||||
@@index([email])
|
||||
@@index([userUuid])
|
||||
@@index([attendeeId])
|
||||
@@index([pseudonymizedAt]) // For compliance cleanup jobs
|
||||
}
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
- **UUID Primary Key**: Uses UUIDs for distributed system compatibility
|
||||
- **Soft References**: Both `userUuid` and `attendeeId` are soft references without foreign key constraints, allowing the audit trail to persist independently even after users or attendees are deleted
|
||||
- **User UUID Reference**: Uses `User.uuid` instead of `User.id` for stable identification across distributed systems
|
||||
- **Unique Constraints**: Prevents duplicate audit actors for the same user/email/phone
|
||||
- **Multiple Identity Fields**: Supports different actor types (users, guests, attendees, system)
|
||||
- **Extensible System Actors**: Architecture supports multiple system actors (e.g., Cron, Webhooks, API integrations, Background Workers) for granular tracking of automated operations
|
||||
- **Identity Fields for Audit Retention**: Identity fields (email, phone, name) are primarily used for GUEST/SYSTEM actors. For USER and ATTENDEE types, these fields can be anonymized when the source record is deleted to preserve the audit trail while removing PII
|
||||
|
||||
---
|
||||
|
||||
### 2. BookingAudit Table
|
||||
|
||||
The `BookingAudit` table stores audit records for all booking-related actions.
|
||||
|
||||
```prisma
|
||||
model BookingAudit {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
// bookingUid is stored as a plain string (not a foreign key relation) to preserve the audit trail
|
||||
// even after the booking is deleted. This is intentional for audit log integrity:
|
||||
// - Audit logs are immutable historical records that should persist independently
|
||||
// - When a booking is deleted, we still need to know which booking the audit log belonged to
|
||||
// - Using a plain string instead of a relation prevents bookingUid from becoming NULL on booking deletion
|
||||
// - This allows users to view complete audit history for deleted bookings
|
||||
bookingUid String
|
||||
|
||||
// Actor who performed the action (USER, GUEST, or SYSTEM)
|
||||
// Stored in AuditActor table to maintain audit trail even after user deletion
|
||||
actorId String
|
||||
// Restrict onDelete to prevent deletion of audit actor if there are any booking audits associated with it
|
||||
actor AuditActor @relation(fields: [actorId], references: [id], onDelete: Restrict)
|
||||
|
||||
type BookingAuditType
|
||||
action BookingAuditAction
|
||||
|
||||
// Timestamp of the actual booking change (business event time)
|
||||
// Important: May differ from createdAt if audit is processed asynchronously
|
||||
timestamp DateTime
|
||||
|
||||
// Database record timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
data Json?
|
||||
|
||||
@@index([actorId])
|
||||
@@index([bookingUid])
|
||||
}
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
- **UUID v7 Primary Key**: Uses `uuid(7)` for time-sortable IDs, providing chronological ordering and improved database performance
|
||||
- **No Foreign Key to Booking**: `bookingUid` is stored as a plain string without a foreign key constraint to preserve audit trail integrity even after bookings are deleted
|
||||
- **Restrict on Delete**: `onDelete: Restrict` prevents actor deletion if audit records exist
|
||||
- **Required Action**: Every audit record must specify a business action, ensuring explicit tracking of what happened
|
||||
- **Explicit Timestamp**: The `timestamp` field has no default and must be explicitly provided, representing when the business event actually occurred
|
||||
- **Separate Database Timestamps**: `createdAt` and `updatedAt` track when the audit record itself was created/modified, distinct from the business event time
|
||||
- **JSON Data Field**: Flexible schema for storing action-specific contextual data
|
||||
- **Indexed Fields**: Efficient queries by `bookingUid` and `actorId`
|
||||
|
||||
|
||||
**Protecting the Audit Trail:**
|
||||
- **AuditActor Record Deletion Prevention**: The database will reject any attempt to delete an `AuditActor` record that has associated `BookingAudit` records, ensuring audit records never become orphaned
|
||||
- **User Deletion Unaffected**: When a `User` is deleted from the system, their corresponding `AuditActor` record persists (with `userUuid` set to null), maintaining the complete audit history
|
||||
|
||||
|
||||
|
||||
## Enums
|
||||
|
||||
### AuditActorType
|
||||
|
||||
Defines the type of entity performing an action:
|
||||
|
||||
```prisma
|
||||
enum AuditActorType {
|
||||
USER @map("user") // Registered Cal.com user (stored here for audit retention even after user deletion)
|
||||
// Considering renaming it to ANONYMOUS to avoid confusion with Guest of a booking
|
||||
GUEST @map("guest") // Non-registered user
|
||||
ATTENDEE @map("attendee") // Guest who booked (has Attendee record)
|
||||
SYSTEM @map("system") // Automated actions
|
||||
}
|
||||
```
|
||||
|
||||
**Values:**
|
||||
- **USER**: Registered Cal.com users who perform actions on bookings
|
||||
- **GUEST**: Non-registered users (typically booking guests)
|
||||
- **ATTENDEE**: Guests who have an Attendee record associated with a booking
|
||||
- **SYSTEM**: Automated system actions (scheduled jobs, webhooks, etc.)
|
||||
|
||||
---
|
||||
|
||||
### BookingAuditType
|
||||
|
||||
Defines the category of audit record:
|
||||
|
||||
```prisma
|
||||
enum BookingAuditType {
|
||||
RECORD_CREATED @map("record_created")
|
||||
RECORD_UPDATED @map("record_updated")
|
||||
RECORD_DELETED @map("record_deleted")
|
||||
}
|
||||
```
|
||||
|
||||
**Usage Pattern:**
|
||||
- **RECORD_CREATED**: Initial booking creation
|
||||
- **RECORD_UPDATED**: Any modification to existing booking (most common)
|
||||
- **RECORD_DELETED**: Permanent deletion (rarely used, as bookings are typically cancelled)
|
||||
|
||||
---
|
||||
|
||||
### BookingAuditAction
|
||||
|
||||
Defines specific actions performed on bookings:
|
||||
|
||||
```prisma
|
||||
enum BookingAuditAction {
|
||||
// Booking lifecycle
|
||||
CREATED @map("created")
|
||||
|
||||
// Status changes
|
||||
CANCELLED @map("cancelled")
|
||||
ACCEPTED @map("accepted")
|
||||
REJECTED @map("rejected")
|
||||
PENDING @map("pending")
|
||||
AWAITING_HOST @map("awaiting_host")
|
||||
RESCHEDULED @map("rescheduled")
|
||||
|
||||
// Attendee management
|
||||
ATTENDEE_ADDED @map("attendee_added")
|
||||
ATTENDEE_REMOVED @map("attendee_removed")
|
||||
|
||||
// Assignment/Reassignment
|
||||
REASSIGNMENT @map("reassignment")
|
||||
|
||||
// Meeting details
|
||||
LOCATION_CHANGED @map("location_changed")
|
||||
|
||||
// No-show tracking
|
||||
HOST_NO_SHOW_UPDATED @map("host_no_show_updated")
|
||||
ATTENDEE_NO_SHOW_UPDATED @map("attendee_no_show_updated")
|
||||
|
||||
// Rescheduling
|
||||
RESCHEDULE_REQUESTED @map("reschedule_requested")
|
||||
}
|
||||
```
|
||||
|
||||
**Action Categories:**
|
||||
|
||||
1. **Booking Lifecycle**: Track creation events
|
||||
- `CREATED`
|
||||
|
||||
2. **Status Changes**: Track booking lifecycle transitions
|
||||
- `CANCELLED`, `ACCEPTED`, `REJECTED`, `PENDING`, `AWAITING_HOST`, `RESCHEDULED`
|
||||
|
||||
3. **Attendee Management**: Track changes to booking participants
|
||||
- `ATTENDEE_ADDED`, `ATTENDEE_REMOVED`
|
||||
|
||||
4. **Assignment/Reassignment**: Track booking host assignment changes
|
||||
- `REASSIGNMENT`
|
||||
|
||||
5. **Meeting Details**: Track changes to meeting logistics
|
||||
- `LOCATION_CHANGED`
|
||||
|
||||
6. **No-Show Tracking**: Track attendance issues
|
||||
- `HOST_NO_SHOW_UPDATED`, `ATTENDEE_NO_SHOW_UPDATED`
|
||||
|
||||
7. **Rescheduling**: Track reschedule requests
|
||||
- `RESCHEDULE_REQUESTED`
|
||||
|
||||
---
|
||||
|
||||
## Schema Structure - Change Tracking
|
||||
|
||||
All audit actions follow a consistent structure for tracking changes. This structure ensures complete audit trail coverage by capturing both the old and new values for every tracked field.
|
||||
|
||||
### Core Pattern
|
||||
|
||||
Each action stores a flat object with all relevant fields. Each field tracks both old and new values:
|
||||
|
||||
```typescript
|
||||
{
|
||||
field1: { old: T | null, new: T },
|
||||
field2: { old: T | null, new: T },
|
||||
field3: { old: T | null, new: T } // Optional fields as needed
|
||||
}
|
||||
```
|
||||
|
||||
### Change Tracking
|
||||
|
||||
**All fields use the same pattern** - `{ old: T | null, new: T }`:
|
||||
- `old`: The previous value (null if the field didn't exist before)
|
||||
- `new`: The new value after the change
|
||||
|
||||
**Examples:**
|
||||
```typescript
|
||||
// Simple field changes
|
||||
status: { old: "ACCEPTED", new: "CANCELLED" }
|
||||
location: { old: "Zoom", new: "Google Meet" }
|
||||
|
||||
// New fields (old is null)
|
||||
cancellationReason: { old: null, new: "Client requested" }
|
||||
```
|
||||
|
||||
### Semantic Clarity at Application Layer
|
||||
|
||||
**Action Services decide what to display prominently.** Each Action Service has methods like `getDisplayDetails()` that determine:
|
||||
- Which fields are most important to show by default
|
||||
- Which fields should be available but not emphasized
|
||||
- How to format the data for display
|
||||
|
||||
This keeps the data structure simple while maintaining semantic clarity where it matters - in the UI.
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Complete Audit Trail**: Full before/after state captured for every change
|
||||
2. **Self-Contained Records**: Each record has complete context without querying previous records
|
||||
3. **Simple Structure**: Flat object, easy to work with and extend
|
||||
4. **Better UI**: Action Services decide what to emphasize based on user needs
|
||||
5. **State Reconstruction**: Can rebuild booking state at any point in the audit timeline
|
||||
6. **Easier Debugging**: See exact state transitions in each record
|
||||
7. **Type Safety**: Zod schemas validate the structure while keeping it flexible
|
||||
|
||||
### Examples by Action
|
||||
|
||||
#### Simple Action
|
||||
```typescript
|
||||
// LOCATION_CHANGED
|
||||
{
|
||||
location: { old: "Zoom", new: "Google Meet" }
|
||||
}
|
||||
```
|
||||
|
||||
#### Action with Multiple Fields
|
||||
```typescript
|
||||
// CANCELLED
|
||||
{
|
||||
cancellationReason: { old: null, new: "Client requested" },
|
||||
cancelledBy: { old: null, new: "user@example.com" },
|
||||
status: { old: "ACCEPTED", new: "CANCELLED" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JSON Data Schemas by Action
|
||||
|
||||
The `BookingAudit.data` field stores action-specific context. Each action has its own schema defined in a dedicated Action Helper Service using Zod validation.
|
||||
|
||||
### Booking Lifecycle Actions
|
||||
|
||||
#### CREATED
|
||||
Used when a booking is initially created. Records the complete state at creation time.
|
||||
|
||||
```typescript
|
||||
{
|
||||
startTime: string // ISO 8601 timestamp
|
||||
endTime: string // ISO 8601 timestamp
|
||||
status: BookingStatus // Actual booking status (any value from BookingStatus enum)
|
||||
}
|
||||
```
|
||||
|
||||
**Design Decision:** The `status` field accepts any `BookingStatus` value, not just the expected creation statuses (ACCEPTED, PENDING, AWAITING_HOST). This follows the principle of capturing reality rather than enforcing business rules in the audit layer. If a booking is ever created with an unexpected status due to a bug, we want to record that fact for debugging purposes rather than silently skip the audit record.
|
||||
|
||||
**Note:** The CREATED action is unique - it captures the initial booking state at creation, so it doesn't use the `{ old, new }` tracking pattern. It's a flat object with just the initial values: `{ startTime, endTime, status }`.
|
||||
|
||||
---
|
||||
|
||||
### Status Change Actions
|
||||
|
||||
#### ACCEPTED
|
||||
Used when a booking status changes to accepted.
|
||||
|
||||
```typescript
|
||||
{
|
||||
status // { old: "PENDING", new: "ACCEPTED" }
|
||||
}
|
||||
```
|
||||
|
||||
#### CANCELLED
|
||||
```typescript
|
||||
{
|
||||
cancellationReason, // { old: null, new: "Client requested" }
|
||||
cancelledBy, // { old: null, new: "user@example.com" }
|
||||
status // { old: "ACCEPTED", new: "CANCELLED" }
|
||||
}
|
||||
```
|
||||
|
||||
#### REJECTED
|
||||
```typescript
|
||||
{
|
||||
rejectionReason, // { old: null, new: "Does not meet requirements" }
|
||||
status // { old: "PENDING", new: "REJECTED" }
|
||||
}
|
||||
```
|
||||
|
||||
#### RESCHEDULED
|
||||
```typescript
|
||||
{
|
||||
startTime, // { old: "2024-01-15T10:00:00Z", new: "2024-01-16T14:00:00Z" }
|
||||
endTime // { old: "2024-01-15T11:00:00Z", new: "2024-01-16T15:00:00Z" }
|
||||
}
|
||||
```
|
||||
|
||||
#### RESCHEDULE_REQUESTED
|
||||
```typescript
|
||||
{
|
||||
cancellationReason, // { old: null, new: "Need to reschedule" }
|
||||
cancelledBy, // { old: null, new: "user@example.com" }
|
||||
rescheduled? // { old: false, new: true } - optional
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Attendee Management Actions
|
||||
|
||||
#### ATTENDEE_ADDED
|
||||
```typescript
|
||||
{
|
||||
addedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state.
|
||||
|
||||
#### ATTENDEE_REMOVED
|
||||
```typescript
|
||||
{
|
||||
removedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state.
|
||||
|
||||
---
|
||||
|
||||
### Assignment/Reassignment Actions
|
||||
|
||||
#### REASSIGNMENT
|
||||
```typescript
|
||||
{
|
||||
assignedToId, // { old: 123, new: 456 }
|
||||
assignedById, // { old: 789, new: 789 }
|
||||
reassignmentReason, // { old: null, new: "Coverage needed" }
|
||||
userPrimaryEmail?, // { old: "old@cal.com", new: "new@cal.com" } - optional
|
||||
title? // { old: "Meeting with A", new: "Meeting with B" } - optional
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Meeting Details Actions
|
||||
|
||||
#### LOCATION_CHANGED
|
||||
```typescript
|
||||
{
|
||||
location // { old: "Zoom", new: "Google Meet" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### No-Show Tracking Actions
|
||||
|
||||
#### HOST_NO_SHOW_UPDATED
|
||||
```typescript
|
||||
{
|
||||
noShowHost // { old: false, new: true }
|
||||
}
|
||||
```
|
||||
|
||||
#### ATTENDEE_NO_SHOW_UPDATED
|
||||
```typescript
|
||||
{
|
||||
noShowAttendee // { old: false, new: true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Important Notes:**
|
||||
- Each schema is strictly typed and validated using the corresponding Action Helper Service
|
||||
- Actor information is **NOT** stored in the JSON data - it's captured through the `actorId` relation linking to the `Actor` table
|
||||
- All action schemas are defined in `/packages/features/booking-audit/lib/actions/`
|
||||
|
||||
---
|
||||
|
||||
### Supporting Schemas
|
||||
|
||||
#### Change Tracking Pattern
|
||||
|
||||
**All changes use the `{ old, new }` pattern:**
|
||||
|
||||
Each field tracks both old and new values:
|
||||
```typescript
|
||||
fieldName: {
|
||||
old: T | null, // Previous value (null if field didn't exist)
|
||||
new: T // New value
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Complete before/after state in every record
|
||||
- Self-contained audit entries (no need to query previous records)
|
||||
- Clear state transitions
|
||||
- Easier debugging and UI display
|
||||
- Simple flat structure that's easy to work with
|
||||
|
||||
---
|
||||
|
||||
## Table Relationships
|
||||
|
||||
```
|
||||
AuditActor (1) ──────< (many) BookingAudit
|
||||
↑
|
||||
│ (soft reference, no FK)
|
||||
├──────────── User (via userUuid)
|
||||
│ (soft reference, no FK)
|
||||
└──────────── Attendee (via attendeeId)
|
||||
```
|
||||
|
||||
**Relationship Details:**
|
||||
|
||||
1. **AuditActor → BookingAudit**: One-to-Many (with FK constraint)
|
||||
- Each AuditActor can have multiple audit records
|
||||
- Each BookingAudit references exactly one AuditActor
|
||||
- `onDelete: Restrict` prevents AuditActor deletion if audits exist
|
||||
|
||||
2. **AuditActor → User**: Soft Reference (no FK constraint)
|
||||
- `userUuid` references `User.uuid` but without foreign key constraint
|
||||
- Nullable to preserve audits after user deletion
|
||||
- Unique constraint ensures one AuditActor per User UUID
|
||||
- Audit trail persists independently of user lifecycle
|
||||
|
||||
3. **AuditActor → Attendee**: Soft Reference (no FK constraint)
|
||||
- `attendeeId` references `Attendee.id` but without foreign key constraint
|
||||
- Links guest actors to their booking attendee records
|
||||
- Nullable to preserve audits after attendee deletion
|
||||
- Unique constraint ensures one AuditActor per Attendee
|
||||
- Audit trail persists independently of attendee lifecycle
|
||||
|
||||
---
|
||||
|
||||
## Indexing Strategy
|
||||
|
||||
### AuditActor Table Indexes
|
||||
|
||||
```prisma
|
||||
@@index([email]) // Fast lookups by guest email
|
||||
@@index([userUuid]) // Fast lookups by user UUID
|
||||
@@index([attendeeId]) // Fast lookups by attendee ID
|
||||
```
|
||||
|
||||
**Query Patterns:**
|
||||
- Find all actions by a user (via UUID)
|
||||
- Find all actions by a guest email
|
||||
- Find all actions by an attendee
|
||||
|
||||
---
|
||||
|
||||
### BookingAudit Table Indexes
|
||||
|
||||
```prisma
|
||||
@@index([bookingId]) // Primary query pattern
|
||||
@@index([actorId]) // Secondary query pattern
|
||||
```
|
||||
|
||||
**Query Patterns:**
|
||||
- **bookingId**: "Show me all audits for this booking" (most common)
|
||||
- **actorId**: "Show me all actions by this actor"
|
||||
|
||||
---
|
||||
|
||||
## Special Actors
|
||||
|
||||
### SYSTEM Actor
|
||||
|
||||
The system actor represents automated actions and has a fixed UUID:
|
||||
|
||||
```typescript
|
||||
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
|
||||
```
|
||||
|
||||
**Used For:**
|
||||
- Automated status changes
|
||||
- System-generated meeting URLs
|
||||
- Scheduled operations (cron jobs)
|
||||
- Webhook-triggered actions
|
||||
- Any action not directly initiated by a user or guest
|
||||
|
||||
**Properties:**
|
||||
- Type: `ActorType.SYSTEM`
|
||||
- No userUuid, attendeeId, email, or phone
|
||||
- Single instance across the entire system
|
||||
|
||||
---
|
||||
|
||||
## Schema Version Management
|
||||
|
||||
The audit system uses **per-action versioning** rather than global versioning. Each `BookingAuditAction` maintains its own schema version independently.
|
||||
|
||||
### Why Per-Action Versioning?
|
||||
|
||||
Different actions have different data requirements:
|
||||
- `CANCELLED` needs `cancellationReason`
|
||||
- `RESCHEDULED` needs new `startTime` and `endTime`
|
||||
- `ATTENDEE_ADDED` needs `attendee` information
|
||||
- `REASSIGNMENT` needs assignment context
|
||||
|
||||
When we update the schema for one action, we don't want to affect other actions.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
Each action has a dedicated Action Service that manages its own versioning independently. The service defines:
|
||||
|
||||
1. **Schema Definition**: Zod schemas for data validation
|
||||
2. **Schema Version**: Each action maintains its own VERSION constant
|
||||
3. **Nested Structure**: Version stored separately from audit data: `{ version, data: {} }`
|
||||
4. **Type Separation**: Distinct types for input (no version) and stored format (with version)
|
||||
5. **Validation**: Type-safe validation of audit data
|
||||
6. **Display Logic**: How to render the audit record in the UI
|
||||
|
||||
**Example Action Services:**
|
||||
- `CreatedAuditActionService` → Handles `CREATED` action
|
||||
- `CancelledAuditActionService` → Handles `CANCELLED` action
|
||||
- `RescheduledAuditActionService` → Handles `RESCHEDULED` action
|
||||
- `ReassignmentAuditActionService` → Handles `REASSIGNMENT` action
|
||||
|
||||
### Version Storage Structure
|
||||
|
||||
Audit data is stored with a nested structure that separates version metadata from actual audit data:
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: 1,
|
||||
data: {
|
||||
cancellationReason: { old: null, new: "Client requested" },
|
||||
cancelledBy: { old: null, new: "user@cal.com" },
|
||||
status: { old: "ACCEPTED", new: "CANCELLED" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of Nested Structure:**
|
||||
- Clear separation between metadata (version) and actual audit data
|
||||
- Makes it easy to extract just the data fields for display
|
||||
- Version handling is transparent to end users
|
||||
- Schema evolution is self-documenting
|
||||
|
||||
**Key Points:**
|
||||
- Callers pass unversioned data (just the fields)
|
||||
- `parse()` automatically wraps input with version before storing
|
||||
- `parseStored()` validates stored data including version
|
||||
- Display methods receive full stored record but only show data fields
|
||||
- Type system enforces correct usage (input vs stored types)
|
||||
|
||||
### Benefits of Per-Action Versioning
|
||||
|
||||
- **Independent Evolution**: Update one action's schema without affecting others
|
||||
- **Explicit Changes**: Version increments are tied to specific business operations
|
||||
- **No Migration Required**: Old records handled via discriminated unions
|
||||
- **Clear History**: Can track schema changes per action type over time
|
||||
- **Type Safety**: Each action has strongly-typed schemas for input and storage
|
||||
- **Caller Simplicity**: Callers don't need to know about versioning
|
||||
- **Display Isolation**: Version handling is internal to Action Services
|
||||
|
||||
|
||||
When adding a new version (e.g., v2 with a new field):
|
||||
|
||||
**Migration Steps:**
|
||||
1. Create `dataSchemaV2` with new fields
|
||||
2. Create `schemaV2` with `version: z.literal(2)`
|
||||
3. Update `schema` to discriminated union supporting both v1 and v2
|
||||
4. Update `VERSION` constant to 2
|
||||
5. Update `parse()` to use v2 schema
|
||||
6. Update display methods to handle both versions
|
||||
7. No changes needed to callers or database
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Immutability
|
||||
Audit records are append-only. Once created, they are never modified or deleted. This ensures:
|
||||
- Complete historical accuracy
|
||||
- Tamper-proof audit trail
|
||||
- Compliance with audit requirements
|
||||
|
||||
### 2. Historical Preservation
|
||||
Actor information is preserved even after source records are deleted:
|
||||
- User deletion doesn't remove AuditActor records - they persist with anonymized identity fields
|
||||
- AuditActor table maintains historical records by nullifying PII fields (email, phone, name) when source records are deleted
|
||||
- Audit trail remains complete and queryable even after user/attendee deletion
|
||||
|
||||
### 3. Flexibility
|
||||
The JSON `data` field provides schema flexibility:
|
||||
- Action-specific context without database schema changes
|
||||
- Easy addition of new fields for future actions
|
||||
- Backward compatible with versioning
|
||||
|
||||
### 4. Traceability
|
||||
Every action is fully traceable:
|
||||
- Who: Actor with type and identity
|
||||
- What: Type and Action enums
|
||||
- When: Explicit timestamp
|
||||
- Why/How: Contextual data in JSON field
|
||||
|
||||
### 5. Integrity
|
||||
Database constraints ensure data quality:
|
||||
- Foreign key constraints prevent orphaned records
|
||||
- `onDelete: Restrict` protects audit integrity
|
||||
- Unique constraints prevent duplicate actors
|
||||
- Indexes ensure query performance
|
||||
|
||||
### 6. Reality Over Enforcement
|
||||
**The audit system records actual state, not expected state.**
|
||||
|
||||
The audit system is designed to capture what actually happened, not to enforce business rules:
|
||||
|
||||
- **Store Actual Values**: When recording state (e.g., booking status at creation), store the actual value from the database without validation or filtering
|
||||
- **No Type Guards for State**: Avoid conditionally creating audit records based on whether the state matches expectations
|
||||
- **Bug Detection**: If a booking is created with an unexpected status, record it—this becomes valuable debugging information
|
||||
- **Single Source of Truth**: Let the business logic layer enforce rules; the audit layer's job is faithful recording
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// ❌ BAD: Enforcing expected values
|
||||
if (booking.status === 'ACCEPTED' || booking.status === 'PENDING' || booking.status === 'AWAITING_HOST') {
|
||||
// Only audit if status is "expected"
|
||||
await auditService.onBookingCreated(...);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Recording actual state
|
||||
await auditService.onBookingCreated(bookingId, userUuid, {
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
status: booking.status // Whatever it actually is
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Audit trail shows real system behavior, including anomalies
|
||||
- Easier to debug issues (incomplete audits hide problems)
|
||||
- Schemas naturally evolve with business requirements
|
||||
- No silent failures where audits are skipped
|
||||
|
||||
**When to Validate:**
|
||||
- Validate data structure (required fields, types) ✅
|
||||
- Do NOT validate business logic (expected values, state transitions) ❌
|
||||
|
||||
---
|
||||
|
||||
## 7. Compliance & Data Privacy
|
||||
|
||||
**GDPR & HIPAA Compliance:**
|
||||
- **AuditActor records are NOT deleted on user deletion** - Instead, AuditActor records persist with PII fields nullified to preserve the immutable audit trail as required by HIPAA §164.312(b)
|
||||
- **Cal.com's HIPAA compliance** requires audit records to remain immutable and tamper-proof. The AuditActor table design ensures BookingAudit records are never modified, only the referenced AuditActor record has its PII fields anonymized
|
||||
- **GDPR Article 17 compliance** is achieved through anonymization: When a user/guest requests deletion, set `userUuid=null`, `email=null`, `phone=null`, `name=null` on the AuditActor record
|
||||
- **Retention Policy**: Application-level logic should handle retention policies and full anonymization after required retention periods
|
||||
|
||||
**Implementation Pattern:**
|
||||
```typescript
|
||||
// On user deletion: Anonymize PII, don't delete the actor record
|
||||
await prisma.auditActor.update({
|
||||
where: { userUuid: deletedUserUuid },
|
||||
data: {
|
||||
userUuid: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
name: null,
|
||||
pseudonymizedAt: new Date(),
|
||||
scheduledDeletionDate: new Date(Date.now() + 7 * 365 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
});
|
||||
// Result: All BookingAudit records reference pseudonymized actor - audit trail preserved, immutable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
### BookingEventHandlerService - Entry Point
|
||||
|
||||
`BookingEventHandlerService` is the primary entry point for tracking any booking changes. It acts as a coordinator that:
|
||||
|
||||
1. **Receives booking events** from various parts of the application (booking creation, status changes, updates, etc.)
|
||||
2. **Relays to BookingAuditService** to create audit records
|
||||
3. **Handles other side effects** such as webhooks, notifications, and workflow triggers
|
||||
|
||||
|
||||
### BookingAuditService
|
||||
|
||||
The audit system is accessed through `BookingAuditService`, which provides:
|
||||
|
||||
### Convenience Methods
|
||||
|
||||
- `onBookingCreated()` - Track booking creation
|
||||
- `onBookingAccepted()` - Track acceptance
|
||||
- `onBookingRejected()` - Track rejection
|
||||
- `onBookingCancelled()` - Track cancellation
|
||||
- `onBookingRescheduled()` - Track reschedule
|
||||
- `onAttendeeAdded()` - Track attendee addition
|
||||
- `onAttendeeRemoved()` - Track attendee removal
|
||||
- `onLocationChanged()` - Track location changes
|
||||
- `onHostNoShowUpdated()` - Track host no-show
|
||||
- `onAttendeeNoShowUpdated()` - Track attendee no-show
|
||||
- `onReassignment()` - Track booking reassignment
|
||||
- `onRescheduleRequested()` - Track reschedule requests
|
||||
|
||||
### Actor Management
|
||||
|
||||
- `getOrCreateUserActor()` - Ensures User actors exist before creating audits
|
||||
- Automatic AuditActor creation/lookup for registered users
|
||||
- System actor for automated actions
|
||||
|
||||
### Future: Trigger.dev Task Orchestration
|
||||
|
||||
**Current Flow (Synchronous):**
|
||||
```
|
||||
Booking Endpoint → BookingEventHandler.onBookingCreated() → await auditService, linkService, webhookService
|
||||
```
|
||||
|
||||
**Future Flow (Async with Trigger.dev):**
|
||||
```
|
||||
Booking Endpoint
|
||||
↓
|
||||
BookingEventHandler.onBookingCreated() [orchestrator]
|
||||
├─ tasks.trigger('bookingAudit', { bookingId, userUuid, data })
|
||||
├─ tasks.trigger('invalidateHashedLink', { bookingId, hashedLink })
|
||||
├─ tasks.trigger('sendNotifications', { bookingId, email, sms })
|
||||
├─ tasks.trigger('triggerWorkflows', { bookingId, event: 'NEW_EVENT' })
|
||||
└─ Immediately returns to user (non-blocking)
|
||||
|
||||
Trigger.dev Queue
|
||||
├─ Task: Booking Audit (with retries, monitoring)
|
||||
├─ Task: Hashed Link Invalidation (independent)
|
||||
├─ Task: Email & SMS Notifications (independent)
|
||||
└─ Task: Workflow Triggers (independent)
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
- **BookingEventHandler remains the single orchestrator** - Entry point for all side effects
|
||||
- **Each task is independent** - One task failure doesn't block others
|
||||
- **Persistent queue** - Trigger.dev handles retries, monitoring, and observability
|
||||
- **Easy to add features** - New side effect = new task definition, no BookingEventHandler complexity
|
||||
- **Immutable audit records** - Booking audit is just one of many tasks, preserving the immutability principle
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Booking Audit System provides a robust, scalable architecture for tracking all booking-related actions. Key features include:
|
||||
|
||||
- ✅ **Complete Audit Trail**: Every action tracked with full context
|
||||
- ✅ **Historical Preservation**: Data retained even after deletions through PII anonymization
|
||||
- ✅ **Flexible Schema**: JSON data supports evolution without migrations
|
||||
- ✅ **Strong Integrity**: Database constraints ensure data quality
|
||||
- ✅ **Performance**: Strategic indexes for common query patterns, UUID v7 for time-sortable IDs
|
||||
- ✅ **HIPAA & GDPR Compliant**: Immutable audit records, anonymized actors, compliance-ready
|
||||
- ✅ **Reality-Based Recording**: Captures actual state, aiding in debugging and analysis
|
||||
- ✅ **Independent Audit Trail**: No foreign key to bookings ensures audit history persists after booking deletion
|
||||
|
||||
This architecture supports compliance requirements (HIPAA §164.312(b), GDPR Article 17), debugging, analytics, and provides transparency for both users and administrators.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type AuditActorType = "USER" | "GUEST" | "ATTENDEE" | "SYSTEM";
|
||||
|
||||
type AuditActor = {
|
||||
id: string;
|
||||
type: AuditActorType;
|
||||
userUuid: string | null;
|
||||
attendeeId: number | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
name: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
export interface IAuditActorRepository {
|
||||
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
|
||||
findSystemActorOrThrow(): Promise<AuditActor>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
|
||||
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "CANCELLATION_REASON_UPDATED" | "REJECTION_REASON_UPDATED" | "ASSIGNMENT_REASON_UPDATED" | "REASSIGNMENT_REASON_UPDATED" | "LOCATION_CHANGED" | "HOST_NO_SHOW_UPDATED" | "ATTENDEE_NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED"
|
||||
export type BookingAuditCreateInput = {
|
||||
bookingUid: string;
|
||||
actorId: string;
|
||||
action: BookingAuditAction;
|
||||
data: unknown;
|
||||
createdAt: Date;
|
||||
type: BookingAuditType;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
type BookingAudit = {
|
||||
id: string;
|
||||
bookingUid: string;
|
||||
actorId: string;
|
||||
action: string;
|
||||
data: unknown;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IBookingAuditRepository {
|
||||
/**
|
||||
* Creates a new booking audit record
|
||||
*/
|
||||
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import type { IAuditActorRepository } from "./IAuditActorRepository";
|
||||
|
||||
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
type Dependencies = {
|
||||
prismaClient: PrismaClient;
|
||||
}
|
||||
export class PrismaAuditActorRepository implements IAuditActorRepository {
|
||||
constructor(private readonly deps: Dependencies) { }
|
||||
async findByUserUuid(userUuid: string) {
|
||||
return this.deps.prismaClient.auditActor.findUnique({
|
||||
where: { userUuid },
|
||||
});
|
||||
}
|
||||
|
||||
async findSystemActorOrThrow() {
|
||||
const actor = await this.deps.prismaClient.auditActor.findUnique({
|
||||
where: { id: SYSTEM_ACTOR_ID },
|
||||
});
|
||||
|
||||
if (!actor) {
|
||||
throw new Error("System actor not found");
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { IBookingAuditRepository, BookingAuditCreateInput } from "./IBookingAuditRepository";
|
||||
|
||||
type Dependencies = {
|
||||
prismaClient: PrismaClient;
|
||||
}
|
||||
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
|
||||
constructor(private readonly deps: Dependencies) { }
|
||||
|
||||
async create(bookingAudit: BookingAuditCreateInput) {
|
||||
return this.deps.prismaClient.bookingAudit.create({
|
||||
data: bookingAudit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useInitialFormValues } from "./useInitialFormValues";
|
||||
|
||||
export interface IUseBookingForm {
|
||||
event?: Pick<BookerEvent, "bookingFields"> | null;
|
||||
event?: Pick<BookerEvent, "bookingFields" | "team" | "owner"> | null;
|
||||
sessionEmail?: string | null;
|
||||
sessionName?: string | null;
|
||||
sessionUsername?: string | null;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
|
||||
import { useInitialFormValues } from "./useInitialFormValues";
|
||||
|
||||
vi.mock("@calcom/features/bookings/Booker/store", () => ({
|
||||
useBookerStore: vi.fn((selector) => {
|
||||
const state = {
|
||||
bookingData: null,
|
||||
formValues: {},
|
||||
};
|
||||
return selector(state);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/bookings/lib/getBookingResponsesSchema", () => ({
|
||||
getBookingResponsesPartialSchema: vi.fn(() => ({
|
||||
parseAsync: vi.fn((data) => Promise.resolve(data)),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useInitialFormValues - Autofill Disable Feature", () => {
|
||||
const mockBookingFields = [
|
||||
{ name: "name", type: "text" as const, required: true },
|
||||
{ name: "email", type: "email" as const, required: true },
|
||||
{ name: "phone", type: "phone" as const, required: false },
|
||||
] as unknown as BookerEvent["bookingFields"];
|
||||
|
||||
const baseProps = {
|
||||
rescheduleUid: null,
|
||||
isRescheduling: false,
|
||||
email: null,
|
||||
name: null,
|
||||
username: null,
|
||||
hasSession: false,
|
||||
extraOptions: {},
|
||||
prefillFormParams: {
|
||||
guests: [],
|
||||
name: null,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when organization has NOT disabled autofill", () => {
|
||||
it("should use URL parameters to prefill form fields", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: {
|
||||
organizationSettings: {
|
||||
disableAutofillOnBookingPage: false,
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+1234567890",
|
||||
customField: "custom value",
|
||||
};
|
||||
|
||||
const prefillFormParams = {
|
||||
guests: ["guest1@example.com", "guest2@example.com"],
|
||||
name: "John Doe",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses).toMatchObject({
|
||||
name: "John Doe",
|
||||
phone: "+1234567890",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use URL parameters when organizationSettings is undefined", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: null,
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+1234567890",
|
||||
};
|
||||
|
||||
const prefillFormParams = {
|
||||
guests: [],
|
||||
name: "Jane Smith",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses).toMatchObject({
|
||||
name: "Jane Smith",
|
||||
phone: "+1234567890",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when organization HAS disabled autofill", () => {
|
||||
it("should NOT use URL parameters when team.organizationSettings.disableAutofillOnBookingPage is true", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: {
|
||||
parent: {
|
||||
organizationSettings: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+1234567890",
|
||||
customField: "custom value",
|
||||
};
|
||||
|
||||
const prefillFormParams = {
|
||||
guests: ["guest1@example.com"],
|
||||
name: "John Doe",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses?.phone).toBeUndefined();
|
||||
expect(result.current.values.responses?.name).toBe("");
|
||||
});
|
||||
|
||||
it("should NOT use URL parameters when team.parent.organizationSettings.disableAutofillOnBookingPage is true", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: {
|
||||
organizationSettings: null,
|
||||
parent: {
|
||||
organizationSettings: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+9876543210",
|
||||
};
|
||||
|
||||
const prefillFormParams = {
|
||||
guests: [],
|
||||
name: "Parent Team User",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses?.phone).toBeUndefined();
|
||||
expect(result.current.values.responses?.name).toBe("");
|
||||
});
|
||||
|
||||
it("should NOT use URL parameters when owner.organization.organizationSettings.disableAutofillOnBookingPage is true", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: null,
|
||||
owner: {
|
||||
profile: {
|
||||
organization: {
|
||||
organizationSettings: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+5555555555",
|
||||
};
|
||||
|
||||
const prefillFormParams = {
|
||||
guests: [],
|
||||
name: "Personal Event User",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
prefillFormParams,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses?.phone).toBeUndefined();
|
||||
expect(result.current.values.responses?.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session data handling with autofill disabled", () => {
|
||||
it("should not use session email/name when autofill is disabled", async () => {
|
||||
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
|
||||
bookingFields: mockBookingFields,
|
||||
team: {
|
||||
parent: {
|
||||
organizationSettings: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const extraOptions = {
|
||||
phone: "+1234567890", // This should be blocked
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialFormValues({
|
||||
...baseProps,
|
||||
eventType,
|
||||
extraOptions,
|
||||
email: "session@example.com", // Session data
|
||||
name: "Session User", // Session data
|
||||
hasSession: true,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.values.responses).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.values.responses?.email).toBe("");
|
||||
expect(result.current.values.responses?.name).toBe("");
|
||||
expect(result.current.values.responses?.phone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
export type useInitialFormValuesReturnType = ReturnType<typeof useInitialFormValues>;
|
||||
|
||||
type UseInitialFormValuesProps = {
|
||||
eventType?: Pick<BookerEvent, "bookingFields"> | null;
|
||||
eventType?: Pick<BookerEvent, "bookingFields" | "team" | "owner"> | null;
|
||||
rescheduleUid: string | null;
|
||||
isRescheduling: boolean;
|
||||
email?: string | null;
|
||||
@@ -78,6 +78,12 @@ export function useInitialFormValues({
|
||||
});
|
||||
const bookingData = useBookerStore((state) => state.bookingData);
|
||||
const formValues = useBookerStore((state) => state.formValues);
|
||||
|
||||
// Check if organization has disabled autofill from URL parameters
|
||||
const isAutofillDisabledByOrg =
|
||||
eventType?.team?.parent?.organizationSettings?.disableAutofillOnBookingPage ??
|
||||
eventType?.owner?.profile?.organization?.organizationSettings?.disableAutofillOnBookingPage ??
|
||||
false;
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (Object.keys(formValues).length) {
|
||||
@@ -96,28 +102,41 @@ export function useInitialFormValues({
|
||||
view: rescheduleUid ? "reschedule" : "booking",
|
||||
});
|
||||
|
||||
const parsedQuery = await querySchema.parseAsync({
|
||||
...extraOptions,
|
||||
name: prefillFormParams.name,
|
||||
// `guest` because we need to support legacy URL with `guest` query param support
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: prefillFormParams.guests,
|
||||
});
|
||||
// If organization has disabled autofill, don't use URL parameters for prefill
|
||||
const urlParamsToUse = isAutofillDisabledByOrg
|
||||
? {}
|
||||
: {
|
||||
...extraOptions,
|
||||
name: prefillFormParams.name,
|
||||
// `guest` because we need to support legacy URL with `guest` query param support
|
||||
// `guests` because the `name` of the corresponding bookingField is `guests`
|
||||
guests: prefillFormParams.guests,
|
||||
};
|
||||
|
||||
const defaultUserValues = {
|
||||
email:
|
||||
const parsedQuery = await querySchema.parseAsync(urlParamsToUse);
|
||||
|
||||
const defaultUserValues = (() => {
|
||||
const rescheduledEmail =
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].email
|
||||
: !!parsedQuery["email"]
|
||||
? parsedQuery["email"]
|
||||
: email ?? "",
|
||||
name:
|
||||
? bookingData.attendees[0].email ?? ""
|
||||
: "";
|
||||
const rescheduledName =
|
||||
rescheduleUid && bookingData && bookingData.attendees.length > 0
|
||||
? bookingData?.attendees[0].name
|
||||
: !!parsedQuery["name"]
|
||||
? parsedQuery["name"]
|
||||
: name ?? username ?? "",
|
||||
};
|
||||
? bookingData.attendees[0].name ?? ""
|
||||
: "";
|
||||
|
||||
if (isAutofillDisabledByOrg) {
|
||||
return {
|
||||
email: rescheduledEmail,
|
||||
name: rescheduledName,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
email: rescheduledEmail || (parsedQuery["email"] ? parsedQuery["email"] : email ?? ""),
|
||||
name: rescheduledName || (parsedQuery["name"] ? parsedQuery["name"] : name ?? username ?? ""),
|
||||
};
|
||||
})();
|
||||
|
||||
if (clientId) {
|
||||
defaultUserValues.email = defaultUserValues.email.replace(`+${clientId}`, "");
|
||||
@@ -189,6 +208,7 @@ export function useInitialFormValues({
|
||||
prefillFormParams,
|
||||
// We need to have extraOptions as a dep so that any change in query params can update the form values, but extraOptions itself isn't stable and changes reference on every render
|
||||
stableHashExtraOptions,
|
||||
isAutofillDisabledByOrg,
|
||||
]);
|
||||
|
||||
return initialValuesState;
|
||||
|
||||
@@ -15,6 +15,36 @@ import type { GetBookingType } from "../lib/get-booking";
|
||||
import type { BookerState, BookerLayout } from "./types";
|
||||
import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param";
|
||||
|
||||
const _iso_3166_1_alpha_2_codes = [
|
||||
"ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az",
|
||||
"ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bl", "bm", "bn", "bo", "bq", "br", "bs", "bt", "bv", "bw", "by", "bz",
|
||||
"ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cw", "cx", "cy", "cz",
|
||||
"de", "dj", "dk", "dm", "do", "dz",
|
||||
"ec", "ee", "eg", "eh", "er", "es", "et",
|
||||
"fi", "fj", "fk", "fm", "fo", "fr",
|
||||
"ga", "gb", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy",
|
||||
"hk", "hm", "hn", "hr", "ht", "hu",
|
||||
"id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it",
|
||||
"je", "jm", "jo", "jp",
|
||||
"ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz",
|
||||
"la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly",
|
||||
"ma", "mc", "md", "me", "mf", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz",
|
||||
"na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz",
|
||||
"om",
|
||||
"pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py",
|
||||
"qa",
|
||||
"re", "ro", "rs", "ru", "rw",
|
||||
"sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sj", "sk", "sl", "sm", "sn", "so", "sr", "ss", "st", "sv", "sx", "sy",
|
||||
"tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz",
|
||||
"ua", "ug", "um", "us", "uy", "uz",
|
||||
"va", "vc", "ve", "vg", "vi", "vn", "vu",
|
||||
"wf", "ws",
|
||||
"ye", "yt",
|
||||
"za", "zm", "zw"
|
||||
] as const;
|
||||
|
||||
export type CountryCode = typeof _iso_3166_1_alpha_2_codes[number];
|
||||
|
||||
|
||||
/**
|
||||
* Arguments passed into store initializer, containing
|
||||
@@ -44,6 +74,7 @@ export type StoreInitializeType = {
|
||||
crmRecordId?: string | null;
|
||||
isPlatform?: boolean;
|
||||
allowUpdatingUrlParams?: boolean;
|
||||
defaultPhoneCountry?: CountryCode;
|
||||
};
|
||||
|
||||
type SeatedEventData = {
|
||||
@@ -183,6 +214,7 @@ export type BookerStore = {
|
||||
crmRecordId?: string | null;
|
||||
isPlatform?: boolean;
|
||||
allowUpdatingUrlParams?: boolean;
|
||||
defaultPhoneCountry?: CountryCode | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -329,6 +361,7 @@ export const createBookerStore = () =>
|
||||
crmRecordId,
|
||||
isPlatform = false,
|
||||
allowUpdatingUrlParams = true,
|
||||
defaultPhoneCountry,
|
||||
}: StoreInitializeType) => {
|
||||
const selectedDateInStore = get().selectedDate;
|
||||
|
||||
@@ -372,6 +405,7 @@ export const createBookerStore = () =>
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
defaultPhoneCountry,
|
||||
});
|
||||
|
||||
if (durationConfig?.includes(Number(getQueryParam("duration")))) {
|
||||
@@ -461,6 +495,7 @@ export const createBookerStore = () =>
|
||||
},
|
||||
isPlatform: false,
|
||||
allowUpdatingUrlParams: true,
|
||||
defaultPhoneCountry: null,
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -489,6 +524,7 @@ export const useInitializeBookerStore = ({
|
||||
crmRecordId,
|
||||
isPlatform = false,
|
||||
allowUpdatingUrlParams = true,
|
||||
defaultPhoneCountry,
|
||||
}: StoreInitializeType) => {
|
||||
const initializeStore = useBookerStore((state) => state.initialize);
|
||||
useEffect(() => {
|
||||
@@ -513,6 +549,7 @@ export const useInitializeBookerStore = ({
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
defaultPhoneCountry,
|
||||
});
|
||||
}, [
|
||||
initializeStore,
|
||||
@@ -536,5 +573,6 @@ export const useInitializeBookerStore = ({
|
||||
crmRecordId,
|
||||
isPlatform,
|
||||
allowUpdatingUrlParams,
|
||||
defaultPhoneCountry,
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,8 +67,9 @@ export const getCalEventResponses = ({
|
||||
parsedBookingFields.forEach((field) => {
|
||||
const label = field.label || field.defaultLabel;
|
||||
if (!label) {
|
||||
//TODO: This error must be thrown while saving event-type as well so that such an event-type can't be saved
|
||||
throw new Error(`Missing label for booking field "${field.name}"`);
|
||||
//TODO: This error must be thrown while saving event-type so that such an event-type can't be saved
|
||||
log.error(`Missing label for booking field "${field.name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.name == "guests" && !!seatsEnabled) {
|
||||
|
||||
@@ -1884,6 +1884,7 @@ async function handler(
|
||||
evt.videoCallData = undefined;
|
||||
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
|
||||
evt.iCalUID = undefined;
|
||||
evt.hasOrganizerChanged = true;
|
||||
}
|
||||
|
||||
if (changedOrganizer && originalRescheduledBooking?.user) {
|
||||
|
||||
@@ -64,6 +64,8 @@ export type BookerEvent = Pick<
|
||||
| "disableCancelling"
|
||||
| "disableRescheduling"
|
||||
| "interfaceLanguage"
|
||||
| "team"
|
||||
| "owner"
|
||||
> & {
|
||||
subsetOfUsers: BookerEventUser[];
|
||||
showInstantEventConnectNowModal: boolean;
|
||||
|
||||
+92
-31
@@ -50,54 +50,115 @@ export class CalendarCacheWrapper implements Calendar {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to use cache
|
||||
* Retrieves availability combining cache and live sources.
|
||||
*
|
||||
* @param dateFrom
|
||||
* @param dateTo
|
||||
* @param selectedCalendars
|
||||
* @param shouldServeCache
|
||||
* - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache.
|
||||
* - Calendars **without** one of them → fetched directly from the original calendar.
|
||||
* - Results are merged into a single array.
|
||||
*
|
||||
* @param dateFrom - Start date (ISO string)
|
||||
* @param dateTo - End date (ISO string)
|
||||
* @param selectedCalendars - List of calendars to retrieve availability from
|
||||
* @returns Combined array of busy date ranges
|
||||
*/
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
// _shouldServeCache?: boolean
|
||||
// _fallbackToPrimary?: boolean
|
||||
): Promise<EventBusyDate[]> {
|
||||
log.debug("getAvailability from cache", { dateFrom, dateTo, selectedCalendars });
|
||||
const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id));
|
||||
if (!selectedCalendarIds.length) {
|
||||
return Promise.resolve([]);
|
||||
log.debug("getAvailability (mixed cache + original)", {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
calendarIds: selectedCalendars.map((c) => c.id),
|
||||
calendarCount: selectedCalendars.length,
|
||||
});
|
||||
|
||||
if (!selectedCalendars?.length) return [];
|
||||
|
||||
const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt);
|
||||
const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt);
|
||||
|
||||
const results: EventBusyDate[] = [];
|
||||
|
||||
// Fetch from cache for synced calendars
|
||||
if (withSync.length) {
|
||||
const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id));
|
||||
const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
ids,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
results.push(...cached);
|
||||
}
|
||||
return this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
selectedCalendarIds,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
|
||||
// Fetch from original calendar for unsynced ones
|
||||
if (withoutSync.length) {
|
||||
const original = await this.deps.originalCalendar.getAvailability(dateFrom, dateTo, withoutSync);
|
||||
results.push(...original);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override this method to use cache
|
||||
* Retrieves availability with time zones, combining cache and live data.
|
||||
*
|
||||
* @param dateFrom
|
||||
* @param dateTo
|
||||
* @param selectedCalendars
|
||||
* @returns
|
||||
* - Calendars **with** both `syncToken` and `syncSubscribedAt` → fetched from cache.
|
||||
* - Calendars **without** one of them → fetched directly from the original calendar.
|
||||
* - Results are merged into a single array with `{ start, end, timeZone }` format.
|
||||
*
|
||||
* @param dateFrom - Start date (ISO string)
|
||||
* @param dateTo - End date (ISO string)
|
||||
* @param selectedCalendars - List of calendars to retrieve availability from
|
||||
* @returns Combined array of time-zone-aware availability ranges
|
||||
*/
|
||||
async getAvailabilityWithTimeZones?(
|
||||
async getAvailabilityWithTimeZones(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
// _fallbackToPrimary?: boolean
|
||||
): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> {
|
||||
log.debug("getAvailabilityWithTimeZones from cache", { dateFrom, dateTo, selectedCalendars });
|
||||
const selectedCalendarIds = selectedCalendars.map((e) => e.id).filter((id): id is string => Boolean(id));
|
||||
const result = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
selectedCalendarIds,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
return result.map(({ start, end, timeZone }) => ({ start, end, timeZone: timeZone || "UTC" }));
|
||||
log.debug("getAvailabilityWithTimeZones (mixed cache + original)", {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
calendarIds: selectedCalendars.map((c) => c.id),
|
||||
calendarCount: selectedCalendars.length,
|
||||
});
|
||||
|
||||
if (!selectedCalendars?.length) return [];
|
||||
|
||||
const withSync = selectedCalendars.filter((c) => c.syncToken && c.syncSubscribedAt);
|
||||
const withoutSync = selectedCalendars.filter((c) => !c.syncToken || !c.syncSubscribedAt);
|
||||
|
||||
const results: { start: Date | string; end: Date | string; timeZone: string }[] = [];
|
||||
|
||||
// Fetch from cache for synced calendars
|
||||
if (withSync.length) {
|
||||
const ids = withSync.map((c) => c.id).filter((id): id is string => Boolean(id));
|
||||
const cached = await this.deps.calendarCacheEventRepository.findAllBySelectedCalendarIdsBetween(
|
||||
ids,
|
||||
new Date(dateFrom),
|
||||
new Date(dateTo)
|
||||
);
|
||||
results.push(
|
||||
...cached.map(({ start, end, timeZone }) => ({
|
||||
start,
|
||||
end,
|
||||
timeZone: timeZone || "UTC",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch from original calendar for unsynced ones
|
||||
if (withoutSync.length) {
|
||||
const original = await this.deps.originalCalendar.getAvailabilityWithTimeZones?.(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
withoutSync
|
||||
);
|
||||
if (original?.length) results.push(...original);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown> {
|
||||
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { ICalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository.interface";
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarServiceEvent,
|
||||
EventBusyDate,
|
||||
IntegrationCalendar,
|
||||
NewCalendarEventType,
|
||||
} from "@calcom/types/Calendar";
|
||||
|
||||
import { CalendarCacheWrapper } from "../CalendarCacheWrapper";
|
||||
|
||||
describe("CalendarCacheWrapper", () => {
|
||||
let mockOriginalCalendar: Calendar;
|
||||
let mockRepository: ICalendarCacheEventRepository;
|
||||
let wrapper: CalendarCacheWrapper;
|
||||
|
||||
const createMockCalendar = (): Calendar => ({
|
||||
getAvailability: vi.fn().mockResolvedValue([]),
|
||||
getAvailabilityWithTimeZones: vi.fn().mockResolvedValue([]),
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
listCalendars: vi.fn(),
|
||||
});
|
||||
|
||||
const createMockRepository = (): ICalendarCacheEventRepository => ({
|
||||
findAllBySelectedCalendarIdsBetween: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockOriginalCalendar = createMockCalendar();
|
||||
mockRepository = createMockRepository();
|
||||
wrapper = new CalendarCacheWrapper({
|
||||
originalCalendar: mockOriginalCalendar,
|
||||
calendarCacheEventRepository: mockRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailability", () => {
|
||||
it("should return empty array when no calendars provided", async () => {
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", []);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from cache when all calendars have syncToken and syncSubscribedAt", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") },
|
||||
{ start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(cachedEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1", "cal-2"],
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
expect(mockOriginalCalendar.getAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from original calendar when all calendars lack syncToken or syncSubscribedAt", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: null, // Missing syncSubscribedAt
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T09:00:00Z"), end: new Date("2025-01-01T10:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(originalEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith(
|
||||
"2025-01-01",
|
||||
"2025-01-02",
|
||||
selectedCalendars
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch from both cache and original calendar when calendars are mixed", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T10:00:00Z"), end: new Date("2025-01-01T11:00:00Z") },
|
||||
];
|
||||
|
||||
const originalEvents: EventBusyDate[] = [
|
||||
{ start: new Date("2025-01-01T14:00:00Z"), end: new Date("2025-01-01T15:00:00Z") },
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
vi.mocked(mockOriginalCalendar.getAvailability).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([...cachedEvents, ...originalEvents]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1"],
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
expect(mockOriginalCalendar.getAvailability).toHaveBeenCalledWith("2025-01-01", "2025-01-02", [
|
||||
selectedCalendars[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should filter out calendars without id when fetching from cache", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-2",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
await wrapper.getAvailability("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).toHaveBeenCalledWith(
|
||||
["cal-1"], // Only cal-1 has a valid id
|
||||
new Date("2025-01-01"),
|
||||
new Date("2025-01-02")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailabilityWithTimeZones", () => {
|
||||
it("should return empty array when no calendars provided", async () => {
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", []);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from cache and apply UTC default timezone when all calendars are synced", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
start: new Date("2025-01-01T14:00:00Z"),
|
||||
end: new Date("2025-01-01T15:00:00Z"),
|
||||
timeZone: null, // Should default to UTC
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
start: cachedEvents[1].start,
|
||||
end: cachedEvents[1].end,
|
||||
timeZone: "UTC", // Default applied
|
||||
},
|
||||
]);
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch only from original calendar when all calendars are unsynced", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents = [
|
||||
{
|
||||
start: new Date("2025-01-01T09:00:00Z"),
|
||||
end: new Date("2025-01-01T10:00:00Z"),
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual(originalEvents);
|
||||
expect(mockRepository.findAllBySelectedCalendarIdsBetween).not.toHaveBeenCalled();
|
||||
expect(mockOriginalCalendar.getAvailabilityWithTimeZones).toHaveBeenCalledWith(
|
||||
"2025-01-01",
|
||||
"2025-01-02",
|
||||
selectedCalendars
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch from both cache and original calendar when calendars are mixed", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
];
|
||||
|
||||
const originalEvents = [
|
||||
{
|
||||
start: new Date("2025-01-01T14:00:00Z"),
|
||||
end: new Date("2025-01-01T15:00:00Z"),
|
||||
timeZone: "Europe/London",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue(originalEvents);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
...originalEvents,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle when originalCalendar.getAvailabilityWithTimeZones is undefined", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: "token-1",
|
||||
syncSubscribedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "cal-2",
|
||||
externalId: "ext-2",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cachedEvents: EventBusyDate[] = [
|
||||
{
|
||||
start: new Date("2025-01-01T10:00:00Z"),
|
||||
end: new Date("2025-01-01T11:00:00Z"),
|
||||
timeZone: "UTC",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockRepository.findAllBySelectedCalendarIdsBetween).mockResolvedValue(cachedEvents);
|
||||
mockOriginalCalendar.getAvailabilityWithTimeZones = undefined;
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
start: cachedEvents[0].start,
|
||||
end: cachedEvents[0].end,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle when originalCalendar.getAvailabilityWithTimeZones returns empty array", async () => {
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
syncToken: null,
|
||||
syncSubscribedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.getAvailabilityWithTimeZones).mockResolvedValue([]);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2025-01-01", "2025-01-02", selectedCalendars);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("other Calendar methods", () => {
|
||||
it("should delegate createEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarServiceEvent = {
|
||||
title: "Test Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
const mockResult: NewCalendarEventType = {
|
||||
uid: "test-uid",
|
||||
id: "test-id",
|
||||
type: "test-type",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.createEvent).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await wrapper.createEvent(mockEvent, 123, "cal-id");
|
||||
|
||||
expect(result).toBe(mockResult);
|
||||
expect(mockOriginalCalendar.createEvent).toHaveBeenCalledWith(mockEvent, 123, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate updateEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarServiceEvent = {
|
||||
title: "Updated Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
const mockResult: NewCalendarEventType = {
|
||||
uid: "test-uid",
|
||||
id: "test-id",
|
||||
type: "test-type",
|
||||
password: "",
|
||||
url: "",
|
||||
additionalInfo: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.updateEvent).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await wrapper.updateEvent("uid-123", mockEvent, "cal-id");
|
||||
|
||||
expect(result).toBe(mockResult);
|
||||
expect(mockOriginalCalendar.updateEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate deleteEvent to original calendar", async () => {
|
||||
const mockEvent: CalendarEvent = {
|
||||
uid: "test-uid",
|
||||
title: "Test Event",
|
||||
startTime: "2025-01-01T10:00:00Z",
|
||||
endTime: "2025-01-01T11:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(mockOriginalCalendar.deleteEvent).mockResolvedValue(undefined);
|
||||
|
||||
await wrapper.deleteEvent("uid-123", mockEvent, "cal-id");
|
||||
|
||||
expect(mockOriginalCalendar.deleteEvent).toHaveBeenCalledWith("uid-123", mockEvent, "cal-id");
|
||||
});
|
||||
|
||||
it("should delegate listCalendars to original calendar", async () => {
|
||||
const mockCalendars: IntegrationCalendar[] = [
|
||||
{
|
||||
id: "cal-1",
|
||||
externalId: "ext-1",
|
||||
integration: "google_calendar",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockOriginalCalendar.listCalendars).mockResolvedValue(mockCalendars);
|
||||
|
||||
const result = await wrapper.listCalendars();
|
||||
|
||||
expect(result).toBe(mockCalendars);
|
||||
expect(mockOriginalCalendar.listCalendars).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import PhoneInput from "react-phone-input-2";
|
||||
import "react-phone-input-2/lib/style.css";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import { useBookerStore, type CountryCode } from "@calcom/features/bookings/Booker/store";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
@@ -33,6 +34,8 @@ function BasePhoneInput({
|
||||
...rest
|
||||
}: PhoneInputProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const defaultPhoneCountryFromStore = useBookerStore((state) => state.defaultPhoneCountry);
|
||||
const effectiveDefaultCountry = defaultPhoneCountryFromStore || defaultCountry;
|
||||
|
||||
// This is to trigger validation on prefill value changes
|
||||
useEffect(() => {
|
||||
@@ -63,7 +66,7 @@ function BasePhoneInput({
|
||||
value={value ? value.trim().replace(/^\+?/, "+") : undefined}
|
||||
enableSearch
|
||||
disableSearchIcon
|
||||
country={defaultCountry}
|
||||
country={effectiveDefaultCountry}
|
||||
inputProps={{
|
||||
name,
|
||||
required: rest.required,
|
||||
@@ -150,7 +153,8 @@ function BasePhoneInputWeb({
|
||||
}
|
||||
|
||||
const useDefaultCountry = () => {
|
||||
const [defaultCountry, setDefaultCountry] = useState("us");
|
||||
const defaultPhoneCountryFromStore = useBookerStore((state) => state.defaultPhoneCountry);
|
||||
const [defaultCountry, setDefaultCountry] = useState<CountryCode>(defaultPhoneCountryFromStore || "us");
|
||||
const query = trpc.viewer.public.countryCode.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -159,16 +163,28 @@ const useDefaultCountry = () => {
|
||||
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
if (defaultPhoneCountryFromStore) {
|
||||
setDefaultCountry(defaultPhoneCountryFromStore);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
if (!data?.countryCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSupportedCountry(data?.countryCode)
|
||||
? setDefaultCountry(data.countryCode.toLowerCase())
|
||||
: setDefaultCountry(navigator.language.split("-")[1]?.toLowerCase() || "us");
|
||||
if (isSupportedCountry(data?.countryCode)) {
|
||||
setDefaultCountry(data.countryCode.toLowerCase() as CountryCode);
|
||||
} else {
|
||||
const navCountry = navigator.language.split("-")[1]?.toUpperCase();
|
||||
if (navCountry && isSupportedCountry(navCountry)) {
|
||||
setDefaultCountry(navCountry.toLowerCase() as CountryCode);
|
||||
} else {
|
||||
setDefaultCountry("us");
|
||||
}
|
||||
}
|
||||
},
|
||||
[query.data]
|
||||
[query.data, defaultPhoneCountryFromStore]
|
||||
);
|
||||
|
||||
return defaultCountry;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import { useReactTable, getCoreRowModel, type Table, type Column } from "@tanstack/react-table";
|
||||
|
||||
import { ColumnVisibilityButton } from "./ColumnVisibilityButton";
|
||||
|
||||
let tableRef: Table<{ a: number; b: number; c: number }> | null = null;
|
||||
|
||||
const TestWrapper = () => {
|
||||
const data = useMemo(() => [{ a: 1, b: 2, c: 3 }], []);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ accessorKey: "a", header: "A" },
|
||||
{ accessorKey: "b", header: "B", enableHiding: true },
|
||||
{ accessorKey: "c", header: "C", enableHiding: true },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
initialState: { columnVisibility: { b: false, c: false } },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
tableRef = table;
|
||||
}, [table]);
|
||||
|
||||
return <ColumnVisibilityButton table={table} />;
|
||||
}
|
||||
|
||||
describe("ColumnVisibilityButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tableRef = null;
|
||||
});
|
||||
|
||||
it("show_all_columns sets all columns visible", async () => {
|
||||
render(<TestWrapper />);
|
||||
|
||||
fireEvent.click(screen.getByText("display"));
|
||||
|
||||
const showAll = await screen.findByText("show_all_columns");
|
||||
fireEvent.click(showAll);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tableRef).not.toBeNull();
|
||||
const allVisible = tableRef!.getAllLeafColumns().every((col: Column<{ a: number; b: number; c: number }, unknown>) => col.getIsVisible());
|
||||
expect(allVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ function ColumnVisibilityButtonComponent<TData>(
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
allColumns.forEach((column) => column.toggleVisibility(true));
|
||||
table.toggleAllColumnsVisible(true);
|
||||
}}
|
||||
className={classNames(
|
||||
buttonClasses({ color: "secondary" }),
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { OrganizationSettings } from "@calcom/prisma/client";
|
||||
|
||||
type MinimumOrganizationSettings = Pick<
|
||||
OrganizationSettings,
|
||||
"orgAutoAcceptEmail" | "orgProfileRedirectsToVerifiedDomain" | "allowSEOIndexing"
|
||||
| "orgAutoAcceptEmail"
|
||||
| "orgProfileRedirectsToVerifiedDomain"
|
||||
| "allowSEOIndexing"
|
||||
| "disableAutofillOnBookingPage"
|
||||
>;
|
||||
|
||||
type SEOOrganizationSettings = Pick<OrganizationSettings, "allowSEOIndexing">;
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface GeneralViewProps {
|
||||
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
|
||||
}
|
||||
|
||||
export const DisableAutofillOnBookingPageSwitch = ({ currentOrg }: GeneralViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [disableAutofillOnBookingPageActive, setDisableAutofillOnBookingPageActive] = useState(
|
||||
!!currentOrg.organizationSettings?.disableAutofillOnBookingPage
|
||||
);
|
||||
|
||||
const mutation = trpc.viewer.organizations.update.useMutation({
|
||||
onSuccess: async (_data, variables) => {
|
||||
setDisableAutofillOnBookingPageActive(!!variables.disableAutofillOnBookingPage);
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.viewer.organizations.listCurrent.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("disable_autofill_on_booking_page")}
|
||||
disabled={mutation?.isPending}
|
||||
description={t("disable_autofill_on_booking_page_description")}
|
||||
checked={disableAutofillOnBookingPageActive}
|
||||
onCheckedChange={(checked) => {
|
||||
mutation.mutate({
|
||||
disableAutofillOnBookingPage: checked,
|
||||
});
|
||||
}}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { Select } from "@calcom/ui/components/form";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { DisableAutofillOnBookingPageSwitch } from "../components/DisableAutofillOnBookingPageSwitch";
|
||||
import { DisablePhoneOnlySMSNotificationsSwitch } from "../components/DisablePhoneOnlySMSNotificationsSwitch";
|
||||
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";
|
||||
import { NoSlotsNotificationSwitch } from "../components/NoSlotsNotificationSwitch";
|
||||
@@ -94,6 +95,7 @@ const OrgGeneralView = ({
|
||||
<LockEventTypeSwitch currentOrg={currentOrg} />
|
||||
<NoSlotsNotificationSwitch currentOrg={currentOrg} />
|
||||
<DisablePhoneOnlySMSNotificationsSwitch currentOrg={currentOrg} />
|
||||
<DisableAutofillOnBookingPageSwitch currentOrg={currentOrg} />
|
||||
</>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
|
||||
@@ -287,6 +287,7 @@ export class OrganizationRepository {
|
||||
orgProfileRedirectsToVerifiedDomain: true,
|
||||
orgAutoAcceptEmail: true,
|
||||
disablePhoneOnlySMSNotifications: true,
|
||||
disableAutofillOnBookingPage: true,
|
||||
orgAutoJoinOnSignup: true,
|
||||
},
|
||||
});
|
||||
@@ -306,6 +307,7 @@ export class OrganizationRepository {
|
||||
orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain,
|
||||
orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail,
|
||||
disablePhoneOnlySMSNotifications: organizationSettings?.disablePhoneOnlySMSNotifications,
|
||||
disableAutofillOnBookingPage: organizationSettings?.disableAutofillOnBookingPage,
|
||||
orgAutoJoinOnSignup: organizationSettings?.orgAutoJoinOnSignup,
|
||||
},
|
||||
user: {
|
||||
|
||||
@@ -10,9 +10,7 @@ import { uuid } from "short-uuid";
|
||||
import { deriveOrgNameFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
|
||||
import { deriveSlugFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -33,7 +31,6 @@ export const CreateANewPlatformForm = () => {
|
||||
const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||
const defaultOrgOwnerEmail = session.data.user.email ?? "";
|
||||
@@ -53,7 +50,7 @@ const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionConte
|
||||
|
||||
const createOrganizationMutation = trpc.viewer.organizations.create.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
telemetry.event(telemetryEventTypes.org_created);
|
||||
// telemetry.event(telemetryEventTypes.org_created);
|
||||
// This is necessary so that server token has the updated upId
|
||||
await session.update({
|
||||
upId: data.upId,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
@@ -12,8 +12,6 @@ import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/c
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -29,7 +27,6 @@ type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][num
|
||||
const AddNewTeamMembers = ({ isOrg = false }: { isOrg?: boolean }) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const session = useSession();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1;
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery(
|
||||
@@ -37,13 +34,6 @@ const AddNewTeamMembers = ({ isOrg = false }: { isOrg?: boolean }) => {
|
||||
{ enabled: session.status === "authenticated" }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const event = searchParams?.get("event");
|
||||
if (event === "team_created") {
|
||||
telemetry.event(telemetryEventTypes.team_created);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
return <AddNewTeamMembersForm teamId={teamId} isOrg={isOrg} />;
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function getTeamWithMembers(args: {
|
||||
allowSEOIndexing: true,
|
||||
orgProfileRedirectsToVerifiedDomain: true,
|
||||
orgAutoAcceptEmail: true,
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -175,6 +176,7 @@ export async function getTeamWithMembers(args: {
|
||||
allowSEOIndexing: true,
|
||||
orgProfileRedirectsToVerifiedDomain: true,
|
||||
orgAutoAcceptEmail: true,
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,6 +44,11 @@ const userSelect = {
|
||||
name: true,
|
||||
slug: true,
|
||||
bannerUrl: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultScheduleId: true,
|
||||
@@ -105,9 +110,19 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isPrivate: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
disableAutofillOnBookingPage: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
successRedirectUrl: true,
|
||||
|
||||
@@ -142,6 +142,40 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams.
|
||||
* Only checks direct user assignments and direct team memberships — does not traverse parents.
|
||||
* @param userId - The ID of the user to check
|
||||
* @param slug - The feature identifier to check
|
||||
* @returns Promise<boolean> - True if the user has direct or same-level team access to the feature
|
||||
* @throws Error if the feature access check fails
|
||||
*/
|
||||
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) {
|
||||
try {
|
||||
// Prismock limitation: findUnique may fail, use findFirst instead
|
||||
const userHasFeature = await this.prismaClient.userFeatures.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
featureId: slug,
|
||||
},
|
||||
});
|
||||
if (userHasFeature) return true;
|
||||
|
||||
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical(
|
||||
userId,
|
||||
slug
|
||||
);
|
||||
|
||||
return userBelongsToTeamWithFeature;
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private helper method to check if a user belongs to any team that has access to a feature.
|
||||
* @param userId - The ID of the user to check
|
||||
@@ -190,6 +224,40 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user belongs to any direct team that has access to a feature.
|
||||
* This version ignores parent/child team relationships — no recursion or hierarchy traversal.
|
||||
* @param userId - The ID of the user to check
|
||||
* @param slug - The feature identifier to check
|
||||
* @returns Promise<boolean> - True if the user belongs to a team with the feature (direct only)
|
||||
* @throws Error if the query fails
|
||||
* @private
|
||||
*/
|
||||
private async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string) {
|
||||
try {
|
||||
const query = Prisma.sql`
|
||||
SELECT 1
|
||||
FROM "Team" t
|
||||
INNER JOIN "Membership" m ON m."teamId" = t.id
|
||||
WHERE m."userId" = ${userId}
|
||||
AND m.accepted = true
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "TeamFeatures" tf
|
||||
WHERE tf."teamId" = t.id
|
||||
AND tf."featureId" = ${slug}
|
||||
)
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
|
||||
return result.length > 0;
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables a feature for a specific team.
|
||||
* @param teamId - The ID of the team to enable the feature for
|
||||
|
||||
@@ -40,6 +40,7 @@ const log = logger.getSubLogger({ prefix: ["repository/profile"] });
|
||||
const organizationSettingsSelect = {
|
||||
allowSEOIndexing: true,
|
||||
orgProfileRedirectsToVerifiedDomain: true,
|
||||
disableAutofillOnBookingPage: true,
|
||||
} satisfies Prisma.OrganizationSettingsSelect;
|
||||
const organizationSelect = {
|
||||
id: true,
|
||||
@@ -71,7 +72,6 @@ const profileSelect = {
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
|
||||
export enum LookupTarget {
|
||||
User,
|
||||
Profile,
|
||||
@@ -583,7 +583,7 @@ export class ProfileRepository {
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: organizationSelect,
|
||||
select: organizationWithSettingsSelect,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -615,7 +615,7 @@ export class ProfileRepository {
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: organizationSelect,
|
||||
select: organizationWithSettingsSelect,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -498,10 +498,12 @@ export type DelegationCredentialErrorPayloadType = {
|
||||
id: number;
|
||||
type: string;
|
||||
appId: string;
|
||||
delegationCredentialId?: string;
|
||||
};
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
organizationId?: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -261,6 +261,36 @@ export class WebhookRepository implements IWebhookRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findByOrgIdAndTrigger({
|
||||
orgId,
|
||||
triggerEvent,
|
||||
}: {
|
||||
orgId: number;
|
||||
triggerEvent: WebhookTriggerEvents;
|
||||
}): Promise<WebhookSubscriber[]> {
|
||||
return await this.prisma.webhook.findMany({
|
||||
where: {
|
||||
teamId: orgId,
|
||||
platform: false,
|
||||
eventTriggers: { has: triggerEvent },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subscriberUrl: true,
|
||||
payloadTemplate: true,
|
||||
active: true,
|
||||
eventTriggers: true,
|
||||
secret: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
platform: true,
|
||||
time: true,
|
||||
timeUnit: true,
|
||||
appId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getFilteredWebhooksForUser({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { DelegationCredentialErrorPayloadType } from "@calcom/features/webhooks/lib/dto/types";
|
||||
import type { CalendarAppDelegationCredentialError } from "@calcom/lib/CalendarAppError";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -15,24 +16,30 @@ export async function triggerDelegationCredentialErrorWebhook(params: {
|
||||
error: CalendarAppDelegationCredentialError;
|
||||
credential: DelegationCredentialErrorPayloadType["credential"];
|
||||
user: DelegationCredentialErrorPayloadType["user"];
|
||||
orgId?: number | null;
|
||||
delegationCredentialId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { error, credential, user, orgId } = params;
|
||||
const { error, credential, user, delegationCredentialId } = params;
|
||||
|
||||
if (!orgId) {
|
||||
log.debug("Skipping webhook emission - no organization context");
|
||||
const delegationCredential = await DelegationCredentialRepository.findById({
|
||||
id: delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!delegationCredential) {
|
||||
log.debug("No delegation credential found", { delegationCredentialId });
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookRepository = WebhookRepository.getInstance();
|
||||
const webhooks = await webhookRepository.getSubscribers({
|
||||
teamId: orgId,
|
||||
const webhooks = await webhookRepository.findByOrgIdAndTrigger({
|
||||
orgId: delegationCredential.organizationId,
|
||||
triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
|
||||
});
|
||||
|
||||
if (webhooks.length === 0) {
|
||||
log.debug("No webhooks subscribed to DELEGATION_CREDENTIAL_ERROR for organization", { orgId });
|
||||
log.debug("No webhooks subscribed to DELEGATION_CREDENTIAL_ERROR for organization", {
|
||||
orgId: delegationCredential.organizationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,10 +52,12 @@ export async function triggerDelegationCredentialErrorWebhook(params: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
appId: credential.appId || "unknown",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
organizationId: delegationCredential.organizationId,
|
||||
},
|
||||
} satisfies DelegationCredentialErrorPayloadType;
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ describe("getValidRhfFieldName", () => {
|
||||
});
|
||||
|
||||
// So that user can freely add spaces and any other character iteratively and it gets converted to - and he can add more characters.
|
||||
// We don't really care about a hyphen in the end
|
||||
it("should not remove dashes from start and end.", () => {
|
||||
expect(getValidRhfFieldName("hello-there-")).toEqual("hello-there-");
|
||||
// Trailing dashes are removed to ensure valid field names
|
||||
it("should remove trailing dashes but keep leading dashes.", () => {
|
||||
expect(getValidRhfFieldName("hello-there-")).toEqual("hello-there");
|
||||
expect(getValidRhfFieldName("-hello-there")).toEqual("-hello-there");
|
||||
expect(getValidRhfFieldName("$hello-there-")).toEqual("-hello-there-");
|
||||
expect(getValidRhfFieldName("$hello-there-")).toEqual("-hello-there");
|
||||
});
|
||||
|
||||
it("should not remove underscore from start and end.", () => {
|
||||
@@ -31,7 +31,8 @@ describe("getValidRhfFieldName", () => {
|
||||
|
||||
it("should remove unicode and emoji characters", () => {
|
||||
expect(getValidRhfFieldName("Hello 📚🕯️®️ There")).toEqual("Hello---------There");
|
||||
expect(getValidRhfFieldName("📚🕯️®️")).toEqual("-------");
|
||||
// When all characters are emojis/special chars, they become dashes which are then removed
|
||||
expect(getValidRhfFieldName("📚🕯️®️")).toEqual("");
|
||||
});
|
||||
|
||||
it("should keep numbers as is", () => {
|
||||
@@ -39,4 +40,18 @@ describe("getValidRhfFieldName", () => {
|
||||
expect(getValidRhfFieldName("321hello there123")).toEqual("321hello-there123");
|
||||
expect(getValidRhfFieldName("hello$there")).toEqual("hello-there");
|
||||
});
|
||||
|
||||
it("should not modify system field names", () => {
|
||||
// System fields should remain unchanged as they don't contain invalid characters or trailing dashes
|
||||
expect(getValidRhfFieldName("name")).toEqual("name");
|
||||
expect(getValidRhfFieldName("email")).toEqual("email");
|
||||
expect(getValidRhfFieldName("location")).toEqual("location");
|
||||
expect(getValidRhfFieldName("title")).toEqual("title");
|
||||
expect(getValidRhfFieldName("notes")).toEqual("notes");
|
||||
expect(getValidRhfFieldName("guests")).toEqual("guests");
|
||||
expect(getValidRhfFieldName("rescheduleReason")).toEqual("rescheduleReason");
|
||||
expect(getValidRhfFieldName("smsReminderNumber")).toEqual("smsReminderNumber");
|
||||
expect(getValidRhfFieldName("attendeePhoneNumber")).toEqual("attendeePhoneNumber");
|
||||
expect(getValidRhfFieldName("aiAgentCallPhoneNumber")).toEqual("aiAgentCallPhoneNumber");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const getValidRhfFieldName = (fieldName: string) => {
|
||||
// Remember that any transformation that you do here would run on System Field names as well. So, be careful and avoiding doing anything here that would modify the SystemField names.
|
||||
// e.g. SystemField name currently have uppercases in them. So, no need to lowercase unless absolutely needed.
|
||||
return fieldName.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||
return fieldName.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/-+$/, "");
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCollector } from "next-collect/client";
|
||||
|
||||
export const useTelemetry = useCollector;
|
||||
@@ -23,7 +23,6 @@
|
||||
"ics": "^2.37.0",
|
||||
"jimp": "^0.16.1",
|
||||
"lingo.dev": "^0.111.1",
|
||||
"next-collect": "^0.2.1",
|
||||
"rrule": "^2.7.1",
|
||||
"sharp": "0.33.5",
|
||||
"sonner": "^1.7.4",
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function resizeBase64Image(
|
||||
maxSize = 96 * 4,
|
||||
} = opts ?? {};
|
||||
const image = await jimp.read(buffer);
|
||||
if (image.getHeight() !== image.getHeight()) {
|
||||
if (image.getHeight() !== image.getWidth()) {
|
||||
// this could be handled later
|
||||
throw new Error("Image is not a square");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { CollectOpts, EventHandler } from "next-collect";
|
||||
// Importing types so we're not directly importing next/server
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ const BookerPlatformWrapperComponent = (
|
||||
showNoAvailabilityDialog,
|
||||
silentlyHandleCalendarFailures = false,
|
||||
hideEventMetadata = false,
|
||||
defaultPhoneCountry,
|
||||
} = props;
|
||||
const layout = BookerLayouts[view];
|
||||
|
||||
@@ -185,6 +186,7 @@ const BookerPlatformWrapperComponent = (
|
||||
bookingData,
|
||||
isPlatform: true,
|
||||
allowUpdatingUrlParams,
|
||||
defaultPhoneCountry,
|
||||
});
|
||||
useInitializeBookerStoreContext({
|
||||
...props,
|
||||
@@ -201,6 +203,7 @@ const BookerPlatformWrapperComponent = (
|
||||
bookingData,
|
||||
isPlatform: true,
|
||||
allowUpdatingUrlParams,
|
||||
defaultPhoneCountry,
|
||||
});
|
||||
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
|
||||
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
|
||||
@@ -300,7 +303,7 @@ const BookerPlatformWrapperComponent = (
|
||||
Boolean(month) &&
|
||||
Boolean(timezone) &&
|
||||
!event?.isPending &&
|
||||
Boolean(event?.data?.id),
|
||||
event?.data?.id != null,
|
||||
orgSlug: props.entity?.orgSlug ?? undefined,
|
||||
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
|
||||
_silentCalendarFailures: silentlyHandleCalendarFailures,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type React from "react";
|
||||
|
||||
|
||||
import type { BookerProps } from "@calcom/features/bookings/Booker";
|
||||
import type { BookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type { BookerStore, CountryCode } from "@calcom/features/bookings/Booker/store";
|
||||
import type { Timezone, VIEW_TYPE } from "@calcom/features/bookings/Booker/types";
|
||||
import type { BookingCreateBody } from "@calcom/features/bookings/lib/bookingCreateBodySchema";
|
||||
import type { BookingResponse } from "@calcom/platform-libraries";
|
||||
@@ -88,6 +88,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
|
||||
roundRobinHideOrgAndTeam?: boolean;
|
||||
silentlyHandleCalendarFailures?: boolean;
|
||||
hideEventMetadata?: boolean;
|
||||
defaultPhoneCountry?: CountryCode;
|
||||
};
|
||||
|
||||
export type BookerPlatformWrapperAtomPropsForIndividual = BookerPlatformWrapperAtomProps & {
|
||||
@@ -101,4 +102,4 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro
|
||||
isTeamEvent: true;
|
||||
teamId: number;
|
||||
routingFormSearchParams?: RoutingFormSearchParams;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,10 +13,8 @@ import { EventPermissionProvider } from "@calcom/features/pbac/client/context/Ev
|
||||
import { useWorkflowPermission } from "@calcom/features/pbac/client/hooks/useEventPermission";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -165,7 +163,6 @@ const EventTypeWeb = ({
|
||||
const { data: user, isPending: isLoggedInUserPending } = useMeQuery();
|
||||
const isTeamEventTypeDeleted = useRef(false);
|
||||
const leaveWithoutAssigningHosts = useRef(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
|
||||
const [pendingRoute, setPendingRoute] = useState("");
|
||||
const { eventType, locationOptions, team, teamMembers, destinationCalendar } = rest;
|
||||
@@ -445,7 +442,7 @@ const EventTypeWeb = ({
|
||||
onConfirm={(e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(form.getValues());
|
||||
telemetry.event(telemetryEventTypes.slugReplacementAction);
|
||||
// telemetry.event(telemetryEventTypes.slugReplacementAction);
|
||||
setSlugExistsChildrenDialogOpen([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationSettings" ADD COLUMN "disableAutofillOnBookingPage" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."BookingAuditType" AS ENUM ('record_created', 'record_updated', 'record_deleted');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."BookingAuditAction" AS ENUM ('created', 'cancelled', 'accepted', 'rejected', 'pending', 'awaiting_host', 'rescheduled', 'attendee_added', 'attendee_removed', 'reassignment', 'location_changed', 'host_no_show_updated', 'attendee_no_show_updated', 'reschedule_requested');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."AuditActorType" AS ENUM ('user', 'guest', 'attendee', 'system');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."AuditActor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "public"."AuditActorType" NOT NULL,
|
||||
"userUuid" UUID,
|
||||
"attendeeId" INTEGER,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"name" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."BookingAudit" (
|
||||
"id" UUID NOT NULL,
|
||||
"bookingUid" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"type" "public"."BookingAuditType" NOT NULL,
|
||||
"action" "public"."BookingAuditAction" NOT NULL,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"data" JSONB,
|
||||
|
||||
CONSTRAINT "BookingAudit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditActor_email_idx" ON "public"."AuditActor"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditActor_userUuid_idx" ON "public"."AuditActor"("userUuid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditActor_attendeeId_idx" ON "public"."AuditActor"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuditActor_userUuid_key" ON "public"."AuditActor"("userUuid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuditActor_attendeeId_key" ON "public"."AuditActor"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuditActor_email_key" ON "public"."AuditActor"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuditActor_phone_key" ON "public"."AuditActor"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingAudit_actorId_idx" ON "public"."BookingAudit"("actorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingAudit_bookingUid_idx" ON "public"."BookingAudit"("bookingUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingAudit_timestamp_idx" ON "public"."BookingAudit"("timestamp");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Insert system actor with predefined UUID
|
||||
-- This actor is used for system-initiated booking actions
|
||||
INSERT INTO "AuditActor" (
|
||||
id,
|
||||
type,
|
||||
"userUuid",
|
||||
"attendeeId",
|
||||
email,
|
||||
phone,
|
||||
name,
|
||||
"createdAt"
|
||||
)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'system',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
'System',
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `AuditActor` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- Changed the type of `id` on the `AuditActor` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||
- Changed the type of `actorId` on the `BookingAudit` table to UUID.
|
||||
|
||||
*/
|
||||
|
||||
ALTER TABLE "public"."BookingAudit" DROP CONSTRAINT "BookingAudit_actorId_fkey";
|
||||
|
||||
ALTER TABLE "public"."BookingAudit" ALTER COLUMN "actorId" TYPE UUID USING "actorId"::UUID;
|
||||
|
||||
ALTER TABLE "public"."AuditActor" DROP CONSTRAINT "AuditActor_pkey";
|
||||
|
||||
ALTER TABLE "public"."AuditActor" ALTER COLUMN "id" TYPE UUID USING "id"::UUID;
|
||||
|
||||
ALTER TABLE "public"."AuditActor" ADD CONSTRAINT "AuditActor_pkey" PRIMARY KEY ("id");
|
||||
|
||||
ALTER TABLE "public"."BookingAudit" ADD CONSTRAINT "BookingAudit_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "public"."AuditActor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user