diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 58ff18ca72ad..9f0c8267d18b 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -609,7 +609,6 @@ checksums: environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09 environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8 environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c - environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86 environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26 environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index cfbfc00f55cf..96a4f1f239ab 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Kontakte aktualisieren", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "create_attribute": "Attribut erstellen", - "create_key": "Schlüssel erstellen", "create_new_attribute": "Neues Attribut erstellen", "create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.", "custom_attributes": "Benutzerdefinierte Attribute", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f13136508ab5..6e348929e7fb 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Refresh contacts", "contacts_table_refresh_success": "Contacts refreshed successfully", "create_attribute": "Create attribute", - "create_key": "Create Key", "create_new_attribute": "Create new attribute", "create_new_attribute_description": "Create a new attribute for segmentation purposes.", "custom_attributes": "Custom Attributes", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index f89841b356d4..0f03d18d7c4f 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Actualizar contactos", "contacts_table_refresh_success": "Contactos actualizados correctamente", "create_attribute": "Crear atributo", - "create_key": "Crear clave", "create_new_attribute": "Crear atributo nuevo", "create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.", "custom_attributes": "Atributos personalizados", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index c3e112b1079c..6d2c8ce4eb4b 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Actualiser les contacts", "contacts_table_refresh_success": "Contacts rafraîchis avec succès", "create_attribute": "Créer un attribut", - "create_key": "Créer une clé", "create_new_attribute": "Créer un nouvel attribut", "create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.", "custom_attributes": "Attributs personnalisés", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 998e011c5702..c2e111c2d550 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Partnerek frissítése", "contacts_table_refresh_success": "A partnerek sikeresen frissítve", "create_attribute": "Attribútum létrehozása", - "create_key": "Kulcs létrehozása", "create_new_attribute": "Új attribútum létrehozása", "create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.", "custom_attributes": "Egyéni attribútumok", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 86d17c9c7179..2dec2f0bccd4 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "連絡先を更新", "contacts_table_refresh_success": "連絡先を正常に更新しました", "create_attribute": "属性を作成", - "create_key": "キーを作成", "create_new_attribute": "新しい属性を作成", "create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。", "custom_attributes": "カスタム属性", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 5ca917bcc591..fd9bf7016cde 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Vernieuw contacten", "contacts_table_refresh_success": "Contacten zijn vernieuwd", "create_attribute": "Attribuut aanmaken", - "create_key": "Sleutel aanmaken", "create_new_attribute": "Nieuw attribuut aanmaken", "create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.", "custom_attributes": "Aangepaste kenmerken", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index fa64404abdd4..bed907d46f75 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Atualizar contatos", "contacts_table_refresh_success": "Contatos atualizados com sucesso", "create_attribute": "Criar atributo", - "create_key": "Criar chave", "create_new_attribute": "Criar novo atributo", "create_new_attribute_description": "Crie um novo atributo para fins de segmentação.", "custom_attributes": "Atributos personalizados", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 69c21aef2a98..f94ad2fbf905 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Atualizar contactos", "contacts_table_refresh_success": "Contactos atualizados com sucesso", "create_attribute": "Criar atributo", - "create_key": "Criar chave", "create_new_attribute": "Criar novo atributo", "create_new_attribute_description": "Crie um novo atributo para fins de segmentação.", "custom_attributes": "Atributos personalizados", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index adfcec9f9f8a..ebb89f1834bb 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Reîmprospătare contacte", "contacts_table_refresh_success": "Contactele au fost actualizate cu succes", "create_attribute": "Creează atribut", - "create_key": "Creează cheie", "create_new_attribute": "Creează atribut nou", "create_new_attribute_description": "Creează un atribut nou pentru segmentare.", "custom_attributes": "Atribute personalizate", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 19453abc050b..85e7d5e99e48 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Обновить контакты", "contacts_table_refresh_success": "Контакты успешно обновлены", "create_attribute": "Создать атрибут", - "create_key": "Создать ключ", "create_new_attribute": "Создать новый атрибут", "create_new_attribute_description": "Создайте новый атрибут для целей сегментации.", "custom_attributes": "Пользовательские атрибуты", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 46f89842a2a8..fdd955839478 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "Uppdatera kontakter", "contacts_table_refresh_success": "Kontakter uppdaterade", "create_attribute": "Skapa attribut", - "create_key": "Skapa nyckel", "create_new_attribute": "Skapa nytt attribut", "create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.", "custom_attributes": "Anpassade attribut", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index df8e25354a08..2d654d64d435 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "刷新 联系人", "contacts_table_refresh_success": "联系人 已成功刷新", "create_attribute": "创建属性", - "create_key": "创建键", "create_new_attribute": "创建新属性", "create_new_attribute_description": "为细分目的创建新属性。", "custom_attributes": "自定义属性", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 9d7fd746c39e..f3ed0e032963 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -645,7 +645,6 @@ "contacts_table_refresh": "重新整理聯絡人", "contacts_table_refresh_success": "聯絡人已成功重新整理", "create_attribute": "建立屬性", - "create_key": "建立金鑰", "create_new_attribute": "建立新屬性", "create_new_attribute_description": "建立新屬性以進行分群用途。", "custom_attributes": "自訂屬性", diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts index 9f5d4f7fb053..e01aa90e0954 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts @@ -7,10 +7,9 @@ import { validateInputs } from "@/lib/utils/validate"; import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query"; import { getPersonSegmentIds, getSegments } from "./segments"; -// Mock the cache functions vi.mock("@/lib/cache", () => ({ cache: { - withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests + withCache: vi.fn(async (fn) => await fn()), }, })); @@ -30,15 +29,15 @@ vi.mock("@formbricks/database", () => ({ contact: { findFirst: vi.fn(), }, + $transaction: vi.fn(), }, })); -// Mock React cache vi.mock("react", async () => { const actual = await vi.importActual("react"); return { ...actual, - cache: any>(fn: T): T => fn, // Return the function with the same type signature + cache: any>(fn: T): T => fn, }; }); @@ -97,22 +96,20 @@ describe("segments lib", () => { }); describe("getPersonSegmentIds", () => { + const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] }; + beforeEach(() => { vi.mocked(prisma.segment.findMany).mockResolvedValue( mockSegmentsData as Prisma.Result ); vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({ ok: true, - data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } }, + data: { whereClause: mockWhereClause }, }); }); test("should return person segment IDs successfully", async () => { - vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result< - typeof prisma.contact, - unknown, - "findFirst" - >); + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]); const result = await getPersonSegmentIds( mockEnvironmentId, @@ -128,12 +125,12 @@ describe("segments lib", () => { }); expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length); - expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(result).toEqual(mockSegmentsData.map((s) => s.id)); }); test("should return empty array if no segments exist", async () => { - vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments + vi.mocked(prisma.segment.findMany).mockResolvedValue([]); const result = await getPersonSegmentIds( mockEnvironmentId, @@ -144,10 +141,11 @@ describe("segments lib", () => { expect(result).toEqual([]); expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); }); test("should return empty array if segments exist but none match", async () => { - vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + vi.mocked(prisma.$transaction).mockResolvedValue([null, null]); const result = await getPersonSegmentIds( mockEnvironmentId, @@ -155,16 +153,14 @@ describe("segments lib", () => { mockContactUserId, mockDeviceType ); + expect(result).toEqual([]); expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); test("should call validateInputs with correct parameters", async () => { - vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result< - typeof prisma.contact, - unknown, - "findFirst" - >); + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]); await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType); expect(validateInputs).toHaveBeenCalledWith( @@ -175,14 +171,7 @@ describe("segments lib", () => { }); test("should return only matching segment IDs", async () => { - // First segment matches, second doesn't - vi.mocked(prisma.contact.findFirst) - .mockResolvedValueOnce({ id: mockContactId } as Prisma.Result< - typeof prisma.contact, - unknown, - "findFirst" - >) // First segment matches - .mockResolvedValueOnce(null); // Second segment does not match + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]); const result = await getPersonSegmentIds( mockEnvironmentId, @@ -193,6 +182,66 @@ describe("segments lib", () => { expect(result).toEqual([mockSegmentsData[0].id]); expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test("should include segments with no filters as always-matching", async () => { + const segmentsWithEmptyFilters = [ + { id: "segment-no-filter", filters: [] }, + { id: "segment-with-filter", filters: [{}] as TBaseFilter[] }, + ]; + vi.mocked(prisma.segment.findMany).mockResolvedValue( + segmentsWithEmptyFilters as Prisma.Result + ); + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]); + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toContain("segment-no-filter"); + expect(result).toContain("segment-with-filter"); + expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test("should skip segments where filter query building fails", async () => { + vi.mocked(segmentFilterToPrismaQuery) + .mockResolvedValueOnce({ + ok: true, + data: { whereClause: mockWhereClause }, + }) + .mockResolvedValueOnce({ + ok: false, + error: { type: "bad_request", message: "Invalid filters", details: [] }, + }); + vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]); + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toEqual(["segment1"]); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test("should return empty array on unexpected error", async () => { + vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected")); + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toEqual([]); }); }); }); 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 b01e837402fe..a942dd96521f 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 @@ -37,47 +37,6 @@ export const getSegments = reactCache( ) ); -/** - * Checks if a contact matches a segment using Prisma query - * This leverages native DB types (valueDate, valueNumber) for accurate comparisons - * Device filters are evaluated at query build time using the provided deviceType - */ -const isContactInSegment = async ( - contactId: string, - segmentId: string, - filters: TBaseFilters, - environmentId: string, - deviceType: "phone" | "desktop" -): Promise => { - // If no filters, segment matches all contacts - if (!filters || filters.length === 0) { - return true; - } - - const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType); - - if (!queryResult.ok) { - logger.warn( - { segmentId, environmentId, error: queryResult.error }, - "Failed to build Prisma query for segment" - ); - return false; - } - - const { whereClause } = queryResult.data; - - // Check if this specific contact matches the segment filters - const matchingContact = await prisma.contact.findFirst({ - where: { - id: contactId, - ...whereClause, - }, - select: { id: true }, - }); - - return matchingContact !== null; -}; - export const getPersonSegmentIds = async ( environmentId: string, contactId: string, @@ -89,23 +48,70 @@ export const getPersonSegmentIds = async ( const segments = await getSegments(environmentId); - // fast path; if there are no segments, return an empty array - if (!segments || !Array.isArray(segments)) { + if (!segments || !Array.isArray(segments) || segments.length === 0) { return []; } - // Device filters are evaluated at query build time using the provided deviceType - const segmentPromises = segments.map(async (segment) => { - const filters = segment.filters; - const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType); - return isIncluded ? segment.id : null; - }); + // 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 }; + } + + const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType); + + 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 }; + }) + ); + + // Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build + const alwaysMatchIds: string[] = []; + const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = []; + + for (const item of segmentWithClauses) { + if (item.whereClause === null) { + continue; + } + + if (Object.keys(item.whereClause).length === 0) { + alwaysMatchIds.push(item.segmentId); + } else { + toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause }); + } + } + + if (toCheck.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 }) => + prisma.contact.findFirst({ + where: { id: contactId, ...whereClause }, + select: { id: true }, + }) + ) + ); - const results = await Promise.all(segmentPromises); + // Phase 3: Collect matching segment IDs + const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId); - return results.filter((id): id is string => id !== null); + return [...alwaysMatchIds, ...dbMatchIds]; } catch (error) { - // Log error for debugging but don't throw to prevent "segments is not iterable" error logger.warn( { environmentId, diff --git a/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx index 14cda6ce939d..b12b60293ff4 100644 --- a/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx +++ b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx @@ -250,7 +250,7 @@ export function CreateAttributeModal({ environmentId }: Readonly - {t("environments.contacts.create_key")} + {t("environments.contacts.create_attribute")} diff --git a/sonar-project.properties b/sonar-project.properties index e21abf95dfa5..5ab3bbd8e8c8 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -20,6 +20,9 @@ sonar.scm.exclusions.disabled=false # Encoding of the source code sonar.sourceEncoding=UTF-8 +# Node.js memory limit for JS/TS analysis (in MB) +sonar.javascript.node.maxspace=8192 + # Coverage sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.* sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*