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 | ||
|
|
9e10dfd77b | ||
|
|
82782fcf77 | ||
|
|
2477220d2d | ||
|
|
89fe869766 | ||
|
|
1f102bf3b4 | ||
|
|
7a0b8a9eba | ||
|
|
8043505f3a | ||
|
|
919fc3cdd5 | ||
|
|
d7e6334ccb | ||
|
|
06e5c9803a |
@@ -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
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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
const { t } = useLocale();
|
||||
const [view, setView] = useQueryState(
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
|
||||
useEffect(() => {
|
||||
// Force list view on mobile
|
||||
if (isMobile && view !== "list") {
|
||||
setView("list");
|
||||
}
|
||||
}, [isMobile, view, setView]);
|
||||
|
||||
return (
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const newView = value as BookingView;
|
||||
|
||||
// When switching from calendar to list view, remove the dateRange filter
|
||||
if (view === "calendar" && newView === "list") {
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
}
|
||||
|
||||
setView(newView);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "list",
|
||||
label: "",
|
||||
tooltip: t("list_view"),
|
||||
iconLeft: <Icon name="menu" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "calendar",
|
||||
label: "",
|
||||
tooltip: t("calendar_view"),
|
||||
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -14,8 +16,6 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-view";
|
||||
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
const querySchema = z.object({
|
||||
status: z.enum(validStatuses),
|
||||
});
|
||||
@@ -54,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"booking-calendar-view"
|
||||
);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} CTA={<ViewToggleButton />}>
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
isCalendarViewEnabled={isCalendarViewEnabled}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
|
||||
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
|
||||
@@ -14,7 +16,14 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
await checkInsightsPagePermission();
|
||||
const session = await checkInsightsPagePermission();
|
||||
|
||||
return <InsightsRoutingPage />;
|
||||
const { timeZone } = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session?.user.id ?? -1 },
|
||||
select: {
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
return <InsightsRoutingPage timeZone={timeZone} />;
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -376,6 +376,21 @@ export const AppPage = ({
|
||||
</div>
|
||||
{installOrDisconnectAppButton()}
|
||||
|
||||
{slug === "msteams" && (
|
||||
<div className="bg-info mt-4 rounded-md px-4 py-3">
|
||||
<div className="items-start space-x-2.5">
|
||||
<div className="text-info flex items-start">
|
||||
<div>
|
||||
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">{t("msteams_calendar_warning_body")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dependencies &&
|
||||
(!dependencyData.isPending ? (
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -499,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
<BookingActionsDropdown booking={booking} />
|
||||
<BookingActionsDropdown booking={booking} context="booking-list-item" />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -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();
|
||||
|
||||
@@ -1,83 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
{/* Table rows with separator at the beginning */}
|
||||
<div className="divide-subtle divide-y">
|
||||
{/* Month separator skeleton */}
|
||||
<div className="bg-muted rounded-t py-2">
|
||||
<SkeletonItem isHeader={true} />
|
||||
</div>
|
||||
<div className="bg-muted">
|
||||
<SkeletonText className="ml-2 mt-3 h-4 w-28 rounded" />
|
||||
</div>
|
||||
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) {
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[132px_130px_185px_150px_140px_280px] gap-6 px-2",
|
||||
isHeader ? "py-2" : "py-2.5"
|
||||
)}>
|
||||
{/* Date column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-20")} />
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-5 w-16" />
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-28")} />
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-16" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event column - 200px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-16" : "w-full")} />
|
||||
</div>
|
||||
|
||||
{/* Who column - 160px, Avatar group */}
|
||||
<div className="flex items-center -space-x-1">
|
||||
{isHeader && <SkeletonText className="h-4 w-20 rounded" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Actions column - 280px */}
|
||||
<div className="mr-2 flex items-center justify-end gap-2">
|
||||
{isHeader && <SkeletonText className="h-4 w-16 rounded-md" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonText className="h-4 w-16 rounded-md" />
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
<SkeletonText className="h-4 w-8 rounded-md" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
@@ -31,7 +31,6 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { buildBookingLink } from "../../../modules/bookings/lib/buildBookingLink";
|
||||
import type { BookingItemProps } from "../types";
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
@@ -40,29 +39,16 @@ import {
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowEditActions,
|
||||
shouldShowPendingActions,
|
||||
getPendingActions,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
|
||||
interface BookingActionsDropdownProps {
|
||||
booking: BookingItemProps;
|
||||
context: "booking-list-item" | "booking-details-sheet";
|
||||
size?: "xs" | "sm" | "base" | "lg";
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to use a portal for the dropdown menu.
|
||||
* Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context.
|
||||
* @default true
|
||||
*/
|
||||
usePortal?: boolean;
|
||||
}
|
||||
|
||||
export function BookingActionsDropdown({
|
||||
booking,
|
||||
size = "base",
|
||||
className,
|
||||
usePortal = true,
|
||||
}: BookingActionsDropdownProps) {
|
||||
export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -178,13 +164,6 @@ export function BookingActionsDropdown({
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
// Build booking confirmation link
|
||||
const bookingLink = buildBookingLink({
|
||||
bookingUid: booking.uid,
|
||||
allRemainingBookings: isRecurring,
|
||||
email: booking.attendees?.[0]?.email,
|
||||
});
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
@@ -232,10 +211,6 @@ export function BookingActionsDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate showPendingPayment based on payment logic
|
||||
const hasPayment = booking.payment.length > 0;
|
||||
const showPendingPayment = hasPayment;
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
@@ -253,7 +228,7 @@ export function BookingActionsDropdown({
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
@@ -263,20 +238,6 @@ export function BookingActionsDropdown({
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
// Get pending actions (accept/reject)
|
||||
const shouldShowPending = shouldShowPendingActions(actionContext);
|
||||
const basePendingActions = shouldShowPending ? getPendingActions(actionContext) : [];
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
disabled: mutation.isPending,
|
||||
onClick:
|
||||
action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
: action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const shouldShowEdit = shouldShowEditActions(actionContext);
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
@@ -539,26 +500,22 @@ export function BookingActionsDropdown({
|
||||
|
||||
// Check if there are any available actions across all action groups
|
||||
const hasAnyAvailableActions = () => {
|
||||
// Check if any pending action is available
|
||||
const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled);
|
||||
|
||||
// Check if any edit action is available
|
||||
const hasAvailableEditAction = editEventActions.some((action) => !action.disabled);
|
||||
|
||||
// Check if any after event action is available
|
||||
const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled);
|
||||
|
||||
// Check report and cancel actions
|
||||
// For booking-list-item context, only check edit and after event actions
|
||||
if (context === "booking-list-item") {
|
||||
return hasAvailableEditAction || hasAvailableAfterAction;
|
||||
}
|
||||
|
||||
// For booking-details-sheet context, also check report and cancel actions
|
||||
const isReportAvailable = !reportActionWithHandler.disabled;
|
||||
const isCancelAvailable = !cancelEventAction.disabled;
|
||||
|
||||
return (
|
||||
hasAvailablePendingAction ||
|
||||
hasAvailableEditAction ||
|
||||
hasAvailableAfterAction ||
|
||||
isReportAvailable ||
|
||||
isCancelAvailable
|
||||
);
|
||||
return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable;
|
||||
};
|
||||
|
||||
// Don't render dropdown if no actions are available
|
||||
@@ -566,150 +523,94 @@ export function BookingActionsDropdown({
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
// Conditional portal wrapper to avoid portal when inside Sheet/Dialog
|
||||
const ConditionalPortal = usePortal
|
||||
? DropdownMenuPortal
|
||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const menuContent = (
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown modal={false}>
|
||||
<Dropdown modal={context === "booking-details-sheet" ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size={size}
|
||||
StartIcon="ellipsis"
|
||||
className={className}
|
||||
// Prevent click from bubbling to parent row/container click handlers
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Button type="button" color="secondary" variant="icon" size={size} StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<ConditionalPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="rounded-lg">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="external-link"
|
||||
href={bookingLink}
|
||||
target="_blank"
|
||||
data-testid="view-booking"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{t("view")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pendingActions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
|
||||
{pendingActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reportActionWithHandler.onClick?.();
|
||||
}}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEventAction.onClick?.(e);
|
||||
}}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</ConditionalPortal>
|
||||
{context === "booking-details-sheet" ? (
|
||||
menuContent
|
||||
) : (
|
||||
<DropdownMenuPortal>{menuContent}</DropdownMenuPortal>
|
||||
)}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -137,18 +137,18 @@ describe("Booking Actions", () => {
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "confirm",
|
||||
bookingId: 1,
|
||||
label: "confirm",
|
||||
icon: "check",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return reject action only for non-pending booking", () => {
|
||||
@@ -167,8 +167,8 @@ describe("Booking Actions", () => {
|
||||
});
|
||||
const actions = getPendingActions(context);
|
||||
|
||||
expect(actions[0].label).toBe("confirm_all");
|
||||
expect(actions[1].label).toBe("reject_all");
|
||||
expect(actions[0].label).toBe("reject_all");
|
||||
expect(actions[1].label).toBe("confirm_all");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,14 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
|
||||
context;
|
||||
|
||||
const actions: ActionType[] = [];
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
},
|
||||
];
|
||||
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
|
||||
@@ -52,13 +59,6 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -36,12 +36,12 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow data-testid="reschedule-dialog">
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<DialogContent enableOverflow>
|
||||
<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";
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
// Initialize with actual media query value on client
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
@@ -16,8 +10,8 @@ const useMediaQuery = (query: string) => {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { VisibilityState } from "@tanstack/react-table";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface BuildFilterColumnsParams {
|
||||
t: (key: string) => string;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of filter columns that should be hidden in the UI
|
||||
* These columns are used for filtering but not displayed
|
||||
*/
|
||||
const FILTER_COLUMN_IDS = [
|
||||
"eventTypeId",
|
||||
"teamId",
|
||||
"userId",
|
||||
"attendeeName",
|
||||
"attendeeEmail",
|
||||
"dateRange",
|
||||
"bookingUid",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Gets the column visibility state for filter columns
|
||||
* All filter columns are hidden by default as they only provide filtering functionality
|
||||
*/
|
||||
export function getFilterColumnVisibility(): VisibilityState {
|
||||
return Object.fromEntries(FILTER_COLUMN_IDS.map((id) => [id, false]));
|
||||
}
|
||||
|
||||
export function buildFilterColumns({ t, permissions, status }: BuildFilterColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.id : null), {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.team?.id ?? null : null), {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.user?.id ?? null : null), {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.uid : null), {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { JoinMeetingButton } from "../components/JoinMeetingButton";
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface PendingActionHandlers {
|
||||
onAccept: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
onReject: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BuildListDisplayColumnsParams {
|
||||
t: (key: string) => string;
|
||||
user?: {
|
||||
id?: number;
|
||||
email?: string;
|
||||
timeZone?: string;
|
||||
timeFormat?: number | null;
|
||||
};
|
||||
pendingActionHandlers: PendingActionHandlers;
|
||||
}
|
||||
|
||||
// Maximum number of attendees to display before showing "..." button
|
||||
const MAX_DISPLAYED_ATTENDEES = 3;
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
// Component for individual attendee avatar with dropdown
|
||||
const IndividualAttendee = ({
|
||||
attendee,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendee: AttendeeProps;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none" data-testid="guest">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={getPlaceholderAvatar(null, attendee.name)}
|
||||
alt={attendee.name || attendee.email}
|
||||
title={attendee.name || attendee.email}
|
||||
className="border-subtle cursor-pointer hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-emphasis px-3 py-2 text-sm font-medium">
|
||||
{attendee.name || attendee.email}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${attendee.email}`}
|
||||
onClick={() => {
|
||||
setOpenDropdown(false);
|
||||
}}>
|
||||
<a href={`mailto:${attendee.email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const smsCalEmail = isSmsCalEmail(attendee.email);
|
||||
copyToClipboard(smsCalEmail ? attendee.email : attendee.phoneNumber ?? "");
|
||||
setOpenDropdown(false);
|
||||
showToast(smsCalEmail ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenDropdown(false);
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
|
||||
});
|
||||
}}
|
||||
StartIcon={attendee.noShow ? "eye" : "eye-off"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for the "..." dropdown that shows all attendees with action buttons
|
||||
const AllAttendeesDropdown = ({
|
||||
attendees,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleEmail = (email: string) => {
|
||||
window.location.href = `mailto:${email}`;
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
const handleCopy = (attendee: AttendeeProps) => {
|
||||
const isSmsAttendee = isSmsCalEmail(attendee.email);
|
||||
copyToClipboard(isSmsAttendee ? attendee.email : attendee.phoneNumber ?? "");
|
||||
showToast(isSmsAttendee ? t("email_copied") : t("phone_number_copied"), "success");
|
||||
};
|
||||
|
||||
const handleToggleNoShow = (attendee: AttendeeProps) => {
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkAllAsNoShow = () => {
|
||||
const allAttendees = attendees.map((attendee) => ({
|
||||
noShow: true,
|
||||
email: attendee.email,
|
||||
}));
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: allAttendees,
|
||||
});
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="ellipsis"
|
||||
className="px-1.5"
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-80" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("attendees")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{attendees.map((attendee) => (
|
||||
<div key={attendee.id} className="hover:bg-muted flex items-center justify-between px-2 py-2">
|
||||
<div className="flex-1 truncate text-sm">
|
||||
<div className="font-medium">{attendee.name || attendee.email}</div>
|
||||
{attendee.name && <div className="text-subtle text-xs">{attendee.email}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="mail"
|
||||
className="px-1.5"
|
||||
tooltip={t("email")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEmail(attendee.email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="clipboard"
|
||||
className="px-1.5"
|
||||
tooltip={t("copy")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(attendee);
|
||||
}}
|
||||
/>
|
||||
{isBookingInPast && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={attendee.noShow ? "eye" : "eye-off"}
|
||||
className="px-1.5"
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
tooltip={attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleNoShow(attendee);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isBookingInPast && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
data-testid="update-no-show"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAllAsNoShow();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// Component that renders the attendees with appropriate dropdown
|
||||
const AttendeeCell = ({
|
||||
attendees,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
// Show "..." button if there are more attendees than the max display count
|
||||
const hasMoreThanMax = attendees.length > MAX_DISPLAYED_ATTENDEES;
|
||||
const displayedAttendees = attendees.slice(0, MAX_DISPLAYED_ATTENDEES);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{displayedAttendees.map((attendee, idx) => (
|
||||
<div key={attendee.id} className={idx > 0 ? "-ml-1" : ""}>
|
||||
<IndividualAttendee attendee={attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</div>
|
||||
))}
|
||||
{hasMoreThanMax && (
|
||||
<AllAttendeesDropdown
|
||||
attendees={attendees}
|
||||
bookingUid={bookingUid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "date",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("date")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "time",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("time")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const startTime = dayjs(row.booking.startTime).tz(user?.timeZone);
|
||||
const endTime = dayjs(row.booking.endTime).tz(user?.timeZone);
|
||||
return (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "event",
|
||||
minSize: 200,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("event")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-emphasis flex-1 truncate text-sm font-medium" data-testid="title">
|
||||
{row.booking.title}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "who",
|
||||
size: 160,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("who")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const isBookingInPast = new Date(row.booking.endTime) < new Date();
|
||||
|
||||
const attendees = row.booking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AttendeeCell
|
||||
attendees={attendees}
|
||||
bookingUid={row.booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "team",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("team")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
if (row.booking.eventType.team) {
|
||||
return (
|
||||
<Badge variant="gray" size="sm">
|
||||
{row.booking.eventType.team.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
size: 280,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => null,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
const booking = row.booking;
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isCancelled = booking.status === "CANCELLED";
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isAccepted = booking.status === "ACCEPTED";
|
||||
|
||||
// Determine if we should show pending actions
|
||||
const shouldShowPendingActions = isPending && isUpcoming && !isCancelled;
|
||||
|
||||
// Determine which buttons to show based on payment status
|
||||
const hasPayment = Array.isArray(booking.payment) && booking.payment.length > 0;
|
||||
const isPaid = booking.paid;
|
||||
const shouldShowAccept = shouldShowPendingActions && (!hasPayment || isPaid);
|
||||
const shouldShowReject = shouldShowPendingActions;
|
||||
|
||||
// Show join meeting button only for upcoming accepted/confirmed bookings
|
||||
const shouldShowJoinButton = isAccepted && isUpcoming && !isCancelled && !isRejected;
|
||||
|
||||
// Determine if this is a recurring booking for the label
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = row.type === "data" && row.recurringInfo !== undefined;
|
||||
const isTabUnconfirmed = booking.status === "PENDING";
|
||||
const showAllLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{shouldShowReject && (
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="ban"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="reject"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onReject(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("reject_all") : t("reject")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowAccept && (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="check"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onAccept(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("confirm_all") : t("confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowJoinButton && (
|
||||
<JoinMeetingButton
|
||||
size="sm"
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
)}
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: row.type === "data" ? row.recurringInfo : undefined,
|
||||
loggedInUser: {
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
},
|
||||
isToday: row.type === "data" ? row.isToday : false,
|
||||
}}
|
||||
className="px-1.5"
|
||||
size="sm"
|
||||
/>
|
||||
</BookingActionsStoreProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { buildBookingLink } from "../lib/buildBookingLink";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
|
||||
|
||||
@@ -80,6 +80,14 @@ function BookingDetailsSheetInner({
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
|
||||
// Build booking confirmation link
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const bookingLink = buildBookingLink({
|
||||
bookingUid: booking.uid,
|
||||
allRemainingBookings: isRecurring,
|
||||
email: booking.attendees?.[0]?.email,
|
||||
});
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (booking.status) {
|
||||
case "ACCEPTED":
|
||||
@@ -100,6 +108,14 @@ function BookingDetailsSheetInner({
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
// Get conference link info for Join button
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
location: booking.location,
|
||||
videoCallUrl: bookingMetadata?.videoCallUrl,
|
||||
t,
|
||||
bookingStatus: booking.status,
|
||||
});
|
||||
|
||||
const recurringInfo =
|
||||
booking.recurringEventId && booking.eventType?.recurringEvent
|
||||
? {
|
||||
@@ -167,8 +183,6 @@ function BookingDetailsSheetInner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RescheduleRequestMessage booking={booking} />
|
||||
|
||||
<WhoSection booking={booking} />
|
||||
|
||||
<WhereSection booking={booking} meta={bookingMetadata} />
|
||||
@@ -189,34 +203,49 @@ function BookingDetailsSheetInner({
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
|
||||
<Button color="secondary" StartIcon="x" onClick={onClose}>
|
||||
{t("close")}
|
||||
<div className="flex w-full flex-row items-center justify-end gap-2 px-4 pb-4 pt-4">
|
||||
{isLocationURL && locationToDisplay && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider.label })
|
||||
: t("join_meeting")}
|
||||
</Button>
|
||||
<div className="border-subtle h-3 w-px border-r" />
|
||||
</>
|
||||
)}
|
||||
<Button color="secondary" size="sm" EndIcon="external-link" href={bookingLink} target="_blank">
|
||||
{t("view")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
/>
|
||||
</div>
|
||||
<BookingActionsDropdown
|
||||
context="booking-details-sheet"
|
||||
size="sm"
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -224,20 +253,6 @@ function BookingDetailsSheetInner({
|
||||
);
|
||||
}
|
||||
|
||||
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
|
||||
@@ -1,62 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDataTable,
|
||||
useFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ColumnFilterType,
|
||||
} from "@calcom/features/data-table";
|
||||
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarProps = {
|
||||
type BookingsCalendarViewProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending?: boolean;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
setCurrentWeekStart: (
|
||||
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
|
||||
) => Promise<URLSearchParams>;
|
||||
bookings: BookingOutput[];
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
export function BookingsCalendar({
|
||||
table,
|
||||
isPending = false,
|
||||
onOpenDetails,
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
bookings,
|
||||
}: BookingsCalendarProps) {
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
|
||||
const { rows } = table.getRowModel();
|
||||
const { updateFilter } = useDataTable();
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const bookings = useMemo(() => {
|
||||
return rows
|
||||
.filter((row) => row.original.type === "data")
|
||||
.map((row) => (row.original.type === "data" ? row.original.booking : null))
|
||||
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
|
||||
}, [rows]);
|
||||
|
||||
const handleWeekStartChange = useCallback(
|
||||
(newWeekStart: dayjs.Dayjs) => {
|
||||
setCurrentWeekStart(newWeekStart);
|
||||
|
||||
// Always set the date range to match the current week exactly
|
||||
const startDate = newWeekStart.toDate();
|
||||
const endDate = newWeekStart.add(6, "day").toDate();
|
||||
|
||||
if (!dateRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
|
||||
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
|
||||
|
||||
const needsStartUpdate = !rangeStart || startDate < rangeStart;
|
||||
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
|
||||
|
||||
if (!needsStartUpdate && !needsEndUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStartDate = needsStartUpdate ? startDate : rangeStart;
|
||||
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
|
||||
|
||||
updateFilter("dateRange", {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: {
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate: newStartDate.toISOString(),
|
||||
endDate: newEndDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFilter, setCurrentWeekStart]
|
||||
[dateRange, updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -76,8 +103,6 @@ export function BookingsCalendar({
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
isPending={isPending}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { useBookingCursor } from "../hooks/useBookingCursor";
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsCalendar } from "./BookingsCalendar";
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
interface BookingsCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data: RowData[];
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function BookingsCalendarContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending = false,
|
||||
}: BookingsCalendarContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const onOpenDetails = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status });
|
||||
}, [t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data and filter by current week
|
||||
const bookings = useMemo(() => {
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
|
||||
return data
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking)
|
||||
.filter((booking) => {
|
||||
const bookingStart = dayjs(booking.startTime);
|
||||
return (
|
||||
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
|
||||
bookingStart.isBefore(weekEnd.endOf("day"))
|
||||
);
|
||||
});
|
||||
}, [data, currentWeekStart]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
|
||||
}, [selectedBookingId, bookings]);
|
||||
|
||||
const bookingNavigation = useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookingsCalendar
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
onOpenDetails={onOpenDetails}
|
||||
currentWeekStart={currentWeekStart}
|
||||
setCurrentWeekStart={setCurrentWeekStart}
|
||||
bookings={bookings}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBookingId(null)}
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
onPrevious={bookingNavigation.onPrevious}
|
||||
hasPrevious={bookingNavigation.hasPrevious}
|
||||
onNext={bookingNavigation.onNext}
|
||||
hasNext={bookingNavigation.hasNext}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,16 +18,12 @@ type BookingsCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
isPending?: boolean;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
isPending = false,
|
||||
onOpenDetails,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const { t } = useLocale();
|
||||
const { timezone } = useTimePreferences();
|
||||
@@ -48,10 +44,8 @@ export function BookingsCalendarView({
|
||||
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
|
||||
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
|
||||
|
||||
// Intentionally only runs on mount to trigger the initial currentWeekStart
|
||||
useEffect(() => {
|
||||
onWeekStartChange(currentWeekStart);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const events = useMemo<CalendarEvent[]>(() => {
|
||||
@@ -84,7 +78,6 @@ export function BookingsCalendarView({
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
bookingId: booking.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -116,7 +109,6 @@ export function BookingsCalendarView({
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
@@ -149,12 +141,7 @@ export function BookingsCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
onEventClick={(event) => {
|
||||
const bookingId = event.options?.bookingId;
|
||||
if (bookingId) {
|
||||
onOpenDetails(bookingId);
|
||||
}
|
||||
}}
|
||||
onEventClick={(_event) => {}}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { Row, Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
@@ -25,49 +23,22 @@ type BookingsListViewProps = {
|
||||
table: ReactTable<RowData>;
|
||||
isPending: boolean;
|
||||
totalRowCount?: number;
|
||||
onOpenDetails: (bookingId: number) => void;
|
||||
};
|
||||
|
||||
export function BookingsList({
|
||||
status,
|
||||
table,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
onOpenDetails,
|
||||
}: BookingsListViewProps) {
|
||||
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Row<RowData>) => {
|
||||
if (!isSeparatorRow(row.original)) {
|
||||
onOpenDetails(row.original.booking.id);
|
||||
}
|
||||
},
|
||||
[onOpenDetails]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableWrapper
|
||||
className="mb-6"
|
||||
table={table}
|
||||
testId={`${status}-bookings`}
|
||||
bodyTestId="bookings"
|
||||
rowTestId={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return "booking-item";
|
||||
}}
|
||||
rowDataAttributes={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return {
|
||||
"data-today": String(row.original.isToday),
|
||||
};
|
||||
}}
|
||||
headerClassName="hidden"
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
variant="default"
|
||||
variant="compact"
|
||||
paginationMode="standard"
|
||||
onRowMouseclick={handleRowClick}
|
||||
hideSeparatorsOnSort={true}
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { buildListDisplayColumns } from "../columns/listColumns";
|
||||
import { useBookingCursor } from "../hooks/useBookingCursor";
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsList } from "./BookingsList";
|
||||
|
||||
interface BookingsListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data: RowData[];
|
||||
isPending: boolean;
|
||||
totalRowCount?: number;
|
||||
}
|
||||
|
||||
export function BookingsListContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
}: BookingsListContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
|
||||
|
||||
// Filter out separator rows and extract bookings
|
||||
const bookings = useMemo(() => {
|
||||
return data
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [data]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
|
||||
}, [selectedBookingId, bookings]);
|
||||
|
||||
const bookingNavigation = useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
});
|
||||
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [pendingRejection, setPendingRejection] = useState<{
|
||||
bookingId: number;
|
||||
recurringEventId?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const confirmMutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
setRejectionReason("");
|
||||
setPendingRejection(null);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = useCallback(
|
||||
(bookingId: number, recurringEventId?: string | null) => {
|
||||
confirmMutation.mutate({
|
||||
bookingId,
|
||||
confirmed: true,
|
||||
reason: "",
|
||||
...(recurringEventId && { recurringEventId }),
|
||||
});
|
||||
},
|
||||
[confirmMutation]
|
||||
);
|
||||
|
||||
const handleReject = useCallback((bookingId: number, recurringEventId?: string | null) => {
|
||||
setPendingRejection({ bookingId, recurringEventId });
|
||||
setRejectionDialogIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRejection = useCallback(() => {
|
||||
if (!pendingRejection) return;
|
||||
|
||||
confirmMutation.mutate({
|
||||
bookingId: pendingRejection.bookingId,
|
||||
confirmed: false,
|
||||
reason: rejectionReason,
|
||||
...(pendingRejection.recurringEventId && { recurringEventId: pendingRejection.recurringEventId }),
|
||||
});
|
||||
}, [pendingRejection, rejectionReason, confirmMutation]);
|
||||
|
||||
const onOpenDetails = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const filterCols = buildFilterColumns({ t, permissions, status });
|
||||
const listCols = buildListDisplayColumns({
|
||||
t,
|
||||
user,
|
||||
pendingActionHandlers: {
|
||||
onAccept: handleAccept,
|
||||
onReject: handleReject,
|
||||
isLoading: confirmMutation.isPending,
|
||||
},
|
||||
});
|
||||
return [...filterCols, ...listCols];
|
||||
}, [t, permissions, status, user, handleAccept, handleReject, confirmMutation.isPending]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
columnPinning: {
|
||||
right: ["actions"],
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const handleRejectionDialogChange = useCallback((open: boolean) => {
|
||||
setRejectionDialogIsOpen(open);
|
||||
if (!open) {
|
||||
setRejectionReason("");
|
||||
setPendingRejection(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={confirmMutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={handleConfirmRejection}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BookingsList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBookingId(null)}
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
onPrevious={bookingNavigation.onPrevious}
|
||||
hasPrevious={bookingNavigation.hasPrevious}
|
||||
onNext={bookingNavigation.onNext}
|
||||
hasNext={bookingNavigation.hasNext}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useJoinableLocation } from "./useJoinableLocation";
|
||||
|
||||
interface JoinMeetingButtonProps {
|
||||
location: string | null;
|
||||
metadata?: unknown;
|
||||
bookingStatus: BookingStatus;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function JoinMeetingButton({
|
||||
location,
|
||||
metadata,
|
||||
bookingStatus,
|
||||
size = "base",
|
||||
color = "secondary",
|
||||
className,
|
||||
onClick,
|
||||
}: JoinMeetingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
const { isJoinable, locationToDisplay, provider } = useJoinableLocation({
|
||||
location,
|
||||
metadata,
|
||||
bookingStatus,
|
||||
t,
|
||||
});
|
||||
|
||||
if (!isJoinable || !locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames("flex items-center gap-2", className)}
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
interface UseJoinableLocationParams {
|
||||
location: string | null;
|
||||
metadata?: unknown;
|
||||
bookingStatus: BookingStatus;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to determine if a booking location is joinable (i.e., has a clickable URL).
|
||||
* This hook is used to show/hide the join meeting button and related UI elements.
|
||||
*
|
||||
* @returns An object containing:
|
||||
* - `isJoinable`: Whether the location is joinable (has a valid URL)
|
||||
* - `locationToDisplay`: The location URL or text to display
|
||||
* - `provider`: Provider information (label, iconUrl, etc.)
|
||||
* - `isLocationURL`: Whether the location is a URL
|
||||
*/
|
||||
export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) {
|
||||
const bookingMetadata = useMemo(() => {
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null);
|
||||
return parsedMetadata.success ? parsedMetadata.data : null;
|
||||
}, [metadata]);
|
||||
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
location,
|
||||
videoCallUrl: bookingMetadata?.videoCallUrl,
|
||||
t,
|
||||
bookingStatus,
|
||||
});
|
||||
|
||||
const isJoinable = isLocationURL && !!locationToDisplay;
|
||||
|
||||
return {
|
||||
isJoinable,
|
||||
locationToDisplay,
|
||||
provider,
|
||||
isLocationURL,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +1,43 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import type { BookingOutput } from "../types";
|
||||
import type { RowData } from "../types";
|
||||
|
||||
function isDataRow(row: RowData): row is Extract<RowData, { type: "data" }> {
|
||||
return row.type === "data";
|
||||
}
|
||||
|
||||
export function useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
}: {
|
||||
bookings: BookingOutput[];
|
||||
bookings: RowData[];
|
||||
selectedBookingId: number | null;
|
||||
setSelectedBookingId: (bookingId: number | null) => void;
|
||||
}) {
|
||||
const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]);
|
||||
|
||||
const currentIndex = useMemo(
|
||||
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
|
||||
[bookings, selectedBookingId]
|
||||
() => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId),
|
||||
[bookingRows, selectedBookingId]
|
||||
);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (currentIndex >= 1) {
|
||||
setSelectedBookingId(bookings[currentIndex - 1].id);
|
||||
setSelectedBookingId(bookingRows[currentIndex - 1].booking.id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookingRows, currentIndex, setSelectedBookingId]);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
|
||||
setSelectedBookingId(bookings[currentIndex + 1].id);
|
||||
if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) {
|
||||
setSelectedBookingId(bookingRows[currentIndex + 1].booking.id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookingRows, currentIndex, setSelectedBookingId]);
|
||||
|
||||
return {
|
||||
onPrevious,
|
||||
onNext,
|
||||
hasPrevious: currentIndex > 0,
|
||||
hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0,
|
||||
hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export function useSelectedBookingId() {
|
||||
return useQueryState("selectedId", {
|
||||
defaultValue: null,
|
||||
parse: (value) => (value ? parseInt(value, 10) : null),
|
||||
serialize: (value) => (value ? String(value) : ""),
|
||||
clearOnDefault: true,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createParser } from "nuqs";
|
||||
|
||||
export const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
export type BookingView = "list" | "calendar";
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
@@ -19,6 +18,8 @@ export type RowData =
|
||||
isToday: boolean;
|
||||
recurringInfo?: RecurringInfo;
|
||||
}
|
||||
| SeparatorRow;
|
||||
| {
|
||||
type: "today" | "next";
|
||||
};
|
||||
|
||||
export type BookingListingStatus = (typeof validStatuses)[number];
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
type SystemFilterSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
@@ -25,26 +27,24 @@ import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import BookingListItem from "@components/booking/BookingListItem";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import { viewParser } from "~/bookings/lib/viewParser";
|
||||
|
||||
import { BookingDetailsSheet } from "../components/BookingDetailsSheet";
|
||||
import { BookingsCalendar } from "../components/BookingsCalendar";
|
||||
import { BookingsList } from "../components/BookingsList";
|
||||
import { useBookingCursor } from "../hooks/useBookingCursor";
|
||||
import type { RowData, BookingOutput } from "../types";
|
||||
|
||||
const BookingsListContainer = dynamic(() =>
|
||||
import("../components/BookingsListContainer").then((mod) => ({ default: mod.BookingsListContainer }))
|
||||
);
|
||||
const BookingsCalendarContainer = dynamic(() =>
|
||||
import("../components/BookingsCalendarContainer").then((mod) => ({
|
||||
default: mod.BookingsCalendarContainer,
|
||||
}))
|
||||
);
|
||||
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
isCalendarViewEnabled: boolean;
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
@@ -86,12 +86,27 @@ export default function Bookings(props: BookingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const [view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
const viewParser = createParser({
|
||||
parse: (value: string) => {
|
||||
if (value === "calendar") return "calendar";
|
||||
return "list";
|
||||
},
|
||||
serialize: (value: "list" | "calendar") => value,
|
||||
});
|
||||
|
||||
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
|
||||
const [_view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = isCalendarViewEnabled ? _view : "list";
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedBookingId, setSelectedBookingId] = useQueryState("selectedId", {
|
||||
defaultValue: null,
|
||||
parse: (value) => (value ? parseInt(value, 10) : null),
|
||||
serialize: (value) => (value ? String(value) : ""),
|
||||
clearOnDefault: true,
|
||||
});
|
||||
|
||||
const tabs: HorizontalTabItemProps[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
@@ -141,47 +156,161 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
// Only apply pagination for list view, calendar view needs all bookings
|
||||
const shouldPaginate = view === "list";
|
||||
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
|
||||
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit: queryLimit,
|
||||
offset: queryOffset,
|
||||
filters: {
|
||||
status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
const query = trpc.viewer.bookings.get.useQuery({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
if (props.row.original.type === "data") {
|
||||
const { booking, recurringInfo, isToday } = props.row.original;
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
// uncomment this line to enable BookingDetailsSheet
|
||||
// onClick={() => setSelectedBookingId(booking.id)}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
} else if (props.row.original.type === "today") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("today")}
|
||||
</p>
|
||||
);
|
||||
} else if (props.row.original.type === "next") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("next")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, permissions.canReadOthersBookings]);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
|
||||
const groupedBookings = useMemo(() => {
|
||||
if (!query.data?.bookings) {
|
||||
return { today: [], currentMonth: [], monthBuckets: {} };
|
||||
}
|
||||
|
||||
const now = dayjs().tz(user?.timeZone);
|
||||
const today = now.format("YYYY-MM-DD");
|
||||
const currentMonthStart = now.startOf("month");
|
||||
const currentMonthEnd = now.endOf("month");
|
||||
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
@@ -196,86 +325,95 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
return false;
|
||||
}
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const todayBookings: RowData[] = [];
|
||||
const currentMonthBookings: RowData[] = [];
|
||||
const monthBuckets: Record<string, RowData[]> = {}; // Key format: "YYYY-MM"
|
||||
|
||||
query.data.bookings.filter(filterBookings).forEach((booking) => {
|
||||
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
|
||||
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
|
||||
const monthKey = bookingDate.format("YYYY-MM");
|
||||
|
||||
const rowData: RowData = {
|
||||
return (
|
||||
query.data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data",
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: query.data?.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
),
|
||||
};
|
||||
|
||||
if (bookingDateStr === today) {
|
||||
todayBookings.push(rowData);
|
||||
} else if (bookingDate.isAfter(currentMonthStart) && bookingDate.isBefore(currentMonthEnd)) {
|
||||
currentMonthBookings.push(rowData);
|
||||
} else if (bookingDate.isAfter(currentMonthEnd)) {
|
||||
if (!monthBuckets[monthKey]) {
|
||||
monthBuckets[monthKey] = [];
|
||||
}
|
||||
monthBuckets[monthKey].push(rowData);
|
||||
} else if (bookingDate.isBefore(currentMonthStart)) {
|
||||
// Handle bookings from months before the current month
|
||||
if (!monthBuckets[monthKey]) {
|
||||
monthBuckets[monthKey] = [];
|
||||
}
|
||||
monthBuckets[monthKey].push(rowData);
|
||||
}
|
||||
});
|
||||
|
||||
return { today: todayBookings, currentMonth: currentMonthBookings, monthBuckets };
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [query.data, status, user?.timeZone]);
|
||||
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()];
|
||||
}, [groupedBookings]);
|
||||
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return groupedBookings.today;
|
||||
}, [groupedBookings]);
|
||||
return (
|
||||
query.data?.bookings
|
||||
.filter(
|
||||
(booking: BookingOutput) =>
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
)
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: query.data?.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
),
|
||||
isToday: true,
|
||||
})) ?? []
|
||||
);
|
||||
}, [query.data, user?.timeZone]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
const merged: RowData[] = [];
|
||||
|
||||
// Add Today section
|
||||
if (groupedBookings.today.length > 0) {
|
||||
merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today);
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
}
|
||||
|
||||
// Add Current Month section (rest of this month, excluding today)
|
||||
if (groupedBookings.currentMonth.length > 0) {
|
||||
merged.push({ type: "separator", label: t("this_month") }, ...groupedBookings.currentMonth);
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
}
|
||||
|
||||
// Add individual month sections
|
||||
const sortedMonthKeys = Object.keys(groupedBookings.monthBuckets).sort();
|
||||
sortedMonthKeys.forEach((monthKey) => {
|
||||
const bookings = groupedBookings.monthBuckets[monthKey];
|
||||
if (bookings.length > 0) {
|
||||
const monthLabel = dayjs(monthKey, "YYYY-MM").format("MMMM YYYY");
|
||||
merged.push({ type: "separator", label: monthLabel }, ...bookings);
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, [groupedBookings, status, t, flatData]);
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
const dataRow = finalData.find(
|
||||
(row): row is Extract<RowData, { type: "data" }> =>
|
||||
row.type === "data" && row.booking.id === selectedBookingId
|
||||
);
|
||||
return dataRow?.booking ?? null;
|
||||
}, [selectedBookingId, finalData]);
|
||||
|
||||
const bookingNavigation = useBookingCursor({
|
||||
bookings: finalData,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
});
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const isPending = query.isPending;
|
||||
const totalRowCount = query.data?.totalCount;
|
||||
@@ -293,32 +431,55 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{query.status === "error" ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
<>
|
||||
<div className="grid w-full items-center gap-2 pb-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
{view === "list" ? (
|
||||
<BookingsListContainer
|
||||
<BookingsList
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
) : (
|
||||
<BookingsCalendarContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
isPending={isPending}
|
||||
/>
|
||||
<BookingsCalendar status={status} table={table} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBookingId(null)}
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
onPrevious={bookingNavigation.onPrevious}
|
||||
hasPrevious={bookingNavigation.hasPrevious}
|
||||
onNext={bookingNavigation.onNext}
|
||||
hasNext={bookingNavigation.hasNext}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ import {
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsRoutingFormResponsesPage() {
|
||||
export default function InsightsRoutingFormResponsesPage({ timeZone }: { timeZone: string }) {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
return (
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments}>
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} timeZone={timeZone}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
@@ -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.8.13",
|
||||
"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,
|
||||
});
|
||||
@@ -1,8 +1,26 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, no-undef */
|
||||
const glob = require("glob");
|
||||
const { nextJsOrgRewriteConfig } = require("./getNextjsOrgRewriteConfig");
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
// Pages found here are excluded from redirects in beforeFiles in next.config.js
|
||||
let pages = (exports.pages = glob
|
||||
|
||||
// Top-level route names that are explicitly allowed for org rewrite (whitelist)
|
||||
|
||||
const topLevelRouteNamesWhitelistedForRewrite = exports.topLevelRouteNamesWhitelistedForRewrite = [
|
||||
// We don't allow all dashboard route names to be used as slug because people are probably accustomed to access links like acme.cal.com/workflows, acme.cal.com/event-types etc.
|
||||
// So, we carefully allow, what is absolutely needed.
|
||||
// Allowed to be a team/user slug in organization because onboarding is a common team name
|
||||
'onboarding',
|
||||
]
|
||||
|
||||
/**
|
||||
* Extracts top-level route names from all pages/app files and excludes them from org rewrite.
|
||||
* For example: /abc/def/ghi -> 'abc'
|
||||
*
|
||||
* These top-level route names are excluded from rewrites in beforeFiles in next.config.js
|
||||
* to prevent conflicts with organization slug rewrites.
|
||||
*/
|
||||
/* eslint-disable no-undef */
|
||||
let topLevelRoutesExcludedFromOrgRewrite = exports.topLevelRoutesExcludedFromOrgRewrite = glob
|
||||
.sync(
|
||||
"{pages,app,app/(booking-page-wrapper),app/(use-page-wrapper),app/(use-page-wrapper)/(main-nav)}/**/[^_]*.{tsx,js,ts}",
|
||||
{
|
||||
@@ -11,11 +29,14 @@ let pages = (exports.pages = glob
|
||||
)
|
||||
.map((filename) =>
|
||||
filename
|
||||
// Remove the directory prefix (pages/, app/ and route group folders.)
|
||||
.replace(
|
||||
/^(app\/\(use-page-wrapper\)\/\(main-nav\)|app\/\(use-page-wrapper\)|app\/\(booking-page-wrapper\)|pages|app)\//,
|
||||
""
|
||||
)
|
||||
// Remove file extensions
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
// Extract only the top-level route name (e.g., /abc/def -> abc)
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter(
|
||||
@@ -34,7 +55,10 @@ let pages = (exports.pages = glob
|
||||
"ShellMainAppDir",
|
||||
"ShellMainAppDirBackButton",
|
||||
].some((prefix) => v.startsWith(prefix))
|
||||
));
|
||||
)
|
||||
.filter((page) => {
|
||||
return !topLevelRouteNamesWhitelistedForRewrite.includes(page);
|
||||
});
|
||||
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
@@ -42,32 +66,36 @@ let pages = (exports.pages = glob
|
||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
||||
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;
|
||||
|
||||
/**
|
||||
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
|
||||
* @param {string} suffix - The suffix to append to each route in the regex
|
||||
*/
|
||||
function getRegExpMatchingAllReservedRoutes(suffix) {
|
||||
// Following routes don't exist but they work by doing rewrite. Thus they need to be excluded from matching the orgRewrite patterns
|
||||
// Make sure to keep it upto date as more nonExistingRouteRewrites are added.
|
||||
const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
|
||||
|
||||
// Most files/dirs in public dir must not be rewritten to org pages. Ideally it should be all the content of public dir, but that can be done later
|
||||
// It is important to exclude the embed pages separately here because with SINGLE_ORG_SLUG enabled, the entire domain is eligible for rewrite vs just the org subdomain otherwise
|
||||
const staticAssets = ["embed"];
|
||||
|
||||
// FIXME: I am not sure why public is needed here, an asset 'test' in public isn't accessible through "/public/test" but only through "/test"
|
||||
// We should infact scan through all files in public and exclude them instead.
|
||||
const nextJsSpecialPaths = ["_next", "public"];
|
||||
|
||||
let beforeRewriteExcludePages = pages
|
||||
let allTopLevelRoutesExcludedFromOrgRewrite = topLevelRoutesExcludedFromOrgRewrite
|
||||
.concat(otherNonExistingRoutePrefixes)
|
||||
.concat(nextJsSpecialPaths)
|
||||
.concat(staticAssets);
|
||||
return beforeRewriteExcludePages.join(`${suffix}|`) + suffix;
|
||||
return allTopLevelRoutesExcludedFromOrgRewrite.join(`${suffix}|`) + suffix;
|
||||
}
|
||||
|
||||
// To handle /something
|
||||
exports.orgUserRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes("/?$")})[a-zA-Z0-9\-_]+)`;
|
||||
|
||||
// To handle /something/somethingelse
|
||||
exports.orgUserTypeRoutePath = `/:user((?!${getRegExpMatchingAllReservedRoutes(
|
||||
"/"
|
||||
|
||||
@@ -10,8 +10,6 @@ import type { Fixtures } from "./lib/fixtures";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { setupManagedEvent } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Bookings", () => {
|
||||
@@ -60,14 +58,16 @@ test.describe("Bookings", () => {
|
||||
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot choose date range presets", async ({ page, users }) => {
|
||||
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
const firstUser = await users.create();
|
||||
await firstUser.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
@@ -91,7 +91,7 @@ test.describe("Bookings", () => {
|
||||
test.describe("Past bookings", () => {
|
||||
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
|
||||
const firstUser = await users.create();
|
||||
const _secondUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
@@ -112,14 +112,15 @@ test.describe("Bookings", () => {
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeHidden();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -141,7 +142,7 @@ test.describe("Bookings", () => {
|
||||
});
|
||||
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
|
||||
const firstUser = await users.create();
|
||||
const _secondUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
@@ -157,33 +158,23 @@ test.describe("Bookings", () => {
|
||||
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
|
||||
await firstUser.apiLogin();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
|
||||
// Click the ellipsis button to open the all attendees dropdown
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
|
||||
await moreGuests.click();
|
||||
|
||||
// Find all the mark-no-show buttons in the dropdown (one for each attendee)
|
||||
const noShowButtons = page.locator('[data-testid="mark-no-show"]');
|
||||
|
||||
// Verify the 3rd attendee (index 2) has mark-no-show button (not already marked)
|
||||
const thirdAttendeeNoShowButton = noShowButtons.nth(2);
|
||||
await expect(thirdAttendeeNoShowButton).toBeVisible();
|
||||
|
||||
// Click the 3rd attendee's mark-no-show button
|
||||
await thirdAttendeeNoShowButton.click();
|
||||
|
||||
// Open dropdown again to verify the 3rd attendee now shows unmark-no-show
|
||||
const firstGuestInMore = page.getByRole("menuitemcheckbox").nth(0);
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: false });
|
||||
await firstGuestInMore.click();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
const updateNoShow = firstPastBooking.locator('[data-testid="update-no-show"]');
|
||||
await updateNoShow.click();
|
||||
await moreGuests.click();
|
||||
const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]');
|
||||
|
||||
// The 3rd attendee should now have the unmark button visible
|
||||
await expect(unmarkNoShowButtons.first()).toBeVisible();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
});
|
||||
test("Team admin/owner can mark first attendee as no-show", async ({
|
||||
page,
|
||||
@@ -208,16 +199,18 @@ test.describe("Bookings", () => {
|
||||
});
|
||||
const booking = await bookingFixture.self();
|
||||
await adminUser.apiLogin();
|
||||
const { webhookReceiver } = await webhooks.createTeamReceiver();
|
||||
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await page.locator('[data-testid="mark-no-show"]').click();
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -239,7 +232,7 @@ test.describe("Bookings", () => {
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
test("Can choose date range presets", async ({ page, users }) => {
|
||||
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
const firstUser = await users.create();
|
||||
await firstUser.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
@@ -381,15 +374,22 @@ test.describe("Bookings", () => {
|
||||
|
||||
//verify with the booking titles
|
||||
const firstUpcomingBooking = bookingListItems.nth(0);
|
||||
await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)
|
||||
).toBeVisible();
|
||||
|
||||
const secondUpcomingBooking = bookingListItems.nth(1);
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`)
|
||||
).toBeVisible();
|
||||
|
||||
const thirdUpcomingBooking = bookingListItems.nth(2);
|
||||
await expect(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Does not show booking from another user from collective event type when a member is filtered", async ({
|
||||
@@ -414,7 +414,7 @@ test.describe("Bookings", () => {
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const eventType = await owner.getFirstTeamEvent(team.id);
|
||||
const { id: eventTypeId, title: _teamEventTitle, slug: _teamEventSlug } = eventType;
|
||||
const { id: eventTypeId, title: teamEventTitle, slug: teamEventSlug } = eventType;
|
||||
|
||||
// remove myself from host of this event type
|
||||
await prisma.host.delete({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -57,7 +57,9 @@ test.describe("Locale-specific pages must not 404", () => {
|
||||
await user.apiLogin();
|
||||
const response = await page.goto(`/${locale}/bookings/upcoming`);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("text=See upcoming and past events booked through your event type links.")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
|
||||
|
||||
@@ -41,9 +41,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
|
||||
|
||||
await page.locator('button[data-testid="send_request"]').click();
|
||||
await expect(page.locator('[data-testid="reschedule-dialog"]')).toBeHidden();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('[id="modal-title"]')).toBeHidden();
|
||||
|
||||
const updatedBooking = await booking.self();
|
||||
|
||||
@@ -60,7 +58,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const _booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: dayjs().subtract(2, "day").toDate(),
|
||||
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
|
||||
@@ -132,9 +130,8 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.apiLogin();
|
||||
await page.goto("/bookings/cancelled");
|
||||
|
||||
await page.locator('[data-testid="booking-item"]').nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible();
|
||||
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
|
||||
await expect(requestRescheduleSentElement).toBeVisible();
|
||||
await booking.delete();
|
||||
});
|
||||
|
||||
@@ -199,7 +196,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const _payment = await payments.create(booking.id);
|
||||
const payment = await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -229,7 +226,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
paid: true,
|
||||
});
|
||||
|
||||
const _payment = await payments.create(booking.id);
|
||||
const payment = await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking?.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -333,6 +330,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
browser,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
@@ -341,13 +339,11 @@ test.describe("Reschedule Tests", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
|
||||
const confirmBooking = async (_bookingId: number) => {
|
||||
const confirmBooking = async (bookingId: number) => {
|
||||
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
|
||||
await authedPage.goto("/bookings/upcoming");
|
||||
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
|
||||
action: async () => {
|
||||
await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click();
|
||||
},
|
||||
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
|
||||
});
|
||||
await authedContext.close();
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -221,7 +221,6 @@
|
||||
"manage_this_team": "Manage this team",
|
||||
"team_info": "Team Info",
|
||||
"join_meeting": "Join Meeting",
|
||||
"join_platform": "Join {{platform}}",
|
||||
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
|
||||
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
|
||||
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
|
||||
@@ -369,6 +368,7 @@
|
||||
"available_apps": "Available Apps",
|
||||
"available_apps_lower_case": "Available apps",
|
||||
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
|
||||
"msteams_calendar_warning_body": "Install Microsoft Teams and Microsoft Outlook Calendar together so meetings include a proper online link and professional description.",
|
||||
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
|
||||
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event. <0>Learn more</0>",
|
||||
"round_robin_helper": "People in the group take turns and only one person will show up for the event. <0>Learn more</0>",
|
||||
@@ -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",
|
||||
@@ -1712,10 +1714,7 @@
|
||||
"error_updating_password": "Error updating password",
|
||||
"two_factor_auth": "Two factor authentication",
|
||||
"recurring_event_tab_description": "Set up a repeating schedule",
|
||||
"today": "Today",
|
||||
"this_month": "This month",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"today": "today",
|
||||
"appearance": "Appearance",
|
||||
"my_account": "My account",
|
||||
"general": "General",
|
||||
@@ -2289,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",
|
||||
@@ -3277,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",
|
||||
@@ -3891,7 +3893,7 @@
|
||||
"history": "History",
|
||||
"no_audit_history": "No audit history available",
|
||||
"remove_value_from_blocklist": "Remove {{value}} from blocklist?",
|
||||
"remove_value_from_blocklist_description": "The action is irreversible",
|
||||
"remove_value_from_blocklist_description":"The action is irreversible",
|
||||
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
|
||||
"created_by": "Created By",
|
||||
"blocked_by": "Blocked By",
|
||||
@@ -3981,7 +3983,7 @@
|
||||
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
|
||||
"repeats_num_times": "Repeats {{count}} times",
|
||||
"view_booking_details": "View Booking Details",
|
||||
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
|
||||
"view": "View",
|
||||
"team_bio": "Team Bio",
|
||||
"team_bio_placeholder": "Tell us about your team...",
|
||||
"onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.",
|
||||
@@ -4010,9 +4012,5 @@
|
||||
"onboarding_browser_view_ask_question_description": "Ask a question",
|
||||
"onboarding_browser_view_default_bio": "Add your bio here...",
|
||||
"book_now": "Book now",
|
||||
"view": "View",
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { it, expect, describe, beforeAll } from "vitest";
|
||||
|
||||
import { getRegExpThatMatchesAllOrgDomains } from "../../getNextjsOrgRewriteConfig";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { match, pathToRegexp } = require("next/dist/compiled/path-to-regexp");
|
||||
type MatcherRes = (path: string) => { params: Record<string, string> };
|
||||
let orgUserTypeRouteMatch: MatcherRes;
|
||||
@@ -15,7 +15,6 @@ beforeAll(async () => {
|
||||
const {
|
||||
orgUserRoutePath,
|
||||
orgUserTypeRoutePath,
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
} = require("../../pagesAndRewritePaths");
|
||||
|
||||
orgUserTypeRouteMatch = match(orgUserTypeRoutePath);
|
||||
@@ -162,5 +161,80 @@ describe("next.config.js - Org Rewrite", () => {
|
||||
expect(orgUserRouteMatch("/forms/xdsdf-sd")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/router?form=")).toEqual(false);
|
||||
});
|
||||
|
||||
it("Whitelisted routes should match on org domains", () => {
|
||||
expect(orgUserRouteMatch("/onboarding")?.params).toEqual({
|
||||
user: "onboarding",
|
||||
});
|
||||
expect(orgUserTypeRouteMatch("/onboarding/30min")?.params).toEqual({
|
||||
user: "onboarding",
|
||||
type: "30min",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Character validation in org slugs", () => {
|
||||
it("should allow valid characters: alphanumeric, hyphens, and underscores", () => {
|
||||
// Valid usernames with allowed characters
|
||||
expect(orgUserRouteMatch("/john-doe")?.params).toEqual({
|
||||
user: "john-doe",
|
||||
});
|
||||
expect(orgUserRouteMatch("/john_doe")?.params).toEqual({
|
||||
user: "john_doe",
|
||||
});
|
||||
expect(orgUserRouteMatch("/user123")?.params).toEqual({
|
||||
user: "user123",
|
||||
});
|
||||
expect(orgUserRouteMatch("/ABC-123_xyz")?.params).toEqual({
|
||||
user: "ABC-123_xyz",
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject invalid punctuation characters in org slugs", () => {
|
||||
// These should NOT match because they contain invalid characters
|
||||
// The character class [a-zA-Z0-9-_] when hyphen is unescaped between 9 and _
|
||||
// creates a range from - (ASCII 45) to _ (ASCII 95), which includes : ; < = > ? @ [ \ ] ^
|
||||
|
||||
// Colon (:) - ASCII 58
|
||||
expect(orgUserRouteMatch("/user:name")).toEqual(false);
|
||||
|
||||
// Semicolon (;) - ASCII 59
|
||||
expect(orgUserRouteMatch("/user;name")).toEqual(false);
|
||||
|
||||
// Less than (<) - ASCII 60
|
||||
expect(orgUserRouteMatch("/user<name")).toEqual(false);
|
||||
|
||||
// Equals (=) - ASCII 61
|
||||
expect(orgUserRouteMatch("/user=name")).toEqual(false);
|
||||
|
||||
// Greater than (>) - ASCII 62
|
||||
expect(orgUserRouteMatch("/user>name")).toEqual(false);
|
||||
|
||||
// Question mark (?) - ASCII 63
|
||||
expect(orgUserRouteMatch("/user?name")).toEqual(false);
|
||||
|
||||
// At symbol (@) - ASCII 64
|
||||
expect(orgUserRouteMatch("/user@name")).toEqual(false);
|
||||
|
||||
// Square brackets ([, ]) - ASCII 91, 93
|
||||
expect(orgUserRouteMatch("/user[name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user]name")).toEqual(false);
|
||||
|
||||
// Backslash (\) - ASCII 92
|
||||
expect(orgUserRouteMatch("/user\\name")).toEqual(false);
|
||||
|
||||
// Caret (^) - ASCII 94
|
||||
expect(orgUserRouteMatch("/user^name")).toEqual(false);
|
||||
|
||||
// Other common punctuation that should also be rejected
|
||||
expect(orgUserRouteMatch("/user.name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user!name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user#name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user$name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user%name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user&name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user*name")).toEqual(false);
|
||||
expect(orgUserRouteMatch("/user+name")).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { it, expect, describe } from "vitest";
|
||||
|
||||
import { pages } from "../../pagesAndRewritePaths.js";
|
||||
import { topLevelRoutesExcludedFromOrgRewrite, topLevelRouteNamesWhitelistedForRewrite } from "../../pagesAndRewritePaths.js";
|
||||
|
||||
describe("pagesAndRewritePaths", () => {
|
||||
describe("beforeFiles must exclude routes in pages/app router", () => {
|
||||
const BEFORE_REWRITE_EXCLUDE_PAGES = [
|
||||
describe("beforeFiles must exclude top-level routes in pages/app router", () => {
|
||||
const ROUTES_EXCLUDED_FROM_ORG_REWRITE = [
|
||||
"apps",
|
||||
"availability",
|
||||
"booking",
|
||||
@@ -30,12 +30,18 @@ describe("pagesAndRewritePaths", () => {
|
||||
"routing-forms",
|
||||
"signup",
|
||||
"team",
|
||||
"d",
|
||||
"d"
|
||||
];
|
||||
|
||||
it("should include all required routes", () => {
|
||||
BEFORE_REWRITE_EXCLUDE_PAGES.forEach((route) => {
|
||||
expect(pages).toContain(route);
|
||||
it("should include all required top-level route names", () => {
|
||||
ROUTES_EXCLUDED_FROM_ORG_REWRITE.forEach((route) => {
|
||||
expect(topLevelRoutesExcludedFromOrgRewrite).toContain(route);
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT include whitelisted routes", () => {
|
||||
topLevelRouteNamesWhitelistedForRewrite.forEach((route) => {
|
||||
expect(topLevelRoutesExcludedFromOrgRewrite).not.toContain(route);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ export const metadata = {
|
||||
publisher: "Cal.com",
|
||||
slug: "msteams",
|
||||
dirName: "office365video",
|
||||
dependencies: ["office365-calendar"],
|
||||
url: "https://www.microsoft.com/en-ca/microsoft-teams/group-chat-software",
|
||||
email: "help@cal.com",
|
||||
isOAuth: true,
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
}
|
||||
},
|
||||
"dirName": "office365video",
|
||||
"dependencies": ["office365-calendar"],
|
||||
"concurrentMeetings": true,
|
||||
"isOAuth": true
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user