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:
co-authored by
Claude Opus 4.6
parent
bc8ebb9213
commit
98b6d63164
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+11
-8
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+4
-4
@@ -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 () => {
|
||||
|
||||
+14
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+424
-425
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+146
-54
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -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]);
|
||||
|
||||
-1
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+5
-3
@@ -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(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+132
-135
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+5
-2
@@ -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
@@ -1,4 +1,3 @@
|
||||
|
||||
import { cloneDeep } from "lodash";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
|
||||
+17
-3
@@ -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,
|
||||
|
||||
+5
-5
@@ -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
Reference in New Issue
Block a user