fix: deep link reschedule audit log to booking drawer history tab (#27709)
* fix: deep link reschedule audit log to booking drawer history tab Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: make booking drawer tab-agnostic for cross-tab deep links Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use status-agnostic /bookings URL for audit log deep links - Update audit service URLs from /bookings/upcoming?uid=... to /bookings?uid=... - Add /bookings/page.tsx redirect that routes to /bookings/upcoming preserving query params - Update tests to expect new URL format Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fixes * refactor: use client-side replaceState instead of server redirect for booking deep links Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use router.replace instead of replaceState to update tab and booking list Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * improvements * refactor: extract deep link logic from BookingListContainer into usePreSelectedBooking hook Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use proper BookingOutput status type in test helper Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: decouple getTabForBooking from BookingOutput type for simpler testing Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: accept Date | string for endTime in BookingForTabResolution interface Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: eliminate initialBookingUid prop drilling and revert formatting-only changes Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use AuditDeepLink wrapper to preserve target=_blank through ServerTrans cloneElement Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Improve code organization * refactor: rename usePreSelectedBooking to useSwitchToCorrectStatusTab Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: push preSelectedBooking into store so drawer opens on direct navigation Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fixes * fixes * fixes * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Udit Takkar
parent
3a7122d613
commit
2bc17313fd
@@ -139,7 +139,13 @@ function ActionTitle({ actionDisplayTitle }: { actionDisplayTitle: TranslationWi
|
||||
values={actionDisplayTitle.params}
|
||||
components={actionDisplayTitle.components.map((comp) =>
|
||||
comp.type === "link" ? (
|
||||
<Link key={comp.href} href={comp.href} className="text-emphasis underline hover:no-underline" />
|
||||
<Link
|
||||
key={comp.href}
|
||||
href={comp.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-emphasis underline hover:no-underline"
|
||||
/>
|
||||
) : (
|
||||
<span key={comp.href} />
|
||||
)
|
||||
@@ -304,15 +310,15 @@ function BookingLogsTimeline({ logs }: BookingLogsTimelineProps) {
|
||||
{/* Render displayFields if available, otherwise show type */}
|
||||
{log.displayFields && log.displayFields.length > 0
|
||||
? log.displayFields.map((field, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValue field={field} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t(field.labelKey)}</span>
|
||||
<span className="font-medium">
|
||||
<DisplayFieldValue field={field} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div className="flex items-start gap-2 py-2 border-b px-3 border-subtle">
|
||||
<span className="font-medium text-emphasis w-[140px]">{t("actor")}</span>
|
||||
|
||||
@@ -13,13 +13,14 @@ import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
|
||||
import { useSwitchToCorrectStatusTab } from "~/bookings/hooks/useSwitchToCorrectStatusTab";
|
||||
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
@@ -231,17 +232,21 @@ function BookingListInner({
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset, setPageIndex, isValidatorPending } = useDataTable();
|
||||
const { limit, offset, isValidatorPending } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
const { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking } = useSwitchToCorrectStatusTab({
|
||||
defaultStatus: props.status,
|
||||
});
|
||||
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
statuses: [props.status],
|
||||
statuses: [resolvedTabStatus],
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
@@ -257,7 +262,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
[
|
||||
limit,
|
||||
offset,
|
||||
props.status,
|
||||
resolvedTabStatus,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
@@ -271,10 +276,19 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
enabled: !isValidatorPending, // Wait for validator to be ready before fetching
|
||||
// We wait for tab status to be resolved before fetching, so that we can fetch the correct bookings as per resolved tab status
|
||||
enabled: !isValidatorPending && !isResolvingTabStatus, // Wait for validator to be ready before fetching
|
||||
});
|
||||
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
const bookings = useMemo(() => {
|
||||
const queryBookings = query.data?.bookings ?? [];
|
||||
if (!preSelectedBooking) return queryBookings;
|
||||
if (queryBookings.some((b) => b.uid === preSelectedBooking.uid)) return queryBookings;
|
||||
// It ensures that the drawer opens for a booking that isn't even in the bookings list
|
||||
// Note that, bookings list doesn't use this so, it won't be visible in the list view but drawer will open for it
|
||||
// We don't want to show this booking in the list view, as it might not match the filters/pagination applied
|
||||
return [...queryBookings, preSelectedBooking];
|
||||
}, [query.data?.bookings, preSelectedBooking]);
|
||||
|
||||
// Always call the hook and provide navigation capabilities
|
||||
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
|
||||
@@ -290,6 +304,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
status={resolvedTabStatus}
|
||||
data={query.data}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getTabForBooking } from "./useSwitchToCorrectStatusTab";
|
||||
|
||||
function makeBooking(overrides: {
|
||||
status: string;
|
||||
endTime: Date;
|
||||
recurringEventId?: string | null;
|
||||
}){
|
||||
return {
|
||||
status: overrides.status,
|
||||
endTime: overrides.endTime,
|
||||
recurringEventId: overrides.recurringEventId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const future = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("getTabForBooking", () => {
|
||||
it("returns 'cancelled' for CANCELLED bookings", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: future }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'cancelled' for REJECTED bookings", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "REJECTED", endTime: future }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'cancelled' for CANCELLED bookings even if past", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "CANCELLED", endTime: past }))).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 'unconfirmed' for PENDING bookings in the future", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: future }))).toBe("unconfirmed");
|
||||
});
|
||||
|
||||
it("returns 'past' for PENDING bookings that have ended", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "PENDING", endTime: past }))).toBe("past");
|
||||
});
|
||||
|
||||
it("returns 'past' for ACCEPTED bookings that have ended", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past }))).toBe("past");
|
||||
});
|
||||
|
||||
it("returns 'recurring' for future ACCEPTED bookings with recurringEventId", () => {
|
||||
expect(
|
||||
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future, recurringEventId: "rec-123" }))
|
||||
).toBe("recurring");
|
||||
});
|
||||
|
||||
it("returns 'upcoming' for future ACCEPTED bookings without recurringEventId", () => {
|
||||
expect(getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: future }))).toBe("upcoming");
|
||||
});
|
||||
|
||||
it("returns 'past' for recurring bookings that have ended", () => {
|
||||
expect(
|
||||
getTabForBooking(makeBooking({ status: "ACCEPTED", endTime: past, recurringEventId: "rec-123" }))
|
||||
).toBe("past");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { validStatuses } from "../lib/validStatuses";
|
||||
import type { BookingListingStatus, BookingOutput } from "../types";
|
||||
|
||||
interface BookingForTabResolution {
|
||||
status: string;
|
||||
endTime: Date | string;
|
||||
recurringEventId: string | null;
|
||||
}
|
||||
|
||||
interface UseSwitchToCorrectTabResult {
|
||||
resolvedTabStatus: BookingListingStatus;
|
||||
isResolvingTabStatus: boolean;
|
||||
preSelectedBooking: BookingOutput | null;
|
||||
}
|
||||
|
||||
export function getTabForBooking(booking: BookingForTabResolution): BookingListingStatus {
|
||||
const isPast = new Date(booking.endTime) <= new Date();
|
||||
|
||||
if (booking.status === "CANCELLED" || booking.status === "REJECTED") {
|
||||
return "cancelled";
|
||||
}
|
||||
if (booking.status === "PENDING" && !isPast) {
|
||||
return "unconfirmed";
|
||||
}
|
||||
if (isPast) {
|
||||
return "past";
|
||||
}
|
||||
if (booking.recurringEventId) {
|
||||
return "recurring";
|
||||
}
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the correct status tab is selected based on the pre-selected booking through query params
|
||||
*/
|
||||
export function useSwitchToCorrectStatusTab({
|
||||
defaultStatus,
|
||||
}: {
|
||||
defaultStatus: BookingListingStatus;
|
||||
}): UseSwitchToCorrectTabResult {
|
||||
const {
|
||||
preSelectedBookingUid,
|
||||
preSelectedBooking,
|
||||
preSelectedBookingFull,
|
||||
isPending: isFetchingPreSelectedBooking,
|
||||
} = usePreSelectedBooking();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [resolvedTabStatus, setResolvedTab] = useState<BookingListingStatus>(defaultStatus);
|
||||
const [isNavigatingToCorrectTab, startNavigationToCorrectTab] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!preSelectedBooking) return;
|
||||
const correctTab = getTabForBooking(preSelectedBooking);
|
||||
const currentTab = pathname?.match(/\/bookings\/(\w+)/)?.[1];
|
||||
const shouldNavigate = correctTab && currentTab && correctTab !== currentTab;
|
||||
if (!shouldNavigate) return;
|
||||
startNavigationToCorrectTab(() => {
|
||||
const newPath = pathname.replace(`/bookings/${currentTab}`, `/bookings/${correctTab}`);
|
||||
router.replace(`${newPath}${window.location.search}`);
|
||||
});
|
||||
setResolvedTab(correctTab);
|
||||
}, [preSelectedBooking, pathname, router]);
|
||||
|
||||
const isResolvingTabStatus = preSelectedBookingUid
|
||||
? isFetchingPreSelectedBooking || isNavigatingToCorrectTab
|
||||
: false;
|
||||
|
||||
return { resolvedTabStatus, isResolvingTabStatus, preSelectedBooking: preSelectedBookingFull };
|
||||
}
|
||||
|
||||
export function usePreSelectedBooking(): {
|
||||
preSelectedBookingUid: string | undefined;
|
||||
preSelectedBooking: BookingForTabResolution | null;
|
||||
preSelectedBookingFull: BookingOutput | null;
|
||||
isPending: boolean;
|
||||
}{
|
||||
const searchParams = useSearchParams();
|
||||
const preSelectedBookingUid = searchParams?.get("uid") ?? undefined;
|
||||
|
||||
const { data: preSelectedBookingData, isPending } = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
filters: {
|
||||
bookingUid: preSelectedBookingUid,
|
||||
statuses: [...validStatuses],
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!preSelectedBookingUid,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
const preSelectedBookingFull = preSelectedBookingData?.bookings?.[0] ?? null;
|
||||
const preSelectedBooking: BookingForTabResolution | null = preSelectedBookingFull;
|
||||
return { preSelectedBookingUid, preSelectedBooking, preSelectedBookingFull, isPending };
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas";
|
||||
import type { DataRequirements } from "../service/EnrichmentDataStore";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type {
|
||||
BaseStoredAuditData,
|
||||
GetDisplayJsonParams,
|
||||
GetDisplayTitleParams,
|
||||
IAuditActionService,
|
||||
TranslationWithParams,
|
||||
GetDisplayTitleParams,
|
||||
GetDisplayJsonParams,
|
||||
BaseStoredAuditData,
|
||||
} from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
@@ -91,7 +90,7 @@ export class RescheduledAuditActionService implements IAuditActionService {
|
||||
newDate,
|
||||
},
|
||||
components: rescheduledToUid
|
||||
? [{ type: "link", href: `/booking/${rescheduledToUid}/logs` }]
|
||||
? [{ type: "link", href: `/bookings?uid=${rescheduledToUid}&activeSegment=history` }]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -122,7 +121,7 @@ export class RescheduledAuditActionService implements IAuditActionService {
|
||||
oldDate,
|
||||
newDate,
|
||||
},
|
||||
components: [{ type: "link", href: `/booking/${fromRescheduleUid}/logs` }],
|
||||
components: [{ type: "link", href: `/bookings?uid=${fromRescheduleUid}&activeSegment=history` }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ export class SeatRescheduledAuditActionService implements IAuditActionService {
|
||||
oldDate,
|
||||
newDate,
|
||||
},
|
||||
components: rescheduledToBookingUid
|
||||
? [{ type: "link", href: `/booking/${rescheduledToBookingUid}/logs` }]
|
||||
: undefined,
|
||||
components: rescheduledToBookingUid
|
||||
? [{ type: "link", href: `/bookings?uid=${rescheduledToBookingUid}&activeSegment=history` }]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+87
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { verifyDataRequirementsContract } from "./contractVerification";
|
||||
import { verifyDataRequirementsContract, createTrackingDbStore, createEmptyAccessedData } from "./contractVerification";
|
||||
import { RescheduledAuditActionService } from "../RescheduledAuditActionService";
|
||||
|
||||
describe("RescheduledAuditActionService - getDataRequirements contract", () => {
|
||||
@@ -26,3 +26,89 @@ describe("RescheduledAuditActionService - getDataRequirements contract", () => {
|
||||
expect(accessedData.userUuids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduledAuditActionService - getDisplayTitle", () => {
|
||||
let service: RescheduledAuditActionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RescheduledAuditActionService();
|
||||
});
|
||||
|
||||
it("should generate deep link to booking drawer with history tab", async () => {
|
||||
const rescheduledToUid = "new-booking-uid-123";
|
||||
const storedData = {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: 1700000000000, new: 1700100000000 },
|
||||
endTime: { old: 1700003600000, new: 1700103600000 },
|
||||
rescheduledToUid: { old: null, new: rescheduledToUid },
|
||||
},
|
||||
};
|
||||
|
||||
const dbStore = createTrackingDbStore(createEmptyAccessedData());
|
||||
const result = await service.getDisplayTitle({
|
||||
storedData,
|
||||
userTimeZone: "UTC",
|
||||
dbStore,
|
||||
});
|
||||
|
||||
expect(result.components).toBeDefined();
|
||||
expect(result.components).toHaveLength(1);
|
||||
expect(result.components![0].href).toBe(
|
||||
`/bookings?uid=${rescheduledToUid}&activeSegment=history`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not include components when rescheduledToUid is null", async () => {
|
||||
const storedData = {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: 1700000000000, new: 1700100000000 },
|
||||
endTime: { old: 1700003600000, new: 1700103600000 },
|
||||
rescheduledToUid: { old: null, new: null },
|
||||
},
|
||||
};
|
||||
|
||||
const dbStore = createTrackingDbStore(createEmptyAccessedData());
|
||||
const result = await service.getDisplayTitle({
|
||||
storedData,
|
||||
userTimeZone: "UTC",
|
||||
dbStore,
|
||||
});
|
||||
|
||||
expect(result.components).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduledAuditActionService - getDisplayTitleForRescheduledFromLog", () => {
|
||||
let service: RescheduledAuditActionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RescheduledAuditActionService();
|
||||
});
|
||||
|
||||
it("should generate deep link to original booking's history tab", () => {
|
||||
const fromRescheduleUid = "original-booking-uid-456";
|
||||
const storedData = {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: 1700000000000, new: 1700100000000 },
|
||||
endTime: { old: 1700003600000, new: 1700103600000 },
|
||||
rescheduledToUid: { old: null, new: "some-uid" },
|
||||
},
|
||||
};
|
||||
|
||||
const result = service.getDisplayTitleForRescheduledFromLog({
|
||||
fromRescheduleUid,
|
||||
userTimeZone: "UTC",
|
||||
storedData,
|
||||
});
|
||||
|
||||
|
||||
expect(result.components).toBeDefined();
|
||||
expect(result.components).toHaveLength(1);
|
||||
expect(result.components![0].href).toBe(
|
||||
`/bookings?uid=${fromRescheduleUid}&activeSegment=history`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+58
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { verifyDataRequirementsContract } from "./contractVerification";
|
||||
import { verifyDataRequirementsContract, createTrackingDbStore, createEmptyAccessedData } from "./contractVerification";
|
||||
import { SeatRescheduledAuditActionService } from "../SeatRescheduledAuditActionService";
|
||||
|
||||
describe("SeatRescheduledAuditActionService - getDataRequirements contract", () => {
|
||||
@@ -27,3 +27,60 @@ describe("SeatRescheduledAuditActionService - getDataRequirements contract", ()
|
||||
expect(accessedData.userUuids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeatRescheduledAuditActionService - getDisplayTitle", () => {
|
||||
let service: SeatRescheduledAuditActionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SeatRescheduledAuditActionService();
|
||||
});
|
||||
|
||||
it("should generate deep link to booking drawer with history tab", async () => {
|
||||
const rescheduledToBookingUid = "new-booking-uid-789";
|
||||
const storedData = {
|
||||
version: 1,
|
||||
fields: {
|
||||
seatReferenceUid: "seat-456",
|
||||
attendeeEmail: "attendee@example.com",
|
||||
startTime: { old: 1700000000000, new: 1700100000000 },
|
||||
endTime: { old: 1700003600000, new: 1700103600000 },
|
||||
rescheduledToBookingUid: { old: null, new: rescheduledToBookingUid },
|
||||
},
|
||||
};
|
||||
|
||||
const dbStore = createTrackingDbStore(createEmptyAccessedData());
|
||||
const result = await service.getDisplayTitle({
|
||||
storedData,
|
||||
userTimeZone: "UTC",
|
||||
dbStore,
|
||||
});
|
||||
|
||||
expect(result.components).toBeDefined();
|
||||
expect(result.components).toHaveLength(1);
|
||||
expect(result.components![0].href).toBe(
|
||||
`/bookings?uid=${rescheduledToBookingUid}&activeSegment=history`
|
||||
);
|
||||
});
|
||||
|
||||
it("should not include components when rescheduledToBookingUid is null", async () => {
|
||||
const storedData = {
|
||||
version: 1,
|
||||
fields: {
|
||||
seatReferenceUid: "seat-456",
|
||||
attendeeEmail: "attendee@example.com",
|
||||
startTime: { old: 1700000000000, new: 1700100000000 },
|
||||
endTime: { old: 1700003600000, new: 1700103600000 },
|
||||
rescheduledToBookingUid: { old: null, new: null },
|
||||
},
|
||||
};
|
||||
|
||||
const dbStore = createTrackingDbStore(createEmptyAccessedData());
|
||||
const result = await service.getDisplayTitle({
|
||||
storedData,
|
||||
userTimeZone: "UTC",
|
||||
dbStore,
|
||||
});
|
||||
|
||||
expect(result.components).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+102
-69
@@ -61,13 +61,19 @@ function hoursFromNow(hours: number): number {
|
||||
async function seedAuditLogsForBooking({
|
||||
bookingUid,
|
||||
userUuid,
|
||||
userId,
|
||||
attendeeId,
|
||||
attendeeEmail,
|
||||
eventTypeId,
|
||||
eventTypeLength,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
userUuid: string;
|
||||
userId: number;
|
||||
attendeeId: number | undefined;
|
||||
attendeeEmail: string;
|
||||
eventTypeId: number;
|
||||
eventTypeLength: number;
|
||||
}): Promise<number> {
|
||||
const existingLogs = await prisma.bookingAudit.findFirst({
|
||||
where: { bookingUid },
|
||||
@@ -89,7 +95,30 @@ async function seedAuditLogsForBooking({
|
||||
attendeeActor = await createAttendeeActor(attendeeId);
|
||||
}
|
||||
|
||||
// Create the "rescheduled-to" booking so rescheduledToUid in audit logs points to a real booking
|
||||
const rescheduledToUid = uuidv4();
|
||||
const rescheduledStartTime = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); // 2 days from now (matches RESCHEDULED log's "new" times)
|
||||
const rescheduledEndTime = new Date(rescheduledStartTime.getTime() + eventTypeLength * 60 * 1000);
|
||||
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
uid: rescheduledToUid,
|
||||
title: "Audit Log Test Booking - Rescheduled",
|
||||
startTime: rescheduledStartTime,
|
||||
endTime: rescheduledEndTime,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
userId,
|
||||
eventTypeId,
|
||||
fromReschedule: bookingUid,
|
||||
attendees: {
|
||||
create: {
|
||||
email: attendeeEmail,
|
||||
name: "James Wilson",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
let timestampOffset = 0;
|
||||
@@ -136,41 +165,6 @@ async function seedAuditLogsForBooking({
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: (attendeeActor ?? guestActor).id,
|
||||
action: "RESCHEDULE_REQUESTED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBAPP",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
rescheduleReason: "Conflict with another meeting",
|
||||
rescheduledRequestedBy: attendeeEmail,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: userActor.id,
|
||||
action: "RESCHEDULED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBAPP",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: hoursFromNow(24), new: hoursFromNow(48) },
|
||||
endTime: { old: hoursFromNow(24.5), new: hoursFromNow(48.5) },
|
||||
rescheduledToUid: { old: null, new: rescheduledToUid },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: userActor.id,
|
||||
@@ -299,24 +293,6 @@ async function seedAuditLogsForBooking({
|
||||
context: { impersonatedBy: userUuid },
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: appActor.id,
|
||||
action: "CANCELLED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBHOOK",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
cancellationReason: "Payment failed - automatic cancellation",
|
||||
cancelledBy: "stripe@app.internal",
|
||||
status: { old: BookingStatus.ACCEPTED, new: BookingStatus.CANCELLED },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: userActor.id,
|
||||
@@ -376,6 +352,59 @@ async function seedAuditLogsForBooking({
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: appActor.id,
|
||||
action: "CANCELLED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBHOOK",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
cancellationReason: "Payment failed - automatic cancellation",
|
||||
cancelledBy: "stripe@app.internal",
|
||||
status: { old: BookingStatus.ACCEPTED, new: BookingStatus.CANCELLED },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: (attendeeActor ?? guestActor).id,
|
||||
action: "RESCHEDULE_REQUESTED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBAPP",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
rescheduleReason: "Conflict with another meeting",
|
||||
rescheduledRequestedBy: attendeeEmail,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
auditLogs.push({
|
||||
bookingUid,
|
||||
actorId: userActor.id,
|
||||
action: "RESCHEDULED",
|
||||
type: "RECORD_UPDATED",
|
||||
timestamp: nextTimestamp(),
|
||||
source: "WEBAPP",
|
||||
operationId: uuidv4(),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: hoursFromNow(24), new: hoursFromNow(48) },
|
||||
endTime: { old: hoursFromNow(24.5), new: hoursFromNow(48.5) },
|
||||
rescheduledToUid: { old: null, new: rescheduledToUid },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const logData of auditLogs) {
|
||||
await prisma.bookingAudit.create({ data: logData });
|
||||
}
|
||||
@@ -423,19 +452,7 @@ export default async function seedBookingAuditLogs() {
|
||||
});
|
||||
console.log(" ✅ Enabled bookings-v3 globally");
|
||||
|
||||
// Enable booking-audit at team level for owner1-acme's organization
|
||||
if (organizationId) {
|
||||
await prisma.feature.upsert({
|
||||
where: { slug: "booking-audit" },
|
||||
create: {
|
||||
slug: "booking-audit",
|
||||
enabled: false,
|
||||
description: "Enable booking audit trails - Track all booking actions and changes for organizations",
|
||||
type: "OPERATIONAL",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.teamFeatures.upsert({
|
||||
where: { teamId_featureId: { teamId: organizationId, featureId: "booking-audit" } },
|
||||
create: {
|
||||
@@ -446,7 +463,20 @@ export default async function seedBookingAuditLogs() {
|
||||
},
|
||||
update: { enabled: true },
|
||||
});
|
||||
|
||||
console.log(` ✅ Enabled booking-audit for organization ${organizationId}`);
|
||||
|
||||
await prisma.teamFeatures.upsert({
|
||||
where: { teamId_featureId: { teamId: organizationId, featureId: "bookings-v3" } },
|
||||
create: {
|
||||
teamId: organizationId,
|
||||
featureId: "bookings-v3",
|
||||
enabled: true,
|
||||
assignedBy: "seed-script",
|
||||
},
|
||||
update: { enabled: true },
|
||||
});
|
||||
console.log(` ✅ Enabled bookings-v3 for organization ${organizationId}`);
|
||||
}
|
||||
|
||||
// Find an event type for this user to create a booking
|
||||
@@ -497,19 +527,22 @@ export default async function seedBookingAuditLogs() {
|
||||
const count = await seedAuditLogsForBooking({
|
||||
bookingUid: booking.uid,
|
||||
userUuid: user.uuid,
|
||||
userId: user.id,
|
||||
attendeeId: booking.attendees[0]?.id,
|
||||
attendeeEmail: booking.attendees[0]?.email ?? "james.wilson@techstart.dev",
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeLength: eventType.length,
|
||||
});
|
||||
|
||||
console.log(` ✅ Created ${count} audit log entries`);
|
||||
console.log(` View logs at: /booking/${booking.uid}/logs`);
|
||||
console.log(` View logs at: /bookings/upcoming?uid=${booking.uid}&activeSegment=history`);
|
||||
|
||||
console.log(`\n📊 Summary:`);
|
||||
console.log(` Booking UID: ${booking.uid}`);
|
||||
console.log(` Total audit log entries created: ${count}`);
|
||||
console.log(" Actions covered: CREATED, ACCEPTED, RESCHEDULE_REQUESTED, RESCHEDULED,");
|
||||
console.log(" LOCATION_CHANGED, ATTENDEE_ADDED, ATTENDEE_REMOVED, REASSIGNMENT (x2),");
|
||||
console.log(" NO_SHOW_UPDATED (x2), CANCELLED, REJECTED, SEAT_BOOKED, SEAT_RESCHEDULED");
|
||||
console.log(" Actions covered: CREATED, ACCEPTED, LOCATION_CHANGED, ATTENDEE_ADDED,");
|
||||
console.log(" ATTENDEE_REMOVED, REASSIGNMENT (x2), NO_SHOW_UPDATED (x2), REJECTED,");
|
||||
console.log(" SEAT_BOOKED, SEAT_RESCHEDULED, CANCELLED, RESCHEDULE_REQUESTED, RESCHEDULED");
|
||||
console.log(" Actor types: USER, ATTENDEE, GUEST, SYSTEM, APP");
|
||||
console.log(" Sources: WEBAPP, API_V1, API_V2, WEBHOOK, MAGIC_LINK, SYSTEM");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user