refactor: apply biome formatting to packages/features (#27844)

* refactor: apply biome formatting to packages/features (batch 1 - small subdirs)

Format small subdirectories in packages/features: di, flags, holidays, oauth,
settings, users, assignment-reason, selectedCalendar, hashedLink, host, form,
form-builder, availability, data-table, pbac, schedules, troubleshooter,
eventtypes, calendar-subscription, and root-level files.

Also includes straggler apps/web BookEventForm.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 2 - medium subdirs)

Format medium subdirectories in packages/features: auth, credentials,
calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone,
tasker, and webhooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 3 - bookings + insights)

Format bookings and insights subdirectories in packages/features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 4 - ee)

Format packages/features/ee subdirectory covering billing, workflows,
organizations, teams, managed-event-types, round-robin, dsync,
integration-attribute-sync, and payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1)

Format booking-audit di, actions, common, dto, repository, and types
subdirectories in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2)

Format booking-audit service subdirectory in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eunjae Lee
2026-02-11 15:47:14 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bc8ebb9213
commit 98b6d63164
310 changed files with 4309 additions and 4585 deletions
@@ -8,11 +8,7 @@ import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBooke
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import {
APP_NAME,
WEBSITE_PRIVACY_POLICY_URL,
WEBSITE_TERMS_URL,
} from "@calcom/lib/constants";
import { APP_NAME, WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TimeFormat } from "@calcom/lib/timeFormat";
@@ -24,10 +20,7 @@ import { Form } from "@calcom/ui/components/form";
import { formatEventFromTime } from "@calcom/features/bookings/Booker/utils/dates";
import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/hooks/useBookingForm";
import type {
IUseBookingErrors,
IUseBookingLoadingStates,
} from "../../hooks/useBookings";
import type { IUseBookingErrors, IUseBookingLoadingStates } from "../../hooks/useBookings";
import { BookingFields } from "./BookingFields";
import { FormSkeleton } from "./Skeleton";
@@ -75,10 +68,7 @@ export const BookEventForm = ({
eventQuery: {
isError: boolean;
isPending: boolean;
data?: Pick<
BookerEvent,
"price" | "currency" | "metadata" | "bookingFields" | "locations"
> | null;
data?: Pick<BookerEvent, "price" | "currency" | "metadata" | "bookingFields" | "locations"> | null;
};
}) => {
const eventType = eventQuery.data;
@@ -86,9 +76,7 @@ export const BookEventForm = ({
const bookingData = useBookerStoreContext((state) => state.bookingData);
const rescheduleUid = useBookerStoreContext((state) => state.rescheduleUid);
const username = useBookerStoreContext((state) => state.username);
const isInstantMeeting = useBookerStoreContext(
(state) => state.isInstantMeeting
);
const isInstantMeeting = useBookerStoreContext((state) => state.isInstantMeeting);
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const { timeFormat, timezone } = useBookerTime();
@@ -98,11 +86,7 @@ export const BookEventForm = ({
const isPaidEvent = useMemo(() => {
if (!eventType?.price) return false;
const paymentAppData = getPaymentAppData(eventType);
return (
eventType?.price > 0 &&
!Number.isNaN(paymentAppData.price) &&
paymentAppData.price > 0
);
return eventType?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
}, [eventType]);
const paymentCurrency = useMemo(() => {
@@ -110,8 +94,7 @@ export const BookEventForm = ({
return getPaymentAppData(eventType)?.currency || "USD";
}, [eventType]);
if (eventQuery.isError)
return <Alert severity="warning" message={t("error_booking_event")} />;
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
if (eventQuery.isPending || !eventQuery.data) return <FormSkeleton />;
if (!timeslot)
return (
@@ -144,8 +127,7 @@ export const BookEventForm = ({
}}
form={bookingForm}
handleSubmit={onSubmit}
noValidate
>
noValidate>
<BookingFields
isDynamicGroupBooking={!!(username && username.indexOf("+") > -1)}
fields={eventType.bookingFields}
@@ -187,8 +169,7 @@ export const BookEventForm = ({
key="please-select-a-new-time-button"
type="button"
className="underline"
onClick={onCancel}
>
onClick={onCancel}>
Please select a new time
</button>,
]}
@@ -209,16 +190,14 @@ export const BookEventForm = ({
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_TERMS_URL}`}
target="_blank"
>
target="_blank">
Terms
</Link>,
<Link
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_PRIVACY_POLICY_URL}`}
target="_blank"
>
target="_blank">
Privacy Policy.
</Link>,
]}
@@ -233,8 +212,7 @@ export const BookEventForm = ({
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_TERMS_URL}`}
target="_blank"
>
target="_blank">
{t("terms")}
</Link>{" "}
{t("and")}{" "}
@@ -242,8 +220,7 @@ export const BookEventForm = ({
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_PRIVACY_POLICY_URL}`}
target="_blank"
>
target="_blank">
{t("privacy_policy")}
</Link>
.
@@ -251,11 +228,7 @@ export const BookEventForm = ({
)}
<div className="flex justify-end mt-auto space-x-2 modalsticky rtl:space-x-reverse">
{isInstantMeeting ? (
<Button
type="submit"
color="primary"
loading={loadingStates.creatingInstantBooking}
>
<Button type="submit" color="primary" loading={loadingStates.creatingInstantBooking}>
{isPaidEvent ? t("pay_and_book") : t("confirm")}
</Button>
) : (
@@ -266,8 +239,7 @@ export const BookEventForm = ({
type="button"
onClick={onCancel}
data-testid="back"
className={classNames?.backButton}
>
className={classNames?.backButton}>
{t("back")}
</Button>
)}
@@ -276,9 +248,7 @@ export const BookEventForm = ({
type="submit"
color="primary"
disabled={
(!!shouldRenderCaptcha && !watchedCfToken) ||
isTimeslotUnavailable ||
confirmButtonDisabled
(!!shouldRenderCaptcha && !watchedCfToken) || isTimeslotUnavailable || confirmButtonDisabled
}
loading={
loadingStates.creatingBooking ||
@@ -287,11 +257,8 @@ export const BookEventForm = ({
}
className={classNames?.confirmButton}
data-testid={
rescheduleUid && bookingData
? "confirm-reschedule-button"
: "confirm-book-button"
}
>
rescheduleUid && bookingData ? "confirm-reschedule-button" : "confirm-book-button"
}>
{rescheduleUid && bookingData
? t("reschedule")
: renderConfirmNotVerifyEmailButtonCond
@@ -352,9 +319,7 @@ const getError = ({
}
const messageKey =
error.message === ErrorCode.BookerLimitExceeded
? "booker_upcoming_limit_reached"
: error.message;
error.message === ErrorCode.BookerLimitExceeded ? "booker_upcoming_limit_reached" : error.message;
return error?.message ? (
<>
@@ -362,9 +327,7 @@ const getError = ({
{error.data?.traceId && (
<div className="mt-2 text-xs text-subtle">
<span className="font-medium">{t("trace_reference_id")}:</span>
<code className="ml-1 font-mono break-all select-all">
{error.data.traceId}
</code>
<code className="ml-1 font-mono break-all select-all">{error.data.traceId}</code>
</div>
)}
</>
+43 -43
View File
@@ -86,22 +86,22 @@ export class CalendarEventBuilder {
if (!eventType) throw new Error(`Booking ${uid} is missing eventType — it may have been deleted.`);
const builder = new CalendarEventBuilder();
const {
description,
attendees,
references,
title,
startTime,
endTime,
location,
responses,
customInputs,
iCalUID,
iCalSequence,
oneTimePassword,
seatsReferences,
assignmentReason,
} = booking;
const {
description,
attendees,
references,
title,
startTime,
endTime,
location,
responses,
customInputs,
iCalUID,
iCalSequence,
oneTimePassword,
seatsReferences,
assignmentReason,
} = booking;
const {
conferenceCredentialId,
@@ -187,18 +187,18 @@ export class CalendarEventBuilder {
platformCancelUrl,
platformBookingUrl,
})
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword)
.withOrganization(organizationId)
.withAssignmentReason(
assignmentReason?.[0]?.reasonEnum
? {
category: getAssignmentReasonCategory(assignmentReason[0].reasonEnum),
details: assignmentReason[0].reasonString ?? null,
}
: null
);
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword)
.withOrganization(organizationId)
.withAssignmentReason(
assignmentReason?.[0]?.reasonEnum
? {
category: getAssignmentReasonCategory(assignmentReason[0].reasonEnum),
details: assignmentReason[0].reasonString ?? null,
}
: null
);
// Seats
if (seatsReferences?.length && bookingResponses) {
@@ -533,23 +533,23 @@ export class CalendarEventBuilder {
return this;
}
withHashedLink(hashedLink?: string | null) {
this.event = {
...this.event,
hashedLink,
};
return this;
}
withHashedLink(hashedLink?: string | null) {
this.event = {
...this.event,
hashedLink,
};
return this;
}
withAssignmentReason(assignmentReason?: { category: string; details?: string | null } | null) {
this.event = {
...this.event,
assignmentReason,
};
return this;
}
withAssignmentReason(assignmentReason?: { category: string; details?: string | null } | null) {
this.event = {
...this.event,
assignmentReason,
};
return this;
}
build(): CalendarEvent | null {
build(): CalendarEvent | null {
// Validate required fields
if (
!this.event.startTime ||
@@ -64,4 +64,3 @@ export class AssignmentReasonRepository {
});
}
}
@@ -1,9 +1,6 @@
import { createContainer } from "@calcom/features/di/di";
import {
type AttributeService,
moduleLoader as attributeServiceModule,
} from "./AttributeService.module";
import { type AttributeService, moduleLoader as attributeServiceModule } from "./AttributeService.module";
const attributeServiceContainer = createContainer();
@@ -1,8 +1,4 @@
import {
bindModuleToClassOnToken,
createModule,
type ModuleLoader,
} from "@calcom/features/di/di";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { AttributeService } from "../services/AttributeService";
import { moduleLoader as attributeToUserRepositoryModuleLoader } from "./AttributeToUserRepository.module";
@@ -868,18 +868,22 @@ describe("getAttributes", () => {
attributeOptionId: "level-senior",
});
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } = await getAttributesAssignmentData({
teamId: team.id,
orgId,
attributeIds: ["attr1", "attr2"],
});
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({
teamId: team.id,
orgId,
attributeIds: ["attr1", "attr2"],
});
expect(attributesOfTheOrg).toHaveLength(2);
expect(attributesOfTheOrg.map((a) => a.id).sort()).toEqual(["attr1", "attr2"]);
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(attributesAssignedToTeamMembersWithOptions[0].userId).toBe(user.id);
expect(Object.keys(attributesAssignedToTeamMembersWithOptions[0].attributes)).toEqual(["attr1", "attr2"]);
expect(Object.keys(attributesAssignedToTeamMembersWithOptions[0].attributes)).toEqual([
"attr1",
"attr2",
]);
expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr1).toBeDefined();
expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr2).toBeDefined();
expect(attributesAssignedToTeamMembersWithOptions[0].attributes.attr3).toBeUndefined();
@@ -898,7 +902,9 @@ describe("getAttributes", () => {
name: "Department",
slug: "department",
type: AttributeType.SINGLE_SELECT,
options: [{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }],
options: [
{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
],
});
await createMockAttribute({
@@ -942,7 +948,9 @@ describe("getAttributes", () => {
name: "Department",
slug: "department",
type: AttributeType.SINGLE_SELECT,
options: [{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }],
options: [
{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
],
});
await createMockAttributeAssignment({
@@ -973,7 +981,9 @@ describe("getAttributes", () => {
name: "Department",
slug: "department",
type: AttributeType.SINGLE_SELECT,
options: [{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }],
options: [
{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
],
});
await createMockAttributeAssignment({
@@ -1006,10 +1016,22 @@ describe("getAttributes", () => {
data: { name: "User 2", email: "user2@test.com" },
});
const orgMembership2 = await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: orgId, userId: user2.id },
data: {
role: MembershipRole.MEMBER,
disableImpersonation: false,
accepted: true,
teamId: orgId,
userId: user2.id,
},
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: team.id, userId: user2.id },
data: {
role: MembershipRole.MEMBER,
disableImpersonation: false,
accepted: true,
teamId: team.id,
userId: user2.id,
},
});
// User 3 has no attribute assignments at all
@@ -1017,10 +1039,22 @@ describe("getAttributes", () => {
data: { name: "User 3", email: "user3@test.com" },
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: orgId, userId: user3.id },
data: {
role: MembershipRole.MEMBER,
disableImpersonation: false,
accepted: true,
teamId: orgId,
userId: user3.id,
},
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: team.id, userId: user3.id },
data: {
role: MembershipRole.MEMBER,
disableImpersonation: false,
accepted: true,
teamId: team.id,
userId: user3.id,
},
});
await createMockAttribute({
@@ -1,9 +1,6 @@
// TODO: Queries in this file are not optimized. Need to optimize them.
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import type {
AttributeId,
AttributeOptionValueWithType,
} from "@calcom/app-store/routing-forms/types/types";
import type { AttributeId, AttributeOptionValueWithType } from "@calcom/app-store/routing-forms/types/types";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository";
import logger from "@calcom/lib/logger";
@@ -88,13 +85,7 @@ type FullAttribute = {
}[];
};
async function _findMembershipsForBothOrgAndTeam({
orgId,
teamId,
}: {
orgId: number;
teamId: number;
}) {
async function _findMembershipsForBothOrgAndTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
const memberships = await prisma.membership.findMany({
where: {
teamId: {
@@ -186,10 +177,7 @@ function _prepareAssignmentData({
newAttributeOptionValue,
];
} else if (currentAttributeOptionValue) {
attributes[attribute.id].attributeOption = [
currentAttributeOptionValue,
newAttributeOptionValue,
];
attributes[attribute.id].attributeOption = [currentAttributeOptionValue, newAttributeOptionValue];
} else {
// Set the first value
attributes[attribute.id] = {
@@ -231,9 +219,7 @@ function _prepareAssignmentData({
console.error(
`Enriching "contains" for attribute ${
attribute.name
}: Option with id ${optionId} not found. Looked up in ${JSON.stringify(
allOptions
)}`
}: Option with id ${optionId} not found. Looked up in ${JSON.stringify(allOptions)}`
);
return null;
}
@@ -243,9 +229,7 @@ function _prepareAssignmentData({
slug: option.slug,
};
})
.filter(
(option): option is NonNullable<typeof option> => option !== null
);
.filter((option): option is NonNullable<typeof option> => option !== null);
}
}
@@ -255,10 +239,7 @@ function _prepareAssignmentData({
*/
function _buildAttributeLookupMaps(attributesOfTheOrg: FullAttribute[]) {
const optionIdToAttribute = new Map<AttributeOptionId, FullAttribute>();
const optionIdToOption = new Map<
AttributeOptionId,
FullAttribute["options"][number]
>();
const optionIdToOption = new Map<AttributeOptionId, FullAttribute["options"][number]>();
const attributeIdToOptions = new Map<string, FullAttribute["options"]>();
for (const attribute of attributesOfTheOrg) {
@@ -294,31 +275,19 @@ function _getAttributeOptionFromId({
return lookupMaps.optionIdToOption.get(attributeOptionId);
}
async function _getOrgMembershipToUserIdForTeam({
orgId,
teamId,
}: {
orgId: number;
teamId: number;
}) {
const { orgMemberships, teamMemberships } =
await _findMembershipsForBothOrgAndTeam({
orgId,
teamId,
});
async function _getOrgMembershipToUserIdForTeam({ orgId, teamId }: { orgId: number; teamId: number }) {
const { orgMemberships, teamMemberships } = await _findMembershipsForBothOrgAndTeam({
orgId,
teamId,
});
// Using map for performance lookup as it matters in the below loop working with 1000s of records
const orgMembershipsByUserId = new Map(
orgMemberships.map((m) => [m.userId, m])
);
const orgMembershipsByUserId = new Map(orgMemberships.map((m) => [m.userId, m]));
/**
* Holds the records of orgMembershipId to userId for the sub-team's members only.
*/
const orgMembershipToUserIdForTeamMembers = new Map<
OrgMembershipId,
UserId
>();
const orgMembershipToUserIdForTeamMembers = new Map<OrgMembershipId, UserId>();
/**
* For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300
@@ -332,10 +301,7 @@ async function _getOrgMembershipToUserIdForTeam({
// );
return;
}
orgMembershipToUserIdForTeamMembers.set(
orgMembership.id,
orgMembership.userId
);
orgMembershipToUserIdForTeamMembers.set(orgMembership.id, orgMembership.userId);
});
return orgMembershipToUserIdForTeamMembers;
@@ -354,23 +320,19 @@ async function _queryAllData({
const attributeRepo = new PrismaAttributeRepository(prisma);
const attributeToUserRepo = new PrismaAttributeToUserRepository(prisma);
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] =
await Promise.all([
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
attributeRepo.findManyByOrgId({ orgId, attributeIds }),
]);
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
attributeRepo.findManyByOrgId({ orgId, attributeIds }),
]);
const orgMembershipIds = Array.from(
orgMembershipToUserIdForTeamMembers.keys()
);
const orgMembershipIds = Array.from(orgMembershipToUserIdForTeamMembers.keys());
// Get the attributes assigned to the members of the team
// If attributeIds is provided, only fetch assignments for those specific attributes
const attributesToUsersForTeam =
await attributeToUserRepo.findManyByOrgMembershipIds({
orgMembershipIds,
attributeIds,
});
const attributesToUsersForTeam = await attributeToUserRepo.findManyByOrgMembershipIds({
orgMembershipIds,
attributeIds,
});
return {
attributesOfTheOrg,
@@ -379,13 +341,7 @@ async function _queryAllData({
};
}
async function getAttributesAssignedToMembersOfTeam({
teamId,
userId,
}: {
teamId: number;
userId?: number;
}) {
async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) {
const log = logger.getSubLogger({
prefix: ["getAttributeToUserWithMembershipAndAttributes"],
});
@@ -455,9 +411,7 @@ function _buildAssignmentsForTeam({
const orgMembershipId = attributeToUser.memberId;
const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId);
if (!userId) {
console.error(
`No org membership found for membership id ${orgMembershipId}`
);
console.error(`No org membership found for membership id ${orgMembershipId}`);
return null;
}
const attribute = _getAttributeFromAttributeOption({
@@ -484,10 +438,7 @@ function _buildAssignmentsForTeam({
attributeOption,
};
})
.filter(
(assignment): assignment is NonNullable<typeof assignment> =>
assignment !== null
);
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null);
}
export async function getAttributesAssignmentData({
@@ -501,15 +452,12 @@ export async function getAttributesAssignmentData({
* This significantly improves performance when only a few attributes are needed. */
attributeIds?: string[];
}) {
const {
attributesOfTheOrg,
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
} = await _queryAllData({
orgId,
teamId,
attributeIds,
});
const { attributesOfTheOrg, attributesToUsersForTeam, orgMembershipToUserIdForTeamMembers } =
await _queryAllData({
orgId,
teamId,
attributeIds,
});
const lookupMaps = _buildAttributeLookupMaps(attributesOfTheOrg);
@@ -550,12 +498,6 @@ export async function getAttributesForTeam({ teamId }: { teamId: number }) {
return attributes satisfies Attribute[];
}
export async function getUsersAttributes({
userId,
teamId,
}: {
userId: number;
teamId: number;
}) {
export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) {
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
}
@@ -103,13 +103,7 @@ export class PrismaAttributeRepository {
});
}
findManyByIdsAndOrgIdWithOptions({
attributeIds,
orgId,
}: {
attributeIds: string[];
orgId: number;
}) {
findManyByIdsAndOrgIdWithOptions({ attributeIds, orgId }: { attributeIds: string[]; orgId: number }) {
return this.prismaClient.attribute.findMany({
where: {
teamId: orgId,
@@ -30,10 +30,11 @@ export class AttributeService {
userId: number;
orgId: number;
}): Promise<Record<string, UserAttribute>> {
const attributeOptionsAssignedToUser =
await this.deps.attributeToUserRepository.findManyIncludeAttribute({
const attributeOptionsAssignedToUser = await this.deps.attributeToUserRepository.findManyIncludeAttribute(
{
member: { userId, teamId: orgId },
});
}
);
const userAttributes: Record<string, UserAttribute> = {};
@@ -17,16 +17,13 @@ describe("AttributeService", () => {
};
service = new AttributeService({
attributeToUserRepository:
mockAttributeToUserRepository as unknown as PrismaAttributeToUserRepository,
attributeToUserRepository: mockAttributeToUserRepository as unknown as PrismaAttributeToUserRepository,
});
});
describe("getUsersAttributesByOrgMembershipId", () => {
it("should return empty object when user has no attributes", async () => {
mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue(
[]
);
mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([]);
const result = await service.getUsersAttributesByOrgMembershipId({
userId: 1,
@@ -34,9 +31,7 @@ describe("AttributeService", () => {
});
expect(result).toEqual({});
expect(
mockAttributeToUserRepository.findManyIncludeAttribute
).toHaveBeenCalledWith({
expect(mockAttributeToUserRepository.findManyIncludeAttribute).toHaveBeenCalledWith({
member: { userId: 1, teamId: 100 },
});
});
@@ -77,7 +77,9 @@ function createDeploymentRepositoryMock(): {
}
function createUserRepositoryMock(): {
UserRepository: new (_prisma: PrismaClient) => {
UserRepository: new (
_prisma: PrismaClient
) => {
enrichUserWithTheProfile: (params: { user: User }) => Promise<User & { profile: null }>;
};
} {
@@ -1,4 +1,3 @@
import parser from "accept-language-parser";
import type { GetServerSidePropsContext, NextApiRequest } from "next";
@@ -64,17 +64,14 @@ describe("getServerSession", () => {
});
describe("User ID Validation", () => {
it.each(["", "invalid", "0", "-1"])(
"returns null when token.sub is invalid (%s)",
async (sub) => {
setupGetTokenMock(createMockToken({ sub }));
it.each(["", "invalid", "0", "-1"])("returns null when token.sub is invalid (%s)", async (sub) => {
setupGetTokenMock(createMockToken({ sub }));
const result = await getServerSession({ req: createMockRequest() });
const result = await getServerSession({ req: createMockRequest() });
expect(result).toBeNull();
expect(prismaMock.user.findUnique).not.toHaveBeenCalled();
}
);
expect(result).toBeNull();
expect(prismaMock.user.findUnique).not.toHaveBeenCalled();
});
});
describe("User Lookup", () => {
@@ -23,9 +23,11 @@ const mockFindByEmailAndIncludeProfilesAndPassword = vi.fn();
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(function() { return {
findByEmailAndIncludeProfilesAndPassword: mockFindByEmailAndIncludeProfilesAndPassword,
}; }),
UserRepository: vi.fn().mockImplementation(function () {
return {
findByEmailAndIncludeProfilesAndPassword: mockFindByEmailAndIncludeProfilesAndPassword,
};
}),
};
});
+27 -27
View File
@@ -640,14 +640,14 @@ export const getOptions = ({
org:
profileOrg && !profileOrg.isPlatform
? {
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg
}
id: profileOrg.id,
name: profileOrg.name,
slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "",
logoUrl: profileOrg.logoUrl,
fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""),
domainSuffix: subdomainSuffix(),
role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg
}
: null,
} as JWT;
};
@@ -881,7 +881,10 @@ export const getOptions = ({
}
if (!user.name) {
log.warn("callbacks:signIn - user name is missing", { emailDomain: user.email.split("@")[1], provider: account?.provider });
log.warn("callbacks:signIn - user name is missing", {
emailDomain: user.email.split("@")[1],
provider: account?.provider,
});
return false;
}
if (account?.provider) {
@@ -1024,7 +1027,11 @@ export const getOptions = ({
// Verify SAML IdP is authoritative before auto-merge
if (idP === IdentityProvider.SAML) {
const samlTenant = getSamlTenant();
const validation = await validateSamlAccountConversion(samlTenant, user.email, "SelfHosted→SAML");
const validation = await validateSamlAccountConversion(
samlTenant,
user.email,
"SelfHosted→SAML"
);
if (!validation.allowed) {
return validation.errorUrl;
}
@@ -1109,12 +1116,8 @@ export const getOptions = ({
} else {
return true;
}
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.CAL
) {
log.error(
`Userid ${user.id} already exists with CAL identity provider`
);
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
log.error(`Userid ${user.id} already exists with CAL identity provider`);
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
} else if (
existingUserWithEmail.identityProvider === IdentityProvider.GOOGLE &&
@@ -1143,17 +1146,14 @@ export const getOptions = ({
return true;
}
}
log.error(
`Userid ${user.id} trying to login with the wrong provider`,
{
userId: user.id,
account: {
providerAccountId: account?.providerAccountId,
type: account?.type,
provider: account?.provider,
},
}
);
log.error(`Userid ${user.id} trying to login with the wrong provider`, {
userId: user.id,
account: {
providerAccountId: account?.providerAccountId,
type: account?.type,
provider: account?.provider,
},
});
return `/auth/error?error=wrong-provider&provider=${existingUserWithEmail.identityProvider}`;
}
@@ -65,9 +65,7 @@ export class SamlAccountLinkingService {
}
}
export type AccountConversionValidationResult =
| { allowed: true }
| { allowed: false; errorUrl: string };
export type AccountConversionValidationResult = { allowed: true } | { allowed: false; errorUrl: string };
export async function validateSamlAccountConversion(
samlTenant: string | undefined,
@@ -76,7 +74,10 @@ export async function validateSamlAccountConversion(
): Promise<AccountConversionValidationResult> {
if (!samlTenant) {
// Deny by default - if tenant is missing, we cannot verify IdP authority
log.error("SAML conversion blocked - missing tenant", { emailDomain: email.split("@")[1], conversionContext });
log.error("SAML conversion blocked - missing tenant", {
emailDomain: email.split("@")[1],
conversionContext,
});
return { allowed: false, errorUrl: SAML_NOT_AUTHORITATIVE_ERROR_URL };
}
@@ -3,7 +3,7 @@ import Handlebars from "handlebars";
import type { SendVerificationRequestParams } from "next-auth/providers/email";
import type { TransportOptions } from "nodemailer";
import nodemailer from "nodemailer";
import path from "node:path"
import path from "node:path";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { serverConfig } from "@calcom/lib/serverConfig";
@@ -60,11 +60,7 @@ async function createTestOrganization(data: {
return { ...team, organizationSettings };
}
async function createTestSubteam(data: {
name: string;
slug: string;
parentId: number;
}): Promise<Team> {
async function createTestSubteam(data: { name: string; slug: string; parentId: number }): Promise<Team> {
const uniqueId = generateUniqueId();
const uniqueSlug = `${data.slug}-${uniqueId}`;
@@ -95,10 +91,7 @@ describe("createOrUpdateMemberships Integration Tests", () => {
// Clean up in reverse dependency order
await prisma.profile.deleteMany({
where: {
OR: [
{ userId: { in: userIds } },
{ organizationId: { in: teamIds } },
],
OR: [{ userId: { in: userIds } }, { organizationId: { in: teamIds } }],
},
});
await prisma.membership.deleteMany({
@@ -23,10 +23,10 @@ export const createOrUpdateMemberships = async ({
}) => {
return await prisma.$transaction(async (tx) => {
// Determine the organization context - either the team itself (if it's an org) or its parent
const organizationId = team.isOrganization ? team.id : team.parent?.id ?? null;
const organizationId = team.isOrganization ? team.id : (team.parent?.id ?? null);
const orgSettings = team.isOrganization
? team.organizationSettings
: team.parent?.organizationSettings ?? null;
: (team.parent?.organizationSettings ?? null);
// Create profile if user is joining an organization context (either directly or via sub-team)
if (organizationId) {
@@ -46,14 +46,17 @@ export const getAggregatedAvailability = (
const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (roundRobinHosts.length) {
// Group round robin hosts by their groupId
const hostsByGroup = roundRobinHosts.reduce((groups, host) => {
const groupId = host.user?.groupId || DEFAULT_GROUP_ID;
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(host);
return groups;
}, {} as Record<string, typeof roundRobinHosts>);
const hostsByGroup = roundRobinHosts.reduce(
(groups, host) => {
const groupId = host.user?.groupId || DEFAULT_GROUP_ID;
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(host);
return groups;
},
{} as Record<string, typeof roundRobinHosts>
);
// at least one host from each group needs to be available
Object.values(hostsByGroup).forEach((groupHosts) => {
@@ -8,16 +8,16 @@ export const actorRepositoryModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: actorRepositoryModule,
moduleToken,
token,
classs: PrismaAuditActorRepository,
depsMap: {
prismaClient: prismaModuleLoader,
},
module: actorRepositoryModule,
moduleToken,
token,
classs: PrismaAuditActorRepository,
depsMap: {
prismaClient: prismaModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule,
token,
loadModule,
};
@@ -1,9 +1,7 @@
import { createContainer } from "@calcom/features/di/di";
import type { IAuditActorRepository } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository";
import {
moduleLoader as auditActorRepositoryModule,
} from "./AuditActorRepository.module";
import { moduleLoader as auditActorRepositoryModule } from "./AuditActorRepository.module";
const container = createContainer();
@@ -8,17 +8,16 @@ export const auditActorRepositoryModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.AUDIT_ACTOR_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: auditActorRepositoryModule,
moduleToken,
token,
classs: PrismaAuditActorRepository,
depsMap: {
prismaClient: prismaModuleLoader,
},
module: auditActorRepositoryModule,
moduleToken,
token,
classs: PrismaAuditActorRepository,
depsMap: {
prismaClient: prismaModuleLoader,
},
});
export const moduleLoader = {
token,
loadModule,
token,
loadModule,
};
@@ -1,9 +1,7 @@
import { createContainer } from "@calcom/features/di/di";
import type { BookingAuditProducerService } from "@calcom/features/booking-audit/lib/service/BookingAuditProducerService.interface";
import {
moduleLoader as bookingAuditTaskerProducerServiceModule,
} from "./BookingAuditTaskerProducerService.module";
import { moduleLoader as bookingAuditTaskerProducerServiceModule } from "./BookingAuditTaskerProducerService.module";
const container = createContainer();
@@ -12,4 +10,3 @@ export function getBookingAuditProducerService() {
return container.get<BookingAuditProducerService>(bookingAuditTaskerProducerServiceModule.token);
}
@@ -1,9 +1,7 @@
import { createContainer } from "@calcom/features/di/di";
import type { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/service/BookingAuditTaskConsumer";
import {
moduleLoader as bookingAuditTaskConsumerModule,
} from "./BookingAuditTaskConsumer.module";
import { moduleLoader as bookingAuditTaskConsumerModule } from "./BookingAuditTaskConsumer.module";
const container = createContainer();
@@ -12,4 +10,3 @@ export function getBookingAuditTaskConsumer() {
return container.get<BookingAuditTaskConsumer>(bookingAuditTaskConsumerModule.token);
}
@@ -28,5 +28,5 @@ const loadModule = bindModuleToClassOnToken({
export const moduleLoader = {
token,
loadModule
loadModule,
};
@@ -24,5 +24,5 @@ const loadModule = bindModuleToClassOnToken({
export const moduleLoader = {
token,
loadModule
loadModule,
};
@@ -12,4 +12,3 @@ export function getBookingAuditViewerService() {
return container.get<BookingAuditViewerService>(bookingAuditViewerServiceModule.token);
}
@@ -14,7 +14,7 @@ export const bookingAuditViewerServiceModule = createModule();
const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE;
const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_AUDIT_VIEWER_SERVICE_MODULE;
export { BookingAuditViewerService }
export { BookingAuditViewerService };
const loadModule = bindModuleToClassOnToken({
module: bookingAuditViewerServiceModule,
@@ -34,6 +34,5 @@ const loadModule = bindModuleToClassOnToken({
export const moduleLoader = {
token,
loadModule
loadModule,
};
+12 -12
View File
@@ -1,14 +1,14 @@
export const BOOKING_AUDIT_DI_TOKENS = {
BOOKING_AUDIT_VIEWER_SERVICE: Symbol("BookingAuditViewerService"),
BOOKING_AUDIT_VIEWER_SERVICE_MODULE: Symbol("BookingAuditViewerServiceModule"),
BOOKING_AUDIT_PRODUCER_SERVICE: Symbol("BookingAuditProducerService"),
BOOKING_AUDIT_PRODUCER_SERVICE_MODULE: Symbol("BookingAuditProducerServiceModule"),
BOOKING_AUDIT_TASK_CONSUMER: Symbol("BookingAuditTaskConsumer"),
BOOKING_AUDIT_TASK_CONSUMER_MODULE: Symbol("BookingAuditTaskConsumerModule"),
BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"),
BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"),
AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"),
AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"),
BOOKING_HISTORY_VIEWER_SERVICE: Symbol("BookingHistoryViewerService"),
BOOKING_HISTORY_VIEWER_SERVICE_MODULE: Symbol("BookingHistoryViewerServiceModule"),
BOOKING_AUDIT_VIEWER_SERVICE: Symbol("BookingAuditViewerService"),
BOOKING_AUDIT_VIEWER_SERVICE_MODULE: Symbol("BookingAuditViewerServiceModule"),
BOOKING_AUDIT_PRODUCER_SERVICE: Symbol("BookingAuditProducerService"),
BOOKING_AUDIT_PRODUCER_SERVICE_MODULE: Symbol("BookingAuditProducerServiceModule"),
BOOKING_AUDIT_TASK_CONSUMER: Symbol("BookingAuditTaskConsumer"),
BOOKING_AUDIT_TASK_CONSUMER_MODULE: Symbol("BookingAuditTaskConsumerModule"),
BOOKING_AUDIT_REPOSITORY: Symbol("BookingAuditRepository"),
BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"),
AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"),
AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"),
BOOKING_HISTORY_VIEWER_SERVICE: Symbol("BookingHistoryViewerService"),
BOOKING_HISTORY_VIEWER_SERVICE_MODULE: Symbol("BookingHistoryViewerServiceModule"),
};
@@ -4,7 +4,12 @@ import { z } from "zod";
import { BookingStatusChangeSchema } from "../common/changeSchemas";
import type { DataRequirements } from "../service/EnrichmentDataStore";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
import type {
IAuditActionService,
TranslationWithParams,
GetDisplayTitleParams,
GetDisplayJsonParams,
} from "./IAuditActionService";
/**
* Accepted Audit Action Service
@@ -13,75 +18,73 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
status: BookingStatusChangeSchema,
status: BookingStatusChangeSchema,
});
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,
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,
});
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);
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(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 };
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.accepted" };
}
getDisplayJson({
storedData,
}: GetDisplayJsonParams): AcceptedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
getDisplayJson({ storedData }: GetDisplayJsonParams): AcceptedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AcceptedAuditDisplayData = {
previousStatus: BookingStatus | null;
newStatus: BookingStatus | null;
previousStatus: BookingStatus | null;
newStatus: BookingStatus | null;
};
@@ -3,7 +3,12 @@ import { z } from "zod";
import { StringArrayChangeSchema } from "../common/changeSchemas";
import type { DataRequirements } from "../service/EnrichmentDataStore";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
import type {
IAuditActionService,
TranslationWithParams,
GetDisplayTitleParams,
GetDisplayJsonParams,
} from "./IAuditActionService";
/**
* Attendee Removed Audit Action Service
@@ -12,77 +17,75 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
attendees: StringArrayChangeSchema,
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,
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,
});
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);
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(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 };
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.attendee_removed" };
}
getDisplayJson({
storedData,
}: GetDisplayJsonParams): AttendeeRemovedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
const removedAttendees = (fields.attendees.old ?? []).filter(
(email) => !remainingAttendeesSet.has(email)
);
return {
removedAttendees,
};
}
getDisplayJson({ storedData }: GetDisplayJsonParams): AttendeeRemovedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
const removedAttendees = (fields.attendees.old ?? []).filter(
(email) => !remainingAttendeesSet.has(email)
);
return {
removedAttendees,
};
}
}
export type AttendeeRemovedAuditData = z.infer<typeof fieldsSchemaV1>;
export type AttendeeRemovedAuditDisplayData = {
removedAttendees: string[];
removedAttendees: string[];
};
@@ -3,14 +3,14 @@ import { formatInTimeZone } from "date-fns-tz";
/**
* Audit Action Service Helper
*
*
* Provides reusable utility methods for audit action services via composition.
*
*
* We use composition instead of inheritance for Action services so that services can evolve to v2, v3 independently without polluting a shared base class
*/
export class AuditActionServiceHelper<
TLatestFieldsSchema extends z.ZodTypeAny,
TStoredDataSchema extends z.ZodTypeAny
TStoredDataSchema extends z.ZodTypeAny,
> {
private readonly latestFieldsSchema: TLatestFieldsSchema;
private readonly latestVersion: number;
@@ -83,4 +83,3 @@ export class AuditActionServiceHelper<
return formatInTimeZone(new Date(date), timeZone, "yyyy-MM-dd HH:mm:ss");
}
}
@@ -3,7 +3,12 @@ import { z } from "zod";
import { BookingStatusChangeSchema } from "../common/changeSchemas";
import type { DataRequirements } from "../service/EnrichmentDataStore";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
import type {
IAuditActionService,
TranslationWithParams,
GetDisplayTitleParams,
GetDisplayJsonParams,
} from "./IAuditActionService";
/**
* Cancelled Audit Action Service
@@ -12,81 +17,79 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
cancellationReason: z.string().nullable(),
cancelledBy: z.string().nullable(),
status: BookingStatusChangeSchema,
cancellationReason: z.string().nullable(),
cancelledBy: z.string().nullable(),
status: BookingStatusChangeSchema,
});
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,
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,
});
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);
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(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 };
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
return { key: "booking_audit_action.cancelled" };
}
getDisplayJson({
storedData,
}: GetDisplayJsonParams): CancelledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
cancellationReason: fields.cancellationReason ?? null,
cancelledBy: fields.cancelledBy ?? null,
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
getDisplayJson({ storedData }: GetDisplayJsonParams): CancelledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
return {
cancellationReason: fields.cancellationReason ?? null,
cancelledBy: fields.cancelledBy ?? null,
previousStatus: fields.status.old ?? null,
newStatus: fields.status.new ?? null,
};
}
}
export type CancelledAuditData = z.infer<typeof fieldsSchemaV1>;
export type CancelledAuditDisplayData = {
cancellationReason: string | null;
cancelledBy: string | null;
previousStatus: string | null;
newStatus: string | null;
cancellationReason: string | null;
cancelledBy: string | null;
previousStatus: string | null;
newStatus: string | null;
};
@@ -2,105 +2,110 @@ import { z } from "zod";
import { BookingStatus } from "@calcom/prisma/enums";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
import type {
IAuditActionService,
TranslationWithParams,
GetDisplayTitleParams,
GetDisplayJsonParams,
BaseStoredAuditData,
} from "./IAuditActionService";
import type { DataRequirements } from "../service/EnrichmentDataStore";
/**
* Created Audit Action Service
*
*
* Note: CREATED action captures initial state, so it doesn't use { old, new } pattern
*/
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
startTime: z.number(),
endTime: z.number(),
status: z.nativeEnum(BookingStatus),
hostUserUuid: z.string().nullable(),
// Allowing it to be optional because most of the time(non-seated booking) it won't be there
seatReferenceUid: z.string().nullish(),
startTime: z.number(),
endTime: z.number(),
status: z.nativeEnum(BookingStatus),
hostUserUuid: z.string().nullable(),
// Allowing it to be optional because most of the time(non-seated booking) it won't be there
seatReferenceUid: z.string().nullish(),
});
export class CreatedAuditActionService implements IAuditActionService {
readonly VERSION = 1;
public static readonly TYPE = "CREATED" as const;
private static dataSchemaV1 = z.object({
version: z.literal(1),
fields: fieldsSchemaV1,
readonly VERSION = 1;
public static readonly TYPE = "CREATED" 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 = CreatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<
typeof CreatedAuditActionService.latestFieldsSchema,
typeof CreatedAuditActionService.storedDataSchema
>;
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
storedDataSchema: CreatedAuditActionService.storedDataSchema,
});
private static fieldsSchemaV1 = fieldsSchemaV1;
public static readonly latestFieldsSchema = fieldsSchemaV1;
// Union of all versions
public static readonly storedDataSchema = CreatedAuditActionService.dataSchemaV1;
// Union of all versions
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
private helper: AuditActionServiceHelper<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
}
constructor() {
this.helper = new AuditActionServiceHelper({
latestVersion: this.VERSION,
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
storedDataSchema: CreatedAuditActionService.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 };
}
getDataRequirements(storedData: BaseStoredAuditData): DataRequirements {
const { fields } = this.parseStored(storedData);
return {
userUuids: fields.hostUserUuid ? [fields.hostUserUuid] : [],
};
}
async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const hostUser = fields.hostUserUuid ? dbStore.getUserByUuid(fields.hostUserUuid) : null;
const hostName = hostUser?.name || "Unknown";
if (fields.seatReferenceUid) {
return { key: "booking_audit_action.created_with_seat", params: { host: hostName } };
}
return { key: "booking_audit_action.created", params: { host: hostName } };
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
getDisplayJson({ storedData, userTimeZone }: GetDisplayJsonParams): CreatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
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 };
}
getDataRequirements(storedData: BaseStoredAuditData): DataRequirements {
const { fields } = this.parseStored(storedData);
return {
userUuids: fields.hostUserUuid ? [fields.hostUserUuid] : [],
};
}
async getDisplayTitle({ storedData, dbStore }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const hostUser = fields.hostUserUuid ? dbStore.getUserByUuid(fields.hostUserUuid) : null;
const hostName = hostUser?.name || "Unknown";
if (fields.seatReferenceUid) {
return { key: "booking_audit_action.created_with_seat", params: { host: hostName } };
}
return { key: "booking_audit_action.created", params: { host: hostName } };
}
getDisplayJson({
storedData,
userTimeZone,
}: GetDisplayJsonParams): CreatedAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
return {
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone),
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone),
status: fields.status,
...(fields.seatReferenceUid ? { seatReferenceUid: fields.seatReferenceUid } : {}),
};
}
return {
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone),
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone),
status: fields.status,
...(fields.seatReferenceUid ? { seatReferenceUid: fields.seatReferenceUid } : {}),
};
}
}
export type CreatedAuditData = z.infer<typeof fieldsSchemaV1>;
export type CreatedAuditDisplayData = {
startTime: string;
endTime: string;
status: BookingStatus;
seatReferenceUid?: string;
startTime: string;
endTime: string;
status: BookingStatus;
seatReferenceUid?: string;
};
@@ -95,7 +95,10 @@ export class NoShowUpdatedAuditActionService implements IAuditActionService {
}
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields: parsedFields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const { fields: parsedFields } = this.parseStored({
version: storedData.version,
fields: storedData.fields,
});
if (this.isHostSet(parsedFields) && this.isAttendeesNoShowSet(parsedFields)) {
return { key: "booking_audit_action.no_show_updated" };
}
@@ -3,7 +3,13 @@ import { z } from "zod";
import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas";
import type { DataRequirements } from "../service/EnrichmentDataStore";
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
import type {
IAuditActionService,
TranslationWithParams,
GetDisplayTitleParams,
GetDisplayJsonParams,
BaseStoredAuditData,
} from "./IAuditActionService";
/**
* Rescheduled Audit Action Service
@@ -12,166 +18,164 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
// Module-level because it is passed to IAuditActionService type outside the class scope
const fieldsSchemaV1 = z.object({
startTime: NumberChangeSchema,
endTime: NumberChangeSchema,
rescheduledToUid: StringChangeSchema,
startTime: NumberChangeSchema,
endTime: NumberChangeSchema,
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,
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,
});
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);
}
getVersionedData(fields: unknown) {
return this.helper.getVersionedData(fields);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
parseStored(data: unknown) {
return this.helper.parseStored(data);
}
getVersion(data: unknown): number {
return this.helper.getVersion(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 };
}
migrateToLatest(data: unknown) {
// V1-only: validate and return as-is (no migration needed)
const validated = fieldsSchemaV1.parse(data);
return { isMigrated: false, latestData: validated };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
getDataRequirements(): DataRequirements {
return { userUuids: [] };
}
async getDisplayTitle({ storedData, userTimeZone }: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const rescheduledToUid = fields.rescheduledToUid.new;
const timeZone = userTimeZone;
async getDisplayTitle({
storedData,
userTimeZone,
}: GetDisplayTitleParams): Promise<TranslationWithParams> {
const { fields } = this.parseStored(storedData);
const rescheduledToUid = fields.rescheduledToUid.new;
const timeZone = userTimeZone;
// Format dates in user timezone
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
// Format dates in user timezone
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
return {
key: "booking_audit_action.rescheduled",
params: {
oldDate,
newDate,
},
components: rescheduledToUid
? [{ type: "link", href: `/booking/${rescheduledToUid}/logs` }]
: undefined,
};
}
return {
key: "booking_audit_action.rescheduled",
params: {
oldDate,
newDate,
},
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}/logs` }] : undefined,
};
}
getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
storedData,
}: {
fromRescheduleUid: string;
userTimeZone: string;
storedData: BaseStoredAuditData;
}): TranslationWithParams {
const timeZone = userTimeZone;
const { fields } = this.parseStored(storedData);
getDisplayTitleForRescheduledFromLog({
fromRescheduleUid,
userTimeZone,
storedData,
}: {
fromRescheduleUid: string;
userTimeZone: string;
storedData: BaseStoredAuditData;
}): TranslationWithParams {
const timeZone = userTimeZone;
const { fields } = this.parseStored(storedData);
// Format dates in user timezone
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
// Format dates in user timezone
const oldDate = fields.startTime.old
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.old, timeZone)
: "";
const newDate = fields.startTime.new
? AuditActionServiceHelper.formatDateInTimeZone(fields.startTime.new, timeZone)
: "";
return {
key: "booking_audit_action.rescheduled_from",
params: {
oldDate,
newDate,
},
components: [{ type: "link", href: `/booking/${fromRescheduleUid}/logs` }],
};
}
return {
key: "booking_audit_action.rescheduled_from",
params: {
oldDate,
newDate,
},
components: [{ type: "link", href: `/booking/${fromRescheduleUid}/logs` }],
};
}
getDisplayJson({ storedData, userTimeZone }: GetDisplayJsonParams): RescheduledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
getDisplayJson({
storedData,
userTimeZone,
}: GetDisplayJsonParams): RescheduledAuditDisplayData {
const { fields } = this.parseStored({ version: storedData.version, fields: storedData.fields });
const timeZone = userTimeZone;
return {
previousStartTime: fields.startTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, timeZone)
: null,
newStartTime: fields.startTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, timeZone)
: null,
previousEndTime: fields.endTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, timeZone)
: null,
newEndTime: fields.endTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, timeZone)
: null,
rescheduledToUid: fields.rescheduledToUid.new ?? null,
};
}
return {
previousStartTime: fields.startTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.old, timeZone)
: null,
newStartTime: fields.startTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime.new, timeZone)
: null,
previousEndTime: fields.endTime.old
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.old, timeZone)
: null,
newEndTime: fields.endTime.new
? AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime.new, timeZone)
: 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<T extends { data: unknown }>({
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;
}
/**
* 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<T extends { data: unknown }>({
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<typeof fieldsSchemaV1>;
export type RescheduledAuditDisplayData = {
previousStartTime: string | null;
newStartTime: string | null;
previousEndTime: string | null;
newEndTime: string | null;
rescheduledToUid: string | null;
previousStartTime: string | null;
newStartTime: string | null;
previousEndTime: string | null;
newEndTime: string | null;
rescheduledToUid: string | null;
};
@@ -21,9 +21,9 @@ describe("NoShowUpdatedAuditActionService - getDataRequirements contract", () =>
},
};
const { errors, accessedData} = await verifyDataRequirementsContract(service, storedData);
const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData);
expect(errors).toEqual([]);
expect(accessedData.userUuids.size).toBe(1)
expect(accessedData.userUuids.size).toBe(1);
});
it("should declare empty userUuids when only attendees are set", async () => {
@@ -34,9 +34,9 @@ describe("NoShowUpdatedAuditActionService - getDataRequirements contract", () =>
},
};
const { errors, accessedData} = await verifyDataRequirementsContract(service, storedData);
const { errors, accessedData } = await verifyDataRequirementsContract(service, storedData);
expect(errors).toEqual([]);
expect(accessedData.userUuids.size).toBe(0)
expect(accessedData.userUuids.size).toBe(0);
});
it("should declare exactly the userUuids accessed when both host and attendees are set", async () => {
@@ -338,7 +338,13 @@ describe("ReassignmentAuditActionService", () => {
const dbStore = createMockEnrichmentDataStore(
{
users: [
{ id: 1, uuid: "organizer-old", name: "Previous Host", email: "old@example.com", avatarUrl: null },
{
id: 1,
uuid: "organizer-old",
name: "Previous Host",
email: "old@example.com",
avatarUrl: null,
},
{ id: 2, uuid: "organizer-new", name: "New Host", email: "new@example.com", avatarUrl: null },
],
},
@@ -372,7 +378,13 @@ describe("ReassignmentAuditActionService", () => {
const dbStore = createMockEnrichmentDataStore(
{
users: [
{ id: 1, uuid: "organizer-old", name: "Previous Host", email: "old@example.com", avatarUrl: null },
{
id: 1,
uuid: "organizer-old",
name: "Previous Host",
email: "old@example.com",
avatarUrl: null,
},
{ id: 2, uuid: "organizer-new", name: "New Host", email: "new@example.com", avatarUrl: null },
],
},
@@ -1,4 +1,10 @@
import type { EnrichmentDataStore, DataRequirements, StoredUser, StoredAttendee, StoredCredential } from "../../service/EnrichmentDataStore";
import type {
EnrichmentDataStore,
DataRequirements,
StoredUser,
StoredAttendee,
StoredCredential,
} from "../../service/EnrichmentDataStore";
import type { IAuditActionService, BaseStoredAuditData } from "../IAuditActionService";
type AccessedData = {
@@ -146,7 +152,9 @@ export async function verifyDataRequirementsContract(
for (const uuid of accessedData.userUuids) {
if (!declaredRequirements.userUuids?.includes(uuid)) {
errors.push(`Under-declaration: getUserByUuid("${uuid}") was called but not declared in getDataRequirements`);
errors.push(
`Under-declaration: getUserByUuid("${uuid}") was called but not declared in getDataRequirements`
);
}
}
@@ -158,7 +166,9 @@ export async function verifyDataRequirementsContract(
for (const id of accessedData.attendeeIds) {
if (!declaredRequirements.attendeeIds?.includes(id)) {
errors.push(`Under-declaration: getAttendeeById(${id}) was called but not declared in getDataRequirements`);
errors.push(
`Under-declaration: getAttendeeById(${id}) was called but not declared in getDataRequirements`
);
}
}
@@ -170,7 +180,9 @@ export async function verifyDataRequirementsContract(
for (const id of accessedData.credentialIds) {
if (!declaredRequirements.credentialIds?.includes(id)) {
errors.push(`Under-declaration: getCredentialById(${id}) was called but not declared in getDataRequirements`);
errors.push(
`Under-declaration: getCredentialById(${id}) was called but not declared in getDataRequirements`
);
}
}
@@ -7,27 +7,26 @@ import { BookingStatus } from "@calcom/prisma/enums";
*/
export const StringChangeSchema = z.object({
old: z.string().nullable(),
new: z.string().nullable(),
old: z.string().nullable(),
new: z.string().nullable(),
});
export const BooleanChangeSchema = z.object({
old: z.boolean().nullable(),
new: z.boolean(),
old: z.boolean().nullable(),
new: z.boolean(),
});
export const StringArrayChangeSchema = z.object({
old: z.array(z.string()).nullable(),
new: z.array(z.string()),
old: z.array(z.string()).nullable(),
new: z.array(z.string()),
});
export const NumberChangeSchema = z.object({
old: z.number().nullable(),
new: z.number(),
old: z.number().nullable(),
new: z.number(),
});
export const BookingStatusChangeSchema = z.object({
old: z.nativeEnum(BookingStatus).nullable(),
new: z.nativeEnum(BookingStatus),
old: z.nativeEnum(BookingStatus).nullable(),
new: z.nativeEnum(BookingStatus),
});
@@ -1,50 +1,50 @@
import { z } from "zod";
const UserActorSchema = z.object({
identifiedBy: z.literal("user"),
userUuid: z.string(),
identifiedBy: z.literal("user"),
userUuid: z.string(),
});
const AttendeeActorSchema = z.object({
identifiedBy: z.literal("attendee"),
attendeeId: z.number(),
identifiedBy: z.literal("attendee"),
attendeeId: z.number(),
});
const ActorByIdSchema = z.object({
identifiedBy: z.literal("id"),
id: z.string(),
identifiedBy: z.literal("id"),
id: z.string(),
});
const GuestActorSchema = z.object({
identifiedBy: z.literal("guest"),
email: z.string(),
name: z.string().nullable(),
identifiedBy: z.literal("guest"),
email: z.string(),
name: z.string().nullable(),
});
const AppActorByCredentialIdSchema = z.object({
identifiedBy: z.literal("app"),
credentialId: z.number(),
identifiedBy: z.literal("app"),
credentialId: z.number(),
});
const AppActorBySlugSchema = z.object({
identifiedBy: z.literal("appSlug"),
appSlug: z.string(),
name: z.string(),
identifiedBy: z.literal("appSlug"),
appSlug: z.string(),
name: z.string(),
});
export const ActorSchema = z.discriminatedUnion("identifiedBy", [
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
GuestActorSchema,
AppActorByCredentialIdSchema,
AppActorBySlugSchema,
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
GuestActorSchema,
AppActorByCredentialIdSchema,
AppActorBySlugSchema,
]);
export const PiiFreeActorSchema = z.discriminatedUnion("identifiedBy", [
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
ActorByIdSchema,
UserActorSchema,
AttendeeActorSchema,
]);
export type Actor = z.infer<typeof ActorSchema>;
@@ -63,8 +63,7 @@ export type AppActorBySlug = z.infer<typeof AppActorBySlugSchema>;
* This is separate from action-specific data because impersonation is orthogonal to the action type
*/
export const BookingAuditContextSchema = z.object({
impersonatedBy: z.string().optional(),
impersonatedBy: z.string().optional(),
});
export type BookingAuditContext = z.infer<typeof BookingAuditContextSchema>;
@@ -3,16 +3,16 @@
* Falls back to slug itself if not in map
*/
const APP_SLUG_TO_NAME: Record<string, string> = {
stripe: "Stripe",
paypal: "Paypal",
alby: "Alby",
hitpay: "HitPay",
btcpayserver: "BTCPayServer",
stripe: "Stripe",
paypal: "Paypal",
alby: "Alby",
hitpay: "HitPay",
btcpayserver: "BTCPayServer",
};
export function getAppNameFromSlug({ appSlug }: { appSlug: string | null }): string {
if (!appSlug) {
return "Unknown App";
}
return APP_SLUG_TO_NAME[appSlug] ?? appSlug;
if (!appSlug) {
return "Unknown App";
}
return APP_SLUG_TO_NAME[appSlug] ?? appSlug;
}
@@ -1,4 +1,11 @@
import type { UserActor, GuestActor, AttendeeActor, ActorById, AppActorByCredentialId, AppActorBySlug } from "./dto/types";
import type {
UserActor,
GuestActor,
AttendeeActor,
ActorById,
AppActorByCredentialId,
AppActorBySlug,
} from "./dto/types";
import { v4 as uuidv4 } from "uuid";
const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
@@ -7,18 +14,18 @@ const SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
* Creates an Actor representing a User by UUID
*/
export function makeUserActor(userUuid: string): UserActor {
return {
identifiedBy: "user",
userUuid,
};
return {
identifiedBy: "user",
userUuid,
};
}
export function makeGuestActor({ email, name }: { email: string, name: string | null }): GuestActor {
return {
identifiedBy: "guest",
email,
name: name ?? null,
};
export function makeGuestActor({ email, name }: { email: string; name: string | null }): GuestActor {
return {
identifiedBy: "guest",
email,
name: name ?? null,
};
}
/**
@@ -26,31 +33,30 @@ export function makeGuestActor({ email, name }: { email: string, name: string |
* System actors must be referenced by ID (requires migration)
*/
export function makeSystemActor(): ActorById {
return {
identifiedBy: "id",
id: SYSTEM_ACTOR_ID,
};
return {
identifiedBy: "id",
id: SYSTEM_ACTOR_ID,
};
}
/**
* Creates an Actor by existing actor ID
*/
export function makeActorById(id: string): ActorById {
return {
identifiedBy: "id",
id,
};
return {
identifiedBy: "id",
id,
};
}
/**
* Creates an Actor representing an Attendee by attendee ID
*/
export function makeAttendeeActor(attendeeId: number): AttendeeActor {
return {
identifiedBy: "attendee",
attendeeId,
};
return {
identifiedBy: "attendee",
attendeeId,
};
}
/**
@@ -59,10 +65,10 @@ export function makeAttendeeActor(attendeeId: number): AttendeeActor {
* App name and slug are derived from the credential at display time
*/
export function makeAppActor(params: { credentialId: number }): AppActorByCredentialId {
return {
identifiedBy: "app",
credentialId: params.credentialId,
};
return {
identifiedBy: "app",
credentialId: params.credentialId,
};
}
/**
@@ -71,20 +77,26 @@ export function makeAppActor(params: { credentialId: number }): AppActorByCreden
* App actors use @app.internal email convention
*/
export function makeAppActorUsingSlug(params: { appSlug: string; name: string }): AppActorBySlug {
return {
identifiedBy: "appSlug",
appSlug: params.appSlug,
name: params.name,
};
return {
identifiedBy: "appSlug",
appSlug: params.appSlug,
name: params.name,
};
}
/**
* identifier should be unique for that actor
*/
export function buildActorEmail({ identifier, actorType }: { identifier: string, actorType: "system" | "guest" | "app" }): string {
return `${identifier}@${actorType}.internal`;
export function buildActorEmail({
identifier,
actorType,
}: {
identifier: string;
actorType: "system" | "guest" | "app";
}): string {
return `${identifier}@${actorType}.internal`;
}
export function getUniqueIdentifier({ prefix }: { prefix: string }): string {
return `${prefix}-${uuidv4()}`;
}
return `${prefix}-${uuidv4()}`;
}
@@ -10,15 +10,17 @@ type AuditActor = {
phone: string | null;
name: string | null;
createdAt: Date;
}
};
export interface IAuditActorRepository {
findByUserUuid(userUuid: string): Promise<AuditActor | null>;
createIfNotExistsUserActor(params: { userUuid: string }): Promise<AuditActor>;
createIfNotExistsAttendeeActor(params: { attendeeId: number }): Promise<AuditActor>;
createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }): Promise<AuditActor>;
createIfNotExistsAppActor(params:
| { credentialId: number }
| { email: string; name: string }
createIfNotExistsGuestActor(params: {
email: string | null;
name: string | null;
phone: string | null;
}): Promise<AuditActor>;
createIfNotExistsAppActor(
params: { credentialId: number } | { email: string; name: string }
): Promise<AuditActor>;
}
@@ -3,74 +3,89 @@ import type { AuditActorType } from "./IAuditActorRepository";
import type { ActionSource } from "../types/actionSource";
import type { BookingAuditContext } from "../dto/types";
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED"
export type BookingAuditType = "RECORD_CREATED" | "RECORD_UPDATED" | "RECORD_DELETED";
/**
* Booking audit actions track changes to bookings throughout their lifecycle.
*
*
* Note: PENDING and AWAITING_HOST represent initial booking states, not transitions.
* They are reserved in the enum for potential future use but should not appear in audit logs.
* Use the CREATED action to capture initial booking status instead.
*/
export type BookingAuditAction = "CREATED" | "CANCELLED" | "ACCEPTED" | "REJECTED" | "PENDING" | "AWAITING_HOST" | "RESCHEDULED" | "ATTENDEE_ADDED" | "ATTENDEE_REMOVED" | "REASSIGNMENT" | "LOCATION_CHANGED" | "NO_SHOW_UPDATED" | "RESCHEDULE_REQUESTED" | "SEAT_BOOKED" | "SEAT_RESCHEDULED"
export type BookingAuditAction =
| "CREATED"
| "CANCELLED"
| "ACCEPTED"
| "REJECTED"
| "PENDING"
| "AWAITING_HOST"
| "RESCHEDULED"
| "ATTENDEE_ADDED"
| "ATTENDEE_REMOVED"
| "REASSIGNMENT"
| "LOCATION_CHANGED"
| "NO_SHOW_UPDATED"
| "RESCHEDULE_REQUESTED"
| "SEAT_BOOKED"
| "SEAT_RESCHEDULED";
export type BookingAuditCreateInput = {
bookingUid: string;
actorId: string;
action: BookingAuditAction;
data: JsonValue;
type: BookingAuditType;
timestamp: Date;
source: ActionSource;
operationId: string;
context?: BookingAuditContext;
}
bookingUid: string;
actorId: string;
action: BookingAuditAction;
data: JsonValue;
type: BookingAuditType;
timestamp: Date;
source: ActionSource;
operationId: string;
context?: BookingAuditContext;
};
type BookingAudit = {
id: string;
bookingUid: string;
actorId: string;
action: BookingAuditAction;
type: BookingAuditType;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: JsonValue;
source: ActionSource;
operationId: string;
}
id: string;
bookingUid: string;
actorId: string;
action: BookingAuditAction;
type: BookingAuditType;
timestamp: Date;
createdAt: Date;
updatedAt: Date;
data: JsonValue;
source: ActionSource;
operationId: string;
};
export type BookingAuditWithActor = BookingAudit & {
context: BookingAuditContext | null;
actor: {
id: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
credentialId: number | null;
name: string | null;
createdAt: Date;
};
}
context: BookingAuditContext | null;
actor: {
id: string;
type: AuditActorType;
userUuid: string | null;
attendeeId: number | null;
credentialId: number | null;
name: string | null;
createdAt: Date;
};
};
export interface IBookingAuditRepository {
/**
* Creates a new booking audit record
*/
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
/**
* Creates a new booking audit record
*/
create(bookingAudit: BookingAuditCreateInput): Promise<BookingAudit>;
createMany(bookingAudits: BookingAuditCreateInput[]): Promise<{ count: number }>;
createMany(bookingAudits: BookingAuditCreateInput[]): Promise<{ count: number }>;
/**
* Retrieves all audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of audit logs with actor information, ordered by timestamp DESC
*/
findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
/**
* Retrieves all audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of audit logs with actor information, ordered by timestamp DESC
*/
findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
/**
* Retrieves all RESCHEDULED audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of RESCHEDULED audit logs with actor information, ordered by timestamp DESC
*/
findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
/**
* Retrieves all RESCHEDULED audit logs for a specific booking
* @param bookingUid - The unique identifier of the booking
* @returns Array of RESCHEDULED audit logs with actor information, ordered by timestamp DESC
*/
findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]>;
}
@@ -2,128 +2,127 @@ import type { PrismaClient } from "@calcom/prisma/client";
import type { IAuditActorRepository } from "./IAuditActorRepository";
type Dependencies = {
prismaClient: PrismaClient;
}
prismaClient: PrismaClient;
};
export class PrismaAuditActorRepository implements IAuditActorRepository {
constructor(private readonly deps: Dependencies) { }
async findByUserUuid(userUuid: string) {
return this.deps.prismaClient.auditActor.findUnique({
where: { userUuid },
});
constructor(private readonly deps: Dependencies) {}
async findByUserUuid(userUuid: string) {
return this.deps.prismaClient.auditActor.findUnique({
where: { userUuid },
});
}
async createIfNotExistsUserActor(params: { userUuid: string }) {
return this.deps.prismaClient.auditActor.upsert({
where: { userUuid: params.userUuid },
create: {
type: "USER",
userUuid: params.userUuid,
},
update: {},
});
}
async createIfNotExistsGuestActor(params: {
email: string | null;
name: string | null;
phone: string | null;
}) {
const { email, name, phone } = params;
const normalizedEmail = email && email.trim() !== "" ? email : null;
const normalizedName = name && name.trim() !== "" ? name : null;
const normalizedPhone = phone && phone.trim() !== "" ? phone : null;
// If all fields are null, we can't use upsert (no unique constraint), so just create a new record
if (!normalizedEmail && !normalizedPhone) {
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: null,
name: normalizedName,
phone: null,
},
});
}
async createIfNotExistsUserActor(params: { userUuid: string }) {
return this.deps.prismaClient.auditActor.upsert({
where: { userUuid: params.userUuid },
create: {
type: "USER",
userUuid: params.userUuid,
},
update: {},
// First try to find by email if email exists
if (normalizedEmail) {
const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({
where: { email: normalizedEmail },
});
if (existingByEmail) {
// Update existing record found by email
return this.deps.prismaClient.auditActor.update({
where: { email: normalizedEmail },
data: {
name: normalizedName ?? undefined,
phone: normalizedPhone ?? undefined,
},
});
}
}
async createIfNotExistsGuestActor(params: { email: string | null; name: string | null; phone: string | null }) {
const { email, name, phone } = params;
const normalizedEmail = email && email.trim() !== "" ? email : null;
const normalizedName = name && name.trim() !== "" ? name : null;
const normalizedPhone = phone && phone.trim() !== "" ? phone : null;
// If not found by email and phone exists, try to find by phone
if (normalizedPhone) {
const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({
where: { phone: normalizedPhone },
});
// If all fields are null, we can't use upsert (no unique constraint), so just create a new record
if (!normalizedEmail && !normalizedPhone) {
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: null,
name: normalizedName,
phone: null,
},
});
}
// First try to find by email if email exists
if (normalizedEmail) {
const existingByEmail = await this.deps.prismaClient.auditActor.findUnique({
where: { email: normalizedEmail },
});
if (existingByEmail) {
// Update existing record found by email
return this.deps.prismaClient.auditActor.update({
where: { email: normalizedEmail },
data: {
name: normalizedName ?? undefined,
phone: normalizedPhone ?? undefined,
},
});
}
}
// If not found by email and phone exists, try to find by phone
if (normalizedPhone) {
const existingByPhone = await this.deps.prismaClient.auditActor.findUnique({
where: { phone: normalizedPhone },
});
if (existingByPhone) {
// Update existing record found by phone
return this.deps.prismaClient.auditActor.update({
where: { phone: normalizedPhone },
data: {
email: normalizedEmail ?? undefined,
name: normalizedName ?? undefined,
},
});
}
}
// Not found by either email or phone, create new record
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: normalizedEmail,
name: normalizedName,
phone: normalizedPhone,
},
if (existingByPhone) {
// Update existing record found by phone
return this.deps.prismaClient.auditActor.update({
where: { phone: normalizedPhone },
data: {
email: normalizedEmail ?? undefined,
name: normalizedName ?? undefined,
},
});
}
}
async createIfNotExistsAttendeeActor(params: { attendeeId: number }) {
return this.deps.prismaClient.auditActor.upsert({
where: { attendeeId: params.attendeeId },
create: {
type: "ATTENDEE",
attendeeId: params.attendeeId,
},
update: {},
});
}
// Not found by either email or phone, create new record
return this.deps.prismaClient.auditActor.create({
data: {
type: "GUEST",
email: normalizedEmail,
name: normalizedName,
phone: normalizedPhone,
},
});
}
async createIfNotExistsAppActor(params:
| { credentialId: number }
| { email: string; name: string }
) {
if ('credentialId' in params) {
return this.deps.prismaClient.auditActor.upsert({
where: { credentialId: params.credentialId },
create: {
type: "APP",
credentialId: params.credentialId,
},
update: {},
});
}
async createIfNotExistsAttendeeActor(params: { attendeeId: number }) {
return this.deps.prismaClient.auditActor.upsert({
where: { attendeeId: params.attendeeId },
create: {
type: "ATTENDEE",
attendeeId: params.attendeeId,
},
update: {},
});
}
return this.deps.prismaClient.auditActor.upsert({
where: { email: params.email },
create: {
type: "APP",
email: params.email,
name: params.name,
},
update: {},
});
async createIfNotExistsAppActor(params: { credentialId: number } | { email: string; name: string }) {
if ("credentialId" in params) {
return this.deps.prismaClient.auditActor.upsert({
where: { credentialId: params.credentialId },
create: {
type: "APP",
credentialId: params.credentialId,
},
update: {},
});
}
return this.deps.prismaClient.auditActor.upsert({
where: { email: params.email },
create: {
type: "APP",
email: params.email,
name: params.name,
},
update: {},
});
}
}
@@ -1,122 +1,125 @@
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { IBookingAuditRepository, BookingAuditCreateInput, BookingAuditWithActor } from "./IBookingAuditRepository";
import type {
IBookingAuditRepository,
BookingAuditCreateInput,
BookingAuditWithActor,
} from "./IBookingAuditRepository";
import { BookingAuditContextSchema } from "../dto/types";
type Dependencies = {
prismaClient: PrismaClient;
}
prismaClient: PrismaClient;
};
/**
* Safe actor fields to expose in audit logs
* Excludes PII fields like email and phone that aren't needed for display
*/
const safeActorSelect = {
id: true,
type: true,
userUuid: true,
attendeeId: true,
credentialId: true,
name: true,
createdAt: true,
id: true,
type: true,
userUuid: true,
attendeeId: true,
credentialId: true,
name: true,
createdAt: true,
} as const;
const safeBookingAuditSelect = {
id: true,
bookingUid: true,
actorId: true,
action: true,
type: true,
timestamp: true,
source: true,
operationId: true,
data: true,
context: true,
createdAt: true,
updatedAt: true,
id: true,
bookingUid: true,
actorId: true,
action: true,
type: true,
timestamp: true,
source: true,
operationId: true,
data: true,
context: true,
createdAt: true,
updatedAt: true,
} as const;
export class PrismaBookingAuditRepository implements IBookingAuditRepository {
constructor(private readonly deps: Dependencies) { }
constructor(private readonly deps: Dependencies) {}
private parsed<T extends { context: Prisma.JsonValue }>(auditLog: T) {
return {
...auditLog,
context: auditLog.context ? BookingAuditContextSchema.parse(auditLog.context) : null,
};
}
private parsed<T extends { context: Prisma.JsonValue }>(auditLog: T) {
return {
...auditLog,
context: auditLog.context ? BookingAuditContextSchema.parse(auditLog.context) : null,
};
}
async create(bookingAudit: BookingAuditCreateInput) {
const created = await this.deps.prismaClient.bookingAudit.create({
data: {
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
context: bookingAudit.context ?? undefined,
},
});
async create(bookingAudit: BookingAuditCreateInput) {
const created = await this.deps.prismaClient.bookingAudit.create({
data: {
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
context: bookingAudit.context ?? undefined,
},
});
return this.parsed(created);
}
return this.parsed(created);
}
async createMany(bookingAudits: BookingAuditCreateInput[]) {
const result = await this.deps.prismaClient.bookingAudit.createMany({
data: bookingAudits.map((bookingAudit) => ({
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
context: bookingAudit.context === undefined ? undefined : bookingAudit.context,
})),
});
return { count: result.count };
}
async createMany(bookingAudits: BookingAuditCreateInput[]) {
const result = await this.deps.prismaClient.bookingAudit.createMany({
data: bookingAudits.map((bookingAudit) => ({
bookingUid: bookingAudit.bookingUid,
actorId: bookingAudit.actorId,
action: bookingAudit.action,
type: bookingAudit.type,
timestamp: bookingAudit.timestamp,
source: bookingAudit.source,
operationId: bookingAudit.operationId,
data: bookingAudit.data === null ? undefined : bookingAudit.data,
context: bookingAudit.context === undefined ? undefined : bookingAudit.context,
})),
});
return { count: result.count };
}
async findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
},
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect,
},
},
orderBy: {
timestamp: "desc",
},
});
async findAllForBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
},
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect,
},
},
orderBy: {
timestamp: "desc",
},
});
return results.map(this.parsed);
}
return results.map(this.parsed);
}
async findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
action: "RESCHEDULED",
},
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect
},
},
orderBy: { timestamp: "desc" },
});
async findRescheduledLogsOfBooking(bookingUid: string): Promise<BookingAuditWithActor[]> {
const results = await this.deps.prismaClient.bookingAudit.findMany({
where: {
bookingUid,
action: "RESCHEDULED",
},
select: {
...safeBookingAuditSelect,
actor: {
select: safeActorSelect,
},
},
orderBy: { timestamp: "desc" },
});
return results.map(this.parsed);
}
return results.map(this.parsed);
}
}
@@ -64,7 +64,9 @@ export const ACTOR_STRATEGIES: Record<AuditActorType, ActorStrategy> = {
enrich: (actor, dbStore) => {
const credential = actor.credentialId ? dbStore.getCredentialById(actor.credentialId) : null;
return {
displayName: credential ? getAppNameFromSlug({ appSlug: credential.appId }) : (actor.name ?? "Deleted App"),
displayName: credential
? getAppNameFromSlug({ appSlug: credential.appId })
: (actor.name ?? "Deleted App"),
displayEmail: null,
displayAvatar: null,
};
@@ -4,23 +4,23 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { MembershipRole } from "@calcom/prisma/enums";
export enum BookingAuditErrorCode {
ORGANIZATION_ID_REQUIRED = "ORGANIZATION_ID_REQUIRED",
BOOKING_NOT_FOUND_OR_PERMISSION_DENIED = "BOOKING_NOT_FOUND_OR_PERMISSION_DENIED",
BOOKING_HAS_NO_OWNER = "BOOKING_HAS_NO_OWNER",
OWNER_NOT_IN_ORGANIZATION = "OWNER_NOT_IN_ORGANIZATION",
PERMISSION_DENIED = "PERMISSION_DENIED",
ORGANIZATION_ID_REQUIRED = "ORGANIZATION_ID_REQUIRED",
BOOKING_NOT_FOUND_OR_PERMISSION_DENIED = "BOOKING_NOT_FOUND_OR_PERMISSION_DENIED",
BOOKING_HAS_NO_OWNER = "BOOKING_HAS_NO_OWNER",
OWNER_NOT_IN_ORGANIZATION = "OWNER_NOT_IN_ORGANIZATION",
PERMISSION_DENIED = "PERMISSION_DENIED",
}
export class BookingAuditPermissionError extends Error {
constructor(public readonly code: BookingAuditErrorCode) {
super(code);
this.name = "BookingAuditPermissionError";
}
constructor(public readonly code: BookingAuditErrorCode) {
super(code);
this.name = "BookingAuditPermissionError";
}
}
interface BookingAuditAccessServiceDeps {
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
bookingRepository: BookingRepository;
membershipRepository: MembershipRepository;
}
/**
@@ -29,67 +29,78 @@ interface BookingAuditAccessServiceDeps {
* Regular users (including booking organizers and hosts) cannot view audit logs.
*/
export class BookingAuditAccessService {
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly permissionCheckService: PermissionCheckService;
private readonly bookingRepository: BookingRepository;
private readonly membershipRepository: MembershipRepository;
private readonly permissionCheckService: PermissionCheckService;
constructor(deps: BookingAuditAccessServiceDeps) {
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.permissionCheckService = new PermissionCheckService();
constructor(deps: BookingAuditAccessServiceDeps) {
this.bookingRepository = deps.bookingRepository;
this.membershipRepository = deps.membershipRepository;
this.permissionCheckService = new PermissionCheckService();
}
/**
* Check if user has permission to view audit logs for a booking
* Throws BookingAuditPermissionError if access is denied
*/
async assertPermissions({
bookingUid,
userId,
organizationId,
}: {
bookingUid: string;
userId: number;
organizationId: number | null;
}): Promise<void> {
if (!organizationId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
}
/**
* Check if user has permission to view audit logs for a booking
* Throws BookingAuditPermissionError if access is denied
*/
async assertPermissions({ bookingUid, userId, organizationId }: { bookingUid: string, userId: number, organizationId: number | null }): Promise<void> {
if (!organizationId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
}
const booking = await this.bookingRepository.findByUidIncludeEventType({ bookingUid });
if (!booking) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
}
const bookingEventType = booking.eventType;
const bookingEventTypeTeamId = bookingEventType?.teamId ?? bookingEventType?.parent?.teamId;
if (bookingEventTypeTeamId) {
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: bookingEventTypeTeamId,
permission: "booking.readTeamAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
}
const bookingOwnerId = booking.userId;
if (!bookingOwnerId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
}
const isBookingOwnerMemberOfOrganization = await this.membershipRepository.hasMembership({ userId: bookingOwnerId, teamId: organizationId });
if (!isBookingOwnerMemberOfOrganization) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
}
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "booking.readOrgAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
throw new BookingAuditPermissionError(BookingAuditErrorCode.PERMISSION_DENIED);
const booking = await this.bookingRepository.findByUidIncludeEventType({ bookingUid });
if (!booking) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
}
const bookingEventType = booking.eventType;
const bookingEventTypeTeamId = bookingEventType?.teamId ?? bookingEventType?.parent?.teamId;
if (bookingEventTypeTeamId) {
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: bookingEventTypeTeamId,
permission: "booking.readTeamAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
}
const bookingOwnerId = booking.userId;
if (!bookingOwnerId) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
}
const isBookingOwnerMemberOfOrganization = await this.membershipRepository.hasMembership({
userId: bookingOwnerId,
teamId: organizationId,
});
if (!isBookingOwnerMemberOfOrganization) {
throw new BookingAuditPermissionError(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
}
const hasAccess = await this.permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "booking.readOrgAuditLogs",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (hasAccess) {
return;
}
throw new BookingAuditPermissionError(BookingAuditErrorCode.PERMISSION_DENIED);
}
}
@@ -3,78 +3,105 @@ import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService";
import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService";
import {
RescheduledAuditActionService,
type RescheduledAuditData,
} from "../actions/RescheduledAuditActionService";
import { AcceptedAuditActionService, type AcceptedAuditData } from "../actions/AcceptedAuditActionService";
import { RescheduleRequestedAuditActionService, type RescheduleRequestedAuditData } from "../actions/RescheduleRequestedAuditActionService";
import { AttendeeAddedAuditActionService, type AttendeeAddedAuditData } from "../actions/AttendeeAddedAuditActionService";
import { NoShowUpdatedAuditActionService, type NoShowUpdatedAuditData } from "../actions/NoShowUpdatedAuditActionService";
import {
RescheduleRequestedAuditActionService,
type RescheduleRequestedAuditData,
} from "../actions/RescheduleRequestedAuditActionService";
import {
AttendeeAddedAuditActionService,
type AttendeeAddedAuditData,
} from "../actions/AttendeeAddedAuditActionService";
import {
NoShowUpdatedAuditActionService,
type NoShowUpdatedAuditData,
} from "../actions/NoShowUpdatedAuditActionService";
import { RejectedAuditActionService, type RejectedAuditData } from "../actions/RejectedAuditActionService";
import { AttendeeRemovedAuditActionService, type AttendeeRemovedAuditData } from "../actions/AttendeeRemovedAuditActionService";
import { ReassignmentAuditActionService, type ReassignmentAuditData } from "../actions/ReassignmentAuditActionService";
import { LocationChangedAuditActionService, type LocationChangedAuditData } from "../actions/LocationChangedAuditActionService";
import { SeatBookedAuditActionService, type SeatBookedAuditData } from "../actions/SeatBookedAuditActionService";
import { SeatRescheduledAuditActionService, type SeatRescheduledAuditData } from "../actions/SeatRescheduledAuditActionService";
import {
AttendeeRemovedAuditActionService,
type AttendeeRemovedAuditData,
} from "../actions/AttendeeRemovedAuditActionService";
import {
ReassignmentAuditActionService,
type ReassignmentAuditData,
} from "../actions/ReassignmentAuditActionService";
import {
LocationChangedAuditActionService,
type LocationChangedAuditData,
} from "../actions/LocationChangedAuditActionService";
import {
SeatBookedAuditActionService,
type SeatBookedAuditData,
} from "../actions/SeatBookedAuditActionService";
import {
SeatRescheduledAuditActionService,
type SeatRescheduledAuditData,
} from "../actions/SeatRescheduledAuditActionService";
/**
* Union type for all audit action data types
* Used for type-safe handling of action-specific data
*/
export type AuditActionData =
| CreatedAuditData
| CancelledAuditData
| RescheduledAuditData
| AcceptedAuditData
| RescheduleRequestedAuditData
| AttendeeAddedAuditData
| NoShowUpdatedAuditData
| RejectedAuditData
| AttendeeRemovedAuditData
| ReassignmentAuditData
| LocationChangedAuditData
| SeatBookedAuditData
| SeatRescheduledAuditData;
| CreatedAuditData
| CancelledAuditData
| RescheduledAuditData
| AcceptedAuditData
| RescheduleRequestedAuditData
| AttendeeAddedAuditData
| NoShowUpdatedAuditData
| RejectedAuditData
| AttendeeRemovedAuditData
| ReassignmentAuditData
| LocationChangedAuditData
| SeatBookedAuditData
| SeatRescheduledAuditData;
/**
* BookingAuditActionServiceRegistry
*
*
* Centralized registry for all booking audit action services.
* Provides a single source of truth for action service mapping and eliminates
* code duplication between consumer and viewer services.
*/
export class BookingAuditActionServiceRegistry {
private readonly actionServices: Map<BookingAuditAction, IAuditActionService>;
private readonly actionServices: Map<BookingAuditAction, IAuditActionService>;
constructor() {
const services: Array<[BookingAuditAction, IAuditActionService]> = [
["CREATED", new CreatedAuditActionService()],
["CANCELLED", new CancelledAuditActionService()],
["RESCHEDULED", new RescheduledAuditActionService()],
["ACCEPTED", new AcceptedAuditActionService()],
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService()],
["REJECTED", new RejectedAuditActionService()],
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
["REASSIGNMENT", new ReassignmentAuditActionService()],
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
["SEAT_BOOKED", new SeatBookedAuditActionService()],
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
];
this.actionServices = new Map(services);
}
constructor() {
const services: Array<[BookingAuditAction, IAuditActionService]> = [
["CREATED", new CreatedAuditActionService()],
["CANCELLED", new CancelledAuditActionService()],
["RESCHEDULED", new RescheduledAuditActionService()],
["ACCEPTED", new AcceptedAuditActionService()],
["RESCHEDULE_REQUESTED", new RescheduleRequestedAuditActionService()],
["ATTENDEE_ADDED", new AttendeeAddedAuditActionService()],
["NO_SHOW_UPDATED", new NoShowUpdatedAuditActionService()],
["REJECTED", new RejectedAuditActionService()],
["ATTENDEE_REMOVED", new AttendeeRemovedAuditActionService()],
["REASSIGNMENT", new ReassignmentAuditActionService()],
["LOCATION_CHANGED", new LocationChangedAuditActionService()],
["SEAT_BOOKED", new SeatBookedAuditActionService()],
["SEAT_RESCHEDULED", new SeatRescheduledAuditActionService()],
];
this.actionServices = new Map(services);
}
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance with proper typing
* @throws Error if no service is found for the action
*/
getActionService(action: BookingAuditAction): IAuditActionService {
const service = this.actionServices.get(action);
if (!service) {
throw new Error(`No action service found for: ${action}`);
}
return service;
/**
* Get Action Service - Returns the appropriate action service for the given action type
*
* @param action - The booking audit action type
* @returns The corresponding action service instance with proper typing
* @throws Error if no service is found for the action
*/
getActionService(action: BookingAuditAction): IAuditActionService {
const service = this.actionServices.get(action);
if (!service) {
throw new Error(`No action service found for: ${action}`);
}
return service;
}
}
@@ -25,460 +25,459 @@ import { SeatRescheduledAuditActionService } from "../actions/SeatRescheduledAud
import type { BookingAuditProducerService } from "./BookingAuditProducerService.interface";
interface BookingAuditTaskerProducerServiceDeps {
tasker: Tasker;
log: ISimpleLogger;
auditActorRepository: IAuditActorRepository;
tasker: Tasker;
log: ISimpleLogger;
auditActorRepository: IAuditActorRepository;
}
/**
* BookingAuditTaskerProducerService - Tasker-based implementation of BookingAuditProducerService
*
*
* Producer that uses Tasker for local/background job processing.
* Task processing is handled by BookingAuditTaskConsumer.
*
*
* For future migration to trigger.dev, create BookingAuditTriggerProducerService
* that implements the same BookingAuditProducerService interface.
*/
export class BookingAuditTaskerProducerService implements BookingAuditProducerService {
private readonly tasker: Tasker;
private readonly log: BookingAuditTaskerProducerServiceDeps["log"];
private readonly auditActorRepository: IAuditActorRepository;
private readonly tasker: Tasker;
private readonly log: BookingAuditTaskerProducerServiceDeps["log"];
private readonly auditActorRepository: IAuditActorRepository;
constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) {
this.tasker = deps.tasker;
this.log = deps.log;
this.auditActorRepository = deps.auditActorRepository;
constructor(private readonly deps: BookingAuditTaskerProducerServiceDeps) {
this.tasker = deps.tasker;
this.log = deps.log;
this.auditActorRepository = deps.auditActorRepository;
}
private async getPIIFreeBookingAuditActor(params: { actor: Actor }): Promise<PiiFreeActor> {
const { actor } = params;
if (actor.identifiedBy === "user" || actor.identifiedBy === "attendee" || actor.identifiedBy === "id") {
return actor;
}
private async getPIIFreeBookingAuditActor(params: {
actor: Actor;
}): Promise<PiiFreeActor> {
const { actor } = params;
if (actor.identifiedBy === "user" || actor.identifiedBy === "attendee" || actor.identifiedBy === "id") {
return actor;
}
if (actor.identifiedBy === "app") {
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
credentialId: actor.credentialId,
});
return makeActorById(piiFreeActor.id);
}
if (actor.identifiedBy === "appSlug") {
const email = buildActorEmail({ identifier: actor.appSlug, actorType: "app" });
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
email,
name: actor.name,
});
return makeActorById(piiFreeActor.id);
}
// Must be guest actor at this point
const piiFreeActor = await this.auditActorRepository.createIfNotExistsGuestActor({
email: actor.email,
name: actor.name ?? null,
phone: null,
});
return makeActorById(piiFreeActor.id);
if (actor.identifiedBy === "app") {
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
credentialId: actor.credentialId,
});
return makeActorById(piiFreeActor.id);
}
/**
* Internal helper to queue audit task to Tasker
* @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed)
* @param params.operationId - Optional operation ID for correlating bulk operations. If null, will be auto-generated.
* @param params.isBookingAuditEnabled - Flag indicating if booking audit is enabled for the organization.
* When false, skips queueing.
*/
private async queueTask(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
data: unknown;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
// Skip queueing if booking audit is disabled for this organization
if (!params.isBookingAuditEnabled) {
this.log.debug(
`Skipping ${params.action} audit: booking-audit feature is disabled for organization`,
{ organizationId: params.organizationId, bookingUid: params.bookingUid, action: params.action }
);
return;
}
try {
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: false,
bookingUid: params.bookingUid,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
data: params.data,
context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error));
}
if (actor.identifiedBy === "appSlug") {
const email = buildActorEmail({ identifier: actor.appSlug, actorType: "app" });
const piiFreeActor = await this.auditActorRepository.createIfNotExistsAppActor({
email,
name: actor.name,
});
return makeActorById(piiFreeActor.id);
}
async queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: CreatedAuditActionService.TYPE,
});
// Must be guest actor at this point
const piiFreeActor = await this.auditActorRepository.createIfNotExistsGuestActor({
email: actor.email,
name: actor.name ?? null,
phone: null,
});
return makeActorById(piiFreeActor.id);
}
/**
* Internal helper to queue audit task to Tasker
* @param params.action - Must be a valid BookingAuditAction value (TYPE from action services are string-typed)
* @param params.operationId - Optional operation ID for correlating bulk operations. If null, will be auto-generated.
* @param params.isBookingAuditEnabled - Flag indicating if booking audit is enabled for the organization.
* When false, skips queueing.
*/
private async queueTask(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
data: unknown;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
async queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
// Skip queueing if booking audit is disabled for this organization
if (!params.isBookingAuditEnabled) {
this.log.debug(`Skipping ${params.action} audit: booking-audit feature is disabled for organization`, {
organizationId: params.organizationId,
bookingUid: params.bookingUid,
action: params.action,
});
return;
}
try {
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
async queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: false,
bookingUid: params.bookingUid,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
data: params.data,
context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing ${params.action} audit`, safeStringify(error));
}
}
async queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: CancelledAuditActionService.TYPE,
});
async queueCreatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueAcceptedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
}
async queueCancelledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: CancelledAuditActionService.TYPE,
});
}
async queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduleRequestedAuditActionService.TYPE,
});
}
async queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeAddedAuditActionService.TYPE,
});
}
async queueNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: NoShowUpdatedAuditActionService.TYPE,
});
}
async queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RejectedAuditActionService.TYPE,
});
}
async queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeRemovedAuditActionService.TYPE,
});
}
async queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: ReassignmentAuditActionService.TYPE,
});
}
async queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: LocationChangedAuditActionService.TYPE,
});
}
async queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatBookedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatBookedAuditActionService.TYPE,
});
}
async queueSeatRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatRescheduledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatRescheduledAuditActionService.TYPE,
});
}
private async queueBulkTask(params: {
bookings: Array<{
bookingUid: string;
data: unknown;
}>;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
async queueRescheduleRequestedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RescheduleRequestedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RescheduleRequestedAuditActionService.TYPE,
});
// Skip queueing if booking audit is disabled for this organization
if (!params.isBookingAuditEnabled) {
this.log.debug(
`Skipping bulk ${params.action} audit: booking-audit feature is disabled for organization`,
{ organizationId: params.organizationId, bookingCount: params.bookings.length, action: params.action }
);
return;
}
try {
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
async queueAttendeeAddedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeAddedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeAddedAuditActionService.TYPE,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: true,
bookings: params.bookings,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error));
}
}
async queueNoShowUpdatedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof NoShowUpdatedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: NoShowUpdatedAuditActionService.TYPE,
});
}
async queueBulkAcceptedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
}
async queueRejectedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: RejectedAuditActionService.TYPE,
});
}
async queueBulkCancelledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CancelledAuditActionService.TYPE,
});
}
async queueAttendeeRemovedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof AttendeeRemovedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: AttendeeRemovedAuditActionService.TYPE,
});
}
async queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueReassignmentAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof ReassignmentAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: ReassignmentAuditActionService.TYPE,
});
}
async queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueLocationChangedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof LocationChangedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: LocationChangedAuditActionService.TYPE,
});
}
async queueSeatBookedAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatBookedAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatBookedAuditActionService.TYPE,
});
}
async queueSeatRescheduledAudit(params: {
bookingUid: string;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
data: z.infer<typeof SeatRescheduledAuditActionService.latestFieldsSchema>;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueTask({
...params,
action: SeatRescheduledAuditActionService.TYPE,
});
}
private async queueBulkTask(params: {
bookings: Array<{
bookingUid: string;
data: unknown;
}>;
actor: Actor;
organizationId: number | null;
action: string;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
// Skip queueing for non-organization bookings
if (params.organizationId === null) {
return;
}
// Skip queueing if booking audit is disabled for this organization
if (!params.isBookingAuditEnabled) {
this.log.debug(
`Skipping bulk ${params.action} audit: booking-audit feature is disabled for organization`,
{ organizationId: params.organizationId, bookingCount: params.bookings.length, action: params.action }
);
return;
}
try {
const piiFreeActor = await this.getPIIFreeBookingAuditActor({
actor: params.actor,
});
const operationId = params.operationId ?? uuidv4();
await this.tasker.create("bookingAudit", {
isBulk: true,
bookings: params.bookings,
actor: piiFreeActor,
organizationId: params.organizationId,
timestamp: Date.now(),
action: params.action as BookingAuditAction,
source: params.source,
operationId,
context: params.context,
});
} catch (error) {
this.log.error(`Error while queueing bulk ${params.action} audit`, safeStringify(error));
}
}
async queueBulkAcceptedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof AcceptedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: AcceptedAuditActionService.TYPE,
});
}
async queueBulkCancelledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CancelledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CancelledAuditActionService.TYPE,
});
}
async queueBulkCreatedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: CreatedAuditActionService.TYPE,
});
}
async queueBulkRescheduledAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RescheduledAuditActionService.TYPE,
});
}
async queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RejectedAuditActionService.TYPE,
});
}
async queueBulkRejectedAudit(params: {
bookings: Array<{
bookingUid: string;
data: z.infer<typeof RejectedAuditActionService.latestFieldsSchema>;
}>;
actor: Actor;
organizationId: number | null;
source: ActionSource;
operationId?: string | null;
context?: BookingAuditContext;
isBookingAuditEnabled: boolean;
}): Promise<void> {
await this.queueBulkTask({
...params,
action: RejectedAuditActionService.TYPE,
});
}
}
@@ -339,12 +339,11 @@ export class BookingAuditViewerService {
};
}
private mergeDataRequirements(...requirements: DataRequirements[]): DataRequirements {
const userUuids = new Set<string>();
const attendeeIds = new Set<number>();
const credentialIds = new Set<number>();
for (const requirement of requirements) {
for (const uuid of requirement.userUuids || []) userUuids.add(uuid);
for (const id of requirement.attendeeIds || []) attendeeIds.add(id);
@@ -368,7 +367,7 @@ export class BookingAuditViewerService {
for (const log of auditLogs) {
actorRequirements.push(getActorDataRequirements(log.actor));
const context = log.context
const context = log.context;
if (context?.impersonatedBy) {
contextRequirements.push({ userUuids: [context.impersonatedBy] });
}
@@ -377,7 +376,7 @@ export class BookingAuditViewerService {
const actionService = this.actionServiceRegistry.getActionService(log.action);
const parsedData = actionService.parseStored(log.data);
serviceRequirements.push(actionService.getDataRequirements(parsedData));
} catch(error) {
} catch (error) {
this.log.error(
`Failed to get data requirements for action ${log.action}: ${error instanceof Error ? error.message : String(error)}`
);
@@ -4,102 +4,111 @@ import type { BookingAuditViewerService, DisplayBookingAuditLog } from "./Bookin
import { getFieldResponseByIdentifier } from "@calcom/features/routing-forms/lib/getFieldResponseByIdentifier";
type GetHistoryForBookingParams = {
bookingUid: string;
userId: number;
userEmail: string;
userTimeZone: string;
organizationId: number | null;
bookingUid: string;
userId: number;
userEmail: string;
userTimeZone: string;
organizationId: number | null;
};
type BookingHistoryLog = DisplayBookingAuditLog;
interface BookingHistoryViewerServiceDeps {
bookingAuditViewerService: BookingAuditViewerService;
routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
bookingAuditViewerService: BookingAuditViewerService;
routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
}
export class BookingHistoryViewerService {
private readonly bookingAuditViewerService: BookingAuditViewerService;
private readonly routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
private readonly bookingAuditViewerService: BookingAuditViewerService;
private readonly routingFormResponseRepository: RoutingFormResponseRepositoryInterface;
constructor(private readonly deps: BookingHistoryViewerServiceDeps) {
this.bookingAuditViewerService = deps.bookingAuditViewerService;
this.routingFormResponseRepository = deps.routingFormResponseRepository;
constructor(private readonly deps: BookingHistoryViewerServiceDeps) {
this.bookingAuditViewerService = deps.bookingAuditViewerService;
this.routingFormResponseRepository = deps.routingFormResponseRepository;
}
private sortLogsReverseChronologically(historyLogs: BookingHistoryLog[]): BookingHistoryLog[] {
return historyLogs.sort((a, b) => {
const timestampA = new Date(a.timestamp).getTime();
const timestampB = new Date(b.timestamp).getTime();
return timestampB - timestampA;
});
}
private async getFormAuditLogsForBooking(bookingUid: string): Promise<BookingHistoryLog[]> {
// TODO: Form doesn't have its Audit Logs yet, so we replicate them using the Form Response directly for now.
const formResponse = await this.routingFormResponseRepository.findByBookingUidIncludeForm(bookingUid);
if (!formResponse) {
return [];
}
return [this.createFormSubmissionEntry({ formResponse, bookingUid })];
}
private sortLogsReverseChronologically(historyLogs: BookingHistoryLog[]): BookingHistoryLog[] {
return historyLogs.sort((a, b) => {
const timestampA = new Date(a.timestamp).getTime();
const timestampB = new Date(b.timestamp).getTime();
return timestampB - timestampA;
});
}
async getHistoryForBooking(
params: GetHistoryForBookingParams
): Promise<{ bookingUid: string; auditLogs: BookingHistoryLog[] }> {
const { bookingUid } = params;
private async getFormAuditLogsForBooking(bookingUid: string): Promise<BookingHistoryLog[]> {
// TODO: Form doesn't have its Audit Logs yet, so we replicate them using the Form Response directly for now.
const formResponse = await this.routingFormResponseRepository.findByBookingUidIncludeForm(bookingUid);
if (!formResponse) {
return [];
}
return [this.createFormSubmissionEntry({ formResponse, bookingUid })];
}
const { auditLogs: bookingAuditLogs } =
await this.bookingAuditViewerService.getAuditLogsForBooking(params);
async getHistoryForBooking(
params: GetHistoryForBookingParams
): Promise<{ bookingUid: string; auditLogs: BookingHistoryLog[] }> {
const { bookingUid } = params;
const historyEntries: BookingHistoryLog[] = [
...bookingAuditLogs,
...(await this.getFormAuditLogsForBooking(bookingUid)),
];
const { auditLogs: bookingAuditLogs } = await this.bookingAuditViewerService.getAuditLogsForBooking(params);
const sortedLogs = this.sortLogsReverseChronologically(historyEntries);
const historyEntries: BookingHistoryLog[] = [...bookingAuditLogs, ...await this.getFormAuditLogsForBooking(bookingUid)];
return {
bookingUid,
auditLogs: sortedLogs,
};
}
const sortedLogs = this.sortLogsReverseChronologically(historyEntries);
private createFormSubmissionEntry({
formResponse,
bookingUid,
}: {
formResponse: NonNullable<
Awaited<ReturnType<RoutingFormResponseRepositoryInterface["findByBookingUidIncludeForm"]>>
>;
bookingUid: string;
}): BookingHistoryLog {
const timestamp = formResponse.createdAt.toISOString();
return {
bookingUid,
auditLogs: sortedLogs,
};
}
private createFormSubmissionEntry({
formResponse,
bookingUid,
}: {
formResponse: NonNullable<
Awaited<ReturnType<RoutingFormResponseRepositoryInterface["findByBookingUidIncludeForm"]>>
>;
bookingUid: string;
}): BookingHistoryLog {
const timestamp = formResponse.createdAt.toISOString();
const emailFieldResult = getFieldResponseByIdentifier({ responsePayload: formResponse.response, formFields: formResponse.form.fields, identifier: "email" });
const emailFieldValueFromResponse = emailFieldResult.success ? emailFieldResult.data : null;
// A valid string can be the email otherwise we assume it is not an email
const submitterEmail = typeof emailFieldValueFromResponse === "string" ? emailFieldValueFromResponse : null;
const uniqueId = `form-submission-${formResponse.id}`;
return {
id: uniqueId,
bookingUid,
type: "RECORD_CREATED",
action: "CREATED",
timestamp,
createdAt: timestamp,
source: "WEBAPP",
operationId: uniqueId,
displayJson: null,
actionDisplayTitle: { key: "form_submitted" },
displayFields: null,
actor: {
id: `form-submission-actor-${formResponse.id}`,
type: "GUEST",
userUuid: null,
attendeeId: null,
name: null,
createdAt: formResponse.createdAt,
displayName: submitterEmail ? `${submitterEmail}` : "Guest",
displayEmail: submitterEmail || null,
displayAvatar: null,
},
};
}
const emailFieldResult = getFieldResponseByIdentifier({
responsePayload: formResponse.response,
formFields: formResponse.form.fields,
identifier: "email",
});
const emailFieldValueFromResponse = emailFieldResult.success ? emailFieldResult.data : null;
// A valid string can be the email otherwise we assume it is not an email
const submitterEmail =
typeof emailFieldValueFromResponse === "string" ? emailFieldValueFromResponse : null;
const uniqueId = `form-submission-${formResponse.id}`;
return {
id: uniqueId,
bookingUid,
type: "RECORD_CREATED",
action: "CREATED",
timestamp,
createdAt: timestamp,
source: "WEBAPP",
operationId: uniqueId,
displayJson: null,
actionDisplayTitle: { key: "form_submitted" },
displayFields: null,
actor: {
id: `form-submission-actor-${formResponse.id}`,
type: "GUEST",
userUuid: null,
attendeeId: null,
name: null,
createdAt: formResponse.createdAt,
displayName: submitterEmail ? `${submitterEmail}` : "Guest",
displayEmail: submitterEmail || null,
displayAvatar: null,
},
};
}
}
@@ -5,26 +5,33 @@ import { BookingRepository } from "@calcom/features/bookings/repositories/Bookin
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import { BookingAuditAccessService, BookingAuditErrorCode, BookingAuditPermissionError } from "../BookingAuditAccessService";
import {
BookingAuditAccessService,
BookingAuditErrorCode,
BookingAuditPermissionError,
} from "../BookingAuditAccessService";
vi.mock("@calcom/features/pbac/services/permission-check.service");
vi.mock("@calcom/features/bookings/repositories/BookingRepository");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
const DB = {
bookings: {} as Record<string, {
uid: string;
userId: number | null;
eventType: {
teamId: number | null;
parent: { teamId: number } | null;
hosts: never[];
users: never[];
};
user: { id: number; email: string };
attendees: never[];
}>,
bookings: {} as Record<
string,
{
uid: string;
userId: number | null;
eventType: {
teamId: number | null;
parent: { teamId: number } | null;
hosts: never[];
users: never[];
};
user: { id: number; email: string };
attendees: never[];
}
>,
memberships: {} as Record<string, boolean>,
}
};
const createMockTeamBooking = (overrides: {
bookingUid: string;
@@ -36,20 +43,20 @@ const createMockTeamBooking = (overrides: {
uid: overrides.bookingUid,
userId: overrides?.userId ?? 456,
eventType: {
teamId: (overrides && "teamId" in overrides ? overrides.teamId : overrides?.teamId ?? 100) ?? null,
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
hosts: [],
users: []
users: [],
},
user: {
id: overrides?.userId ?? 456,
email: "test@example.com",
},
attendees: [],
}
};
DB.bookings[booking.uid] = booking;
return booking;
}
};
const createMockPersonalBooking = (overrides: { userId?: number; bookingUid: string }) => {
const booking = {
@@ -69,7 +76,7 @@ const createMockPersonalBooking = (overrides: { userId?: number; bookingUid: str
};
DB.bookings[booking.uid] = booking;
return booking;
}
};
const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => {
const key = `${userId}-${teamId}`;
@@ -78,25 +85,49 @@ const createMockMembership = ({ userId, teamId }: { userId: number; teamId: numb
type MockPermissionCheckService = {
checkPermission: Mock<PermissionCheckService["checkPermission"]>;
}
};
const provideReadTeamAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => {
mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readTeamAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
const provideReadTeamAuditLogsPermission = ({
mockPermissionCheckService,
value,
targetUserId,
targetTeamId,
}: {
mockPermissionCheckService: MockPermissionCheckService;
value: boolean;
targetUserId: number;
targetTeamId: number;
}) => {
mockPermissionCheckService.checkPermission.mockImplementation(
({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readTeamAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
}
return Promise.resolve(false);
}
return Promise.resolve(false);
});
}
);
};
const provideReadOrgAuditLogsPermission = ({ mockPermissionCheckService, value, targetUserId, targetTeamId }: { mockPermissionCheckService: MockPermissionCheckService, value: boolean, targetUserId: number, targetTeamId: number }) => {
mockPermissionCheckService.checkPermission.mockImplementation(({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readOrgAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
const provideReadOrgAuditLogsPermission = ({
mockPermissionCheckService,
value,
targetUserId,
targetTeamId,
}: {
mockPermissionCheckService: MockPermissionCheckService;
value: boolean;
targetUserId: number;
targetTeamId: number;
}) => {
mockPermissionCheckService.checkPermission.mockImplementation(
({ userId, teamId, permission, _fallbackRoles }) => {
if (permission === "booking.readOrgAuditLogs" && userId === targetUserId && teamId === targetTeamId) {
return Promise.resolve(value);
}
return Promise.resolve(false);
}
return Promise.resolve(false);
});
}
);
};
const mockBookingRepository: {
findByUidIncludeEventType: Mock<BookingRepository["findByUidIncludeEventType"]>;
@@ -130,9 +161,15 @@ describe("BookingAuditAccessService - Permission Checks", () => {
checkPermission: vi.fn(),
};
vi.mocked(BookingRepository).mockImplementation(function() { return mockBookingRepository as unknown as BookingRepository; });
vi.mocked(MembershipRepository).mockImplementation(function() { return mockMembershipRepository as unknown as MembershipRepository; });
vi.mocked(PermissionCheckService).mockImplementation(function() { return mockPermissionCheckService as unknown as PermissionCheckService; });
vi.mocked(BookingRepository).mockImplementation(function () {
return mockBookingRepository as unknown as BookingRepository;
});
vi.mocked(MembershipRepository).mockImplementation(function () {
return mockMembershipRepository as unknown as MembershipRepository;
});
vi.mocked(PermissionCheckService).mockImplementation(function () {
return mockPermissionCheckService as unknown as PermissionCheckService;
});
service = new BookingAuditAccessService({
bookingRepository: mockBookingRepository as unknown as BookingRepository,
@@ -146,9 +183,16 @@ describe("BookingAuditAccessService - Permission Checks", () => {
const userId = 123;
const teamId = 100;
createMockTeamBooking({ teamId, bookingUid });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: teamId });
provideReadTeamAuditLogsPermission({
mockPermissionCheckService,
value: true,
targetUserId: userId,
targetTeamId: teamId,
});
await expect(service.assertPermissions({ bookingUid, userId, organizationId: 200 })).resolves.not.toThrow();
await expect(
service.assertPermissions({ bookingUid, userId, organizationId: 200 })
).resolves.not.toThrow();
});
it("should throw PERMISSION_DENIED error when user lacks booking.readTeamAuditLogs permission and also doesn't even have a membership in the organization for the booking's team", async () => {
@@ -158,8 +202,18 @@ describe("BookingAuditAccessService - Permission Checks", () => {
const organizationId = 200;
createMockTeamBooking({ teamId, bookingUid, userId: 456 });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: teamId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId });
provideReadTeamAuditLogsPermission({
mockPermissionCheckService,
value: false,
targetUserId: userId,
targetTeamId: teamId,
});
provideReadOrgAuditLogsPermission({
mockPermissionCheckService,
value: false,
targetUserId: userId,
targetTeamId: organizationId,
});
const promise = service.assertPermissions({ bookingUid, userId, organizationId });
await expect(promise).rejects.toThrow(BookingAuditPermissionError);
await expect(promise).rejects.toThrow(BookingAuditErrorCode.PERMISSION_DENIED);
@@ -173,7 +227,12 @@ describe("BookingAuditAccessService - Permission Checks", () => {
const organizationId = 200;
createMockPersonalBooking({ userId: 456, bookingUid });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: organizationId });
provideReadOrgAuditLogsPermission({
mockPermissionCheckService,
value: true,
targetUserId: userId,
targetTeamId: organizationId,
});
await service.assertPermissions({ bookingUid, userId, organizationId });
@@ -192,7 +251,12 @@ describe("BookingAuditAccessService - Permission Checks", () => {
const userId = 123;
const parentTeamId = 500;
createMockTeamBooking({ teamId: null, parentTeamId, bookingUid });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: true, targetUserId: userId, targetTeamId: parentTeamId });
provideReadTeamAuditLogsPermission({
mockPermissionCheckService,
value: true,
targetUserId: userId,
targetTeamId: parentTeamId,
});
await service.assertPermissions({ bookingUid, userId, organizationId: 200 });
@@ -211,25 +275,45 @@ describe("BookingAuditAccessService - Permission Checks", () => {
const organizationId = 200;
createMockTeamBooking({ teamId: null, parentTeamId, bookingUid, userId: 456 });
createMockMembership({ userId: 456, teamId: organizationId });
provideReadTeamAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: parentTeamId });
provideReadOrgAuditLogsPermission({ mockPermissionCheckService, value: false, targetUserId: userId, targetTeamId: organizationId });
provideReadTeamAuditLogsPermission({
mockPermissionCheckService,
value: false,
targetUserId: userId,
targetTeamId: parentTeamId,
});
provideReadOrgAuditLogsPermission({
mockPermissionCheckService,
value: false,
targetUserId: userId,
targetTeamId: organizationId,
});
await expect(service.assertPermissions({ bookingUid, userId, organizationId })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId, organizationId })).rejects.toThrow(
BookingAuditPermissionError
);
});
});
describe("assertPermissions - Edge Cases", () => {
it("should throw error when organizationId is null", async () => {
await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })).rejects.toThrow(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
await expect(
service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })
).rejects.toThrow(BookingAuditPermissionError);
await expect(
service.assertPermissions({ bookingUid: "test-booking-uid", userId: 123, organizationId: null })
).rejects.toThrow(BookingAuditErrorCode.ORGANIZATION_ID_REQUIRED);
});
it("should throw error when booking not found", async () => {
const bookingUid = "non-existent-booking-uid";
// Don't create any booking in DB
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditPermissionError);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditErrorCode.BOOKING_NOT_FOUND_OR_PERMISSION_DENIED);
});
it("should throw error when booking has no userId", async () => {
@@ -251,8 +335,12 @@ describe("BookingAuditAccessService - Permission Checks", () => {
};
DB.bookings[bookingUid] = booking;
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditPermissionError);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditErrorCode.BOOKING_HAS_NO_OWNER);
});
it("should throw error when booking owner is not member of organization", async () => {
@@ -260,8 +348,12 @@ describe("BookingAuditAccessService - Permission Checks", () => {
createMockPersonalBooking({ userId: 456, bookingUid });
// Don't create membership for userId 456 in organization 200
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditPermissionError);
await expect(service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })).rejects.toThrow(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditPermissionError);
await expect(
service.assertPermissions({ bookingUid, userId: 123, organizationId: 200 })
).rejects.toThrow(BookingAuditErrorCode.OWNER_NOT_IN_ORGANIZATION);
});
});
});
@@ -158,7 +158,13 @@ type MockUser = {
const createMockUser = (
uuid?: string,
overrides?: Partial<{ id: number; uuid: string; name: string | null; email: string; avatarUrl: string | null }>
overrides?: Partial<{
id: number;
uuid: string;
name: string | null;
email: string;
avatarUrl: string | null;
}>
) => {
const userUuid = uuid ?? overrides?.uuid ?? `user-uuid-${overrides?.id ?? 123}`;
const user: MockUser = {
@@ -31,10 +31,7 @@ describe("EnrichmentDataStore", () => {
describe("constructor", () => {
it("should pre-populate usersByUuid map with nulls for declared userUuids", () => {
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1", "uuid-2"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1", "uuid-2"] }, repositories);
expect(() => store.getUserByUuid("uuid-1")).not.toThrow();
expect(() => store.getUserByUuid("uuid-2")).not.toThrow();
@@ -43,10 +40,7 @@ describe("EnrichmentDataStore", () => {
});
it("should pre-populate attendeesById map with nulls for declared attendeeIds", () => {
const store = new EnrichmentDataStore(
{ attendeeIds: [1, 2, 3] },
repositories
);
const store = new EnrichmentDataStore({ attendeeIds: [1, 2, 3] }, repositories);
expect(() => store.getAttendeeById(1)).not.toThrow();
expect(() => store.getAttendeeById(2)).not.toThrow();
@@ -55,10 +49,7 @@ describe("EnrichmentDataStore", () => {
});
it("should pre-populate credentialsById map with nulls for declared credentialIds", () => {
const store = new EnrichmentDataStore(
{ credentialIds: [10, 20] },
repositories
);
const store = new EnrichmentDataStore({ credentialIds: [10, 20] }, repositories);
expect(() => store.getCredentialById(10)).not.toThrow();
expect(() => store.getCredentialById(20)).not.toThrow();
@@ -82,10 +73,7 @@ describe("EnrichmentDataStore", () => {
];
vi.mocked(mockUserRepository.findByUuids).mockResolvedValue(mockUsers);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1", "uuid-2"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1", "uuid-2"] }, repositories);
await store.fetch();
expect(mockUserRepository.findByUuids).toHaveBeenCalledWith({ uuids: ["uuid-1", "uuid-2"] });
@@ -100,10 +88,7 @@ describe("EnrichmentDataStore", () => {
];
vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue(mockAttendees);
const store = new EnrichmentDataStore(
{ attendeeIds: [1, 2] },
repositories
);
const store = new EnrichmentDataStore({ attendeeIds: [1, 2] }, repositories);
await store.fetch();
expect(mockAttendeeRepository.findByIds).toHaveBeenCalledWith({ ids: [1, 2] });
@@ -118,10 +103,7 @@ describe("EnrichmentDataStore", () => {
];
vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue(mockCredentials);
const store = new EnrichmentDataStore(
{ credentialIds: [10, 20] },
repositories
);
const store = new EnrichmentDataStore({ credentialIds: [10, 20] }, repositories);
await store.fetch();
expect(mockCredentialRepository.findByIds).toHaveBeenCalledWith({ ids: [10, 20] });
@@ -136,9 +118,7 @@ describe("EnrichmentDataStore", () => {
vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([
{ id: 1, name: "Attendee", email: "attendee@example.com" },
]);
vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([
{ id: 10, appId: "app" },
]);
vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([{ id: 10, appId: "app" }]);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1"], attendeeIds: [1], credentialIds: [10] },
@@ -165,10 +145,7 @@ describe("EnrichmentDataStore", () => {
{ id: 1, uuid: "uuid-1", name: "User 1", email: "user1@example.com", avatarUrl: null },
]);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1", "uuid-2", "uuid-3"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1", "uuid-2", "uuid-3"] }, repositories);
await store.fetch();
expect(store.getUserByUuid("uuid-1")).not.toBeNull();
@@ -179,10 +156,7 @@ describe("EnrichmentDataStore", () => {
describe("getUserByUuid", () => {
it("should throw error when accessing undeclared UUID", () => {
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1"] }, repositories);
expect(() => store.getUserByUuid("undeclared-uuid")).toThrow(
'EnrichmentDataStore: getUserByUuid("undeclared-uuid") called but was not declared in getDataRequirements'
@@ -192,23 +166,23 @@ describe("EnrichmentDataStore", () => {
it("should return null for declared UUID that does not exist in database", async () => {
vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([]);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1"] }, repositories);
await store.fetch();
expect(store.getUserByUuid("uuid-1")).toBeNull();
});
it("should return user data for declared UUID that exists in database", async () => {
const mockUser = { id: 1, uuid: "uuid-1", name: "Test User", email: "test@example.com", avatarUrl: null };
const mockUser = {
id: 1,
uuid: "uuid-1",
name: "Test User",
email: "test@example.com",
avatarUrl: null,
};
vi.mocked(mockUserRepository.findByUuids).mockResolvedValue([mockUser]);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1"] }, repositories);
await store.fetch();
expect(store.getUserByUuid("uuid-1")).toEqual(mockUser);
@@ -217,10 +191,7 @@ describe("EnrichmentDataStore", () => {
describe("getAttendeeById", () => {
it("should throw error when accessing undeclared attendee ID", () => {
const store = new EnrichmentDataStore(
{ attendeeIds: [1] },
repositories
);
const store = new EnrichmentDataStore({ attendeeIds: [1] }, repositories);
expect(() => store.getAttendeeById(999)).toThrow(
"EnrichmentDataStore: getAttendeeById(999) called but was not declared in getDataRequirements"
@@ -230,10 +201,7 @@ describe("EnrichmentDataStore", () => {
it("should return null for declared ID that does not exist in database", async () => {
vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([]);
const store = new EnrichmentDataStore(
{ attendeeIds: [1] },
repositories
);
const store = new EnrichmentDataStore({ attendeeIds: [1] }, repositories);
await store.fetch();
expect(store.getAttendeeById(1)).toBeNull();
@@ -243,10 +211,7 @@ describe("EnrichmentDataStore", () => {
const mockAttendee = { id: 1, name: "Test Attendee", email: "attendee@example.com" };
vi.mocked(mockAttendeeRepository.findByIds).mockResolvedValue([mockAttendee]);
const store = new EnrichmentDataStore(
{ attendeeIds: [1] },
repositories
);
const store = new EnrichmentDataStore({ attendeeIds: [1] }, repositories);
await store.fetch();
expect(store.getAttendeeById(1)).toEqual(mockAttendee);
@@ -255,10 +220,7 @@ describe("EnrichmentDataStore", () => {
describe("getCredentialById", () => {
it("should throw error when accessing undeclared credential ID", () => {
const store = new EnrichmentDataStore(
{ credentialIds: [10] },
repositories
);
const store = new EnrichmentDataStore({ credentialIds: [10] }, repositories);
expect(() => store.getCredentialById(999)).toThrow(
"EnrichmentDataStore: getCredentialById(999) called but was not declared in getDataRequirements"
@@ -268,10 +230,7 @@ describe("EnrichmentDataStore", () => {
it("should return null for declared ID that does not exist in database", async () => {
vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([]);
const store = new EnrichmentDataStore(
{ credentialIds: [10] },
repositories
);
const store = new EnrichmentDataStore({ credentialIds: [10] }, repositories);
await store.fetch();
expect(store.getCredentialById(10)).toBeNull();
@@ -281,10 +240,7 @@ describe("EnrichmentDataStore", () => {
const mockCredential = { id: 10, appId: "google-calendar" };
vi.mocked(mockCredentialRepository.findByIds).mockResolvedValue([mockCredential]);
const store = new EnrichmentDataStore(
{ credentialIds: [10] },
repositories
);
const store = new EnrichmentDataStore({ credentialIds: [10] }, repositories);
await store.fetch();
expect(store.getCredentialById(10)).toEqual(mockCredential);
@@ -300,10 +256,7 @@ describe("EnrichmentDataStore", () => {
];
vi.mocked(mockUserRepository.findByUuids).mockResolvedValue(mockUsers);
const store = new EnrichmentDataStore(
{ userUuids: ["uuid-1", "uuid-2", "uuid-3"] },
repositories
);
const store = new EnrichmentDataStore({ userUuids: ["uuid-1", "uuid-2", "uuid-3"] }, repositories);
await store.fetch();
expect(store.getUserByUuid("uuid-1")).toEqual(mockUsers[0]);
@@ -123,4 +123,3 @@ describe("Cancelled Action Integration", () => {
});
});
});
@@ -2,10 +2,25 @@ import { z } from "zod";
// This is the schema for DB value, here we use UNKNOWN in case client didn't pass an explicit action source.
// SYSTEM is used for background jobs (tasker tasks, trigger.dev, etc.)
export const ActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK", "SYSTEM", "UNKNOWN"]);
export const ActionSourceSchema = z.enum([
"API_V1",
"API_V2",
"WEBAPP",
"WEBHOOK",
"MAGIC_LINK",
"SYSTEM",
"UNKNOWN",
]);
export type ActionSource = z.infer<typeof ActionSourceSchema>;
// We don't keep UNKNOWN here because we don't want clients to pass UNKNOWN.
// SYSTEM is used for background jobs (tasker tasks, trigger.dev, etc.)
export const ValidActionSourceSchema = z.enum(["API_V1", "API_V2", "WEBAPP", "WEBHOOK", "MAGIC_LINK", "SYSTEM"]);
export const ValidActionSourceSchema = z.enum([
"API_V1",
"API_V2",
"WEBAPP",
"WEBHOOK",
"MAGIC_LINK",
"SYSTEM",
]);
export type ValidActionSource = z.infer<typeof ValidActionSourceSchema>;
@@ -8,19 +8,19 @@ import { ActionSourceSchema } from "./actionSource";
* Used for runtime validation of action field
*/
const BookingAuditActionSchema = z.enum([
"CREATED",
"RESCHEDULED",
"ACCEPTED",
"CANCELLED",
"RESCHEDULE_REQUESTED",
"ATTENDEE_ADDED",
"REJECTED",
"ATTENDEE_REMOVED",
"REASSIGNMENT",
"LOCATION_CHANGED",
"NO_SHOW_UPDATED",
"SEAT_BOOKED",
"SEAT_RESCHEDULED",
"CREATED",
"RESCHEDULED",
"ACCEPTED",
"CANCELLED",
"RESCHEDULE_REQUESTED",
"ATTENDEE_ADDED",
"REJECTED",
"ATTENDEE_REMOVED",
"REASSIGNMENT",
"LOCATION_CHANGED",
"NO_SHOW_UPDATED",
"SEAT_BOOKED",
"SEAT_RESCHEDULED",
]);
export type BookingAuditAction = z.infer<typeof BookingAuditActionSchema>;
@@ -28,48 +28,48 @@ export type BookingAuditAction = z.infer<typeof BookingAuditActionSchema>;
const actionAgnosticDataSchema = z.unknown();
const bookingAuditPayloadSchema = z.object({
bookingUid: z.string(),
data: actionAgnosticDataSchema,
bookingUid: z.string(),
data: actionAgnosticDataSchema,
});
export const SingleBookingAuditTaskConsumerSchema = z.object({
isBulk: z.literal(false),
...bookingAuditPayloadSchema.shape,
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
context: BookingAuditContextSchema.optional(),
isBulk: z.literal(false),
...bookingAuditPayloadSchema.shape,
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
context: BookingAuditContextSchema.optional(),
});
export type SingleBookingAuditTaskConsumerPayload = z.infer<typeof SingleBookingAuditTaskConsumerSchema>;
/**
* Bulk booking audit task payload schema
*
*
* Used for operations that affect multiple bookings in a single action.
* Contains an array of bookings, each with bookingUid and action-specific data.
* All bookings share the same actor, organizationId, timestamp, action, source, and operationId.
*/
export const BulkBookingAuditTaskConsumerSchema = z.object({
isBulk: z.literal(true),
bookings: z.array(bookingAuditPayloadSchema).min(1),
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
context: BookingAuditContextSchema.optional(),
isBulk: z.literal(true),
bookings: z.array(bookingAuditPayloadSchema).min(1),
actor: PiiFreeActorSchema,
organizationId: z.number().nullable(),
timestamp: z.number(),
action: BookingAuditActionSchema,
source: ActionSourceSchema.default("UNKNOWN"),
operationId: z.string(),
context: BookingAuditContextSchema.optional(),
});
export type BulkBookingAuditTaskConsumerPayload = z.infer<typeof BulkBookingAuditTaskConsumerSchema>;
export const BookingAuditTaskConsumerSchema = z.discriminatedUnion("isBulk", [
SingleBookingAuditTaskConsumerSchema,
BulkBookingAuditTaskConsumerSchema,
SingleBookingAuditTaskConsumerSchema,
BulkBookingAuditTaskConsumerSchema,
]);
export type BookingAuditTaskConsumerPayload = z.infer<typeof BookingAuditTaskConsumerSchema>;
@@ -103,4 +103,4 @@ export const useBookerLayout = (
slotsViewOnSmallScreen,
bookerLayouts,
};
};
};
@@ -17,4 +17,4 @@ export const useBookerTime = () => {
timezoneFromBookerStore,
timezoneFromTimePreferences,
};
};
};
@@ -127,4 +127,4 @@ export const useBookingForm = ({
formErrors: errors,
errors,
};
};
};
@@ -291,4 +291,4 @@ describe("useInitialFormValues - Autofill Disable Feature", () => {
expect(result.current.values.responses?.phone).toBeUndefined();
});
});
});
});
@@ -212,4 +212,4 @@ export function useInitialFormValues({
]);
return initialValuesState;
}
}
@@ -68,4 +68,4 @@ export function useLocalSet<T extends HasExternalId>(key: string, initialValue:
};
return { set, addValue, removeById, toggleValue, hasItem, clearSet };
}
}
+15 -52
View File
@@ -9,11 +9,7 @@ import { BookerLayouts } from "@calcom/prisma/zod-utils";
import type { GetBookingType } from "../lib/get-booking";
import type { BookerState, BookerLayout } from "./types";
import {
updateQueryParam,
getQueryParam,
removeQueryParam,
} from "./utils/query-param";
import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param";
const _iso_3166_1_alpha_2_codes = [
"ad",
@@ -357,9 +353,7 @@ export type BookerStore = {
* Multiple Selected Dates and Times
*/
selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } } | null;
setSelectedDatesAndTimes: (selectedDatesAndTimes: {
[key: string]: { [key: string]: string[] };
}) => void;
setSelectedDatesAndTimes: (selectedDatesAndTimes: { [key: string]: { [key: string]: string[] } }) => void;
/**
* Multiple duration configuration
*/
@@ -457,10 +451,7 @@ export const createBookerStore = () =>
setLayout: (layout: BookerLayout) => {
// If we switch to a large layout and don't have a date selected yet,
// we selected it here, so week title is rendered properly.
if (
["week_view", "column_view"].includes(layout) &&
!get().selectedDate
) {
if (["week_view", "column_view"].includes(layout) && !get().selectedDate) {
set({ selectedDate: dayjs().format("YYYY-MM-DD") });
}
if (!get().isPlatform || get().allowUpdatingUrlParams) {
@@ -469,11 +460,7 @@ export const createBookerStore = () =>
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: ({
date: selectedDate,
omitUpdatingParams = false,
preventMonthSwitching = false,
}) => {
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
@@ -483,24 +470,15 @@ export const createBookerStore = () =>
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("date", selectedDate ?? "");
}
// Setting month make sure small calendar in fullscreen layouts also updates.
// preventMonthSwitching is true in monthly view
if (
!preventMonthSwitching &&
newSelection.month() !== currentSelection.month()
) {
if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (
!omitUpdatingParams &&
(!get().isPlatform || get().allowUpdatingUrlParams)
) {
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
}
@@ -561,8 +539,7 @@ export const createBookerStore = () =>
}
get().setSelectedDate({ date: null });
},
dayCount:
BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
set({ dayCount });
},
@@ -644,9 +621,7 @@ export const createBookerStore = () =>
// Preselect today's date in week / column view, since they use this to show the week title.
selectedDate:
selectedDateInStore ||
(["week_view", "column_view"].includes(layout)
? dayjs().format("YYYY-MM-DD")
: null),
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
@@ -707,24 +682,14 @@ export const createBookerStore = () =>
set({ rescheduleUid });
},
recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) =>
set({ recurringEventCount }),
recurringEventCountQueryParam:
Number(getQueryParam("recurringEventCount")) || null,
setRecurringEventCountQueryParam: (
recurringEventCountQueryParam: number | null
) => {
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
recurringEventCountQueryParam: Number(getQueryParam("recurringEventCount")) || null,
setRecurringEventCountQueryParam: (recurringEventCountQueryParam: number | null) => {
// Guard: only update state if value is valid (not NaN or null)
if (
recurringEventCountQueryParam !== null &&
!isNaN(recurringEventCountQueryParam)
) {
if (recurringEventCountQueryParam !== null && !isNaN(recurringEventCountQueryParam)) {
set({ recurringEventCountQueryParam });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam(
"recurringEventCount",
recurringEventCountQueryParam
);
updateQueryParam("recurringEventCount", recurringEventCountQueryParam);
}
}
// If invalid, don't update state or URL - just ignore the call
@@ -755,9 +720,7 @@ export const createBookerStore = () =>
allowUpdatingUrlParams: true,
defaultPhoneCountry: null,
isSlotSelectionModalVisible: false,
setIsSlotSelectionModalVisible: (
isSlotSelectionModalVisible: boolean
) => {
setIsSlotSelectionModalVisible: (isSlotSelectionModalVisible: boolean) => {
set({ isSlotSelectionModalVisible });
},
}));
@@ -34,10 +34,7 @@ export const formatEventFromTime = ({ date, timeFormat, timeZone, language }: Ev
return {
date: formattedDate,
time:
timeFormat === TimeFormat.TWELVE_HOUR
? formattedTime.toLowerCase()
: formattedTime,
time: timeFormat === TimeFormat.TWELVE_HOUR ? formattedTime.toLowerCase() : formattedTime,
};
};
@@ -68,10 +65,7 @@ export const formatEventFromToTime = ({
return {
date: formattedDate,
time:
timeFormat === TimeFormat.TWELVE_HOUR
? formattedTime.toLowerCase()
: formattedTime,
time: timeFormat === TimeFormat.TWELVE_HOUR ? formattedTime.toLowerCase() : formattedTime,
};
};
@@ -84,7 +84,13 @@ describe("getPrefetchMonthCount", () => {
});
it("should return undefined when both months are invalid", () => {
const result = getPrefetchMonthCount(BookerLayouts.COLUMN_VIEW, "selecting_time", Infinity, -Infinity, false);
const result = getPrefetchMonthCount(
BookerLayouts.COLUMN_VIEW,
"selecting_time",
Infinity,
-Infinity,
false
);
expect(result).toBe(undefined);
});
});
@@ -12,4 +12,3 @@ export function getBookingEventHandlerService() {
return container.get<BookingEventHandlerService>(bookingEventHandlerServiceModule.token);
}
@@ -399,7 +399,7 @@ export default class EventManager {
return {
type: result.type,
uid: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString() ?? "",
uid: createdEventObj ? createdEventObj.id : (result.createdEvent?.id?.toString() ?? ""),
thirdPartyRecurringEventId: isCalendarType ? thirdPartyRecurringEventId : undefined,
meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(),
meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password,
@@ -688,9 +688,7 @@ export default class EventManager {
if (evt.requiresConfirmation) {
if (!skipDeleteEventsAndMeetings) {
log.debug(
"RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking"
);
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({
event: {
@@ -24,7 +24,7 @@ export function getOrganizationIdOfBooking(booking: {
const { eventType, profileEnrichedBookingUser } = booking;
return eventType.team
? eventType.team.parentId
: profileEnrichedBookingUser?.profile.organizationId ?? null;
: (profileEnrichedBookingUser?.profile.organizationId ?? null);
}
export async function buildEventUrlFromBooking(booking: {
+14 -44
View File
@@ -26,8 +26,7 @@ function getResponsesFromOldBooking(
) {
const customInputs = rawBooking.customInputs || {};
const responses = Object.keys(customInputs).reduce((acc, label) => {
acc[slugify(label) as keyof typeof acc] =
customInputs[label as keyof typeof customInputs];
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
return acc;
}, {});
return {
@@ -46,11 +45,7 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(
prisma: PrismaClient,
uid: string,
isSeatedEvent?: boolean
) {
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
const rawBooking = await prisma.booking.findUnique({
where: {
uid,
@@ -102,12 +97,8 @@ async function getBooking(
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (
booking?.startTime as Date
)?.toISOString() as unknown as Date;
booking["endTime"] = (
booking?.endTime as Date
)?.toISOString() as unknown as Date;
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
booking["endTime"] = (booking?.endTime as Date)?.toISOString() as unknown as Date;
}
return booking;
@@ -120,16 +111,14 @@ export const getBookingWithResponses = <
select: BookingSelect & {
responses: true;
};
}>
}>,
>(
booking: T,
isSeatedEvent?: boolean
) => {
return {
...booking,
responses: isSeatedEvent
? booking.responses
: booking.responses || getResponsesFromOldBooking(booking),
responses: isSeatedEvent ? booking.responses : booking.responses || getResponsesFromOldBooking(booking),
} as Omit<T, "responses"> & { responses: Record<string, any> };
};
@@ -211,16 +200,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
// Otherwise, we return null here.
let hasOwnershipOnBooking = false;
if (
theBooking &&
theBooking?.eventType?.seatsPerTimeSlot &&
bookingSeatReferenceUid === null
) {
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
const isOwnerOfBooking = theBooking.userId === userId;
const isHostOfEventType = theBooking?.eventType?.hosts.some(
(host) => host.userId === userId
);
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
const isUserIdInBooking = theBooking.userId === userId;
@@ -235,13 +218,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
});
}
if (
!isOwnerOfBooking &&
!isHostOfEventType &&
!isUserIdInBooking &&
!hasOrgAccess
)
return null;
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking && !hasOrgAccess) return null;
hasOwnershipOnBooking = true;
}
@@ -249,27 +226,21 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// and we return null here.
if (!theBooking && !rescheduleUid) return null;
const booking = await getBooking(
prisma,
rescheduleUid || uid,
bookingSeatReferenceUid ? true : false
);
const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false);
if (!booking) return null;
if (bookingSeatReferenceUid) {
booking["description"] = bookingSeatData?.description ?? null;
booking["responses"] = bookingResponsesDbSchema.parse(
bookingSeatData?.responses ?? {}
);
booking["responses"] = bookingResponsesDbSchema.parse(bookingSeatData?.responses ?? {});
}
return {
...booking,
attendees: rescheduleUid
? booking.attendees.filter((attendee) => attendee.email === attendeeEmail)
: hasOwnershipOnBooking
? []
: booking.attendees,
? []
: booking.attendees,
};
};
@@ -349,7 +320,6 @@ export const getMultipleDurationValue = (
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration)))
return Number(queryDuration);
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
@@ -11,9 +11,11 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(function() { return {
enrichUserWithItsProfile: vi.fn(),
}; }),
UserRepository: vi.fn().mockImplementation(function () {
return {
enrichUserWithItsProfile: vi.fn(),
};
}),
};
});
@@ -24,7 +24,7 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
eventType: EventType
) => {
let allCredentials = Array.isArray(user.credentials) ? user.credentials : [];
if (eventType?.team?.id) {
const teamCredentialsQuery = await prisma.credential.findMany({
where: {
@@ -33,10 +33,10 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
select: credentialForCalendarServiceSelect,
});
if (Array.isArray(teamCredentialsQuery)) {
allCredentials.push(...teamCredentialsQuery);
allCredentials.push(...teamCredentialsQuery);
}
}
if (eventType?.parentId) {
const teamCredentialsQuery = await prisma.team.findFirst({
where: {
@@ -60,7 +60,7 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
const { profile } = await new UserRepository(prisma).enrichUserWithItsProfile({
user: user,
});
if (profile?.organizationId) {
const org = await prisma.team.findUnique({
where: {
@@ -26,7 +26,9 @@ describe("getAssignmentReasonCategory", () => {
});
it("returns 'salesforce_assigned' for SALESFORCE_ASSIGNMENT", () => {
expect(getAssignmentReasonCategory(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT)).toBe("salesforce_assigned");
expect(getAssignmentReasonCategory(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT)).toBe(
"salesforce_assigned"
);
});
it("returns 'routed' for unknown enum values (default case)", () => {
@@ -36,7 +36,7 @@ export const getSmsReminderNumberField = () =>
defaultLabel: "number_text_notifications",
defaultPlaceholder: "enter_phone_number",
editable: "system",
} as const);
}) as const;
export const getSmsReminderNumberSource = ({
workflowId,
@@ -59,7 +59,7 @@ export const getAIAgentCallPhoneNumberField = () =>
defaultLabel: "phone_number_for_ai_call",
defaultPlaceholder: "enter_phone_number",
editable: "system",
} as const);
}) as const;
export const getAIAgentCallPhoneNumberSource = ({
workflowId,
@@ -1876,7 +1876,7 @@ describe("require email/domain validation", () => {
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
name: "John Doe",
email: "john@example.com",
@@ -6,19 +6,11 @@ import { fieldTypesSchemaMap } from "@calcom/features/form-builder/schema";
import { dbReadResponseSchema } from "@calcom/lib/dbReadResponseSchema";
import logger from "@calcom/lib/logger";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import {
bookingResponses,
emailSchemaRefinement,
} from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
type View = ALL_VIEWS | (string & {});
type BookingFields =
| (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">)
| null;
type TranslationFunction = (
key: string,
options?: Record<string, unknown>
) => string;
type BookingFields = (z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">) | null;
type TranslationFunction = (key: string, options?: Record<string, unknown>) => string;
type CommonParams = {
bookingFields: BookingFields;
view: View;
@@ -58,11 +50,7 @@ const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
return bookerEmailLower.endsWith("@" + entry.toLowerCase());
};
export const getBookingResponsesPartialSchema = ({
bookingFields,
view,
translateFn,
}: CommonParams) => {
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
return preprocess({
@@ -77,11 +65,7 @@ export const getBookingResponsesPartialSchema = ({
// Should be used when we know that not all fields responses are present
// - Can happen when we are parsing the prefill query string
// - Can happen when we are parsing a booking's responses (which was created before we added a new required field)
export default function getBookingResponsesSchema({
bookingFields,
view,
translateFn,
}: CommonParams) {
export default function getBookingResponsesSchema({ bookingFields, view, translateFn }: CommonParams) {
const schema = bookingResponses.and(z.record(z.any()));
return preprocess({
schema,
@@ -129,8 +113,7 @@ function preprocess<T extends z.ZodType>({
const log = logger.getSubLogger({ prefix: ["getBookingResponsesSchema"] });
const preprocessed = z.preprocess(
(responses) => {
const parsedResponses =
z.record(z.any()).nullable().parse(responses) || {};
const parsedResponses = z.record(z.any()).nullable().parse(responses) || {};
const newResponses = {} as typeof parsedResponses;
// if eventType has been deleted, we won't have bookingFields and thus we can't preprocess or validate them.
if (!bookingFields) return parsedResponses;
@@ -142,17 +125,12 @@ function preprocess<T extends z.ZodType>({
}
const views = field.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS"
? true
: views
? views.find((view) => view.id === currentView)
: true;
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
if (!isFieldApplicableToCurrentView) {
// If the field is not applicable in the current view, then we don't need to do any processing
return;
}
const fieldTypeSchema =
fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap];
const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap];
// TODO: Move all the schemas along with their respective types to fieldTypeSchema, that would make schemas shared across Routing Forms builder and Booking Question Formm builder
if (fieldTypeSchema) {
newResponses[field.name] = fieldTypeSchema.preprocess({
@@ -167,11 +145,7 @@ function preprocess<T extends z.ZodType>({
newResponses[field.name] = value === "true" || value === true;
}
// Make sure that the value is an array
else if (
field.type === "multiemail" ||
field.type === "checkbox" ||
field.type === "multiselect"
) {
else if (field.type === "multiemail" || field.type === "checkbox" || field.type === "multiselect") {
newResponses[field.name] = value instanceof Array ? value : [value];
}
// Parse JSON
@@ -183,17 +157,12 @@ function preprocess<T extends z.ZodType>({
try {
parsedValue = JSON.parse(value);
} catch (e) {
log.error(
`Failed to parse JSON for field ${field.name}`,
e
);
log.error(`Failed to parse JSON for field ${field.name}`, e);
}
const optionsInputs = field.optionsInputs;
const optionInputField = optionsInputs?.[parsedValue.value];
if (optionInputField && optionInputField.type === "phone") {
parsedValue.optionValue = ensureValidPhoneNumber(
parsedValue.optionValue
);
parsedValue.optionValue = ensureValidPhoneNumber(parsedValue.optionValue);
}
newResponses[field.name] = parsedValue;
} else if (field.type === "phone") {
@@ -214,9 +183,7 @@ function preprocess<T extends z.ZodType>({
return;
}
const attendeePhoneNumberField = bookingFields.find(
(field) => field.name === "attendeePhoneNumber"
);
const attendeePhoneNumberField = bookingFields.find((field) => field.name === "attendeePhoneNumber");
const isAttendeePhoneNumberFieldHidden = attendeePhoneNumberField?.hidden;
const emailField = bookingFields.find((field) => field.name === "email");
@@ -230,9 +197,7 @@ function preprocess<T extends z.ZodType>({
for (const bookingField of bookingFields) {
const value = responses[bookingField.name];
const stringSchema = z.string();
const emailSchema = isPartialSchema
? z.string()
: z.string().refine(emailSchemaRefinement);
const emailSchema = isPartialSchema ? z.string() : z.string().refine(emailSchemaRefinement);
const phoneSchema = isPartialSchema
? z.string()
: z.string().refine(async (val) => {
@@ -240,18 +205,12 @@ function preprocess<T extends z.ZodType>({
});
// Tag the message with the input name so that the message can be shown at appropriate place
const m = (message: string, options?: Record<string, unknown>) => {
const translatedMessage = translateFn
? translateFn(message, options)
: message;
const translatedMessage = translateFn ? translateFn(message, options) : message;
return `{${bookingField.name}}${translatedMessage}`;
};
const views = bookingField.views;
const isFieldApplicableToCurrentView =
currentView === "ALL_VIEWS"
? true
: views
? views.find((view) => view.id === currentView)
: true;
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
let hidden = bookingField.hidden;
const numOptions = bookingField.options?.length ?? 0;
if (bookingField.hideWhenJustOneOption) {
@@ -276,10 +235,7 @@ function preprocess<T extends z.ZodType>({
}
if (bookingField.type === "email") {
if (
!bookingField.hidden &&
(isRequired || (value && value.trim() !== ""))
) {
if (!bookingField.hidden && (isRequired || (value && value.trim() !== ""))) {
// Email RegExp to validate if the input is a valid email
if (!emailSchema.safeParse(value).success) {
ctx.addIssue({
@@ -292,9 +248,7 @@ function preprocess<T extends z.ZodType>({
if (value) {
const bookerEmail = value;
const excludedEmails =
bookingField.excludeEmails
?.split(",")
.map((domain) => domain.trim()) || [];
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
const match = excludedEmails.find((excludedEntry) =>
doesEmailMatchEntry(bookerEmail, excludedEntry)
@@ -325,10 +279,7 @@ function preprocess<T extends z.ZodType>({
continue;
}
const fieldTypeSchema =
fieldTypesSchemaMap[
bookingField.type as keyof typeof fieldTypesSchemaMap
];
const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap];
if (fieldTypeSchema) {
fieldTypeSchema.superRefine({
@@ -354,10 +305,7 @@ function preprocess<T extends z.ZodType>({
if (!emailsParsed.success) {
// If additional guests are shown but all inputs are empty then don't show any errors
if (
bookingField.name === "guests" &&
value.every((email: string) => email === "")
) {
if (bookingField.name === "guests" && value.every((email: string) => email === "")) {
// reset guests to empty array, otherwise it adds "" for every input
responses[bookingField.name] = [];
continue;
@@ -443,9 +391,7 @@ function preprocess<T extends z.ZodType>({
const typeOfOptionInput = optionField?.type;
if (
// Either the field is required or there is a radio selected, we need to check if the optionInput is required or not.
(isRequired || value?.value) && checkOptional
? true
: optionField?.required && !optionValue
(isRequired || value?.value) && checkOptional ? true : optionField?.required && !optionValue
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -471,11 +417,7 @@ function preprocess<T extends z.ZodType>({
// Use fieldTypeConfig.propsType to validate for propsType=="text" or propsType=="select" as in those cases, the response would be a string.
// If say we want to do special validation for 'address' that can be added to `fieldTypesSchemaMap`
if (
["address", "text", "select", "number", "radio", "textarea"].includes(
bookingField.type
)
) {
if (["address", "text", "select", "number", "radio", "textarea"].includes(bookingField.type)) {
const schema = stringSchema;
if (!schema.safeParse(value).success) {
ctx.addIssue({
@@ -496,10 +438,7 @@ function preprocess<T extends z.ZodType>({
if (isPartialSchema) {
// Query Params can be completely invalid, try to preprocess as much of it in correct format but in worst case simply don't prefill instead of crashing
return preprocessed.catch(function (res?: { error?: unknown[] }) {
console.error(
"Failed to preprocess query params, prefilling will be skipped",
res?.error
);
console.error("Failed to preprocess query params, prefilling will be skipped", res?.error);
return {};
});
}
@@ -40,10 +40,12 @@ export function getHostsAndGuests(booking: BookingInput): { hosts: Host[]; guest
const hostEmails = new Set(filteredHosts.map((host) => host.email));
const guests =
booking.attendees?.filter((attendee) => !hostEmails.has(attendee.email)).map((attendee) => ({
email: attendee.email,
name: attendee.name,
})) ?? [];
booking.attendees
?.filter((attendee) => !hostEmails.has(attendee.email))
.map((attendee) => ({
email: attendee.email,
name: attendee.name,
})) ?? [];
return {
hosts: filteredHosts,
+19 -17
View File
@@ -370,7 +370,7 @@ export class LuckyUserService implements ILuckyUserService {
private filterUsersBasedOnWeights<
T extends PartialUser & {
weight?: number | null;
}
},
>({
availableUsers,
bookingsOfAvailableUsersOfInterval,
@@ -493,7 +493,7 @@ export class LuckyUserService implements ILuckyUserService {
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
},
>(
allRRHosts: GetLuckyUserParams<T>["allRRHosts"],
attributesQueryValueChild: Record<
@@ -693,15 +693,18 @@ export class LuckyUserService implements ILuckyUserService {
)
);
return usersBusyTimesQuery.reduce((usersBusyTime, userBusyTimeQuery, index) => {
if (userBusyTimeQuery.success) {
usersBusyTime.push({
userId: usersWithCredentials[index].id,
busyTimes: userBusyTimeQuery.data,
});
}
return usersBusyTime;
}, [] as { userId: number; busyTimes: Awaited<ReturnType<typeof getBusyCalendarTimes>>["data"] }[]);
return usersBusyTimesQuery.reduce(
(usersBusyTime, userBusyTimeQuery, index) => {
if (userBusyTimeQuery.success) {
usersBusyTime.push({
userId: usersWithCredentials[index].id,
busyTimes: userBusyTimeQuery.data,
});
}
return usersBusyTime;
},
[] as { userId: number; busyTimes: Awaited<ReturnType<typeof getBusyCalendarTimes>>["data"] }[]
);
}
private async getBookingsOfInterval({
@@ -736,7 +739,7 @@ export class LuckyUserService implements ILuckyUserService {
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
},
>(getLuckyUserParams: GetLuckyUserParams<T>): Promise<FetchedData> {
const startTime = performance.now();
@@ -763,9 +766,8 @@ export class LuckyUserService implements ILuckyUserService {
);
})();
const { attributeWeights, virtualQueuesData } = await this.prepareQueuesAndAttributesData(
getLuckyUserParams
);
const { attributeWeights, virtualQueuesData } =
await this.prepareQueuesAndAttributesData(getLuckyUserParams);
const interval =
eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrResetInterval
@@ -917,7 +919,7 @@ export class LuckyUserService implements ILuckyUserService {
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
},
>(getLuckyUserParams: GetLuckyUserParams<T>) {
// Early return if only one available user to avoid unnecessary data fetching
if (getLuckyUserParams.availableUsers.length === 1) {
@@ -938,7 +940,7 @@ export class LuckyUserService implements ILuckyUserService {
T extends PartialUser & {
priority?: number | null;
weight?: number | null;
}
},
>({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams<T> & FetchedData) {
const {
eventType,
@@ -143,7 +143,14 @@ async function saveBooking(
const createBookingObj = {
include: {
user: {
select: { uuid: true, email: true, name: true, timeZone: true, username: true, isPlatformManaged: true },
select: {
uuid: true,
email: true,
name: true,
timeZone: true,
username: true,
isPlatformManaged: true,
},
},
attendees: true,
payment: true,
@@ -258,8 +265,8 @@ function buildNewBookingData(params: CreateBookingParams) {
destinationCalendar:
evt.destinationCalendar && evt.destinationCalendar.length > 0
? {
connect: { id: evt.destinationCalendar[0].id },
}
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,
routedFromRoutingFormReponse: routingFormResponseId
@@ -2,14 +2,26 @@ import { CreationSource } from "@calcom/prisma/enums";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import { criticalLogger } from "@calcom/lib/logger.server";
export const getAuditActionSource = ({ creationSource, eventTypeId, rescheduleUid }: { creationSource: CreationSource | null | undefined, eventTypeId: number, rescheduleUid: string | null }): ActionSource => {
if (creationSource === CreationSource.API_V1 || creationSource === CreationSource.API_V2 || creationSource === CreationSource.WEBAPP) {
return creationSource;
}
// Unknown creationSource - log for tracking and fix
criticalLogger.warn("Unknown booking creationSource detected", {
eventTypeId,
rescheduleUid,
});
return "UNKNOWN";
};
export const getAuditActionSource = ({
creationSource,
eventTypeId,
rescheduleUid,
}: {
creationSource: CreationSource | null | undefined;
eventTypeId: number;
rescheduleUid: string | null;
}): ActionSource => {
if (
creationSource === CreationSource.API_V1 ||
creationSource === CreationSource.API_V2 ||
creationSource === CreationSource.WEBAPP
) {
return creationSource;
}
// Unknown creationSource - log for tracking and fix
criticalLogger.warn("Unknown booking creationSource detected", {
eventTypeId,
rescheduleUid,
});
return "UNKNOWN";
};
@@ -16,10 +16,13 @@ type RequestBody = {
function mapCustomInputs(
customInputs: { label: string; value: CustomInputs[number]["value"] }[]
): Record<string, CustomInputs[number]["value"]> {
return customInputs.reduce((acc, { label, value }) => {
acc[label] = value;
return acc;
}, {} as Record<string, CustomInputs[number]["value"]>);
return customInputs.reduce(
(acc, { label, value }) => {
acc[label] = value;
return acc;
},
{} as Record<string, CustomInputs[number]["value"]>
);
}
function mapResponsesToCustomInputs(
@@ -27,13 +30,16 @@ function mapResponsesToCustomInputs(
eventTypeCustomInputs: getEventTypeResponse["customInputs"]
): NonNullable<CalendarEvent["customInputs"]> {
// Backward Compatibility: Map new `responses` to old `customInputs` format so that webhooks can still receive same values.
return Object.entries(responses).reduce((acc, [fieldName, fieldValue]) => {
const foundInput = eventTypeCustomInputs.find((input) => slugify(input.label) === fieldName);
if (foundInput) {
acc[foundInput.label] = fieldValue;
}
return acc;
}, {} as NonNullable<CalendarEvent["customInputs"]>);
return Object.entries(responses).reduce(
(acc, [fieldName, fieldValue]) => {
const foundInput = eventTypeCustomInputs.find((input) => slugify(input.label) === fieldName);
if (foundInput) {
acc[foundInput.label] = fieldValue;
}
return acc;
},
{} as NonNullable<CalendarEvent["customInputs"]>
);
}
export function getCustomInputsResponses(
@@ -20,7 +20,7 @@ export const _getLocationValuesForDb = <
username: string | null;
metadata: Prisma.JsonValue;
credentials: CredentialForCalendarService[];
}
},
>({
dynamicUserList,
users,
@@ -46,16 +46,19 @@ function calculateAggregatedAppsStatus(
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
// proceeding to prepare the info for the event
const aggregatedStatus = reqAppsStatus.concat(resultStatus).reduce((acc, curr) => {
if (acc[curr.type]) {
acc[curr.type].success += curr.success;
acc[curr.type].errors = acc[curr.type].errors.concat(curr.errors);
acc[curr.type].warnings = acc[curr.type].warnings?.concat(curr.warnings || []);
} else {
acc[curr.type] = curr;
}
return acc;
}, {} as { [key: string]: AppsStatus });
const aggregatedStatus = reqAppsStatus.concat(resultStatus).reduce(
(acc, curr) => {
if (acc[curr.type]) {
acc[curr.type].success += curr.success;
acc[curr.type].errors = acc[curr.type].errors.concat(curr.errors);
acc[curr.type].warnings = acc[curr.type].warnings?.concat(curr.warnings || []);
} else {
acc[curr.type] = curr;
}
return acc;
},
{} as { [key: string]: AppsStatus }
);
return Object.values(aggregatedStatus);
}
@@ -25,7 +25,7 @@ vi.mock("@calcom/features/CalendarEventBuilder", () => {
fromEvent: vi.fn().mockImplementation((_evt) => ({
withDestinationCalendar: withDestinationCalendarSpy,
withTeam: withTeamSpy,
build: vi.fn().mockImplementation(function() {
build: vi.fn().mockImplementation(function () {
return {
destinationCalendar: [],
team: {}, // <- you won't use this result anyway
@@ -88,7 +88,10 @@ async function mockPaymentSuccessWebhookFromStripe({ externalId }: { externalId:
let webhookResponse = null;
try {
const traceContext = distributedTracing.createTrace("test_stripe_webhook");
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }), traceContext);
await handleStripePaymentSuccess(
getMockedStripePaymentEvent({ paymentIntentId: externalId }),
traceContext
);
} catch (e) {
log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
webhookResponse = e as HttpError;
@@ -1392,7 +1395,11 @@ describe("handleNewBooking", () => {
},
],
organizer,
apps: [TestData.apps["google-meet"], TestData.apps["daily-video"], TestData.apps["office365-calendar"]],
apps: [
TestData.apps["google-meet"],
TestData.apps["daily-video"],
TestData.apps["office365-calendar"],
],
});
mockSuccessfulVideoMeetingCreation({
@@ -1481,7 +1488,11 @@ describe("handleNewBooking", () => {
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["google-meet"], TestData.apps["daily-video"]],
apps: [
TestData.apps["google-calendar"],
TestData.apps["google-meet"],
TestData.apps["daily-video"],
],
});
mockSuccessfulVideoMeetingCreation({
@@ -1089,154 +1089,151 @@ describe("Round Robin handleNewBooking", () => {
});
describe("Round Robin with requiresConfirmation", () => {
test(
"should not create calendar events for unconfirmed round robin bookings on first booking and reschedule",
async () => {
const handleNewBooking = getNewBookingHandler();
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
test("should not create calendar events for unconfirmed round robin bookings on first booking and reschedule", async () => {
const handleNewBooking = getNewBookingHandler();
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const roundRobinHost1 = getOrganizer({
name: "RR Host 1",
email: "rrhost1@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "rrhost1@google-calendar.com",
},
});
const roundRobinHost1 = getOrganizer({
name: "RR Host 1",
email: "rrhost1@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "rrhost1@google-calendar.com",
},
});
const roundRobinHost2 = getOrganizer({
name: "RR Host 2",
email: "rrhost2@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "rrhost2@google-calendar.com",
},
});
const roundRobinHost2 = getOrganizer({
name: "RR Host 2",
email: "rrhost2@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: TestData.apps["google-calendar"].type,
externalId: "rrhost2@google-calendar.com",
},
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
requiresConfirmation: true,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [
{
id: 101,
},
{
id: 102,
},
],
hosts: [
{ userId: 101, isFixed: false },
{ userId: 102, isFixed: false },
],
schedule: TestData.schedules.IstWorkHours,
},
],
organizer: roundRobinHost1,
usersApartFromOrganizer: [roundRobinHost2],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
// Mock calendar - we should NOT see calendar events created for unconfirmed bookings
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
// First booking with first host - should be PENDING and NO calendar events
const firstBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
user: roundRobinHost1.name,
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
requiresConfirmation: true,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [
{
id: 101,
},
{
id: 102,
},
],
hosts: [
{ userId: 101, isFixed: false },
{ userId: 102, isFixed: false },
],
schedule: TestData.schedules.IstWorkHours,
},
],
organizer: roundRobinHost1,
usersApartFromOrganizer: [roundRobinHost2],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
// Mock calendar - we should NOT see calendar events created for unconfirmed bookings
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
// First booking with first host - should be PENDING and NO calendar events
const firstBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
user: roundRobinHost1.name,
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
});
},
});
const firstBooking = await handleNewBooking({
bookingData: firstBookingData,
});
const firstBooking = await handleNewBooking({
bookingData: firstBookingData,
});
// Verify first booking is PENDING
expect(firstBooking.status).toBe(BookingStatus.PENDING);
// Verify first booking is PENDING
expect(firstBooking.status).toBe(BookingStatus.PENDING);
// Verify first booking has NO calendar references (no calendar events created)
const firstBookingInDb = await prisma.booking.findUnique({
where: {
id: firstBooking.id,
// Verify first booking has NO calendar references (no calendar events created)
const firstBookingInDb = await prisma.booking.findUnique({
where: {
id: firstBooking.id,
},
include: {
references: true,
},
});
expect(firstBookingInDb?.references).toHaveLength(0);
expect(firstBookingInDb?.status).toBe(BookingStatus.PENDING);
// Now reschedule with second host - should still be PENDING and NO calendar events
const rescheduleBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
user: roundRobinHost2.name,
rescheduleUid: firstBooking.uid,
start: `${plus1DateString}T06:00:00.000Z`,
end: `${plus1DateString}T06:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
include: {
references: true,
},
});
rescheduledBy: booker.email,
},
});
expect(firstBookingInDb?.references).toHaveLength(0);
expect(firstBookingInDb?.status).toBe(BookingStatus.PENDING);
const rescheduledBooking = await handleNewBooking({
bookingData: rescheduleBookingData,
});
// Now reschedule with second host - should still be PENDING and NO calendar events
const rescheduleBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
user: roundRobinHost2.name,
rescheduleUid: firstBooking.uid,
start: `${plus1DateString}T06:00:00.000Z`,
end: `${plus1DateString}T06:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
rescheduledBy: booker.email,
},
});
// Verify rescheduled booking is still PENDING
expect(rescheduledBooking.status).toBe(BookingStatus.PENDING);
const rescheduledBooking = await handleNewBooking({
bookingData: rescheduleBookingData,
});
// Verify rescheduled booking has NO calendar references (no calendar events created)
// This is the key fix: rescheduling unconfirmed bookings should NOT create calendar events
const rescheduledBookingInDb = await prisma.booking.findUnique({
where: {
id: rescheduledBooking.id,
},
include: {
references: true,
},
});
// Verify rescheduled booking is still PENDING
expect(rescheduledBooking.status).toBe(BookingStatus.PENDING);
// Verify rescheduled booking has NO calendar references (no calendar events created)
// This is the key fix: rescheduling unconfirmed bookings should NOT create calendar events
const rescheduledBookingInDb = await prisma.booking.findUnique({
where: {
id: rescheduledBooking.id,
},
include: {
references: true,
},
});
expect(rescheduledBookingInDb?.references).toHaveLength(0);
expect(rescheduledBookingInDb?.status).toBe(BookingStatus.PENDING);
}
);
expect(rescheduledBookingInDb?.references).toHaveLength(0);
expect(rescheduledBookingInDb?.status).toBe(BookingStatus.PENDING);
});
});
});
@@ -39,7 +39,7 @@ const _validateBookingTimeIsNotOutOfBounds = async <T extends ValidateBookingTim
periodStartDate: eventType.periodStartDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
bookerUtcOffset: getUTCOffsetByTimezone(reqBodyTimeZone) ?? 0,
eventUtcOffset: eventTimeZone ? getUTCOffsetByTimezone(eventTimeZone) ?? 0 : 0,
eventUtcOffset: eventTimeZone ? (getUTCOffsetByTimezone(eventTimeZone) ?? 0) : 0,
},
eventType.minimumBookingNotice
);
@@ -8,7 +8,9 @@ import type { AppCategories, Prisma, EventType } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
const isPaymentService = (x: unknown): x is { BuildPaymentService: (credentials: { key: unknown }) => unknown } =>
const isPaymentService = (
x: unknown
): x is { BuildPaymentService: (credentials: { key: unknown }) => unknown } =>
!!x && typeof x === "object" && "BuildPaymentService" in x && typeof x.BuildPaymentService === "function";
const handlePayment = async ({
@@ -141,8 +143,8 @@ const handlePayment = async ({
const selectedValues = Array.isArray(responseValue)
? responseValue
: responseValue
? [responseValue]
: [];
? [responseValue]
: [];
selectedValues.forEach((value) => {
const option = typedInput.options?.find((opt) => opt.value === value);
@@ -18,11 +18,7 @@ import { BookingStatus } from "@calcom/prisma/enums";
import { findBookingQuery } from "../../handleNewBooking/findBookingQuery";
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking/types";
import type {
SeatedBooking,
NewSeatedBookingObject,
HandleSeatsResultBooking,
} from "../types";
import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types";
export type AddSeatInput = {
bookingUid: string;
@@ -48,10 +44,7 @@ export type AddSeatInput = {
* Uses a transaction with a fresh read to prevent TOCTOU race conditions
* where concurrent requests could exceed the seat limit.
*/
export async function addSeatToBooking(
input: AddSeatInput,
prismaClient: PrismaClient = prisma
) {
export async function addSeatToBooking(input: AddSeatInput, prismaClient: PrismaClient = prisma) {
const referenceUid = uuid();
return prismaClient.$transaction(async (tx) => {
@@ -80,9 +73,7 @@ export async function addSeatToBooking(
// Check seat availability with fresh data
// Only enforce the limit when seatsPerTimeSlot > 0 (matching original behavior
// where falsy seatsPerTimeSlot would skip this check entirely)
const currentSeatCount = freshBooking.attendees.filter(
(attendee) => !!attendee.bookingSeat
).length;
const currentSeatCount = freshBooking.attendees.filter((attendee) => !!attendee.bookingSeat).length;
if (input.seatsPerTimeSlot > 0 && input.seatsPerTimeSlot <= currentSeatCount) {
throw new HttpError({
statusCode: 409,
@@ -159,9 +150,7 @@ const createNewSeat = async (
};
});
const videoCallReference = seatedBooking.references.find((reference) =>
reference.type.includes("_video")
);
const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
@@ -218,20 +207,16 @@ const createNewSeat = async (
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isHostConfirmationEmailsDisabled = eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee ||
false;
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled =
allowDisablingHostConfirmationEmails(workflows);
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled =
allowDisablingAttendeeConfirmationEmails(workflows);
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
await sendScheduledSeatsEmailsAndSMS(
copyEvent,
@@ -244,27 +229,16 @@ const createNewSeat = async (
);
}
const credentials = await refreshCredentials(allCredentials);
const apps = eventTypeAppMetadataOptionalSchema.parse(
eventType?.metadata?.apps
);
const eventManager = new EventManager(
{ ...organizerUser, credentials },
apps
);
const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps);
const eventManager = new EventManager({ ...organizerUser, credentials }, apps);
await eventManager.updateCalendarAttendees(evt, seatedBooking);
const foundBooking = await findBookingQuery(seatedBooking.id);
if (
!Number.isNaN(paymentAppData.price) &&
paymentAppData.price > 0 &&
!!seatedBooking
) {
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) {
const credentialPaymentAppCategories = await prisma.credential.findMany({
where: {
...(paymentAppData.credentialId
? { id: paymentAppData.credentialId }
: { userId: organizerUser.id }),
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
app: {
categories: {
hasSome: ["payment"],
@@ -283,11 +257,9 @@ const createNewSeat = async (
},
});
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find(
(credential) => {
return credential.appId === paymentAppData.appId;
}
);
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
return credential.appId === paymentAppData.appId;
});
if (!eventTypePaymentAppCredential) {
throw new HttpError({
@@ -313,8 +285,7 @@ const createNewSeat = async (
}
: {},
},
paymentAppCredentials:
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking: seatedBooking,
bookerName: fullName,
bookerEmail,
@@ -1,4 +1,3 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-store/delegationCredential";
@@ -1,4 +1,3 @@
import { cloneDeep } from "lodash";
import { sendRescheduledSeatEmailAndSMS } from "@calcom/emails/email-manager";
@@ -107,7 +106,11 @@ const attendeeRescheduleSeatedBooking = async (
? addVideoCallDataToEvent(newTimeSlotBooking.references, copyEvent)
: copyEvent;
await sendRescheduledSeatEmailAndSMS(copyEventWithVideoCallData, seatAttendee as Person, eventType.metadata);
await sendRescheduledSeatEmailAndSMS(
copyEventWithVideoCallData,
seatAttendee as Person,
eventType.metadata
);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
@@ -1,4 +1,3 @@
import { cloneDeep } from "lodash";
import { uuid } from "short-uuid";
@@ -1,4 +1,3 @@
import { cloneDeep } from "lodash";
import { sendRescheduledEmailsAndSMS } from "@calcom/emails/email-manager";
@@ -13,7 +12,17 @@ import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus";
import type { createLoggerWithEventDetails } from "../../../handleNewBooking/logger";
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
async function updateBooking({ bookingId, startTime, endTime, cancellationReason }: { bookingId: number, startTime: string, endTime: string, cancellationReason: string }): Promise<(Booking & { appsStatus?: AppsStatus[] })> {
async function updateBooking({
bookingId,
startTime,
endTime,
cancellationReason,
}: {
bookingId: number;
startTime: string;
endTime: string;
cancellationReason: string;
}): Promise<Booking & { appsStatus?: AppsStatus[] }> {
const booking = await prisma.booking.update({
where: {
id: bookingId,
@@ -51,7 +60,12 @@ const moveSeatedBookingToNewTimeSlot = async (
} = rescheduleSeatedBookingObject;
let { evt } = rescheduleSeatedBookingObject;
const newBooking = await updateBooking({ bookingId: seatedBooking.id, startTime: evt.startTime, endTime: evt.endTime, cancellationReason: rescheduleReason });
const newBooking = await updateBooking({
bookingId: seatedBooking.id,
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
});
evt = { ...addVideoCallDataToEvent(newBooking.references, evt), bookerUrl: evt.bookerUrl };
@@ -12,7 +12,7 @@ export class FilterHostsService {
T extends {
isFixed: false; // ensure no fixed hosts are passed.
user: { id: number; email: string };
}
},
>({
hosts,
rescheduleUid,
@@ -61,9 +61,9 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
rrHostSubsetEnabled,
schedulingType,
}: { rrHostSubsetEnabled: boolean; schedulingType?: SchedulingType } = {
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
rrHostSubsetEnabled: false,
schedulingType: undefined,
}
): host is T & { isFixed: false } => {
if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) {
return true;
@@ -72,7 +72,7 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
};
export class QualifiedHostsService {
constructor(public readonly dependencies: IQualifiedHostsService) { }
constructor(public readonly dependencies: IQualifiedHostsService) {}
async _findQualifiedHostsWithDelegationCredentials<
T extends {
@@ -81,7 +81,7 @@ export class QualifiedHostsService {
uuid: string;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
} & Record<string, unknown>,
>({
eventType,
rescheduleUid,
@@ -147,8 +147,16 @@ export class BookingEventHandlerService {
}
async onBookingAccepted(params: OnBookingAcceptedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueAcceptedAudit({
bookingUid,
actor,
@@ -162,8 +170,16 @@ export class BookingEventHandlerService {
}
async onBookingCancelled(params: OnBookingCancelledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueCancelledAudit({
bookingUid,
actor,
@@ -177,8 +193,16 @@ export class BookingEventHandlerService {
}
async onRescheduleRequested(params: OnRescheduleRequestedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueRescheduleRequestedAudit({
bookingUid,
actor,
@@ -192,8 +216,16 @@ export class BookingEventHandlerService {
}
async onAttendeeAdded(params: OnAttendeeAddedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueAttendeeAddedAudit({
bookingUid,
actor,
@@ -207,8 +239,16 @@ export class BookingEventHandlerService {
}
async onNoShowUpdated(params: OnNoShowUpdatedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueNoShowUpdatedAudit({
bookingUid,
actor,
@@ -222,8 +262,16 @@ export class BookingEventHandlerService {
}
async onBookingRejected(params: OnBookingRejectedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueRejectedAudit({
bookingUid,
actor,
@@ -237,8 +285,16 @@ export class BookingEventHandlerService {
}
async onAttendeeRemoved(params: OnAttendeeRemovedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueAttendeeRemovedAudit({
bookingUid,
actor,
@@ -252,8 +308,16 @@ export class BookingEventHandlerService {
}
async onReassignment(params: OnReassignmentParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueReassignmentAudit({
bookingUid,
actor,
@@ -268,8 +332,16 @@ export class BookingEventHandlerService {
async onLocationChanged(params: OnLocationChangedParams) {
try {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueLocationChangedAudit({
bookingUid,
actor,
@@ -286,8 +358,16 @@ export class BookingEventHandlerService {
}
async onSeatBooked(params: OnSeatBookedParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueSeatBookedAudit({
bookingUid,
actor,
@@ -301,8 +381,16 @@ export class BookingEventHandlerService {
}
async onSeatRescheduled(params: OnSeatRescheduledParams) {
const { bookingUid, actor, organizationId, auditData, source, operationId, context, isBookingAuditEnabled } =
params;
const {
bookingUid,
actor,
organizationId,
auditData,
source,
operationId,
context,
isBookingAuditEnabled,
} = params;
await this.bookingAuditProducerService.queueSeatRescheduledAudit({
bookingUid,
actor,
@@ -52,9 +52,11 @@ vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => (
}));
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
TeamRepository: vi.fn().mockImplementation(function() { return {
findParentOrganizationByTeamId: vi.fn(),
}; }),
TeamRepository: vi.fn().mockImplementation(function () {
return {
findParentOrganizationByTeamId: vi.fn(),
};
}),
}));
vi.mock("@calcom/prisma", () => ({
@@ -71,7 +73,9 @@ describe("handleNoShowFee", () => {
};
const paymentServiceModule = await PaymentServiceMap.stripepayment;
vi.mocked(paymentServiceModule.BuildPaymentService).mockImplementation(function() { return mockPaymentService; });
vi.mocked(paymentServiceModule.BuildPaymentService).mockImplementation(function () {
return mockPaymentService;
});
});
const mockBooking = {
@@ -220,7 +224,9 @@ describe("handleNoShowFee", () => {
const mockTeamRepository = {
findParentOrganizationByTeamId: vi.fn().mockResolvedValue({ id: 2 }),
};
vi.mocked(TeamRepository).mockImplementation(function() { return mockTeamRepository; });
vi.mocked(TeamRepository).mockImplementation(function () {
return mockTeamRepository;
});
const result = await handleNoShowFee({
booking: teamBooking,

Some files were not shown because too many files have changed in this diff Show More