mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-21 09:21:13 -05:00
Compare commits
1 Commits
codex/simp
...
fix/active
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea62125067 |
@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "uploadBulkContacts",
|
||||
summary: "Upload Bulk Contacts",
|
||||
description:
|
||||
"Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
|
||||
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
description:
|
||||
"The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
|
||||
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactBulkUploadRequest,
|
||||
|
||||
@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContact",
|
||||
summary: "Create a contact",
|
||||
description:
|
||||
"Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
|
||||
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
|
||||
tags: ["Management API - Contacts"],
|
||||
|
||||
requestBody: {
|
||||
required: true,
|
||||
description:
|
||||
"The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
|
||||
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactCreateRequest,
|
||||
|
||||
@@ -15,23 +15,60 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { SegmentActivityTab } from "./segment-activity-tab";
|
||||
import { TSegmentActivitySummary } from "./segment-activity-utils";
|
||||
|
||||
interface EditSegmentModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
activitySummary: TSegmentActivitySummary;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const SegmentSettingsTab = ({
|
||||
contactAttributeKeys,
|
||||
currentSegment,
|
||||
environmentId,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
segments,
|
||||
setOpen,
|
||||
}: Pick<
|
||||
EditSegmentModalProps,
|
||||
| "contactAttributeKeys"
|
||||
| "currentSegment"
|
||||
| "environmentId"
|
||||
| "isContactsEnabled"
|
||||
| "isReadOnly"
|
||||
| "segments"
|
||||
| "setOpen"
|
||||
>) => {
|
||||
if (!isContactsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentSettings
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditSegmentModal = ({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
currentSegment,
|
||||
activitySummary,
|
||||
contactAttributeKeys,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -40,31 +77,24 @@ export const EditSegmentModal = ({
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const SettingsTab = () => {
|
||||
if (isContactsEnabled) {
|
||||
return (
|
||||
<SegmentSettings
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
initialSegment={currentSegment}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: t("common.activity"),
|
||||
children: <SegmentActivityTab currentSegment={currentSegment} />,
|
||||
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
|
||||
},
|
||||
{
|
||||
title: t("common.settings"),
|
||||
children: <SettingsTab />,
|
||||
children: (
|
||||
<SegmentSettingsTab
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
currentSegment={currentSegment}
|
||||
environmentId={environmentId}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
segments={segments}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,15 +5,16 @@ import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TSegmentActivitySummary } from "./segment-activity-utils";
|
||||
|
||||
interface SegmentActivityTabProps {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
activitySummary: TSegmentActivitySummary;
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { activeSurveys, inactiveSurveys } = currentSegment;
|
||||
const { activeSurveys, inactiveSurveys } = activitySummary;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
@@ -22,20 +23,20 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
|
||||
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
|
||||
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
|
||||
|
||||
{activeSurveys?.map((survey, index) => (
|
||||
<p className="text-sm text-slate-900" key={index + survey}>
|
||||
{survey}
|
||||
</p>
|
||||
{activeSurveys?.map((surveyName) => (
|
||||
<div className="py-0.5" key={surveyName}>
|
||||
<p className="text-sm text-slate-900">{surveyName}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
|
||||
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
|
||||
|
||||
{inactiveSurveys?.map((survey, index) => (
|
||||
<p className="text-sm text-slate-900" key={index + survey}>
|
||||
{survey}
|
||||
</p>
|
||||
{inactiveSurveys?.map((surveyName) => (
|
||||
<div className="py-0.5" key={surveyName}>
|
||||
<p className="text-sm text-slate-900">{surveyName}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TBaseFilters, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildSegmentActivitySummary,
|
||||
buildSegmentActivitySummaryFromSegments,
|
||||
doesSegmentReferenceSegment,
|
||||
getReferencingSegments,
|
||||
} from "./segment-activity-utils";
|
||||
|
||||
const createSurvey = (overrides: Partial<TSurvey>): TSurvey =>
|
||||
({
|
||||
id: "survey_1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: "env_1",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: {},
|
||||
html: {},
|
||||
fileUrl: "",
|
||||
buttonLabel: {},
|
||||
timeToFinish: false,
|
||||
},
|
||||
questions: [],
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
triggers: [],
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
projectOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
redirectUrl: null,
|
||||
displayStatus: null,
|
||||
displayCount: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
recaptcha: null,
|
||||
variables: [],
|
||||
blocks: undefined,
|
||||
followUps: [],
|
||||
verifyEmailTemplateId: null,
|
||||
...overrides,
|
||||
}) as TSurvey;
|
||||
|
||||
const createSegment = (overrides: Partial<TSegment>): TSegment =>
|
||||
({
|
||||
id: "segment_1",
|
||||
title: "Segment 1",
|
||||
description: null,
|
||||
isPrivate: false,
|
||||
environmentId: "env_1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveys: [],
|
||||
filters: [],
|
||||
...overrides,
|
||||
}) as TSegment;
|
||||
|
||||
const createSegmentWithSurveyNames = (overrides: Partial<TSegmentWithSurveyNames>): TSegmentWithSurveyNames =>
|
||||
({
|
||||
...createSegment(overrides),
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
...overrides,
|
||||
}) as TSegmentWithSurveyNames;
|
||||
|
||||
describe("segment activity utils", () => {
|
||||
test("doesSegmentReferenceSegment returns true for nested segment filters", () => {
|
||||
const filters: TBaseFilters = [
|
||||
{
|
||||
id: "group_1",
|
||||
connector: null,
|
||||
resource: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsNotIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(doesSegmentReferenceSegment(filters, "segment_target")).toBe(true);
|
||||
expect(doesSegmentReferenceSegment(filters, "segment_other")).toBe(false);
|
||||
});
|
||||
|
||||
test("getReferencingSegments excludes the current segment and returns only matching segments", () => {
|
||||
const segments = [
|
||||
createSegment({ id: "segment_target" }),
|
||||
createSegment({
|
||||
id: "segment_ref",
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
createSegment({
|
||||
id: "segment_other",
|
||||
filters: [
|
||||
{
|
||||
id: "filter_2",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "attribute_filter_1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "plan",
|
||||
},
|
||||
value: "enterprise",
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(getReferencingSegments(segments, "segment_target").map((segment) => segment.id)).toEqual([
|
||||
"segment_ref",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary returns direct surveys grouped by status", () => {
|
||||
const directSurveys = [
|
||||
createSurvey({
|
||||
id: "survey_direct",
|
||||
name: "Direct Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
createSurvey({
|
||||
id: "survey_draft",
|
||||
name: "Draft Survey",
|
||||
status: "draft",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary(directSurveys, [])).toEqual({
|
||||
activeSurveys: ["Direct Survey"],
|
||||
inactiveSurveys: ["Draft Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary includes indirect surveys when there is no direct match", () => {
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref",
|
||||
segmentTitle: "Referenced Segment",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_draft",
|
||||
name: "Draft Survey",
|
||||
status: "draft",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: ["Draft Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary prefers direct surveys over indirect duplicates", () => {
|
||||
const directSurveys = [
|
||||
createSurvey({
|
||||
id: "survey_shared",
|
||||
name: "Shared Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
];
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref",
|
||||
segmentTitle: "Referenced Segment",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_shared",
|
||||
name: "Shared Survey",
|
||||
status: "inProgress",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary(directSurveys, indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: ["Shared Survey"],
|
||||
inactiveSurveys: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummary deduplicates indirect surveys referenced by multiple segments", () => {
|
||||
const indirectSurveyGroups = [
|
||||
{
|
||||
segmentId: "segment_ref_1",
|
||||
segmentTitle: "Referenced Segment 1",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_indirect",
|
||||
name: "Indirect Survey",
|
||||
status: "paused",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
segmentId: "segment_ref_2",
|
||||
segmentTitle: "Referenced Segment 2",
|
||||
surveys: [
|
||||
createSurvey({
|
||||
id: "survey_indirect",
|
||||
name: "Indirect Survey",
|
||||
status: "paused",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: ["Indirect Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummaryFromSegments merges direct and indirect surveys from segment table data", () => {
|
||||
const currentSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_target",
|
||||
activeSurveys: ["Direct Survey"],
|
||||
inactiveSurveys: ["Paused Survey"],
|
||||
});
|
||||
const segments = [
|
||||
currentSegment,
|
||||
createSegmentWithSurveyNames({
|
||||
id: "segment_ref",
|
||||
title: "Referenced Segment",
|
||||
activeSurveys: ["Indirect Survey"],
|
||||
inactiveSurveys: ["Paused Survey"],
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(buildSegmentActivitySummaryFromSegments(currentSegment, segments)).toEqual({
|
||||
activeSurveys: ["Direct Survey", "Indirect Survey"],
|
||||
inactiveSurveys: ["Paused Survey"],
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSegmentActivitySummaryFromSegments includes indirect usage from private survey segments", () => {
|
||||
const currentSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_target",
|
||||
});
|
||||
|
||||
const privateReferencingSegment = createSegmentWithSurveyNames({
|
||||
id: "segment_private_ref",
|
||||
title: "Private Survey Segment",
|
||||
isPrivate: true,
|
||||
activeSurveys: ["Indirect Private Survey"],
|
||||
filters: [
|
||||
{
|
||||
id: "filter_1",
|
||||
connector: null,
|
||||
resource: {
|
||||
id: "segment_filter_1",
|
||||
root: {
|
||||
type: "segment",
|
||||
segmentId: "segment_target",
|
||||
},
|
||||
value: "segment_target",
|
||||
qualifier: {
|
||||
operator: "userIsNotIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSegmentActivitySummaryFromSegments(currentSegment, [currentSegment, privateReferencingSegment])
|
||||
).toEqual({
|
||||
activeSurveys: ["Indirect Private Survey"],
|
||||
inactiveSurveys: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { TBaseFilters, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
|
||||
type TReferencingSegmentSurveyGroup = {
|
||||
segmentId: string;
|
||||
segmentTitle: string;
|
||||
surveys: TSurveySummary[];
|
||||
};
|
||||
|
||||
export type TSegmentActivitySummary = {
|
||||
activeSurveys: string[];
|
||||
inactiveSurveys: string[];
|
||||
};
|
||||
|
||||
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
|
||||
for (const filter of filters) {
|
||||
const { resource } = filter;
|
||||
|
||||
if (Array.isArray(resource)) {
|
||||
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getReferencingSegments = (segments: TSegment[], targetSegmentId: string): TSegment[] =>
|
||||
segments.filter(
|
||||
(segment) =>
|
||||
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
|
||||
);
|
||||
|
||||
export const buildSegmentActivitySummary = (
|
||||
directSurveys: TSurveySummary[],
|
||||
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
|
||||
): TSegmentActivitySummary => {
|
||||
const surveyMap = new Map<string, TSurveySummary>();
|
||||
|
||||
for (const survey of directSurveys) {
|
||||
surveyMap.set(survey.id, survey);
|
||||
}
|
||||
|
||||
for (const segment of indirectSurveyGroups) {
|
||||
for (const survey of segment.surveys) {
|
||||
if (!surveyMap.has(survey.id)) {
|
||||
surveyMap.set(survey.id, survey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const surveys = Array.from(surveyMap.values());
|
||||
|
||||
return {
|
||||
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
|
||||
inactiveSurveys: surveys
|
||||
.filter((survey) => survey.status === "draft" || survey.status === "paused")
|
||||
.map((survey) => survey.name),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSegmentActivitySummaryFromSegments = (
|
||||
currentSegment: TSegmentWithSurveyNames,
|
||||
segments: TSegmentWithSurveyNames[]
|
||||
): TSegmentActivitySummary => {
|
||||
const activeSurveys = new Set(currentSegment.activeSurveys);
|
||||
const inactiveSurveys = new Set(currentSegment.inactiveSurveys);
|
||||
const directSurveyNames = new Set([...activeSurveys, ...inactiveSurveys]);
|
||||
|
||||
const referencingSegments = getReferencingSegments(
|
||||
segments,
|
||||
currentSegment.id
|
||||
) as TSegmentWithSurveyNames[];
|
||||
for (const segment of referencingSegments) {
|
||||
for (const surveyName of segment.activeSurveys) {
|
||||
if (!directSurveyNames.has(surveyName)) {
|
||||
activeSurveys.add(surveyName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const surveyName of segment.inactiveSurveys) {
|
||||
if (!directSurveyNames.has(surveyName)) {
|
||||
inactiveSurveys.add(surveyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeSurveys: Array.from(activeSurveys),
|
||||
inactiveSurveys: Array.from(inactiveSurveys),
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { getSurveysBySegmentId } from "@/lib/survey/service";
|
||||
import { buildSegmentActivitySummary, getReferencingSegments } from "./segment-activity-utils";
|
||||
import { SegmentTableDataRow } from "./segment-table-data-row";
|
||||
|
||||
type TSegmentTableDataRowProps = {
|
||||
@@ -18,17 +19,28 @@ export const SegmentTableDataRowContainer = async ({
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: TSegmentTableDataRowProps) => {
|
||||
const surveys = await getSurveysBySegmentId(currentSegment.id);
|
||||
const directSurveys = await getSurveysBySegmentId(currentSegment.id);
|
||||
|
||||
const activeSurveys = surveys?.length
|
||||
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
|
||||
const activeSurveys = directSurveys?.length
|
||||
? directSurveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
|
||||
: [];
|
||||
|
||||
const inactiveSurveys = surveys?.length
|
||||
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
|
||||
const inactiveSurveys = directSurveys?.length
|
||||
? directSurveys
|
||||
.filter((survey) => ["draft", "paused"].includes(survey.status))
|
||||
.map((survey) => survey.name)
|
||||
: [];
|
||||
|
||||
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
|
||||
const referencingSegments = getReferencingSegments(filteredSegments, currentSegment.id);
|
||||
const indirectSurveyGroups = await Promise.all(
|
||||
referencingSegments.map(async (segment) => ({
|
||||
segmentId: segment.id,
|
||||
segmentTitle: segment.title,
|
||||
surveys: await getSurveysBySegmentId(segment.id),
|
||||
}))
|
||||
);
|
||||
const activitySummary = buildSegmentActivitySummary(directSurveys, indirectSurveyGroups);
|
||||
|
||||
return (
|
||||
<SegmentTableDataRow
|
||||
@@ -37,6 +49,7 @@ export const SegmentTableDataRowContainer = async ({
|
||||
activeSurveys,
|
||||
inactiveSurveys,
|
||||
}}
|
||||
activitySummary={activitySummary}
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { TSegmentActivitySummary } from "./segment-activity-utils";
|
||||
|
||||
type TSegmentTableDataRowProps = {
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
activitySummary: TSegmentActivitySummary;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
@@ -17,6 +19,7 @@ type TSegmentTableDataRowProps = {
|
||||
|
||||
export const SegmentTableDataRow = ({
|
||||
currentSegment,
|
||||
activitySummary,
|
||||
contactAttributeKeys,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -62,6 +65,7 @@ export const SegmentTableDataRow = ({
|
||||
open={isEditSegmentModalOpen}
|
||||
setOpen={setIsEditSegmentModalOpen}
|
||||
currentSegment={currentSegment}
|
||||
activitySummary={activitySummary}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
|
||||
import { generateSegmentTableColumns } from "./segment-table-columns";
|
||||
|
||||
interface SegmentTableUpdatedProps {
|
||||
segments: TSegmentWithSurveyNames[];
|
||||
allSegments: TSegmentWithSurveyNames[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
@@ -18,16 +20,17 @@ interface SegmentTableUpdatedProps {
|
||||
|
||||
export function SegmentTable({
|
||||
segments,
|
||||
allSegments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
}: Readonly<SegmentTableUpdatedProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns(t);
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
@@ -136,6 +139,7 @@ export function SegmentTable({
|
||||
open={!!editingSegment}
|
||||
setOpen={(open) => !open && setEditingSegment(null)}
|
||||
currentSegment={editingSegment}
|
||||
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
|
||||
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
|
||||
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
|
||||
<SegmentTable
|
||||
allSegments={segments}
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -1386,22 +1386,17 @@ paths:
|
||||
put:
|
||||
operationId: uploadBulkContacts
|
||||
summary: Upload Bulk Contacts
|
||||
description: >-
|
||||
Uploads contacts in bulk. This endpoint expects the bulk request
|
||||
shape: `contacts` must be an array, and each contact item must contain
|
||||
an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST
|
||||
/management/contacts`, this endpoint does not accept a top-level `attributes`
|
||||
object. Each contact must include an `email` attribute in its `attributes`
|
||||
array, and that email must be valid.
|
||||
description: Uploads contacts in bulk. Each contact in the payload must have an
|
||||
'email' attribute present in their attributes array. The email attribute
|
||||
is mandatory and must be a valid email format. Without a valid email,
|
||||
the contact will be skipped during processing.
|
||||
tags:
|
||||
- Management API - Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: >-
|
||||
The contacts to upload. Use the full nested bulk body shown
|
||||
in the example or cURL snippet: `{ environmentId, contacts: [{ attributes:
|
||||
[{ attributeKey: { key, name }, value }] }] }`. Each contact must include
|
||||
an `email` attribute inside its `attributes` array.
|
||||
description: The contacts to upload. Each contact must include an 'email'
|
||||
attribute in their attributes array. The email is used as the unique
|
||||
identifier for the contact.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1525,19 +1520,16 @@ paths:
|
||||
post:
|
||||
operationId: createContact
|
||||
summary: Create a contact
|
||||
description: Creates a single contact in the database. This endpoint expects
|
||||
a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`,
|
||||
which expects `contacts[].attributes[]` instead. Each contact must have
|
||||
a valid email address in the attributes. All attribute keys must already
|
||||
exist in the environment. The email is used as the unique identifier along
|
||||
description: Creates a contact in the database. Each contact must have a valid
|
||||
email address in the attributes. All attribute keys must already exist
|
||||
in the environment. The email is used as the unique identifier along
|
||||
with the environment.
|
||||
tags:
|
||||
- Management API - Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: The single contact to create. Must include a top-level `attributes`
|
||||
object with an email attribute, and all attribute keys must already exist
|
||||
in the environment.
|
||||
description: The contact to create. Must include an email attribute and all
|
||||
attribute keys must already exist in the environment.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: "Multi-language Surveys"
|
||||
description: "Survey respondents in multiple-languages."
|
||||
icon: "language"
|
||||
---
|
||||
|
||||
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)
|
||||
@@ -77,7 +77,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Pin-protected surveys | ✅ | ✅ |
|
||||
| Webhooks | ✅ | ✅ |
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language surveys | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
@@ -86,6 +85,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Quota Management | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
|
||||
@@ -4,6 +4,10 @@ description: "Create surveys that support multiple languages using translations.
|
||||
icon: "language"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Multi-language surveys are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
|
||||
How to deliver a specific language depends on the survey type (app or link survey):
|
||||
|
||||
- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration)
|
||||
|
||||
@@ -73,6 +73,12 @@ Use the `setLanguage` function to set the user's preferred language for surveys.
|
||||
formbricks.setLanguage("de"); // ISO identifier or Alias set when creating language
|
||||
```
|
||||
|
||||
<Note>
|
||||
If a user has a language assigned, a survey has multi-language activated and it is missing a translation in
|
||||
the language of the user, the survey will not be displayed. Learn more about [Multi-language Surveys](/docs/xm-and-surveys/surveys/general-features/multi-language-surveys).
|
||||
</Note>
|
||||
|
||||
|
||||
### Logging Out Users
|
||||
|
||||
When a user logs out of your webpage, also log them out of Formbricks to prevent activity from being linked to the wrong user. Use the logout function:
|
||||
|
||||
16
package.json
16
package.json
@@ -42,8 +42,7 @@
|
||||
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
|
||||
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
|
||||
"i18n": "pnpm generate-translations && pnpm scan-translations",
|
||||
"i18n:validate": "pnpm scan-translations",
|
||||
"dev:setup": "node scripts/setup-dev-env.mjs"
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.4",
|
||||
@@ -83,22 +82,15 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.10",
|
||||
"axios": "1.13.5",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.4",
|
||||
"@microsoft/api-extractor>minimatch": "10.2.4",
|
||||
"axios": ">=1.12.2",
|
||||
"node-forge": ">=1.3.2",
|
||||
"rollup": "4.59.0",
|
||||
"socket.io-parser": "4.2.6",
|
||||
"tar": ">=7.5.11",
|
||||
"typeorm": ">=0.3.26",
|
||||
"undici": "7.24.0",
|
||||
"fast-xml-parser": "5.5.7",
|
||||
"fast-xml-parser": "5.4.2",
|
||||
"diff": ">=8.0.3"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316) - awaiting Prisma update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | fast-xml-parser (CVE-2026-25896/26278) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.10",
|
||||
"react-email": "5.2.10"
|
||||
"@react-email/components": "1.0.9",
|
||||
"react-email": "5.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@react-email/preview-server": "5.2.9",
|
||||
"autoprefixer": "10.4.27",
|
||||
"clsx": "2.1.1",
|
||||
"postcss": "8.5.8",
|
||||
|
||||
1251
pnpm-lock.yaml
generated
1251
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const examplePath = path.join(repoRoot, ".env.example");
|
||||
const envPath = path.join(repoRoot, ".env");
|
||||
|
||||
const generatedSecretKeys = ["ENCRYPTION_KEY", "NEXTAUTH_SECRET", "CRON_SECRET"];
|
||||
|
||||
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const parseEnvValue = (rawValue) => {
|
||||
const trimmedStartValue = rawValue.trimStart();
|
||||
let normalizedValue = "";
|
||||
let inSingleQuotes = false;
|
||||
let inDoubleQuotes = false;
|
||||
|
||||
for (const char of trimmedStartValue) {
|
||||
if (char === "'" && !inDoubleQuotes) {
|
||||
inSingleQuotes = !inSingleQuotes;
|
||||
} else if (char === '"' && !inSingleQuotes) {
|
||||
inDoubleQuotes = !inDoubleQuotes;
|
||||
} else if (char === "#" && !inSingleQuotes && !inDoubleQuotes) {
|
||||
break;
|
||||
}
|
||||
|
||||
normalizedValue += char;
|
||||
}
|
||||
|
||||
const trimmedValue = normalizedValue.trim();
|
||||
|
||||
if (
|
||||
(trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ||
|
||||
(trimmedValue.startsWith("'") && trimmedValue.endsWith("'"))
|
||||
) {
|
||||
return trimmedValue.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmedValue;
|
||||
};
|
||||
|
||||
const parseEnv = (content) => {
|
||||
const entries = new Map();
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line
|
||||
.slice(0, separatorIndex)
|
||||
.trim()
|
||||
.replace(/^export\s+/, "");
|
||||
const value = parseEnvValue(line.slice(separatorIndex + 1));
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.set(key, value);
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const replaceOrAppendEnvValue = (content, key, value) => {
|
||||
const linePattern = new RegExp(`^(?:export\\s+)?${escapeRegExp(key)}\\s*=.*$`, "m");
|
||||
const nextLine = `${key}=${value}`;
|
||||
|
||||
if (linePattern.test(content)) {
|
||||
return content.replace(linePattern, nextLine);
|
||||
}
|
||||
|
||||
const normalizedContent = content.endsWith("\n") ? content : `${content}\n`;
|
||||
return `${normalizedContent}${nextLine}\n`;
|
||||
};
|
||||
|
||||
const isValidEncryptionKey = (value) => value.length === 32 || /^[0-9a-fA-F]{64}$/.test(value);
|
||||
|
||||
const isMissingSecretValue = (key, value) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "ENCRYPTION_KEY" && !isValidEncryptionKey(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!existsSync(examplePath)) {
|
||||
console.error(`❌ Could not find template file at ${path.relative(repoRoot, examplePath)}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const exampleContent = readFileSync(examplePath, "utf8");
|
||||
let envContent = existsSync(envPath) ? readFileSync(envPath, "utf8") : exampleContent;
|
||||
const initialEnvExists = existsSync(envPath);
|
||||
const parsedEnv = parseEnv(envContent);
|
||||
|
||||
const changedKeys = [];
|
||||
|
||||
for (const key of generatedSecretKeys) {
|
||||
const currentValue = parsedEnv.get(key);
|
||||
|
||||
if (isMissingSecretValue(key, currentValue)) {
|
||||
const generatedValue = randomBytes(32).toString("hex");
|
||||
envContent = replaceOrAppendEnvValue(envContent, key, generatedValue);
|
||||
parsedEnv.set(key, generatedValue);
|
||||
changedKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialEnvExists || changedKeys.length > 0) {
|
||||
writeFileSync(envPath, envContent, "utf8");
|
||||
}
|
||||
|
||||
const relativeEnvPath = path.relative(repoRoot, envPath);
|
||||
|
||||
if (!initialEnvExists) {
|
||||
console.log(`✅ Created ${relativeEnvPath} from .env.example.`);
|
||||
} else {
|
||||
console.log(`ℹ️ Using existing ${relativeEnvPath}.`);
|
||||
}
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
console.log(`🔐 Updated ${relativeEnvPath}: ${changedKeys.join(", ")}.`);
|
||||
} else {
|
||||
console.log(`✅ ${relativeEnvPath} already has all required generated secrets.`);
|
||||
}
|
||||
|
||||
console.log("🚀 Development environment file is ready.");
|
||||
Reference in New Issue
Block a user