mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-15 12:18:42 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdddf034f2 | |||
| 6e35fc1769 | |||
| 48cded1646 | |||
| db752cee15 | |||
| b33aae0a73 | |||
| 72126ad736 | |||
| 4a2eeac90b | |||
| 46be3e7d70 | |||
| 6d140532a7 | |||
| 8c4a7f1518 | |||
| 63fe32a786 | |||
| 84c465f974 | |||
| 6a33498737 | |||
| 5130c747d4 | |||
| f5583d2652 | |||
| e0d75914a4 | |||
| f02ca1cfe1 | |||
| 4ade83f189 | |||
| f1fc9fea2c | |||
| 25266e4566 | |||
| b960cfd2a1 | |||
| 9e1d1c1dc2 | |||
| 8c63a9f7af | |||
| fff0a7f052 | |||
| 0ecc8aabff | |||
| 01cc0ab64d | |||
| 1d125bdac2 | |||
| 5555112e56 | |||
| 7c92e2b5bb | |||
| e90bb93dfb |
@@ -0,0 +1,404 @@
|
||||
---
|
||||
name: Date Attribute Type Feature
|
||||
overview: Add DATE type support to the Formbricks attribute system, enabling time-based segment filters like "Sign Up Date is older than 3 months". This involves schema changes, new operators, UI components, SDK updates, and evaluation logic.
|
||||
todos:
|
||||
- id: schema
|
||||
content: Add ContactAttributeDataType enum and dataType field to ContactAttributeKey in Prisma schema
|
||||
status: completed
|
||||
- id: types
|
||||
content: "Update type definitions: add data type to contact-attribute-key.ts, add date operators to segment.ts"
|
||||
status: completed
|
||||
- id: zod
|
||||
content: Update Zod schemas in packages/database/zod/ to include dataType
|
||||
status: completed
|
||||
- id: detect
|
||||
content: Create auto-detection logic for attribute data types based on value format
|
||||
status: completed
|
||||
- id: attributes
|
||||
content: Update attribute creation/update logic to auto-detect and persist dataType
|
||||
status: completed
|
||||
- id: date-utils
|
||||
content: Create date utility functions for relative time calculations
|
||||
status: completed
|
||||
- id: eval-logic
|
||||
content: Add date filter evaluation logic to segments.ts evaluateSegment function
|
||||
status: completed
|
||||
- id: prisma-query
|
||||
content: Update prisma-query.ts to handle date comparisons in segment filters
|
||||
status: completed
|
||||
- id: ui-operators
|
||||
content: Update segment-filter.tsx to show date-specific operators when attribute is DATE type
|
||||
status: completed
|
||||
- id: ui-value
|
||||
content: Create date-filter-value.tsx component for date filter value input
|
||||
status: completed
|
||||
- id: utils
|
||||
content: Add date operator text/title conversions in utils.ts
|
||||
status: completed
|
||||
- id: sdk
|
||||
content: Update JS SDK to accept Date objects and convert to ISO strings
|
||||
status: completed
|
||||
- id: api
|
||||
content: Update API endpoints to expose dataType in contact attribute key responses
|
||||
status: completed
|
||||
- id: i18n
|
||||
content: Add translation keys for new operators and UI elements
|
||||
status: completed
|
||||
- id: tests
|
||||
content: Add unit tests for date detection, evaluation, and UI components
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Date Attribute Type Feature
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The attribute system currently stores all values as strings:
|
||||
|
||||
- `ContactAttribute.value` is `String` in Prisma schema (line 73)
|
||||
- `ContactAttributeKey` has no `dataType` field - only `type` (default/custom)
|
||||
- Segment filter operators are string/number-focused with no date awareness
|
||||
- SDK accepts `Record<string, string>` only
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### 1. Database Schema Updates
|
||||
|
||||
Add `dataType` enum and field to `ContactAttributeKey`:
|
||||
|
||||
```prisma
|
||||
enum ContactAttributeDataType {
|
||||
text
|
||||
number
|
||||
date
|
||||
}
|
||||
|
||||
model ContactAttributeKey {
|
||||
// ... existing fields
|
||||
dataType ContactAttributeDataType @default(text)
|
||||
}
|
||||
```
|
||||
|
||||
Store dates as ISO 8601 strings in `ContactAttribute.value` (no schema change needed for value column).
|
||||
|
||||
### 2. Type Definitions (`packages/types/`)
|
||||
|
||||
**`packages/types/contact-attribute-key.ts`** - Add data type:
|
||||
|
||||
```typescript
|
||||
export const ZContactAttributeDataType = z.enum(["text", "number", "date"]);
|
||||
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
|
||||
```
|
||||
|
||||
**`packages/types/segment.ts`** - Add date operators:
|
||||
|
||||
```typescript
|
||||
export const DATE_OPERATORS = [
|
||||
"isOlderThan", // relative: X days/weeks/months/years ago
|
||||
"isNewerThan", // relative: within last X days/weeks/months/years
|
||||
"isBefore", // absolute: before specific date
|
||||
"isAfter", // absolute: after specific date
|
||||
"isBetween", // absolute: between two dates
|
||||
"isSameDay", // absolute: matches specific date
|
||||
] as const;
|
||||
|
||||
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
|
||||
|
||||
export const ZSegmentDateFilter = z.object({
|
||||
id: z.string().cuid2(),
|
||||
root: z.object({
|
||||
type: z.literal("attribute"),
|
||||
contactAttributeKey: z.string(),
|
||||
}),
|
||||
value: z.union([
|
||||
// Relative: { amount: 3, unit: "months" }
|
||||
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
|
||||
// Absolute: ISO date string or [start, end] for between
|
||||
z.string(),
|
||||
z.tuple([z.string(), z.string()]),
|
||||
]),
|
||||
qualifier: z.object({
|
||||
operator: z.enum(DATE_OPERATORS),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Auto-Detection Logic (`apps/web/modules/ee/contacts/lib/`)
|
||||
|
||||
Create `detect-attribute-type.ts`:
|
||||
|
||||
```typescript
|
||||
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
|
||||
// Check if valid ISO 8601 date
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return "date";
|
||||
}
|
||||
// Check if numeric
|
||||
if (!isNaN(Number(value)) && value.trim() !== "") {
|
||||
return "number";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
```
|
||||
|
||||
Update `apps/web/modules/ee/contacts/lib/attributes.ts` to auto-detect and set `dataType` when creating new attribute keys.
|
||||
|
||||
### 4. Segment Filter UI Components
|
||||
|
||||
**New files in `apps/web/modules/ee/contacts/segments/components/`:**
|
||||
|
||||
- `date-filter-value.tsx` - Combined component for date filter value input:
|
||||
- Relative time: number input + unit dropdown (days/weeks/months/years)
|
||||
- Absolute date: date picker component
|
||||
- Between: two date pickers for range
|
||||
|
||||
- Update `segment-filter.tsx`:
|
||||
- Check `contactAttributeKey.dataType` to determine which operators to show
|
||||
- Render appropriate value input based on operator type
|
||||
- Handle date-specific validation
|
||||
|
||||
### 5. Filter Evaluation Logic
|
||||
|
||||
Update `apps/web/modules/ee/contacts/segments/lib/segments.ts`:
|
||||
|
||||
```typescript
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string,
|
||||
filterValue: TDateFilterValue,
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
const attrDate = new Date(attributeValue);
|
||||
const now = new Date();
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate < threshold;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate >= threshold;
|
||||
}
|
||||
case "isBefore":
|
||||
return attrDate < new Date(filterValue);
|
||||
case "isAfter":
|
||||
return attrDate > new Date(filterValue);
|
||||
case "isBetween":
|
||||
return attrDate >= new Date(filterValue[0]) && attrDate <= new Date(filterValue[1]);
|
||||
case "isSameDay":
|
||||
return isSameDay(attrDate, new Date(filterValue));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Prisma Query Generation (No Raw SQL)
|
||||
|
||||
Update `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts`:
|
||||
|
||||
Since dates are stored as ISO 8601 strings, lexicographic string comparison works correctly (e.g., `"2024-01-15" < "2024-02-01"`). Calculate threshold dates in JavaScript and pass as ISO strings:
|
||||
|
||||
```typescript
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentDateFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { operator } = qualifier;
|
||||
const now = new Date();
|
||||
|
||||
let dateCondition: Prisma.StringFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { lt: threshold.toISOString() };
|
||||
break;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { gte: threshold.toISOString() };
|
||||
break;
|
||||
}
|
||||
case "isBefore":
|
||||
dateCondition = { lt: value };
|
||||
break;
|
||||
case "isAfter":
|
||||
dateCondition = { gt: value };
|
||||
break;
|
||||
case "isBetween":
|
||||
dateCondition = { gte: value[0], lte: value[1] };
|
||||
break;
|
||||
case "isSameDay": {
|
||||
const dayStart = startOfDay(new Date(value)).toISOString();
|
||||
const dayEnd = endOfDay(new Date(value)).toISOString();
|
||||
dateCondition = { gte: dayStart, lte: dayEnd };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: root.contactAttributeKey },
|
||||
value: dateCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Backwards Compatibility Concerns
|
||||
|
||||
### 1. API Response Changes (Non-Breaking)
|
||||
|
||||
- **Concern**: Adding `dataType` to `ContactAttributeKey` responses
|
||||
- **Solution**: This is an additive change - existing clients ignore unknown fields
|
||||
- **Action**: No breaking change, just document the new field
|
||||
|
||||
### 2. API Request Changes (Non-Breaking)
|
||||
|
||||
- **Concern**: Existing integrations create attributes without specifying `dataType`
|
||||
- **Solution**: Make `dataType` optional in create/update requests; auto-detect from value if not provided
|
||||
- **Action**: Default to auto-detection, allow explicit override
|
||||
|
||||
### 3. SDK Signature Change (Backwards Compatible)
|
||||
|
||||
- **Concern**: Current signature `Record<string, string>` changing to `Record<string, string | Date>`
|
||||
- **Solution**: TypeScript union types are backwards compatible - existing string values work
|
||||
- **Action**: Existing code continues to work; Date objects are a new optional capability
|
||||
|
||||
### 4. Existing Segment Filters (Critical)
|
||||
|
||||
- **Concern**: Existing filters in database use current operator format
|
||||
- **Solution**:
|
||||
- Keep all existing operators functional
|
||||
- Date operators only appear in UI when attribute has `dataType: "date"`
|
||||
- Filter evaluation checks operator type and routes to appropriate handler
|
||||
- **Action**: Add `isDateOperator()` check in evaluation logic
|
||||
|
||||
### 5. Filter Value Schema Change (Requires Careful Handling)
|
||||
|
||||
- **Concern**: Current `TSegmentFilterValue = string | number`, dates need `{ amount, unit }` for relative
|
||||
- **Solution**: Extend the union type, not replace:
|
||||
```typescript
|
||||
export const ZSegmentFilterValue = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }), // NEW
|
||||
z.tuple([z.string(), z.string()]), // NEW: for "between" operator
|
||||
]);
|
||||
```
|
||||
|
||||
- **Action**: Existing filters parse correctly; new format only used for date operators
|
||||
|
||||
### 6. Database Migration (Safe)
|
||||
|
||||
- **Concern**: Adding `dataType` column to existing `ContactAttributeKey` rows
|
||||
- **Solution**:
|
||||
- Add column with `@default(text)`
|
||||
- All existing attributes become `text` type automatically
|
||||
- No data transformation needed
|
||||
- **Action**: Simple additive migration, no downtime
|
||||
|
||||
### 7. Segment Evaluation at Runtime
|
||||
|
||||
- **Concern**: Old segments with text operators should not break
|
||||
- **Solution**:
|
||||
- `evaluateAttributeFilter()` checks if operator is date-specific
|
||||
- If yes, calls `evaluateDateFilter()`
|
||||
- If no, uses existing `compareValues()` logic
|
||||
- **Action**: Add operator type routing in evaluation
|
||||
|
||||
### 8. Client-Side Segment Evaluation (JS SDK)
|
||||
|
||||
- **Concern**: SDK may evaluate segments client-side for performance
|
||||
- **Solution**: Ensure SDK's segment evaluation logic also handles date operators
|
||||
- **Action**: Update `packages/js-core` if client-side evaluation exists
|
||||
|
||||
### Version Matrix
|
||||
|
||||
| Component | Breaking Change | Migration Required |
|
||||
|
||||
|-----------|-----------------|-------------------|
|
||||
|
||||
| Database Schema | No | Yes (additive) |
|
||||
|
||||
| REST API | No | No |
|
||||
|
||||
| JS SDK | No | No (optional upgrade) |
|
||||
|
||||
| Existing Segments | No | No |
|
||||
|
||||
| UI | No | No |
|
||||
|
||||
### 7. SDK Updates (`packages/js-core/`)
|
||||
|
||||
Update `packages/js-core/src/lib/user/attribute.ts`:
|
||||
|
||||
```typescript
|
||||
export const setAttributes = async (
|
||||
attributes: Record<string, string | Date>
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
// Convert Date objects to ISO strings
|
||||
const normalizedAttributes = Object.fromEntries(
|
||||
Object.entries(attributes).map(([key, value]) => [
|
||||
key,
|
||||
value instanceof Date ? value.toISOString() : value,
|
||||
])
|
||||
);
|
||||
// ... rest of implementation
|
||||
};
|
||||
```
|
||||
|
||||
### 8. API Updates
|
||||
|
||||
Update attribute endpoints to include `dataType` in responses:
|
||||
|
||||
- `apps/web/modules/api/v2/management/contact-attribute-keys/`
|
||||
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|
||||
|------|--------|
|
||||
|
||||
| `packages/database/schema.prisma` | Add `ContactAttributeDataType` enum, add `dataType` field |
|
||||
|
||||
| `packages/types/contact-attribute-key.ts` | Add data type definitions |
|
||||
|
||||
| `packages/types/segment.ts` | Add date operators, time units, date filter schema |
|
||||
|
||||
| `packages/database/zod/contact-attribute-keys.ts` | Add dataType to zod schema |
|
||||
|
||||
| `apps/web/modules/ee/contacts/lib/attributes.ts` | Auto-detect dataType on attribute creation |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/segments.ts` | Add date filter evaluation |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts` | Add date query building |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/utils.ts` | Add date operator text/title conversions |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/components/segment-filter.tsx` | Conditionally render date operators/inputs |
|
||||
|
||||
| `packages/js-core/src/lib/user/attribute.ts` | Accept Date objects |
|
||||
|
||||
## New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
|------|---------|
|
||||
|
||||
| `apps/web/modules/ee/contacts/lib/detect-attribute-type.ts` | Auto-detection logic |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx` | Date filter value UI |
|
||||
|
||||
| `apps/web/modules/ee/contacts/segments/lib/date-utils.ts` | Date comparison utilities |
|
||||
|
||||
## Migration
|
||||
|
||||
Create Prisma migration:
|
||||
|
||||
```bash
|
||||
pnpm db:migrate:dev --name add_contact_attribute_data_type
|
||||
```
|
||||
|
||||
Default existing attributes to `text` dataType (no data migration needed).
|
||||
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
||||
# Enterprise License Key
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
|
||||
# Internal Environment (production, staging) - used for internal staging environment
|
||||
# ENVIRONMENT=production
|
||||
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
|
||||
@@ -62,3 +62,4 @@ branch.json
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
stats.html
|
||||
|
||||
+1
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
+21
-1
@@ -3,6 +3,7 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
Code2Icon,
|
||||
CodeIcon,
|
||||
Link2Icon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
@@ -18,6 +19,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||
@@ -51,6 +53,7 @@ interface ShareSurveyModalProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -65,6 +68,7 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -191,9 +195,24 @@ export const ShareSurveyModal = ({
|
||||
componentType: PrettyUrlTab,
|
||||
componentProps: { publicDomain, isReadOnly },
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.CUSTOM_HTML,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.custom_html.nav_title"),
|
||||
icon: CodeIcon,
|
||||
title: t("environments.surveys.share.custom_html.nav_title"),
|
||||
description: t("environments.surveys.share.custom_html.description"),
|
||||
componentType: CustomHtmlTab,
|
||||
componentProps: { projectCustomScripts, isReadOnly },
|
||||
},
|
||||
];
|
||||
|
||||
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
|
||||
// Filter out tabs that should not be shown on Formbricks Cloud
|
||||
return isFormbricksCloud
|
||||
? tabs.filter(
|
||||
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
|
||||
)
|
||||
: tabs;
|
||||
}, [
|
||||
t,
|
||||
survey,
|
||||
@@ -207,6 +226,7 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
]);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||
|
||||
interface CustomHtmlTabProps {
|
||||
projectCustomScripts: string | null | undefined;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
interface CustomHtmlFormData {
|
||||
customHeadScripts: string;
|
||||
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
|
||||
}
|
||||
|
||||
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { survey } = useSurvey();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const form = useForm<CustomHtmlFormData>({
|
||||
defaultValues: {
|
||||
customHeadScripts: survey.customHeadScripts ?? "",
|
||||
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = form;
|
||||
|
||||
const scriptsMode = watch("customHeadScriptsMode");
|
||||
|
||||
const onSubmit = async (data: CustomHtmlFormData) => {
|
||||
if (isSaving || isReadOnly) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const updatedSurvey: TSurvey = {
|
||||
...survey,
|
||||
customHeadScripts: data.customHeadScripts || null,
|
||||
customHeadScriptsMode: data.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
const result = await updateSurveyAction(updatedSurvey);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
|
||||
reset(data);
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-1">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Mode Toggle */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
|
||||
<TabToggle
|
||||
id="custom-scripts-mode"
|
||||
options={[
|
||||
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
|
||||
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
|
||||
]}
|
||||
defaultSelected={scriptsMode ?? "add"}
|
||||
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<p className="text-sm text-slate-500">
|
||||
{scriptsMode === "add"
|
||||
? t("environments.surveys.share.custom_html.add_mode_description")
|
||||
: t("environments.surveys.share.custom_html.replace_mode_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Workspace Scripts Preview */}
|
||||
{projectCustomScripts && (
|
||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||
{projectCustomScripts}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!projectCustomScripts && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Survey Scripts */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customHeadScripts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("environments.surveys.share.custom_html.survey_scripts_description")}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<textarea
|
||||
rows={8}
|
||||
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
{...field}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
{/* Security Warning */}
|
||||
<Alert variant="warning" className="flex items-start gap-2">
|
||||
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.custom_html.security_warning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -13,6 +13,7 @@ export enum ShareViaType {
|
||||
export enum ShareSettingsType {
|
||||
LINK_SETTINGS = "link-settings",
|
||||
PRETTY_URL = "pretty-url",
|
||||
CUSTOM_HTML = "custom-html",
|
||||
}
|
||||
|
||||
export enum LinkTabsType {
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -82,6 +82,7 @@ const mockPipelineInput = {
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
ipAddress: "203.0.113.7",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
// Adjust expectations for metadata and recalled question
|
||||
const expectedMetadataString =
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
|
||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
||||
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
||||
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
||||
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
|
||||
|
||||
// Join all the elements in the result array with a newline for formatting
|
||||
return result.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Standard Webhooks headers
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
};
|
||||
|
||||
// Add signature if webhook has a secret configured
|
||||
if (webhook.secret) {
|
||||
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||
webhookMessageId,
|
||||
webhookTimestamp,
|
||||
body,
|
||||
webhook.secret
|
||||
);
|
||||
}
|
||||
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
|
||||
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -4835,7 +4835,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
segment: null,
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
id: "cltxxaa6x0000g8hacxdxeje1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
@@ -4857,7 +4857,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
id: "cltxxaa6x0000g8hacxdxeje2",
|
||||
name: "Block 2",
|
||||
elements: [
|
||||
{
|
||||
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
slug: null,
|
||||
|
||||
@@ -170,6 +170,7 @@ checksums:
|
||||
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/domain: 402d46965eacc3af4c5df92e53e95712
|
||||
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||
@@ -736,20 +737,26 @@ checksums:
|
||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
|
||||
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
|
||||
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
|
||||
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
|
||||
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
|
||||
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
|
||||
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
|
||||
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
|
||||
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
|
||||
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
|
||||
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
|
||||
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
|
||||
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
|
||||
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
|
||||
@@ -1119,6 +1126,8 @@ checksums:
|
||||
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
|
||||
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
|
||||
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
|
||||
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
|
||||
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
|
||||
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
|
||||
@@ -1569,6 +1578,7 @@ checksums:
|
||||
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
|
||||
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
|
||||
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
@@ -1609,6 +1619,20 @@ checksums:
|
||||
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
|
||||
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
|
||||
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
|
||||
environments/surveys/share/custom_html/add_mode_description: f48dcf53bce27cc40c3546547e8395cb
|
||||
environments/surveys/share/custom_html/add_to_workspace: af9cd24872f25cfc4231b926acc76d7c
|
||||
environments/surveys/share/custom_html/description: 0634048655de8b4b17b41d496e1ea457
|
||||
environments/surveys/share/custom_html/nav_title: 01f993f027ab277058eacb8a48ea7c01
|
||||
environments/surveys/share/custom_html/no_workspace_scripts: 7fc57f576c98e96ee73e7b489345d51a
|
||||
environments/surveys/share/custom_html/placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||
environments/surveys/share/custom_html/replace_mode_description: 6eaf17275c02b0d5ac21255747f36271
|
||||
environments/surveys/share/custom_html/replace_workspace: b80e698cc8790246fea42453bfa4b09d
|
||||
environments/surveys/share/custom_html/saved_successfully: 14e7d2d646803ac1dd24cfa45c22606c
|
||||
environments/surveys/share/custom_html/script_mode: 60ed1102dd42ad14e272df5f6921b423
|
||||
environments/surveys/share/custom_html/security_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||
environments/surveys/share/custom_html/survey_scripts_description: 948746d51db23b348164105c175391b3
|
||||
environments/surveys/share/custom_html/survey_scripts_label: 095d9fe768abe2bb32428184ee1c9b5a
|
||||
environments/surveys/share/custom_html/workspace_scripts_label: 3d9b6c09eae10a2bacb3ac96b4db4a19
|
||||
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
|
||||
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
|
||||
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
|
||||
@@ -1820,6 +1844,13 @@ checksums:
|
||||
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
||||
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
||||
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
|
||||
environments/workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb
|
||||
environments/workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1
|
||||
environments/workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6
|
||||
environments/workspace/general/custom_scripts_label: 3b189dd62ae0cc35d616e04af90f0b38
|
||||
environments/workspace/general/custom_scripts_placeholder: 229eb1676a69311ff1dcc19c1a52c080
|
||||
environments/workspace/general/custom_scripts_updated_successfully: eabe8e6ededa86342d59093fe308c681
|
||||
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
|
||||
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
|
||||
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
||||
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
|
||||
|
||||
+131
-1
@@ -1,8 +1,11 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
generateStandardWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
getWebhookSecretBytes,
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook Signature Functions", () => {
|
||||
describe("generateWebhookSecret", () => {
|
||||
test("should generate a secret with whsec_ prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
expect(secret.startsWith("whsec_")).toBe(true);
|
||||
});
|
||||
|
||||
test("should generate base64-encoded content after prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const base64Part = secret.slice(6); // Remove "whsec_"
|
||||
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
|
||||
// Should decode to 32 bytes (256 bits)
|
||||
const decoded = Buffer.from(base64Part, "base64");
|
||||
expect(decoded.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should generate unique secrets each time", () => {
|
||||
const secret1 = generateWebhookSecret();
|
||||
const secret2 = generateWebhookSecret();
|
||||
expect(secret1).not.toBe(secret2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebhookSecretBytes", () => {
|
||||
test("should decode whsec_ prefixed secret to bytes", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const bytes = getWebhookSecretBytes(secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should handle secret without whsec_ prefix", () => {
|
||||
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
|
||||
const bytes = getWebhookSecretBytes(base64Secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
|
||||
});
|
||||
|
||||
test("should correctly decode a known secret", () => {
|
||||
// Create a known secret
|
||||
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
|
||||
const secret = `whsec_${knownBytes.toString("base64")}`;
|
||||
|
||||
const decoded = getWebhookSecretBytes(secret);
|
||||
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStandardWebhookSignature", () => {
|
||||
test("should generate signature in v1,{base64} format", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
|
||||
|
||||
expect(signature.startsWith("v1,")).toBe(true);
|
||||
const base64Part = signature.slice(3);
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
});
|
||||
|
||||
test("should generate deterministic signatures for same inputs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different payloads", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different timestamps", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different webhook IDs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should produce verifiable signatures", () => {
|
||||
// This test verifies the signature can be verified using the same algorithm
|
||||
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
|
||||
const secret = `whsec_${secretBytes.toString("base64")}`;
|
||||
const webhookId = "msg_verify";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"verify"}';
|
||||
|
||||
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
// Manually compute the expected signature
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
|
||||
expect(signature).toBe(`v1,${expectedSig}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
// Standard Webhooks secret prefix
|
||||
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
||||
|
||||
/**
|
||||
* Generate a Standard Webhooks compliant secret
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* Format: whsec_ + base64(32 random bytes)
|
||||
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
|
||||
*/
|
||||
export const generateWebhookSecret = (): string => {
|
||||
const secretBytes = randomBytes(32); // 256 bits of entropy
|
||||
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a Standard Webhooks secret to get the raw bytes
|
||||
* Strips the whsec_ prefix and base64 decodes the rest
|
||||
*
|
||||
* @param secret The webhook secret (with or without whsec_ prefix)
|
||||
* @returns Buffer containing the raw secret bytes
|
||||
*/
|
||||
export const getWebhookSecretBytes = (secret: string): Buffer => {
|
||||
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
||||
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
||||
: secret;
|
||||
return Buffer.from(base64Part, "base64");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Standard Webhooks compliant signature
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* @param webhookId Unique message identifier
|
||||
* @param timestamp Unix timestamp in seconds
|
||||
* @param payload The request body as a string
|
||||
* @param secret The shared secret (whsec_ prefixed)
|
||||
* @returns The signature in format "v1,{base64_signature}"
|
||||
*/
|
||||
export const generateStandardWebhookSignature = (
|
||||
webhookId: string,
|
||||
timestamp: number,
|
||||
payload: string,
|
||||
secret: string
|
||||
): string => {
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const secretBytes = getWebhookSecretBytes(secret);
|
||||
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
return `v1,${signature}`;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const env = createEnv({
|
||||
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
@@ -151,6 +152,7 @@ export const env = createEnv({
|
||||
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
|
||||
+6
-145
@@ -130,217 +130,78 @@ export const appLanguages = [
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "English (US)",
|
||||
"de-DE": "Englisch (US)",
|
||||
"pt-BR": "Inglês (EUA)",
|
||||
"fr-FR": "Anglais (États-Unis)",
|
||||
"zh-Hant-TW": "英文 (美國)",
|
||||
"pt-PT": "Inglês (EUA)",
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
"ru-RU": "Английский (США)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Alemão",
|
||||
"fr-FR": "Allemand",
|
||||
"zh-Hant-TW": "德語",
|
||||
"pt-PT": "Alemão",
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
"ru-RU": "Немецкий",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"de-DE": "Portugiesisch (Brasilien)",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"fr-FR": "Portugais (Brésil)",
|
||||
"zh-Hant-TW": "葡萄牙語 (巴西)",
|
||||
"pt-PT": "Português (Brasil)",
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
"ru-RU": "Португальский (Бразилия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "fr-FR",
|
||||
label: {
|
||||
"en-US": "French",
|
||||
"de-DE": "Französisch",
|
||||
"pt-BR": "Francês",
|
||||
"fr-FR": "Français",
|
||||
"zh-Hant-TW": "法語",
|
||||
"pt-PT": "Francês",
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
"ru-RU": "Французский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"de-DE": "Chinesisch (Traditionell)",
|
||||
"pt-BR": "Chinês (Tradicional)",
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
"ru-RU": "Китайский (традиционный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-PT",
|
||||
label: {
|
||||
"en-US": "Portuguese (Portugal)",
|
||||
"de-DE": "Portugiesisch (Portugal)",
|
||||
"pt-BR": "Português (Portugal)",
|
||||
"fr-FR": "Portugais (Portugal)",
|
||||
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
|
||||
"pt-PT": "Português (Portugal)",
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
"ru-RU": "Португальский (Португалия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ro-RO",
|
||||
label: {
|
||||
"en-US": "Romanian",
|
||||
"de-DE": "Rumänisch",
|
||||
"pt-BR": "Romeno",
|
||||
"fr-FR": "Roumain",
|
||||
"zh-Hant-TW": "羅馬尼亞語",
|
||||
"pt-PT": "Romeno",
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
"ru-RU": "Румынский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
"de-DE": "Japanisch",
|
||||
"pt-BR": "Japonês",
|
||||
"fr-FR": "Japonais",
|
||||
"zh-Hant-TW": "日語",
|
||||
"pt-PT": "Japonês",
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
"ru-RU": "Японский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Chinese (Simplified)",
|
||||
"de-DE": "Chinesisch (Vereinfacht)",
|
||||
"pt-BR": "Chinês (Simplificado)",
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
"ru-RU": "Китайский (упрощенный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
"de-DE": "Niederländisch",
|
||||
"pt-BR": "Holandês",
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
"ru-RU": "Голландский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
"ru-RU": "Испанский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
"ru-RU": "Шведский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
export { iso639Languages };
|
||||
|
||||
@@ -21,7 +21,7 @@ export type TInstanceInfo = {
|
||||
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
|
||||
try {
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const selectProject = {
|
||||
environments: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
customHeadScripts: true,
|
||||
};
|
||||
|
||||
export const getUserProjects = reactCache(
|
||||
|
||||
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
@@ -268,6 +269,8 @@ export const mockSyncSurveyOutput: SurveyMock = {
|
||||
showLanguageSwitch: null,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const mockSurveyOutput: SurveyMock = {
|
||||
@@ -292,6 +295,8 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
showLanguageSwitch: null,
|
||||
...baseSurveyProperties,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const createSurveyInput: TSurveyCreateInput = {
|
||||
@@ -322,6 +327,8 @@ export const updateSurveyInput: TSurvey = {
|
||||
...baseSurveyProperties,
|
||||
...commonMockProperties,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
export const mockTransformedSurveyOutput = {
|
||||
@@ -574,4 +581,6 @@ export const mockSurveyWithLogic: TSurvey = {
|
||||
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
|
||||
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
|
||||
],
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
@@ -65,6 +66,8 @@ export const selectSurvey = {
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
customHeadScripts: true,
|
||||
customHeadScriptsMode: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
@@ -563,6 +566,7 @@ export const updateSurveyInternal = async (
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
|
||||
segment: surveySegment,
|
||||
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
return modifiedSurvey;
|
||||
@@ -783,6 +787,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
segment: surveySegment,
|
||||
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
|
||||
};
|
||||
|
||||
return modifiedSurvey;
|
||||
|
||||
@@ -29,6 +29,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
|
||||
...surveyPrisma,
|
||||
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
|
||||
segment,
|
||||
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
|
||||
} as T;
|
||||
|
||||
return transformedSurvey;
|
||||
|
||||
@@ -90,11 +90,10 @@ describe("locale", () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
// Verify Swedish has a language entry with proper label
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domain",
|
||||
"done": "Fertig",
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook hinzufügen",
|
||||
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
||||
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
||||
"copy_secret_now": "Signierungsschlüssel kopieren",
|
||||
"created_by_third_party": "Erstellt von einer dritten Partei",
|
||||
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
||||
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||
"response_created": "Antwort erstellt",
|
||||
"response_finished": "Antwort abgeschlossen",
|
||||
"response_updated": "Antwort aktualisiert",
|
||||
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
|
||||
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
|
||||
"signing_secret": "Signierungsschlüssel",
|
||||
"source": "Quelle",
|
||||
"test_endpoint": "Test-Endpunkt",
|
||||
"triggers": "Auslöser",
|
||||
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
||||
"webhook_created": "Webhook erstellt",
|
||||
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
||||
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
||||
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
||||
"calculate": "Berechnen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
||||
"capture_ip_address": "IP-Adresse erfassen",
|
||||
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
|
||||
"capture_new_action": "Neue Aktion erfassen",
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"ip_address": "IP-Adresse",
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
|
||||
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Umfrage-Skripte werden zusätzlich zu den Workspace-Skripten ausgeführt.",
|
||||
"add_to_workspace": "Zu Workspace-Skripten hinzufügen",
|
||||
"description": "Tracking-Skripte und Pixel zu dieser Umfrage hinzufügen",
|
||||
"nav_title": "Benutzerdefiniertes HTML",
|
||||
"no_workspace_scripts": "Keine Workspace-Skripte konfiguriert. Sie können diese in Workspace-Einstellungen → Allgemein hinzufügen.",
|
||||
"placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Nur Umfrage-Skripte werden ausgeführt. Workspace-Skripte werden ignoriert. Leer lassen, um keine Skripte zu laden.",
|
||||
"replace_workspace": "Workspace-Skripte ersetzen",
|
||||
"saved_successfully": "Benutzerdefinierte Skripte erfolgreich gespeichert",
|
||||
"script_mode": "Skript-Modus",
|
||||
"security_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||
"survey_scripts_description": "Benutzerdefiniertes HTML hinzufügen, das in den <head> dieser Umfrageseite eingefügt wird.",
|
||||
"survey_scripts_label": "Umfragespezifische Skripte",
|
||||
"workspace_scripts_label": "Workspace-Skripte (vererbt)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Umfrage bearbeiten",
|
||||
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab ‚Einstellungen‘ im Umfrage-Editor ändern.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Dies ist Ihr einziges Projekt, es kann nicht gelöscht werden. Erstellen Sie zuerst ein neues Projekt.",
|
||||
"custom_scripts": "Benutzerdefinierte Skripte",
|
||||
"custom_scripts_card_description": "Tracking-Skripte und Pixel zu allen Link-Umfragen in diesem Workspace hinzufügen.",
|
||||
"custom_scripts_description": "Skripte werden in den <head> aller Link-Umfrageseiten eingefügt.",
|
||||
"custom_scripts_label": "HTML-Skripte",
|
||||
"custom_scripts_placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Benutzerdefinierte Skripte erfolgreich aktualisiert",
|
||||
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||
"delete_workspace": "Projekt löschen",
|
||||
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
||||
|
||||
@@ -184,8 +184,10 @@
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"delete_selected": "{count, plural, one {Delete # item} other {Delete # items}}",
|
||||
"description": "Description",
|
||||
"dev_env": "Dev Environment",
|
||||
"development": "Development",
|
||||
@@ -197,6 +199,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domain",
|
||||
"done": "Done",
|
||||
"download": "Download",
|
||||
"draft": "Draft",
|
||||
"duplicate": "Duplicate",
|
||||
@@ -205,6 +208,7 @@
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enter_value": "Enter value",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment": "Environment",
|
||||
"environment_not_found": "Environment not found",
|
||||
@@ -272,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -387,6 +392,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -430,6 +436,7 @@
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
@@ -442,6 +449,7 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -452,6 +460,7 @@
|
||||
"workspace_not_found": "Workspace not found",
|
||||
"workspace_permission_not_found": "Workspace permission not found",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -608,38 +617,55 @@
|
||||
},
|
||||
"contacts": {
|
||||
"add_attribute": "Add Attribute",
|
||||
"attribute_added_successfully": "Attribute added successfully",
|
||||
"attribute_created_successfully": "Attribute created successfully",
|
||||
"attribute_deleted_successfully": "Attribute deleted successfully",
|
||||
"attribute_description": "Description",
|
||||
"attribute_description_placeholder": "Short description",
|
||||
"attribute_key": "Key",
|
||||
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
|
||||
"attribute_key_created_successfully": "Attribute key created successfully",
|
||||
"attribute_key_description": "Unique identifier (e.g., signUpDate, planType)",
|
||||
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
|
||||
"attribute_key_placeholder": "e.g. date_of_birth",
|
||||
"attribute_key_required": "Key is required",
|
||||
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
"attribute_keys_deleted_successfully": "{count, plural, one {Attribute key deleted successfully} other {# attribute keys deleted successfully}}",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "e.g. Date of Birth",
|
||||
"attribute_name_description": "Human-readable display name",
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_value": "Value",
|
||||
"attribute_value_placeholder": "Attribute Value",
|
||||
"attributes_updated_successfully": "Attributes updated successfully",
|
||||
"confirm_delete_attribute": "Are you sure you want to delete the {attributeName} attribute? This cannot be undone.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_attribute_key": "Create Attribute Key",
|
||||
"create_key": "Create Key",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"data_type": "Data Type",
|
||||
"data_type_cannot_be_changed": "Data type cannot be changed after creation",
|
||||
"data_type_description": "Choose how this attribute should be stored and filtered",
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"delete_attribute_keys_warning_detailed": "{count, plural, one {Deleting this attribute key will permanently remove all attribute values across all contacts in this environment. Any segments or filters using this attribute will stop working. This action cannot be undone.} other {Deleting these # attribute keys will permanently remove all attribute values across all contacts in this environment. Any segments or filters using these attributes will stop working. This action cannot be undone.}}",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"edit_attribute": "Edit attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
|
||||
"edit_attributes": "Edit Attributes",
|
||||
"edit_attributes_success": "Contact attributes updated successfully",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_date_value": "Invalid date value",
|
||||
"invalid_email_value": "Invalid email address",
|
||||
"no_custom_attributes_yet": "No custom attribute keys yet. Create one to get started.",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
@@ -648,10 +674,12 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
"please_select_a_survey": "Please select a survey",
|
||||
"please_select_attribute_and_value": "Please select an attribute and enter a value",
|
||||
"search_attribute_keys": "Search attribute keys...",
|
||||
"search_contact": "Search contact",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"selected_attribute_keys": "{count, plural, one {# attribute key} other {# attribute keys}}",
|
||||
"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.",
|
||||
@@ -783,20 +811,26 @@
|
||||
"add_webhook": "Add Webhook",
|
||||
"add_webhook_description": "Send survey response data to a custom endpoint",
|
||||
"all_current_and_new_surveys": "All current and new surveys",
|
||||
"copy_secret_now": "Copy your signing secret",
|
||||
"created_by_third_party": "Created by a Third Party",
|
||||
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
|
||||
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
|
||||
"endpoint_pinged": "Yay! We are able to ping the webhook!",
|
||||
"endpoint_pinged_error": "Unable to ping the webhook!",
|
||||
"learn_to_verify": "Learn how to verify webhook signatures",
|
||||
"please_check_console": "Please check the console for more details",
|
||||
"please_enter_a_url": "Please enter a URL",
|
||||
"response_created": "Response Created",
|
||||
"response_finished": "Response Finished",
|
||||
"response_updated": "Response Updated",
|
||||
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
|
||||
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
|
||||
"signing_secret": "Signing Secret",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Test Endpoint",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook added successfully",
|
||||
"webhook_created": "Webhook Created",
|
||||
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
|
||||
"webhook_deleted_successfully": "Webhook deleted successfully",
|
||||
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
|
||||
@@ -857,6 +891,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
|
||||
"value_cannot_be_empty": "Value cannot be empty.",
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"value_must_be_positive": "Value must be a positive number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
@@ -1190,6 +1225,8 @@
|
||||
"cal_username": "Cal.com username or username/event",
|
||||
"calculate": "Calculate",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
|
||||
"capture_ip_address": "Capture IP address",
|
||||
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
|
||||
"capture_new_action": "Capture new action",
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
@@ -1646,6 +1683,7 @@
|
||||
"error_downloading_responses": "An error occurred while downloading responses",
|
||||
"first_name": "First Name",
|
||||
"how_to_identify_users": "How to identify users",
|
||||
"ip_address": "IP Address",
|
||||
"last_name": "Last Name",
|
||||
"not_completed": "Not Completed ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1728,22 @@
|
||||
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
|
||||
"url_encryption_label": "URL encryption of single-use ID"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Survey scripts will run in addition to workspace-level scripts.",
|
||||
"add_to_workspace": "Add to Workspace scripts",
|
||||
"description": "Add tracking scripts and pixels to this survey",
|
||||
"nav_title": "Custom HTML",
|
||||
"no_workspace_scripts": "No workspace-level scripts configured. You can add them in Workspace Settings → General.",
|
||||
"placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Only survey scripts will run. Workspace scripts will be ignored. Keep empty to not load any scripts.",
|
||||
"replace_workspace": "Replace Workspace scripts",
|
||||
"saved_successfully": "Custom scripts saved successfully",
|
||||
"script_mode": "Script Mode",
|
||||
"security_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"survey_scripts_description": "Add custom HTML to inject into the <head> of this survey page.",
|
||||
"survey_scripts_label": "Survey-specific scripts",
|
||||
"workspace_scripts_label": "Workspace scripts (inherited)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Edit survey",
|
||||
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
|
||||
@@ -1929,6 +1983,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
|
||||
"custom_scripts": "Custom Scripts",
|
||||
"custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.",
|
||||
"custom_scripts_description": "Scripts will be injected into the <head> of all link survey pages.",
|
||||
"custom_scripts_label": "HTML Scripts",
|
||||
"custom_scripts_placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Custom scripts updated successfully",
|
||||
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"delete_workspace": "Delete Workspace",
|
||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentación",
|
||||
"documentation": "Documentación",
|
||||
"domain": "Dominio",
|
||||
"done": "Hecho",
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Añadir webhook",
|
||||
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
||||
"copy_secret_now": "Copia tu secreto de firma",
|
||||
"created_by_third_party": "Creado por un tercero",
|
||||
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
||||
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||
"please_enter_a_url": "Por favor, introduce una URL",
|
||||
"response_created": "Respuesta creada",
|
||||
"response_finished": "Respuesta finalizada",
|
||||
"response_updated": "Respuesta actualizada",
|
||||
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
|
||||
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
|
||||
"signing_secret": "Secreto de firma",
|
||||
"source": "Origen",
|
||||
"test_endpoint": "Probar endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook añadido correctamente",
|
||||
"webhook_created": "Webhook creado",
|
||||
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
||||
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
||||
"capture_ip_address": "Capturar dirección IP",
|
||||
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
|
||||
"capture_new_action": "Capturar nueva acción",
|
||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||
"card_background_color": "Color de fondo de la tarjeta",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
||||
"first_name": "Nombre",
|
||||
"how_to_identify_users": "Cómo identificar a los usuarios",
|
||||
"ip_address": "Dirección IP",
|
||||
"last_name": "Apellido",
|
||||
"not_completed": "No completado ⏳",
|
||||
"os": "Sistema operativo",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Desactiva solo si necesitas establecer un ID de uso único personalizado.",
|
||||
"url_encryption_label": "Cifrado URL del ID de uso único"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Los scripts de la encuesta se ejecutarán además de los scripts a nivel de espacio de trabajo.",
|
||||
"add_to_workspace": "Añadir a los scripts del espacio de trabajo",
|
||||
"description": "Añade scripts de seguimiento y píxeles a esta encuesta",
|
||||
"nav_title": "HTML personalizado",
|
||||
"no_workspace_scripts": "No hay scripts configurados a nivel de espacio de trabajo. Puedes añadirlos en Configuración del espacio de trabajo → General.",
|
||||
"placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Solo se ejecutarán los scripts de la encuesta. Los scripts del espacio de trabajo serán ignorados. Déjalo vacío para no cargar ningún script.",
|
||||
"replace_workspace": "Reemplazar scripts del espacio de trabajo",
|
||||
"saved_successfully": "Scripts personalizados guardados correctamente",
|
||||
"script_mode": "Modo de script",
|
||||
"security_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||
"survey_scripts_description": "Añade HTML personalizado para inyectar en el <head> de esta página de encuesta.",
|
||||
"survey_scripts_label": "Scripts específicos de la encuesta",
|
||||
"workspace_scripts_label": "Scripts del espacio de trabajo (heredados)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editar encuesta",
|
||||
"alert_description": "Esta encuesta está actualmente configurada como una encuesta de enlace, que no admite ventanas emergentes dinámicas. Puedes cambiar esto en la pestaña de ajustes del editor de encuestas.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
|
||||
"custom_scripts": "Scripts personalizados",
|
||||
"custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.",
|
||||
"custom_scripts_description": "Los scripts se inyectarán en el <head> de todas las páginas de encuestas con enlace.",
|
||||
"custom_scripts_label": "Scripts HTML",
|
||||
"custom_scripts_placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Scripts personalizados actualizados correctamente",
|
||||
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||
"delete_workspace": "Eliminar proyecto",
|
||||
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domaine",
|
||||
"done": "Terminé",
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
||||
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
||||
"copy_secret_now": "Copiez votre secret de signature",
|
||||
"created_by_third_party": "Créé par un tiers",
|
||||
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
||||
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||
"response_created": "Réponse créée",
|
||||
"response_finished": "Réponse terminée",
|
||||
"response_updated": "Réponse mise à jour",
|
||||
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
|
||||
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
|
||||
"signing_secret": "Secret de signature",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Point de test",
|
||||
"triggers": "Déclencheurs",
|
||||
"webhook_added_successfully": "Webhook ajouté avec succès",
|
||||
"webhook_created": "Webhook créé",
|
||||
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
||||
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
||||
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
||||
"calculate": "Calculer",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
||||
"capture_ip_address": "Capturer l'adresse IP",
|
||||
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
|
||||
"capture_new_action": "Capturer une nouvelle action",
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond de la carte",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"ip_address": "Adresse IP",
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
|
||||
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Les scripts de l'enquête s'exécuteront en plus des scripts au niveau de l'espace de travail.",
|
||||
"add_to_workspace": "Ajouter aux scripts de l'espace de travail",
|
||||
"description": "Ajouter des scripts de suivi et des pixels à cette enquête",
|
||||
"nav_title": "HTML personnalisé",
|
||||
"no_workspace_scripts": "Aucun script au niveau de l'espace de travail configuré. Vous pouvez les ajouter dans Paramètres de l'espace de travail → Général.",
|
||||
"placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Seuls les scripts de l'enquête s'exécuteront. Les scripts de l'espace de travail seront ignorés. Laissez vide pour ne charger aucun script.",
|
||||
"replace_workspace": "Remplacer les scripts de l'espace de travail",
|
||||
"saved_successfully": "Scripts personnalisés enregistrés avec succès",
|
||||
"script_mode": "Mode de script",
|
||||
"security_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||
"survey_scripts_description": "Ajouter du HTML personnalisé à injecter dans le <head> de cette page d'enquête.",
|
||||
"survey_scripts_label": "Scripts spécifiques à l'enquête",
|
||||
"workspace_scripts_label": "Scripts de l'espace de travail (hérités)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Modifier enquête",
|
||||
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
|
||||
"custom_scripts": "Scripts personnalisés",
|
||||
"custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.",
|
||||
"custom_scripts_description": "Les scripts seront injectés dans le <head> de toutes les pages d'enquête par lien.",
|
||||
"custom_scripts_label": "Scripts HTML",
|
||||
"custom_scripts_placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Scripts personnalisés mis à jour avec succès",
|
||||
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||
"delete_workspace": "Supprimer le projet",
|
||||
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "ドキュメント",
|
||||
"documentation": "ドキュメント",
|
||||
"domain": "ドメイン",
|
||||
"done": "完了",
|
||||
"download": "ダウンロード",
|
||||
"draft": "下書き",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook を追加",
|
||||
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
||||
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
||||
"copy_secret_now": "署名シークレットをコピー",
|
||||
"created_by_third_party": "サードパーティによって作成",
|
||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
"response_finished": "回答完了",
|
||||
"response_updated": "回答更新",
|
||||
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
|
||||
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
|
||||
"signing_secret": "署名シークレット",
|
||||
"source": "ソース",
|
||||
"test_endpoint": "エンドポイントをテスト",
|
||||
"triggers": "トリガー",
|
||||
"webhook_added_successfully": "Webhook を追加しました",
|
||||
"webhook_created": "Webhook を作成しました",
|
||||
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
||||
"webhook_deleted_successfully": "Webhook を削除しました",
|
||||
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
||||
"capture_ip_address": "IPアドレスを記録",
|
||||
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
|
||||
"capture_new_action": "新しいアクションをキャプチャ",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||
"card_background_color": "カードの背景色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||
"first_name": "名",
|
||||
"how_to_identify_users": "ユーザーを識別する方法",
|
||||
"ip_address": "IPアドレス",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "カスタムの単一使用IDを設定する必要がある場合にのみ無効にしてください。",
|
||||
"url_encryption_label": "単一使用IDのURL暗号化"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "アンケートスクリプトは、ワークスペースレベルのスクリプトに加えて実行されます。",
|
||||
"add_to_workspace": "ワークスペーススクリプトに追加",
|
||||
"description": "このアンケートにトラッキングスクリプトとピクセルを追加",
|
||||
"nav_title": "カスタムHTML",
|
||||
"no_workspace_scripts": "ワークスペースレベルのスクリプトが設定されていません。ワークスペース設定→一般から追加できます。",
|
||||
"placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
|
||||
"replace_mode_description": "アンケートスクリプトのみが実行されます。ワークスペーススクリプトは無視されます。スクリプトを読み込まない場合は空のままにしてください。",
|
||||
"replace_workspace": "ワークスペーススクリプトを置き換え",
|
||||
"saved_successfully": "カスタムスクリプトを正常に保存しました",
|
||||
"script_mode": "スクリプトモード",
|
||||
"security_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||
"survey_scripts_description": "このアンケートページの<head>に挿入するカスタムHTMLを追加します。",
|
||||
"survey_scripts_label": "アンケート固有のスクリプト",
|
||||
"workspace_scripts_label": "ワークスペーススクリプト(継承)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "フォームを編集",
|
||||
"alert_description": "このフォームは現在、動的なポップアップをサポートしていないリンクフォームとして設定されています。フォームエディターの設定タブでこれを変更できます。",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。",
|
||||
"custom_scripts": "カスタムスクリプト",
|
||||
"custom_scripts_card_description": "このワークスペース内のすべてのリンクアンケートにトラッキングスクリプトとピクセルを追加します。",
|
||||
"custom_scripts_description": "すべてのリンクアンケートページの<head>にスクリプトが挿入されます。",
|
||||
"custom_scripts_label": "HTMLスクリプト",
|
||||
"custom_scripts_placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
|
||||
"custom_scripts_updated_successfully": "カスタムスクリプトを正常に更新しました",
|
||||
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentatie",
|
||||
"documentation": "Documentatie",
|
||||
"domain": "Domein",
|
||||
"done": "Klaar",
|
||||
"download": "Downloaden",
|
||||
"draft": "Voorlopige versie",
|
||||
"duplicate": "Duplicaat",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Webhook toevoegen",
|
||||
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
||||
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
||||
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
|
||||
"created_by_third_party": "Gemaakt door een derde partij",
|
||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
"response_finished": "Reactie voltooid",
|
||||
"response_updated": "Reactie bijgewerkt",
|
||||
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
|
||||
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
|
||||
"signing_secret": "Ondertekeningsgeheim",
|
||||
"source": "Bron",
|
||||
"test_endpoint": "Eindpunt testen",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
||||
"webhook_created": "Webhook aangemaakt",
|
||||
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
||||
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
||||
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
||||
"calculate": "Berekenen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
||||
"capture_ip_address": "IP-adres vastleggen",
|
||||
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
|
||||
"capture_new_action": "Leg nieuwe actie vast",
|
||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||
"card_background_color": "Achtergrondkleur van de kaart",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
||||
"first_name": "Voornaam",
|
||||
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
||||
"ip_address": "IP-adres",
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Schakel dit alleen uit als u een aangepaste ID voor eenmalig gebruik moet instellen.",
|
||||
"url_encryption_label": "URL-codering van ID voor eenmalig gebruik"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Enquêtescripts worden uitgevoerd naast scripts op werkruimteniveau.",
|
||||
"add_to_workspace": "Toevoegen aan werkruimtescripts",
|
||||
"description": "Voeg trackingscripts en pixels toe aan deze enquête",
|
||||
"nav_title": "Aangepaste HTML",
|
||||
"no_workspace_scripts": "Geen scripts op werkruimteniveau geconfigureerd. Je kunt ze toevoegen in Werkruimte-instellingen → Algemeen.",
|
||||
"placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Alleen enquêtescripts worden uitgevoerd. Werkruimtescripts worden genegeerd. Laat leeg om geen scripts te laden.",
|
||||
"replace_workspace": "Werkruimtescripts vervangen",
|
||||
"saved_successfully": "Aangepaste scripts succesvol opgeslagen",
|
||||
"script_mode": "Scriptmodus",
|
||||
"security_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||
"survey_scripts_description": "Voeg aangepaste HTML toe om te injecteren in de <head> van deze enquêtepagina.",
|
||||
"survey_scripts_label": "Enquêtespecifieke scripts",
|
||||
"workspace_scripts_label": "Werkruimtescripts (overgenomen)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Enquête bewerken",
|
||||
"alert_description": "Deze enquête is momenteel geconfigureerd als een linkenquête, die geen dynamische pop-ups ondersteunt. U kunt dit wijzigen op het tabblad Instellingen van de enquête-editor.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
|
||||
"custom_scripts": "Aangepaste scripts",
|
||||
"custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.",
|
||||
"custom_scripts_description": "Scripts worden geïnjecteerd in de <head> van alle linkenquêtepagina's.",
|
||||
"custom_scripts_label": "HTML-scripts",
|
||||
"custom_scripts_placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Aangepaste scripts succesvol bijgewerkt",
|
||||
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||
"delete_workspace": "Project verwijderen",
|
||||
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "baixar",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
||||
"copy_secret_now": "Copie seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Finalizada",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
|
||||
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "fonte",
|
||||
"test_endpoint": "Testar Ponto de Extremidade",
|
||||
"triggers": "gatilhos",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
||||
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
|
||||
"url_encryption_label": "Criptografia de URL de ID de uso único"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Os scripts da pesquisa serão executados além dos scripts do nível do workspace.",
|
||||
"add_to_workspace": "Adicionar aos scripts do workspace",
|
||||
"description": "Adicione scripts de rastreamento e pixels a esta pesquisa",
|
||||
"nav_title": "HTML personalizado",
|
||||
"no_workspace_scripts": "Nenhum script de nível de workspace configurado. Você pode adicioná-los em Configurações do Workspace → Geral.",
|
||||
"placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Apenas os scripts da pesquisa serão executados. Os scripts do workspace serão ignorados. Deixe vazio para não carregar nenhum script.",
|
||||
"replace_workspace": "Substituir scripts do workspace",
|
||||
"saved_successfully": "Scripts personalizados salvos com sucesso",
|
||||
"script_mode": "Modo de script",
|
||||
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||
"survey_scripts_description": "Adicione HTML personalizado para injetar no <head> desta página de pesquisa.",
|
||||
"survey_scripts_label": "Scripts específicos da pesquisa",
|
||||
"workspace_scripts_label": "Scripts do workspace (herdados)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editar pesquisa",
|
||||
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
|
||||
"custom_scripts": "Scripts personalizados",
|
||||
"custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.",
|
||||
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de pesquisa de link.",
|
||||
"custom_scripts_label": "Scripts HTML",
|
||||
"custom_scripts_placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||
"delete_workspace": "Excluir projeto",
|
||||
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "Transferir",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
||||
"copy_secret_now": "Copiar o seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Concluída",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
|
||||
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "Fonte",
|
||||
"test_endpoint": "Testar Endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
|
||||
"url_encryption_label": "Encriptação do URL de ID de uso único"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Os scripts do inquérito serão executados para além dos scripts ao nível da área de trabalho.",
|
||||
"add_to_workspace": "Adicionar aos scripts da área de trabalho",
|
||||
"description": "Adicionar scripts de rastreamento e pixels a este inquérito",
|
||||
"nav_title": "HTML personalizado",
|
||||
"no_workspace_scripts": "Nenhum script ao nível da área de trabalho configurado. Pode adicioná-los em Definições da Área de Trabalho → Geral.",
|
||||
"placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Apenas os scripts do inquérito serão executados. Os scripts da área de trabalho serão ignorados. Deixe vazio para não carregar nenhum script.",
|
||||
"replace_workspace": "Substituir scripts da área de trabalho",
|
||||
"saved_successfully": "Scripts personalizados guardados com sucesso",
|
||||
"script_mode": "Modo de script",
|
||||
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||
"survey_scripts_description": "Adicionar HTML personalizado para injetar no <head> desta página de inquérito.",
|
||||
"survey_scripts_label": "Scripts específicos do inquérito",
|
||||
"workspace_scripts_label": "Scripts da área de trabalho (herdados)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editar inquérito",
|
||||
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
|
||||
"custom_scripts": "Scripts personalizados",
|
||||
"custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.",
|
||||
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de inquéritos de link.",
|
||||
"custom_scripts_label": "Scripts HTML",
|
||||
"custom_scripts_placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||
"delete_workspace": "Eliminar projeto",
|
||||
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Documentație",
|
||||
"documentation": "Documentație",
|
||||
"domain": "Domeniu",
|
||||
"done": "Gata",
|
||||
"download": "Descărcare",
|
||||
"draft": "Schiță",
|
||||
"duplicate": "Duplicități",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Adaugă Webhook",
|
||||
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
||||
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
||||
"copy_secret_now": "Copiază secretul de semnare",
|
||||
"created_by_third_party": "Creat de o Parte Terță",
|
||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
"response_finished": "Răspuns finalizat",
|
||||
"response_updated": "Răspuns actualizat",
|
||||
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
|
||||
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
|
||||
"signing_secret": "Secret de semnare",
|
||||
"source": "Sursă",
|
||||
"test_endpoint": "Punct final de test",
|
||||
"triggers": "Declanșatori",
|
||||
"webhook_added_successfully": "Webhook adăugat cu succes",
|
||||
"webhook_created": "Webhook creat",
|
||||
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
||||
"webhook_deleted_successfully": "Webhook șters cu succes",
|
||||
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
||||
"calculate": "Calculați",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
||||
"capture_ip_address": "Capturare adresă IP",
|
||||
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
|
||||
"capture_new_action": "Capturați acțiune nouă",
|
||||
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
||||
"card_background_color": "Culoarea de fundal a cardului",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
||||
"first_name": "Prenume",
|
||||
"how_to_identify_users": "Cum să identifici utilizatorii",
|
||||
"ip_address": "Adresă IP",
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.",
|
||||
"url_encryption_label": "Criptarea URL pentru ID unic de utilizare"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Scripturile sondajului vor rula în plus față de scripturile la nivel de spațiu de lucru.",
|
||||
"add_to_workspace": "Adaugă la scripturile spațiului de lucru",
|
||||
"description": "Adaugă scripturi de tracking și pixeli acestui sondaj",
|
||||
"nav_title": "HTML personalizat",
|
||||
"no_workspace_scripts": "Nu există scripturi la nivel de spațiu de lucru configurate. Le poți adăuga în Setări spațiu de lucru → General.",
|
||||
"placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Vor rula doar scripturile sondajului. Scripturile spațiului de lucru vor fi ignorate. Lasă gol pentru a nu încărca niciun script.",
|
||||
"replace_workspace": "Înlocuiește scripturile spațiului de lucru",
|
||||
"saved_successfully": "Scripturile personalizate au fost salvate cu succes",
|
||||
"script_mode": "Modul script",
|
||||
"security_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||
"survey_scripts_description": "Adaugă HTML personalizat pentru a fi injectat în <head> pe această pagină de sondaj.",
|
||||
"survey_scripts_label": "Scripturi specifice sondajului",
|
||||
"workspace_scripts_label": "Scripturi spațiu de lucru (moștenite)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Editează chestionar",
|
||||
"alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
|
||||
"custom_scripts": "Scripturi personalizate",
|
||||
"custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.",
|
||||
"custom_scripts_description": "Scripturile vor fi injectate în <head> pe toate paginile sondajelor cu link.",
|
||||
"custom_scripts_label": "Scripturi HTML",
|
||||
"custom_scripts_placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Scripturile personalizate au fost actualizate cu succes",
|
||||
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||
"delete_workspace": "Șterge proiectul",
|
||||
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Документация",
|
||||
"documentation": "Документация",
|
||||
"domain": "Домен",
|
||||
"done": "Готово",
|
||||
"download": "Скачать",
|
||||
"draft": "Черновик",
|
||||
"duplicate": "Дублировать",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Добавить webhook",
|
||||
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
||||
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
||||
"copy_secret_now": "Скопируйте ваш секрет подписи",
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
"response_finished": "Ответ завершён",
|
||||
"response_updated": "Ответ обновлён",
|
||||
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
|
||||
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
|
||||
"signing_secret": "Секрет подписи",
|
||||
"source": "Источник",
|
||||
"test_endpoint": "Тестировать endpoint",
|
||||
"triggers": "Триггеры",
|
||||
"webhook_added_successfully": "Webhook успешно добавлен",
|
||||
"webhook_created": "Webhook создан",
|
||||
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
||||
"webhook_deleted_successfully": "Webhook успешно удалён",
|
||||
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Имя пользователя Cal.com или username/event",
|
||||
"calculate": "Вычислить",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
||||
"capture_ip_address": "Сохранять IP-адрес",
|
||||
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
|
||||
"capture_new_action": "Захватить новое действие",
|
||||
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
||||
"card_background_color": "Цвет фона карточки",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||
"first_name": "Имя",
|
||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||
"ip_address": "IP-адрес",
|
||||
"last_name": "Фамилия",
|
||||
"not_completed": "Не завершено ⏳",
|
||||
"os": "ОС",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Отключайте только если нужно задать собственный одноразовый ID.",
|
||||
"url_encryption_label": "Шифрование URL для одноразового ID"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Скрипты опроса будут выполняться дополнительно к скриптам на уровне рабочего пространства.",
|
||||
"add_to_workspace": "Добавить к скриптам рабочего пространства",
|
||||
"description": "Добавьте трекинговые скрипты и пиксели в этот опрос",
|
||||
"nav_title": "Пользовательский HTML",
|
||||
"no_workspace_scripts": "Скрипты на уровне рабочего пространства не настроены. Вы можете добавить их в настройках рабочего пространства → Общие.",
|
||||
"placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
|
||||
"replace_mode_description": "Будут выполняться только скрипты опроса. Скрипты рабочего пространства будут проигнорированы. Оставьте пустым, чтобы не загружать скрипты.",
|
||||
"replace_workspace": "Заменить скрипты рабочего пространства",
|
||||
"saved_successfully": "Пользовательские скрипты успешно сохранены",
|
||||
"script_mode": "Режим скриптов",
|
||||
"security_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||
"survey_scripts_description": "Добавьте пользовательский HTML для внедрения в <head> этой страницы опроса.",
|
||||
"survey_scripts_label": "Скрипты, специфичные для опроса",
|
||||
"workspace_scripts_label": "Скрипты рабочего пространства (унаследованные)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Редактировать опрос",
|
||||
"alert_description": "Этот опрос сейчас настроен как опрос по ссылке, что не поддерживает динамические pop-up окна. Вы можете изменить это на вкладке настроек редактора опроса.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
|
||||
"custom_scripts": "Пользовательские скрипты",
|
||||
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
|
||||
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
|
||||
"custom_scripts_label": "HTML-скрипты",
|
||||
"custom_scripts_placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Пользовательские скрипты успешно обновлены",
|
||||
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||
"delete_workspace": "Удалить рабочий проект",
|
||||
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domän",
|
||||
"done": "Klar",
|
||||
"download": "Ladda ner",
|
||||
"draft": "Utkast",
|
||||
"duplicate": "Duplicera",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "Lägg till webhook",
|
||||
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
||||
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
||||
"copy_secret_now": "Kopiera din signeringsnyckel",
|
||||
"created_by_third_party": "Skapad av tredje part",
|
||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
"response_finished": "Svar slutfört",
|
||||
"response_updated": "Svar uppdaterat",
|
||||
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
|
||||
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
|
||||
"signing_secret": "Signeringsnyckel",
|
||||
"source": "Källa",
|
||||
"test_endpoint": "Testa endpoint",
|
||||
"triggers": "Utlösare",
|
||||
"webhook_added_successfully": "Webhook tillagd",
|
||||
"webhook_created": "Webhook skapad",
|
||||
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
||||
"webhook_deleted_successfully": "Webhook borttagen",
|
||||
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
||||
"calculate": "Beräkna",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
||||
"capture_ip_address": "Registrera IP-adress",
|
||||
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
|
||||
"capture_new_action": "Fånga ny åtgärd",
|
||||
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
||||
"card_background_color": "Kortets bakgrundsfärg",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
||||
"first_name": "Förnamn",
|
||||
"how_to_identify_users": "Hur man identifierar användare",
|
||||
"ip_address": "IP-adress",
|
||||
"last_name": "Efternamn",
|
||||
"not_completed": "Inte slutförd ⏳",
|
||||
"os": "OS",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "Inaktivera endast om du behöver ange ett anpassat engångs-ID.",
|
||||
"url_encryption_label": "URL-kryptering av engångs-ID"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "Undersökningsskript kommer att köras utöver arbetsytans skript.",
|
||||
"add_to_workspace": "Lägg till i arbetsytans skript",
|
||||
"description": "Lägg till spårningsskript och pixlar i denna undersökning",
|
||||
"nav_title": "Anpassad HTML",
|
||||
"no_workspace_scripts": "Inga arbetsytans skript har konfigurerats. Du kan lägga till dem i Arbetsytans inställningar → Allmänt.",
|
||||
"placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"replace_mode_description": "Endast undersökningsskript kommer att köras. Arbetsytans skript ignoreras. Lämna tomt för att inte ladda några skript.",
|
||||
"replace_workspace": "Ersätt arbetsytans skript",
|
||||
"saved_successfully": "Anpassade skript har sparats",
|
||||
"script_mode": "Skriptläge",
|
||||
"security_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||
"survey_scripts_description": "Lägg till anpassad HTML för att injicera i <head> på denna undersökningssida.",
|
||||
"survey_scripts_label": "Undersökningsspecifika skript",
|
||||
"workspace_scripts_label": "Arbetsytans skript (ärvda)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "Redigera enkät",
|
||||
"alert_description": "Denna enkät är för närvarande konfigurerad som en länkenkät, vilket inte stöder dynamiska popup-fönster. Du kan ändra detta i inställningsfliken i enkätredigeraren.",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.",
|
||||
"custom_scripts": "Anpassade skript",
|
||||
"custom_scripts_card_description": "Lägg till spårningsskript och pixlar i alla länkundersökningar i denna arbetsyta.",
|
||||
"custom_scripts_description": "Skript kommer att injiceras i <head> på alla länkundersökningssidor.",
|
||||
"custom_scripts_label": "HTML-skript",
|
||||
"custom_scripts_placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
|
||||
"custom_scripts_updated_successfully": "Anpassade skript har uppdaterats",
|
||||
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||
"delete_workspace": "Ta bort arbetsyta",
|
||||
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "文档",
|
||||
"documentation": "文档",
|
||||
"domain": "域名",
|
||||
"done": "完成",
|
||||
"download": "下载",
|
||||
"draft": "草稿",
|
||||
"duplicate": "复制",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "添加 Webhook",
|
||||
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
||||
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
||||
"copy_secret_now": "复制您的签名密钥",
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
"response_finished": "响应 完成",
|
||||
"response_updated": "更新 响应",
|
||||
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
|
||||
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
|
||||
"signing_secret": "签名密钥",
|
||||
"source": "来源",
|
||||
"test_endpoint": "测试 端点",
|
||||
"triggers": "触发器",
|
||||
"webhook_added_successfully": "Webhook 添加成功",
|
||||
"webhook_created": "Webhook 已创建",
|
||||
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
||||
"webhook_deleted_successfully": "Webhook 删除 成功",
|
||||
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
||||
"calculate": "计算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
||||
"capture_ip_address": "记录IP地址",
|
||||
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
|
||||
"capture_new_action": "捕获 新动作",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||
"card_background_color": "卡片 的 背景 颜色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "下载答复时发生错误",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何 识别 用户",
|
||||
"ip_address": "IP地址",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "仅在 需要 设置 自定义 单次使用 ID 时 才 禁用。",
|
||||
"url_encryption_label": "单次 使用 ID 的 URL 加密"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "调查脚本将在工作区级脚本的基础上运行。",
|
||||
"add_to_workspace": "添加到工作区脚本",
|
||||
"description": "为此调查添加跟踪脚本和像素代码",
|
||||
"nav_title": "自定义 HTML",
|
||||
"no_workspace_scripts": "尚未配置工作区级脚本。你可以在工作区设置 → 常规中添加。",
|
||||
"placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||
"replace_mode_description": "仅运行调查脚本,工作区脚本将被忽略。保持为空则不加载任何脚本。",
|
||||
"replace_workspace": "替换工作区脚本",
|
||||
"saved_successfully": "自定义脚本保存成功",
|
||||
"script_mode": "脚本模式",
|
||||
"security_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||
"survey_scripts_description": "添加自定义 HTML 注入到此调查页面的<head>中。",
|
||||
"survey_scripts_label": "调查专用脚本",
|
||||
"workspace_scripts_label": "工作区脚本(继承)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "编辑 survey",
|
||||
"alert_description": "此 问卷 当前 配置 为 链接 问卷, 不 支持 动态 弹出 窗。 您 可以 在 问卷 编辑器 的 设置 选项 中 进行 修改。",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。",
|
||||
"custom_scripts": "自定义脚本",
|
||||
"custom_scripts_card_description": "为此工作区内所有链接调查添加跟踪脚本和像素代码。",
|
||||
"custom_scripts_description": "脚本将被注入到所有链接调查页面的<head>中。",
|
||||
"custom_scripts_label": "HTML 脚本",
|
||||
"custom_scripts_placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||
"custom_scripts_updated_successfully": "自定义脚本更新成功",
|
||||
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"docs": "文件",
|
||||
"documentation": "文件",
|
||||
"domain": "網域",
|
||||
"done": "完成",
|
||||
"download": "下載",
|
||||
"draft": "草稿",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +784,26 @@
|
||||
"add_webhook": "新增 Webhook",
|
||||
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
||||
"all_current_and_new_surveys": "所有目前和新的問卷",
|
||||
"copy_secret_now": "複製您的簽章密鑰",
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
"response_finished": "已完成回應",
|
||||
"response_updated": "已更新回應",
|
||||
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
|
||||
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
|
||||
"signing_secret": "簽章密鑰",
|
||||
"source": "來源",
|
||||
"test_endpoint": "測試端點",
|
||||
"triggers": "觸發器",
|
||||
"webhook_added_successfully": "Webhook 已成功新增",
|
||||
"webhook_created": "Webhook 已建立",
|
||||
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
||||
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
||||
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
||||
@@ -1190,6 +1197,8 @@
|
||||
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
||||
"capture_ip_address": "擷取 IP 位址",
|
||||
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
|
||||
"capture_new_action": "擷取新操作",
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
@@ -1646,6 +1655,7 @@
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"ip_address": "IP 位址",
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
@@ -1690,6 +1700,22 @@
|
||||
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
|
||||
"url_encryption_label": "單次使用 ID 的 URL 加密"
|
||||
},
|
||||
"custom_html": {
|
||||
"add_mode_description": "調查問卷腳本將會與工作區層級的腳本一同執行。",
|
||||
"add_to_workspace": "加入至工作區腳本",
|
||||
"description": "將追蹤腳本與像素碼加入此調查問卷",
|
||||
"nav_title": "自訂 HTML",
|
||||
"no_workspace_scripts": "尚未設定工作區層級腳本。您可以在「工作區設定」→「一般」中新增。",
|
||||
"placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||
"replace_mode_description": "僅執行調查問卷腳本,將忽略工作區腳本。若不需載入任何腳本,請保持空白。",
|
||||
"replace_workspace": "取代工作區腳本",
|
||||
"saved_successfully": "自訂腳本已成功儲存",
|
||||
"script_mode": "腳本模式",
|
||||
"security_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||
"survey_scripts_description": "新增自訂 HTML 以注入至此調查問卷頁面的 <head>。",
|
||||
"survey_scripts_label": "調查問卷專屬腳本",
|
||||
"workspace_scripts_label": "工作區腳本(繼承)"
|
||||
},
|
||||
"dynamic_popup": {
|
||||
"alert_button": "編輯 問卷",
|
||||
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
|
||||
@@ -1929,6 +1955,13 @@
|
||||
},
|
||||
"general": {
|
||||
"cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。",
|
||||
"custom_scripts": "自訂腳本",
|
||||
"custom_scripts_card_description": "將追蹤腳本與像素碼加入此工作區內所有連結調查問卷。",
|
||||
"custom_scripts_description": "腳本將注入至所有連結調查問卷頁面的 <head>。",
|
||||
"custom_scripts_label": "HTML 腳本",
|
||||
"custom_scripts_placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
|
||||
"custom_scripts_updated_successfully": "自訂腳本已成功更新",
|
||||
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||
"delete_workspace": "刪除工作區",
|
||||
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
|
||||
+5
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.ipAddress && (
|
||||
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
|
||||
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -67,7 +68,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
const bodyData = await request.json();
|
||||
let bodyData;
|
||||
try {
|
||||
bodyData = await request.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON input");
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "Malformed JSON input, please check your request body",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const bodyResult = schemas.body.safeParse(bodyData);
|
||||
|
||||
if (!bodyResult.success) {
|
||||
|
||||
@@ -132,6 +132,71 @@ describe("apiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed JSON input in request body", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: "{ invalid json }",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "Malformed JSON input, please check your request body",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty body when body schema is provided", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, {
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "Malformed JSON input, please check your request body",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
|
||||
+3
-1
@@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache(
|
||||
export const createContactAttributeKey = async (
|
||||
contactAttributeKey: TContactAttributeKeyInput
|
||||
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
const { environmentId, name, description, key } = contactAttributeKey;
|
||||
const { environmentId, name, description, key, dataType } = contactAttributeKey;
|
||||
|
||||
try {
|
||||
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
|
||||
@@ -49,6 +49,8 @@ export const createContactAttributeKey = async (
|
||||
name,
|
||||
description,
|
||||
key,
|
||||
// If dataType is provided, use it; otherwise Prisma will use the default (text)
|
||||
...(dataType && { dataType }),
|
||||
};
|
||||
|
||||
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
|
||||
+4
@@ -28,8 +28,12 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
dataType: true,
|
||||
environmentId: true,
|
||||
})
|
||||
.extend({
|
||||
dataType: ZContactAttributeKey.shape.dataType.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Enforce safe identifier format for key
|
||||
if (!isSafeIdentifier(data.key)) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
secret: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const prismaData: Prisma.WebhookCreateInput = {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
@@ -58,3 +63,105 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateContactAttributesAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
});
|
||||
|
||||
export const updateContactAttributesAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Get userId from contact attributes
|
||||
const userIdAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: "userId" },
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (!userIdAttribute) {
|
||||
throw new InvalidInputError("Contact does not have a userId attribute");
|
||||
}
|
||||
|
||||
const result = await updateAttributes(
|
||||
parsedInput.contactId,
|
||||
userIdAttribute.value,
|
||||
contact.environmentId,
|
||||
parsedInput.attributes
|
||||
);
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZDeleteContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributeKey: z.string(),
|
||||
});
|
||||
|
||||
export const deleteContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Delete the attribute
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: parsedInput.attributeKey },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
|
||||
const [contact, attributes, attributesWithMetadata] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributesWithMetadata(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -53,13 +62,18 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
|
||||
</div>
|
||||
|
||||
{Object.entries(attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
|
||||
.map(([key, attributeData]) => {
|
||||
{attributesWithMetadata
|
||||
.filter((attr) => attr.key !== "email" && attr.key !== "userId" && attr.key !== "language")
|
||||
.map((attr) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
<div key={attr.key}>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">
|
||||
{formatAttributeValue(attr.value, attr.dataType)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,23 +5,31 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { EditAttributesModal } from "./edit-attributes-modal";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface ContactControlBarProps {
|
||||
environmentId: string;
|
||||
contactId: string;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
currentAttributes: TContactAttributes;
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -31,6 +39,7 @@ export const ContactControlBar = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
allAttributeKeys,
|
||||
currentAttributes,
|
||||
attributeKeys,
|
||||
}: ContactControlBarProps) => {
|
||||
@@ -63,7 +72,7 @@ export const ContactControlBar = ({
|
||||
const iconActions = [
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("environments.contacts.edit_attribute_values"),
|
||||
tooltip: t("environments.contacts.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
@@ -104,6 +113,13 @@ export const ContactControlBar = ({
|
||||
: t("environments.contacts.delete_contact_confirmation")
|
||||
}
|
||||
/>
|
||||
<EditAttributesModal
|
||||
open={isEditAttributesModalOpen}
|
||||
setOpen={setIsEditAttributesModalOpen}
|
||||
contactId={contactId}
|
||||
attributes={currentAttributes}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
/>
|
||||
<GeneratePersonalLinkModal
|
||||
open={isGenerateLinkModalOpen}
|
||||
setOpen={setIsGenerateLinkModalOpen}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteContactAttributeAction,
|
||||
updateContactAttributesAction,
|
||||
} from "@/modules/ee/contacts/[contactId]/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditAttributesModalProps {
|
||||
contactId: string;
|
||||
attributes: AttributeWithMetadata[];
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditAttributesModal({
|
||||
contactId,
|
||||
attributes,
|
||||
allAttributeKeys,
|
||||
open,
|
||||
setOpen,
|
||||
}: EditAttributesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(new Set());
|
||||
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNewAttributeKey, setSelectedNewAttributeKey] = useState<string>("");
|
||||
const [newAttributeValue, setNewAttributeValue] = useState<string>("");
|
||||
|
||||
// Reset deleted keys when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeletedKeys(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Filter out protected attributes and locally deleted ones
|
||||
const editableAttributes = useMemo(() => {
|
||||
return attributes.filter(
|
||||
(attr) => attr.key !== "contactId" && attr.key !== "userId" && !deletedKeys.has(attr.key)
|
||||
);
|
||||
}, [attributes, deletedKeys]);
|
||||
|
||||
// Get available attribute keys that are not yet assigned to this contact (including deleted ones)
|
||||
const availableAttributeKeys = useMemo(() => {
|
||||
const currentKeys = new Set(editableAttributes.map((attr) => attr.key));
|
||||
return allAttributeKeys.filter((key) => !currentKeys.has(key.key) && key.key !== "userId");
|
||||
}, [editableAttributes, allAttributeKeys]);
|
||||
|
||||
const selectedAttributeKey = useMemo(() => {
|
||||
return allAttributeKeys.find((key) => key.key === selectedNewAttributeKey);
|
||||
}, [selectedNewAttributeKey, allAttributeKeys]);
|
||||
|
||||
// Create schema dynamically based on current editable attributes
|
||||
const attributeSchema = useMemo(() => {
|
||||
return z.object(
|
||||
editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
// Add specific validation for known attributes
|
||||
if (attr.key === "email") {
|
||||
acc[attr.key] = z.string().email({ message: "Invalid email address" });
|
||||
} else if (attr.key === "language") {
|
||||
acc[attr.key] = z.string().min(2, { message: "Language code must be at least 2 characters" });
|
||||
} else {
|
||||
// Generic string validation for other attributes
|
||||
acc[attr.key] = z.string();
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, z.ZodString | z.ZodEffects<z.ZodString>>
|
||||
)
|
||||
);
|
||||
}, [editableAttributes]);
|
||||
|
||||
type TAttributeForm = z.infer<typeof attributeSchema>;
|
||||
|
||||
const form = useForm<TAttributeForm>({
|
||||
resolver: zodResolver(attributeSchema),
|
||||
defaultValues: editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
});
|
||||
|
||||
// Update form when editable attributes change
|
||||
useEffect(() => {
|
||||
const newDefaults = editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
form.reset(newDefaults);
|
||||
}, [editableAttributes, form]);
|
||||
|
||||
const onSubmit = async (data: TAttributeForm) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: data,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attributes_updated_successfully"));
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAttribute = async (attributeKey: string) => {
|
||||
// Confirm deletion for important attributes
|
||||
if (attributeKey === "email" || attributeKey === "language") {
|
||||
const confirmed = globalThis.confirm(
|
||||
t("environments.contacts.confirm_delete_attribute", {
|
||||
attributeName: attributeKey,
|
||||
})
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setDeletingKeys((prev) => new Set(prev).add(attributeKey));
|
||||
try {
|
||||
const result = await deleteContactAttributeAction({
|
||||
contactId,
|
||||
attributeKey,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_deleted_successfully"));
|
||||
// Mark as deleted locally and remove from form
|
||||
setDeletedKeys((prev) => new Set(prev).add(attributeKey));
|
||||
form.unregister(attributeKey);
|
||||
router.refresh();
|
||||
// Keep modal open so user can see the attribute is now available to add
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setDeletingKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(attributeKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAttribute = async () => {
|
||||
if (!selectedNewAttributeKey || !newAttributeValue) {
|
||||
toast.error(t("environments.contacts.please_select_attribute_and_value"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate based on data type
|
||||
const selectedKey = selectedAttributeKey;
|
||||
if (selectedKey?.dataType === "date") {
|
||||
const date = new Date(newAttributeValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
toast.error(t("environments.contacts.invalid_date_value"));
|
||||
return;
|
||||
}
|
||||
} else if (selectedKey?.key === "email") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(newAttributeValue)) {
|
||||
toast.error(t("environments.contacts.invalid_email_value"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: {
|
||||
[selectedNewAttributeKey]: newAttributeValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_added_successfully"));
|
||||
// Add to form dynamically
|
||||
form.setValue(selectedNewAttributeKey, newAttributeValue);
|
||||
// Remove from deleted keys if it was previously deleted
|
||||
setDeletedKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(selectedNewAttributeKey);
|
||||
return newSet;
|
||||
});
|
||||
setSelectedNewAttributeKey("");
|
||||
setNewAttributeValue("");
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.contacts.edit_attributes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="max-h-[60vh] overflow-y-auto pb-4 pr-6">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{editableAttributes.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={attr.key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
{attr.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
{...field}
|
||||
value={field.value ? field.value.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
field.onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : attr.dataType === "number" ? (
|
||||
<Input type="number" {...field} />
|
||||
) : (
|
||||
<Input type="text" {...field} />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteAttribute(attr.key)}
|
||||
disabled={deletingKeys.has(attr.key)}
|
||||
loading={deletingKeys.has(attr.key)}
|
||||
title={t("common.delete")}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add New Attribute Section */}
|
||||
{availableAttributeKeys.length > 0 && (
|
||||
<>
|
||||
<hr className="my-6" />
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.add_attribute")}
|
||||
</h3>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">
|
||||
{t("environments.contacts.select_attribute")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNewAttributeKey}
|
||||
onValueChange={(value) => {
|
||||
setSelectedNewAttributeKey(value);
|
||||
setNewAttributeValue("");
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("environments.contacts.select_attribute")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableAttributeKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge text={key.dataType} type="gray" size="tiny" />
|
||||
<span>{key.name || key.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedNewAttributeKey && (
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">{t("common.value")}</label>
|
||||
{selectedAttributeKey?.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={newAttributeValue ? newAttributeValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
setNewAttributeValue(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : selectedAttributeKey?.dataType === "number" ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddAttribute}
|
||||
disabled={!selectedNewAttributeKey || !newAttributeValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || !form.formState.isDirty}
|
||||
loading={isSubmitting}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
@@ -22,14 +25,21 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
const [
|
||||
environmentTags,
|
||||
contact,
|
||||
contactAttributes,
|
||||
publishedLinkSurveys,
|
||||
attributesWithMetadata,
|
||||
allAttributeKeys,
|
||||
] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributesWithMetadata(params.contactId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -45,8 +55,9 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
currentAttributes={contactAttributes}
|
||||
attributeKeys={contactAttributeKeys}
|
||||
currentAttributes={attributesWithMetadata}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
attributeKeys={allAttributeKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,6 +162,8 @@ export const updateUser = async (
|
||||
// Single comprehensive query - gets contact + user state data
|
||||
let contactData = await getContactWithFullData(environmentId, userId);
|
||||
|
||||
console.log("contactData", contactData);
|
||||
|
||||
// Create contact if doesn't exist
|
||||
if (!contactData) {
|
||||
contactData = await createContact(environmentId, userId);
|
||||
|
||||
+1
@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
@@ -1,9 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const ZContactAttributeKeyCreateInput = z.object({
|
||||
key: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["custom"]),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
environmentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
@@ -13,6 +15,7 @@ export const ZContactAttributeKeyUpdateInput = z.object({
|
||||
description: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ export const createContactAttributeKey = async (
|
||||
name: data.name ?? data.key,
|
||||
type: data.type,
|
||||
description: data.description ?? "",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
|
||||
@@ -2,8 +2,11 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
export const upsertBulkContacts = async (
|
||||
@@ -88,6 +91,72 @@ export const upsertBulkContacts = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
// Type Detection Phase: Analyze attribute values to detect data types
|
||||
// For each attribute key, collect all non-empty values and detect type from first value
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
contacts.forEach((contact) => {
|
||||
contact.attributes.forEach((attr) => {
|
||||
if (!attributeValuesByKey.has(attr.attributeKey.key)) {
|
||||
attributeValuesByKey.set(attr.attributeKey.key, []);
|
||||
}
|
||||
if (attr.value.trim() !== "") {
|
||||
attributeValuesByKey.get(attr.attributeKey.key)!.push(attr.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected/expected type
|
||||
// If validation fails for any value, we fallback to treating that attribute as string type
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
// Test if we can convert the value to the expected type
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade this attribute to string type for compatibility
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break; // No need to check remaining values for this key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log validation warnings if any
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during bulk upload");
|
||||
}
|
||||
|
||||
// Build a map from email to contact id (if the email attribute exists)
|
||||
const contactMap = new Map<
|
||||
string,
|
||||
@@ -239,28 +308,35 @@ export const upsertBulkContacts = async (
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
if (attributeKeyMap[attr.attributeKey.key]) {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
} else {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
const keysToUpsert = new Map<
|
||||
string,
|
||||
{ key: string; name: string; dataType: TContactAttributeDataType }
|
||||
>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
// For name updates, preserve existing dataType
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
const dataType = existingKey?.dataType ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
if (keysToUpsert.size > 0) {
|
||||
@@ -272,12 +348,13 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
|
||||
${environmentId},
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
|
||||
NOW(),
|
||||
NOW()
|
||||
ON CONFLICT ("key", "environmentId")
|
||||
@@ -308,25 +385,39 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
@@ -341,7 +432,7 @@ export const upsertBulkContacts = async (
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
"id", "created_at", "updated_at", "contactId", "value", "valueNumber", "valueDate", "attributeKeyId"
|
||||
)
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.id)}]`}),
|
||||
@@ -349,9 +440,13 @@ export const upsertBulkContacts = async (
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.updatedAt)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.contactId)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.value)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueNumber)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueDate)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.attributeKeyId)}]`})
|
||||
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
|
||||
"value" = EXCLUDED."value",
|
||||
"valueNumber" = EXCLUDED."valueNumber",
|
||||
"valueDate" = EXCLUDED."valueDate",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -24,6 +25,7 @@ const ZCreateContactAttributeKeyAction = z.object({
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
type TCreateContactAttributeKeyActionInput = z.infer<typeof ZCreateContactAttributeKeyAction>;
|
||||
@@ -66,6 +68,7 @@ export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { deleteAttributeKeyAction } from "../actions";
|
||||
import { generateAttributeKeysTableColumns } from "./attribute-keys-table-columns";
|
||||
|
||||
interface AttributeKeysManagerProps {
|
||||
environmentId: string;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function AttributeKeysManager({
|
||||
environmentId,
|
||||
attributeKeys,
|
||||
isReadOnly,
|
||||
}: AttributeKeysManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingKeys, setIsDeletingKeys] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
// Filter to only show custom attribute keys
|
||||
const customAttributeKeys = useMemo(() => {
|
||||
return attributeKeys.filter((key) => key.type === "custom");
|
||||
}, [attributeKeys]);
|
||||
|
||||
// Filter by search
|
||||
const filteredAttributeKeys = useMemo(() => {
|
||||
if (!searchValue) return customAttributeKeys;
|
||||
|
||||
return customAttributeKeys.filter((key) => {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return (
|
||||
key.key.toLowerCase().includes(searchLower) ||
|
||||
key.name?.toLowerCase().includes(searchLower) ||
|
||||
key.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}, [customAttributeKeys, searchValue]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeKeysTableColumns(isReadOnly);
|
||||
}, [isReadOnly]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredAttributeKeys,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: !isReadOnly,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedAttributeKeyIds = selectedRows.map((row) => row.original.id);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedAttributeKeyIds.length === 0) return;
|
||||
|
||||
setIsDeletingKeys(true);
|
||||
try {
|
||||
const deletePromises = selectedAttributeKeyIds.map((id) =>
|
||||
deleteAttributeKeyAction({ environmentId, attributeKeyId: id })
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
toast.success(
|
||||
t("environments.contacts.attribute_keys_deleted_successfully", {
|
||||
count: selectedAttributeKeyIds.length,
|
||||
})
|
||||
);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingKeys(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_attribute_keys")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar with bulk actions */}
|
||||
{!isReadOnly && selectedRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })}
|
||||
</p>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("common.delete_selected", { count: selectedRows.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
// Skip rendering checkbox in the header for selection column
|
||||
if (header.id === "select") {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-10 w-12 rounded-tl-lg border-b border-slate-200 bg-white px-4 font-semibold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, index) => {
|
||||
const isLastRow = index === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={`hover:bg-white ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-2 ${
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}`}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.contacts.no_custom_attributes_yet")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={
|
||||
selectedRows.length === 1
|
||||
? selectedRows[0].original.name || selectedRows[0].original.key
|
||||
: t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })
|
||||
}
|
||||
onDelete={handleBulkDelete}
|
||||
isDeleting={isDeletingKeys}
|
||||
text={t("environments.contacts.delete_attribute_keys_warning_detailed", {
|
||||
count: selectedRows.length,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
const getIconForDataType = (dataType: TContactAttributeDataType) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4 text-slate-600" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4 text-slate-600" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4 text-slate-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateAttributeKeysTableColumns = (isReadOnly: boolean): ColumnDef<TContactAttributeKey>[] => {
|
||||
const nameColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name || row.original.key;
|
||||
return <span className="font-medium text-slate-900">{name}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const keyColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "key",
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
cell: ({ row }) => {
|
||||
return <IdBadge id={row.original.key} />;
|
||||
},
|
||||
};
|
||||
|
||||
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "dataType",
|
||||
accessorKey: "dataType",
|
||||
header: "Data Type",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{getIconForDataType(row.original.dataType)}
|
||||
<Badge text={row.original.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
return <span className="text-sm text-slate-600">{row.original.description || "—"}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const baseColumns = [
|
||||
nameColumn,
|
||||
keyColumn,
|
||||
dataTypeColumn,
|
||||
descriptionColumn,
|
||||
createdAtColumn,
|
||||
updatedAtColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Calendar1Icon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { createContactAttributeKeyAction } from "../actions";
|
||||
|
||||
interface CreateAttributeModalProps {
|
||||
@@ -33,6 +41,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string" as TContactAttributeDataType,
|
||||
});
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
@@ -41,6 +50,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string",
|
||||
});
|
||||
setKeyError("");
|
||||
setOpen(false);
|
||||
@@ -92,6 +102,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: formData.key,
|
||||
name: formData.name || formData.key,
|
||||
description: formData.description || undefined,
|
||||
dataType: formData.dataType,
|
||||
});
|
||||
|
||||
if (!createContactAttributeKeyResponse?.data) {
|
||||
@@ -166,6 +177,42 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<Select
|
||||
value={formData.dataType}
|
||||
onValueChange={(value: TContactAttributeDataType) =>
|
||||
setFormData((prev) => ({ ...prev, dataType: value }))
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span>{t("common.string")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
<div className="flex items-center gap-2">
|
||||
<HashIcon className="h-4 w-4" />
|
||||
<span>{t("common.number")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="date">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
<span>{t("common.date")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.data_type_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +21,18 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateContactAttributeKeyAction } from "../actions";
|
||||
|
||||
const getDataTypeIcon = (dataType: string) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditAttributeModalProps {
|
||||
attribute: TContactAttributeKey;
|
||||
open: boolean;
|
||||
@@ -86,6 +100,19 @@ export function EditAttributeModal({ attribute, open, setOpen }: Readonly<EditAt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3">
|
||||
{getDataTypeIcon(attribute.dataType)}
|
||||
<Badge text={t(`common.${attribute.dataType}`)} type="gray" size="tiny" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.data_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { debounce } from "lodash";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -48,40 +48,40 @@ export const ContactDataView = ({
|
||||
);
|
||||
}, [contactAttributeKeys]);
|
||||
|
||||
// Fetch contacts from offset 0 with current search value
|
||||
const fetchContactsFromStart = useCallback(async () => {
|
||||
setIsDataLoaded(false);
|
||||
try {
|
||||
setHasMore(true);
|
||||
const contactsResponse = await getContactsAction({
|
||||
environmentId: environment.id,
|
||||
offset: 0,
|
||||
searchValue,
|
||||
});
|
||||
if (contactsResponse?.data) {
|
||||
setContacts(contactsResponse.data);
|
||||
}
|
||||
if (contactsResponse?.data && contactsResponse.data.length < itemsPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
toast.error("Error fetching contacts. Please try again.");
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
}, [environment.id, itemsPerPage, searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstRender.current) {
|
||||
const fetchData = async () => {
|
||||
setIsDataLoaded(false);
|
||||
try {
|
||||
setHasMore(true);
|
||||
const getPersonActionData = await getContactsAction({
|
||||
environmentId: environment.id,
|
||||
offset: 0,
|
||||
searchValue,
|
||||
});
|
||||
const personData = getPersonActionData?.data;
|
||||
if (getPersonActionData?.data) {
|
||||
setContacts(getPersonActionData.data);
|
||||
}
|
||||
if (personData && personData.length < itemsPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching people data:", error);
|
||||
toast.error("Error fetching people data. Please try again.");
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedFetchData = debounce(fetchData, 300);
|
||||
const debouncedFetchData = debounce(fetchContactsFromStart, 300);
|
||||
debouncedFetchData();
|
||||
|
||||
return () => {
|
||||
debouncedFetchData.cancel();
|
||||
};
|
||||
}
|
||||
}, [environment.id, itemsPerPage, searchValue]);
|
||||
}, [fetchContactsFromStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
@@ -131,6 +131,7 @@ export const ContactDataView = ({
|
||||
key: attr.key,
|
||||
name: attr.name,
|
||||
value: contact.attributes[attr.key] ?? "",
|
||||
dataType: attr.dataType,
|
||||
})),
|
||||
}));
|
||||
}, [contacts, environmentAttributes]);
|
||||
@@ -147,6 +148,7 @@ export const ContactDataView = ({
|
||||
setSearchValue={setSearchValue}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
refreshContacts={fetchContactsFromStart}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -27,7 +28,7 @@ export const generateContactTableColumns = (
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <IdBadge id={userId} showCopyIconOnHover={true} />;
|
||||
return <IdBadge id={userId} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +72,9 @@ export const generateContactTableColumns = (
|
||||
header: attr.name ?? attr.key,
|
||||
cell: ({ row }) => {
|
||||
const attribute = row.original.attributes.find((a) => a.key === attr.key);
|
||||
return <HighlightedText value={attribute?.value} searchValue={searchValue} />;
|
||||
if (!attribute) return null;
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
|
||||
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ interface ContactsTableProps {
|
||||
setSearchValue: (value: string) => void;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
refreshContacts: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ContactsTable = ({
|
||||
@@ -56,6 +57,7 @@ export const ContactsTable = ({
|
||||
setSearchValue,
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
refreshContacts,
|
||||
}: ContactsTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -235,6 +237,7 @@ export const ContactsTable = ({
|
||||
type="contact"
|
||||
deleteAction={deleteContact}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
onRefresh={refreshContacts}
|
||||
leftContent={
|
||||
<div className="w-64">
|
||||
<SearchBar
|
||||
@@ -291,9 +294,9 @@ export const ContactsTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("common.no_results")}
|
||||
<p className="text-slate-400">{t("common.no_results")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -7,8 +7,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -33,11 +32,18 @@ import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-co
|
||||
import { updateContactAttributesAction } from "../actions";
|
||||
import { TEditContactAttributesForm, ZEditContactAttributesForm } from "../types/contact";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditContactAttributesModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
currentAttributes: TContactAttributes;
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -53,9 +59,9 @@ export const EditContactAttributesModal = ({
|
||||
// Convert current attributes to form format
|
||||
const defaultValues: TEditContactAttributesForm = useMemo(
|
||||
() => ({
|
||||
attributes: Object.entries(currentAttributes).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
attributes: currentAttributes.map((attr) => ({
|
||||
key: attr.key,
|
||||
value: attr.value ?? "",
|
||||
})),
|
||||
}),
|
||||
[currentAttributes]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { detectAttributeDataType } from "./detect-attribute-type";
|
||||
|
||||
/**
|
||||
* Storage columns for a contact attribute value
|
||||
*/
|
||||
export type TAttributeStorageColumns = {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares an attribute value for storage by routing to the appropriate column(s).
|
||||
* Used when creating a new attribute - detects type and prepares all columns.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @returns Object with dataType and column values for storage
|
||||
*/
|
||||
export const prepareNewAttributeForStorage = (
|
||||
value: string | number | Date
|
||||
): {
|
||||
dataType: TContactAttributeDataType;
|
||||
columns: TAttributeStorageColumns;
|
||||
} => {
|
||||
const dataType = detectAttributeDataType(value);
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return { dataType, columns };
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares attribute column values based on the data type.
|
||||
* Used when updating an existing attribute with a known data type.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns Object with column values for storage
|
||||
*/
|
||||
export const prepareAttributeColumnsForStorage = (
|
||||
value: string | number | Date,
|
||||
dataType: TContactAttributeDataType
|
||||
): TAttributeStorageColumns => {
|
||||
switch (dataType) {
|
||||
case "string": {
|
||||
// String type - only use value column
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else if (typeof value === "number") {
|
||||
stringValue = String(value);
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: stringValue,
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Number type - use both value (for backwards compat) and valueNumber columns
|
||||
let numericValue: number;
|
||||
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
numericValue = Number(value.trim());
|
||||
} else {
|
||||
// Date - shouldn't happen if validation passed, but handle gracefully
|
||||
numericValue = value.getTime();
|
||||
}
|
||||
|
||||
return {
|
||||
value: String(numericValue),
|
||||
valueNumber: numericValue,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// Date type - use both value (for backwards compat) and valueDate columns
|
||||
let dateValue: Date;
|
||||
|
||||
if (value instanceof Date) {
|
||||
dateValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
dateValue = new Date(value);
|
||||
} else {
|
||||
// Number - treat as timestamp
|
||||
dateValue = new Date(value);
|
||||
}
|
||||
|
||||
return {
|
||||
value: dateValue.toISOString(),
|
||||
valueNumber: null,
|
||||
valueDate: dateValue,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown type - treat as string
|
||||
return {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an attribute value from the appropriate column based on data type.
|
||||
*
|
||||
* @param attribute - The attribute with all column values
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns The value from the appropriate column
|
||||
*/
|
||||
export const readAttributeValue = (
|
||||
attribute: {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
},
|
||||
dataType: TContactAttributeDataType
|
||||
): string => {
|
||||
// For now, always return from value column for backwards compatibility
|
||||
// The typed columns are primarily for query performance
|
||||
switch (dataType) {
|
||||
case "number":
|
||||
// Return from valueNumber if available, otherwise fallback to value
|
||||
if (attribute.valueNumber === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
return String(attribute.valueNumber);
|
||||
|
||||
case "date":
|
||||
// Return from valueDate if available, otherwise fallback to value
|
||||
if (attribute.valueDate === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
return attribute.valueDate.toISOString();
|
||||
|
||||
case "string":
|
||||
default:
|
||||
return attribute.value;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { updateAttributes } from "./attributes";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -20,6 +24,7 @@ vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
|
||||
...actual,
|
||||
getContactAttributes: vi.fn(),
|
||||
hasEmailAttribute: vi.fn(),
|
||||
hasUserIdAttribute: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -75,6 +80,7 @@ describe("updateAttributes", () => {
|
||||
vi.clearAllMocks();
|
||||
// Set default mock return values - these will be overridden in individual tests
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -83,19 +89,21 @@ describe("updateAttributes", () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("skips updating email if it already exists", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
@@ -106,45 +114,147 @@ describe("updateAttributes", () => {
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
});
|
||||
|
||||
test("creates new attributes if under limit", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
|
||||
test("skips updating userId if it already exists", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "old-user-id" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", newAttr: "val" };
|
||||
const attributes = { name: "John", userId: "duplicate-user-id" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
test("skips updating both email and userId if both already exist", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "old@example.com",
|
||||
userId: "old-user-id",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "duplicate@example.com", userId: "duplicate-user-id" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.ignoreEmailAttribute).toBe(true);
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
test("creates new attributes if under limit", async () => {
|
||||
// Use name and email keys (2 existing keys), MAX is mocked to 2
|
||||
// We update existing attributes, no new ones created
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0], attributeKeys[1]]); // name, email
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not create new attributes if over the limit", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { name: "John", newAttr: "val" };
|
||||
// Include email to satisfy the "at least one of email or userId" requirement
|
||||
const attributes = { name: "John", email: "john@example.com", newAttr: "val" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
|
||||
});
|
||||
|
||||
test("returns success with no attributes to update or create", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({});
|
||||
test("returns success with only email attribute", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = {};
|
||||
const attributes = { email: "updated@example.com" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("deletes non-default attributes that are removed from payload", async () => {
|
||||
test("deletes non-default attributes when deleteRemovedAttributes is true", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "jane@example.com",
|
||||
customAttr: "oldValue",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
// Pass deleteRemovedAttributes: true to enable deletion behavior
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
||||
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId,
|
||||
attributeKeyId: {
|
||||
in: ["key-3"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not delete attributes when deleteRemovedAttributes is false (default behavior)", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
@@ -156,27 +266,19 @@ describe("updateAttributes", () => {
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
||||
const attributes = { name: "John", email: "john@example.com" };
|
||||
// Default behavior (deleteRemovedAttributes: false) should NOT delete existing attributes
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
||||
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId,
|
||||
attributeKeyId: {
|
||||
in: ["key-3"],
|
||||
},
|
||||
},
|
||||
});
|
||||
// deleteMany should NOT be called since we're merging, not replacing
|
||||
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not delete default attributes even if removed from payload", async () => {
|
||||
test("does not delete default attributes even when deleteRemovedAttributes is true", async () => {
|
||||
// Reset mocks explicitly for this test
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
||||
|
||||
// Need to include userId and firstName in attributeKeys for this test
|
||||
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
|
||||
const attributeKeysWithDefaults: TContactAttributeKey[] = [
|
||||
{
|
||||
@@ -231,13 +333,105 @@ describe("updateAttributes", () => {
|
||||
firstName: "John",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
const attributes = { customAttr: "value" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
// Pass deleteRemovedAttributes: true to test that default attributes are still preserved
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
||||
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
|
||||
// since all current attributes are default attributes
|
||||
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves existing email when empty string is submitted", async () => {
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "existing@example.com" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Attempt to clear email by submitting empty string
|
||||
const attributes = { name: "John", email: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
// Verify that the transaction was called with the preserved email
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||
// The email should be preserved (existing@example.com), not cleared
|
||||
expect(transactionCall).toHaveLength(2); // name and email
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clearing userId when empty string is submitted", async () => {
|
||||
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "existing-user-id" });
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Clear userId by submitting empty string - this should be allowed
|
||||
const attributes = { name: "John", userId: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
// Verify that the transaction was called
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
||||
// Only name and userId (empty) should be in the transaction
|
||||
expect(transactionCall).toHaveLength(2); // name and userId (with empty value)
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves existing values when both email and userId would be cleared", async () => {
|
||||
const attributeKeysWithBoth: TContactAttributeKey[] = [
|
||||
...attributeKeys,
|
||||
{
|
||||
id: "key-4",
|
||||
key: "userId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isUnique: true,
|
||||
name: "User ID",
|
||||
description: null,
|
||||
type: "default",
|
||||
environmentId,
|
||||
},
|
||||
];
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithBoth);
|
||||
vi.mocked(getContactAttributes).mockResolvedValue({
|
||||
name: "Jane",
|
||||
email: "existing@example.com",
|
||||
userId: "existing-user-id",
|
||||
});
|
||||
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
||||
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
// Attempt to clear both email and userId
|
||||
const attributes = { name: "John", email: "", userId: "" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain(
|
||||
"Either email or userId is required. The existing values were preserved."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,14 @@ import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contac
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareNewAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { validateAndParseAttributeValue } from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
@@ -168,22 +170,42 @@ export const updateAttributes = async (
|
||||
// Create lookup map for attribute keys
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
|
||||
// Separate existing and new attributes in a single pass
|
||||
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
if (attributeKey) {
|
||||
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
||||
// Separate existing and new attributes, validating types for existing attributes
|
||||
const existingAttributes: {
|
||||
key: string;
|
||||
attributeKeyId: string;
|
||||
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
|
||||
}[] = [];
|
||||
const newAttributes: { key: string; value: string }[] = [];
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(contactAttributes)) {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
|
||||
if (attributeKey) {
|
||||
// Existing attribute - validate type and prepare columns
|
||||
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
|
||||
|
||||
if (validationResult.valid) {
|
||||
existingAttributes.push({
|
||||
key,
|
||||
attributeKeyId: attributeKey.id,
|
||||
columns: validationResult.parsedValue,
|
||||
});
|
||||
} else {
|
||||
acc.newAttributes.push({ key, value });
|
||||
// Type mismatch - add to errors
|
||||
typeValidationErrors.push(validationResult.error);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ existingAttributes: [], newAttributes: [] } as {
|
||||
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string }[];
|
||||
} else {
|
||||
// New attribute - will detect type on creation
|
||||
newAttributes.push({ key, value });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add type validation errors to messages
|
||||
if (typeValidationErrors.length > 0) {
|
||||
messages.push(...typeValidationErrors);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
@@ -193,10 +215,10 @@ export const updateAttributes = async (
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
}
|
||||
|
||||
// Update all existing attributes
|
||||
// Update all existing attributes with typed column values
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
existingAttributes.map(({ attributeKeyId, value }) =>
|
||||
existingAttributes.map(({ attributeKeyId, columns }) =>
|
||||
prisma.contactAttribute.upsert({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
@@ -204,11 +226,17 @@ export const updateAttributes = async (
|
||||
attributeKeyId,
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
value,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -227,18 +255,25 @@ export const updateAttributes = async (
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
newAttributes.map(({ key, value }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
newAttributes.map(({ key, value }) => {
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(value);
|
||||
return prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type: "custom",
|
||||
dataType,
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: { contactId, value },
|
||||
create: {
|
||||
contactId,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
@@ -28,6 +28,7 @@ export const createContactAttributeKey = async (data: {
|
||||
key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
dataType?: TContactAttributeDataType;
|
||||
}): Promise<TContactAttributeKey> => {
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
@@ -37,6 +38,7 @@ export const createContactAttributeKey = async (data: {
|
||||
description: data.description ?? null,
|
||||
environmentId: data.environmentId,
|
||||
type: "custom",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
|
||||
import { TContactAttribute } from "@formbricks/types/contact-attribute";
|
||||
import { getContactAttributes, hasEmailAttribute, hasUserIdAttribute } from "./contact-attributes";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -16,11 +17,12 @@ vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
||||
const contactId = "contact-1";
|
||||
const environmentId = "env-1";
|
||||
const email = "john@example.com";
|
||||
const userId = "user-123";
|
||||
|
||||
const mockAttributes = [
|
||||
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
||||
];
|
||||
] as unknown as TContactAttribute[];
|
||||
|
||||
describe("getContactAttributes", () => {
|
||||
beforeEach(() => {
|
||||
@@ -50,7 +52,9 @@ describe("hasEmailAttribute", () => {
|
||||
});
|
||||
|
||||
test("returns true if email attribute exists", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({ id: "attr-1" });
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
|
||||
id: "attr-1",
|
||||
} as unknown as TContactAttribute);
|
||||
const result = await hasEmailAttribute(email, environmentId, contactId);
|
||||
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -67,3 +71,29 @@ describe("hasEmailAttribute", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUserIdAttribute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns true if userId attribute exists on another contact", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({
|
||||
id: "attr-1",
|
||||
} as unknown as TContactAttribute);
|
||||
const result = await hasUserIdAttribute(userId, environmentId, contactId);
|
||||
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
AND: [{ attributeKey: { key: "userId", environmentId }, value: userId }, { NOT: { contactId } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false if userId attribute does not exist on another contact", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue(null);
|
||||
const result = await hasUserIdAttribute(userId, environmentId, contactId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,13 @@ import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
const selectContactAttribute = {
|
||||
value: true,
|
||||
valueNumber: true,
|
||||
valueDate: true,
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
name: true,
|
||||
dataType: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactAttributeSelect;
|
||||
@@ -41,6 +44,34 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: selectContactAttribute,
|
||||
});
|
||||
|
||||
return prismaAttributes.map((attr) => ({
|
||||
key: attr.attributeKey.key,
|
||||
name: attr.attributeKey.name,
|
||||
value: attr.value,
|
||||
valueNumber: attr.valueNumber,
|
||||
valueDate: attr.valueDate,
|
||||
dataType: attr.attributeKey.dataType,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const hasEmailAttribute = reactCache(
|
||||
async (email: string, environmentId: string, contactId: string): Promise<boolean> => {
|
||||
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);
|
||||
|
||||
@@ -4,10 +4,13 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import {
|
||||
@@ -210,14 +213,25 @@ const contactAttributesInclude = {
|
||||
},
|
||||
} satisfies Prisma.ContactInclude;
|
||||
|
||||
// Helper to create attribute objects for Prisma create operations
|
||||
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
|
||||
Object.entries(record).map(([key, value]) => ({
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value,
|
||||
}));
|
||||
// Helper to create attribute objects for Prisma create operations with typed columns
|
||||
const createAttributeConnections = (
|
||||
record: Record<string, string>,
|
||||
environmentId: string,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>
|
||||
) =>
|
||||
Object.entries(record).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to handle userId conflicts when updating/overwriting contacts
|
||||
const resolveUserIdConflict = (
|
||||
@@ -327,7 +341,7 @@ export const createContactsFromCSV = async (
|
||||
// Fetch existing attribute keys and cache them
|
||||
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
const attributeKeyMap = new Map<string, string>();
|
||||
@@ -345,6 +359,71 @@ export const createContactsFromCSV = async (
|
||||
Object.keys(record).forEach((key) => csvKeys.add(key));
|
||||
});
|
||||
|
||||
// Type Detection Phase: Detect data types for new attribute keys
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
csvData.forEach((record) => {
|
||||
Object.entries(record).forEach(([key, value]) => {
|
||||
if (!attributeValuesByKey.has(key)) {
|
||||
attributeValuesByKey.set(key, []);
|
||||
}
|
||||
if (value && value.trim() !== "") {
|
||||
attributeValuesByKey.get(key)!.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
// Use case-insensitive lookup for existing keys
|
||||
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
|
||||
const existingKey = actualKey ? existingAttributeKeys.find((ak) => ak.key === actualKey) : null;
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected type
|
||||
// If validation fails, fallback to string type for compatibility
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade to string type
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during CSV upload");
|
||||
}
|
||||
|
||||
// Identify missing attribute keys (case-insensitive check)
|
||||
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
|
||||
|
||||
@@ -363,6 +442,7 @@ export const createContactsFromCSV = async (
|
||||
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
|
||||
key,
|
||||
name: key,
|
||||
dataType: attributeTypeMap.get(key) ?? "string",
|
||||
environmentId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -374,7 +454,7 @@ export const createContactsFromCSV = async (
|
||||
key: { in: Array.from(uniqueMissingKeys.values()) },
|
||||
environmentId,
|
||||
},
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
newAttributeKeys.forEach((attrKey) => {
|
||||
@@ -414,19 +494,30 @@ export const createContactsFromCSV = async (
|
||||
case "update": {
|
||||
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
|
||||
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
},
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value,
|
||||
},
|
||||
}));
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Update contact with upserted attributes
|
||||
return prisma.contact.update({
|
||||
@@ -453,7 +544,7 @@ export const createContactsFromCSV = async (
|
||||
where: { id: existingContact.id },
|
||||
data: {
|
||||
attributes: {
|
||||
create: createAttributeConnections(recordToProcess, environmentId),
|
||||
create: createAttributeConnections(recordToProcess, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
@@ -466,7 +557,7 @@ export const createContactsFromCSV = async (
|
||||
data: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
create: createAttributeConnections(mappedRecord, environmentId),
|
||||
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { detectAttributeDataType } from "./detect-attribute-type";
|
||||
|
||||
describe("detectAttributeDataType", () => {
|
||||
describe("Date object input", () => {
|
||||
test("detects Date objects as date type", () => {
|
||||
expect(detectAttributeDataType(new Date())).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15"))).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15T10:30:00Z"))).toBe("date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("number input", () => {
|
||||
test("detects numbers as number type", () => {
|
||||
expect(detectAttributeDataType(42)).toBe("number");
|
||||
expect(detectAttributeDataType(3.14)).toBe("number");
|
||||
expect(detectAttributeDataType(-10)).toBe("number");
|
||||
expect(detectAttributeDataType(0)).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("string input", () => {
|
||||
test("detects ISO 8601 date strings", () => {
|
||||
expect(detectAttributeDataType("2024-01-15")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00.000Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2023-12-31")).toBe("date");
|
||||
});
|
||||
|
||||
test("detects numeric string values", () => {
|
||||
expect(detectAttributeDataType("42")).toBe("number");
|
||||
expect(detectAttributeDataType("3.14")).toBe("number");
|
||||
expect(detectAttributeDataType("-10")).toBe("number");
|
||||
expect(detectAttributeDataType("0")).toBe("number");
|
||||
expect(detectAttributeDataType(" 123 ")).toBe("number");
|
||||
});
|
||||
|
||||
test("detects string values", () => {
|
||||
expect(detectAttributeDataType("hello")).toBe("string");
|
||||
expect(detectAttributeDataType("john@example.com")).toBe("string");
|
||||
expect(detectAttributeDataType("123abc")).toBe("string");
|
||||
expect(detectAttributeDataType("")).toBe("string");
|
||||
});
|
||||
|
||||
test("handles invalid date strings as string", () => {
|
||||
expect(detectAttributeDataType("2024-13-01")).toBe("string"); // Invalid month
|
||||
expect(detectAttributeDataType("not-a-date")).toBe("string");
|
||||
expect(detectAttributeDataType("2024/01/15")).toBe("string"); // Wrong format
|
||||
});
|
||||
|
||||
test("handles edge cases", () => {
|
||||
expect(detectAttributeDataType(" ")).toBe("string"); // Whitespace only
|
||||
expect(detectAttributeDataType("NaN")).toBe("string");
|
||||
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Parses a date string in DD-MM-YYYY or MM-DD-YYYY format.
|
||||
* Uses heuristics to disambiguate between formats.
|
||||
*/
|
||||
const parseDateFromParts = (part1: number, part2: number, part3: number): Date | null => {
|
||||
// Heuristic: If first part > 12, it's likely DD-MM-YYYY
|
||||
if (part1 > 12) {
|
||||
return new Date(part3, part2 - 1, part1);
|
||||
}
|
||||
|
||||
// If second part > 12, it's definitely MM-DD-YYYY
|
||||
if (part2 > 12) {
|
||||
return new Date(part3, part1 - 1, part2);
|
||||
}
|
||||
|
||||
// Ambiguous - use additional heuristics
|
||||
if (part1 > 31 || part3 < 100) {
|
||||
// Likely YYYY-MM-DD format
|
||||
return new Date(part1, part2 - 1, part3);
|
||||
}
|
||||
|
||||
// Default to American format MM-DD-YYYY
|
||||
return new Date(part3, part1 - 1, part2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse a string as a date in various formats.
|
||||
*/
|
||||
const tryParseDate = (stringValue: string): Date | null => {
|
||||
// Try ISO format first (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss)
|
||||
if (/^\d{4}[-/]\d{2}[-/]\d{2}/.test(stringValue)) {
|
||||
return new Date(stringValue);
|
||||
}
|
||||
|
||||
// For DD-MM-YYYY or MM-DD-YYYY formats, parse manually
|
||||
const parts = stringValue.split(/[-/]/);
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [part1, part2, part3] = parts.map((p) => Number.parseInt(p, 10));
|
||||
return parseDateFromParts(part1, part2, part3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the data type of an attribute value based on its format.
|
||||
* Used for first-time attribute creation to infer the dataType.
|
||||
*
|
||||
* Supported date formats:
|
||||
* - ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ
|
||||
* - European: DD-MM-YYYY or DD/MM/YYYY
|
||||
* - American: MM-DD-YYYY or MM/DD/YYYY
|
||||
*
|
||||
* @param value - The attribute value to detect the type of (string, number, or Date)
|
||||
* @returns The detected data type (string, number, or date)
|
||||
*/
|
||||
export const detectAttributeDataType = (value: string | number | Date): TContactAttributeDataType => {
|
||||
// Handle Date objects directly
|
||||
if (value instanceof Date) {
|
||||
return "date";
|
||||
}
|
||||
|
||||
// Handle numbers directly
|
||||
if (typeof value === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// For string values, try to detect the actual type
|
||||
const stringValue = value.trim();
|
||||
|
||||
// Check if it matches common date formats
|
||||
const datePattern = /^(\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4})/;
|
||||
if (datePattern.test(stringValue)) {
|
||||
const parsedDate = tryParseDate(stringValue);
|
||||
|
||||
// Verify it's a valid date
|
||||
if (parsedDate && !Number.isNaN(parsedDate.getTime())) {
|
||||
return "date";
|
||||
}
|
||||
}
|
||||
|
||||
// Check if numeric (integer or decimal)
|
||||
if (stringValue !== "" && !Number.isNaN(Number(stringValue))) {
|
||||
return "number";
|
||||
}
|
||||
|
||||
// Default to string for everything else
|
||||
return "string";
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { format } from "date-fns";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Formats an attribute value for display based on its data type.
|
||||
*
|
||||
* @param value - The raw attribute value (string representation from DB)
|
||||
* @param dataType - The data type of the attribute
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
export const formatAttributeValue = (
|
||||
value: string | number | Date | null | undefined,
|
||||
dataType: TContactAttributeDataType
|
||||
): string => {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
|
||||
switch (dataType) {
|
||||
case "date": {
|
||||
try {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
// Format as "Jan 15, 2024" for better readability
|
||||
return format(date, "MMM d, yyyy");
|
||||
} catch {
|
||||
// If date parsing fails, return the raw value
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Format numbers with proper localization
|
||||
const num = typeof value === "number" ? value : Number.parseFloat(String(value));
|
||||
if (Number.isNaN(num)) {
|
||||
return String(value);
|
||||
}
|
||||
// Use toLocaleString for proper formatting with commas
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
case "string":
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { getContactAttributeKeys } from "./contact-attribute-keys";
|
||||
import { getContactAttributes } from "./contact-attributes";
|
||||
@@ -16,7 +16,7 @@ describe("updateContactAttributes", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should update contact attributes successfully", async () => {
|
||||
test("should update contact attributes with deleteRemovedAttributes: true", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -91,13 +91,14 @@ describe("updateContactAttributes", () => {
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId);
|
||||
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes);
|
||||
// Should call updateAttributes with deleteRemovedAttributes: true for UI form updates
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes, true);
|
||||
expect(getContactAttributes).toHaveBeenCalledWith(contactId);
|
||||
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
|
||||
expect(result.updatedAttributeKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should detect new attribute keys when created", async () => {
|
||||
test("should detect new attribute keys when created", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -184,7 +185,7 @@ describe("updateContactAttributes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle missing userId with warning message", async () => {
|
||||
test("should handle missing userId gracefully", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const attributes = {
|
||||
@@ -226,13 +227,13 @@ describe("updateContactAttributes", () => {
|
||||
|
||||
const result = await updateContactAttributes(contactId, attributes);
|
||||
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
|
||||
expect(result.messages).toContain(
|
||||
"Warning: userId attribute is missing. Some operations may not work correctly."
|
||||
);
|
||||
// When userId is not in attributes, pass empty string to updateAttributes
|
||||
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes, true);
|
||||
// No warning message - the backend now gracefully handles missing userId by keeping current value
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should merge messages from updateAttributes", async () => {
|
||||
test("should merge messages from updateAttributes", async () => {
|
||||
const contactId = "contact123";
|
||||
const environmentId = "env123";
|
||||
const userId = "user123";
|
||||
@@ -279,7 +280,7 @@ describe("updateContactAttributes", () => {
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
});
|
||||
|
||||
it("should throw error if contact not found", async () => {
|
||||
test("should throw error if contact not found", async () => {
|
||||
const contactId = "contact123";
|
||||
const attributes = {
|
||||
firstName: "John",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { validateAndParseAttributeValue } from "./validate-attribute-type";
|
||||
|
||||
describe("validateAndParseAttributeValue", () => {
|
||||
describe("string type", () => {
|
||||
test("accepts any string value", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("hello");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("converts numbers to string", () => {
|
||||
const result = validateAndParseAttributeValue(42, "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("42");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("converts Date to ISO string", () => {
|
||||
const date = new Date("2024-01-15T10:30:00.000Z");
|
||||
const result = validateAndParseAttributeValue(date, "string", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("number type", () => {
|
||||
test("accepts number values", () => {
|
||||
const result = validateAndParseAttributeValue(42, "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("42");
|
||||
expect(result.parsedValue.valueNumber).toBe(42);
|
||||
expect(result.parsedValue.valueDate).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts numeric string values", () => {
|
||||
const result = validateAndParseAttributeValue("3.14", "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueNumber).toBe(3.14);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts numeric strings with whitespace", () => {
|
||||
const result = validateAndParseAttributeValue(" 123 ", "number", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueNumber).toBe(123);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-numeric strings", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("testKey");
|
||||
expect(result.error).toContain("expects a number");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Date values", () => {
|
||||
const date = new Date();
|
||||
const result = validateAndParseAttributeValue(date, "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("expects a number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("date type", () => {
|
||||
test("accepts Date objects", () => {
|
||||
const date = new Date("2024-01-15T10:30:00.000Z");
|
||||
const result = validateAndParseAttributeValue(date, "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(result.parsedValue.valueNumber).toBeNull();
|
||||
expect(result.parsedValue.valueDate).toEqual(date);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts ISO date strings", () => {
|
||||
const result = validateAndParseAttributeValue("2024-01-15T10:30:00.000Z", "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueDate).toEqual(new Date("2024-01-15T10:30:00.000Z"));
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts date-only strings", () => {
|
||||
const result = validateAndParseAttributeValue("2024-01-15", "date", "testKey");
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.parsedValue.valueDate).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid date strings", () => {
|
||||
const result = validateAndParseAttributeValue("not-a-date", "date", "purchaseDate");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("purchaseDate");
|
||||
expect(result.error).toContain("expects a date");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects number values", () => {
|
||||
const result = validateAndParseAttributeValue(42, "date", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("expects a date");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid Date objects", () => {
|
||||
const invalidDate = new Date("invalid");
|
||||
const result = validateAndParseAttributeValue(invalidDate, "date", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("Invalid Date");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("error messages", () => {
|
||||
test("includes attribute key in error message", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "purchaseAmount");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("purchaseAmount");
|
||||
}
|
||||
});
|
||||
|
||||
test("includes received value type in error message", () => {
|
||||
const result = validateAndParseAttributeValue("hello", "number", "testKey");
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain("hello");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Result of attribute value validation
|
||||
*/
|
||||
export type TAttributeValidationResult =
|
||||
| {
|
||||
valid: true;
|
||||
parsedValue: {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
valid: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string value is a valid ISO 8601 date
|
||||
*/
|
||||
const isValidISODate = (value: string): boolean => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return !Number.isNaN(date.getTime());
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string value is a valid number
|
||||
*/
|
||||
const isValidNumber = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed !== "" && !Number.isNaN(Number(trimmed));
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a value matches the expected data type and parses it for storage.
|
||||
* Used for subsequent writes to an existing attribute key.
|
||||
*
|
||||
* @param value - The value to validate (string, number, or Date)
|
||||
* @param expectedDataType - The expected data type of the attribute key
|
||||
* @param attributeKey - The attribute key name (for error messages)
|
||||
* @returns Validation result with parsed values for storage or error message
|
||||
*/
|
||||
export const validateAndParseAttributeValue = (
|
||||
value: string | number | Date,
|
||||
expectedDataType: TContactAttributeDataType,
|
||||
attributeKey: string
|
||||
): TAttributeValidationResult => {
|
||||
switch (expectedDataType) {
|
||||
case "string": {
|
||||
// String type accepts any value - convert to string
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else if (typeof value === "number") {
|
||||
stringValue = String(value);
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: stringValue,
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "number": {
|
||||
// Number type expects a numeric value
|
||||
let numericValue: number;
|
||||
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && isValidNumber(value)) {
|
||||
numericValue = Number(value.trim());
|
||||
} else {
|
||||
const receivedType = value instanceof Date ? "Date" : typeof value;
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a number. Received: ${receivedType} value '${String(value)}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: String(numericValue), // Keep string column for backwards compatibility
|
||||
valueNumber: numericValue,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "date": {
|
||||
// Date type expects a Date object or valid ISO date string
|
||||
let dateValue: Date;
|
||||
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a valid date. Received: Invalid Date`,
|
||||
};
|
||||
}
|
||||
dateValue = value;
|
||||
} else if (typeof value === "string" && isValidISODate(value)) {
|
||||
dateValue = new Date(value);
|
||||
} else {
|
||||
const receivedType = typeof value;
|
||||
return {
|
||||
valid: false,
|
||||
error: `Attribute '${attributeKey}' expects a date (ISO 8601 string or Date object). Received: ${receivedType} value '${String(value)}'`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: dateValue.toISOString(), // Keep string column for backwards compatibility
|
||||
valueNumber: null,
|
||||
valueDate: dateValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown type - treat as string
|
||||
return {
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
|
||||
import {
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
TagIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import React, { type JSX, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
@@ -33,6 +40,7 @@ export const handleAddFilter = ({
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
deviceType,
|
||||
segmentId,
|
||||
}: {
|
||||
@@ -40,12 +48,22 @@ export const handleAddFilter = ({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}): void => {
|
||||
if (type === "attribute") {
|
||||
if (!contactAttributeKey) return;
|
||||
|
||||
// Set default operator and value based on attribute data type
|
||||
let defaultOperator: "equals" | "isOlderThan" = "equals";
|
||||
let defaultValue: string | { amount: number; unit: "days" } = "";
|
||||
|
||||
if (attributeDataType === "date") {
|
||||
defaultOperator = "isOlderThan";
|
||||
defaultValue = { amount: 1, unit: "days" };
|
||||
}
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
@@ -53,9 +71,9 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
operator: defaultOperator,
|
||||
},
|
||||
value: "",
|
||||
value: defaultValue,
|
||||
};
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
@@ -235,33 +253,46 @@ export function AddFilterModal({
|
||||
|
||||
{allFiltersFiltered.map((filters, index) => (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
{filters.attributes.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => (
|
||||
<FilterButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { Calendar1Icon, FingerprintIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter } from "@formbricks/types/segment";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AttributeTabContentProps {
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ function FilterButtonWithHandler({
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
}: {
|
||||
dataTestId: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -38,8 +40,10 @@ function FilterButtonWithHandler({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) {
|
||||
return (
|
||||
<FilterButton
|
||||
@@ -51,7 +55,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -61,7 +65,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -104,19 +108,31 @@ function AttributeTabContent({
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
/>
|
||||
))}
|
||||
{contactAttributeKeys.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
attributeDataType={attributeKey.dataType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDateOperator, TSegmentFilterValue, TTimeUnit } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface DateFilterValueProps {
|
||||
operator: TDateOperator;
|
||||
value: TSegmentFilterValue;
|
||||
onChange: (value: TSegmentFilterValue) => void;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DateFilterValue({ operator, value, onChange, viewOnly }: DateFilterValueProps) {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Relative time operators: isOlderThan, isNewerThan
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
const relativeValue =
|
||||
typeof value === "object" && "amount" in value && "unit" in value
|
||||
? value
|
||||
: { amount: 1, unit: "days" as TTimeUnit };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className={cn("h-9 w-20 bg-white", error && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.amount}
|
||||
onChange={(e) => {
|
||||
const amount = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(amount) || amount < 1) {
|
||||
setError(t("environments.segments.value_must_be_positive"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onChange({ amount, unit: relativeValue.unit });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.unit}
|
||||
onValueChange={(unit: TTimeUnit) => {
|
||||
onChange({ amount: relativeValue.amount, unit });
|
||||
}}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white" hideArrow>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">{t("common.days")}</SelectItem>
|
||||
<SelectItem value="weeks">{t("common.weeks")}</SelectItem>
|
||||
<SelectItem value="months">{t("common.months")}</SelectItem>
|
||||
<SelectItem value="years">{t("common.years")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Between operator: needs two date inputs
|
||||
if (operator === "isBetween") {
|
||||
const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[0] ? betweenValue[0].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([dateValue, betweenValue[1]]);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{t("common.and")}</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[1] ? betweenValue[1].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([betweenValue[0], dateValue]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Absolute date operators: isBefore, isAfter, isSameDay
|
||||
// Use a single date picker
|
||||
const dateValue = typeof value === "string" ? value : "";
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={dateValue ? dateValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,7 +20,7 @@ interface EditSegmentModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
currentSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
|
||||
@@ -8,16 +8,14 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface SegmentActivityTabProps {
|
||||
environmentId: string;
|
||||
currentSegment: TSegment & {
|
||||
activeSurveys: string[];
|
||||
inactiveSurveys: string[];
|
||||
};
|
||||
currentSegment: TSegment;
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const activeSurveys = currentSegment?.activeSurveys;
|
||||
const inactiveSurveys = currentSegment?.inactiveSurveys;
|
||||
|
||||
const activeSurveys: string[] = [];
|
||||
const inactiveSurveys: string[] = [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
MoreVertical,
|
||||
TagIcon,
|
||||
@@ -14,26 +16,27 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TDeviceOperator,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
DATE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
NUMBER_TYPE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
STRING_TYPE_OPERATORS,
|
||||
type TArithmeticOperator,
|
||||
type TAttributeOperator,
|
||||
type TBaseFilter,
|
||||
type TDeviceOperator,
|
||||
type TSegment,
|
||||
type TSegmentAttributeFilter,
|
||||
type TSegmentConnector,
|
||||
type TSegmentDeviceFilter,
|
||||
type TSegmentFilter,
|
||||
type TSegmentFilterValue,
|
||||
type TSegmentOperator,
|
||||
type TSegmentPersonFilter,
|
||||
type TSegmentSegmentFilter,
|
||||
isDateOperator,
|
||||
} from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -64,6 +67,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { DateFilterValue } from "./date-filter-value";
|
||||
|
||||
interface TSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
@@ -204,7 +208,6 @@ type TAttributeSegmentFilterProps = TSegmentFilterProps & {
|
||||
resource: TSegmentAttributeFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
|
||||
function AttributeSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
@@ -239,17 +242,32 @@ function AttributeSegmentFilter({
|
||||
}
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
// Default to 'string' if dataType is undefined (for backwards compatibility)
|
||||
const attributeDataType = attributeKey?.dataType ?? "string";
|
||||
const isDateAttribute = attributeDataType === "date";
|
||||
|
||||
// Show operators based on attribute data type
|
||||
const getOperatorsForDataType = () => {
|
||||
switch (attributeDataType) {
|
||||
case "date":
|
||||
return DATE_OPERATORS;
|
||||
case "number":
|
||||
return NUMBER_TYPE_OPERATORS;
|
||||
case "string":
|
||||
default:
|
||||
return STRING_TYPE_OPERATORS;
|
||||
}
|
||||
};
|
||||
const availableOperators = getOperatorsForDataType();
|
||||
const operatorArr = availableOperators.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
};
|
||||
});
|
||||
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
@@ -263,6 +281,15 @@ function AttributeSegmentFilter({
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateContactAttributeKeyInFilter(updatedSegment.filters, filterId, newAttributeClassName);
|
||||
|
||||
// When changing attribute, reset operator to appropriate default for the new attribute type
|
||||
const newAttributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === newAttributeClassName);
|
||||
const newAttributeDataType = newAttributeKey?.dataType ?? "string";
|
||||
const defaultOperator = newAttributeDataType === "date" ? "isOlderThan" : "equals";
|
||||
const defaultValue = newAttributeDataType === "date" ? { amount: 1, unit: "days" as const } : "";
|
||||
|
||||
updateOperatorInFilter(updatedSegment.filters, filterId, defaultOperator as any);
|
||||
updateFilterValue(updatedSegment.filters, filterId, defaultValue as any);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
@@ -315,11 +342,17 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
{isDateAttribute ? (
|
||||
<Calendar1Icon className="h-4 w-4 text-sm" />
|
||||
) : attributeDataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4 text-sm" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
)}
|
||||
<p>{attrKeyValue}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
@@ -328,7 +361,16 @@ function AttributeSegmentFilter({
|
||||
<SelectContent>
|
||||
{contactAttributeKeys.map((attrClass) => (
|
||||
<SelectItem key={attrClass.id} value={attrClass.key}>
|
||||
{attrClass.name ?? attrClass.key}
|
||||
<div className="flex items-center gap-2">
|
||||
{attrClass.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attrClass.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{attrClass.name ?? attrClass.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -356,23 +398,39 @@ function AttributeSegmentFilter({
|
||||
</Select>
|
||||
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
/>
|
||||
<>
|
||||
{isDateAttribute && isDateOperator(resource.qualifier.operator) ? (
|
||||
<DateFilterValue
|
||||
operator={resource.qualifier.operator}
|
||||
value={resource.value}
|
||||
onChange={(newValue) => {
|
||||
updateValueInLocalSurvey(resource.id, newValue);
|
||||
}}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 w-auto bg-white",
|
||||
valueError && "border border-red-500 focus:border-red-500"
|
||||
)}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
@@ -497,7 +555,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -544,7 +602,7 @@ function PersonSegmentFilter({
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
@@ -648,7 +706,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
@@ -660,7 +718,9 @@ function SegmentSegmentFilter({
|
||||
{segments
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<SelectItem value={segment.id}>{segment.title}</SelectItem>
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -803,7 +863,7 @@ export function SegmentFilter({
|
||||
}: TSegmentFilterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: string | number) => {
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: TSegmentFilterValue) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateFilterValue(updatedSegment.filters, filterId, newValue);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -21,7 +21,7 @@ import { SegmentEditor } from "./segment-editor";
|
||||
interface TSegmentSettingsTabProps {
|
||||
environmentId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
initialSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
export const generateSegmentTableColumns = (): ColumnDef<TSegment>[] => {
|
||||
const titleColumn: ColumnDef<TSegment> = {
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||
<UsersIcon className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium text-slate-900">{row.original.title}</div>
|
||||
{row.original.description && (
|
||||
<div className="text-xs text-slate-500">{row.original.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TSegment> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TSegment> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [titleColumn, updatedAtColumn, createdAtColumn];
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { generateSegmentTableColumns } from "./segment-table-columns";
|
||||
|
||||
interface SegmentTableUpdatedProps {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function SegmentTableUpdated({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingSegment, setEditingSegment] = useState<TSegment | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns();
|
||||
}, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, rowIndex) => {
|
||||
const isLastRow = rowIndex === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => setEditingSegment(row.original)}
|
||||
className={`cursor-pointer hover:bg-slate-50 ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}>
|
||||
{typeof cell.column.columnDef.cell === "function"
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.column.columnDef.cell}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.segments.create_your_first_segment")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Edit Segment Modal */}
|
||||
{editingSegment && (
|
||||
<EditSegmentModal
|
||||
environmentId={editingSegment.environmentId}
|
||||
open={!!editingSegment}
|
||||
setOpen={(open) => !open && setEditingSegment(null)}
|
||||
currentSegment={editingSegment}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
|
||||
|
||||
type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = async ({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: TSegmentTableProps) => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-4 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.segments.create_your_first_segment")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<SegmentTableDataRowContainer
|
||||
key={segment.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { addTimeUnit, endOfDay, isSameDay, startOfDay, subtractTimeUnit } from "./date-utils";
|
||||
|
||||
describe("date-utils", () => {
|
||||
describe("subtractTimeUnit", () => {
|
||||
test("subtracts days correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 5, "days");
|
||||
expect(result.getDate()).toBe(10);
|
||||
expect(result.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
test("subtracts weeks correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 2, "weeks");
|
||||
expect(result.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("subtracts months correctly", () => {
|
||||
const date = new Date("2024-03-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 2, "months");
|
||||
expect(result.getMonth()).toBe(0); // January
|
||||
});
|
||||
|
||||
test("subtracts years correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = subtractTimeUnit(date, 1, "years");
|
||||
expect(result.getFullYear()).toBe(2023);
|
||||
});
|
||||
|
||||
test("does not modify original date", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const original = date.getTime();
|
||||
subtractTimeUnit(date, 5, "days");
|
||||
expect(date.getTime()).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTimeUnit", () => {
|
||||
test("adds days correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 5, "days");
|
||||
expect(result.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
test("adds weeks correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 2, "weeks");
|
||||
expect(result.getDate()).toBe(29);
|
||||
});
|
||||
|
||||
test("adds months correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 2, "months");
|
||||
expect(result.getMonth()).toBe(2); // March
|
||||
});
|
||||
|
||||
test("adds years correctly", () => {
|
||||
const date = new Date("2024-01-15T12:00:00Z");
|
||||
const result = addTimeUnit(date, 1, "years");
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startOfDay", () => {
|
||||
test("sets time to 00:00:00.000", () => {
|
||||
const date = new Date("2024-01-15T14:30:45.123Z");
|
||||
const result = startOfDay(date);
|
||||
expect(result.getHours()).toBe(0);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
expect(result.getMilliseconds()).toBe(0);
|
||||
expect(result.getDate()).toBe(date.getDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("endOfDay", () => {
|
||||
test("sets time to 23:59:59.999", () => {
|
||||
const date = new Date("2024-01-15T14:30:45.123Z");
|
||||
const result = endOfDay(date);
|
||||
expect(result.getHours()).toBe(23);
|
||||
expect(result.getMinutes()).toBe(59);
|
||||
expect(result.getSeconds()).toBe(59);
|
||||
expect(result.getMilliseconds()).toBe(999);
|
||||
expect(result.getDate()).toBe(date.getDate());
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameDay", () => {
|
||||
test("returns true for dates on the same day", () => {
|
||||
const date1 = new Date("2024-01-15T10:00:00Z");
|
||||
const date2 = new Date("2024-01-15T22:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for dates on different days", () => {
|
||||
const date1 = new Date("2024-01-15T23:59:59Z");
|
||||
const date2 = new Date("2024-01-16T00:00:01Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for dates in different months", () => {
|
||||
const date1 = new Date("2024-01-31T12:00:00Z");
|
||||
const date2 = new Date("2024-02-01T12:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for dates in different years", () => {
|
||||
const date1 = new Date("2023-12-31T12:00:00Z");
|
||||
const date2 = new Date("2024-01-01T12:00:00Z");
|
||||
expect(isSameDay(date1, date2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TTimeUnit } from "@formbricks/types/segment";
|
||||
|
||||
/**
|
||||
* Subtracts a time unit from a date
|
||||
* @param date - The date to subtract from
|
||||
* @param amount - The amount of time units to subtract
|
||||
* @param unit - The time unit (days, weeks, months, years)
|
||||
* @returns A new Date object with the time subtracted
|
||||
*/
|
||||
export const subtractTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() - amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() - amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() - amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() - amount);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a time unit to a date
|
||||
* @param date - The date to add to
|
||||
* @param amount - The amount of time units to add
|
||||
* @param unit - The time unit (days, weeks, months, years)
|
||||
* @returns A new Date object with the time added
|
||||
*/
|
||||
export const addTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
|
||||
const result = new Date(date);
|
||||
|
||||
switch (unit) {
|
||||
case "days":
|
||||
result.setDate(result.getDate() + amount);
|
||||
break;
|
||||
case "weeks":
|
||||
result.setDate(result.getDate() + amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
result.setMonth(result.getMonth() + amount);
|
||||
break;
|
||||
case "years":
|
||||
result.setFullYear(result.getFullYear() + amount);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the start of a day (00:00:00.000)
|
||||
* @param date - The date to get the start of
|
||||
* @returns A new Date object at the start of the day
|
||||
*/
|
||||
export const startOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the end of a day (23:59:59.999)
|
||||
* @param date - The date to get the end of
|
||||
* @returns A new Date object at the end of the day
|
||||
*/
|
||||
export const endOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if two dates are on the same day (ignoring time)
|
||||
* @param date1 - The first date
|
||||
* @param date2 - The second date
|
||||
* @returns True if the dates are on the same day
|
||||
*/
|
||||
export const isSameDay = (date1: Date, date2: Date): boolean => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { cache as reactCache } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
@@ -18,6 +21,108 @@ export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for date attribute filters
|
||||
* Uses the native valueDate column for performant DateTime comparisons
|
||||
*/
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier as { operator: TDateOperator };
|
||||
const now = new Date();
|
||||
|
||||
let dateCondition: Prisma.DateTimeNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { lt: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { gte: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isBefore":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { lt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isAfter":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { gt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isBetween":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
dateCondition = { gte: new Date(value[0]), lte: new Date(value[1]) };
|
||||
}
|
||||
break;
|
||||
case "isSameDay": {
|
||||
if (typeof value === "string") {
|
||||
const dayStart = startOfDay(new Date(value));
|
||||
const dayEnd = endOfDay(new Date(value));
|
||||
dateCondition = { gte: dayStart, lte: dayEnd };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueDate: dateCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for number attribute filters
|
||||
* Uses the native valueNumber column for performant numeric comparisons
|
||||
*/
|
||||
const buildNumberAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
|
||||
let numberCondition: Prisma.FloatNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "greaterThan":
|
||||
numberCondition = { gt: numericValue };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
numberCondition = { gte: numericValue };
|
||||
break;
|
||||
case "lessThan":
|
||||
numberCondition = { lt: numericValue };
|
||||
break;
|
||||
case "lessEqual":
|
||||
numberCondition = { lte: numericValue };
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueNumber: numberCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
@@ -60,6 +165,11 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Handle date operators
|
||||
if (DATE_OPERATORS.includes(operator as TDateOperator)) {
|
||||
return buildDateAttributeFilterWhereClause(filter);
|
||||
}
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
@@ -81,17 +191,10 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
return buildNumberAttributeFilterWhereClause(filter);
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TAllOperators,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TEvaluateSegmentUserAttributeData,
|
||||
TEvaluateSegmentUserData,
|
||||
TSegment,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentCreateInput,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
TSegmentUpdateInput,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { isSameDay, subtractTimeUnit } from "./date-utils";
|
||||
|
||||
export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
include: {
|
||||
@@ -387,6 +391,12 @@ const evaluateAttributeFilter = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a date operator
|
||||
if (isDateOperator(qualifier.operator)) {
|
||||
return evaluateDateFilter(String(attributeValue), value, qualifier.operator);
|
||||
}
|
||||
|
||||
// Use standard comparison for non-date operators
|
||||
const attResult = compareValues(attributeValue, value, qualifier.operator);
|
||||
return attResult;
|
||||
};
|
||||
@@ -440,6 +450,86 @@ const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDevic
|
||||
return compareValues(device, value, qualifier.operator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an operator is a date-specific operator
|
||||
*/
|
||||
const isDateOperator = (operator: TAllOperators): operator is TDateOperator => {
|
||||
return DATE_OPERATORS.includes(operator as TDateOperator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a date filter against an attribute value
|
||||
*/
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string,
|
||||
filterValue: TSegmentFilterValue,
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
// Parse the attribute value as a date
|
||||
const attrDate = new Date(attributeValue);
|
||||
|
||||
// Validate the attribute value is a valid date
|
||||
if (isNaN(attrDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate < threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate >= threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBefore": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate < compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isAfter": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate > compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBetween": {
|
||||
// filterValue should be a tuple [startDate, endDate]
|
||||
if (Array.isArray(filterValue) && filterValue.length === 2) {
|
||||
const startDate = new Date(filterValue[0]);
|
||||
const endDate = new Date(filterValue[1]);
|
||||
return attrDate >= startDate && attrDate <= endDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isSameDay": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return isSameDay(attrDate, compareDate);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareValues = (
|
||||
a: string | number | undefined,
|
||||
b: string | number,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "is older than";
|
||||
case "isNewerThan":
|
||||
return "is newer than";
|
||||
case "isBefore":
|
||||
return "is before";
|
||||
case "isAfter":
|
||||
return "is after";
|
||||
case "isBetween":
|
||||
return "is between";
|
||||
case "isSameDay":
|
||||
return "is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "Is older than";
|
||||
case "isNewerThan":
|
||||
return "Is newer than";
|
||||
case "isBefore":
|
||||
return "Is before";
|
||||
case "isAfter":
|
||||
return "Is after";
|
||||
case "isBetween":
|
||||
return "Is between";
|
||||
case "isSameDay":
|
||||
return "Is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { SegmentTableUpdated } from "@/modules/ee/contacts/segments/components/segment-table-updated";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -46,7 +46,7 @@ export const SegmentsPage = async ({
|
||||
}
|
||||
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
|
||||
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
|
||||
<SegmentTable
|
||||
<SegmentTableUpdated
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const ZContact = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -11,6 +12,7 @@ const ZContactTableAttributeData = z.object({
|
||||
key: z.string(),
|
||||
name: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
export const ZContactTableData = z.object({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
@@ -690,4 +691,61 @@ describe("License Core Logic", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment-based endpoint selection", () => {
|
||||
test("should use staging endpoint when ENVIRONMENT is staging", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "staging",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
projects: 5,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Re-import the module to apply the new mock
|
||||
const { fetchLicense } = await import("./license");
|
||||
await fetchLicense();
|
||||
|
||||
// Verify the staging endpoint was called
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://staging.ee.formbricks.com/api/licenses/check",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,10 @@ const CONFIG = {
|
||||
RETRY_DELAY_MS: 1000,
|
||||
},
|
||||
API: {
|
||||
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
|
||||
ENDPOINT:
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -153,6 +153,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
||||
darkOverlay: true,
|
||||
styling: true,
|
||||
logo: true,
|
||||
customHeadScripts: true,
|
||||
// All project environments
|
||||
environments: {
|
||||
select: {
|
||||
@@ -222,6 +223,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
|
||||
darkOverlay: data.project.darkOverlay,
|
||||
styling: data.project.styling,
|
||||
logo: data.project.logo,
|
||||
customHeadScripts: data.project.customHeadScripts,
|
||||
environments: data.project.environments,
|
||||
},
|
||||
organization: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
||||
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
||||
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
|
||||
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
});
|
||||
if (createWebhookActionResult?.data) {
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
setCreatedWebhook(createWebhookActionResult.data);
|
||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
const resetAndClose = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
setCreatedWebhook(null);
|
||||
};
|
||||
|
||||
// Show success dialog with secret after webhook creation
|
||||
if (createdWebhook) {
|
||||
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<Webhook />
|
||||
<WebhookIcon />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user