From ab8556790ee3d2cd543457d403220d9222b5cef7 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 6 May 2025 20:34:45 +0200 Subject: [PATCH] feat: add denormalized table for BookingTimeStatus view (#20947) * feat: add denormalized table for BookingTimeStatus * Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * add more indexes * improvement * update schema * improve indexes * apply feedback from mrge * split update trigger for better perf * remove unnecessary checks * split migration into two * less logging * address feedback * add updatedAt and userUsername * remove timeStatus * add deletion trigger * clean up denorm table on row deletion at EventType or users * add timeStatus back as a part of a new view * keep user and eventType nullable * rename triggers * add tests * clean up migrations * update tests * update tests * update test clean up * one more try * similar updates * use e2e test instead --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../playwright/booking-denormalized.e2e.ts | 201 ++++++++++++++++++ .../migration.sql | 62 ++++++ .../migration.sql | 189 ++++++++++++++++ .../migration.sql | 11 + packages/prisma/schema.prisma | 66 ++++++ 5 files changed, 529 insertions(+) create mode 100644 apps/web/playwright/booking-denormalized.e2e.ts create mode 100644 packages/prisma/migrations/20250502130807_create_booking_denormalized/migration.sql create mode 100644 packages/prisma/migrations/20250502130843_setup_triggers_for_booking_denormalized/migration.sql create mode 100644 packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql diff --git a/apps/web/playwright/booking-denormalized.e2e.ts b/apps/web/playwright/booking-denormalized.e2e.ts new file mode 100644 index 0000000000..cd2dde7fdd --- /dev/null +++ b/apps/web/playwright/booking-denormalized.e2e.ts @@ -0,0 +1,201 @@ +import { expect } from "@playwright/test"; + +import type { User, EventType } from "@calcom/prisma/client"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("BookingDenormalized", () => { + let userId: number; + let eventTypeId: number; + let bookingId: number; + let user: User; + let eventType: EventType; + + test.beforeEach(async ({ prisma }) => { + // Create test user with random email to avoid conflicts + const randomId = Math.floor(Math.random() * 1000000); + user = await prisma.user.create({ + data: { + email: `booking-denorm-${randomId}@example.com`, + username: `booking-denorm-testuser-${randomId}`, + name: "Test User", + }, + }); + userId = user.id; + + // Create test event type + eventType = await prisma.eventType.create({ + data: { + title: "Test Event", + slug: "test-event", + length: 60, + userId: user.id, + }, + }); + eventTypeId = eventType.id; + + // Create test booking with PENDING status (no idempotencyKey needed) + const booking = await prisma.booking.create({ + data: { + uid: `booking-denorm-${randomId}`, + title: "Test Booking", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), // 1 hour later + userId: user.id, + eventTypeId: eventType.id, + status: BookingStatus.PENDING, + }, + }); + bookingId = booking.id; + }); + + test.afterEach(async ({ prisma }) => { + // Clean up test data in reverse order of creation to avoid foreign key constraints + if (bookingId) { + await prisma.booking.deleteMany({ + where: { id: bookingId }, + }); + } + if (eventTypeId) { + await prisma.eventType.deleteMany({ + where: { id: eventTypeId }, + }); + } + if (userId) { + await prisma.user.deleteMany({ + where: { id: userId }, + }); + } + }); + + test("should create denormalized entry when booking is created", async ({ prisma }) => { + const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({ + where: { id: bookingId }, + }); + + expect(denormalizedBooking.eventTypeId).toBe(eventTypeId); + expect(denormalizedBooking.userId).toBe(userId); + expect(denormalizedBooking.title).toBe("Test Booking"); + expect(denormalizedBooking.eventLength).toBe(60); + expect(denormalizedBooking.userName).toBe("Test User"); + }); + + test("should update denormalized entry when booking is updated", async ({ prisma }) => { + await prisma.booking.update({ + where: { id: bookingId }, + data: { + title: "Updated Booking", + status: BookingStatus.CANCELLED, + }, + }); + + const updatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({ + where: { id: bookingId }, + }); + + expect(updatedDenormalizedBooking.title).toBe("Updated Booking"); + expect(updatedDenormalizedBooking.status).toBe(BookingStatus.CANCELLED); + }); + + test("should update denormalized entry when user is updated", async ({ prisma }) => { + await prisma.user.update({ + where: { id: userId }, + data: { + name: "Updated User", + email: "updated@example.com", + }, + }); + + const userUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({ + where: { id: bookingId }, + }); + + expect(userUpdatedDenormalizedBooking.userName).toBe("Updated User"); + expect(userUpdatedDenormalizedBooking.userEmail).toBe("updated@example.com"); + }); + + test("should update denormalized entry when event type is updated", async ({ prisma }) => { + await prisma.eventType.update({ + where: { id: eventTypeId }, + data: { + length: 30, + }, + }); + + const eventTypeUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({ + where: { id: bookingId }, + }); + + expect(eventTypeUpdatedDenormalizedBooking.eventLength).toBe(30); + }); + + test("should delete denormalized entry when booking is deleted", async ({ prisma }) => { + await prisma.booking.delete({ + where: { id: bookingId }, + }); + + const deletedDenormalizedBooking = await prisma.bookingDenormalized.findUnique({ + where: { id: bookingId }, + }); + + expect(deletedDenormalizedBooking).toBeNull(); + }); + + test("should update denormalized entries when event type is deleted", async ({ prisma }) => { + // Create an additional booking for this test + const booking2 = await prisma.booking.create({ + data: { + uid: `test-booking-${Math.floor(Math.random() * 1000000)}`, + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: userId, + eventTypeId: eventTypeId, + status: BookingStatus.PENDING, + }, + }); + + await prisma.eventType.delete({ + where: { id: eventTypeId }, + }); + + const eventTypeDeletedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({ + where: { id: booking2.id }, + }); + + expect(eventTypeDeletedDenormalizedBooking.eventTypeId).toBeNull(); + expect(eventTypeDeletedDenormalizedBooking.eventLength).toBeNull(); + }); + + test("should delete denormalized entries when user is deleted", async ({ prisma }) => { + // Create an additional booking for this test + const booking2 = await prisma.booking.create({ + data: { + uid: `test-booking-${Math.floor(Math.random() * 1000000)}`, + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(Date.now() + 60 * 60 * 1000), + userId: userId, + eventTypeId: eventTypeId, + status: BookingStatus.PENDING, + }, + }); + + await prisma.user.delete({ + where: { id: userId }, + }); + + const userDeletedDenormalizedBooking1 = await prisma.bookingDenormalized.findUnique({ + where: { id: bookingId }, + }); + const userDeletedDenormalizedBooking2 = await prisma.bookingDenormalized.findUnique({ + where: { id: booking2.id }, + }); + + expect(userDeletedDenormalizedBooking1).toBeNull(); + expect(userDeletedDenormalizedBooking2).toBeNull(); + }); +}); diff --git a/packages/prisma/migrations/20250502130807_create_booking_denormalized/migration.sql b/packages/prisma/migrations/20250502130807_create_booking_denormalized/migration.sql new file mode 100644 index 0000000000..4410b996be --- /dev/null +++ b/packages/prisma/migrations/20250502130807_create_booking_denormalized/migration.sql @@ -0,0 +1,62 @@ +-- CreateTable +CREATE TABLE "BookingDenormalized" ( + "id" INTEGER NOT NULL, + "uid" TEXT NOT NULL, + "eventTypeId" INTEGER, + "title" TEXT NOT NULL, + "description" TEXT, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3), + "location" TEXT, + "paid" BOOLEAN NOT NULL, + "status" "BookingStatus" NOT NULL, + "rescheduled" BOOLEAN, + "userId" INTEGER, + "teamId" INTEGER, + "eventLength" INTEGER, + "eventParentId" INTEGER, + "userEmail" TEXT, + "userName" TEXT, + "userUsername" TEXT, + "ratingFeedback" TEXT, + "rating" INTEGER, + "noShowHost" BOOLEAN, + "isTeamBooking" BOOLEAN NOT NULL, + + CONSTRAINT "BookingDenormalized_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_userId_idx" ON "BookingDenormalized"("userId"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_createdAt_idx" ON "BookingDenormalized"("createdAt"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_eventTypeId_idx" ON "BookingDenormalized"("eventTypeId"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_eventParentId_idx" ON "BookingDenormalized"("eventParentId"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_teamId_idx" ON "BookingDenormalized"("teamId"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_startTime_idx" ON "BookingDenormalized"("startTime"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_endTime_idx" ON "BookingDenormalized"("endTime"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_status_idx" ON "BookingDenormalized"("status"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_teamId_isTeamBooking_idx" ON "BookingDenormalized"("teamId", "isTeamBooking"); + +-- CreateIndex +CREATE INDEX "BookingDenormalized_userId_isTeamBooking_idx" ON "BookingDenormalized"("userId", "isTeamBooking"); + +-- CreateIndex +CREATE UNIQUE INDEX "BookingDenormalized_id_key" ON "BookingDenormalized"("id"); diff --git a/packages/prisma/migrations/20250502130843_setup_triggers_for_booking_denormalized/migration.sql b/packages/prisma/migrations/20250502130843_setup_triggers_for_booking_denormalized/migration.sql new file mode 100644 index 0000000000..44be03a34f --- /dev/null +++ b/packages/prisma/migrations/20250502130843_setup_triggers_for_booking_denormalized/migration.sql @@ -0,0 +1,189 @@ +-- Function to calculate isTeamBooking +CREATE OR REPLACE FUNCTION calculate_is_team_booking(team_id INTEGER) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN team_id IS NOT NULL; +END; +$$ LANGUAGE plpgsql; + +-- Function to refresh a single booking's data +CREATE OR REPLACE FUNCTION refresh_booking_time_status_denormalized(booking_id INTEGER) +RETURNS VOID AS $$ +BEGIN + -- Delete existing entry if any + DELETE FROM "BookingDenormalized" WHERE id = booking_id; + + -- Insert a denormalized booking joined with EventType and user + INSERT INTO "BookingDenormalized" ( + id, + uid, + "eventTypeId", + title, + description, + "startTime", + "endTime", + "createdAt", + "updatedAt", + location, + paid, + status, + rescheduled, + "userId", + "teamId", + "eventLength", + "eventParentId", + "userEmail", + "userName", + "userUsername", + "ratingFeedback", + "rating", + "noShowHost", + "isTeamBooking" + ) + SELECT + "Booking".id, + "Booking".uid, + "Booking"."eventTypeId", + "Booking".title, + "Booking".description, + "Booking"."startTime", + "Booking"."endTime", + "Booking"."createdAt", + "Booking"."updatedAt", + "Booking".location, + "Booking".paid, + "Booking".status, + "Booking".rescheduled, + "Booking"."userId", + et."teamId", + et.length AS "eventLength", + et."parentId" AS "eventParentId", + "u"."email" AS "userEmail", + "u"."name" AS "userName", + "u"."username" AS "userUsername", + "Booking"."ratingFeedback", + "Booking"."rating", + "Booking"."noShowHost", + calculate_is_team_booking(et."teamId") as "isTeamBooking" + FROM "Booking" + LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id + LEFT JOIN users u ON u.id = "Booking"."userId" + WHERE "Booking".id = booking_id; +END; +$$ LANGUAGE plpgsql; + +-- Trigger function for booking changes (insert/update) +CREATE OR REPLACE FUNCTION trigger_refresh_booking_time_status_denormalized() +RETURNS TRIGGER AS $$ +BEGIN + BEGIN + PERFORM refresh_booking_time_status_denormalized(NEW.id); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to handle booking change for id %', NEW.id; + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger function for booking deletions +CREATE OR REPLACE FUNCTION trigger_delete_booking_time_status_denormalized() +RETURNS TRIGGER AS $$ +BEGIN + BEGIN + DELETE FROM "BookingDenormalized" WHERE id = OLD.id; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to delete denormalized booking id %', OLD.id; + END; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +-- Create triggers for Booking table +CREATE TRIGGER booking_denorm_booking_insert_update_trigger + AFTER INSERT OR UPDATE ON "Booking" + FOR EACH ROW + EXECUTE FUNCTION trigger_refresh_booking_time_status_denormalized(); + +CREATE TRIGGER booking_denorm_booking_delete_trigger + AFTER DELETE ON "Booking" + FOR EACH ROW + EXECUTE FUNCTION trigger_delete_booking_time_status_denormalized(); + +-- Function for teamId changes on EventType +CREATE OR REPLACE FUNCTION refresh_booking_time_status_team_id() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE "BookingDenormalized" btsd + SET + "teamId" = NEW."teamId", + "isTeamBooking" = calculate_is_team_booking(NEW."teamId") + WHERE btsd."eventTypeId" = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function for length changes on EventType +CREATE OR REPLACE FUNCTION refresh_booking_time_status_length() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE "BookingDenormalized" btsd + SET "eventLength" = NEW.length + WHERE btsd."eventTypeId" = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Function for parentId changes on EventType +CREATE OR REPLACE FUNCTION refresh_booking_time_status_parent_id() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE "BookingDenormalized" btsd + SET "eventParentId" = NEW."parentId" + WHERE btsd."eventTypeId" = NEW.id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create separate triggers for each property on EventType +CREATE TRIGGER booking_denorm_event_type_team_id_update_trigger + AFTER UPDATE OF "teamId" ON "EventType" + FOR EACH ROW + EXECUTE FUNCTION refresh_booking_time_status_team_id(); + +CREATE TRIGGER booking_denorm_event_type_length_update_trigger + AFTER UPDATE OF length ON "EventType" + FOR EACH ROW + EXECUTE FUNCTION refresh_booking_time_status_length(); + +CREATE TRIGGER booking_denorm_event_type_parent_id_update_trigger + AFTER UPDATE OF "parentId" ON "EventType" + FOR EACH ROW + EXECUTE FUNCTION refresh_booking_time_status_parent_id(); + +-- Create triggers for users table +CREATE OR REPLACE FUNCTION trigger_refresh_booking_time_status_denormalized_user() +RETURNS TRIGGER AS $$ +BEGIN + BEGIN + UPDATE "BookingDenormalized" btsd + SET + "userEmail" = NEW.email, + "userName" = NEW.name, + "userUsername" = NEW.username + WHERE btsd."userId" = NEW.id; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to update user changes for id %', NEW.id; + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER booking_denorm_user_update_trigger + AFTER UPDATE OF email, name, username ON users + FOR EACH ROW + EXECUTE FUNCTION trigger_refresh_booking_time_status_denormalized_user(); + +-- Populate the table with initial data +-- DELETE FROM "BookingDenormalized"; +-- INSERT INTO "BookingDenormalized" +-- SELECT * FROM "BookingTimeStatus"; diff --git a/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql b/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql new file mode 100644 index 0000000000..693d62a03b --- /dev/null +++ b/packages/prisma/migrations/20250505135207_create_booking_time_status_denormalized/migration.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE VIEW public."BookingTimeStatusDenormalized" AS +SELECT + *, + CASE + WHEN "rescheduled" IS TRUE THEN 'rescheduled' + WHEN "status" = 'cancelled'::public."BookingStatus" AND "rescheduled" IS NULL THEN 'cancelled' + WHEN "endTime" < now() THEN 'completed' + WHEN "endTime" > now() THEN 'uncompleted' + ELSE NULL + END as "timeStatus" +FROM public."BookingDenormalized"; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index bc9356fab4..50a23f2744 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1472,6 +1472,72 @@ view BookingTimeStatus { isTeamBooking Boolean } +model BookingDenormalized { + id Int @id @unique + uid String + eventTypeId Int? + title String + description String? + startTime DateTime + endTime DateTime + createdAt DateTime + updatedAt DateTime? + location String? + paid Boolean + status BookingStatus + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + eventParentId Int? + userEmail String? + userName String? + userUsername String? + ratingFeedback String? + rating Int? + noShowHost Boolean? + isTeamBooking Boolean + + @@index([userId]) + @@index([createdAt]) + @@index([eventTypeId]) + @@index([eventParentId]) + @@index([teamId]) + @@index([startTime]) + @@index([endTime]) + @@index([status]) + @@index([teamId, isTeamBooking]) + @@index([userId, isTeamBooking]) +} + +view BookingTimeStatusDenormalized { + id Int @id @unique + uid String + eventTypeId Int? + title String + description String? + startTime DateTime + endTime DateTime + createdAt DateTime + updatedAt DateTime? + location String? + paid Boolean + status BookingStatus + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + eventParentId Int? + userEmail String? + userName String? + userUsername String? + ratingFeedback String? + rating Int? + noShowHost Boolean? + isTeamBooking Boolean + timeStatus String? // this is the addition on top of BookingDenormalized +} + model CalendarCache { // To be made required in a followup id String? @default(uuid())