=> {
- const personEmail = response.personAttributes?.email;
+ const personEmail = response.contactAttributes?.email;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
diff --git a/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx b/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx
index 86e7036c80..a3588c8ff4 100644
--- a/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx
+++ b/apps/web/modules/surveys/components/QuestionFormInput/components/RecallItemSelect.tsx
@@ -22,7 +22,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { replaceRecallInfoWithUnderline } from "@formbricks/lib/utils/recall";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
+import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import {
TSurvey,
TSurveyHiddenFields,
@@ -51,7 +51,7 @@ interface RecallItemSelectProps {
recallItems: TSurveyRecallItem[];
selectedLanguageCode: string;
hiddenFields: TSurveyHiddenFields;
- attributeClasses: TAttributeClass[];
+ contactAttributeKeys: TContactAttributeKey[];
}
export const RecallItemSelect = ({
@@ -61,7 +61,7 @@ export const RecallItemSelect = ({
setShowRecallItemSelect,
recallItems,
selectedLanguageCode,
- attributeClasses,
+ contactAttributeKeys,
}: RecallItemSelectProps) => {
const [searchValue, setSearchValue] = useState("");
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
@@ -96,16 +96,16 @@ export const RecallItemSelect = ({
const attributeClassRecallItems = useMemo(() => {
if (localSurvey.type !== "app") return [];
- return attributeClasses
- .filter((attributeClass) => !recallItemIds.includes(attributeClass.name.replaceAll(" ", "nbsp")))
- .map((attributeClass) => {
+ return contactAttributeKeys
+ .filter((attributeKey) => !recallItemIds.includes(attributeKey.key.replaceAll(" ", "nbsp")))
+ .map((attributeKey) => {
return {
- id: attributeClass.name.replaceAll(" ", "nbsp"),
- label: attributeClass.name,
+ id: attributeKey.key.replaceAll(" ", "nbsp"),
+ label: attributeKey.name ?? attributeKey.key,
type: "attributeClass" as const,
};
});
- }, [attributeClasses]);
+ }, [contactAttributeKeys]);
const variableRecallItems = useMemo(() => {
if (localSurvey.variables.length) {
diff --git a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx
index 6d90ea96c7..4ef03cdb45 100644
--- a/apps/web/modules/surveys/components/QuestionFormInput/index.tsx
+++ b/apps/web/modules/surveys/components/QuestionFormInput/index.tsx
@@ -29,7 +29,7 @@ import {
recallToHeadline,
replaceRecallInfoWithUnderline,
} from "@formbricks/lib/utils/recall";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
+import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import {
TI18nString,
TSurvey,
@@ -71,7 +71,7 @@ interface QuestionFormInputProps {
ref?: RefObject;
onBlur?: React.FocusEventHandler;
className?: string;
- attributeClasses: TAttributeClass[];
+ contactAttributeKeys: TContactAttributeKey[];
locale: TUserLocale;
}
@@ -92,7 +92,7 @@ export const QuestionFormInput = ({
placeholder,
onBlur,
className,
- attributeClasses,
+ contactAttributeKeys,
locale,
}: QuestionFormInputProps) => {
const t = useTranslations();
@@ -177,7 +177,7 @@ export const QuestionFormInput = ({
getLocalizedValue(text, usedLanguageCode),
localSurvey,
usedLanguageCode,
- attributeClasses
+ contactAttributeKeys
)
: []
);
@@ -205,7 +205,7 @@ export const QuestionFormInput = ({
getLocalizedValue(text, usedLanguageCode),
localSurvey,
usedLanguageCode,
- attributeClasses
+ contactAttributeKeys
)
: []
);
@@ -234,7 +234,7 @@ export const QuestionFormInput = ({
// Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines.
const processInput = (): JSX.Element[] => {
const parts: JSX.Element[] = [];
- let remainingText = recallToHeadline(text, localSurvey, false, usedLanguageCode, attributeClasses)[
+ let remainingText = recallToHeadline(text, localSurvey, false, usedLanguageCode, contactAttributeKeys)[
usedLanguageCode
];
filterRecallItems(remainingText);
@@ -411,13 +411,13 @@ export const QuestionFormInput = ({
localSurvey,
false,
usedLanguageCode,
- attributeClasses
+ contactAttributeKeys
);
setText(modifiedHeadlineWithName);
setShowFallbackInput(true);
},
- [attributeClasses, elementText, fallbacks, handleUpdate, localSurvey, usedLanguageCode]
+ [contactAttributeKeys, elementText, fallbacks, handleUpdate, localSurvey, usedLanguageCode]
);
// Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states.
@@ -503,7 +503,7 @@ export const QuestionFormInput = ({
localSurvey,
false,
usedLanguageCode,
- attributeClasses
+ contactAttributeKeys
);
setText(valueTI18nString);
@@ -585,7 +585,7 @@ export const QuestionFormInput = ({
aria-label={label}
autoComplete={showRecallItemSelect ? "off" : "on"}
value={
- recallToHeadline(text, localSurvey, false, usedLanguageCode, attributeClasses)[
+ recallToHeadline(text, localSurvey, false, usedLanguageCode, contactAttributeKeys)[
usedLanguageCode
]
}
@@ -659,14 +659,14 @@ export const QuestionFormInput = ({
recallItems={recallItems}
selectedLanguageCode={usedLanguageCode}
hiddenFields={localSurvey.hiddenFields}
- attributeClasses={attributeClasses}
+ contactAttributeKeys={contactAttributeKeys}
/>
)}
{usedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
{t("environments.project.languages.translate")}: {" "}
- {recallToHeadline(value, localSurvey, false, "default", attributeClasses)["default"]}
+ {recallToHeadline(value, localSurvey, false, "default", contactAttributeKeys)["default"]}
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
diff --git a/apps/web/modules/ui/components/basic-add-filter-modal/index.tsx b/apps/web/modules/ui/components/basic-add-filter-modal/index.tsx
deleted file mode 100644
index 4f7de0d707..0000000000
--- a/apps/web/modules/ui/components/basic-add-filter-modal/index.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client";
-
-import { Input } from "@/modules/ui/components/input";
-import { Modal } from "@/modules/ui/components/modal";
-import { FingerprintIcon, TagIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useMemo, useState } from "react";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
-import { TBaseFilter } from "@formbricks/types/segment";
-import { handleAddFilter } from "./lib/utils";
-
-interface TBasicAddFilterModalProps {
- open: boolean;
- setOpen: (open: boolean) => void;
- onAddFilter: (filter: TBaseFilter) => void;
- attributeClasses: TAttributeClass[];
-}
-
-export const BasicAddFilterModal = ({
- onAddFilter,
- open,
- setOpen,
- attributeClasses,
-}: TBasicAddFilterModalProps) => {
- const [searchValue, setSearchValue] = useState("");
- const t = useTranslations();
- const attributeClassesFiltered = useMemo(() => {
- if (!attributeClasses) return [];
-
- if (!searchValue) return attributeClasses;
-
- return attributeClasses.filter((attributeClass) =>
- attributeClass.name.toLowerCase().includes(searchValue.toLowerCase())
- );
- }, [attributeClasses, searchValue]);
-
- return (
-
-
- setSearchValue(e.target.value)} />
-
-
-
-
-
{t("common.person")}
-
-
{
- handleAddFilter({
- type: "person",
- onAddFilter,
- setOpen,
- });
- }}
- className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
-
-
{t("common.user_id")}
-
-
-
-
-
-
-
-
{t("common.attributes")}
-
- {attributeClassesFiltered?.length === 0 && (
-
-
{t("environments.segments.no_attributes_yet")}
-
- )}
- {attributeClassesFiltered.map((attributeClass) => {
- return (
-
{
- handleAddFilter({
- type: "attribute",
- onAddFilter,
- setOpen,
- attributeClassName: attributeClass.name,
- });
- }}
- className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50">
-
-
{attributeClass.name}
-
- );
- })}
-
-
- );
-};
diff --git a/apps/web/modules/ui/components/basic-add-filter-modal/lib/utils.ts b/apps/web/modules/ui/components/basic-add-filter-modal/lib/utils.ts
deleted file mode 100644
index 7d2e2318ad..0000000000
--- a/apps/web/modules/ui/components/basic-add-filter-modal/lib/utils.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { createId } from "@paralleldrive/cuid2";
-import { TBaseFilter, TSegmentAttributeFilter, TSegmentPersonFilter } from "@formbricks/types/segment";
-
-export const handleAddFilter = ({
- type,
- attributeClassName,
- isUserId = false,
- onAddFilter,
- setOpen,
-}: {
- type: "person" | "attribute";
- attributeClassName?: string;
- isUserId?: boolean;
- onAddFilter: (filter: TBaseFilter) => void;
- setOpen: (open: boolean) => void;
-}) => {
- if (type === "person") {
- const newResource: TSegmentPersonFilter = {
- id: createId(),
- root: { type: "person", personIdentifier: "userId" },
- qualifier: {
- operator: "equals",
- },
- value: "",
- };
-
- const newFilter: TBaseFilter = {
- id: createId(),
- connector: "and",
- resource: newResource,
- };
-
- onAddFilter(newFilter);
- setOpen(false);
-
- return;
- }
-
- if (!attributeClassName) return;
-
- const newFilterResource: TSegmentAttributeFilter = {
- id: createId(),
- root: {
- type: "attribute",
- attributeClassName,
- },
- qualifier: {
- operator: "equals",
- },
- value: "",
- ...(isUserId && { meta: { isUserId } }),
- };
-
- const newFilter: TBaseFilter = {
- id: createId(),
- connector: "and",
- resource: newFilterResource,
- };
-
- onAddFilter(newFilter);
- setOpen(false);
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/components/attribute-segment-filter.tsx b/apps/web/modules/ui/components/basic-segment-editor/components/attribute-segment-filter.tsx
deleted file mode 100644
index 215a56cc2b..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/components/attribute-segment-filter.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { Input } from "@/modules/ui/components/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/modules/ui/components/select";
-import { TagIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
-import { z } from "zod";
-import { cn } from "@formbricks/lib/cn";
-import {
- convertOperatorToText,
- convertOperatorToTitle,
- updateAttributeClassNameInFilter,
- updateOperatorInFilter,
-} from "@formbricks/lib/segment/utils";
-import { isCapitalized } from "@formbricks/lib/utils/strings";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
-import {
- ARITHMETIC_OPERATORS,
- ATTRIBUTE_OPERATORS,
- TArithmeticOperator,
- TAttributeOperator,
- TSegment,
- TSegmentAttributeFilter,
- TSegmentConnector,
- TSegmentFilterValue,
-} from "@formbricks/types/segment";
-import { SegmentFilterItemConnector } from "./segment-filter-item-connector";
-import { SegmentFilterItemContextMenu } from "./segment-filter-item-context-menu";
-
-interface AttributeSegmentFilterProps {
- connector: TSegmentConnector;
- environmentId: string;
- segment: TSegment;
- attributeClasses: TAttributeClass[];
- setSegment: (segment: TSegment) => void;
- onDeleteFilter: (filterId: string) => void;
- onMoveFilter: (filterId: string, direction: "up" | "down") => void;
- viewOnly?: boolean;
- resource: TSegmentAttributeFilter;
- updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
-}
-
-export const AttributeSegmentFilter = ({
- connector,
- resource,
- onDeleteFilter,
- onMoveFilter,
- updateValueInLocalSurvey,
- segment,
- setSegment,
- attributeClasses,
- viewOnly,
-}: AttributeSegmentFilterProps) => {
- const { attributeClassName } = resource.root;
- const operatorText = convertOperatorToText(resource.qualifier.operator);
- const t = useTranslations();
- const [valueError, setValueError] = useState("");
-
- // when the operator changes, we need to check if the value is valid
- useEffect(() => {
- const { operator } = resource.qualifier;
-
- if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
- const isNumber = z.coerce.number().safeParse(resource.value);
-
- if (isNumber.success) {
- setValueError("");
- } else {
- setValueError("Value must be a number");
- }
- }
- }, [resource.qualifier, resource.value]);
-
- const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
- return {
- id: operator,
- name: convertOperatorToText(operator),
- };
- });
-
- const attributeClass = attributeClasses?.find((attrClass) => attrClass?.name === attributeClassName)?.name;
-
- const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- updateOperatorInFilter(updatedSegment.filters, filterId, newOperator);
- }
-
- setSegment(updatedSegment);
- };
-
- const updateAttributeClassNameInLocalSurvey = (filterId: string, newAttributeClassName: string) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- updateAttributeClassNameInFilter(updatedSegment.filters, filterId, newAttributeClassName);
- }
-
- setSegment(updatedSegment);
- };
-
- const checkValueAndUpdate = (e: React.ChangeEvent) => {
- const { value } = e.target;
- updateValueInLocalSurvey(resource.id, value);
-
- if (!value) {
- setValueError(t("environments.segments.value_cannot_be_empty"));
- return;
- }
-
- const { operator } = resource.qualifier;
-
- if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
- const isNumber = z.coerce.number().safeParse(value);
-
- if (isNumber.success) {
- setValueError("");
- updateValueInLocalSurvey(resource.id, parseInt(value, 10));
- } else {
- setValueError(t("environments.segments.value_must_be_a_number"));
- updateValueInLocalSurvey(resource.id, value);
- }
-
- return;
- }
-
- setValueError("");
- updateValueInLocalSurvey(resource.id, value);
- };
-
- return (
-
-
-
-
{
- updateAttributeClassNameInLocalSurvey(resource.id, value);
- }}
- disabled={viewOnly}>
-
-
-
-
-
-
-
- {attributeClasses
- ?.filter((attributeClass) => !attributeClass.archived)
- ?.map((attrClass) => (
-
- {attrClass.name}
-
- ))}
-
-
-
-
{
- updateOperatorInLocalSurvey(resource.id, operator);
- }}
- disabled={viewOnly}>
-
-
- {operatorText}
-
-
-
-
- {operatorArr.map((operator) => (
-
- {operator.name}
-
- ))}
-
-
-
- {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
-
-
{
- checkValueAndUpdate(e);
- }}
- className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
- />
-
- {valueError && (
-
- {valueError}
-
- )}
-
- )}
-
-
-
- );
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/components/basic-segment-filter.tsx b/apps/web/modules/ui/components/basic-segment-editor/components/basic-segment-filter.tsx
deleted file mode 100644
index 17128c9a40..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/components/basic-segment-filter.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { AttributeSegmentFilter } from "@/modules/ui/components/basic-segment-editor/components/attribute-segment-filter";
-import { PersonSegmentFilter } from "@/modules/ui/components/basic-segment-editor/components/person-segment-filter";
-import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
-import { updateFilterValue } from "@formbricks/lib/segment/utils";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
-import {
- TSegment,
- TSegmentAttributeFilter,
- TSegmentConnector,
- TSegmentFilter,
- TSegmentPersonFilter,
-} from "@formbricks/types/segment";
-
-interface BasicSegmentFilterProps {
- connector: TSegmentConnector;
- resource: TSegmentFilter;
- environmentId: string;
- segment: TSegment;
- attributeClasses: TAttributeClass[];
- setSegment: (segment: TSegment) => void;
- onDeleteFilter: (filterId: string) => void;
- onMoveFilter: (filterId: string, direction: "up" | "down") => void;
- viewOnly?: boolean;
-}
-
-export const BasicSegmentFilter = ({
- resource,
- connector,
- environmentId,
- segment,
- attributeClasses,
- setSegment,
- onDeleteFilter,
- onMoveFilter,
- viewOnly,
-}: BasicSegmentFilterProps) => {
- const updateFilterValueInSegment = (filterId: string, newValue: string | number) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- updateFilterValue(updatedSegment.filters, filterId, newValue);
- }
-
- setSegment(updatedSegment);
- };
-
- switch (resource.root.type) {
- case "attribute":
- return (
- <>
-
- >
- );
-
- case "person":
- return (
- <>
-
- >
- );
-
- default:
- return null;
- }
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/components/person-segment-filter.tsx b/apps/web/modules/ui/components/basic-segment-editor/components/person-segment-filter.tsx
deleted file mode 100644
index f27cd5d32d..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/components/person-segment-filter.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { SegmentFilterItemConnector } from "@/modules/ui/components/basic-segment-editor/components/segment-filter-item-connector";
-import { SegmentFilterItemContextMenu } from "@/modules/ui/components/basic-segment-editor/components/segment-filter-item-context-menu";
-import { Input } from "@/modules/ui/components/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/modules/ui/components/select";
-import { FingerprintIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
-import { z } from "zod";
-import { cn } from "@formbricks/lib/cn";
-import {
- convertOperatorToText,
- convertOperatorToTitle,
- updateOperatorInFilter,
- updatePersonIdentifierInFilter,
-} from "@formbricks/lib/segment/utils";
-import {
- ARITHMETIC_OPERATORS,
- PERSON_OPERATORS,
- TArithmeticOperator,
- TAttributeOperator,
- TSegment,
- TSegmentConnector,
- TSegmentFilterValue,
- TSegmentPersonFilter,
-} from "@formbricks/types/segment";
-
-interface PersonSegmentFilterProps {
- connector: TSegmentConnector;
- environmentId: string;
- segment: TSegment;
- setSegment: (segment: TSegment) => void;
- onDeleteFilter: (filterId: string) => void;
- onMoveFilter: (filterId: string, direction: "up" | "down") => void;
- viewOnly?: boolean;
- resource: TSegmentPersonFilter;
- updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
-}
-
-export const PersonSegmentFilter = ({
- connector,
- resource,
- onDeleteFilter,
- onMoveFilter,
- updateValueInLocalSurvey,
- segment,
- setSegment,
- viewOnly,
-}: PersonSegmentFilterProps) => {
- const t = useTranslations();
- const { personIdentifier } = resource.root;
- const operatorText = convertOperatorToText(resource.qualifier.operator);
-
- const [valueError, setValueError] = useState("");
-
- // when the operator changes, we need to check if the value is valid
- useEffect(() => {
- const { operator } = resource.qualifier;
-
- if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
- const isNumber = z.coerce.number().safeParse(resource.value);
-
- if (isNumber.success) {
- setValueError("");
- } else {
- setValueError(t("environments.segments.value_must_be_a_number"));
- }
- }
- }, [resource.qualifier, resource.value]);
-
- const operatorArr = PERSON_OPERATORS.map((operator) => {
- return {
- id: operator,
- name: convertOperatorToText(operator),
- };
- });
-
- const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- updateOperatorInFilter(updatedSegment.filters, filterId, newOperator);
- }
-
- setSegment(updatedSegment);
- };
-
- const updatePersonIdentifierInLocalSurvey = (filterId: string, newPersonIdentifier: string) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- updatePersonIdentifierInFilter(updatedSegment.filters, filterId, newPersonIdentifier);
- }
-
- setSegment(updatedSegment);
- };
-
- const checkValueAndUpdate = (e: React.ChangeEvent) => {
- const { value } = e.target;
- updateValueInLocalSurvey(resource.id, value);
-
- if (!value) {
- setValueError(t("environments.segments.value_cannot_be_empty"));
- return;
- }
-
- const { operator } = resource.qualifier;
-
- if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
- const isNumber = z.coerce.number().safeParse(value);
-
- if (isNumber.success) {
- setValueError("");
- updateValueInLocalSurvey(resource.id, parseInt(value, 10));
- } else {
- setValueError(t("environments.segments.value_must_be_a_number"));
- updateValueInLocalSurvey(resource.id, value);
- }
-
- return;
- }
-
- setValueError("");
- updateValueInLocalSurvey(resource.id, value);
- };
-
- return (
-
-
-
-
{
- updatePersonIdentifierInLocalSurvey(resource.id, value);
- }}
- disabled={viewOnly}>
-
-
-
-
-
-
-
-
- {personIdentifier}
-
-
-
-
-
{
- updateOperatorInLocalSurvey(resource.id, operator);
- }}
- disabled={viewOnly}>
-
-
- {operatorText}
-
-
-
-
- {operatorArr.map((operator) => (
-
- {operator.name}
-
- ))}
-
-
-
- {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
-
-
{
- checkValueAndUpdate(e);
- }}
- className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
- disabled={viewOnly}
- />
-
- {valueError && (
-
- {valueError}
-
- )}
-
- )}
-
-
-
- );
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-connector.tsx b/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-connector.tsx
deleted file mode 100644
index 0aced8d66e..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-connector.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { cn } from "@formbricks/lib/cn";
-import { toggleFilterConnector } from "@formbricks/lib/segment/utils";
-import { TSegment, TSegmentConnector } from "@formbricks/types/segment";
-
-interface SegmentFilterItemConnectorProps {
- connector: TSegmentConnector;
- segment: TSegment;
- setSegment: (segment: TSegment) => void;
- filterId: string;
- viewOnly?: boolean;
-}
-
-export const SegmentFilterItemConnector = ({
- connector,
- segment,
- setSegment,
- filterId,
- viewOnly,
-}: SegmentFilterItemConnectorProps) => {
- const updateLocalSurvey = (newConnector: TSegmentConnector) => {
- const updatedSegment = structuredClone(segment);
- if (updatedSegment.filters) {
- toggleFilterConnector(updatedSegment.filters, filterId, newConnector);
- }
-
- setSegment(updatedSegment);
- };
-
- const onConnectorChange = () => {
- if (!connector) return;
-
- if (connector === "and") {
- updateLocalSurvey("or");
- } else {
- updateLocalSurvey("and");
- }
- };
-
- return (
-
- {
- if (viewOnly) return;
- onConnectorChange();
- }}>
- {!!connector ? connector : "Where"}
-
-
- );
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-context-menu.tsx b/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-context-menu.tsx
deleted file mode 100644
index 1718f54b0c..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/components/segment-filter-item-context-menu.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Button } from "@/modules/ui/components/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/modules/ui/components/dropdown-menu";
-import { ArrowDownIcon, ArrowUpIcon, EllipsisVerticalIcon, Trash2Icon } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { cn } from "@formbricks/lib/cn";
-
-interface SegmentFilterItemContextMenuProps {
- filterId: string;
- onDeleteFilter: (filterId: string) => void;
- onMoveFilter: (filterId: string, direction: "up" | "down") => void;
- viewOnly?: boolean;
-}
-
-export const SegmentFilterItemContextMenu = ({
- filterId,
- onDeleteFilter,
- onMoveFilter,
- viewOnly,
-}: SegmentFilterItemContextMenuProps) => {
- const t = useTranslations();
- return (
-
-
-
-
-
-
-
- onMoveFilter(filterId, "up")}
- icon={ }>
- {t("common.move_up")}
-
- onMoveFilter(filterId, "down")}
- icon={ }>
- {t("common.move_down")}
-
-
-
-
-
{
- onDeleteFilter(filterId);
- }}
- disabled={viewOnly}>
-
-
-
- );
-};
diff --git a/apps/web/modules/ui/components/basic-segment-editor/index.tsx b/apps/web/modules/ui/components/basic-segment-editor/index.tsx
deleted file mode 100644
index d2b1fe1a36..0000000000
--- a/apps/web/modules/ui/components/basic-segment-editor/index.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useAutoAnimate } from "@formkit/auto-animate/react";
-import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
-import { deleteResource, isResourceFilter, moveResource } from "@formbricks/lib/segment/utils";
-import { TAttributeClass } from "@formbricks/types/attribute-classes";
-import { TBaseFilters, TSegment } from "@formbricks/types/segment";
-import { BasicSegmentFilter } from "./components/basic-segment-filter";
-
-interface BasicSegmentEditorProps {
- group: TBaseFilters;
- environmentId: string;
- segment: TSegment;
- attributeClasses: TAttributeClass[];
- setSegment: React.Dispatch>;
- viewOnly?: boolean;
-}
-
-export const BasicSegmentEditor = ({
- group,
- environmentId,
- setSegment,
- segment,
- attributeClasses,
- viewOnly,
-}: BasicSegmentEditorProps) => {
- const [parent] = useAutoAnimate();
- const handleMoveResource = (resourceId: string, direction: "up" | "down") => {
- const localSegmentCopy = structuredClone(segment);
- if (localSegmentCopy.filters) {
- moveResource(localSegmentCopy.filters, resourceId, direction);
- }
-
- setSegment(localSegmentCopy);
- };
-
- const handleDeleteResource = (resourceId: string) => {
- const localSegmentCopy = structuredClone(segment);
-
- if (localSegmentCopy.filters) {
- deleteResource(localSegmentCopy.filters, resourceId);
- }
-
- setSegment(localSegmentCopy);
- };
-
- return (
-
- {group?.map((groupItem) => {
- const { connector, resource, id: groupId } = groupItem;
-
- if (isResourceFilter(resource)) {
- return (
- handleDeleteResource(filterId)}
- onMoveFilter={(filterId: string, direction: "up" | "down") =>
- handleMoveResource(filterId, direction)
- }
- viewOnly={viewOnly}
- />
- );
- } else {
- return null;
- }
- })}
-
- );
-};
diff --git a/apps/web/modules/ui/components/command/index.tsx b/apps/web/modules/ui/components/command/index.tsx
index 61f03f0bea..210847e2e9 100644
--- a/apps/web/modules/ui/components/command/index.tsx
+++ b/apps/web/modules/ui/components/command/index.tsx
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
({ header, setIsTableSettingsModalOpen }: Dat
whiteSpace: "nowrap",
width: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
-
...(header.column.id === "select" ? getCommonPinningStyles(header.column) : {}),
};
diff --git a/apps/web/modules/ui/components/data-table/components/data-table-settings-modal-item.tsx b/apps/web/modules/ui/components/data-table/components/data-table-settings-modal-item.tsx
index 9fd2b66c44..efbd521f79 100644
--- a/apps/web/modules/ui/components/data-table/components/data-table-settings-modal-item.tsx
+++ b/apps/web/modules/ui/components/data-table/components/data-table-settings-modal-item.tsx
@@ -36,6 +36,15 @@ export const DataTableSettingsModalItem = ({ column, survey }: DataTableSett
return t("environments.surveys.edit.zip");
case "verifiedEmail":
return t("common.verified_email");
+ case "userId":
+ return t("common.user_id");
+ case "contactsTableUser":
+ return "ID";
+ case "firstName":
+ return t("environments.contacts.first_name");
+ case "lastName":
+ return t("environments.contacts.last_name");
+
default:
return capitalize(column.id);
}
diff --git a/apps/web/modules/ui/components/data-table/components/data-table-settings-modal.tsx b/apps/web/modules/ui/components/data-table/components/data-table-settings-modal.tsx
index e4366b9cbd..bb4dbac0a1 100644
--- a/apps/web/modules/ui/components/data-table/components/data-table-settings-modal.tsx
+++ b/apps/web/modules/ui/components/data-table/components/data-table-settings-modal.tsx
@@ -11,6 +11,7 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { Table } from "@tanstack/react-table";
import { SettingsIcon } from "lucide-react";
import { useTranslations } from "next-intl";
+import { useMemo } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { DataTableSettingsModalItem } from "./data-table-settings-modal-item";
@@ -40,6 +41,8 @@ export const DataTableSettingsModal = ({
})
);
+ const tableColumns = useMemo(() => table.getAllColumns(), [table]);
+
return (
@@ -65,7 +68,7 @@ export const DataTableSettingsModal =
({
{columnOrder.map((columnId) => {
if (columnId === "select" || columnId === "createdAt") return;
- const column = table.getAllColumns().find((column) => column.id === columnId);
+ const column = tableColumns.find((column) => column.id === columnId);
if (!column) return null;
return ;
})}
diff --git a/apps/web/modules/ui/components/data-table/components/data-table-toolbar.tsx b/apps/web/modules/ui/components/data-table/components/data-table-toolbar.tsx
index b8fa8e777d..cbb24fdd6c 100644
--- a/apps/web/modules/ui/components/data-table/components/data-table-toolbar.tsx
+++ b/apps/web/modules/ui/components/data-table/components/data-table-toolbar.tsx
@@ -1,6 +1,6 @@
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Table } from "@tanstack/react-table";
-import { MoveVerticalIcon, SettingsIcon } from "lucide-react";
+import { MoveVerticalIcon, RefreshCcwIcon, SettingsIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
import { SelectedRowSettings } from "./selected-row-settings";
@@ -11,8 +11,9 @@ interface DataTableToolbarProps {
isExpanded: boolean;
table: Table;
deleteRows: (rowIds: string[]) => void;
- type: "person" | "response";
+ type: "response" | "contact";
deleteAction: (id: string) => Promise;
+ refreshContacts?: () => void;
}
export const DataTableToolbar = ({
@@ -23,8 +24,10 @@ export const DataTableToolbar = ({
deleteRows,
type,
deleteAction,
+ refreshContacts,
}: DataTableToolbarProps) => {
const t = useTranslations();
+
return (
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
@@ -33,6 +36,18 @@ export const DataTableToolbar =
({
)}
+ {type === "contact" ? (
+
+ refreshContacts?.()}
+ className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
+
+
+
+ ) : null}
+
setIsTableSettingsModalOpen(true)}
diff --git a/apps/web/modules/ui/components/data-table/components/selected-row-settings.tsx b/apps/web/modules/ui/components/data-table/components/selected-row-settings.tsx
index 6da7d4b166..edd2b274b5 100644
--- a/apps/web/modules/ui/components/data-table/components/selected-row-settings.tsx
+++ b/apps/web/modules/ui/components/data-table/components/selected-row-settings.tsx
@@ -9,7 +9,7 @@ import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
interface SelectedRowSettingsProps
{
table: Table;
deleteRows: (rowId: string[]) => void;
- type: "response" | "person";
+ type: "response" | "contact";
deleteAction: (id: string) => Promise;
}
@@ -40,8 +40,8 @@ export const SelectedRowSettings = ({
if (type === "response") {
await Promise.all(rowsToBeDeleted.map((responseId) => deleteAction(responseId)));
- } else if (type === "person") {
- await Promise.all(rowsToBeDeleted.map((personId) => deleteAction(personId)));
+ } else if (type === "contact") {
+ await Promise.all(rowsToBeDeleted.map((responseId) => deleteAction(responseId)));
}
deleteRows(rowsToBeDeleted);
@@ -75,7 +75,7 @@ export const SelectedRowSettings = ({
return (
- {selectedRowCount} {type}s selected
+ {selectedRowCount} {t(`common.${type}`)}s {t("common.selected")}
handleToggleAllRowsSelection(true)} />
@@ -93,7 +93,7 @@ export const SelectedRowSettings = ({
diff --git a/apps/web/modules/ui/components/modal/index.tsx b/apps/web/modules/ui/components/modal/index.tsx
index 473e5271a3..4c44448dfa 100644
--- a/apps/web/modules/ui/components/modal/index.tsx
+++ b/apps/web/modules/ui/components/modal/index.tsx
@@ -31,7 +31,7 @@ const sizeClassName = {
md: "sm:max-w-xl",
lg: "sm:max-w-[820px]",
xl: "sm:max-w-[960px]",
- xxl: "sm:max-w-[1240px]",
+ xxl: "sm:max-w-[1240px] sm:max-h-[760px]",
};
const DialogContent = React.forwardRef<
diff --git a/apps/web/modules/ui/components/pro-badge/index.tsx b/apps/web/modules/ui/components/pro-badge/index.tsx
index ca927721da..bba7295c51 100644
--- a/apps/web/modules/ui/components/pro-badge/index.tsx
+++ b/apps/web/modules/ui/components/pro-badge/index.tsx
@@ -4,6 +4,7 @@ export const ProBadge = () => {
return (
+ PRO
);
};
diff --git a/apps/web/modules/ui/components/upgrade-prompt/index.tsx b/apps/web/modules/ui/components/upgrade-prompt/index.tsx
new file mode 100644
index 0000000000..e9949d92cd
--- /dev/null
+++ b/apps/web/modules/ui/components/upgrade-prompt/index.tsx
@@ -0,0 +1,43 @@
+import { Button } from "@/modules/ui/components/button";
+
+export type ModalButton = {
+ text: string;
+ href?: string;
+ onClick?: () => void;
+};
+
+interface UpgradePromptProps {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ buttons: [ModalButton, ModalButton];
+}
+
+export const UpgradePrompt = ({ icon, title, description, buttons }: UpgradePromptProps) => {
+ const [primaryButton, secondaryButton] = buttons;
+
+ return (
+
+
{icon}
+
+
{title}
+
{description}
+
+
+
+ {primaryButton.text}
+
+
+ {secondaryButton.text}
+
+
+
+ );
+};
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index e0b2ec38d6..8ccc5bb8c5 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -239,6 +239,26 @@ const nextConfig = {
source: "/api/v1/client/:environmentId/app/people/:userId",
destination: "/api/v1/client/:environmentId/identify/people/:userId",
},
+ {
+ source: "/api/v1/client/:environmentId/identify/people/:userId",
+ destination: "/api/v1/client/:environmentId/identify/contacts/:userId",
+ },
+ {
+ source: "/api/v1/client/:environmentId/people/:userId/attributes",
+ destination: "/api/v1/client/:environmentId/contacts/:userId/attributes",
+ },
+ {
+ source: "/api/v1/management/people/:id*",
+ destination: "/api/v1/management/contacts/:id*",
+ },
+ {
+ source: "/api/v1/management/attribute-classes",
+ destination: "/api/v1/management/contact-attribute-keys",
+ },
+ {
+ source: "/api/v1/management/attribute-classes/:id*",
+ destination: "/api/v1/management/contact-attribute-keys/:id*",
+ },
];
},
env: {
@@ -255,6 +275,7 @@ if (process.env.CUSTOM_CACHE_DISABLED !== "1") {
if (process.env.WEBAPP_URL) {
nextConfig.experimental.serverActions = {
allowedOrigins: [process.env.WEBAPP_URL.replace(/https?:\/\//, "")],
+ bodySizeLimit: "2mb",
};
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 2019c83112..aba110a613 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -71,6 +71,7 @@
"class-variance-authority": "0.7.0",
"clsx": "2.1.1",
"cmdk": "1.0.0",
+ "csv-parse": "5.5.6",
"dotenv": "16.4.5",
"encoding": "0.1.13",
"file-loader": "6.2.0",
@@ -119,7 +120,9 @@
"@types/bcryptjs": "2.4.6",
"@types/lodash": "4.17.10",
"@types/markdown-it": "14.1.2",
+ "@types/nodemailer": "6.4.17",
"@types/papaparse": "5.3.14",
- "@types/qrcode": "1.5.5"
+ "@types/qrcode": "1.5.5",
+ "nodemailer": "6.9.16"
}
}
diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts
index 74599b19ec..c1d2cf2efd 100644
--- a/apps/web/playwright/fixtures/users.ts
+++ b/apps/web/playwright/fixtures/users.ts
@@ -91,6 +91,16 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo) => {
},
],
},
+ attributeKeys: {
+ create: [
+ {
+ name: "userId",
+ key: "userId",
+ isUnique: true,
+ type: "default",
+ },
+ ],
+ },
},
{
type: "production",
@@ -103,6 +113,16 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo) => {
},
],
},
+ attributeKeys: {
+ create: [
+ {
+ name: "userId",
+ key: "userId",
+ isUnique: true,
+ type: "default",
+ },
+ ],
+ },
},
],
},
diff --git a/packages/api/src/api/client/attribute.ts b/packages/api/src/api/client/attribute.ts
index f99d40a72f..e3b3879571 100644
--- a/packages/api/src/api/client/attribute.ts
+++ b/packages/api/src/api/client/attribute.ts
@@ -1,6 +1,6 @@
import { type TAttributeUpdateInput } from "@formbricks/types/attributes";
import { type Result } from "@formbricks/types/error-handlers";
-import { type NetworkError } from "@formbricks/types/errors";
+import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class AttributeAPI {
@@ -14,7 +14,12 @@ export class AttributeAPI {
async update(
attributeUpdateInput: Omit
- ): Promise> {
+ ): Promise<
+ Result<
+ { changed: boolean; message: string; details?: Record },
+ NetworkError | Error | ForbiddenError
+ >
+ > {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record = {};
for (const key in attributeUpdateInput.attributes) {
@@ -23,7 +28,7 @@ export class AttributeAPI {
return makeRequest(
this.apiHost,
- `/api/v1/client/${this.environmentId}/people/${attributeUpdateInput.userId}/attributes`,
+ `/api/v1/client/${this.environmentId}/contacts/${attributeUpdateInput.userId}/attributes`,
"PUT",
{ attributes }
);
diff --git a/packages/api/src/api/client/display.ts b/packages/api/src/api/client/display.ts
index a01da13d37..4f3113ec19 100644
--- a/packages/api/src/api/client/display.ts
+++ b/packages/api/src/api/client/display.ts
@@ -1,6 +1,6 @@
import { type TDisplayCreateInput } from "@formbricks/types/displays";
import { type Result } from "@formbricks/types/error-handlers";
-import { type NetworkError } from "@formbricks/types/errors";
+import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class DisplayAPI {
@@ -14,7 +14,7 @@ export class DisplayAPI {
async create(
displayInput: Omit
- ): Promise> {
+ ): Promise> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
}
}
diff --git a/packages/api/src/api/client/index.ts b/packages/api/src/api/client/index.ts
index 620bb8b3f4..e1a5704490 100644
--- a/packages/api/src/api/client/index.ts
+++ b/packages/api/src/api/client/index.ts
@@ -1,14 +1,12 @@
import { type ApiConfig } from "../../types";
import { AttributeAPI } from "./attribute";
import { DisplayAPI } from "./display";
-import { PeopleAPI } from "./people";
import { ResponseAPI } from "./response";
import { StorageAPI } from "./storage";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
- people: PeopleAPI;
storage: StorageAPI;
attribute: AttributeAPI;
@@ -17,7 +15,6 @@ export class Client {
this.response = new ResponseAPI(apiHost, environmentId);
this.display = new DisplayAPI(apiHost, environmentId);
- this.people = new PeopleAPI(apiHost, environmentId);
this.attribute = new AttributeAPI(apiHost, environmentId);
this.storage = new StorageAPI(apiHost, environmentId);
}
diff --git a/packages/api/src/api/client/people.ts b/packages/api/src/api/client/people.ts
deleted file mode 100644
index 96c316fb20..0000000000
--- a/packages/api/src/api/client/people.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { type Result } from "@formbricks/types/error-handlers";
-import { type NetworkError } from "@formbricks/types/errors";
-import { makeRequest } from "../../utils/make-request";
-
-export class PeopleAPI {
- private apiHost: string;
- private environmentId: string;
-
- constructor(apiHost: string, environmentId: string) {
- this.apiHost = apiHost;
- this.environmentId = environmentId;
- }
-
- async create(userId: string): Promise> {
- return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/people`, "POST", {
- environmentId: this.environmentId,
- userId,
- });
- }
-}
diff --git a/packages/api/src/api/client/response.ts b/packages/api/src/api/client/response.ts
index c510525cc1..ccd385ea0e 100644
--- a/packages/api/src/api/client/response.ts
+++ b/packages/api/src/api/client/response.ts
@@ -1,5 +1,5 @@
import { type Result } from "@formbricks/types/error-handlers";
-import { type NetworkError } from "@formbricks/types/errors";
+import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import { type TResponseInput, type TResponseUpdateInput } from "@formbricks/types/responses";
import { makeRequest } from "../../utils/make-request";
@@ -16,7 +16,7 @@ export class ResponseAPI {
async create(
responseInput: Omit
- ): Promise> {
+ ): Promise> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}
@@ -28,7 +28,7 @@ export class ResponseAPI {
ttc,
variables,
language,
- }: TResponseUpdateInputWithResponseId): Promise> {
+ }: TResponseUpdateInputWithResponseId): Promise> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
endingId,
diff --git a/packages/api/src/utils/make-request.ts b/packages/api/src/utils/make-request.ts
index 6624b37bcb..33ed9349eb 100644
--- a/packages/api/src/utils/make-request.ts
+++ b/packages/api/src/utils/make-request.ts
@@ -1,5 +1,5 @@
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
-import { type NetworkError } from "@formbricks/types/errors";
+import { type ForbiddenError, type NetworkError } from "@formbricks/types/errors";
import type { ApiErrorResponse, ApiResponse, ApiSuccessResponse } from "../types";
export const makeRequest = async (
@@ -7,7 +7,7 @@ export const makeRequest = async (
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE",
data?: unknown
-): Promise> => {
+): Promise> => {
const url = new URL(apiHost + endpoint);
const body = data ? JSON.stringify(data) : undefined;
@@ -27,7 +27,7 @@ export const makeRequest = async (
if (!response.ok) {
const errorResponse = json as ApiErrorResponse;
return err({
- code: "network_error",
+ code: errorResponse.code === "forbidden" ? "forbidden" : "network_error",
status: response.status,
message: errorResponse.message || "Something went wrong",
url,
diff --git a/packages/database/data-migrations/20241010133706_xm_user_identification/data-migration.ts b/packages/database/data-migrations/20241010133706_xm_user_identification/data-migration.ts
new file mode 100644
index 0000000000..b7ecb1bae5
--- /dev/null
+++ b/packages/database/data-migrations/20241010133706_xm_user_identification/data-migration.ts
@@ -0,0 +1,272 @@
+
+
+/* eslint-disable no-constant-condition -- Required for the while loop */
+
+/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
+
+/* eslint-disable no-console -- logging is allowed in migration scripts */
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
+
+async function runMigration(): Promise {
+ const startTime = Date.now();
+ console.log("Starting data migration...");
+
+ await prisma.$transaction(
+ async (tx) => {
+ const totalContacts = await tx.contact.count();
+
+ // Check if any contacts still have a userId
+ const contactsWithUserId = await tx.contact.count({
+ where: {
+ userId: { not: null },
+ },
+ });
+
+ // If no contacts have a userId, migration is already complete
+ if (totalContacts > 0 && contactsWithUserId === 0) {
+ console.log("Migration already completed. No contacts with userId found.");
+ return;
+ }
+
+ const BATCH_SIZE = 10000; // Adjust based on your system's capacity
+ let skip = 0;
+
+ while (true) {
+ // Ensure email, firstName, lastName attributeKeys exist for all environments
+ const allEnvironmentsInBatch = await tx.environment.findMany({
+ select: { id: true },
+ skip,
+ take: BATCH_SIZE,
+ });
+
+ if (allEnvironmentsInBatch.length === 0) {
+ break;
+ }
+
+ console.log("Processing attributeKeys for", allEnvironmentsInBatch.length, "environments");
+
+ for (const env of allEnvironmentsInBatch) {
+ await tx.environment.update({
+ where: { id: env.id },
+ data: {
+ attributeKeys: {
+ upsert: [
+ {
+ where: {
+ key_environmentId: {
+ key: "email",
+ environmentId: env.id,
+ },
+ },
+ update: {
+ type: "default",
+ isUnique: true,
+ },
+ create: {
+ key: "email",
+ name: "Email",
+ description: "The email of a contact",
+ type: "default",
+ isUnique: true,
+ },
+ },
+ {
+ where: {
+ key_environmentId: {
+ key: "firstName",
+ environmentId: env.id,
+ },
+ },
+ update: {
+ type: "default",
+ },
+ create: {
+ key: "firstName",
+ name: "First Name",
+ description: "Your contact's first name",
+ type: "default",
+ },
+ },
+ {
+ where: {
+ key_environmentId: {
+ key: "lastName",
+ environmentId: env.id,
+ },
+ },
+ update: {
+ type: "default",
+ },
+ create: {
+ key: "lastName",
+ name: "Last Name",
+ description: "Your contact's last name",
+ type: "default",
+ },
+ },
+ {
+ where: {
+ key_environmentId: {
+ key: "userId",
+ environmentId: env.id,
+ },
+ },
+ update: {
+ type: "default",
+ isUnique: true,
+ },
+ create: {
+ key: "userId",
+ name: "User ID",
+ description: "The user ID of a contact",
+ type: "default",
+ isUnique: true,
+ },
+ },
+ ],
+ },
+ },
+ });
+ }
+
+ skip += allEnvironmentsInBatch.length;
+ }
+
+ const CONTACTS_BATCH_SIZE = 20000;
+ let processedContacts = 0;
+
+ // delete userIds for these environments:
+ const { count } = await tx.contactAttribute.deleteMany({
+ where: {
+ attributeKey: {
+ key: "userId",
+ },
+ },
+ });
+
+ console.log("Deleted userId attributes for", count, "contacts");
+
+ while (true) {
+ const contacts = await tx.contact.findMany({
+ take: CONTACTS_BATCH_SIZE,
+ select: {
+ id: true,
+ userId: true,
+ environmentId: true,
+ },
+ where: {
+ userId: { not: null },
+ },
+ });
+
+ if (contacts.length === 0) {
+ break;
+ }
+
+ const environmentIdsByContacts = contacts.map((c) => c.environmentId);
+
+ const attributeMap = new Map();
+
+ const userIdAttributeKeys = await tx.contactAttributeKey.findMany({
+ where: {
+ key: "userId",
+ environmentId: {
+ in: environmentIdsByContacts,
+ },
+ },
+ select: { id: true, environmentId: true },
+ });
+
+ userIdAttributeKeys.forEach((ak) => {
+ attributeMap.set(ak.environmentId, ak.id);
+ });
+
+ // Insert contactAttributes in bulk
+ await tx.contactAttribute.createMany({
+ data: contacts.map((contact) => {
+ if (!contact.userId) {
+ throw new Error(`Contact with id ${contact.id} has no userId`);
+ }
+
+ const userIdAttributeKey = attributeMap.get(contact.environmentId);
+
+ if (!userIdAttributeKey) {
+ throw new Error(`Attribute key for userId not found for environment ${contact.environmentId}`);
+ }
+
+ return {
+ contactId: contact.id,
+ value: contact.userId,
+ attributeKeyId: userIdAttributeKey,
+ };
+ }),
+ });
+
+ await tx.contact.updateMany({
+ where: {
+ id: { in: contacts.map((c) => c.id) },
+ },
+ data: {
+ userId: null,
+ },
+ });
+
+ processedContacts += contacts.length;
+
+ if (processedContacts > 0) {
+ console.log(`Processed ${processedContacts.toString()} contacts`);
+ }
+ }
+
+ const totalContactsAfterMigration = await tx.contact.count();
+
+ console.log("Total contacts after migration:", totalContactsAfterMigration);
+
+ // total attributes with userId:
+ const totalAttributes = await tx.contactAttribute.count({
+ where: {
+ attributeKey: {
+ key: "userId",
+ },
+ },
+ });
+
+ console.log("Total attributes with userId now:", totalAttributes);
+
+ if (totalContactsAfterMigration !== totalAttributes) {
+ throw new Error(
+ "Data migration failed. Total contacts after migration does not match total attributes with userId"
+ );
+ }
+ },
+ {
+ timeout: TRANSACTION_TIMEOUT,
+ }
+ );
+
+ const endTime = Date.now();
+ console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
+}
+
+function handleError(error: unknown): void {
+ console.error("An error occurred during migration:", error);
+ process.exit(1);
+}
+
+function handleDisconnectError(): void {
+ console.error("Failed to disconnect Prisma client");
+ process.exit(1);
+}
+
+function main(): void {
+ runMigration()
+ .catch(handleError)
+ .finally(() => {
+ prisma.$disconnect().catch(handleDisconnectError);
+ });
+}
+
+main();
diff --git a/packages/database/data-migrations/20241021123456_xm_segment_migration/data-migration.ts b/packages/database/data-migrations/20241021123456_xm_segment_migration/data-migration.ts
new file mode 100644
index 0000000000..dacc9ab0e2
--- /dev/null
+++ b/packages/database/data-migrations/20241021123456_xm_segment_migration/data-migration.ts
@@ -0,0 +1,102 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
+
+/* eslint-disable no-console -- logging is allowed in migration scripts */
+import { PrismaClient } from "@prisma/client";
+import type { TBaseFilters, TSegmentAttributeFilter, TSegmentFilter } from "../../../types/segment";
+
+const prisma = new PrismaClient();
+const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
+
+export const isResourceFilter = (resource: TSegmentFilter | TBaseFilters): resource is TSegmentFilter => {
+ return (resource as TSegmentFilter).root !== undefined;
+};
+
+const findAndReplace = (filters: TBaseFilters): TBaseFilters => {
+ const newFilters: TBaseFilters = [];
+ for (const filter of filters) {
+ if (isResourceFilter(filter.resource)) {
+ let { root } = filter.resource;
+ if (root.type === "attribute") {
+ // @ts-expect-error -- Legacy type
+ if (root.attributeClassName as string) {
+ root = {
+ type: "attribute",
+ // @ts-expect-error -- Legacy type
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Legacy type
+ contactAttributeKey: root.attributeClassName,
+ };
+
+ const newFilter = {
+ ...filter.resource,
+ root,
+ } as TSegmentAttributeFilter;
+
+ newFilters.push({
+ ...filter,
+ resource: newFilter,
+ });
+ }
+ } else {
+ newFilters.push(filter);
+ }
+ } else {
+ const updatedResource = findAndReplace(filter.resource);
+ newFilters.push({
+ ...filter,
+ resource: updatedResource,
+ });
+ }
+ }
+
+ return newFilters;
+};
+
+async function runMigration(): Promise {
+ const startTime = Date.now();
+ console.log("Starting data migration...");
+
+ await prisma.$transaction(
+ async (tx) => {
+ const allSegments = await tx.segment.findMany();
+ const updationPromises = [];
+ for (const segment of allSegments) {
+ updationPromises.push(
+ tx.segment.update({
+ where: { id: segment.id },
+ data: {
+ filters: findAndReplace(segment.filters),
+ },
+ })
+ );
+ }
+
+ await Promise.all(updationPromises);
+ },
+ {
+ timeout: TRANSACTION_TIMEOUT,
+ }
+ );
+
+ const endTime = Date.now();
+ console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
+}
+
+function handleError(error: unknown): void {
+ console.error("An error occurred during migration:", error);
+ process.exit(1);
+}
+
+function handleDisconnectError(): void {
+ console.error("Failed to disconnect Prisma client");
+ process.exit(1);
+}
+
+function main(): void {
+ runMigration()
+ .catch(handleError)
+ .finally(() => {
+ prisma.$disconnect().catch(handleDisconnectError);
+ });
+}
+
+main();
diff --git a/packages/database/data-migrations/20241024123456_xm_attribute_removal/data-migration.ts b/packages/database/data-migrations/20241024123456_xm_attribute_removal/data-migration.ts
new file mode 100644
index 0000000000..96ecbc8244
--- /dev/null
+++ b/packages/database/data-migrations/20241024123456_xm_attribute_removal/data-migration.ts
@@ -0,0 +1,143 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
+
+/* eslint-disable no-console -- logging is allowed in migration scripts */
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
+
+async function runMigration(): Promise {
+ const startTime = Date.now();
+ console.log("Starting data migration...");
+
+ await prisma.$transaction(
+ async (tx) => {
+ const emailAttributes = await tx.contactAttribute.findMany({
+ where: {
+ attributeKey: {
+ key: "email",
+ },
+ },
+ select: {
+ id: true,
+ value: true,
+ contact: {
+ select: {
+ id: true,
+ environmentId: true,
+ createdAt: true,
+ },
+ },
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: "asc", // Keep oldest attribute
+ },
+ });
+
+ // 2. Group by environment and email
+ const emailsByEnvironment: Record<
+ // environmentId key
+ string,
+ // email record
+ Record
+ > = {};
+
+ // Group attributes by environment and email
+ for (const attr of emailAttributes) {
+ const { environmentId } = attr.contact;
+ const email = attr.value;
+
+ if (!emailsByEnvironment[environmentId]) {
+ emailsByEnvironment[environmentId] = {};
+ }
+
+ if (!emailsByEnvironment[environmentId][email]) {
+ emailsByEnvironment[environmentId][email] = [];
+ }
+
+ emailsByEnvironment[environmentId][email].push({
+ id: attr.id,
+ contactId: attr.contact.id,
+ createdAt: attr.createdAt,
+ });
+ }
+
+ // 3. Identify and delete duplicates
+ const deletionSummary: Record<
+ string,
+ {
+ email: string;
+ deletedAttributeIds: string[];
+ keptAttributeId: string;
+ }[]
+ > = {};
+
+ for (const [environmentId, emailGroups] of Object.entries(emailsByEnvironment)) {
+ deletionSummary[environmentId] = [];
+
+ for (const [email, attributes] of Object.entries(emailGroups)) {
+ if (attributes.length > 1) {
+ // Sort by createdAt to ensure we keep the oldest
+ attributes.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+
+ // Keep the first (oldest) attribute and delete the rest
+ const [kept, ...duplicates] = attributes;
+ const duplicateIds = duplicates.map((d) => d.id);
+
+ // Delete duplicate attributes
+ await tx.contactAttribute.deleteMany({
+ where: {
+ id: {
+ in: duplicateIds,
+ },
+ },
+ });
+
+ deletionSummary[environmentId].push({
+ email,
+ deletedAttributeIds: duplicateIds,
+ keptAttributeId: kept.id,
+ });
+ }
+ }
+ }
+
+ // 4. Return summary of what was cleaned up
+ const summary = {
+ totalDuplicateAttributesRemoved: Object.values(deletionSummary).reduce(
+ (acc, env) => acc + env.reduce((sum, item) => sum + item.deletedAttributeIds.length, 0),
+ 0
+ ),
+ };
+
+ console.log("Data migration completed. Summary: ", summary);
+ },
+ {
+ timeout: TRANSACTION_TIMEOUT,
+ }
+ );
+
+ const endTime = Date.now();
+ console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
+}
+
+function handleError(error: unknown): void {
+ console.error("An error occurred during migration:", error);
+ process.exit(1);
+}
+
+function handleDisconnectError(): void {
+ console.error("Failed to disconnect Prisma client");
+ process.exit(1);
+}
+
+function main(): void {
+ runMigration()
+ .catch(handleError)
+ .finally(() => {
+ prisma.$disconnect().catch(handleDisconnectError);
+ });
+}
+
+main();
diff --git a/packages/database/json-types.ts b/packages/database/json-types.ts
index 0868183be0..147d36f0e4 100644
--- a/packages/database/json-types.ts
+++ b/packages/database/json-types.ts
@@ -5,7 +5,7 @@ import { type TActionClassNoCodeConfig } from "../types/action-classes";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProjectConfig, type TProjectStyling } from "../types/project";
-import { type TResponseData, type TResponseMeta, type TResponsePersonAttributes } from "../types/responses";
+import { type TResponseContactAttributes, type TResponseData, type TResponseMeta } from "../types/responses";
import { type TBaseFilters } from "../types/segment";
import {
type TSurveyClosedMessage,
@@ -18,7 +18,7 @@ import {
type TSurveyVariables,
type TSurveyWelcomeCard,
} from "../types/surveys/types";
-import { type TUserLocale, type TUserNotificationSettings } from "../types/user";
+import type { TUserLocale, TUserNotificationSettings } from "../types/user";
import type { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "./types/survey-follow-up";
declare global {
@@ -29,7 +29,7 @@ declare global {
export type ProjectConfig = TProjectConfig;
export type ResponseData = TResponseData;
export type ResponseMeta = TResponseMeta;
- export type ResponsePersonAttributes = TResponsePersonAttributes;
+ export type ResponseContactAttributes = TResponseContactAttributes;
export type SurveyWelcomeCard = TSurveyWelcomeCard;
export type SurveyQuestions = TSurveyQuestions;
export type SurveyEnding = TSurveyEnding;
diff --git a/packages/database/migrations/20241010133706_xm_user_identification/migration.sql b/packages/database/migrations/20241010133706_xm_user_identification/migration.sql
new file mode 100644
index 0000000000..7c733e69cf
--- /dev/null
+++ b/packages/database/migrations/20241010133706_xm_user_identification/migration.sql
@@ -0,0 +1,102 @@
+-- Rename table "Person" to "Contact"
+ALTER TABLE "Person" RENAME TO "Contact";
+ALTER TABLE "Contact" RENAME CONSTRAINT "Person_pkey" TO "Contact_pkey";
+ALTER TABLE "Contact" RENAME CONSTRAINT "Person_environmentId_fkey" TO "Contact_environmentId_fkey";
+-- Rename column "personId" to "contactId" in "Attribute" table
+ALTER TABLE "Attribute" RENAME COLUMN "personId" TO "contactId";
+
+-- Rename column "personId" to "contactId" in "Response" table
+ALTER TABLE "Response" RENAME COLUMN "personId" TO "contactId";
+ALTER TABLE "Response" RENAME COLUMN "personAttributes" TO "contactAttributes";
+
+-- Rename column "personId" to "contactId" in "Display" table
+ALTER TABLE "Display" RENAME COLUMN "personId" TO "contactId";
+
+-- If there are any foreign key constraints involving "personId", they should be renamed to "contactId" as well.
+ALTER TABLE "Attribute" RENAME CONSTRAINT "Attribute_personId_fkey" TO "Attribute_contactId_fkey";
+ALTER TABLE "Response" RENAME CONSTRAINT "Response_personId_fkey" TO "Response_contactId_fkey";
+ALTER TABLE "Display" RENAME CONSTRAINT "Display_personId_fkey" TO "Display_contactId_fkey";
+
+-- Rename indexes
+ALTER INDEX "Person_environmentId_idx" RENAME TO "Contact_environmentId_idx";
+ALTER INDEX "Person_environmentId_userId_key" RENAME TO "Contact_environmentId_userId_key";
+ALTER INDEX "Attribute_personId_attributeClassId_key" RENAME TO "Attribute_contactId_attributeClassId_key";
+ALTER INDEX "Response_personId_created_at_idx" RENAME TO "Response_contactId_created_at_idx";
+ALTER INDEX "Display_personId_created_at_idx" RENAME TO "Display_contactId_created_at_idx";
+
+-- Renaming the tables
+ALTER TABLE "AttributeClass" RENAME TO "ContactAttributeKey";
+ALTER TABLE "ContactAttributeKey" RENAME CONSTRAINT "AttributeClass_pkey" TO "ContactAttributeKey_pkey";
+ALTER TABLE "ContactAttributeKey" RENAME CONSTRAINT "AttributeClass_environmentId_fkey" TO "ContactAttributeKey_environmentId_fkey";
+
+ALTER TABLE "Attribute" RENAME TO "ContactAttribute";
+ALTER TABLE "ContactAttribute" RENAME CONSTRAINT "Attribute_pkey" TO "ContactAttribute_pkey";
+ALTER TABLE "ContactAttribute" RENAME COLUMN "attributeClassId" TO "attributeKeyId";
+ALTER TABLE "ContactAttribute" RENAME CONSTRAINT "Attribute_attributeClassId_fkey" TO "ContactAttribute_attributeKeyId_fkey";
+ALTER TABLE "ContactAttribute" RENAME CONSTRAINT "Attribute_contactId_fkey" TO "ContactAttribute_contactId_fkey";
+
+ALTER TABLE "SurveyAttributeFilter" RENAME COLUMN "attributeClassId" TO "attributeKeyId";
+ALTER TABLE "SurveyAttributeFilter" RENAME CONSTRAINT "SurveyAttributeFilter_attributeClassId_fkey" TO "SurveyAttributeFilter_attributeKeyId_fkey";
+
+ALTER INDEX "SurveyAttributeFilter_surveyId_attributeClassId_key" RENAME TO "SurveyAttributeFilter_surveyId_attributeKeyId_key";
+ALTER INDEX "SurveyAttributeFilter_attributeClassId_idx" RENAME TO "SurveyAttributeFilter_attributeKeyId_idx";
+ALTER INDEX "Attribute_contactId_attributeClassId_key" RENAME TO "ContactAttribute_contactId_attributeKeyId_key";
+ALTER INDEX "AttributeClass_name_environmentId_key" RENAME TO "ContactAttributeKey_name_environmentId_key";
+ALTER INDEX "AttributeClass_environmentId_created_at_idx" RENAME TO "ContactAttributeKey_environmentId_created_at_idx";
+ALTER INDEX "AttributeClass_environmentId_archived_idx" RENAME TO "ContactAttributeKey_environmentId_archived_idx";
+
+-- Step 1: Create the new enum type
+CREATE TYPE "ContactAttributeType" AS ENUM ('default', 'custom');
+
+-- Step 2: Add the new temporary column for 'type'
+ALTER TABLE "ContactAttributeKey" ADD COLUMN "type_new" "ContactAttributeType";
+
+-- Step 3: Update the new 'type_new' column with mapped values
+UPDATE "ContactAttributeKey"
+SET "type_new" = CASE
+ WHEN "type" = 'automatic' THEN 'default'::"ContactAttributeType"
+ ELSE 'custom'::"ContactAttributeType"
+END;
+
+-- Step 4: Drop the old 'type' column
+ALTER TABLE "ContactAttributeKey" DROP COLUMN "type";
+
+-- DropEnum
+DROP TYPE "AttributeType";
+
+-- Step 5: Rename the new 'type_new' column to 'type'
+ALTER TABLE "ContactAttributeKey" RENAME COLUMN "type_new" TO "type";
+
+-- AlterTable
+ALTER TABLE "ContactAttributeKey" ALTER COLUMN "type" SET NOT NULL,
+ALTER COLUMN "type" SET DEFAULT 'custom';
+
+-- Step 7: Add the new 'key' column with a default value
+ALTER TABLE "ContactAttributeKey" ADD COLUMN "key" TEXT NOT NULL DEFAULT '';
+
+-- Step 8: Copy data from 'name' to 'key'
+UPDATE "ContactAttributeKey" SET "key" = "name";
+
+-- Step 9: Make 'name' column nullable
+ALTER TABLE "ContactAttributeKey" ALTER COLUMN "name" DROP NOT NULL;
+
+-- Step 10: Drop the old unique index on 'name' and 'environmentId'
+DROP INDEX "ContactAttributeKey_name_environmentId_key";
+
+-- Step 11: Create a new unique index on 'key' and 'environmentId'
+CREATE UNIQUE INDEX "ContactAttributeKey_key_environmentId_key" ON "ContactAttributeKey"("key", "environmentId");
+
+-- Testing this rn
+-- ALTER TABLE "Contact" DROP COLUMN "userId";
+
+ALTER TABLE "Contact" ALTER COLUMN "userId" DROP NOT NULL;
+DROP INDEX "Contact_environmentId_userId_key";
+
+-- Step 12: Remove the default value from 'key' column
+ALTER TABLE "ContactAttributeKey" ALTER COLUMN "key" DROP DEFAULT;
+
+DROP INDEX "ContactAttributeKey_environmentId_archived_idx";
+ALTER TABLE "ContactAttributeKey" DROP COLUMN "archived";
+
+ALTER TABLE "ContactAttributeKey" ADD COLUMN "isUnique" BOOLEAN NOT NULL DEFAULT false;
+CREATE INDEX "ContactAttribute_attributeKeyId_value_idx" ON "ContactAttribute"("attributeKeyId", "value");
\ No newline at end of file
diff --git a/packages/database/package.json b/packages/database/package.json
index 25b523f2c4..911c64c799 100644
--- a/packages/database/package.json
+++ b/packages/database/package.json
@@ -54,6 +54,10 @@
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts",
"data-migration:migrate-survey-types": "ts-node ./data-migrations/20241002123456_migrate_survey_types/data-migration.ts",
"data-migration:v2.6": "pnpm data-migration:add-display-id-to-response && pnpm data-migration:address-question && pnpm data-migration:advanced-logic && pnpm data-migration:segments-actions-cleanup && pnpm data-migration:migrate-survey-types",
+ "data-migration:xm": "ts-node ./data-migrations/20241010133706_xm_user_identification/data-migration.ts",
+ "data-migration:xm-segments": "ts-node ./data-migrations/20241021123456_xm_segment_migration/data-migration.ts",
+ "data-migration:xm-attribute-removal": "ts-node ./data-migrations/20241024123456_xm_attribute_removal/data-migration.ts",
+ "data-migration:xm-user-identification": "pnpm data-migration:xm && pnpm data-migration:xm-segments && pnpm data-migration:xm-attribute-removal",
"data-migration:add-teams": "ts-node ./data-migrations/20241107161932_add_teams/data-migration.ts",
"data-migration:v2.7": "pnpm data-migration:add-teams",
"data-migration:update-org-limits": "ts-node ./data-migrations/20241118123456_update_org_limits/data-migration.ts",
diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma
index bac83a8887..2d316aa5ab 100644
--- a/packages/database/schema.prisma
+++ b/packages/database/schema.prisma
@@ -55,95 +55,94 @@ model Webhook {
@@index([environmentId])
}
-model Attribute {
- id String @id @default(cuid())
- createdAt DateTime @default(now()) @map(name: "created_at")
- updatedAt DateTime @updatedAt @map(name: "updated_at")
- attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade)
- attributeClassId String
- person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
- personId String
- value String
+model ContactAttribute {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @updatedAt @map(name: "updated_at")
+ attributeKey ContactAttributeKey @relation(fields: [attributeKeyId], references: [id], onDelete: Cascade)
+ attributeKeyId String
+ contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
+ contactId String
+ value String
- @@unique([personId, attributeClassId])
+ @@unique([contactId, attributeKeyId])
+ @@index([attributeKeyId, value])
}
-enum AttributeType {
- code
- noCode
- automatic
+enum ContactAttributeType {
+ default
+ custom
}
-model AttributeClass {
+model ContactAttributeKey {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
- name String
+ isUnique Boolean @default(false)
+ key String
+ name String?
description String?
- archived Boolean @default(false)
- type AttributeType
+ type ContactAttributeType @default(custom)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
- attributes Attribute[]
+ attributes ContactAttribute[]
attributeFilters SurveyAttributeFilter[]
- @@unique([name, environmentId])
+ @@unique([key, environmentId])
@@index([environmentId, createdAt])
- @@index([environmentId, archived])
}
-model Person {
- id String @id @default(cuid())
- userId String
- createdAt DateTime @default(now()) @map(name: "created_at")
- updatedAt DateTime @updatedAt @map(name: "updated_at")
- environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
+model Contact {
+ id String @id @default(cuid())
+ userId String?
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @updatedAt @map(name: "updated_at")
+ environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
responses Response[]
- attributes Attribute[]
+ attributes ContactAttribute[]
displays Display[]
- @@unique([environmentId, userId])
@@index([environmentId])
}
model Response {
- id String @id @default(cuid())
- createdAt DateTime @default(now()) @map(name: "created_at")
- updatedAt DateTime @updatedAt @map(name: "updated_at")
- finished Boolean @default(false)
- endingId String?
- survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
- surveyId String
- person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
- personId String?
- notes ResponseNote[]
+ id String @id @default(cuid())
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @updatedAt @map(name: "updated_at")
+ finished Boolean @default(false)
+ survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
+ surveyId String
+ contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
+ contactId String?
+ endingId String?
+ notes ResponseNote[]
/// @zod.custom(imports.ZResponseData)
/// [ResponseData]
- data Json @default("{}")
+ data Json @default("{}")
/// @zod.custom(imports.ZResponseVariables)
/// [ResponseVariables]
- variables Json @default("{}")
+ variables Json @default("{}")
/// @zod.custom(imports.ZResponseTtc)
/// [ResponseTtc]
- ttc Json @default("{}")
+ ttc Json @default("{}")
/// @zod.custom(imports.ZResponseMeta)
/// [ResponseMeta]
- meta Json @default("{}")
- tags TagsOnResponses[]
- /// @zod.custom(imports.ZResponsePersonAttributes)
- /// [ResponsePersonAttributes]
- personAttributes Json?
+ meta Json @default("{}")
+ tags TagsOnResponses[]
+ /// @zod.custom(imports.ZResponseContactAttributes)
+ /// [ResponseContactAttributes]
+ contactAttributes Json?
// singleUseId, used to prevent multiple responses
- singleUseId String?
- language String?
- documents Document[]
- displayId String? @unique
- display Display? @relation(fields: [displayId], references: [id])
+ singleUseId String?
+ language String?
+ documents Document[]
+ displayId String? @unique
+ display Display? @relation(fields: [displayId], references: [id])
@@unique([surveyId, singleUseId])
@@index([surveyId, createdAt]) // to determine monthly response count
- @@index([personId, createdAt]) // to determine monthly identified users (persons)
+ @@index([contactId, createdAt]) // to determine monthly identified users (persons)
@@index([surveyId])
}
@@ -204,14 +203,14 @@ model Display {
updatedAt DateTime @updatedAt @map(name: "updated_at")
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
- person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
- personId String?
+ contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade)
+ contactId String?
responseId String? @unique //deprecated
status DisplayStatus?
response Response?
@@index([surveyId])
- @@index([personId, createdAt])
+ @@index([contactId, createdAt])
}
model SurveyTrigger {
@@ -233,19 +232,19 @@ enum SurveyAttributeFilterCondition {
}
model SurveyAttributeFilter {
- id String @id @default(cuid())
- createdAt DateTime @default(now()) @map(name: "created_at")
- updatedAt DateTime @updatedAt @map(name: "updated_at")
- attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade)
- attributeClassId String
- survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
- surveyId String
- condition SurveyAttributeFilterCondition
- value String
+ id String @id @default(cuid())
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @updatedAt @map(name: "updated_at")
+ attributeKey ContactAttributeKey @relation(fields: [attributeKeyId], references: [id], onDelete: Cascade)
+ attributeKeyId String
+ survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
+ surveyId String
+ condition SurveyAttributeFilterCondition
+ value String
- @@unique([surveyId, attributeClassId])
+ @@unique([surveyId, attributeKeyId])
@@index([surveyId])
- @@index([attributeClassId])
+ @@index([attributeKeyId])
}
enum SurveyType {
@@ -407,18 +406,18 @@ model Integration {
}
model Environment {
- id String @id @default(cuid())
- createdAt DateTime @default(now()) @map(name: "created_at")
- updatedAt DateTime @updatedAt @map(name: "updated_at")
+ id String @id @default(cuid())
+ createdAt DateTime @default(now()) @map(name: "created_at")
+ updatedAt DateTime @updatedAt @map(name: "updated_at")
type EnvironmentType
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
- widgetSetupCompleted Boolean @default(false)
- appSetupCompleted Boolean @default(false)
+ widgetSetupCompleted Boolean @default(false)
+ appSetupCompleted Boolean @default(false)
surveys Survey[]
- people Person[]
+ contacts Contact[]
actionClasses ActionClass[]
- attributeClasses AttributeClass[]
+ attributeKeys ContactAttributeKey[]
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
diff --git a/packages/js-core/src/lib/attributes.ts b/packages/js-core/src/lib/attributes.ts
index f13caffb5c..0db882563a 100644
--- a/packages/js-core/src/lib/attributes.ts
+++ b/packages/js-core/src/lib/attributes.ts
@@ -1,5 +1,6 @@
import { FormbricksAPI } from "@formbricks/api";
import { TAttributes } from "@formbricks/types/attributes";
+import { ForbiddenError } from "@formbricks/types/errors";
import { Config } from "./config";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "./errors";
import { Logger } from "./logger";
@@ -17,8 +18,9 @@ export const updateAttribute = async (
{
changed: boolean;
message: string;
+ details?: Record;
},
- Error | NetworkError
+ NetworkError | ForbiddenError
>
> => {
const { apiHost, environmentId } = config.get();
@@ -29,7 +31,7 @@ export const updateAttribute = async (
code: "network_error",
status: 500,
message: "Missing userId",
- url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
+ url: `${apiHost}/api/v1/client/${environmentId}/contacts/${userId}/attributes`,
responseMessage: "Missing userId",
});
}
@@ -53,22 +55,33 @@ export const updateAttribute = async (
},
};
}
+
return err({
- code: "network_error",
- status: 500,
- message: res.error.message ?? `Error updating person with userId ${userId}`,
- url: `${config.get().apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
+ code: (res.error as ForbiddenError).code ?? "network_error",
+ status: (res.error as NetworkError | ForbiddenError).status ?? 500,
+ message: `Error updating person with userId ${userId}`,
+ url: new URL(`${apiHost}/api/v1/client/${environmentId}/contacts/${userId}/attributes`),
responseMessage: res.error.message,
});
}
+ if (res.data.details) {
+ Object.entries(res.data.details).forEach(([key, value]) => {
+ logger.error(`${key}: ${value}`);
+ });
+ }
+
if (res.data.changed) {
logger.debug("Attribute updated in Formbricks");
+
return {
ok: true,
value: {
changed: true,
message: "Attribute updated in Formbricks",
+ ...(res.data.details && {
+ details: res.data.details,
+ }),
},
};
}
@@ -78,6 +91,9 @@ export const updateAttribute = async (
value: {
changed: false,
message: "Attribute not updated in Formbricks",
+ ...(res.data.details && {
+ details: res.data.details,
+ }),
},
};
};
@@ -87,7 +103,7 @@ export const updateAttributes = async (
environmentId: string,
userId: string,
attributes: TAttributes
-): Promise> => {
+): Promise> => {
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
@@ -107,6 +123,12 @@ export const updateAttributes = async (
const res = await api.client.attribute.update({ userId, attributes: updatedAttributes });
if (res.ok) {
+ if (res.data.details) {
+ Object.entries(res.data.details).forEach(([key, value]) => {
+ logger.debug(`${key}: ${value}`);
+ });
+ }
+
return ok(updatedAttributes);
} else {
// @ts-expect-error
@@ -116,10 +138,10 @@ export const updateAttributes = async (
}
return err({
- code: "network_error",
- status: 500,
+ code: (res.error as ForbiddenError).code ?? "network_error",
+ status: (res.error as NetworkError | ForbiddenError).status ?? 500,
message: `Error updating person with userId ${userId}`,
- url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
+ url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
responseMessage: res.error.message,
});
}
@@ -172,6 +194,11 @@ export const setAttributeInApp = async (
}
return okVoid();
+ } else {
+ const error = result.error;
+ if (error && error.code === "forbidden") {
+ logger.error(`Authorization error: ${error.responseMessage}`);
+ }
}
return err(result.error as NetworkError);
diff --git a/packages/js-core/src/lib/initialize.ts b/packages/js-core/src/lib/initialize.ts
index 035c531f79..8231083e36 100644
--- a/packages/js-core/src/lib/initialize.ts
+++ b/packages/js-core/src/lib/initialize.ts
@@ -1,4 +1,5 @@
import { TAttributes } from "@formbricks/types/attributes";
+import { type ForbiddenError } from "@formbricks/types/errors";
import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
import { trackNoCodeAction } from "./actions";
import { updateAttributes } from "./attributes";
@@ -125,7 +126,7 @@ const migrateProductToProject = (): { changed: boolean; newState?: TJsConfig } =
export const initialize = async (
configInput: TJsConfigInput
-): Promise> => {
+): Promise> => {
const isDebug = getIsDebug();
if (isDebug) {
logger.configure({ logLevel: "debug" });
@@ -286,26 +287,6 @@ export const initialize = async (
config.resetConfig();
logger.debug("Syncing.");
- let updatedAttributes: TAttributes | null = null;
- if (configInput.attributes) {
- if (configInput.userId) {
- const res = await updateAttributes(
- configInput.apiHost,
- configInput.environmentId,
- configInput.userId,
- configInput.attributes
- );
-
- if (res.ok !== true) {
- return err(res.error);
- }
-
- updatedAttributes = res.value;
- } else {
- updatedAttributes = { ...configInput.attributes };
- }
- }
-
try {
const environmentState = await fetchEnvironmentState(
{
@@ -314,6 +295,7 @@ export const initialize = async (
},
false
);
+
const personState = configInput.userId
? await fetchPersonState(
{
@@ -327,6 +309,29 @@ export const initialize = async (
const filteredSurveys = filterSurveys(environmentState, personState);
+ let updatedAttributes: TAttributes | null = null;
+ if (configInput.attributes) {
+ if (configInput.userId) {
+ const res = await updateAttributes(
+ configInput.apiHost,
+ configInput.environmentId,
+ configInput.userId,
+ configInput.attributes
+ );
+
+ if (res.ok !== true) {
+ if (res.error.code === "forbidden") {
+ logger.error(`Authorization error: ${res.error.responseMessage}`);
+ }
+ return err(res.error);
+ }
+
+ updatedAttributes = res.value;
+ } else {
+ updatedAttributes = { ...configInput.attributes };
+ }
+ }
+
config.update({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
@@ -336,7 +341,7 @@ export const initialize = async (
attributes: updatedAttributes ?? {},
});
} catch (e) {
- handleErrorOnFirstInit();
+ handleErrorOnFirstInit(e as Error);
}
// and track the new session event
@@ -356,7 +361,11 @@ export const initialize = async (
return okVoid();
};
-export const handleErrorOnFirstInit = () => {
+export const handleErrorOnFirstInit = (e: any) => {
+ if (e.error.code === "forbidden") {
+ logger.error(`Authorization error: ${e.error.responseMessage}`);
+ }
+
if (getIsDebug()) {
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
return;
diff --git a/packages/js-core/src/lib/personState.ts b/packages/js-core/src/lib/personState.ts
index f3982e4e5c..920c63e756 100644
--- a/packages/js-core/src/lib/personState.ts
+++ b/packages/js-core/src/lib/personState.ts
@@ -39,7 +39,7 @@ export const fetchPersonState = async (
logger.debug("No cache option set for sync");
}
- const url = `${apiHost}/api/v1/client/${environmentId}/identify/people/${userId}`;
+ const url = `${apiHost}/api/v1/client/${environmentId}/identify/contacts/${userId}`;
const response = await fetch(url, fetchOptions);
@@ -47,7 +47,7 @@ export const fetchPersonState = async (
const jsonRes = await response.json();
const error = err({
- code: "network_error",
+ code: jsonRes.code === "forbidden" ? "forbidden" : "network_error",
status: response.status,
message: "Error syncing with backend",
url: new URL(url),
diff --git a/packages/js/index.html b/packages/js/index.html
index a2ab62e526..df5509546e 100644
--- a/packages/js/index.html
+++ b/packages/js/index.html
@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
- environmentId: "cm2vz0ivu000562ncopa3hjwo",
+ environmentId: "cm48e77r50006ihewbunm8vta",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});
diff --git a/packages/lib/attribute/cache.ts b/packages/lib/attribute/cache.ts
deleted file mode 100644
index 4b0939f5d7..0000000000
--- a/packages/lib/attribute/cache.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { revalidateTag } from "next/cache";
-
-interface RevalidateProps {
- id?: string;
- environmentId?: string;
- userId?: string;
- personId?: string;
- name: string;
-}
-
-export const attributeCache = {
- tag: {
- byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
- return `environments-${environmentId}-personByUserId-${userId}-attributes`;
- },
- byPersonId(personId: string): string {
- return `person-${personId}-attributes`;
- },
- byNameAndPersonId(name: string, personId: string): string {
- return `person-${personId}-attribute-${name}`;
- },
- },
- revalidate({ environmentId, userId, personId, name }: RevalidateProps): void {
- if (environmentId && userId) {
- revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
- }
- if (personId) {
- revalidateTag(this.tag.byPersonId(personId));
- }
- if (personId && name) {
- revalidateTag(this.tag.byNameAndPersonId(name, personId));
- }
- },
-};
diff --git a/packages/lib/attribute/service.ts b/packages/lib/attribute/service.ts
deleted file mode 100644
index 56e103a136..0000000000
--- a/packages/lib/attribute/service.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import "server-only";
-import { Prisma } from "@prisma/client";
-import { cache as reactCache } from "react";
-import { prisma } from "@formbricks/database";
-import { TAttributes, ZAttributes } from "@formbricks/types/attributes";
-import { ZString } from "@formbricks/types/common";
-import { ZId } from "@formbricks/types/common";
-import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
-import { attributeCache } from "../attribute/cache";
-import { attributeClassCache } from "../attributeClass/cache";
-import {
- getAttributeClassByName,
- getAttributeClasses,
- getAttributeClassesCount,
-} from "../attributeClass/service";
-import { cache } from "../cache";
-import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "../constants";
-import { getPerson, getPersonByUserId } from "../person/service";
-import { validateInputs } from "../utils/validate";
-
-export const selectAttribute: Prisma.AttributeSelect = {
- value: true,
- attributeClass: {
- select: {
- name: true,
- id: true,
- },
- },
-};
-
-// convert prisma attributes to a key-value object
-const convertPrismaAttributes = (prismaAttributes: any): TAttributes => {
- return prismaAttributes.reduce(
- (acc, attr) => {
- acc[attr.attributeClass.name] = attr.value;
- return acc;
- },
- {} as Record
- );
-};
-
-export const getAttributes = reactCache(
- async (personId: string): Promise =>
- cache(
- async () => {
- validateInputs([personId, ZId]);
-
- try {
- const prismaAttributes = await prisma.attribute.findMany({
- where: {
- personId,
- },
- select: selectAttribute,
- });
-
- return convertPrismaAttributes(prismaAttributes);
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getAttributes-${personId}`],
- {
- tags: [attributeCache.tag.byPersonId(personId)],
- }
- )()
-);
-
-export const getAttributesByUserId = reactCache(
- async (environmentId: string, userId: string): Promise =>
- cache(
- async () => {
- validateInputs([environmentId, ZId], [userId, ZString]);
-
- const person = await getPersonByUserId(environmentId, userId);
-
- if (!person) {
- throw new Error("Person not found");
- }
-
- try {
- const prismaAttributes = await prisma.attribute.findMany({
- where: {
- personId: person.id,
- },
- select: selectAttribute,
- });
-
- return convertPrismaAttributes(prismaAttributes);
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getAttributesByUserId-${environmentId}-${userId}`],
- {
- tags: [attributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
- }
- )()
-);
-
-export const getAttribute = (name: string, personId: string): Promise =>
- cache(
- async () => {
- validateInputs([name, ZString], [personId, ZId]);
-
- const person = await getPerson(personId);
-
- if (!person) {
- throw new Error("Person not found");
- }
-
- const attributeClass = await getAttributeClassByName(person?.environmentId, name);
-
- if (!attributeClass) {
- return undefined;
- }
-
- try {
- const prismaAttributes = await prisma.attribute.findFirst({
- where: {
- attributeClassId: attributeClass.id,
- personId,
- },
- select: { value: true },
- });
-
- return prismaAttributes?.value;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getAttribute-${name}-${personId}`],
- {
- tags: [attributeCache.tag.byNameAndPersonId(name, personId)],
- }
- )();
-
-export const updateAttributes = async (personId: string, attributes: TAttributes): Promise => {
- validateInputs([personId, ZId], [attributes, ZAttributes]);
-
- const person = await getPerson(personId);
-
- if (!person) {
- throw new Error("Person not found");
- }
-
- const environmentId = person.environmentId;
- const userId = person.userId;
-
- const attributeClasses = await getAttributeClasses(environmentId);
-
- const attributeClassMap = new Map(attributeClasses.map((ac) => [ac.name, ac.id]));
- const upsertOperations: Promise[] = [];
- const createOperations: Promise[] = [];
- const newAttributes: { name: string; value: string }[] = [];
-
- for (const [name, value] of Object.entries(attributes)) {
- const attributeClassId = attributeClassMap.get(name);
-
- if (attributeClassId) {
- // Class exists, perform an upsert operation
- upsertOperations.push(
- prisma.attribute
- .upsert({
- select: {
- id: true,
- },
- where: {
- personId_attributeClassId: {
- personId,
- attributeClassId,
- },
- },
- update: {
- value,
- },
- create: {
- personId,
- attributeClassId,
- value,
- },
- })
- .then(() => {
- attributeCache.revalidate({ environmentId, personId, userId, name });
- })
- );
- } else {
- // Collect new attributes to be created later
- newAttributes.push({ name, value });
- }
- }
-
- // Execute all upsert operations concurrently
- await Promise.all(upsertOperations);
-
- if (newAttributes.length === 0) {
- // short-circuit if no new attributes to create
- return true;
- }
-
- // Check if new attribute classes will exceed the limit
- const attributeClassCount = await getAttributeClassesCount(environmentId);
-
- const totalAttributeClassesLength = attributeClassCount + newAttributes.length;
-
- if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
- throw new OperationNotAllowedError(
- `Updating these attributes would exceed the maximum number of attribute classes (${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT}) for environment ${environmentId}. Existing attributes have been updated.`
- );
- }
-
- for (const { name, value } of newAttributes) {
- createOperations.push(
- prisma.attributeClass
- .create({
- select: { id: true },
- data: {
- name,
- type: "code",
- environment: {
- connect: {
- id: environmentId,
- },
- },
- attributes: {
- create: {
- personId,
- value,
- },
- },
- },
- })
- .then(({ id }) => {
- attributeClassCache.revalidate({ id, environmentId, name });
- attributeCache.revalidate({ environmentId, personId, userId, name });
- })
- );
- }
-
- // Execute all create operations for new attribute classes
- await Promise.all(createOperations);
-
- // Revalidate the count cache
- attributeClassCache.revalidate({
- environmentId,
- });
-
- return true;
-};
diff --git a/packages/lib/attributeClass/auth.ts b/packages/lib/attributeClass/auth.ts
deleted file mode 100644
index 78e8bb1631..0000000000
--- a/packages/lib/attributeClass/auth.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import "server-only";
-import { ZId } from "@formbricks/types/common";
-import { cache } from "../cache";
-import { hasUserEnvironmentAccess } from "../environment/auth";
-import { validateInputs } from "../utils/validate";
-import { getAttributeClass } from "./service";
-
-export const canUserAccessAttributeClass = async (
- userId: string,
- attributeClassId: string
-): Promise =>
- cache(
- async () => {
- validateInputs([userId, ZId], [attributeClassId, ZId]);
- if (!userId) return false;
-
- try {
- const attributeClass = await getAttributeClass(attributeClassId);
- if (!attributeClass) return false;
-
- const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, attributeClass.environmentId);
- if (!hasAccessToEnvironment) return false;
-
- return true;
- } catch (error) {
- throw error;
- }
- },
-
- [`canUserAccessAttributeClass-${userId}-${attributeClassId}`],
- { tags: [`attributeClasses-${attributeClassId}`] }
- )();
diff --git a/packages/lib/attributeClass/cache.ts b/packages/lib/attributeClass/cache.ts
deleted file mode 100644
index 71714babb1..0000000000
--- a/packages/lib/attributeClass/cache.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { revalidateTag } from "next/cache";
-
-interface RevalidateProps {
- id?: string;
- name?: string;
- environmentId?: string;
-}
-
-export const attributeClassCache = {
- tag: {
- byId(id: string) {
- return `attributeClass-${id}`;
- },
- byEnvironmentId(environmentId: string) {
- return `environments-${environmentId}-attributeClasses`;
- },
- byEnvironmentIdAndName(environmentId: string, name: string) {
- return `environments-${environmentId}-name-${name}-attributeClasses`;
- },
- },
- revalidate({ id, environmentId, name }: RevalidateProps): void {
- if (id) {
- revalidateTag(this.tag.byId(id));
- }
-
- if (environmentId) {
- revalidateTag(this.tag.byEnvironmentId(environmentId));
- }
-
- if (environmentId && name) {
- revalidateTag(this.tag.byEnvironmentIdAndName(environmentId, name));
- }
- },
-};
diff --git a/packages/lib/attributeClass/service.ts b/packages/lib/attributeClass/service.ts
deleted file mode 100644
index de3c9ce487..0000000000
--- a/packages/lib/attributeClass/service.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-"use server";
-
-import "server-only";
-import { Prisma } from "@prisma/client";
-import { cache as reactCache } from "react";
-import { prisma } from "@formbricks/database";
-import {
- TAttributeClass,
- TAttributeClassType,
- TAttributeClassUpdateInput,
- ZAttributeClassType,
- ZAttributeClassUpdateInput,
-} from "@formbricks/types/attribute-classes";
-import { ZOptionalNumber, ZString } from "@formbricks/types/common";
-import { ZId } from "@formbricks/types/common";
-import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
-import { cache } from "../cache";
-import { ITEMS_PER_PAGE, MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "../constants";
-import { validateInputs } from "../utils/validate";
-import { attributeClassCache } from "./cache";
-
-export const getAttributeClass = reactCache(
- async (attributeClassId: string): Promise =>
- cache(
- async () => {
- validateInputs([attributeClassId, ZId]);
-
- try {
- const attributeClass = await prisma.attributeClass.findFirst({
- where: {
- id: attributeClassId,
- },
- });
-
- return attributeClass;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
- },
- [`getAttributeClass-${attributeClassId}`],
- {
- tags: [attributeClassCache.tag.byId(attributeClassId)],
- }
- )()
-);
-
-export const getAttributeClasses = reactCache(
- async (
- environmentId: string,
- page?: number,
- options?: { skipArchived: boolean }
- ): Promise =>
- cache(
- async () => {
- validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
-
- try {
- const attributeClasses = await prisma.attributeClass.findMany({
- where: {
- environmentId: environmentId,
- ...(options?.skipArchived ? { archived: false } : {}),
- },
- orderBy: {
- createdAt: "asc",
- },
- take: page ? ITEMS_PER_PAGE : undefined,
- skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
- });
-
- return attributeClasses.filter((attributeClass) => {
- if (attributeClass.name === "userId" && attributeClass.type === "automatic") {
- return false;
- }
-
- return true;
- });
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
- },
- [`getAttributeClasses-${environmentId}-${page}`],
- {
- tags: [attributeClassCache.tag.byEnvironmentId(environmentId)],
- }
- )()
-);
-
-export const updateAttributeClass = async (
- attributeClassId: string,
- data: Partial
-): Promise => {
- validateInputs([attributeClassId, ZId], [data, ZAttributeClassUpdateInput.partial()]);
-
- try {
- const attributeClass = await prisma.attributeClass.update({
- where: {
- id: attributeClassId,
- },
- data: {
- description: data.description,
- archived: data.archived,
- },
- });
-
- attributeClassCache.revalidate({
- id: attributeClass.id,
- environmentId: attributeClass.environmentId,
- name: attributeClass.name,
- });
-
- return attributeClass;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
-};
-
-export const getAttributeClassByName = reactCache(async (environmentId: string, name: string) =>
- cache(
- async (): Promise => {
- validateInputs([environmentId, ZId], [name, ZString]);
-
- try {
- const attributeClass = await prisma.attributeClass.findFirst({
- where: {
- environmentId,
- name,
- },
- });
-
- return attributeClass;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
- },
- [`getAttributeClassByName-${environmentId}-${name}`],
- {
- tags: [attributeClassCache.tag.byEnvironmentIdAndName(environmentId, name)],
- }
- )()
-);
-
-export const createAttributeClass = async (
- environmentId: string,
- name: string,
- type: TAttributeClassType
-): Promise => {
- validateInputs([environmentId, ZId], [name, ZString], [type, ZAttributeClassType]);
-
- const attributeClassesCount = await getAttributeClassesCount(environmentId);
-
- if (attributeClassesCount >= MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
- throw new OperationNotAllowedError(
- `Maximum number of attribute classes (${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT}) reached for environment ${environmentId}`
- );
- }
-
- try {
- const attributeClass = await prisma.attributeClass.create({
- data: {
- name,
- type,
- environment: {
- connect: {
- id: environmentId,
- },
- },
- },
- });
-
- attributeClassCache.revalidate({
- id: attributeClass.id,
- environmentId: attributeClass.environmentId,
- name: attributeClass.name,
- });
-
- return attributeClass;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
-};
-
-export const deleteAttributeClass = async (attributeClassId: string): Promise => {
- validateInputs([attributeClassId, ZId]);
-
- try {
- const deletedAttributeClass = await prisma.attributeClass.delete({
- where: {
- id: attributeClassId,
- },
- });
-
- attributeClassCache.revalidate({
- id: deletedAttributeClass.id,
- environmentId: deletedAttributeClass.environmentId,
- name: deletedAttributeClass.name,
- });
-
- return deletedAttributeClass;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
-};
-
-export const getAttributeClassesCount = reactCache(
- async (environmentId: string): Promise =>
- cache(
- async () => {
- validateInputs([environmentId, ZId]);
-
- try {
- return prisma.attributeClass.count({
- where: {
- environmentId,
- },
- });
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
- throw error;
- }
- },
- [`getAttributeClassesCount-${environmentId}`],
- {
- tags: [attributeClassCache.tag.byEnvironmentId(environmentId)],
- }
- )()
-);
diff --git a/packages/lib/segment/cache.ts b/packages/lib/cache/segment.ts
similarity index 60%
rename from packages/lib/segment/cache.ts
rename to packages/lib/cache/segment.ts
index c49989c121..cbdc5d474a 100644
--- a/packages/lib/segment/cache.ts
+++ b/packages/lib/cache/segment.ts
@@ -3,7 +3,7 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
- attributeClassName?: string;
+ attributeKey?: string;
}
export const segmentCache = {
@@ -14,11 +14,11 @@ export const segmentCache = {
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-segements`;
},
- byAttributeClassName(attributeClassName: string): string {
- return `attribute-${attributeClassName}-segements`;
+ byAttributeKey(attributeKey: string): string {
+ return `attribute-${attributeKey}-segements`;
},
},
- revalidate({ id, environmentId, attributeClassName }: RevalidateProps): void {
+ revalidate({ id, environmentId, attributeKey }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
@@ -27,8 +27,8 @@ export const segmentCache = {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
- if (attributeClassName) {
- revalidateTag(this.tag.byAttributeClassName(attributeClassName));
+ if (attributeKey) {
+ revalidateTag(this.tag.byAttributeKey(attributeKey));
}
},
};
diff --git a/packages/lib/display/cache.ts b/packages/lib/display/cache.ts
index a77e640e93..cb8c25d487 100644
--- a/packages/lib/display/cache.ts
+++ b/packages/lib/display/cache.ts
@@ -3,7 +3,7 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
surveyId?: string;
- personId?: string | null;
+ contactId?: string | null;
userId?: string;
environmentId?: string;
}
@@ -16,8 +16,8 @@ export const displayCache = {
bySurveyId(surveyId: string) {
return `surveys-${surveyId}-displays`;
},
- byPersonId(personId: string) {
- return `people-${personId}-displays`;
+ byContactId(contactId: string) {
+ return `contacts-${contactId}-displays`;
},
byEnvironmentIdAndUserId(environmentId: string, userId: string) {
return `environments-${environmentId}-users-${userId}-displays`;
@@ -26,7 +26,7 @@ export const displayCache = {
return `environments-${environmentId}-displays`;
},
},
- revalidate({ id, surveyId, personId, environmentId, userId }: RevalidateProps): void {
+ revalidate({ id, surveyId, contactId, environmentId, userId }: RevalidateProps): void {
if (environmentId && userId) {
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
}
@@ -39,8 +39,8 @@ export const displayCache = {
revalidateTag(this.tag.bySurveyId(surveyId));
}
- if (personId) {
- revalidateTag(this.tag.byPersonId(personId));
+ if (contactId) {
+ revalidateTag(this.tag.byContactId(contactId));
}
if (environmentId) {
diff --git a/packages/lib/display/service.ts b/packages/lib/display/service.ts
index fd4ba1dbec..0a57dfd81a 100644
--- a/packages/lib/display/service.ts
+++ b/packages/lib/display/service.ts
@@ -2,18 +2,10 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
-import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
-import {
- TDisplay,
- TDisplayCreateInput,
- TDisplayFilters,
- ZDisplayCreateInput,
-} from "@formbricks/types/displays";
-import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
+import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
+import { DatabaseError } from "@formbricks/types/errors";
import { cache } from "../cache";
-import { ITEMS_PER_PAGE } from "../constants";
-import { createPerson, getPersonByUserId } from "../person/service";
import { validateInputs } from "../utils/validate";
import { displayCache } from "./cache";
@@ -22,162 +14,9 @@ export const selectDisplay = {
createdAt: true,
updatedAt: true,
surveyId: true,
- personId: true,
+ contactId: true,
status: true,
-};
-
-export const getDisplay = reactCache(
- async (displayId: string): Promise =>
- cache(
- async () => {
- validateInputs([displayId, ZId]);
-
- try {
- const display = await prisma.display.findUnique({
- where: {
- id: displayId,
- },
- select: selectDisplay,
- });
-
- return display;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getDisplay-${displayId}`],
- {
- tags: [displayCache.tag.byId(displayId)],
- }
- )()
-);
-
-export const createDisplay = async (displayInput: TDisplayCreateInput): Promise => {
- validateInputs([displayInput, ZDisplayCreateInput]);
-
- const { environmentId, userId, surveyId } = displayInput;
- try {
- let person;
- if (userId) {
- person = await getPersonByUserId(environmentId, userId);
- if (!person) {
- person = await createPerson(environmentId, userId);
- }
- }
- const display = await prisma.display.create({
- data: {
- survey: {
- connect: {
- id: surveyId,
- },
- },
-
- ...(person && {
- person: {
- connect: {
- id: person.id,
- },
- },
- }),
- },
- select: selectDisplay,
- });
- displayCache.revalidate({
- id: display.id,
- personId: display.personId,
- surveyId: display.surveyId,
- userId,
- environmentId,
- });
- return display;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
-};
-
-export const getDisplaysByPersonId = reactCache(
- async (personId: string, page?: number): Promise =>
- cache(
- async () => {
- validateInputs([personId, ZId], [page, ZOptionalNumber]);
-
- try {
- const displays = await prisma.display.findMany({
- where: {
- personId: personId,
- },
- select: selectDisplay,
- take: page ? ITEMS_PER_PAGE : undefined,
- skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
- orderBy: {
- createdAt: "desc",
- },
- });
-
- return displays;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getDisplaysByPersonId-${personId}-${page}`],
- {
- tags: [displayCache.tag.byPersonId(personId)],
- }
- )()
-);
-
-export const getDisplaysByUserId = reactCache(
- async (environmentId: string, userId: string, page?: number): Promise =>
- cache(
- async () => {
- validateInputs([environmentId, ZId], [userId, ZString], [page, ZOptionalNumber]);
-
- const person = await getPersonByUserId(environmentId, userId);
-
- if (!person) {
- throw new ResourceNotFoundError("person", userId);
- }
-
- try {
- const displays = await prisma.display.findMany({
- where: {
- personId: person.id,
- },
- select: selectDisplay,
- take: page ? ITEMS_PER_PAGE : undefined,
- skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
- orderBy: {
- createdAt: "desc",
- },
- });
-
- return displays;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
- },
- [`getDisplaysByUserId-${environmentId}-${userId}-${page}`],
- {
- tags: [displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
- }
- )()
-);
+} satisfies Prisma.DisplaySelect;
export const getDisplayCountBySurveyId = reactCache(
async (surveyId: string, filters?: TDisplayFilters): Promise =>
@@ -231,7 +70,7 @@ export const deleteDisplay = async (displayId: string): Promise => {
displayCache.revalidate({
id: display.id,
- personId: display.personId,
+ contactId: display.contactId,
surveyId: display.surveyId,
});
diff --git a/packages/lib/display/tests/display.test.ts b/packages/lib/display/tests/display.test.ts
index 0aed38ac48..94816accf2 100644
--- a/packages/lib/display/tests/display.test.ts
+++ b/packages/lib/display/tests/display.test.ts
@@ -1,24 +1,18 @@
import { prisma } from "../../__mocks__/database";
-import { mockPerson } from "../../response/tests/__mocks__/data.mock";
+import { mockContact } from "../../response/tests/__mocks__/data.mock";
import {
mockDisplay,
mockDisplayInput,
mockDisplayInputWithUserId,
mockDisplayWithPersonId,
mockEnvironment,
- mockSurveyId,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { DatabaseError } from "@formbricks/types/errors";
-import {
- createDisplay,
- deleteDisplay,
- getDisplay,
- getDisplayCountBySurveyId,
- getDisplaysByPersonId,
-} from "../service";
+import { createDisplay } from "../../../../apps/web/app/api/v1/client/[environmentId]/displays/lib/display";
+import { deleteDisplay } from "../service";
beforeEach(() => {
vi.resetModules();
@@ -30,62 +24,7 @@ afterEach(() => {
});
beforeEach(() => {
- prisma.person.findFirst.mockResolvedValue(mockPerson);
-});
-
-describe("Tests for getDisplay", () => {
- describe("Happy Path", () => {
- it("Returns display associated with a given display ID", async () => {
- prisma.display.findUnique.mockResolvedValue(mockDisplay);
-
- const display = await getDisplay(mockDisplay.id);
- expect(display).toEqual(mockDisplay);
- });
-
- it("Returns all displays associated with a given person ID", async () => {
- prisma.display.findMany.mockResolvedValue([mockDisplayWithPersonId]);
-
- const displays = await getDisplaysByPersonId(mockPerson.id);
- expect(displays).toEqual([mockDisplayWithPersonId]);
- });
-
- it("Returns an empty array when no displays are found for the given person ID", async () => {
- prisma.display.findMany.mockResolvedValue([]);
-
- const displays = await getDisplaysByPersonId(mockPerson.id);
- expect(displays).toEqual([]);
- });
-
- it("Returns display count for the given survey ID", async () => {
- prisma.display.count.mockResolvedValue(1);
-
- const displaCount = await getDisplayCountBySurveyId(mockSurveyId);
- expect(displaCount).toEqual(1);
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(getDisplaysByPersonId, "123#", 1);
-
- it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
- const mockErrorMessage = "Mock error message";
- const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
- code: "P2002",
- clientVersion: "0.0.1",
- });
-
- prisma.display.findMany.mockRejectedValue(errToThrow);
-
- await expect(getDisplaysByPersonId(mockPerson.id)).rejects.toThrow(DatabaseError);
- });
-
- it("Throws a generic Error for unexpected exceptions", async () => {
- const mockErrorMessage = "Mock error message";
- prisma.display.findMany.mockRejectedValue(new Error(mockErrorMessage));
-
- await expect(getDisplaysByPersonId(mockPerson.id)).rejects.toThrow(Error);
- });
- });
+ prisma.contact.findFirst.mockResolvedValue(mockContact);
});
describe("Tests for createDisplay service", () => {
diff --git a/packages/lib/environment/service.ts b/packages/lib/environment/service.ts
index 5cd79ef355..3ae0349efe 100644
--- a/packages/lib/environment/service.ts
+++ b/packages/lib/environment/service.ts
@@ -177,11 +177,34 @@ export const createEnvironment = async (
},
],
},
- attributeClasses: {
+ attributeKeys: {
create: [
- // { name: "userId", description: "The internal ID of the person", type: "automatic" },
- { name: "email", description: "The email of the person", type: "automatic" },
- { name: "language", description: "The language used by the person", type: "automatic" },
+ {
+ key: "userId",
+ name: "User Id",
+ description: "The user id of a contact",
+ type: "default",
+ isUnique: true,
+ },
+ {
+ key: "email",
+ name: "Email",
+ description: "The email of a contact",
+ type: "default",
+ isUnique: true,
+ },
+ {
+ key: "firstName",
+ name: "First Name",
+ description: "Your contact's first name",
+ type: "default",
+ },
+ {
+ key: "lastName",
+ name: "Last Name",
+ description: "Your contact's last name",
+ type: "default",
+ },
],
},
},
diff --git a/packages/lib/i18n/i18n.mock.ts b/packages/lib/i18n/i18n.mock.ts
index 99298924d9..ef813b5e18 100644
--- a/packages/lib/i18n/i18n.mock.ts
+++ b/packages/lib/i18n/i18n.mock.ts
@@ -1,4 +1,3 @@
-import { mockSegment } from "segment/tests/__mocks__/segment.mock";
import { mockSurveyLanguages } from "survey/tests/__mock__/survey.mock";
import {
TSurvey,
@@ -314,7 +313,7 @@ export const mockSurvey: TSurvey = {
resultShareKey: null,
triggers: [],
languages: mockSurveyLanguages,
- segment: mockSegment,
+ segment: null,
showLanguageSwitch: null,
} as unknown as TSurvey;
diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json
index 68a8fac207..0a9fb927cc 100644
--- a/packages/lib/messages/de-DE.json
+++ b/packages/lib/messages/de-DE.json
@@ -101,6 +101,7 @@
"accepted": "Akzeptiert",
"account": "Konto",
"account_settings": "Kontoeinstellungen",
+ "action": "Aktion",
"actions": "Aktionen",
"active_surveys": "Aktive Umfragen",
"activity": "Aktivität",
@@ -151,6 +152,8 @@
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
+ "contact": "Kontakt",
+ "contacts": "Kontakte",
"copied_to_clipboard": "In die Zwischenablage kopiert",
"copy": "Kopieren",
"copy_code": "Code kopieren",
@@ -335,6 +338,7 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_survey": "Umfrage auswählen",
+ "selected": "Ausgewählt",
"selected_questions": "Ausgewählte Fragen",
"selection": "Auswahl",
"selections": "Auswahlen",
@@ -350,6 +354,7 @@
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
"sort_by": "Sortieren nach",
+ "start_free_trial": "Kostenlos starten",
"status": "Status",
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
"styling": "Styling",
@@ -388,6 +393,9 @@
"update": "Aktualisierung",
"updated": "Aktualisiert",
"updated_at": "Aktualisiert am",
+ "upgrade_now": "Jetzt upgraden",
+ "upload": "Hochladen",
+ "upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
"url": "URL",
"user": "Benutzer",
"user_id": "Benutzer-ID",
@@ -572,6 +580,42 @@
"subtitle": "Das dauert keine 4 Minuten.",
"waiting_for_your_signal": "Warte auf ein Signal von dir..."
},
+ "contacts": {
+ "contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
+ "contact_not_found": "Kein solcher Kontakt gefunden",
+ "contacts_table_refresh": "Kontakte aktualisieren",
+ "error_fetching_next_page_of_people_data": "Fehler beim Abrufen der nächsten Seite mit Kontaktdaten",
+ "error_fetching_people_data": "Fehler beim Abrufen der Kontaktdaten",
+ "fetching_user": "Benutzer wird geladen",
+ "first_name": "Vorname",
+ "formbricks_id": "Formbricks-ID (intern)",
+ "how_to_add_contacts": "Wie man Kontakte hinzufügt",
+ "last_name": "Nachname",
+ "loading_user_responses": "Benutzerantworten werden geladen",
+ "no_responses_found": "Keine Antworten gefunden",
+ "not_provided": "Nicht angegeben",
+ "search_contact": "Kontakt suchen",
+ "sessions": "Sitzungen",
+ "unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
+ "unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
+ "upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
+ "upload_contacts_modal_attributes_new": "Neues Attribut",
+ "upload_contacts_modal_attributes_search_or_add": "Attribut suchen oder hinzufügen",
+ "upload_contacts_modal_attributes_should_be_mapped_to": "sollte zugeordnet werden zu",
+ "upload_contacts_modal_attributes_title": "Attribute",
+ "upload_contacts_modal_description": "Lade eine CSV hoch, um Kontakte mit Attributen schnell zu importieren",
+ "upload_contacts_modal_duplicates_description": "Wie sollen wir vorgehen, wenn ein Kontakt bereits existiert?",
+ "upload_contacts_modal_duplicates_overwrite_description": "Überschreibt die bestehenden Kontakte",
+ "upload_contacts_modal_duplicates_overwrite_title": "Überschreiben",
+ "upload_contacts_modal_duplicates_skip_description": "Überspringt doppelte Kontakte",
+ "upload_contacts_modal_duplicates_skip_title": "Überspringen",
+ "upload_contacts_modal_duplicates_title": "Duplikate",
+ "upload_contacts_modal_duplicates_update_description": "Aktualisiert die bestehenden Kontakte",
+ "upload_contacts_modal_duplicates_update_title": "Aktualisieren",
+ "upload_contacts_modal_pick_different_file": "Wähle eine andere Datei",
+ "upload_contacts_modal_preview": "Hier ist eine Vorschau deiner Daten.",
+ "upload_contacts_modal_upload_btn": "Kontakte hochladen"
+ },
"experience": {
"all": "Alle",
"all_time": "Gesamt",
@@ -729,20 +773,6 @@
"website_or_app_integration_description": "Integriere Formbricks in deine Website oder App",
"zapier_integration_description": "Integriere Formbricks mit über 5000 Apps über Zapier"
},
- "people": {
- "error_fetching_next_page_of_people_data": "Fehler beim Abrufen der nächsten Seite der Personendaten",
- "error_fetching_people_data": "Fehler beim Abrufen der Personendaten",
- "fetching_user": "Benutzer werden abgerufen",
- "formbricks_id": "Formbricks-ID (intern)",
- "how_to_add_people": "Wie man Leute hinzufügt",
- "loading_user_responses": "Benutzerantworten werden geladen",
- "no_responses_found": "Keine Antworten gefunden",
- "not_provided": "Nicht angegeben",
- "person_deleted_successfully": "Person erfolgreich gelöscht",
- "person_not_found": "Keine solche Person gefunden",
- "search_person": "Person suchen",
- "sessions": "Sitzungen"
- },
"project": {
"api-keys": {
"add_api_key": "API-Schlüssel hinzufügen",
@@ -964,6 +994,8 @@
"this_segment_is_used_in_other_surveys": "Dieser Abschnitt wird in anderen Umfragen verwendet. Änderungen vornehmen",
"title_is_required": "Der Titel ist erforderlich.",
"unknown_filter_type": "Unbekannter Filtertyp",
+ "unlock_segments_description": "Organisiere Kontakte in Segmente, um spezifische Nutzergruppen anzusprechen",
+ "unlock_segments_title": "Segmente mit einem höheren Plan freischalten",
"upgrade_your_plan": "aktualisiere deinen Plan.",
"upgrade_your_plan_to_create_more_than_5_segments": "Upgrade deinen Plan, um mehr als 5 Segmente zu erstellen.",
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
@@ -1022,7 +1054,6 @@
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
- "start_free_trial": "Kostenlos starten",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
@@ -1620,6 +1651,8 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
+ "unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
+ "unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Bis sie eine Antwort einreichen",
"upgrade_to_the_scale_plan": "auf den Scale-Plan upgraden.",
diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json
index 91229a1e74..d551829573 100644
--- a/packages/lib/messages/en-US.json
+++ b/packages/lib/messages/en-US.json
@@ -101,6 +101,7 @@
"accepted": "Accepted",
"account": "Account",
"account_settings": "Account settings",
+ "action": "Action",
"actions": "Actions",
"active_surveys": "Active surveys",
"activity": "Activity",
@@ -151,6 +152,8 @@
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
+ "contact": "Contact",
+ "contacts": "Contacts",
"copied_to_clipboard": "Copied to clipboard",
"copy": "Copy",
"copy_code": "Copy code",
@@ -335,6 +338,7 @@
"select": "Select",
"select_all": "Select all",
"select_survey": "Select Survey",
+ "selected": "Selected",
"selected_questions": "Selected questions",
"selection": "Selection",
"selections": "Selections",
@@ -350,6 +354,7 @@
"some_files_failed_to_upload": "Some files failed to upload",
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
"sort_by": "Sort by",
+ "start_free_trial": "Start Free Trial",
"status": "Status",
"step_by_step_manual": "Step by step manual",
"styling": "Styling",
@@ -388,6 +393,9 @@
"update": "Update",
"updated": "Updated",
"updated_at": "Updated at",
+ "upgrade_now": "Upgrade now",
+ "upload": "Upload",
+ "upload_input_description": "Click or drag to upload files.",
"url": "URL",
"user": "User",
"user_id": "User ID",
@@ -572,6 +580,42 @@
"subtitle": "It takes less than 4 minutes.",
"waiting_for_your_signal": "Waiting for your signal..."
},
+ "contacts": {
+ "contact_deleted_successfully": "Contact deleted successfully",
+ "contact_not_found": "No such contact found",
+ "contacts_table_refresh": "Refresh contacts",
+ "error_fetching_next_page_of_people_data": "Error fetching next page of contacts data",
+ "error_fetching_people_data": "Error fetching contacts data",
+ "fetching_user": "Fetching user",
+ "first_name": "First Name",
+ "formbricks_id": "Formbricks Id (internal)",
+ "how_to_add_contacts": "How to add contacts",
+ "last_name": "Last Name",
+ "loading_user_responses": "Loading user responses",
+ "no_responses_found": "No responses found",
+ "not_provided": "Not provided",
+ "search_contact": "Search contact",
+ "sessions": "Sessions",
+ "unlock_contacts_description": "Manage contacts and send out targeted surveys",
+ "unlock_contacts_title": "Unlock contacts with a higher plan",
+ "upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
+ "upload_contacts_modal_attributes_new": "New attribute",
+ "upload_contacts_modal_attributes_search_or_add": "Search or add attribute",
+ "upload_contacts_modal_attributes_should_be_mapped_to": "should be mapped to",
+ "upload_contacts_modal_attributes_title": "Attributes",
+ "upload_contacts_modal_description": "Upload a CSV to quickly import contacts with attributes",
+ "upload_contacts_modal_duplicates_description": "How should we handle if a contact already exists in your contacts?",
+ "upload_contacts_modal_duplicates_overwrite_description": "Overwrites the existing contacts",
+ "upload_contacts_modal_duplicates_overwrite_title": "Overwrite",
+ "upload_contacts_modal_duplicates_skip_description": "Skips the duplicate contacts",
+ "upload_contacts_modal_duplicates_skip_title": "Skip",
+ "upload_contacts_modal_duplicates_title": "Duplicates",
+ "upload_contacts_modal_duplicates_update_description": "Updates the existing contacts",
+ "upload_contacts_modal_duplicates_update_title": "Update",
+ "upload_contacts_modal_pick_different_file": "Pick a different file",
+ "upload_contacts_modal_preview": "Here's a preview of your data.",
+ "upload_contacts_modal_upload_btn": "Upload contacts"
+ },
"experience": {
"all": "All",
"all_time": "All time",
@@ -729,20 +773,6 @@
"website_or_app_integration_description": "Integrate Formbricks into your Website or App",
"zapier_integration_description": "Integrate Formbricks with 5000+ apps via Zapier"
},
- "people": {
- "error_fetching_next_page_of_people_data": "Error fetching next page of people data",
- "error_fetching_people_data": "Error fetching people data",
- "fetching_user": "Fetching user",
- "formbricks_id": "Formbricks Id (internal)",
- "how_to_add_people": "How to add people",
- "loading_user_responses": "Loading user responses",
- "no_responses_found": "No responses found",
- "not_provided": "Not provided",
- "person_deleted_successfully": "Person deleted successfully",
- "person_not_found": "No such person found",
- "search_person": "Search person",
- "sessions": "Sessions"
- },
"project": {
"api-keys": {
"add_api_key": "Add API Key",
@@ -964,6 +994,8 @@
"this_segment_is_used_in_other_surveys": "This segment is used in other surveys. Make changes",
"title_is_required": "Title is required.",
"unknown_filter_type": "Unknown filter type",
+ "unlock_segments_description": "Organize contacts into segments to target specific user groups",
+ "unlock_segments_title": "Unlock segments with a higher plan",
"upgrade_your_plan": "upgrade your plan.",
"upgrade_your_plan_to_create_more_than_5_segments": "Upgrade your plan to create more than 5 segments.",
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
@@ -1022,7 +1054,6 @@
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
- "start_free_trial": "Start Free Trial",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
@@ -1620,6 +1651,8 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
"type_field_id": "Type field id",
+ "unlock_targeting_description": "Target specific user groups based on attributes or device information",
+ "unlock_targeting_title": "Unlock targeting with a higher plan",
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Until they submit a response",
"upgrade_to_the_scale_plan": "upgrade to the Scale plan.",
diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json
index f8bd4333d1..da565f7f11 100644
--- a/packages/lib/messages/pt-BR.json
+++ b/packages/lib/messages/pt-BR.json
@@ -101,6 +101,7 @@
"accepted": "Aceito",
"account": "conta",
"account_settings": "Configurações da conta",
+ "action": "Ação",
"actions": "Ações",
"active_surveys": "Pesquisas ativas",
"activity": "Atividade",
@@ -151,6 +152,8 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "conectado",
+ "contact": "Contato",
+ "contacts": "Contatos",
"copied_to_clipboard": "Copiado para a área de transferência",
"copy": "Copiar",
"copy_code": "Copiar código",
@@ -335,6 +338,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_survey": "Selecionar Pesquisa",
+ "selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "seleção",
"selections": "seleções",
@@ -350,6 +354,7 @@
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
"something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.",
"sort_by": "Ordenar por",
+ "start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"styling": "estilização",
@@ -388,6 +393,9 @@
"update": "atualizar",
"updated": "atualizado",
"updated_at": "Atualizado em",
+ "upgrade_now": "Atualize agora",
+ "upload": "Enviar",
+ "upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
"url": "URL",
"user": "Usuário",
"user_id": "ID do usuário",
@@ -572,6 +580,42 @@
"subtitle": "Leva menos de 4 minutos.",
"waiting_for_your_signal": "Esperando seu sinal..."
},
+ "contacts": {
+ "contact_deleted_successfully": "Contato excluído com sucesso",
+ "contact_not_found": "Nenhum contato encontrado",
+ "contacts_table_refresh": "Atualizar contatos",
+ "error_fetching_next_page_of_people_data": "Erro ao buscar a próxima página de dados de contatos",
+ "error_fetching_people_data": "Erro ao buscar os dados de contatos",
+ "fetching_user": "Carregando usuário",
+ "first_name": "Primeiro Nome",
+ "formbricks_id": "Formbricks Id (interno)",
+ "how_to_add_contacts": "Como adicionar contatos",
+ "last_name": "Sobrenome",
+ "loading_user_responses": "Carregando respostas do usuário",
+ "no_responses_found": "Nenhuma resposta encontrada",
+ "not_provided": "Não fornecido",
+ "search_contact": "Buscar contato",
+ "sessions": "Sessões",
+ "unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
+ "unlock_contacts_title": "Desbloqueie contatos com um plano superior",
+ "upload_contacts_modal_attributes_description": "Mapeie as colunas do seu CSV para os atributos no Formbricks.",
+ "upload_contacts_modal_attributes_new": "Novo atributo",
+ "upload_contacts_modal_attributes_search_or_add": "Buscar ou adicionar atributo",
+ "upload_contacts_modal_attributes_should_be_mapped_to": "deve ser mapeado para",
+ "upload_contacts_modal_attributes_title": "Atributos",
+ "upload_contacts_modal_description": "Faça upload de um CSV para importar contatos com atributos rapidamente",
+ "upload_contacts_modal_duplicates_description": "O que devemos fazer se um contato já existir nos seus contatos?",
+ "upload_contacts_modal_duplicates_overwrite_description": "Sobrescreve os contatos existentes",
+ "upload_contacts_modal_duplicates_overwrite_title": "Sobrescrever",
+ "upload_contacts_modal_duplicates_skip_description": "Ignora os contatos duplicados",
+ "upload_contacts_modal_duplicates_skip_title": "Ignorar",
+ "upload_contacts_modal_duplicates_title": "Duplicados",
+ "upload_contacts_modal_duplicates_update_description": "Atualiza os contatos existentes",
+ "upload_contacts_modal_duplicates_update_title": "Atualizar",
+ "upload_contacts_modal_pick_different_file": "Escolha um arquivo diferente",
+ "upload_contacts_modal_preview": "Aqui está uma prévia dos seus dados.",
+ "upload_contacts_modal_upload_btn": "Fazer upload de contatos"
+ },
"experience": {
"all": "tudo",
"all_time": "Todo o tempo",
@@ -729,20 +773,6 @@
"website_or_app_integration_description": "Integrar o Formbricks no seu site ou app",
"zapier_integration_description": "Integrar o Formbricks com mais de 5000 apps via Zapier"
},
- "people": {
- "error_fetching_next_page_of_people_data": "Erro ao buscar a próxima página de dados das pessoas",
- "error_fetching_people_data": "Erro ao buscar dados das pessoas",
- "fetching_user": "Buscando usuário",
- "formbricks_id": "ID do Formbricks (interno)",
- "how_to_add_people": "Como adicionar pessoas",
- "loading_user_responses": "Carregando respostas dos usuários",
- "no_responses_found": "Não foram encontradas respostas",
- "not_provided": "Não fornecido",
- "person_deleted_successfully": "Pessoa deletada com sucesso",
- "person_not_found": "Pessoa não encontrada",
- "search_person": "Procurar pessoa",
- "sessions": "Sessões"
- },
"project": {
"api-keys": {
"add_api_key": "Adicionar Chave API",
@@ -964,6 +994,8 @@
"this_segment_is_used_in_other_surveys": "Esse segmento é usado em outras pesquisas. Faça alterações",
"title_is_required": "É necessário um título.",
"unknown_filter_type": "Tipo de filtro desconhecido",
+ "unlock_segments_description": "Organize contatos em segmentos para direcionar grupos específicos de usuários",
+ "unlock_segments_title": "Desbloqueie segmentos com um plano superior",
"upgrade_your_plan": "atualize seu plano.",
"upgrade_your_plan_to_create_more_than_5_segments": "Faça um upgrade no seu plano para criar mais de 5 segmentos.",
"user_targeting_is_currently_only_available_when": "A segmentação de usuários está disponível apenas quando",
@@ -1022,7 +1054,6 @@
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
- "start_free_trial": "Iniciar Teste Grátis",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
@@ -1620,6 +1651,8 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
"type_field_id": "Digite o id do campo",
+ "unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
+ "unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Até eles enviarem uma resposta",
"upgrade_to_the_scale_plan": "faça um upgrade para o plano Scale.",
diff --git a/packages/lib/person/auth.ts b/packages/lib/person/auth.ts
deleted file mode 100644
index 2dc7ebbbbe..0000000000
--- a/packages/lib/person/auth.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import "server-only";
-import { ZId } from "@formbricks/types/common";
-import { cache } from "../cache";
-import { hasUserEnvironmentAccess } from "../environment/auth";
-import { validateInputs } from "../utils/validate";
-import { personCache } from "./cache";
-import { getPerson } from "./service";
-
-export const canUserAccessPerson = (userId: string, personId: string): Promise