diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts index a942dd96521f..6de6454beac5 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts @@ -52,54 +52,41 @@ export const getPersonSegmentIds = async ( return []; } - // Phase 1: Build all Prisma where clauses concurrently. - // This converts segment filters into where clauses without per-contact DB queries. - const segmentWithClauses = await Promise.all( - segments.map(async (segment) => { - const filters = segment.filters as TBaseFilters | null; - - if (!filters || filters.length === 0) { - return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput }; - } + // Phase 1: Build WHERE clauses sequentially to avoid connection pool contention. + // segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks), + // so running all builds concurrently would saturate the pool. + const alwaysMatchIds: string[] = []; + const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = []; - const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType); + for (const segment of segments) { + const filters = segment.filters as TBaseFilters; - if (!queryResult.ok) { - logger.warn( - { segmentId: segment.id, environmentId, error: queryResult.error }, - "Failed to build Prisma query for segment" - ); - return { segmentId: segment.id, whereClause: null }; - } - - return { segmentId: segment.id, whereClause: queryResult.data.whereClause }; - }) - ); + if (!filters?.length) { + alwaysMatchIds.push(segment.id); + continue; + } - // Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build - const alwaysMatchIds: string[] = []; - const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = []; + const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType); - for (const item of segmentWithClauses) { - if (item.whereClause === null) { + if (!queryResult.ok) { + logger.warn( + { segmentId: segment.id, environmentId, error: queryResult.error }, + "Failed to build Prisma query for segment, skipping" + ); continue; } - if (Object.keys(item.whereClause).length === 0) { - alwaysMatchIds.push(item.segmentId); - } else { - toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause }); - } + dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause }); } - if (toCheck.length === 0) { + if (dbChecks.length === 0) { return alwaysMatchIds; } - // Phase 2: Batch all contact-match checks into a single DB transaction. - // Replaces N individual findFirst queries with one batched round-trip. - const batchResults = await prisma.$transaction( - toCheck.map(({ whereClause }) => + // Phase 2: Execute all membership checks in a single transaction. + // Uses one connection instead of N concurrent ones, eliminating pool contention. + const txResults = await prisma.$transaction( + dbChecks.map(({ whereClause }) => prisma.contact.findFirst({ where: { id: contactId, ...whereClause }, select: { id: true }, @@ -107,17 +94,12 @@ export const getPersonSegmentIds = async ( ) ); - // Phase 3: Collect matching segment IDs - const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId); + const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId); - return [...alwaysMatchIds, ...dbMatchIds]; + return [...alwaysMatchIds, ...matchedIds]; } catch (error) { logger.warn( - { - environmentId, - contactId, - error, - }, + { environmentId, contactId, error }, "Failed to get person segment IDs, returning empty array" ); return []; diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts deleted file mode 100644 index 380678c18dc6..000000000000 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { prisma } from "@formbricks/database"; -import { TJsPersonState } from "@formbricks/types/js"; -import { getPersonSegmentIds } from "./segments"; -import { getUserState } from "./user-state"; - -vi.mock("@formbricks/database", () => ({ - prisma: { - contact: { - findUniqueOrThrow: vi.fn(), - }, - }, -})); - -vi.mock("./segments", () => ({ - getPersonSegmentIds: vi.fn(), -})); - -const mockEnvironmentId = "test-environment-id"; -const mockUserId = "test-user-id"; -const mockContactId = "test-contact-id"; -const mockDevice = "desktop"; - -describe("getUserState", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - test("should return user state with empty responses and displays", async () => { - const mockContactData = { - id: mockContactId, - responses: [], - displays: [], - }; - vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any); - vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]); - - const result = await getUserState({ - environmentId: mockEnvironmentId, - userId: mockUserId, - contactId: mockContactId, - device: mockDevice, - }); - - expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: mockContactId }, - select: { - id: true, - responses: { - select: { surveyId: true }, - }, - displays: { - select: { surveyId: true, createdAt: true }, - orderBy: { createdAt: "desc" }, - }, - }, - }); - expect(getPersonSegmentIds).toHaveBeenCalledWith( - mockEnvironmentId, - mockContactId, - mockUserId, - mockDevice - ); - expect(result).toEqual({ - contactId: mockContactId, - userId: mockUserId, - segments: ["segment1"], - displays: [], - responses: [], - lastDisplayAt: null, - }); - }); - - test("should return user state with responses and displays, and sort displays by createdAt", async () => { - const mockDate1 = new Date("2023-01-01T00:00:00.000Z"); - const mockDate2 = new Date("2023-01-02T00:00:00.000Z"); - - const mockContactData = { - id: mockContactId, - responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }], - displays: [ - { surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc) - { surveyId: "survey3", createdAt: mockDate1 }, - ], - }; - vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any); - vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]); - - const result = await getUserState({ - environmentId: mockEnvironmentId, - userId: mockUserId, - contactId: mockContactId, - device: mockDevice, - }); - - expect(result).toEqual({ - contactId: mockContactId, - userId: mockUserId, - segments: ["segment2", "segment3"], - displays: [ - { surveyId: "survey4", createdAt: mockDate2 }, - { surveyId: "survey3", createdAt: mockDate1 }, - ], - responses: ["survey1", "survey2"], - lastDisplayAt: mockDate2, - }); - }); - - test("should handle empty arrays from prisma", async () => { - // This case tests with proper empty arrays instead of null - const mockContactData = { - id: mockContactId, - responses: [], - displays: [], - }; - vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any); - vi.mocked(getPersonSegmentIds).mockResolvedValue([]); - - const result = await getUserState({ - environmentId: mockEnvironmentId, - userId: mockUserId, - contactId: mockContactId, - device: mockDevice, - }); - - expect(result).toEqual({ - contactId: mockContactId, - userId: mockUserId, - segments: [], - displays: [], - responses: [], - lastDisplayAt: null, - }); - }); -}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts deleted file mode 100644 index 7c224bcf5264..000000000000 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { prisma } from "@formbricks/database"; -import { TJsPersonState } from "@formbricks/types/js"; -import { getPersonSegmentIds } from "./segments"; - -/** - * Optimized single query to get all user state data - * Replaces multiple separate queries with one efficient query - */ -const getUserStateDataOptimized = async (contactId: string) => { - return prisma.contact.findUniqueOrThrow({ - where: { id: contactId }, - select: { - id: true, - responses: { - select: { surveyId: true }, - }, - displays: { - select: { - surveyId: true, - createdAt: true, - }, - orderBy: { createdAt: "desc" }, - }, - }, - }); -}; - -/** - * Optimized user state fetcher without caching - * Uses single database query and efficient data processing - * NO CACHING - user state changes frequently with contact updates - * - * @param environmentId - The environment id - * @param userId - The user id - * @param device - The device type - * @returns The person state - * @throws {ValidationError} - If the input is invalid - * @throws {ResourceNotFoundError} - If the environment or organization is not found - */ -export const getUserState = async ({ - environmentId, - userId, - contactId, - device, -}: { - environmentId: string; - userId: string; - contactId: string; - device: "phone" | "desktop"; -}): Promise => { - // Single optimized query for all contact data - const contactData = await getUserStateDataOptimized(contactId); - - // Get segments using Prisma-based evaluation (no attributes needed - fetched from DB) - const segments = await getPersonSegmentIds(environmentId, contactId, userId, device); - - // Process displays efficiently - const displays = (contactData.displays ?? []).map((display) => ({ - surveyId: display.surveyId, - createdAt: display.createdAt, - })); - - // Get latest display date - const lastDisplayAt = - contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null; - - // Process responses efficiently - const responses = (contactData.responses ?? []).map((response) => response.surveyId); - - const userState: TJsPersonState["data"] = { - contactId, - userId, - segments, - displays, - responses, - lastDisplayAt, - }; - - return userState; -};