From 978c5bdb9582a12ce5d2430c949c739ce29ac1f8 Mon Sep 17 00:00:00 2001 From: Kartik Labhshetwar Date: Tue, 9 Dec 2025 12:49:10 +0530 Subject: [PATCH 1/5] fix(insights): align call history empty state with search (#25718) --- apps/web/modules/insights/insights-call-history-view.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/modules/insights/insights-call-history-view.tsx b/apps/web/modules/insights/insights-call-history-view.tsx index f98d47f2a7269b..1e54b3835a7833 100644 --- a/apps/web/modules/insights/insights-call-history-view.tsx +++ b/apps/web/modules/insights/insights-call-history-view.tsx @@ -312,14 +312,12 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) { } EmptyView={ -
-
} /> From 161ebdbbecafd9cd6bd38ad41668fe0dc27f7acf Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:02:30 +0530 Subject: [PATCH 2/5] perf: Fix N+1 queries and optimize database operations (#25630) * perf: batch database operations and fix N+1 queries * delete * Fix condition for checking CalVideo location activity --- .../credentials/handleDeleteCredential.ts | 66 +++++++------ .../crm/__tests__/createCRMEvent.test.ts | 11 +++ .../tasker/tasks/crm/createCRMEvent.ts | 94 +++++++++++-------- .../team/listTeamAvailability.handler.ts | 12 ++- 4 files changed, 114 insertions(+), 69 deletions(-) diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index ad43caab0af534..c60a3b72195721 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -277,39 +277,47 @@ const handleDeleteCredential = async ({ }, }); - for (const booking of unpaidBookings) { - await prisma.booking.update({ - where: { - id: booking.id, + const unpaidBookingsIds = unpaidBookings.map((booking) => booking.id); + const unpaidBookingsPaymentIds = unpaidBookings.flatMap((booking) => + booking.payment.map((payment) => payment.id) + ); + await prisma.booking.updateMany({ + where: { + id: { + in: unpaidBookingsIds, }, - data: { - status: BookingStatus.CANCELLED, - cancellationReason: "Payment method removed", + }, + data: { + status: BookingStatus.CANCELLED, + cancellationReason: "Payment method removed", + }, + }); + for (const paymentId of unpaidBookingsPaymentIds) { + await deletePayment(paymentId, credential); + } + await prisma.payment.deleteMany({ + where: { + id: { + in: unpaidBookingsPaymentIds, }, - }); - - for (const payment of booking.payment) { - await deletePayment(payment.id, credential); - await prisma.payment.delete({ - where: { - id: payment.id, - }, - }); - } - - await prisma.attendee.deleteMany({ - where: { - bookingId: booking.id, + }, + }); + await prisma.attendee.deleteMany({ + where: { + bookingId: { + in: unpaidBookingsIds, }, - }); - - await prisma.bookingReference.updateMany({ - where: { - bookingId: booking.id, + }, + }); + await prisma.bookingReference.updateMany({ + where: { + bookingId: { + in: unpaidBookingsIds, }, - data: { deleted: true }, - }); - + }, + data: { deleted: true }, + }); + for (const booking of unpaidBookings) { const attendeesListPromises = booking.attendees.map(async (attendee) => { return { name: attendee.name, diff --git a/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts b/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts index be39032c95cd7a..e3689dc4aa6000 100644 --- a/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts +++ b/packages/features/tasker/tasks/crm/__tests__/createCRMEvent.test.ts @@ -131,6 +131,7 @@ describe("createCRMEvent", () => { // Set up Prisma mocks with proper return values prismaMock.booking.findUnique.mockResolvedValueOnce(mockBooking); prismaMock.credential.findUnique.mockResolvedValueOnce(mockCredential); + prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]); prismaMock.bookingReference.createMany.mockResolvedValueOnce({ count: 1 }); prismaMock.bookingReference.findMany.mockResolvedValueOnce([]); const payload = JSON.stringify({ @@ -198,6 +199,7 @@ describe("createCRMEvent", () => { }; prismaMock.booking.findUnique.mockResolvedValue(mockBooking); + prismaMock.credential.findMany.mockResolvedValueOnce([]); const payload = JSON.stringify({ bookingUid: "booking-123", @@ -238,6 +240,7 @@ describe("createCRMEvent", () => { prismaMock.booking.findUnique.mockResolvedValue(mockBooking); prismaMock.credential.findUnique.mockResolvedValue(null); + prismaMock.credential.findMany.mockResolvedValueOnce([]); prismaMock.bookingReference.findMany.mockResolvedValueOnce([]); mockCreateEvent.mockRejectedValue(new Error("Salesforce API error")); @@ -288,6 +291,7 @@ describe("createCRMEvent", () => { prismaMock.booking.findUnique.mockResolvedValue(mockBooking); prismaMock.credential.findUnique.mockResolvedValue(mockCredential); + prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]); prismaMock.bookingReference.findMany.mockResolvedValueOnce([]); mockCreateEvent.mockRejectedValue(new RetryableError("Salesforce API Retryable error")); @@ -334,6 +338,7 @@ describe("createCRMEvent", () => { prismaMock.booking.findUnique.mockResolvedValue(mockBooking); prismaMock.credential.findUnique.mockResolvedValue(mockCredential); + prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]); prismaMock.bookingReference.findMany.mockResolvedValueOnce([]); mockCreateEvent.mockRejectedValue(new Error("Salesforce API error")); @@ -401,6 +406,11 @@ describe("createCRMEvent", () => { .mockResolvedValueOnce(mockSalesforceCredential) .mockResolvedValueOnce(mockHubspotCredential); + prismaMock.credential.findMany.mockResolvedValueOnce([ + mockSalesforceCredential, + mockHubspotCredential, + ]); + prismaMock.bookingReference.findMany.mockResolvedValueOnce([]); // Throw error for first app and resolve for second app @@ -462,6 +472,7 @@ describe("createCRMEvent", () => { prismaMock.booking.findUnique.mockResolvedValue(mockBooking); prismaMock.credential.findUnique.mockResolvedValueOnce(mockSalesforceCredential); + prismaMock.credential.findMany.mockResolvedValueOnce([mockSalesforceCredential]); prismaMock.bookingReference.findMany.mockResolvedValueOnce([ { id: 1, type: "salesforce_crm", uid: "sf-event-123", credentialId: 1, bookingId: 1 }, ]); diff --git a/packages/features/tasker/tasks/crm/createCRMEvent.ts b/packages/features/tasker/tasks/crm/createCRMEvent.ts index f8fe749492bc52..edb783c65608a2 100644 --- a/packages/features/tasker/tasks/crm/createCRMEvent.ts +++ b/packages/features/tasker/tasks/crm/createCRMEvent.ts @@ -111,52 +111,72 @@ export async function createCRMEvent(payload: string): Promise { }); const errorPerApp: Record = {}; - // Find enabled CRM apps for the event type + + // Parse apps and collect credential IDs for enabled CRM apps + const appInfoMap = new Map(); + const credentialIds = new Set(); + for (const appSlug of Object.keys(eventTypeAppMetadata)) { - // Try Catch per app to ensure all apps are tried even if any of them throws an error - // If we want to retry for an error from this try catch, then that error must be thrown as a RetryableError - try { - const appData = eventTypeAppMetadata[appSlug as keyof typeof eventTypeAppMetadata]; - const appDataSchema = appDataSchemas[appSlug as keyof typeof appDataSchemas]; - if (!appData || !appDataSchema) { - throw new Error(`Could not find appData or appDataSchema for ${appSlug}`); - } + const appData = eventTypeAppMetadata[appSlug as keyof typeof eventTypeAppMetadata]; + const appDataSchema = appDataSchemas[appSlug as keyof typeof appDataSchemas]; - const appParse = appDataSchema.safeParse(appData); + if (!appData || !appDataSchema) { + throw new Error(`Could not find appData or appDataSchema for ${appSlug}`); + } - if (!appParse.success) { - log.error(`Error parsing event type app data for bookingUid ${bookingUid}`, appParse?.error); - continue; - } + const appParse = appDataSchema.safeParse(appData); - const app = appParse.data; - const hasCrmCategory = - app.appCategories && app.appCategories.some((category: string) => category === "crm"); + if (!appParse.success) { + log.error(`Error parsing event type app data for bookingUid ${bookingUid}`, appParse?.error); + continue; + } - if (!app.enabled || !app.credentialId || !hasCrmCategory) { - log.info(`Skipping CRM app ${appSlug}`, { - enabled: app.enabled, - credentialId: app.credentialId, - hasCrmCategory, - }); - continue; - } + const app = appParse.data; + const hasCrmCategory = + app.appCategories && app.appCategories.some((category: string) => category === "crm"); - const crmCredential = await prisma.credential.findUnique({ - where: { - id: app.credentialId, - }, - include: { - user: { - select: { - email: true, - }, - }, - }, + if (!app.enabled || !app.credentialId || !hasCrmCategory) { + log.info(`Skipping CRM app ${appSlug}`, { + enabled: app.enabled, + credentialId: app.credentialId, + hasCrmCategory, }); + continue; + } + + appInfoMap.set(appSlug, { app, credentialId: app.credentialId }); + credentialIds.add(app.credentialId); + } + + const crmCredentials = await prisma.credential.findMany({ + where: { + id: { + in: Array.from(credentialIds), + }, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + const crmCredentialMap = new Map(); + for (const credential of crmCredentials) { + crmCredentialMap.set(credential.id, credential); + } + //Find enabled CRM apps for the event type + for (const appSlug of Array.from(appInfoMap.keys())) { + const { app, credentialId } = appInfoMap.get(appSlug)!; + // Try Catch per app to ensure all apps are tried even if any of them throws an error + // If we want to retry for an error from this try catch, then that error must be thrown as a RetryableError + try { + const crmCredential = crmCredentialMap.get(credentialId); if (!crmCredential) { - throw new Error(`Credential not found for credentialId: ${app.credentialId}`); + throw new Error(`Credential not found for credentialId: ${credentialId}`); } const existingBookingReferenceForTheCredential = existingBookingReferences.find( diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 5d4e572dd978a7..779a9f1b7994eb 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -71,13 +71,19 @@ async function getTeamMembers({ }); const userRepo = new UserRepository(prisma); + const users = memberships.map((membership) => membership.user); + const enrichedUsers = await userRepo.enrichUsersWithTheirProfileExcludingOrgMetadata(users); + const enrichedUserMap = new Map(); + enrichedUsers.forEach((enrichedUser) => { + enrichedUserMap.set(enrichedUser.id, enrichedUser); + }); const membershipWithUserProfile = []; for (const membership of memberships) { + const enrichedUser = enrichedUserMap.get(membership.user.id); + if (!enrichedUser) continue; membershipWithUserProfile.push({ ...membership, - user: await userRepo.enrichUserWithItsProfileExcludingOrgMetadata({ - user: membership.user, - }), + user: enrichedUser, }); } From 75b93cebdb22e727bc8241bf25cd19301d587425 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:37:21 +0000 Subject: [PATCH 3/5] fix: troubleshooter team events + improve race condition (#25704) * fix query to bring back teams + fix hover state * fix team issues with troubleshooter --- .../components/EventScheduleItem.tsx | 2 +- .../components/EventTypeSelect.tsx | 82 +++++++++++-------- .../components/LargeCalendar.tsx | 6 +- packages/features/troubleshooter/store.ts | 3 +- .../viewer/eventTypes/listWithTeam.handler.ts | 14 +++- 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/packages/features/troubleshooter/components/EventScheduleItem.tsx b/packages/features/troubleshooter/components/EventScheduleItem.tsx index df124610f5d514..45aa1d8c1bb9d7 100644 --- a/packages/features/troubleshooter/components/EventScheduleItem.tsx +++ b/packages/features/troubleshooter/components/EventScheduleItem.tsx @@ -31,7 +31,7 @@ export function EventScheduleItem() { suffixSlot={ schedule && ( - + {t("edit")} diff --git a/packages/features/troubleshooter/components/EventTypeSelect.tsx b/packages/features/troubleshooter/components/EventTypeSelect.tsx index bd29b4d59f30e2..c430c6b2f6ba1a 100644 --- a/packages/features/troubleshooter/components/EventTypeSelect.tsx +++ b/packages/features/troubleshooter/components/EventTypeSelect.tsx @@ -1,4 +1,5 @@ import { useMemo, useEffect, startTransition } from "react"; +import { shallow } from "zustand/shallow"; import { trpc } from "@calcom/trpc"; import { SelectField } from "@calcom/ui/components/form"; @@ -7,67 +8,80 @@ import { getQueryParam } from "../../bookings/Booker/utils/query-param"; import { useTroubleshooterStore } from "../store"; export function EventTypeSelect() { - const { data: eventTypes, isPending } = trpc.viewer.eventTypes.list.useQuery(); - const selectedEventType = useTroubleshooterStore((state) => state.event); - const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent); - - const selectedEventQueryParam = getQueryParam("eventType"); + const { data: eventTypes, isPending } = trpc.viewer.eventTypes.listWithTeam.useQuery(); + const { event: selectedEventType, setEvent: setSelectedEventType } = useTroubleshooterStore( + (state) => ({ + event: state.event, + setEvent: state.setEvent, + }), + shallow + ); const options = useMemo(() => { if (!eventTypes) return []; return eventTypes.map((e) => ({ label: e.title, - value: e.slug, + value: e.id.toString(), id: e.id, duration: e.length, })); }, [eventTypes]); + // Initialize event type from query param or default to first event useEffect(() => { - if (!selectedEventType && eventTypes && eventTypes[0] && !selectedEventQueryParam) { - const { id, slug, length } = eventTypes[0]; - setSelectedEventType({ - id, - slug, - duration: length, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventTypes]); + if (!eventTypes || eventTypes.length === 0) return; - useEffect(() => { - if (selectedEventQueryParam) { - // ensure that the update is deferred until the Suspense boundary has finished hydrating + const selectedEventIdParam = getQueryParam("eventTypeId"); + const eventTypeId = selectedEventIdParam ? parseInt(selectedEventIdParam, 10) : null; + + // If we already have a selected event that matches the query param, don't do anything + if (selectedEventType?.id === eventTypeId) return; + + // If there's a query param, try to find and set that event + if (eventTypeId && !isNaN(eventTypeId)) { startTransition(() => { - const foundEventType = eventTypes?.find((et) => et.slug === selectedEventQueryParam); + const foundEventType = eventTypes.find((et) => et.id === eventTypeId); if (foundEventType) { - const { id, slug, length } = foundEventType; - setSelectedEventType({ id, slug, duration: length }); - } else if (eventTypes && eventTypes[0]) { - const { id, slug, length } = eventTypes[0]; setSelectedEventType({ - id, - slug, - duration: length, + id: foundEventType.id, + slug: foundEventType.slug, + duration: foundEventType.length, + teamId: foundEventType.team?.id ?? null, }); + return; } }); } - }, [eventTypes, selectedEventQueryParam, setSelectedEventType]); + + // If no event is selected and no valid query param, default to first event + if (!selectedEventType && !eventTypeId) { + const firstEvent = eventTypes[0]; + setSelectedEventType({ + id: firstEvent.id, + slug: firstEvent.slug, + duration: firstEvent.length, + teamId: firstEvent.team?.id ?? null, + }); + } + }, [eventTypes, selectedEventType, setSelectedEventType]); return ( option.value === selectedEventType?.slug) || options[0]} + value={options.find((option) => option.id === selectedEventType?.id) || options[0]} onChange={(option) => { if (!option) return; - setSelectedEventType({ - slug: option.value, - id: option.id, - duration: option.duration, - }); + const foundEventType = eventTypes?.find((et) => et.id === option.id); + if (foundEventType) { + setSelectedEventType({ + id: foundEventType.id, + slug: foundEventType.slug, + duration: foundEventType.length, + teamId: foundEventType.team?.id ?? null, + }); + } }} /> ); diff --git a/packages/features/troubleshooter/components/LargeCalendar.tsx b/packages/features/troubleshooter/components/LargeCalendar.tsx index de2f725a711a38..012b63d5cb1f69 100644 --- a/packages/features/troubleshooter/components/LargeCalendar.tsx +++ b/packages/features/troubleshooter/components/LargeCalendar.tsx @@ -28,6 +28,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { .add(extraDays - 1, "day") .utc() .format(), + eventTypeId: event?.id, withSource: true, }, { @@ -35,13 +36,16 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { } ); + const isTeamEvent = !!event?.teamId; const { data: schedule } = useSchedule({ username: session?.user.username || "", - eventSlug: event?.slug, + // For team events, don't pass eventSlug to avoid slug lookup issues - use eventId instead + eventSlug: isTeamEvent ? null : event?.slug, eventId: event?.id, timezone, month: startDate.format("YYYY-MM"), orgSlug: session?.user.org?.slug, + isTeamEvent, }); const endDate = dayjs(startDate) diff --git a/packages/features/troubleshooter/store.ts b/packages/features/troubleshooter/store.ts index 4d5daa26018ff7..49e23ee4e2c025 100644 --- a/packages/features/troubleshooter/store.ts +++ b/packages/features/troubleshooter/store.ts @@ -17,6 +17,7 @@ type EventType = { id: number; slug: string; duration: number; + teamId?: number | null; }; export type TroubleshooterStore = { @@ -76,7 +77,7 @@ export const useTroubleshooterStore = create((set, get) => event: null, setEvent: (event: EventType) => { set({ event }); - updateQueryParam("eventType", event.slug ?? ""); + updateQueryParam("eventTypeId", event.id.toString()); }, month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"), setMonth: (month: string | null) => { diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts index 6bdff698006fbb..907c6b23a70287 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts @@ -11,19 +11,26 @@ type ListWithTeamOptions = { export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => { const userId = ctx.user.id; - const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName" + const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName" FROM "public"."EventType" LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId") WHERE "public"."EventType"."userId" = ${userId} UNION - SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName" + SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName" FROM "public"."EventType" INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId") INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id" WHERE "t2"."userId" = ${userId} AND "t2"."accepted" = true`; const result = await db.$queryRaw< - { id: number; teamId: number | null; title: string; slug: string; teamName: string | null }[] + { + id: number; + teamId: number | null; + title: string; + slug: string; + length: number; + teamName: string | null; + }[] >(query); return result.map((row) => ({ @@ -31,5 +38,6 @@ export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => { team: row.teamId ? { id: row.teamId, name: row.teamName || "" } : null, title: row.title, slug: row.slug, + length: row.length, })); }; From 257a49ce886f4cd34c9fba6605a3a8df4a4299ad Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Tue, 9 Dec 2025 08:47:17 +0100 Subject: [PATCH 4/5] fix(companion): replaced localhost with prod url (#25576) * replaced localhost with prod url * nit --------- Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> --- .gitattributes | 4 ++++ companion/extension/entrypoints/content.ts | 2 +- companion/extension/public/manifest.json | 4 ++-- companion/wxt.config.ts | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000000..af3ad128122dfe --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +/.yarn/** linguist-vendored +/.yarn/releases/* binary +/.yarn/plugins/**/* binary +/.pnp.* binary linguist-generated diff --git a/companion/extension/entrypoints/content.ts b/companion/extension/entrypoints/content.ts index 677d860db014a2..18483faf5d7acd 100644 --- a/companion/extension/entrypoints/content.ts +++ b/companion/extension/entrypoints/content.ts @@ -48,7 +48,7 @@ export default defineContentScript({ // Create iframe const iframe = document.createElement("iframe"); - iframe.src = "http://localhost:8081"; + iframe.src = "https://companion.cal.com"; iframe.style.width = "400px"; iframe.style.height = "100%"; iframe.style.border = "none"; diff --git a/companion/extension/public/manifest.json b/companion/extension/public/manifest.json index 9c3a63ef086c9c..615cf816908ddb 100644 --- a/companion/extension/public/manifest.json +++ b/companion/extension/public/manifest.json @@ -3,9 +3,9 @@ "name": "Cal.com Companion", "version": "1.7.0", "description": "Your calendar companion for quick booking and scheduling", - "permissions": ["activeTab", "http://localhost:8081/*", "identity"], + "permissions": ["activeTab", "https://companion.cal.com/*", "identity"], "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;" + "extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com;" }, "action": { "default_title": "Cal.com Companion" diff --git a/companion/wxt.config.ts b/companion/wxt.config.ts index f93e8dea94d86d..705e7cc1a3fd99 100644 --- a/companion/wxt.config.ts +++ b/companion/wxt.config.ts @@ -11,14 +11,14 @@ export default defineConfig({ description: "Your calendar companion for quick booking and scheduling", permissions: ["activeTab", "storage", "identity"], host_permissions: [ - "http://localhost:8081/*", + "https://companion.cal.com/*", "https://api.cal.com/*", "https://app.cal.com/*", "https://mail.google.com/*", ], content_security_policy: { extension_pages: - "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;", + "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com", }, action: { default_title: "Cal.com Companion", From f350542d0dad2f809b10acd46982903f000f48dd Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 9 Dec 2025 14:29:41 +0530 Subject: [PATCH 5/5] feat: add booking audit action services for lib/actions folder (#25720) Extract lib/actions folder changes from booking-audit-more-infra branch: - AcceptedAuditActionService - AttendeeAddedAuditActionService - AttendeeNoShowUpdatedAuditActionService - AttendeeRemovedAuditActionService - CancelledAuditActionService - CreatedAuditActionService (modified) - HostNoShowUpdatedAuditActionService - IAuditActionService (modified) - LocationChangedAuditActionService - ReassignmentAuditActionService - RejectedAuditActionService - RescheduleRequestedAuditActionService - RescheduledAuditActionService Also includes common/changeSchemas.ts required for type-checks to pass. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/booking-audit/ARCHITECTURE.md | 8 +- .../lib/actions/AcceptedAuditActionService.ts | 80 +++++++++++++ .../AttendeeAddedAuditActionService.ts | 82 +++++++++++++ ...AttendeeNoShowUpdatedAuditActionService.ts | 80 +++++++++++++ .../AttendeeRemovedAuditActionService.ts | 85 +++++++++++++ .../actions/CancelledAuditActionService.ts | 90 ++++++++++++++ .../lib/actions/CreatedAuditActionService.ts | 15 ++- .../HostNoShowUpdatedAuditActionService.ts | 80 +++++++++++++ .../lib/actions/IAuditActionService.ts | 32 ++++- .../LocationChangedAuditActionService.ts | 79 ++++++++++++ .../actions/ReassignmentAuditActionService.ts | 91 ++++++++++++++ .../lib/actions/RejectedAuditActionService.ts | 85 +++++++++++++ .../RescheduleRequestedAuditActionService.ts | 82 +++++++++++++ .../actions/RescheduledAuditActionService.ts | 112 ++++++++++++++++++ .../booking-audit/lib/common/changeSchemas.ts | 27 +++++ .../service/booking-audit.integration-test.ts | 45 ++++--- 16 files changed, 1040 insertions(+), 33 deletions(-) create mode 100644 packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts create mode 100644 packages/features/booking-audit/lib/common/changeSchemas.ts diff --git a/packages/features/booking-audit/ARCHITECTURE.md b/packages/features/booking-audit/ARCHITECTURE.md index 602225a4904032..f2c3cd42aff264 100644 --- a/packages/features/booking-audit/ARCHITECTURE.md +++ b/packages/features/booking-audit/ARCHITECTURE.md @@ -361,20 +361,20 @@ Used when a booking status changes to accepted. #### ATTENDEE_ADDED ```typescript { - addedAttendees // { old: null, new: ["email@example.com", ...] } + attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] } } ``` -Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state. +Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old). #### ATTENDEE_REMOVED ```typescript { - removedAttendees // { old: null, new: ["email@example.com", ...] } + attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] } } ``` -Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state. +Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new). --- diff --git a/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts new file mode 100644 index 00000000000000..52a387fd31ba75 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AcceptedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Accepted Audit Action Service + * Handles ACCEPTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + status: StringChangeSchema, +}); + +export class AcceptedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ACCEPTED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = AcceptedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AcceptedAuditActionService.latestFieldsSchema, + typeof AcceptedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema, + storedDataSchema: AcceptedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.accepted" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AcceptedAuditDisplayData { + const { fields } = storedData; + return { + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type AcceptedAuditData = z.infer; + +export type AcceptedAuditDisplayData = { + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts new file mode 100644 index 00000000000000..40e5d30b7a556f --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeAddedAuditActionService.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee Added Audit Action Service + * Handles ATTENDEE_ADDED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + attendees: StringArrayChangeSchema, +}); + +export class AttendeeAddedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_ADDED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = AttendeeAddedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeAddedAuditActionService.latestFieldsSchema, + typeof AttendeeAddedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_added" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeAddedAuditDisplayData { + const { fields } = storedData; + const previousAttendeesSet = new Set(fields.attendees.old ?? []); + const addedAttendees = fields.attendees.new.filter( + (email) => !previousAttendeesSet.has(email) + ); + return { + addedAttendees, + }; + } +} + +export type AttendeeAddedAuditData = z.infer; + +export type AttendeeAddedAuditDisplayData = { + addedAttendees: string[]; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts new file mode 100644 index 00000000000000..662ec76e6d63dc --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeNoShowUpdatedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee No-Show Updated Audit Action Service + * Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + noShowAttendee: BooleanChangeSchema, +}); + +export class AttendeeNoShowUpdatedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema, + typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_no_show_updated" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeNoShowUpdatedAuditDisplayData { + const { fields } = storedData; + return { + noShowAttendee: fields.noShowAttendee.new, + previousNoShowAttendee: fields.noShowAttendee.old ?? null, + }; + } +} + +export type AttendeeNoShowUpdatedAuditData = z.infer; + +export type AttendeeNoShowUpdatedAuditDisplayData = { + noShowAttendee: boolean; + previousNoShowAttendee: boolean | null; +}; diff --git a/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts new file mode 100644 index 00000000000000..d90356ced122e0 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/AttendeeRemovedAuditActionService.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { StringArrayChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Attendee Removed Audit Action Service + * Handles ATTENDEE_REMOVED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + attendees: StringArrayChangeSchema, +}); + +export class AttendeeRemovedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "ATTENDEE_REMOVED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = AttendeeRemovedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof AttendeeRemovedAuditActionService.latestFieldsSchema, + typeof AttendeeRemovedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema, + storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.attendee_removed" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): AttendeeRemovedAuditDisplayData { + const { fields } = storedData; + // Note: fields.attendees stores the state change (old -> new), not the removed attendees directly + // old = attendees before removal, new = remaining attendees after removal + const remainingAttendeesSet = new Set(fields.attendees.new ?? []); + // Compute removed attendees: those in old but not in new (remaining) + const removedAttendees = (fields.attendees.old ?? []).filter( + (email) => !remainingAttendeesSet.has(email) + ); + return { + removedAttendees, + }; + } +} + +export type AttendeeRemovedAuditData = z.infer; + +export type AttendeeRemovedAuditDisplayData = { + removedAttendees: string[]; +}; diff --git a/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts new file mode 100644 index 00000000000000..fef3683181ee9d --- /dev/null +++ b/packages/features/booking-audit/lib/actions/CancelledAuditActionService.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Cancelled Audit Action Service + * Handles CANCELLED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + status: StringChangeSchema, +}); + +export class CancelledAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "CANCELLED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = CancelledAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = CancelledAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof CancelledAuditActionService.latestFieldsSchema, + typeof CancelledAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: CancelledAuditActionService.latestFieldsSchema, + storedDataSchema: CancelledAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.cancelled" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): CancelledAuditDisplayData { + const { fields } = storedData; + return { + cancellationReason: fields.cancellationReason.new ?? null, + previousReason: fields.cancellationReason.old ?? null, + cancelledBy: fields.cancelledBy.new ?? null, + previousCancelledBy: fields.cancelledBy.old ?? null, + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type CancelledAuditData = z.infer; + +export type CancelledAuditDisplayData = { + cancellationReason: string | null; + previousReason: string | null; + cancelledBy: string | null; + previousCancelledBy: string | null; + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts index caed606bc7b886..a2cccf38dacbd6 100644 --- a/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/CreatedAuditActionService.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BookingStatus } from "@calcom/prisma/enums"; import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; -import type { IAuditActionService } from "./IAuditActionService"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; /** * Created Audit Action Service @@ -22,7 +22,7 @@ export class CreatedAuditActionService implements IAuditActionService< typeof fieldsSchemaV1 > { readonly VERSION = 1; - public static readonly TYPE = "CREATED"; + public static readonly TYPE = "CREATED" as const; private static dataSchemaV1 = z.object({ version: z.literal(1), fields: fieldsSchemaV1, @@ -61,11 +61,16 @@ export class CreatedAuditActionService implements IAuditActionService< return { isMigrated: false, latestData: validated }; } + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.created" }; + } + getDisplayJson(storedData: { version: number; fields: z.infer }): CreatedAuditDisplayData { + const { fields } = storedData; return { - startTime: new Date(storedData.fields.startTime).toISOString(), - endTime: new Date(storedData.fields.endTime).toISOString(), - status: storedData.fields.status, + startTime: new Date(fields.startTime).toISOString(), + endTime: new Date(fields.endTime).toISOString(), + status: fields.status, }; } } diff --git a/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts new file mode 100644 index 00000000000000..b1d08faeedfa9a --- /dev/null +++ b/packages/features/booking-audit/lib/actions/HostNoShowUpdatedAuditActionService.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Host No-Show Updated Audit Action Service + * Handles HOST_NO_SHOW_UPDATED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + noShowHost: BooleanChangeSchema, +}); + +export class HostNoShowUpdatedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema, + typeof HostNoShowUpdatedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema, + storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.host_no_show_updated" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): HostNoShowUpdatedAuditDisplayData { + const { fields } = storedData; + return { + noShowHost: fields.noShowHost.new, + previousNoShowHost: fields.noShowHost.old ?? null, + }; + } +} + +export type HostNoShowUpdatedAuditData = z.infer; + +export type HostNoShowUpdatedAuditDisplayData = { + noShowHost: boolean; + previousNoShowHost: boolean | null; +}; diff --git a/packages/features/booking-audit/lib/actions/IAuditActionService.ts b/packages/features/booking-audit/lib/actions/IAuditActionService.ts index aac3f555f4d255..e49e1bed7f6a28 100644 --- a/packages/features/booking-audit/lib/actions/IAuditActionService.ts +++ b/packages/features/booking-audit/lib/actions/IAuditActionService.ts @@ -1,5 +1,25 @@ import { z } from "zod"; +/** + * Represents a component that can be interpolated into translations + * Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.) + */ +export type TranslationComponent = { + type: "link"; + href: string; +}; + +/** + * Represents a translation key with optional interpolation params and components + * Used for dynamic display titles that need to be translated with context + * Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking") + */ +export type TranslationWithParams = { + key: string; + params?: Record; + components?: TranslationComponent[]; +}; + /** * Interface for Audit Action Services * @@ -43,10 +63,20 @@ export interface IAuditActionService< /** * Get flattened JSON data for display (fields only, no version wrapper) + * Optional - implement only if custom display formatting is needed * @param storedData - Parsed stored data { version, fields } * @returns The fields object without version wrapper and we decide what fields to show to the client */ - getDisplayJson(storedData: { version: number; fields: z.infer }): unknown; + getDisplayJson?(storedData: { version: number; fields: z.infer }): unknown; + + /** + * Get the display title for the audit action + * Returns a translation key with optional interpolation params for dynamic titles + * (e.g., "Booking reassigned to John Doe" instead of just "Reassignment") + * @param storedData - Parsed stored data { version, fields } + * @returns Translation key with optional interpolation params + */ + getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise; /** * Migrate old version data to latest version diff --git a/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts new file mode 100644 index 00000000000000..b8199cf8c4c6dc --- /dev/null +++ b/packages/features/booking-audit/lib/actions/LocationChangedAuditActionService.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +import { getHumanReadableLocationValue } from "@calcom/app-store/locations"; +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; +import { getTranslation } from "@calcom/lib/server/i18n"; +/** + * Location Changed Audit Action Service + * Handles LOCATION_CHANGED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + location: StringChangeSchema, +}); + +export class LocationChangedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "LOCATION_CHANGED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = LocationChangedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = LocationChangedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof LocationChangedAuditActionService.latestFieldsSchema, + typeof LocationChangedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: LocationChangedAuditActionService.latestFieldsSchema, + storedDataSchema: LocationChangedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const { fields } = storedData; + // TODO: Ideally we want to translate the location label to the user's locale + // We currently don't accept requesting user's translate fn here, fix it later. + const t = await getTranslation("en", "common"); + + const fromLocation = getHumanReadableLocationValue(fields.location.old, t); + const toLocation = getHumanReadableLocationValue(fields.location.new, t); + + return { + key: "booking_audit_action.location_changed_from_to", + params: { fromLocation, toLocation }, + }; + } +} + +export type LocationChangedAuditData = z.infer; diff --git a/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts new file mode 100644 index 00000000000000..37066914f502b2 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/ReassignmentAuditActionService.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +import type { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Reassignment Audit Action Service + * Handles REASSIGNMENT action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + assignedToId: NumberChangeSchema, + assignedById: NumberChangeSchema, + reassignmentReason: StringChangeSchema, + userPrimaryEmail: StringChangeSchema.optional(), + title: StringChangeSchema.optional(), +}); + +export class ReassignmentAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "REASSIGNMENT" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof ReassignmentAuditActionService.latestFieldsSchema, + typeof ReassignmentAuditActionService.storedDataSchema + >; + + constructor(private userRepository: UserRepository) { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema, + storedDataSchema: ReassignmentAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const { fields } = storedData; + const user = await this.userRepository.findById({ id: fields.assignedToId.new }); + const reassignedToName = user?.name || "Unknown"; + return { + key: "booking_audit_action.booking_reassigned_to_host", + params: { host: reassignedToName }, + }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): ReassignmentAuditDisplayData { + const { fields } = storedData; + return { + newAssignedToId: fields.assignedToId.new, + reassignmentReason: fields.reassignmentReason.new ?? null, + }; + } +} + +export type ReassignmentAuditData = z.infer; + +export type ReassignmentAuditDisplayData = { + newAssignedToId: number; + reassignmentReason: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts new file mode 100644 index 00000000000000..91bfb9d951b13c --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RejectedAuditActionService.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Rejected Audit Action Service + * Handles REJECTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + rejectionReason: StringChangeSchema, + status: StringChangeSchema, +}); + +export class RejectedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "REJECTED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = RejectedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RejectedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RejectedAuditActionService.latestFieldsSchema, + typeof RejectedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RejectedAuditActionService.latestFieldsSchema, + storedDataSchema: RejectedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.rejected" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RejectedAuditDisplayData { + const { fields } = storedData; + return { + rejectionReason: fields.rejectionReason.new ?? null, + previousReason: fields.rejectionReason.old ?? null, + previousStatus: fields.status.old ?? null, + newStatus: fields.status.new ?? null, + }; + } +} + +export type RejectedAuditData = z.infer; + +export type RejectedAuditDisplayData = { + rejectionReason: string | null; + previousReason: string | null; + previousStatus: string | null; + newStatus: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts new file mode 100644 index 00000000000000..8afe5123df5ee6 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RescheduleRequestedAuditActionService.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Reschedule Requested Audit Action Service + * Handles RESCHEDULE_REQUESTED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + cancellationReason: StringChangeSchema, + cancelledBy: StringChangeSchema, + rescheduled: BooleanChangeSchema.optional(), +}); + +export class RescheduleRequestedAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "RESCHEDULE_REQUESTED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RescheduleRequestedAuditActionService.latestFieldsSchema, + typeof RescheduleRequestedAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema, + storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(): Promise { + return { key: "booking_audit_action.reschedule_requested" }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduleRequestedAuditDisplayData { + const { fields } = storedData; + return { + reason: fields.cancellationReason.new ?? null, + requestedBy: fields.cancelledBy.new ?? null, + }; + } +} + +export type RescheduleRequestedAuditData = z.infer; + +export type RescheduleRequestedAuditDisplayData = { + reason: string | null; + requestedBy: string | null; +}; diff --git a/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts new file mode 100644 index 00000000000000..950670cbd42785 --- /dev/null +++ b/packages/features/booking-audit/lib/actions/RescheduledAuditActionService.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; + +import { StringChangeSchema } from "../common/changeSchemas"; +import { AuditActionServiceHelper } from "./AuditActionServiceHelper"; +import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService"; + +/** + * Rescheduled Audit Action Service + * Handles RESCHEDULED action with per-action versioning + */ + +// Module-level because it is passed to IAuditActionService type outside the class scope +const fieldsSchemaV1 = z.object({ + startTime: StringChangeSchema, + endTime: StringChangeSchema, + rescheduledToUid: StringChangeSchema, +}); + +export class RescheduledAuditActionService + implements IAuditActionService { + readonly VERSION = 1; + public static readonly TYPE = "RESCHEDULED" as const; + private static dataSchemaV1 = z.object({ + version: z.literal(1), + fields: fieldsSchemaV1, + }); + private static fieldsSchemaV1 = fieldsSchemaV1; + public static readonly latestFieldsSchema = fieldsSchemaV1; + // Union of all versions + public static readonly storedDataSchema = RescheduledAuditActionService.dataSchemaV1; + // Union of all versions + public static readonly storedFieldsSchema = RescheduledAuditActionService.fieldsSchemaV1; + private helper: AuditActionServiceHelper< + typeof RescheduledAuditActionService.latestFieldsSchema, + typeof RescheduledAuditActionService.storedDataSchema + >; + + constructor() { + this.helper = new AuditActionServiceHelper({ + latestVersion: this.VERSION, + latestFieldsSchema: RescheduledAuditActionService.latestFieldsSchema, + storedDataSchema: RescheduledAuditActionService.storedDataSchema, + }); + } + + getVersionedData(fields: unknown) { + return this.helper.getVersionedData(fields); + } + + parseStored(data: unknown) { + return this.helper.parseStored(data); + } + + getVersion(data: unknown): number { + return this.helper.getVersion(data); + } + + migrateToLatest(data: unknown) { + // V1-only: validate and return as-is (no migration needed) + const validated = fieldsSchemaV1.parse(data); + return { isMigrated: false, latestData: validated }; + } + + async getDisplayTitle(storedData: { version: number; fields: z.infer }): Promise { + const rescheduledToUid = storedData.fields.rescheduledToUid.new; + return { + key: "booking_audit_action.rescheduled", + components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined, + }; + } + + getDisplayJson(storedData: { version: number; fields: z.infer }): RescheduledAuditDisplayData { + const { fields } = storedData; + return { + previousStartTime: fields.startTime.old ?? null, + newStartTime: fields.startTime.new ?? null, + previousEndTime: fields.endTime.old ?? null, + newEndTime: fields.endTime.new ?? null, + rescheduledToUid: fields.rescheduledToUid.new ?? null, + }; + } + + /** + * Finds the rescheduled log that created a specific booking + * by matching the rescheduledToUid field with the target booking UID + * @param rescheduledLogs - Array of rescheduled audit logs to search through + * @param rescheduledToBookingUid - The UID of the booking that was created from the reschedule + * @returns The matching log or null if not found + */ + getMatchingLog({ + rescheduledLogs, + rescheduledToBookingUid, + }: { + rescheduledLogs: T[]; + rescheduledToBookingUid: string; + }): T | null { + return rescheduledLogs.find((log) => { + const parsedData = this.parseStored(log.data); + return parsedData.fields.rescheduledToUid.new === rescheduledToBookingUid; + }) ?? null; + } +} + +export type RescheduledAuditData = z.infer; + +export type RescheduledAuditDisplayData = { + previousStartTime: string | null; + newStartTime: string | null; + previousEndTime: string | null; + newEndTime: string | null; + rescheduledToUid: string | null; +}; diff --git a/packages/features/booking-audit/lib/common/changeSchemas.ts b/packages/features/booking-audit/lib/common/changeSchemas.ts new file mode 100644 index 00000000000000..50025e8ec2a750 --- /dev/null +++ b/packages/features/booking-audit/lib/common/changeSchemas.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +/** + * Common change schemas for audit data + * These represent old -> new value transitions + */ + +export const StringChangeSchema = z.object({ + old: z.string().nullable(), + new: z.string().nullable(), +}); + +export const BooleanChangeSchema = z.object({ + old: z.boolean().nullable(), + new: z.boolean(), +}); + +export const StringArrayChangeSchema = z.object({ + old: z.array(z.string()).nullable(), + new: z.array(z.string()), +}); + +export const NumberChangeSchema = z.object({ + old: z.number().nullable(), + new: z.number(), +}); + diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts index 8ad0a8b2bdc628..d9f1b2be5e20a3 100644 --- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -56,12 +56,23 @@ describe("Booking Audit Integration", () => { }); testEventTypeId = eventType.id; - // Create test booking + // Create test attendee user for permission tests (needed before booking creation) + const attendeeUser = await prisma.user.create({ + data: { + email: `test-attendee-${timestamp}-${randomSuffix}@example.com`, + username: `testattendee-${timestamp}-${randomSuffix}`, + name: "Test Attendee", + }, + }); + testAttendeeUserId = attendeeUser.id; + testAttendeeEmail = attendeeUser.email; + + // Create test booking with attendee in single atomic operation const startTime = new Date(); const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); testBookingUid = `test-booking-${timestamp}-${randomSuffix}`; - const testBooking = await prisma.booking.create({ + await prisma.booking.create({ data: { uid: testBookingUid, title: "Test Booking", @@ -70,27 +81,15 @@ describe("Booking Audit Integration", () => { userId: testUserId, eventTypeId: testEventTypeId, status: BookingStatus.ACCEPTED, - }, - }); - - // Create test attendee user for permission tests - const attendeeUser = await prisma.user.create({ - data: { - email: `test-attendee-${timestamp}-${randomSuffix}@example.com`, - username: `testattendee-${timestamp}-${randomSuffix}`, - name: "Test Attendee", - }, - }); - testAttendeeUserId = attendeeUser.id; - testAttendeeEmail = attendeeUser.email; - - // Add attendee to booking - await prisma.attendee.create({ - data: { - email: testAttendeeEmail, - name: "Test Attendee", - timeZone: "UTC", - bookingId: testBooking.id, + attendees: { + create: [ + { + email: testAttendeeEmail, + name: "Test Attendee", + timeZone: "UTC", + }, + ], + }, }, }); });