Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,15 @@ jobs:
# @formbricks/database is already built by the "Build App" step above,
# so run the migration runner directly instead of db:migrate:dev
# (which would rebuild + re-generate the package unnecessarily).
pnpm --filter=@formbricks/database db:migrate:ci
# db:migrate:ci was added in #8627; PR branches cut before it lack the
# script (this workflow runs from the base ref against the PR-head code),
# so fall back to db:migrate:dev, which every older branch already has.
if node -e "process.exit(require('./packages/database/package.json').scripts['db:migrate:ci'] ? 0 : 1)"; then
pnpm --filter=@formbricks/database db:migrate:ci
else
echo "::warning::db:migrate:ci absent on this ref (predates #8627); falling back to db:migrate:dev"
pnpm --filter=@formbricks/database db:migrate:dev
fi

- name: Run Rate Limiter Load Tests
run: |
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,16 @@ jobs:
# @formbricks/database is already built by the "Build workspace package
# dependencies" step above, so run the migration runner directly instead
# of db:migrate:dev (which would rebuild + re-generate the package).
run: pnpm --filter=@formbricks/database db:migrate:ci
# db:migrate:ci was added in #8627; PR branches cut before it lack the
# script (this workflow runs from the base ref against the PR-head code),
# so fall back to db:migrate:dev, which every older branch already has.
run: |
if node -e "process.exit(require('./packages/database/package.json').scripts['db:migrate:ci'] ? 0 : 1)"; then
pnpm --filter=@formbricks/database db:migrate:ci
else
echo "::warning::db:migrate:ci absent on this ref (predates #8627); falling back to db:migrate:dev"
pnpm --filter=@formbricks/database db:migrate:dev
fi
shell: bash

# Shared with apps/web/integration/global-setup.ts (the local harness applies the same file) so
Expand Down
116 changes: 116 additions & 0 deletions apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ vi.mock("@formbricks/database", () => ({
workspace: {
findUnique: vi.fn(),
},
segment: {
findMany: vi.fn(),
},
},
}));

Expand Down Expand Up @@ -334,6 +337,119 @@ describe("getWorkspaceStateData", () => {
expect(codes).toEqual(["en-US", "fil-PH", "ak-GH", "en", "tl", "ak", "tw"]);
});

// Survey-interaction segment gate: per-survey `interactionRefresh`.
const interactionSurvey = (id: string, operator: string, targetSurveyId: string) => ({
...mockWorkspaceData.surveys[0],
id,
segment: {
id: `seg-${id}`,
filters: [
{
id: "f1",
connector: null,
resource: {
id: "r1",
root: { type: "surveyInteraction" },
qualifier: { operator },
value: {
surveyScope: "specific",
surveyIds: [targetSurveyId],
within: { amount: 1, unit: "months" },
},
},
},
],
},
});

test("attaches per-survey interactionRefresh for an interaction filter", async () => {
// survey-B references survey-A via "have seen" → A gets onDisplay only; B (referenced by nobody) all-false.
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
...mockWorkspaceData,
surveys: [
{ ...mockWorkspaceData.surveys[0], id: "survey-A", segment: null },
interactionSurvey("survey-B", "haveSeen", "survey-A"),
],
} as never);
vi.mocked(prisma.segment.findMany).mockResolvedValue([] as never);

const result = await getWorkspaceStateData(workspaceId);

const byId = Object.fromEntries(result.surveys.map((s) => [s.id, s]));
expect((byId["survey-A"] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({
onDisplay: true,
onResponse: false,
onFinished: false,
});
expect((byId["survey-B"] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({
onDisplay: false,
onResponse: false,
onFinished: false,
});
});

test("resolves an interaction filter hidden inside a nested userIsIn segment", async () => {
// survey-A's segment only references segment "seg-nested" (userIsIn); that segment holds the
// "have completed survey-A" interaction leaf, so A must still be flagged onFinished.
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
...mockWorkspaceData,
surveys: [
{
...mockWorkspaceData.surveys[0],
id: "survey-A",
segment: {
id: "seg-a",
filters: [
{
id: "f1",
connector: null,
resource: {
id: "r1",
root: { type: "segment", segmentId: "seg-nested" },
qualifier: { operator: "userIsIn" },
value: "seg-nested",
},
},
],
},
},
],
} as never);
vi.mocked(prisma.segment.findMany).mockResolvedValue([
{
id: "seg-nested",
filters: [
{
id: "f2",
connector: null,
resource: {
id: "r2",
root: { type: "surveyInteraction" },
qualifier: { operator: "haveCompleted" },
value: {
surveyScope: "specific",
surveyIds: ["survey-A"],
within: { amount: 1, unit: "months" },
},
},
},
],
},
] as never);

const result = await getWorkspaceStateData(workspaceId);

expect((result.surveys[0] as { interactionRefresh?: unknown }).interactionRefresh).toEqual({
onDisplay: false,
onResponse: false,
onFinished: true,
});
expect(prisma.segment.findMany).toHaveBeenCalledWith({
where: { workspaceId },
select: { id: true, filters: true },
});
});

