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/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={
-
-
}
/>
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",
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 Booking1>")
+ */
+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",
+ },
+ ],
+ },
},
});
});
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/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/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,
});
}
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,
}));
};