Files
calendar/scripts/seed-booking-audit.ts
T
Hariom BalharaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit Takkar
2bc17313fd 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>
2026-02-17 13:20:06 +05:30

559 lines
14 KiB
TypeScript

import process from "node:process";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { v4 as uuidv4 } from "uuid";
async function createUserActor(userUuid: string) {
return prisma.auditActor.upsert({
where: { userUuid },
create: { type: "USER", userUuid },
update: {},
select: { id: true },
});
}
async function createAttendeeActor(attendeeId: number) {
return prisma.auditActor.upsert({
where: { attendeeId },
create: { type: "ATTENDEE", attendeeId },
update: {},
select: { id: true },
});
}
async function createSystemActor() {
const systemEmail = "system@system.internal";
return prisma.auditActor.upsert({
where: { email: systemEmail },
create: { type: "SYSTEM", email: systemEmail, name: "System" },
update: {},
select: { id: true },
});
}
async function createGuestActor(email: string, name: string) {
const existing = await prisma.auditActor.findUnique({
where: { email },
select: { id: true },
});
if (existing) return existing;
return prisma.auditActor.create({
data: { type: "GUEST", email, name },
select: { id: true },
});
}
async function createAppActor(appSlug: string, appName: string) {
const email = `${appSlug}@app.internal`;
return prisma.auditActor.upsert({
where: { email },
create: { type: "APP", email, name: appName },
update: {},
select: { id: true },
});
}
function hoursFromNow(hours: number): number {
return Date.now() + hours * 60 * 60 * 1000;
}
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 },
select: { id: true },
});
if (existingLogs) {
console.log(` ⏭️ Audit logs already exist for ${bookingUid}, skipping.`);
return 0;
}
const userActor = await createUserActor(userUuid);
const systemActor = await createSystemActor();
const guestActor = await createGuestActor("priya.sharma@acmecorp.io", "Priya Sharma");
const appActor = await createAppActor("stripe", "Stripe");
let attendeeActor: { id: string } | null = null;
if (attendeeId) {
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;
function nextTimestamp(): Date {
timestampOffset += 1;
return new Date(now - (20 - timestampOffset) * 60 * 1000);
}
const auditLogs: Parameters<typeof prisma.bookingAudit.create>[0]["data"][] = [];
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "CREATED",
type: "RECORD_CREATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
startTime: hoursFromNow(24),
endTime: hoursFromNow(24.5),
status: BookingStatus.PENDING,
hostUserUuid: userUuid,
seatReferenceUid: null,
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "ACCEPTED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
status: { old: BookingStatus.PENDING, new: BookingStatus.ACCEPTED },
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "LOCATION_CHANGED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
location: { old: "integrations:google:meet", new: "integrations:zoom" },
},
},
});
auditLogs.push({
bookingUid,
actorId: guestActor.id,
action: "ATTENDEE_ADDED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "MAGIC_LINK",
operationId: uuidv4(),
data: {
version: 1,
fields: {
added: ["rachel.nguyen@designhub.co"],
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "ATTENDEE_REMOVED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
attendees: {
old: [attendeeEmail, "rachel.nguyen@designhub.co"],
new: [attendeeEmail],
},
},
},
});
auditLogs.push({
bookingUid,
actorId: systemActor.id,
action: "REASSIGNMENT",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "SYSTEM",
operationId: uuidv4(),
data: {
version: 1,
fields: {
organizerUuid: { old: null, new: userUuid },
reassignmentReason: "Round-robin auto-reassignment",
reassignmentType: "roundRobin",
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "REASSIGNMENT",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
organizerUuid: { old: userUuid, new: userUuid },
reassignmentReason: "Manual reassignment by admin",
reassignmentType: "manual",
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "NO_SHOW_UPDATED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
attendeesNoShow: [
{
attendeeEmail,
noShow: { old: false, new: true },
},
],
},
},
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "NO_SHOW_UPDATED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
host: {
userUuid,
noShow: { old: null, new: true },
},
},
},
context: { impersonatedBy: userUuid },
});
auditLogs.push({
bookingUid,
actorId: userActor.id,
action: "REJECTED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "API_V2",
operationId: uuidv4(),
data: {
version: 1,
fields: {
rejectionReason: "Time slot no longer available",
status: { old: BookingStatus.PENDING, new: BookingStatus.REJECTED },
},
},
});
const seatRefUid = uuidv4();
auditLogs.push({
bookingUid,
actorId: guestActor.id,
action: "SEAT_BOOKED",
type: "RECORD_CREATED",
timestamp: nextTimestamp(),
source: "WEBAPP",
operationId: uuidv4(),
data: {
version: 1,
fields: {
seatReferenceUid: seatRefUid,
attendeeEmail: "marcus.johnson@consulting.io",
attendeeName: "Marcus Johnson",
startTime: hoursFromNow(48),
endTime: hoursFromNow(48.5),
},
},
});
auditLogs.push({
bookingUid,
actorId: (attendeeActor ?? guestActor).id,
action: "SEAT_RESCHEDULED",
type: "RECORD_UPDATED",
timestamp: nextTimestamp(),
source: "API_V1",
operationId: uuidv4(),
data: {
version: 1,
fields: {
seatReferenceUid: seatRefUid,
attendeeEmail: "marcus.johnson@consulting.io",
startTime: { old: hoursFromNow(48), new: hoursFromNow(72) },
endTime: { old: hoursFromNow(48.5), new: hoursFromNow(72.5) },
rescheduledToBookingUid: { old: null, new: rescheduledToUid },
},
},
});
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 });
}
return auditLogs.length;
}
export default async function seedBookingAuditLogs() {
console.log("🔍 Seeding booking audit logs...");
// Audit logs is an org-only feature, so we only seed for owner1-acme
const user = await prisma.user.findFirst({
where: { username: "owner1-acme" },
select: {
id: true,
uuid: true,
username: true,
email: true,
profiles: { select: { organizationId: true } },
},
});
if (!user) {
console.log("❌ User owner1-acme not found. Run the main seed first.");
return;
}
if (!user.profiles.length) {
console.log("❌ User owner1-acme has no organization profile. Run the main seed first.");
return;
}
const organizationId = user.profiles[0].organizationId;
// Enable bookings-v3 globally
await prisma.feature.upsert({
where: { slug: "bookings-v3" },
create: {
slug: "bookings-v3",
enabled: true,
description: "Enable bookings redesign v3 for all users",
type: "EXPERIMENT",
},
update: { enabled: true },
});
console.log(" ✅ Enabled bookings-v3 globally");
if (organizationId) {
await prisma.teamFeatures.upsert({
where: { teamId_featureId: { teamId: organizationId, featureId: "booking-audit" } },
create: {
teamId: organizationId,
featureId: "booking-audit",
enabled: true,
assignedBy: "seed-script",
},
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
const eventType = await prisma.eventType.findFirst({
where: { userId: user.id },
select: { id: true, title: true, length: true },
});
if (!eventType) {
console.log("❌ No event type found for owner1-acme. Run the main seed first.");
return;
}
// Create a new upcoming booking specifically for audit logs
const bookingUid = uuidv4();
const startTime = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
const endTime = new Date(startTime.getTime() + eventType.length * 60 * 1000);
console.log(`📋 Creating new booking for audit logs...`);
const booking = await prisma.booking.create({
data: {
uid: bookingUid,
title: `Audit Log Test Booking - ${eventType.title}`,
startTime,
endTime,
status: BookingStatus.ACCEPTED,
userId: user.id,
eventTypeId: eventType.id,
attendees: {
create: {
email: "james.wilson@techstart.dev",
name: "James Wilson",
timeZone: "UTC",
},
},
},
select: {
uid: true,
attendees: { select: { id: true, email: true }, take: 1 },
},
});
console.log(` ✅ Created booking: ${booking.uid}`);
console.log(`📋 Seeding audit logs for ${user.username} — booking ${booking.uid}`);
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: /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, 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");
}
seedBookingAuditLogs()
.then(() => {
console.log("\n🎉 Booking audit seed complete!");
process.exit(0);
})
.catch((err) => {
console.error("❌ Seed failed:", err);
process.exit(1);
});