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:
+730
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+93
@@ -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;
|
||||
+303
@@ -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();
|
||||
+142
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user