mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b4cb4fc49 | ||
|
|
f8204d659a | ||
|
|
be38c41c6d | ||
|
|
f4598c3db5 | ||
|
|
0831c8c31d | ||
|
|
20ce0f9f34 |
@@ -101,6 +101,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
@@ -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: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
|
||||
cache: <T extends (...args: any[]) => 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<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
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<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> => {
|
||||
// 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;
|
||||
|
||||
const results = await Promise.all(segmentPromises);
|
||||
if (!filters || filters.length === 0) {
|
||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||
}
|
||||
|
||||
return results.filter((id): id is string => id !== null);
|
||||
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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
||||
logger.warn(
|
||||
{
|
||||
environmentId,
|
||||
|
||||
@@ -118,7 +118,7 @@ Scaling:
|
||||
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||
```
|
||||
{{- else }}
|
||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
|
||||
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
|
||||
Manually scale using:
|
||||
```sh
|
||||
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
|
||||
@@ -127,6 +127,34 @@ Scaling:
|
||||
|
||||
---
|
||||
|
||||
Pod Disruption Budget:
|
||||
|
||||
{{- if .Values.pdb.enabled }}
|
||||
A PodDisruptionBudget is active to protect against voluntary disruptions.
|
||||
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
|
||||
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
|
||||
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
|
||||
{{- end }}
|
||||
|
||||
Check PDB status:
|
||||
```sh
|
||||
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
|
||||
```
|
||||
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
|
||||
|
||||
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
|
||||
will block all node drains when only 1 replica is running. Set
|
||||
autoscaling.minReplicas to at least 2 for proper HA protection.
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
|
||||
take down all pods simultaneously.
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
|
||||
External Secrets:
|
||||
{{- if .Values.externalSecret.enabled }}
|
||||
External secrets are enabled.
|
||||
|
||||
37
charts/formbricks/templates/pdb.yaml
Normal file
37
charts/formbricks/templates/pdb.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
{{- if .Values.pdb.enabled }}
|
||||
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
|
||||
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
|
||||
{{- if and $hasMinAvailable $hasMaxUnavailable }}
|
||||
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
|
||||
{{- end }}
|
||||
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
|
||||
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ template "formbricks.name" . }}
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
{{- with .Values.pdb.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.pdb.annotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.pdb.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if $hasMinAvailable }}
|
||||
minAvailable: {{ .Values.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if $hasMaxUnavailable }}
|
||||
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "formbricks.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -214,6 +214,42 @@ autoscaling:
|
||||
value: 2
|
||||
periodSeconds: 60 # Add at most 2 pods every minute
|
||||
|
||||
##########################################################
|
||||
# Pod Disruption Budget (PDB)
|
||||
#
|
||||
# Ensures a minimum number of pods remain available during
|
||||
# voluntary disruptions (node drains, cluster upgrades, etc.).
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
|
||||
# Setting both will cause a helm install/upgrade failure.
|
||||
# To switch, set the unused one to null in your override file.
|
||||
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
|
||||
# - For PDB to provide real HA protection, ensure
|
||||
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
|
||||
# if HPA is disabled). With only 1 replica and minAvailable: 1,
|
||||
# the PDB will block ALL node drains and cluster upgrades.
|
||||
##########################################################
|
||||
pdb:
|
||||
enabled: true
|
||||
additionalLabels: {}
|
||||
annotations: {}
|
||||
|
||||
# Minimum pods that must remain available during disruptions.
|
||||
# Set to null and configure maxUnavailable instead if preferred.
|
||||
minAvailable: 1
|
||||
|
||||
# Maximum pods that can be unavailable during disruptions.
|
||||
# Mutually exclusive with minAvailable — uncomment and set
|
||||
# minAvailable to null to use this instead.
|
||||
# maxUnavailable: 1
|
||||
|
||||
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
|
||||
# "IfHealthy" — unhealthy pods count toward the budget (default).
|
||||
# "AlwaysAllow" — unhealthy pods can always be evicted,
|
||||
# preventing them from blocking node drain.
|
||||
# unhealthyPodEvictionPolicy: AlwaysAllow
|
||||
|
||||
##########################################################
|
||||
# Service Configuration
|
||||
##########################################################
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Reference in New Issue
Block a user