feat: add denormalized table for RoutingFormResponse view (#21009)

* feat: add denormalized table for RoutingFormResponse

* WIP

* improve

* update schema

* update schema

* improvement

* fix type of bookingStatus

* cascading DELETE

* split update trigger

* denormalize fields into a separate table

* error handling

* address feedback

* fix

* fix constraints and relations

* delete rows when rows are deleted in the joined tables

* add tests and make booking nullable in RoutingFormResponseDenormalized

* add tests

* update test

* use e2e tests instead

* fix type error

* address feedback

* move e2e to integration test

* add missing tests

* move index migration to another file

* clean up migrations
This commit is contained in:
Eunjae Lee
2025-05-14 10:41:28 -04:00
committed by GitHub
parent bfb51b82b2
commit 2a93e661f9
5 changed files with 1347 additions and 19 deletions
@@ -0,0 +1,730 @@
import { Prisma } from "@prisma/client";
import { describe, expect, beforeEach, afterEach, it } from "vitest";
import type { Field as FormField } from "@calcom/app-store/routing-forms/types/types";
import prisma from "@calcom/prisma";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
describe("RoutingFormResponseDenormalized", () => {
let userId: number;
let formId: string;
let eventTypeId: number;
let bookingId: number;
let responseId: number;
const randomId = Math.floor(Math.random() * 1000000);
const email = `booking-denorm-${randomId}@example.com`;
beforeEach(async () => {
// Create test user
const user = await prisma.user.create({
data: {
email,
username: `booking-denorm-testuser-${randomId}`,
name: "Test User",
},
});
userId = user.id;
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event",
slug: "test-event",
length: 60,
userId: user.id,
},
});
eventTypeId = eventType.id;
// Create test form
const form = await prisma.app_RoutingForms_Form.create({
data: {
name: "Test Form",
userId: user.id,
fields: [
{
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
type: "text",
label: "Manager",
required: true,
},
{
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
type: "select",
label: "Location",
required: true,
options: [
{
id: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
label: "New York",
},
],
},
{
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
type: "multiselect",
label: "skills",
required: false,
options: [
{
id: "ee494e36-1f15-4a04-b88e-fc4795e57eb1",
label: "JavaScript",
},
{
id: "34c50dca-9eed-4ade-89d4-9b2ccda52acc",
label: "TypeScript",
},
],
},
{
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
type: "email",
label: "Email",
required: true,
},
{
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
type: "number",
label: "Rating",
required: false,
},
],
},
});
formId = form.id;
// Create test booking
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),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
bookingId = booking.id;
// Create test form response
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler",
formId: form.id,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "Miss Dora Farrell",
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
},
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["ee494e36-1f15-4a04-b88e-fc4795e57eb1", "34c50dca-9eed-4ade-89d4-9b2ccda52acc"],
},
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "Felicity.Schuppe9@gmail.com",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 1,
},
},
routedToBookingUid: booking.uid,
},
});
responseId = formResponse.id;
});
afterEach(async () => {
// Clean up test data in reverse order of creation
if (bookingId) {
await prisma.booking.deleteMany({
where: { id: bookingId },
});
}
if (responseId) {
await prisma.app_RoutingForms_FormResponse.deleteMany({
where: { id: responseId },
});
}
if (formId) {
await prisma.app_RoutingForms_Form.deleteMany({
where: { id: formId },
});
}
if (eventTypeId) {
await prisma.eventType.deleteMany({
where: { id: eventTypeId },
});
}
if (userId) {
await prisma.user.deleteMany({
where: { id: userId },
});
}
});
describe("Creation and Updates", () => {
it("should create denormalized entry when form response is created", async () => {
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
// Verify form fields are properly stored
const formWithFields = await prisma.app_RoutingForms_Form.findUniqueOrThrow({
where: { id: formId },
});
const fields = formWithFields.fields as FormField[];
if (!fields) {
throw new Error("Form fields are required for this test");
}
expect(fields).toHaveLength(5);
expect(fields[0]).toEqual({
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
type: "text",
label: "Manager",
required: true,
});
expect(fields[2].options).toHaveLength(2); // Verify multiselect options
// Verify the rest of the denormalized data
expect(denormalizedResponse.formId).toBe(formId);
expect(denormalizedResponse.formName).toBe("Test Form");
expect(denormalizedResponse.formUserId).toBe(userId);
expect(denormalizedResponse.bookingId).toBe(bookingId);
expect(denormalizedResponse.bookingUserId).toBe(userId);
expect(denormalizedResponse.bookingUserName).toBe("Test User");
expect(denormalizedResponse.bookingUserEmail).toBe(email);
expect(denormalizedResponse.eventTypeId).toBe(eventTypeId);
});
it("should update denormalized entry when form response is updated", async () => {
await prisma.app_RoutingForms_FormResponse.update({
where: { id: responseId },
data: {
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "Updated Manager Name",
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
},
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["ee494e36-1f15-4a04-b88e-fc4795e57eb1"],
},
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "updated.email@example.com",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 2,
},
},
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
include: {
response: true,
},
});
// Verify the response was updated
const formResponse = await prisma.app_RoutingForms_FormResponse.findUniqueOrThrow({
where: { id: responseId },
});
expect(formResponse.response).toEqual(denormalizedResponse.response.response);
});
it("should update denormalized entry when user is updated", async () => {
await prisma.user.update({
where: { id: userId },
data: {
name: "Updated User",
email: "updated@example.com",
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.bookingUserName).toBe("Updated User");
expect(denormalizedResponse.bookingUserEmail).toBe("updated@example.com");
});
it("should update denormalized entry when booking is updated", async () => {
await prisma.booking.update({
where: { id: bookingId },
data: {
status: BookingStatus.CANCELLED,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.bookingStatus).toBe(BookingStatus.CANCELLED);
});
it("should update denormalized entry when event type is updated", async () => {
// Create a parent event type
const parentEventType = await prisma.eventType.create({
data: {
title: "Parent Event",
slug: "parent-event",
length: 60,
userId: userId,
},
});
await prisma.eventType.update({
where: { id: eventTypeId },
data: {
schedulingType: SchedulingType.ROUND_ROBIN,
parentId: parentEventType.id,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.eventTypeId).toBe(eventTypeId);
expect(denormalizedResponse.eventTypeParentId).toBe(parentEventType.id);
expect(denormalizedResponse.eventTypeSchedulingType).toBe("roundRobin");
// Clean up the parent event type
await prisma.eventType.delete({
where: { id: parentEventType.id },
});
});
});
describe("Deletion Scenarios", () => {
it("should delete denormalized entry when form response is deleted", async () => {
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: responseId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should not delete denormalized entry but nullify booking data when booking is deleted", async () => {
await prisma.booking.delete({
where: { id: bookingId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse.bookingId).toBeNull();
expect(denormalizedResponse.bookingStatus).toBeNull();
expect(denormalizedResponse.bookingStartTime).toBeNull();
expect(denormalizedResponse.bookingEndTime).toBeNull();
});
it("should cascade delete denormalized entries when form is deleted", async () => {
await prisma.app_RoutingForms_Form.delete({
where: { id: formId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should cascade delete denormalized entries when user is deleted", async () => {
await prisma.user.delete({
where: { id: userId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should not delete but nullify event type data when event type is deleted", async () => {
await prisma.eventType.delete({
where: { id: eventTypeId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse.eventTypeId).toBeNull();
expect(denormalizedResponse.eventTypeParentId).toBeNull();
});
});
describe("Multiple Records Scenarios", () => {
it("should handle multiple form responses for the same form", async () => {
const response2 = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: formId,
response: {},
},
});
const denormalizedResponse1 = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
const denormalizedResponse2 = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: response2.id },
});
// clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: response2.id },
});
expect(denormalizedResponse1.formId).toBe(formId);
expect(denormalizedResponse2.formId).toBe(formId);
expect(denormalizedResponse1.bookingId).toBe(bookingId);
expect(denormalizedResponse2.bookingId).toBeNull();
expect(denormalizedResponse1.id).not.toBe(denormalizedResponse2.id);
});
});
describe("Edge Cases", () => {
it("should handle null values in optional fields", async () => {
const responseWithNulls = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-nulls",
formId: formId,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: null,
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: null,
},
},
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseWithNulls.id },
});
expect(denormalizedResponse.bookingId).toBeNull();
expect(denormalizedResponse.bookingStatus).toBeNull();
expect(denormalizedResponse.eventTypeId).toBeNull();
});
it("should handle special characters in text fields", async () => {
const specialChars = "Test 'quotes' and \"double quotes\" and \\ backslashes";
await prisma.app_RoutingForms_Form.update({
where: { id: formId },
data: {
name: specialChars,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.formName).toBe(specialChars);
});
});
describe("Data Integrity", () => {
it("should maintain correct relationships across all tables", async () => {
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
include: {
response: true,
booking: true,
},
});
expect(denormalizedResponse.formId).toBe(formId);
expect(denormalizedResponse.bookingId).toBe(bookingId);
expect(denormalizedResponse.response.formId).toBe(formId);
expect(denormalizedResponse.booking?.id).toBe(bookingId);
});
});
describe("Field Filtering and Relationships", () => {
it("should allow filtering by field values including arrays", async () => {
// Create a response with specific field values for testing
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-fields",
formId: formId,
response: {
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["644e7f80-b76b-4a3c-94f7-bf0efa65d3d6", "34c50dca-9eed-4ade-89d4-9b2ccda52acc"],
},
},
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.fields).toBeDefined();
expect(denormalizedResponse?.fields).toHaveLength(1);
// Test filtering by string array (skills)
const arrayFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
fields: {
some: {
fieldId: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
valueStringArray: {
has: "644e7f80-b76b-4a3c-94f7-bf0efa65d3d6",
},
},
},
},
include: {
fields: true,
},
});
expect(arrayFilterResult).toHaveLength(1);
expect(arrayFilterResult[0].fields).toBeDefined();
expect(arrayFilterResult[0].fields).toHaveLength(1);
expect(
arrayFilterResult[0].fields.some(
(field) =>
field.fieldId === "83316968-45bf-4c9d-b5d4-5368a8d2d2a8" &&
field.valueStringArray?.includes("644e7f80-b76b-4a3c-94f7-bf0efa65d3d6")
)
).toBe(true);
// Clean up
await prisma.routingFormResponseField.deleteMany({
where: { responseId: formResponse.id },
});
await prisma.routingFormResponseDenormalized.deleteMany({
where: { id: formResponse.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
it("should allow filtering by string and number field values", async () => {
// Create a response with specific field values for testing
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-fields-2",
formId: formId,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "John Smith",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 5,
},
},
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.fields).toBeDefined();
expect(denormalizedResponse?.fields).toHaveLength(2);
// Test filtering by string value
const stringFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
fields: {
some: {
fieldId: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
valueString: "John Smith",
},
},
},
include: {
fields: true,
},
});
expect(stringFilterResult).toHaveLength(1);
expect(stringFilterResult[0].fields).toBeDefined();
expect(
stringFilterResult[0].fields.some(
(field) =>
field.fieldId === "57734f65-8bbb-4065-9e71-fb7f0b7485f8" && field.valueString === "John Smith"
)
).toBe(true);
// Test filtering by number value
const numberFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
formId,
fields: {
some: {
fieldId: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
valueNumber: 5,
},
},
},
include: {
fields: true,
},
});
expect(numberFilterResult).toHaveLength(1);
expect(numberFilterResult[0].fields).toBeDefined();
expect(
numberFilterResult[0].fields.some(
(field) =>
field.fieldId === "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729" &&
field.valueNumber?.equals(new Prisma.Decimal(5))
)
).toBe(true);
// Clean up
await prisma.routingFormResponseField.deleteMany({
where: { responseId: formResponse.id },
});
await prisma.routingFormResponseDenormalized.deleteMany({
where: { id: formResponse.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
it("should allow filtering by attendee email", async () => {
// Create a booking with attendees
const booking = await prisma.booking.create({
data: {
uid: `booking-denorm-${Math.floor(Math.random() * 1000000)}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: userId,
eventTypeId: eventTypeId,
status: BookingStatus.PENDING,
attendees: {
create: [
{
email: "attendee1@example.com",
name: "Attendee One",
timeZone: "UTC",
},
{
email: "attendee2@example.com",
name: "Attendee Two",
timeZone: "UTC",
},
],
},
},
include: {
attendees: true,
},
});
// Create a form response linked to the booking
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-attendees",
formId: formId,
response: {
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "attendee1@example.com",
},
},
routedToBookingUid: booking.uid,
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.bookingId).toBe(booking.id);
// Test filtering by attendee email
const attendeeFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
booking: {
attendees: {
some: {
email: {
startsWith: "attendee1@",
},
},
},
},
},
include: {
fields: true,
booking: {
include: {
attendees: true,
},
},
},
});
expect(attendeeFilterResult).toHaveLength(1);
expect(attendeeFilterResult[0].booking?.attendees).toBeDefined();
expect(
attendeeFilterResult[0].booking?.attendees.some(
(attendee) => attendee.email === "attendee1@example.com"
)
).toBe(true);
// Clean up
await prisma.booking.delete({
where: { id: booking.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
});
});
@@ -0,0 +1,93 @@
-- CreateTable
CREATE TABLE "RoutingFormResponseField" (
"id" SERIAL NOT NULL,
"responseId" INTEGER NOT NULL,
"fieldId" TEXT NOT NULL,
"valueString" TEXT,
"valueNumber" DECIMAL(65,30),
"valueStringArray" TEXT[],
CONSTRAINT "RoutingFormResponseField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RoutingFormResponseDenormalized" (
"id" INTEGER NOT NULL,
"formId" TEXT NOT NULL,
"formName" TEXT NOT NULL,
"formTeamId" INTEGER,
"formUserId" INTEGER NOT NULL,
"bookingUid" TEXT,
"bookingId" INTEGER,
"bookingStatus" "BookingStatus",
"bookingStatusOrder" INTEGER,
"bookingCreatedAt" TIMESTAMP(3),
"bookingStartTime" TIMESTAMP(3),
"bookingEndTime" TIMESTAMP(3),
"bookingUserId" INTEGER,
"bookingUserName" TEXT,
"bookingUserEmail" TEXT,
"bookingUserAvatarUrl" TEXT,
"bookingAssignmentReason" TEXT,
"eventTypeId" INTEGER,
"eventTypeParentId" INTEGER,
"eventTypeSchedulingType" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"utm_term" TEXT,
"utm_content" TEXT,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "RoutingFormResponseField" ADD CONSTRAINT "RoutingFormResponseField_response_fkey" FOREIGN KEY ("responseId") REFERENCES "App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Add index for valueString (lowercase)
CREATE INDEX "RoutingFormResponseField_valueString_idx" ON "RoutingFormResponseField" (LOWER("valueString"));
-- CreateIndex
CREATE INDEX "RoutingFormResponseField_responseId_idx" ON "RoutingFormResponseField"("responseId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseField_fieldId_idx" ON "RoutingFormResponseField"("fieldId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseField_valueNumber_idx" ON "RoutingFormResponseField"("valueNumber");
-- CreateIndex
CREATE INDEX "RoutingFormResponseField_valueStringArray_idx" ON "RoutingFormResponseField" USING GIN ("valueStringArray");
-- Add index for bookingAssignmentReason (lowercase)
CREATE INDEX "RoutingFormResponseDenormalized_bookingAssignmentReason_idx" ON "RoutingFormResponseDenormalized" (LOWER("bookingAssignmentReason"));
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_formId_idx" ON "RoutingFormResponseDenormalized"("formId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_formTeamId_idx" ON "RoutingFormResponseDenormalized"("formTeamId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_formUserId_idx" ON "RoutingFormResponseDenormalized"("formUserId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_formId_createdAt_idx" ON "RoutingFormResponseDenormalized"("formId", "createdAt");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_bookingId_idx" ON "RoutingFormResponseDenormalized"("bookingId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_bookingUserId_idx" ON "RoutingFormResponseDenormalized"("bookingUserId");
-- CreateIndex
CREATE INDEX "RoutingFormResponseDenormalized_eventTypeId_eventTypeParent_idx" ON "RoutingFormResponseDenormalized"("eventTypeId", "eventTypeParentId");
-- AddForeignKey
ALTER TABLE "RoutingFormResponseDenormalized" ADD CONSTRAINT "RoutingFormResponseDenormalized_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutingFormResponseDenormalized" ADD CONSTRAINT "RoutingFormResponseDenormalized_id_fkey" FOREIGN KEY ("id") REFERENCES "App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoutingFormResponseDenormalized" ADD CONSTRAINT "RoutingFormResponseDenormalized_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,303 @@
-- Function to calculate bookingStatusOrder
CREATE OR REPLACE FUNCTION calculate_booking_status_order(status TEXT)
RETURNS INTEGER AS $$
BEGIN
RETURN CASE status
WHEN 'accepted' THEN 1
WHEN 'pending' THEN 2
WHEN 'awaiting_host' THEN 3
WHEN 'cancelled' THEN 4
WHEN 'rejected' THEN 5
ELSE 999 -- Default to end of sort order for unknown statuses
END;
END;
$$ LANGUAGE plpgsql;
-- Function to refresh a single form response's data
CREATE OR REPLACE FUNCTION refresh_routing_form_response_denormalized(response_id INTEGER)
RETURNS VOID AS $$
BEGIN
-- Delete existing entry if any
DELETE FROM "RoutingFormResponseDenormalized" WHERE id = response_id;
-- Insert form response with all related data
INSERT INTO "RoutingFormResponseDenormalized" (
id,
"formId",
"formName",
"formTeamId",
"formUserId",
"bookingUid",
"bookingId",
"bookingStatus",
"bookingStatusOrder",
"bookingCreatedAt",
"bookingStartTime",
"bookingEndTime",
"bookingUserId",
"bookingUserName",
"bookingUserEmail",
"bookingUserAvatarUrl",
"bookingAssignmentReason",
"eventTypeId",
"eventTypeParentId",
"eventTypeSchedulingType",
"createdAt",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content"
)
SELECT
r.id,
r."formId",
f.name as "formName",
f."teamId" as "formTeamId",
f."userId" as "formUserId",
b.uid as "bookingUid",
b.id as "bookingId",
b.status as "bookingStatus",
calculate_booking_status_order(b.status::text) as "bookingStatusOrder",
b."createdAt" as "bookingCreatedAt",
b."startTime" as "bookingStartTime",
b."endTime" as "bookingEndTime",
b."userId" as "bookingUserId",
u.name as "bookingUserName",
u.email as "bookingUserEmail",
u."avatarUrl" as "bookingUserAvatarUrl",
COALESCE(
(
SELECT ar."reasonString"
FROM "AssignmentReason" ar
WHERE ar."bookingId" = b.id
LIMIT 1
),
''
) as "bookingAssignmentReason",
et.id as "eventTypeId",
et."parentId" as "eventTypeParentId",
et."schedulingType" as "eventTypeSchedulingType",
r."createdAt",
t.utm_source,
t.utm_medium,
t.utm_campaign,
t.utm_term,
t.utm_content
FROM "App_RoutingForms_FormResponse" r
INNER JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
INNER JOIN "users" u ON f."userId" = u.id
LEFT JOIN "Booking" b ON b.uid = r."routedToBookingUid"
LEFT JOIN "EventType" et ON b."eventTypeId" = et.id
LEFT JOIN "Tracking" t ON t."bookingId" = b.id
WHERE r.id = response_id;
END;
$$ LANGUAGE plpgsql;
-- Trigger function for form response changes (insert/update)
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized()
RETURNS TRIGGER AS $$
BEGIN
BEGIN
PERFORM refresh_routing_form_response_denormalized(NEW.id);
EXCEPTION WHEN OTHERS THEN
-- Log the error but don't fail the original operation
RAISE WARNING 'DENORM_ERROR: RoutingFormResponseDenormalized - refresh failed for response_id %', NEW.id;
END;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger function for form response deletions
CREATE OR REPLACE FUNCTION trigger_delete_routing_form_response_denormalized()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM "RoutingFormResponseDenormalized" WHERE id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create triggers for App_RoutingForms_FormResponse table
CREATE TRIGGER routing_form_response_insert_update_trigger
AFTER INSERT OR UPDATE ON "App_RoutingForms_FormResponse"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized();
CREATE TRIGGER routing_form_response_delete_trigger
AFTER DELETE ON "App_RoutingForms_FormResponse"
FOR EACH ROW
EXECUTE FUNCTION trigger_delete_routing_form_response_denormalized();
-- Trigger function for form name changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_form_name()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses for this form's name
UPDATE "RoutingFormResponseDenormalized" rfrd
SET "formName" = NEW.name
WHERE rfrd."formId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger function for form team changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_form_team()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses for this form's team
UPDATE "RoutingFormResponseDenormalized" rfrd
SET "formTeamId" = NEW."teamId"
WHERE rfrd."formId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger function for form user changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_form_user()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses for this form's user
UPDATE "RoutingFormResponseDenormalized" rfrd
SET "formUserId" = NEW."userId"
WHERE rfrd."formId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create separate triggers for App_RoutingForms_Form table
CREATE TRIGGER routing_form_name_update_trigger
AFTER UPDATE OF name ON "App_RoutingForms_Form"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_form_name();
CREATE TRIGGER routing_form_team_update_trigger
AFTER UPDATE OF "teamId" ON "App_RoutingForms_Form"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_form_team();
CREATE TRIGGER routing_form_user_update_trigger
AFTER UPDATE OF "userId" ON "App_RoutingForms_Form"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_form_user();
-- Trigger function for booking changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_booking()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses linked to this booking
UPDATE "RoutingFormResponseDenormalized" rfrd
SET
"bookingStatus" = NEW.status,
"bookingStatusOrder" = calculate_booking_status_order(NEW.status::text),
"bookingCreatedAt" = NEW."createdAt",
"bookingStartTime" = NEW."startTime",
"bookingEndTime" = NEW."endTime"
WHERE rfrd."bookingId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for Booking table
CREATE TRIGGER booking_update_trigger_for_routing_form
AFTER UPDATE OF status, "createdAt", "startTime", "endTime" ON "Booking"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_booking();
-- Trigger function for user changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_user()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses where this user is the booking user
UPDATE "RoutingFormResponseDenormalized" rfrd
SET
"bookingUserName" = NEW.name,
"bookingUserEmail" = NEW.email,
"bookingUserAvatarUrl" = NEW."avatarUrl"
WHERE rfrd."bookingUserId" = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for users table
CREATE TRIGGER user_update_trigger_for_routing_form
AFTER UPDATE OF name, email, "avatarUrl" ON users
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_user();
-- Trigger function for EventType changes
CREATE OR REPLACE FUNCTION trigger_refresh_routing_form_response_denormalized_event_type()
RETURNS TRIGGER AS $$
BEGIN
-- Update all responses linked to this event type through bookings
UPDATE "RoutingFormResponseDenormalized" rfrd
SET
"eventTypeParentId" = NEW."parentId",
"eventTypeSchedulingType" = NEW."schedulingType"
FROM "Booking" b
WHERE b."eventTypeId" = NEW.id
AND rfrd."bookingId" = b.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for EventType table
CREATE TRIGGER event_type_update_trigger_for_routing_form
AFTER UPDATE OF "parentId", "schedulingType" ON "EventType"
FOR EACH ROW
EXECUTE FUNCTION trigger_refresh_routing_form_response_denormalized_event_type();
-- Trigger function for form deletions
CREATE OR REPLACE FUNCTION trigger_cleanup_routing_form_response_denormalized_form()
RETURNS TRIGGER AS $$
BEGIN
-- Delete all denormalized responses for this form
DELETE FROM "RoutingFormResponseDenormalized"
WHERE "formId" = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create DELETE trigger for App_RoutingForms_Form table
CREATE TRIGGER routing_form_delete_trigger
AFTER DELETE ON "App_RoutingForms_Form"
FOR EACH ROW
EXECUTE FUNCTION trigger_cleanup_routing_form_response_denormalized_form();
-- Trigger function for user deletions
CREATE OR REPLACE FUNCTION trigger_cleanup_routing_form_response_denormalized_user()
RETURNS TRIGGER AS $$
BEGIN
-- Delete all responses where this user was the booking user
DELETE FROM "RoutingFormResponseDenormalized"
WHERE "bookingUserId" = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create DELETE trigger for users table
CREATE TRIGGER user_delete_trigger_for_routing_form
AFTER DELETE ON users
FOR EACH ROW
EXECUTE FUNCTION trigger_cleanup_routing_form_response_denormalized_user();
-- Note: Booking deletions are handled by foreign key constraint with ON DELETE CASCADE
-- Function to nullify event type data in denormalized table when event type is deleted
CREATE OR REPLACE FUNCTION trigger_nullify_routing_form_response_denormalized_event_type()
RETURNS TRIGGER AS $$
BEGIN
UPDATE "RoutingFormResponseDenormalized"
SET
"eventTypeId" = NULL,
"eventTypeParentId" = NULL,
"eventTypeSchedulingType" = NULL
WHERE "eventTypeId" = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Trigger to nullify event type data before event type deletion
CREATE TRIGGER trigger_nullify_routing_form_response_denormalized_event_type
BEFORE DELETE ON "EventType"
FOR EACH ROW
EXECUTE FUNCTION trigger_nullify_routing_form_response_denormalized_event_type();
@@ -0,0 +1,142 @@
-- Create function to handle form response field denormalization
CREATE OR REPLACE FUNCTION handle_routing_form_response_fields()
RETURNS TRIGGER AS $$
DECLARE
form_fields jsonb;
field_record jsonb;
response_field jsonb;
field_type text;
response_data jsonb;
form_id text;
BEGIN
-- For INSERT trigger on RoutingFormResponseDenormalized, we need to get the response data from App_RoutingForms_FormResponse
-- For UPDATE trigger on App_RoutingForms_FormResponse, we can use NEW.response directly
IF TG_TABLE_NAME = 'RoutingFormResponseDenormalized' THEN
SELECT response, "formId" INTO response_data, form_id
FROM "App_RoutingForms_FormResponse"
WHERE id = NEW.id;
ELSE
response_data := NEW.response::jsonb;
form_id := NEW."formId";
END IF;
-- Validate response_data exists and is a valid JSON object
IF response_data IS NULL OR jsonb_typeof(response_data) != 'object' THEN
RAISE WARNING 'Invalid response data for id %. Type: %', NEW.id, COALESCE(jsonb_typeof(response_data), 'null');
RETURN NEW;
END IF;
-- Get the fields from App_RoutingForms_Form
SELECT fields::jsonb INTO form_fields
FROM "App_RoutingForms_Form"
WHERE id = form_id;
-- Delete existing entries for this response
DELETE FROM "RoutingFormResponseField"
WHERE "responseId" = NEW.id;
-- Exit early if form_fields is NULL or not an array
IF form_fields IS NULL OR jsonb_typeof(form_fields) != 'array' THEN
RAISE WARNING 'form_fields is NULL or not an array for formId %, skipping processing', form_id;
RETURN NEW;
END IF;
-- Iterate through each field in the response
FOR field_record IN SELECT * FROM jsonb_array_elements(form_fields)
LOOP
BEGIN
-- Validate field_record has required properties
IF field_record->>'id' IS NULL THEN
RAISE WARNING 'Field record is missing id property, skipping field';
CONTINUE;
END IF;
IF field_record->>'type' IS NULL THEN
RAISE WARNING 'Field record % is missing type property, skipping field', field_record->>'id';
CONTINUE;
END IF;
-- Get the response field object for this field
response_field := response_data->(field_record->>'id');
-- Skip if no response for this field
IF response_field IS NULL THEN
CONTINUE;
END IF;
-- Get field type
field_type := field_record->>'type';
-- Insert new record based on field type
IF field_type = 'multiselect' THEN
-- Handle array values for multiselect
-- Only insert if value is not null and is a valid JSON array
IF response_field->>'value' IS NOT NULL AND jsonb_typeof(response_field->'value') = 'array' THEN
BEGIN
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueStringArray")
VALUES (
NEW.id,
field_record->>'id',
ARRAY(SELECT jsonb_array_elements_text(response_field->'value'))
);
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert multiselect values for field %', field_record->>'id';
END;
END IF;
ELSIF field_type = 'number' THEN
-- Handle number values
-- Only insert if value is not null and is a valid number
IF response_field->>'value' IS NOT NULL AND jsonb_typeof(response_field->'value') = 'number' THEN
BEGIN
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueNumber")
VALUES (
NEW.id,
field_record->>'id',
(response_field->>'value')::decimal
);
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert number value for field %', field_record->>'id';
END;
END IF;
ELSE
-- Handle all other types as strings
-- Only insert if value is not null and is a valid string
IF response_field->>'value' IS NOT NULL AND jsonb_typeof(response_field->'value') = 'string' THEN
BEGIN
INSERT INTO "RoutingFormResponseField" ("responseId", "fieldId", "valueString")
VALUES (
NEW.id,
field_record->>'id',
response_field->>'value'
);
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to insert string value for field %', field_record->>'id';
END;
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN
-- Log the error and continue processing other fields
RAISE WARNING 'Error processing field %', field_record->>'id';
CONTINUE;
END;
END LOOP;
RETURN NEW;
EXCEPTION WHEN OTHERS THEN
-- Log any unhandled errors and continue
RAISE WARNING 'Unhandled error in handle_routing_form_response_fields for response %', NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for INSERT on RoutingFormResponseDenormalized
CREATE TRIGGER routing_form_response_denormalized_insert_trigger
AFTER INSERT ON "RoutingFormResponseDenormalized"
FOR EACH ROW
EXECUTE FUNCTION handle_routing_form_response_fields();
-- Create trigger for UPDATE on App_RoutingForms_FormResponse
CREATE TRIGGER routing_form_response_update_trigger
AFTER UPDATE OF response ON "App_RoutingForms_FormResponse"
FOR EACH ROW
EXECUTE FUNCTION handle_routing_form_response_fields();
+79 -19
View File
@@ -684,17 +684,17 @@ enum BookingStatus {
}
model Booking {
id Int @id @default(autoincrement())
uid String @unique
id Int @id @default(autoincrement())
uid String @unique
// (optional) UID based on slot start/end time & email against duplicates
idempotencyKey String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
idempotencyKey String? @unique
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
// User's email at the time of booking
/// @zod.custom(imports.emailSchema)
userPrimaryEmail String?
references BookingReference[]
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
title String
description String?
@@ -705,17 +705,17 @@ model Booking {
endTime DateTime
attendees Attendee[]
location String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
status BookingStatus @default(ACCEPTED)
paid Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
status BookingStatus @default(ACCEPTED)
paid Boolean @default(false)
payment Payment[]
destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id])
destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id])
destinationCalendarId Int?
cancellationReason String?
rejectionReason String?
reassignReason String?
reassignBy User? @relation("reassignByUser", fields: [reassignById], references: [id])
reassignBy User? @relation("reassignByUser", fields: [reassignById], references: [id])
reassignById Int?
dynamicEventSlugRef String?
dynamicGroupSlugRef String?
@@ -728,15 +728,15 @@ model Booking {
seatsReferences BookingSeat[]
/// @zod.custom(imports.bookingMetadataSchema)
metadata Json?
isRecorded Boolean @default(false)
iCalUID String? @default("")
iCalSequence Int @default(0)
isRecorded Boolean @default(false)
iCalUID String? @default("")
iCalSequence Int @default(0)
instantMeetingToken InstantMeetingToken?
rating Int?
ratingFeedback String?
noShowHost Boolean? @default(false)
noShowHost Boolean? @default(false)
scheduledTriggers WebhookScheduledTriggers[]
oneTimePassword String? @unique @default(uuid())
oneTimePassword String? @unique @default(uuid())
/// @zod.email()
cancelledBy String?
/// @zod.email()
@@ -747,6 +747,7 @@ model Booking {
internalNote BookingInternalNote[]
creationSource CreationSource?
tracking Tracking?
routingFormResponses RoutingFormResponseDenormalized[]
expenseLogs CreditExpenseLog[]
@@index([eventTypeId])
@@ -1116,14 +1117,33 @@ model App_RoutingForms_FormResponse {
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
routedToBookingUid String? @unique
routedToBookingUid String? @unique
// We should not cascade delete the booking, because we want to keep the form response even if the routedToBooking is deleted
routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid])
chosenRouteId String?
routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid])
chosenRouteId String?
routingFormResponseFields RoutingFormResponseField[]
routingFormResponses RoutingFormResponseDenormalized[]
@@unique([formFillerId, formId])
@@index([formFillerId])
@@index([formId])
@@index([routedToBookingUid])
}
model RoutingFormResponseField {
id Int @id @default(autoincrement())
responseId Int?
fieldId String
valueString String?
valueNumber Decimal?
valueStringArray String[]
response App_RoutingForms_FormResponse? @relation(fields: [responseId], references: [id], map: "RoutingFormResponseField_response_fkey")
denormalized RoutingFormResponseDenormalized? @relation("DenormalizedResponseToFields", fields: [responseId], references: [id])
@@index([responseId])
@@index([fieldId])
@@index([valueNumber])
@@index([valueStringArray], type: Gin)
}
view RoutingFormResponse {
@@ -1155,6 +1175,46 @@ view RoutingFormResponse {
utm_content String?
}
model RoutingFormResponseDenormalized {
id Int @id
formId String
formName String
formTeamId Int?
formUserId Int
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
bookingUid String?
bookingId Int?
bookingStatus BookingStatus?
bookingStatusOrder Int?
bookingCreatedAt DateTime? @db.Timestamp(3)
bookingStartTime DateTime? @db.Timestamp(3)
bookingEndTime DateTime? @db.Timestamp(3)
bookingUserId Int?
bookingUserName String?
bookingUserEmail String?
bookingUserAvatarUrl String?
bookingAssignmentReason String?
eventTypeId Int?
eventTypeParentId Int?
eventTypeSchedulingType String?
createdAt DateTime @db.Timestamp(3)
utm_source String?
utm_medium String?
utm_campaign String?
utm_term String?
utm_content String?
response App_RoutingForms_FormResponse @relation(fields: [id], references: [id], onDelete: Cascade)
fields RoutingFormResponseField[] @relation("DenormalizedResponseToFields")
@@index([formId])
@@index([formTeamId])
@@index([formUserId])
@@index([formId, createdAt])
@@index([bookingId])
@@index([bookingUserId])
@@index([eventTypeId, eventTypeParentId])
}
model Feedback {
id Int @id @default(autoincrement())
date DateTime @default(now())