Compare commits

...

4 Commits

Author SHA1 Message Date
pandeymangg
cdddf034f2 chore: merge with main 2026-01-19 10:04:22 +05:30
Johannes
5555112e56 feat: add typed attributes system with date filtering and comprehensive UI improvements
## Summary

Introduces a complete typed attribute system for contacts, enabling date/number/text types with auto-detection, time-based segment filtering, full CRUD operations, and unified table styling across Contacts/Segments/Attributes.

## Core Features

### 1. Typed Attribute System
- Add `ContactAttributeDataType` enum (text, number, date) to database schema
- Auto-detection logic recognizes ISO 8601 dates and numeric values
- Backwards compatible: existing attributes default to "text" type
- SDK now accepts Date objects and auto-converts to ISO strings

### 2. Date-Based Segment Filtering
- Six new operators: isOlderThan, isNewerThan, isBefore, isAfter, isBetween, isSameDay
- Relative time filtering with days/weeks/months/years units
- Custom DateFilterValue component with type-aware inputs (date pickers, time unit selectors)
- Server-side evaluation and Prisma query generation using ISO string comparison

### 3. Attribute Management UI
- New "Attributes" tab in Contacts section for schema management
- TanStack data table with search, bulk selection, and deletion
- Create modal with type selection (text/number/date)
- Icon system: Calendar (date), Hash (number), Tag (text)

### 4. Contact Attribute Editing
- Edit Attributes modal with React Hook Form and Framer Motion animations
- Type-aware inputs: text/number/date pickers based on dataType
- Add new attributes with validation (email format, date validity)
- Delete attributes with confirmation for critical fields
- Dynamic form that updates on delete/add without modal close

### 5. Table Visual Consistency
- Unified styling across Contacts, Segments, and Attributes tables
- Removed vertical borders, added rounded corners (top-left, top-right, bottom-left, bottom-right)
- Consistent header styling: h-10, font-semibold, border-b
- Hover effects: subtle bg-slate-50
- Search bar aligned with action buttons in all tables

### 6. Segments Table Performance
- Refactored from custom grid to TanStack Table
- Removed expensive N+1 survey queries (5-10x faster page load)
- Survey details lazy-loaded only when modal opens
- Columns: Title, Updated (relative), Created (formatted)

## Technical Implementation

### Database Changes
- Add ContactAttributeDataType enum to schema.prisma
- Add dataType field to ContactAttributeKey with @default(text)
- No migration of existing data required

### Backend
- Auto-detection on attribute creation (detect-attribute-type.ts)
- Date utility functions for relative time calculations (date-utils.ts)
- Extended segment evaluation logic to handle date operators
- Prisma query builders for date comparisons using string operators
- Server actions for attribute CRUD and key management

### Frontend
- 6 new components for date filtering and attribute management
- Type-conditional operator selection in segment filters
- Smart defaults: date attributes default to "isOlderThan 1 days"
- Operator/value reset when switching between attribute types
- Badge components showing data types throughout UI

### SDK
- Accept Record<string, string | Date> in setAttributes()
- Auto-convert Date objects to ISO strings
- Fully backwards compatible

### API
- V1 and V2 endpoints updated to support optional dataType parameter
- Additive changes only, no breaking changes

## UX Improvements

### Icons & Visual Indicators
- Calendar icon for date attributes everywhere (filters, modals, tables)
- Hash icon for number attributes
- Tag icon for text attributes
- Type badges on attribute rows and form fields

### Table Consistency
- All three tables (Contacts, Segments, Attributes) now have identical:
  - Border radius (rounded-lg)
  - Header height and font weight
  - Hover effects (bg-slate-50)
  - Empty states (text-slate-400)
  - Search bar positioning
- Removed unnecessary vertical borders for cleaner look
- Proper rounded corners on first/last rows

### Form Patterns
- Consistent modal patterns using React Hook Form
- Type-specific validation (email format, date validity, positive numbers)
- Clear error messages with field-level feedback
- Framer Motion animations for smooth add/delete transitions