test("distinguishes Simplified and Traditional Chinese (script preserved, not a bare 'zh')", async () => {
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
...mockWorkspaceData,
Expand Down
36 changes: 36 additions & 0 deletions apps/web/app/api/v1/client/[workspaceId]/environment/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TJsWorkspaceStateSurvey,
TJsWorkspaceStateWorkspaceSetting,
} from "@formbricks/types/js";
import { type TBaseFilters, buildSurveyInteractionRefreshMap } from "@formbricks/types/segment";
import { toLegacyLanguageCodes } from "@/lib/i18n/utils";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
Expand Down Expand Up @@ -202,6 +203,36 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
},
};

// Per-survey gate for the SDK's post-interaction segment refresh. Reverse-indexes every
// `surveyInteraction` filter used by a live app survey onto the survey(s) it references, so the SDK
// learns — per survey, per event (display / response / finish) — whether interacting with it can
// change any live survey's membership, and skips the heavy `/user` refetch otherwise.
const surveysWithFilters = workspaceData.surveys.map((survey) => ({
id: survey.id,
segmentFilters: (Array.isArray(survey.segment?.filters)
? (survey.segment.filters as unknown as TBaseFilters)
: null) as TBaseFilters | null,
}));

// Nested `userIsIn` / `userIsNotIn` filters can hide an interaction filter inside a referenced
// segment, and those affect membership at runtime too — so resolve them. Load the workspace's
// segments once (a small, indexed query behind the 60s workspace-state cache) only when some
// survey actually has filters; the common no-targeting case pays nothing.
let segmentFiltersById: Map<string, TBaseFilters> | null = null;
if (surveysWithFilters.some((survey) => (survey.segmentFilters?.length ?? 0) > 0)) {
const workspaceSegments = await prisma.segment.findMany({
where: { workspaceId },
select: { id: true, filters: true },
});
segmentFiltersById = new Map(
workspaceSegments.map((segment) => [segment.id, segment.filters as unknown as TBaseFilters])
);
}

const { refreshBySurveyId, hasAny } = buildSurveyInteractionRefreshMap(surveysWithFilters, (segmentId) =>
segmentFiltersById?.get(segmentId)
);

const transformedSurveys = workspaceData.surveys.map((survey) => {
const realHasFilters =
Array.isArray(survey.segment?.filters) && (survey.segment.filters as unknown[]).length > 0;
Expand Down Expand Up @@ -242,10 +273,15 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
segment: null,
});

// Only attach the per-survey refresh gate when the workspace actually uses interaction
// targeting — otherwise it's dead weight on every survey in the response.
const interactionRefresh = hasAny ? refreshBySurveyId[survey.id] : undefined;

return {
...transformed,
name: "[deprecated] survey name omitted from public API - will be removed soon",
segment: sanitizedSegment,
...(interactionRefresh ? { interactionRefresh } : {}),
};
});

Expand Down
52 changes: 51 additions & 1 deletion apps/web/app/api/v3/surveys/targeting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { TContactAttributeKey } from "@formbricks/types/contact-attribute-k
import { DatabaseError } from "@formbricks/types/errors";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getExistingWorkspaceSurveyIds, getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import type { TV3SurveyTargeting } from "./schemas";
Expand Down Expand Up @@ -36,6 +36,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({

vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
getSegments: vi.fn(),
getExistingWorkspaceSurveyIds: vi.fn(),
}));

