diff --git a/packages/features/insights/__tests__/routing-form-response-denormalized.integration-test.ts b/packages/features/insights/__tests__/routing-form-response-denormalized.integration-test.ts new file mode 100644 index 0000000000..10113deb7a --- /dev/null +++ b/packages/features/insights/__tests__/routing-form-response-denormalized.integration-test.ts @@ -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 }, + }); + }); + }); +}); diff --git a/packages/prisma/migrations/20250502113653_create_denormalized_tables_for_routing_form_response/migration.sql b/packages/prisma/migrations/20250502113653_create_denormalized_tables_for_routing_form_response/migration.sql new file mode 100644 index 0000000000..580074b667 --- /dev/null +++ b/packages/prisma/migrations/20250502113653_create_denormalized_tables_for_routing_form_response/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20250502113828_setup_triggers_for_routing_form_response_denormalized/migration.sql b/packages/prisma/migrations/20250502113828_setup_triggers_for_routing_form_response_denormalized/migration.sql new file mode 100644 index 0000000000..dc083da17e --- /dev/null +++ b/packages/prisma/migrations/20250502113828_setup_triggers_for_routing_form_response_denormalized/migration.sql @@ -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(); \ No newline at end of file diff --git a/packages/prisma/migrations/20250502114245_setup_triggers_for_routing_form_response_field/migration.sql b/packages/prisma/migrations/20250502114245_setup_triggers_for_routing_form_response_field/migration.sql new file mode 100644 index 0000000000..2baf976dd4 --- /dev/null +++ b/packages/prisma/migrations/20250502114245_setup_triggers_for_routing_form_response_field/migration.sql @@ -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(); + diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c206cdcf8d..1582bfbf22 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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())