## Files Changed

**New Files (16):**
- Attribute type detection and tests
- Date utilities and tests
- Date filter value component
- Edit attributes modal
- Attribute keys management (page, actions, components)
- Segment table refactor (columns, updated table)

**Modified Files (20):**
- Schema, types, and Zod validators
- Segment filter components and evaluation logic
- Contact table styling and column definitions
- API endpoints (V1 and V2)
- Data table header and toolbar components
- Translation files

**Deleted Files (1):**
- Old segment table component (replaced with TanStack version)

## Translation Keys

Added 30+ new keys for date operators, attribute management, and validation messages with proper plural support.

## Testing

- Unit tests for date detection edge cases
- Unit tests for date math operations (leap years, month boundaries)
- Type validation ensures filter values match operator requirements

## Backwards Compatibility

- All changes are additive and non-breaking
- Existing attributes get text type automatically
- Old segment filters continue working
- SDK signature is backwards compatible (string | Date union)
- API changes are optional parameters only

## Next Steps

- Run database migration: pnpm db:migrate:dev --name add_contact_attribute_data_type
- Sync translations to other languages via Lingo.dev
- Add Playwright E2E tests for date filtering workflows
2025-12-14 08:54:05 +01:00
Johannes
7c92e2b5bb Merge branch 'main' of https://github.com/formbricks/formbricks into feat/attribute-type-date 2025-12-12 22:54:02 +01:00
Johannes
e90bb93dfb vibe draft 2025-12-10 20:37:42 +01:00
63 changed files with 3579 additions and 297 deletions

View File

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

View File

@@ -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",
@@ -206,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",
@@ -273,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",
@@ -388,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",
@@ -431,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",
@@ -443,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",
@@ -453,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.",
@@ -609,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",
@@ -649,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.",
@@ -864,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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
);
};

View File

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

View File

@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
description: data.description,
name: data.name,
key: data.key,
...(data.dataType && { dataType: data.dataType }),
},
});

View File

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

View File

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

View File