vi.mock("@/modules/ee/license-check/lib/utils", () => ({
Expand Down Expand Up @@ -204,6 +205,21 @@ const mockSegments = (ids: string[]): void => {
);
};

const surveyInteractionNode = (id: string, surveyScope: "any" | "specific", surveyIds: string[]) => ({
id,
connector: null,
resource: {
id: `${id}_res`,
root: { type: "surveyInteraction" },
qualifier: { operator: "haveSeen" },
value: { surveyScope, surveyIds, within: { amount: 1, unit: "months" } },
},
});

const mockSurveyRefs = (ids: string[]): void => {
vi.mocked(getExistingWorkspaceSurveyIds).mockResolvedValueOnce(new Set(ids));
};

describe("assertV3SurveyTargetingFilterReferences", () => {
test("performs no workspace lookup when filters need none", async () => {
const validDeviceOnly = [deviceFilterNode("f1", "phone")] as unknown as TFilterTree;
Expand Down Expand Up @@ -356,4 +372,38 @@ describe("assertV3SurveyTargetingFilterReferences", () => {
],
});
});

test("skips the survey lookup for an any-scope survey interaction filter", async () => {
const filters = [surveyInteractionNode("f1", "any", [])] as unknown as TFilterTree;

await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined();
expect(getExistingWorkspaceSurveyIds).not.toHaveBeenCalled();
});

test("resolves a specific-scope survey interaction filter against workspace surveys", async () => {
mockSurveyRefs(["survey_known"]);
const filters = [surveyInteractionNode("f1", "specific", ["survey_known"])] as unknown as TFilterTree;

await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).resolves.toBeUndefined();
// Validated by the exact referenced ids (unbounded), not the picker's recent-window list.
expect(getExistingWorkspaceSurveyIds).toHaveBeenCalledWith("ws_1", ["survey_known"]);
});

test("rejects a survey interaction filter referencing an unknown or foreign survey", async () => {
mockSurveyRefs(["survey_known"]);
const filters = [
surveyInteractionNode("f1", "specific", ["survey_known", "survey_foreign"]),
] as unknown as TFilterTree;

await expect(assertV3SurveyTargetingFilterReferences("ws_1", filters)).rejects.toMatchObject({
invalidParams: [
{
name: "targeting.filters.0.resource.value.surveyIds.1",
reason: "Survey 'survey_foreign' was not found in this workspace",
code: "invalid_reference",
identifier: "survey_foreign",
},
],
});
});
});
37 changes: 35 additions & 2 deletions apps/web/app/api/v3/surveys/targeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import type {
TSegmentDeviceFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentSurveyInteractionFilter,
} from "@formbricks/types/segment";
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getExistingWorkspaceSurveyIds, getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import type { TV3SurveyTargeting } from "./schemas";
Expand Down Expand Up @@ -115,7 +116,7 @@ const V3_TARGETING_DEVICE_VALUES = ["phone", "desktop"] as const;
// whose identifier can never match) and `invalidDeviceValue` (a device filter whose value isn't a
// known device).
type TV3TargetingFilterReference = {
kind: "attributeKey" | "segment" | "unsupportedPersonIdentifier" | "invalidDeviceValue";
kind: "attributeKey" | "segment" | "survey" | "unsupportedPersonIdentifier" | "invalidDeviceValue";
value: string;
path: string;
};
Expand Down Expand Up @@ -201,6 +202,19 @@ function collectV3TargetingFilterReferences(
];
return deviceIssues.filter((issue): issue is TV3TargetingFilterReference => issue !== null);
}
case "surveyInteraction": {
// Only "specific" scope references surveys; "any" scope matches all surveys and needs no
// reference check. Each referenced survey id must resolve within the workspace.
const { value } = resource as TSegmentSurveyInteractionFilter;
if (value.surveyScope !== "specific") {
return [];
}
return value.surveyIds.map((surveyId, surveyIndex) => ({
kind: "survey" as const,
value: surveyId,
path: `${resourcePath}.value.surveyIds.${surveyIndex}`,
}));
}
default:
return [];
}
Expand Down Expand Up @@ -274,6 +288,25 @@ export async function assertV3SurveyTargetingFilterReferences(
}
}

const surveyRefs = references.filter((ref) => ref.kind === "survey");
if (surveyRefs.length > 0) {
// Scope to the workspace's own surveys so a survey-interaction filter cannot reference another
// workspace's survey. Look up by the exact referenced ids (not the picker's bounded recent list)
// so a valid survey in a large workspace isn't wrongly rejected for being outside that window.
const knownSurveyIds = await getExistingWorkspaceSurveyIds(
workspaceId,
surveyRefs.map((ref) => ref.value)
);
for (const reference of surveyRefs.filter((ref) => !knownSurveyIds.has(ref.value))) {
invalidParams.push({
name: reference.path,
reason: `Survey '${reference.value}' was not found in this workspace`,
code: "invalid_reference",
identifier: reference.value,
});
}
}

if (invalidParams.length > 0) {
throw new V3SurveyReferenceValidationError(invalidParams);
}
Expand Down
Loading
Loading