Compare commits

...

6 Commits
4.7.3 ... 4.7.5

7 changed files with 241 additions and 79 deletions

View File

@@ -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

View File

@@ -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([]);
});
});
});

View File

@@ -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,

View File

@@ -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.

View 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 }}

View File

@@ -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
##########################################################

View File

@@ -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.*