@@ -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"
`;
}

View File

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

View File

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

View File

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

View File

@@ -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")})

View File

@@ -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")}

View File

@@ -131,6 +131,7 @@ export const ContactDataView = ({
key: attr.key,
name: attr.name,
value: contact.attributes[attr.key] ?? "",
dataType: attr.dataType,
})),
}));
}, [contacts, environmentAttributes]);

View File

@@ -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} />;
},
};
})

View File

@@ -294,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>
)}

View File

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

View File

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

View File

@@ -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,
},
},
},
})
)
});
})
);
}
}

View File

@@ -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 }),
},
});

View File

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

View File

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

View File

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

View File

@@ -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";
};

View File

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

View File

@@ -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");
}
});
});
});

View File

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

View File

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

View File

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

View File

@@ -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);
}}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,7 +151,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
<p className="text-sm text-slate-700 capitalize">{actionClass.type}</p>
</div>
</div>
<div className="">

View File

@@ -153,9 +153,9 @@ export const ElementFormInput = ({
(currentElement &&
(id.includes(".")
? // Handle nested properties
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
: // Original behavior
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
createI18nString("", surveyLanguageCodes)
);
}, [
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
@@ -521,7 +521,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
@@ -583,8 +583,9 @@ export const ElementFormInput = ({
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -611,8 +612,9 @@ export const ElementFormInput = ({
maxLength={maxLength}
ref={inputRef}
onBlur={onBlur}
className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : ""
} ${className}`}
className={`absolute top-0 text-black caret-black ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
} ${className}`}
isInvalid={
isInvalid &&
text[usedLanguageCode]?.trim() === "" &&

View File

@@ -38,7 +38,7 @@ export const DataTableToolbar = <T,>({
const { t } = useTranslation();
return (
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
<div className="flex w-full items-center justify-end">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<SelectedRowSettings
table={table}

View File

@@ -0,0 +1,15 @@
-- CreateEnum
CREATE TYPE "public"."ContactAttributeDataType" AS ENUM ('string', 'number', 'date');
-- AlterTable
ALTER TABLE "public"."ContactAttribute" ADD COLUMN "valueDate" TIMESTAMP(3),
ADD COLUMN "valueNumber" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "public"."ContactAttributeKey" ADD COLUMN "dataType" "public"."ContactAttributeDataType" NOT NULL DEFAULT 'string';
-- CreateIndex
CREATE INDEX "ContactAttribute_attributeKeyId_valueNumber_idx" ON "public"."ContactAttribute"("attributeKeyId", "valueNumber");
-- CreateIndex
CREATE INDEX "ContactAttribute_attributeKeyId_valueDate_idx" ON "public"."ContactAttribute"("attributeKeyId", "valueDate");

View File

@@ -62,7 +62,9 @@ model Webhook {
/// @property id - Unique identifier for the attribute
/// @property attributeKey - Reference to the attribute definition
/// @property contact - The contact this attribute belongs to
/// @property value - The actual value of the attribute
/// @property value - The string value of the attribute (used for string type + backwards compatibility)
/// @property valueNumber - Native numeric storage for number type attributes
/// @property valueDate - Native date storage for date type attributes
model ContactAttribute {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
@@ -72,9 +74,13 @@ model ContactAttribute {
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactId String
value String
valueNumber Float?
valueDate DateTime?
@@unique([contactId, attributeKeyId])
@@index([attributeKeyId, value])
@@index([attributeKeyId, valueNumber])
@@index([attributeKeyId, valueDate])
}
enum ContactAttributeType {
@@ -82,6 +88,12 @@ enum ContactAttributeType {
custom
}
enum ContactAttributeDataType {
string
number
date
}
/// Defines the possible attributes that can be assigned to contacts.
/// Acts as a schema for contact attributes within an environment.
///
@@ -90,17 +102,19 @@ enum ContactAttributeType {
/// @property key - The attribute identifier used in the system
/// @property name - Display name for the attribute
/// @property type - Whether this is a default or custom attribute
/// @property dataType - The data type of the attribute (string, number, date)
/// @property environment - The environment this attribute belongs to
model ContactAttributeKey {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
isUnique Boolean @default(false)
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
isUnique Boolean @default(false)
key String
name String?
description String?
type ContactAttributeType @default(custom)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
type ContactAttributeType @default(custom)
dataType ContactAttributeDataType @default(string)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
attributes ContactAttribute[]
attributeFilters SurveyAttributeFilter[]

View File

@@ -1,4 +1,4 @@
import { type ContactAttributeKey, ContactAttributeType } from "@prisma/client";
import { ContactAttributeDataType, type ContactAttributeKey, ContactAttributeType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
@@ -36,6 +36,10 @@ export const ZContactAttributeKey = z.object({
description: "Whether this is a default or custom attribute",
example: "custom",
}),
dataType: z.nativeEnum(ContactAttributeDataType).openapi({
description: "The data type of the attribute (string, number, date)",
example: "string",
}),
environmentId: z.string().cuid2().openapi({
description: "The ID of the environment this attribute belongs to",
}),

View File

@@ -1,12 +1,37 @@
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
/**
* Sets attributes on the current user/contact.
*
* Attribute types are inferred from the value:
* - Date objects or ISO 8601 strings → date type
* - Numbers or numeric strings → number type
* - All other strings → string type
*
* On first write to a new attribute, the type is auto-detected.
* On subsequent writes, the value must match the existing type.
*
* @param attributes - Key-value pairs where values can be strings, numbers, or Date objects
*/
export const setAttributes = async (
attributes: Record<string, string>
attributes: Record<string, string | number | Date>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
// Normalize values: convert Date to ISO string, numbers to string
const normalizedAttributes: Record<string, string> = {};
for (const [key, value] of Object.entries(attributes)) {
if (value instanceof Date) {
normalizedAttributes[key] = value.toISOString();
} else if (typeof value === "number") {
normalizedAttributes[key] = String(value);
} else {
normalizedAttributes[key] = value;
}
}
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
updateQueue.updateAttributes(normalizedAttributes);
void updateQueue.processUpdates();
return okVoid();
};

View File

@@ -86,7 +86,8 @@ function CTA({
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
{buttonExternal ? <div className="flex w-full justify-start">
{buttonExternal ? (
<div className="flex w-full justify-start">
<Button
id={inputId}
type="button"
@@ -97,7 +98,8 @@ function CTA({
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>
</div> : null}
</div>
) : null}
</div>
</div>
);

View File

@@ -76,7 +76,9 @@ function ElementHeader({
{/* Headline */}
<div>
<div>
{required ? <span className="label-headline mb-[3px] text-xs opacity-60">{requiredLabel}</span> : null}
{required ? (
<span className="label-headline mb-[3px] text-xs opacity-60">{requiredLabel}</span>
) : null}
</div>
<div className="flex">
{isHeadlineHtml && safeHeadlineHtml ? (

View File

@@ -181,9 +181,7 @@ export function WelcomeCard({
data-testid="fb__surveys__welcome-card__time-display">
<TimerIcon />
<p className="pt-1 text-xs">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
</p>
</div>
) : null}
@@ -201,9 +199,7 @@ export function WelcomeCard({
<div className="text-subheading my-4 flex items-center">
<TimerIcon />
<p className="pt-1 text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3
? `${t("common.people_responded", { count: responseCount })}`

View File

@@ -4,6 +4,10 @@ export const ZContactAttributeKeyType = z.enum(["default", "custom"]);
export type TContactAttributeKeyType = z.infer<typeof ZContactAttributeKeyType>;
export const ZContactAttributeDataType = z.enum(["string", "number", "date"]);
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
export const ZContactAttributeKey = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
@@ -13,6 +17,7 @@ export const ZContactAttributeKey = z.object({
name: z.string().nullable(),
description: z.string().nullable(),
type: ZContactAttributeKeyType,
dataType: ZContactAttributeDataType.default("string"),
environmentId: z.string(),
});

View File

@@ -8,13 +8,15 @@ export const ZContactAttribute = z.object({
attributeKeyId: ZId,
contactId: ZId,
value: z.string(),
valueNumber: z.number().nullable(),
valueDate: z.date().nullable(),
});
export type TContactAttribute = z.infer<typeof ZContactAttribute>;
export const ZContactAttributeUpdateInput = z.object({
environmentId: z.string().cuid2(),
contactId: z.string(),
attributes: z.record(z.union([z.string(), z.number()])),
attributes: z.record(z.union([z.string(), z.number(), z.date()])),
});
export type TContactAttributeUpdateInput = z.infer<typeof ZContactAttributeUpdateInput>;

View File

@@ -16,8 +16,47 @@ export type TStringOperator = (typeof STRING_OPERATORS)[number];
export const ZBaseOperator = z.enum(BASE_OPERATORS);
export type TBaseOperator = z.infer<typeof ZBaseOperator>;
// An attribute filter can have these operators
export const ATTRIBUTE_OPERATORS = [
// operators for date filters
export const DATE_OPERATORS = [
"isOlderThan",
"isNewerThan",
"isBefore",
"isAfter",
"isBetween",
"isSameDay",
"isSet",
"isNotSet",
] as const;
// time units for relative date operators
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
// Operators for string type attributes only (text operations, no arithmetic)
export const STRING_TYPE_OPERATORS = [
"equals",
"notEquals",
"isSet",
"isNotSet",
"contains",
"doesNotContain",
"startsWith",
"endsWith",
] as const;
// Operators for number type attributes (arithmetic + basic)
export const NUMBER_TYPE_OPERATORS = [
"equals",
"notEquals",
"lessThan",
"lessEqual",
"greaterThan",
"greaterEqual",
"isSet",
"isNotSet",
] as const;
// Combined operators for backwards compatibility (used in validation)
export const STRING_ATTRIBUTE_OPERATORS = [
...BASE_OPERATORS,
"isSet",
"isNotSet",
@@ -27,6 +66,9 @@ export const ATTRIBUTE_OPERATORS = [
"endsWith",
] as const;
// An attribute filter can have these operators (including date operators)
export const ATTRIBUTE_OPERATORS = [...STRING_ATTRIBUTE_OPERATORS, ...DATE_OPERATORS] as const;
// the person filter currently has the same operators as the attribute filter
// but we might want to add more operators in the future, so we keep it separated
export const PERSON_OPERATORS = ATTRIBUTE_OPERATORS;
@@ -52,9 +94,32 @@ export type TSegmentOperator = z.infer<typeof ZSegmentOperator>;
export const ZDeviceOperator = z.enum(DEVICE_OPERATORS);
export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
export const ZDateOperator = z.enum(DATE_OPERATORS);
export type TDateOperator = z.infer<typeof ZDateOperator>;
// Type guard to check if an operator is a date operator
export const isDateOperator = (operator: TAttributeOperator): operator is TDateOperator => {
return (DATE_OPERATORS as readonly string[]).includes(operator);
};
export const ZTimeUnit = z.enum(TIME_UNITS);
export type TTimeUnit = z.infer<typeof ZTimeUnit>;
export type TAllOperators = (typeof ALL_OPERATORS)[number];
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
// Relative date value for operators like "isOlderThan" and "isNewerThan"
export const ZRelativeDateValue = z.object({
amount: z.number(),
unit: ZTimeUnit,
});
export type TRelativeDateValue = z.infer<typeof ZRelativeDateValue>;
export const ZSegmentFilterValue = z.union([
z.string(),
z.number(),
ZRelativeDateValue,
z.tuple([z.string(), z.string()]), // for "isBetween" operator
]);
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
@@ -137,10 +202,34 @@ export const ZSegmentFilter = z
return false;
}
// if the operator is a relative date operator (isOlderThan, isNewerThan), value must be an object with amount and unit
if (
(filter.qualifier.operator === "isOlderThan" || filter.qualifier.operator === "isNewerThan") &&
(typeof filter.value !== "object" || !("amount" in filter.value) || !("unit" in filter.value))
) {
return false;
}
// if the operator is an absolute date operator (isBefore, isAfter, isSameDay), value must be a string
if (
(filter.qualifier.operator === "isBefore" ||
filter.qualifier.operator === "isAfter" ||
filter.qualifier.operator === "isSameDay") &&
typeof filter.value !== "string"
) {
return false;
}
// if the operator is isBetween, value must be a tuple of two strings
if (filter.qualifier.operator === "isBetween" && !Array.isArray(filter.value)) {
return false;
}
return true;
},
{
message: "Value must be a string for string operators and a number for arithmetic operators",
message:
"Value must be a string for string operators, a number for arithmetic operators, and an object for relative date operators",
}
)
.refine(
@@ -153,6 +242,31 @@ export const ZSegmentFilter = z
return true;
}
// for relative date operators, validate the object structure
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (typeof value === "object" && "amount" in value && "unit" in value) {
return value.amount > 0 && TIME_UNITS.includes(value.unit);
}
return false;
}
// for isBetween, validate we have a tuple with two non-empty strings
if (operator === "isBetween") {
if (!Array.isArray(value)) return false;
return (
typeof value[0] === "string" &&
typeof value[1] === "string" &&
value[0].length > 0 &&
value[1].length > 0
);
}
// for absolute date operators, validate we have a non-empty string
if (operator === "isBefore" || operator === "isAfter" || operator === "isSameDay") {
return typeof value === "string" && value.length > 0;
}
// for string values, check they're not empty
if (typeof value === "string") {
return value.length > 0;
}