mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 18:58:46 -06:00
Compare commits
4 Commits
typeerror-
...
feat/attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdddf034f2 | ||
|
|
5555112e56 | ||
|
|
7c92e2b5bb | ||
|
|
e90bb93dfb |
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal file
404
.cursor/plans/date_attribute_type_feature_6f67ae57.plan.md
Normal 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).
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
|
||||
const ZGeneratePersonalSurveyLinkAction = z.object({
|
||||
contactId: ZId,
|
||||
@@ -58,3 +63,105 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
});
|
||||
|
||||
const ZUpdateContactAttributesAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
});
|
||||
|
||||
export const updateContactAttributesAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Get userId from contact attributes
|
||||
const userIdAttribute = await prisma.contactAttribute.findFirst({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: "userId" },
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
if (!userIdAttribute) {
|
||||
throw new InvalidInputError("Contact does not have a userId attribute");
|
||||
}
|
||||
|
||||
const result = await updateAttributes(
|
||||
parsedInput.contactId,
|
||||
userIdAttribute.value,
|
||||
contact.environmentId,
|
||||
parsedInput.attributes
|
||||
);
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ZDeleteContactAttributeAction = z.object({
|
||||
contactId: ZId,
|
||||
attributeKey: z.string(),
|
||||
});
|
||||
|
||||
export const deleteContactAttributeAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
|
||||
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contact = await getContact(parsedInput.contactId);
|
||||
if (!contact) {
|
||||
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
|
||||
}
|
||||
|
||||
// Delete the attribute
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
where: {
|
||||
contactId: parsedInput.contactId,
|
||||
attributeKey: { key: parsedInput.attributeKey },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
|
||||
const [contact, attributes, attributesWithMetadata] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributesWithMetadata(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -53,13 +62,18 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
|
||||
</div>
|
||||
|
||||
{Object.entries(attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
|
||||
.map(([key, attributeData]) => {
|
||||
{attributesWithMetadata
|
||||
.filter((attr) => attr.key !== "email" && attr.key !== "userId" && attr.key !== "language")
|
||||
.map((attr) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
<div key={attr.key}>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">
|
||||
{formatAttributeValue(attr.value, attr.dataType)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,23 +5,31 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
|
||||
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { EditAttributesModal } from "./edit-attributes-modal";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface ContactControlBarProps {
|
||||
environmentId: string;
|
||||
contactId: string;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
currentAttributes: TContactAttributes;
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
currentAttributes: AttributeWithMetadata[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -31,6 +39,7 @@ export const ContactControlBar = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
allAttributeKeys,
|
||||
currentAttributes,
|
||||
attributeKeys,
|
||||
}: ContactControlBarProps) => {
|
||||
@@ -63,7 +72,7 @@ export const ContactControlBar = ({
|
||||
const iconActions = [
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("environments.contacts.edit_attribute_values"),
|
||||
tooltip: t("environments.contacts.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
@@ -104,6 +113,13 @@ export const ContactControlBar = ({
|
||||
: t("environments.contacts.delete_contact_confirmation")
|
||||
}
|
||||
/>
|
||||
<EditAttributesModal
|
||||
open={isEditAttributesModalOpen}
|
||||
setOpen={setIsEditAttributesModalOpen}
|
||||
contactId={contactId}
|
||||
attributes={currentAttributes}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
/>
|
||||
<GeneratePersonalLinkModal
|
||||
open={isGenerateLinkModalOpen}
|
||||
setOpen={setIsGenerateLinkModalOpen}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteContactAttributeAction,
|
||||
updateContactAttributesAction,
|
||||
} from "@/modules/ee/contacts/[contactId]/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AttributeWithMetadata {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditAttributesModalProps {
|
||||
contactId: string;
|
||||
attributes: AttributeWithMetadata[];
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditAttributesModal({
|
||||
contactId,
|
||||
attributes,
|
||||
allAttributeKeys,
|
||||
open,
|
||||
setOpen,
|
||||
}: EditAttributesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(new Set());
|
||||
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNewAttributeKey, setSelectedNewAttributeKey] = useState<string>("");
|
||||
const [newAttributeValue, setNewAttributeValue] = useState<string>("");
|
||||
|
||||
// Reset deleted keys when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeletedKeys(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Filter out protected attributes and locally deleted ones
|
||||
const editableAttributes = useMemo(() => {
|
||||
return attributes.filter(
|
||||
(attr) => attr.key !== "contactId" && attr.key !== "userId" && !deletedKeys.has(attr.key)
|
||||
);
|
||||
}, [attributes, deletedKeys]);
|
||||
|
||||
// Get available attribute keys that are not yet assigned to this contact (including deleted ones)
|
||||
const availableAttributeKeys = useMemo(() => {
|
||||
const currentKeys = new Set(editableAttributes.map((attr) => attr.key));
|
||||
return allAttributeKeys.filter((key) => !currentKeys.has(key.key) && key.key !== "userId");
|
||||
}, [editableAttributes, allAttributeKeys]);
|
||||
|
||||
const selectedAttributeKey = useMemo(() => {
|
||||
return allAttributeKeys.find((key) => key.key === selectedNewAttributeKey);
|
||||
}, [selectedNewAttributeKey, allAttributeKeys]);
|
||||
|
||||
// Create schema dynamically based on current editable attributes
|
||||
const attributeSchema = useMemo(() => {
|
||||
return z.object(
|
||||
editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
// Add specific validation for known attributes
|
||||
if (attr.key === "email") {
|
||||
acc[attr.key] = z.string().email({ message: "Invalid email address" });
|
||||
} else if (attr.key === "language") {
|
||||
acc[attr.key] = z.string().min(2, { message: "Language code must be at least 2 characters" });
|
||||
} else {
|
||||
// Generic string validation for other attributes
|
||||
acc[attr.key] = z.string();
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, z.ZodString | z.ZodEffects<z.ZodString>>
|
||||
)
|
||||
);
|
||||
}, [editableAttributes]);
|
||||
|
||||
type TAttributeForm = z.infer<typeof attributeSchema>;
|
||||
|
||||
const form = useForm<TAttributeForm>({
|
||||
resolver: zodResolver(attributeSchema),
|
||||
defaultValues: editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
});
|
||||
|
||||
// Update form when editable attributes change
|
||||
useEffect(() => {
|
||||
const newDefaults = editableAttributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
form.reset(newDefaults);
|
||||
}, [editableAttributes, form]);
|
||||
|
||||
const onSubmit = async (data: TAttributeForm) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: data,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attributes_updated_successfully"));
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAttribute = async (attributeKey: string) => {
|
||||
// Confirm deletion for important attributes
|
||||
if (attributeKey === "email" || attributeKey === "language") {
|
||||
const confirmed = globalThis.confirm(
|
||||
t("environments.contacts.confirm_delete_attribute", {
|
||||
attributeName: attributeKey,
|
||||
})
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setDeletingKeys((prev) => new Set(prev).add(attributeKey));
|
||||
try {
|
||||
const result = await deleteContactAttributeAction({
|
||||
contactId,
|
||||
attributeKey,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_deleted_successfully"));
|
||||
// Mark as deleted locally and remove from form
|
||||
setDeletedKeys((prev) => new Set(prev).add(attributeKey));
|
||||
form.unregister(attributeKey);
|
||||
router.refresh();
|
||||
// Keep modal open so user can see the attribute is now available to add
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setDeletingKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(attributeKey);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAttribute = async () => {
|
||||
if (!selectedNewAttributeKey || !newAttributeValue) {
|
||||
toast.error(t("environments.contacts.please_select_attribute_and_value"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate based on data type
|
||||
const selectedKey = selectedAttributeKey;
|
||||
if (selectedKey?.dataType === "date") {
|
||||
const date = new Date(newAttributeValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
toast.error(t("environments.contacts.invalid_date_value"));
|
||||
return;
|
||||
}
|
||||
} else if (selectedKey?.key === "email") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(newAttributeValue)) {
|
||||
toast.error(t("environments.contacts.invalid_email_value"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
attributes: {
|
||||
[selectedNewAttributeKey]: newAttributeValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.contacts.attribute_added_successfully"));
|
||||
// Add to form dynamically
|
||||
form.setValue(selectedNewAttributeKey, newAttributeValue);
|
||||
// Remove from deleted keys if it was previously deleted
|
||||
setDeletedKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(selectedNewAttributeKey);
|
||||
return newSet;
|
||||
});
|
||||
setSelectedNewAttributeKey("");
|
||||
setNewAttributeValue("");
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.contacts.edit_attributes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="max-h-[60vh] overflow-y-auto pb-4 pr-6">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{editableAttributes.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={attr.key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{attr.name || attr.key}</span>
|
||||
<Badge text={attr.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
{attr.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
{...field}
|
||||
value={field.value ? field.value.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
field.onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : attr.dataType === "number" ? (
|
||||
<Input type="number" {...field} />
|
||||
) : (
|
||||
<Input type="text" {...field} />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteAttribute(attr.key)}
|
||||
disabled={deletingKeys.has(attr.key)}
|
||||
loading={deletingKeys.has(attr.key)}
|
||||
title={t("common.delete")}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add New Attribute Section */}
|
||||
{availableAttributeKeys.length > 0 && (
|
||||
<>
|
||||
<hr className="my-6" />
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.add_attribute")}
|
||||
</h3>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">
|
||||
{t("environments.contacts.select_attribute")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedNewAttributeKey}
|
||||
onValueChange={(value) => {
|
||||
setSelectedNewAttributeKey(value);
|
||||
setNewAttributeValue("");
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("environments.contacts.select_attribute")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableAttributeKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge text={key.dataType} type="gray" size="tiny" />
|
||||
<span>{key.name || key.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedNewAttributeKey && (
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-slate-600">{t("common.value")}</label>
|
||||
{selectedAttributeKey?.dataType === "date" ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={newAttributeValue ? newAttributeValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value
|
||||
? new Date(e.target.value).toISOString()
|
||||
: "";
|
||||
setNewAttributeValue(dateValue);
|
||||
}}
|
||||
/>
|
||||
) : selectedAttributeKey?.dataType === "number" ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder={t("common.enter_value")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddAttribute}
|
||||
disabled={!selectedNewAttributeKey || !newAttributeValue}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || !form.formState.isDirty}
|
||||
loading={isSubmitting}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
getContactAttributes,
|
||||
getContactAttributesWithMetadata,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
@@ -22,14 +25,21 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
const [
|
||||
environmentTags,
|
||||
contact,
|
||||
contactAttributes,
|
||||
publishedLinkSurveys,
|
||||
attributesWithMetadata,
|
||||
allAttributeKeys,
|
||||
] = await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributesWithMetadata(params.contactId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -45,8 +55,9 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
currentAttributes={contactAttributes}
|
||||
attributeKeys={contactAttributeKeys}
|
||||
currentAttributes={attributesWithMetadata}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
attributeKeys={allAttributeKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,6 +162,8 @@ export const updateUser = async (
|
||||
// Single comprehensive query - gets contact + user state data
|
||||
let contactData = await getContactWithFullData(environmentId, userId);
|
||||
|
||||
console.log("contactData", contactData);
|
||||
|
||||
// Create contact if doesn't exist
|
||||
if (!contactData) {
|
||||
contactData = await createContact(environmentId, userId);
|
||||
|
||||
@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const createContactAttributeKey = async (
|
||||
name: data.name ?? data.key,
|
||||
type: data.type,
|
||||
description: data.description ?? "",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
|
||||
@@ -2,8 +2,11 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
export const upsertBulkContacts = async (
|
||||
@@ -88,6 +91,72 @@ export const upsertBulkContacts = async (
|
||||
}),
|
||||
]);
|
||||
|
||||
// Type Detection Phase: Analyze attribute values to detect data types
|
||||
// For each attribute key, collect all non-empty values and detect type from first value
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
contacts.forEach((contact) => {
|
||||
contact.attributes.forEach((attr) => {
|
||||
if (!attributeValuesByKey.has(attr.attributeKey.key)) {
|
||||
attributeValuesByKey.set(attr.attributeKey.key, []);
|
||||
}
|
||||
if (attr.value.trim() !== "") {
|
||||
attributeValuesByKey.get(attr.attributeKey.key)!.push(attr.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected/expected type
|
||||
// If validation fails for any value, we fallback to treating that attribute as string type
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
// Test if we can convert the value to the expected type
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade this attribute to string type for compatibility
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break; // No need to check remaining values for this key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log validation warnings if any
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during bulk upload");
|
||||
}
|
||||
|
||||
// Build a map from email to contact id (if the email attribute exists)
|
||||
const contactMap = new Map<
|
||||
string,
|
||||
@@ -239,28 +308,35 @@ export const upsertBulkContacts = async (
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
if (attributeKeyMap[attr.attributeKey.key]) {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
} else {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
const keysToUpsert = new Map<
|
||||
string,
|
||||
{ key: string; name: string; dataType: TContactAttributeDataType }
|
||||
>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
// For name updates, preserve existing dataType
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
const dataType = existingKey?.dataType ?? "string";
|
||||
keysToUpsert.set(key, { ...value, dataType });
|
||||
}
|
||||
|
||||
if (keysToUpsert.size > 0) {
|
||||
@@ -272,12 +348,13 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
|
||||
${environmentId},
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
|
||||
NOW(),
|
||||
NOW()
|
||||
ON CONFLICT ("key", "environmentId")
|
||||
@@ -308,25 +385,39 @@ export const upsertBulkContacts = async (
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
@@ -341,7 +432,7 @@ export const upsertBulkContacts = async (
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
"id", "created_at", "updated_at", "contactId", "value", "valueNumber", "valueDate", "attributeKeyId"
|
||||
)
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.id)}]`}),
|
||||
@@ -349,9 +440,13 @@ export const upsertBulkContacts = async (
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.updatedAt)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.contactId)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.value)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueNumber)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueDate)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.attributeKeyId)}]`})
|
||||
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
|
||||
"value" = EXCLUDED."value",
|
||||
"valueNumber" = EXCLUDED."valueNumber",
|
||||
"valueDate" = EXCLUDED."valueDate",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -24,6 +25,7 @@ const ZCreateContactAttributeKeyAction = z.object({
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
type TCreateContactAttributeKeyActionInput = z.infer<typeof ZCreateContactAttributeKeyAction>;
|
||||
@@ -66,6 +68,7 @@ export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { deleteAttributeKeyAction } from "../actions";
|
||||
import { generateAttributeKeysTableColumns } from "./attribute-keys-table-columns";
|
||||
|
||||
interface AttributeKeysManagerProps {
|
||||
environmentId: string;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function AttributeKeysManager({
|
||||
environmentId,
|
||||
attributeKeys,
|
||||
isReadOnly,
|
||||
}: AttributeKeysManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeletingKeys, setIsDeletingKeys] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
// Filter to only show custom attribute keys
|
||||
const customAttributeKeys = useMemo(() => {
|
||||
return attributeKeys.filter((key) => key.type === "custom");
|
||||
}, [attributeKeys]);
|
||||
|
||||
// Filter by search
|
||||
const filteredAttributeKeys = useMemo(() => {
|
||||
if (!searchValue) return customAttributeKeys;
|
||||
|
||||
return customAttributeKeys.filter((key) => {
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return (
|
||||
key.key.toLowerCase().includes(searchLower) ||
|
||||
key.name?.toLowerCase().includes(searchLower) ||
|
||||
key.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
}, [customAttributeKeys, searchValue]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeKeysTableColumns(isReadOnly);
|
||||
}, [isReadOnly]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredAttributeKeys,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: !isReadOnly,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const selectedAttributeKeyIds = selectedRows.map((row) => row.original.id);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedAttributeKeyIds.length === 0) return;
|
||||
|
||||
setIsDeletingKeys(true);
|
||||
try {
|
||||
const deletePromises = selectedAttributeKeyIds.map((id) =>
|
||||
deleteAttributeKeyAction({ environmentId, attributeKeyId: id })
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
toast.success(
|
||||
t("environments.contacts.attribute_keys_deleted_successfully", {
|
||||
count: selectedAttributeKeyIds.length,
|
||||
})
|
||||
);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingKeys(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_attribute_keys")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar with bulk actions */}
|
||||
{!isReadOnly && selectedRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })}
|
||||
</p>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("common.delete_selected", { count: selectedRows.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
// Skip rendering checkbox in the header for selection column
|
||||
if (header.id === "select") {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-10 w-12 rounded-tl-lg border-b border-slate-200 bg-white px-4 font-semibold"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, index) => {
|
||||
const isLastRow = index === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={`hover:bg-white ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`py-2 ${
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}`}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.contacts.no_custom_attributes_yet")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={
|
||||
selectedRows.length === 1
|
||||
? selectedRows[0].original.name || selectedRows[0].original.key
|
||||
: t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })
|
||||
}
|
||||
onDelete={handleBulkDelete}
|
||||
isDeleting={isDeletingKeys}
|
||||
text={t("environments.contacts.delete_attribute_keys_warning_detailed", {
|
||||
count: selectedRows.length,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
const getIconForDataType = (dataType: TContactAttributeDataType) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4 text-slate-600" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4 text-slate-600" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4 text-slate-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateAttributeKeysTableColumns = (isReadOnly: boolean): ColumnDef<TContactAttributeKey>[] => {
|
||||
const nameColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name || row.original.key;
|
||||
return <span className="font-medium text-slate-900">{name}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const keyColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "key",
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
cell: ({ row }) => {
|
||||
return <IdBadge id={row.original.key} />;
|
||||
},
|
||||
};
|
||||
|
||||
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "dataType",
|
||||
accessorKey: "dataType",
|
||||
header: "Data Type",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{getIconForDataType(row.original.dataType)}
|
||||
<Badge text={row.original.dataType} type="gray" size="tiny" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
return <span className="text-sm text-slate-600">{row.original.description || "—"}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const baseColumns = [
|
||||
nameColumn,
|
||||
keyColumn,
|
||||
dataTypeColumn,
|
||||
descriptionColumn,
|
||||
createdAtColumn,
|
||||
updatedAtColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Calendar1Icon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { createContactAttributeKeyAction } from "../actions";
|
||||
|
||||
interface CreateAttributeModalProps {
|
||||
@@ -33,6 +41,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string" as TContactAttributeDataType,
|
||||
});
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
@@ -41,6 +50,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string",
|
||||
});
|
||||
setKeyError("");
|
||||
setOpen(false);
|
||||
@@ -92,6 +102,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: formData.key,
|
||||
name: formData.name || formData.key,
|
||||
description: formData.description || undefined,
|
||||
dataType: formData.dataType,
|
||||
});
|
||||
|
||||
if (!createContactAttributeKeyResponse?.data) {
|
||||
@@ -166,6 +177,42 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<Select
|
||||
value={formData.dataType}
|
||||
onValueChange={(value: TContactAttributeDataType) =>
|
||||
setFormData((prev) => ({ ...prev, dataType: value }))
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span>{t("common.string")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
<div className="flex items-center gap-2">
|
||||
<HashIcon className="h-4 w-4" />
|
||||
<span>{t("common.number")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="date">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
<span>{t("common.date")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.data_type_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +21,18 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateContactAttributeKeyAction } from "../actions";
|
||||
|
||||
const getDataTypeIcon = (dataType: string) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditAttributeModalProps {
|
||||
attribute: TContactAttributeKey;
|
||||
open: boolean;
|
||||
@@ -86,6 +100,19 @@ export function EditAttributeModal({ attribute, open, setOpen }: Readonly<EditAt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3">
|
||||
{getDataTypeIcon(attribute.dataType)}
|
||||
<Badge text={t(`common.${attribute.dataType}`)} type="gray" size="tiny" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.data_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ContactDataView = ({
|
||||
key: attr.key,
|
||||
name: attr.name,
|
||||
value: contact.attributes[attr.key] ?? "",
|
||||
dataType: attr.dataType,
|
||||
})),
|
||||
}));
|
||||
}, [contacts, environmentAttributes]);
|
||||
|
||||
@@ -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} />;
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
|
||||
151
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal file
151
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -4,12 +4,14 @@ import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contac
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareNewAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { validateAndParseAttributeValue } from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
@@ -168,22 +170,42 @@ export const updateAttributes = async (
|
||||
// Create lookup map for attribute keys
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
|
||||
// Separate existing and new attributes in a single pass
|
||||
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
if (attributeKey) {
|
||||
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
||||
// Separate existing and new attributes, validating types for existing attributes
|
||||
const existingAttributes: {
|
||||
key: string;
|
||||
attributeKeyId: string;
|
||||
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
|
||||
}[] = [];
|
||||
const newAttributes: { key: string; value: string }[] = [];
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(contactAttributes)) {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
|
||||
if (attributeKey) {
|
||||
// Existing attribute - validate type and prepare columns
|
||||
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
|
||||
|
||||
if (validationResult.valid) {
|
||||
existingAttributes.push({
|
||||
key,
|
||||
attributeKeyId: attributeKey.id,
|
||||
columns: validationResult.parsedValue,
|
||||
});
|
||||
} else {
|
||||
acc.newAttributes.push({ key, value });
|
||||
// Type mismatch - add to errors
|
||||
typeValidationErrors.push(validationResult.error);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ existingAttributes: [], newAttributes: [] } as {
|
||||
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string }[];
|
||||
} else {
|
||||
// New attribute - will detect type on creation
|
||||
newAttributes.push({ key, value });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add type validation errors to messages
|
||||
if (typeValidationErrors.length > 0) {
|
||||
messages.push(...typeValidationErrors);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
@@ -193,10 +215,10 @@ export const updateAttributes = async (
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
}
|
||||
|
||||
// Update all existing attributes
|
||||
// Update all existing attributes with typed column values
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
existingAttributes.map(({ attributeKeyId, value }) =>
|
||||
existingAttributes.map(({ attributeKeyId, columns }) =>
|
||||
prisma.contactAttribute.upsert({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
@@ -204,11 +226,17 @@ export const updateAttributes = async (
|
||||
attributeKeyId,
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
value,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -227,18 +255,25 @@ export const updateAttributes = async (
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
newAttributes.map(({ key, value }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
newAttributes.map(({ key, value }) => {
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(value);
|
||||
return prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type: "custom",
|
||||
dataType,
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: { contactId, value },
|
||||
create: {
|
||||
contactId,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
@@ -28,6 +28,7 @@ export const createContactAttributeKey = async (data: {
|
||||
key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
dataType?: TContactAttributeDataType;
|
||||
}): Promise<TContactAttributeKey> => {
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
@@ -37,6 +38,7 @@ export const createContactAttributeKey = async (data: {
|
||||
description: data.description ?? null,
|
||||
environmentId: data.environmentId,
|
||||
type: "custom",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
const selectContactAttribute = {
|
||||
value: true,
|
||||
valueNumber: true,
|
||||
valueDate: true,
|
||||
attributeKey: {
|
||||
select: {
|
||||
key: true,
|
||||
name: true,
|
||||
dataType: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.ContactAttributeSelect;
|
||||
@@ -41,6 +44,34 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const prismaAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: selectContactAttribute,
|
||||
});
|
||||
|
||||
return prismaAttributes.map((attr) => ({
|
||||
key: attr.attributeKey.key,
|
||||
name: attr.attributeKey.name,
|
||||
value: attr.value,
|
||||
valueNumber: attr.valueNumber,
|
||||
valueDate: attr.valueDate,
|
||||
dataType: attr.attributeKey.dataType,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const hasEmailAttribute = reactCache(
|
||||
async (email: string, environmentId: string, contactId: string): Promise<boolean> => {
|
||||
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);
|
||||
|
||||
@@ -4,10 +4,13 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import {
|
||||
@@ -210,14 +213,25 @@ const contactAttributesInclude = {
|
||||
},
|
||||
} satisfies Prisma.ContactInclude;
|
||||
|
||||
// Helper to create attribute objects for Prisma create operations
|
||||
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
|
||||
Object.entries(record).map(([key, value]) => ({
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value,
|
||||
}));
|
||||
// Helper to create attribute objects for Prisma create operations with typed columns
|
||||
const createAttributeConnections = (
|
||||
record: Record<string, string>,
|
||||
environmentId: string,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>
|
||||
) =>
|
||||
Object.entries(record).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key, environmentId } },
|
||||
},
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to handle userId conflicts when updating/overwriting contacts
|
||||
const resolveUserIdConflict = (
|
||||
@@ -327,7 +341,7 @@ export const createContactsFromCSV = async (
|
||||
// Fetch existing attribute keys and cache them
|
||||
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
|
||||
where: { environmentId },
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
const attributeKeyMap = new Map<string, string>();
|
||||
@@ -345,6 +359,71 @@ export const createContactsFromCSV = async (
|
||||
Object.keys(record).forEach((key) => csvKeys.add(key));
|
||||
});
|
||||
|
||||
// Type Detection Phase: Detect data types for new attribute keys
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
csvData.forEach((record) => {
|
||||
Object.entries(record).forEach(([key, value]) => {
|
||||
if (!attributeValuesByKey.has(key)) {
|
||||
attributeValuesByKey.set(key, []);
|
||||
}
|
||||
if (value && value.trim() !== "") {
|
||||
attributeValuesByKey.get(key)!.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build a map of attribute keys to their detected/existing data types
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
// Use case-insensitive lookup for existing keys
|
||||
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
|
||||
const existingKey = actualKey ? existingAttributeKeys.find((ak) => ak.key === actualKey) : null;
|
||||
|
||||
if (existingKey) {
|
||||
// Use existing dataType for existing keys
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
// Detect type from first non-empty value for new keys
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
if (firstValue) {
|
||||
const detectedType = detectAttributeDataType(firstValue);
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string"); // default for empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all values can be converted to their detected type
|
||||
// If validation fails, fallback to string type for compatibility
|
||||
const typeValidationErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
|
||||
// Skip validation for string type (always valid)
|
||||
if (dataType === "string") continue;
|
||||
|
||||
for (const value of values) {
|
||||
try {
|
||||
prepareAttributeColumnsForStorage(value, dataType);
|
||||
} catch {
|
||||
// If any value fails conversion, downgrade to string type
|
||||
attributeTypeMap.set(key, "string");
|
||||
typeValidationErrors.push(
|
||||
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeValidationErrors.length > 0) {
|
||||
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during CSV upload");
|
||||
}
|
||||
|
||||
// Identify missing attribute keys (case-insensitive check)
|
||||
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
|
||||
|
||||
@@ -363,6 +442,7 @@ export const createContactsFromCSV = async (
|
||||
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
|
||||
key,
|
||||
name: key,
|
||||
dataType: attributeTypeMap.get(key) ?? "string",
|
||||
environmentId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
@@ -374,7 +454,7 @@ export const createContactsFromCSV = async (
|
||||
key: { in: Array.from(uniqueMissingKeys.values()) },
|
||||
environmentId,
|
||||
},
|
||||
select: { key: true, id: true },
|
||||
select: { key: true, id: true, dataType: true },
|
||||
});
|
||||
|
||||
newAttributeKeys.forEach((attrKey) => {
|
||||
@@ -414,19 +494,30 @@ export const createContactsFromCSV = async (
|
||||
case "update": {
|
||||
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
|
||||
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return {
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
contactId: existingContact.id,
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
},
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value,
|
||||
},
|
||||
}));
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
attributeKeyId: attributeKeyMap.get(key),
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Update contact with upserted attributes
|
||||
return prisma.contact.update({
|
||||
@@ -453,7 +544,7 @@ export const createContactsFromCSV = async (
|
||||
where: { id: existingContact.id },
|
||||
data: {
|
||||
attributes: {
|
||||
create: createAttributeConnections(recordToProcess, environmentId),
|
||||
create: createAttributeConnections(recordToProcess, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
@@ -466,7 +557,7 @@ export const createContactsFromCSV = async (
|
||||
data: {
|
||||
environmentId,
|
||||
attributes: {
|
||||
create: createAttributeConnections(mappedRecord, environmentId),
|
||||
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
|
||||
},
|
||||
},
|
||||
include: contactAttributesInclude,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { detectAttributeDataType } from "./detect-attribute-type";
|
||||
|
||||
describe("detectAttributeDataType", () => {
|
||||
describe("Date object input", () => {
|
||||
test("detects Date objects as date type", () => {
|
||||
expect(detectAttributeDataType(new Date())).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15"))).toBe("date");
|
||||
expect(detectAttributeDataType(new Date("2024-01-15T10:30:00Z"))).toBe("date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("number input", () => {
|
||||
test("detects numbers as number type", () => {
|
||||
expect(detectAttributeDataType(42)).toBe("number");
|
||||
expect(detectAttributeDataType(3.14)).toBe("number");
|
||||
expect(detectAttributeDataType(-10)).toBe("number");
|
||||
expect(detectAttributeDataType(0)).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("string input", () => {
|
||||
test("detects ISO 8601 date strings", () => {
|
||||
expect(detectAttributeDataType("2024-01-15")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2024-01-15T10:30:00.000Z")).toBe("date");
|
||||
expect(detectAttributeDataType("2023-12-31")).toBe("date");
|
||||
});
|
||||
|
||||
test("detects numeric string values", () => {
|
||||
expect(detectAttributeDataType("42")).toBe("number");
|
||||
expect(detectAttributeDataType("3.14")).toBe("number");
|
||||
expect(detectAttributeDataType("-10")).toBe("number");
|
||||
expect(detectAttributeDataType("0")).toBe("number");
|
||||
expect(detectAttributeDataType(" 123 ")).toBe("number");
|
||||
});
|
||||
|
||||
test("detects string values", () => {
|
||||
expect(detectAttributeDataType("hello")).toBe("string");
|
||||
expect(detectAttributeDataType("john@example.com")).toBe("string");
|
||||
expect(detectAttributeDataType("123abc")).toBe("string");
|
||||
expect(detectAttributeDataType("")).toBe("string");
|
||||
});
|
||||
|
||||
test("handles invalid date strings as string", () => {
|
||||
expect(detectAttributeDataType("2024-13-01")).toBe("string"); // Invalid month
|
||||
expect(detectAttributeDataType("not-a-date")).toBe("string");
|
||||
expect(detectAttributeDataType("2024/01/15")).toBe("string"); // Wrong format
|
||||
});
|
||||
|
||||
test("handles edge cases", () => {
|
||||
expect(detectAttributeDataType(" ")).toBe("string"); // Whitespace only
|
||||
expect(detectAttributeDataType("NaN")).toBe("string");
|
||||
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
91
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal 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";
|
||||
};
|
||||
46
apps/web/modules/ee/contacts/lib/format-attribute-value.ts
Normal file
46
apps/web/modules/ee/contacts/lib/format-attribute-value.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
154
apps/web/modules/ee/contacts/lib/validate-attribute-type.test.ts
Normal file
154
apps/web/modules/ee/contacts/lib/validate-attribute-type.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
146
apps/web/modules/ee/contacts/lib/validate-attribute-type.ts
Normal file
146
apps/web/modules/ee/contacts/lib/validate-attribute-type.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "lucide-react";
|
||||
import {
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
TagIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import React, { type JSX, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
TSegment,
|
||||
@@ -33,6 +40,7 @@ export const handleAddFilter = ({
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
deviceType,
|
||||
segmentId,
|
||||
}: {
|
||||
@@ -40,12 +48,22 @@ export const handleAddFilter = ({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
segmentId?: string;
|
||||
deviceType?: string;
|
||||
}): void => {
|
||||
if (type === "attribute") {
|
||||
if (!contactAttributeKey) return;
|
||||
|
||||
// Set default operator and value based on attribute data type
|
||||
let defaultOperator: "equals" | "isOlderThan" = "equals";
|
||||
let defaultValue: string | { amount: number; unit: "days" } = "";
|
||||
|
||||
if (attributeDataType === "date") {
|
||||
defaultOperator = "isOlderThan";
|
||||
defaultValue = { amount: 1, unit: "days" };
|
||||
}
|
||||
|
||||
const newFilterResource: TSegmentAttributeFilter = {
|
||||
id: createId(),
|
||||
root: {
|
||||
@@ -53,9 +71,9 @@ export const handleAddFilter = ({
|
||||
contactAttributeKey,
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
operator: defaultOperator,
|
||||
},
|
||||
value: "",
|
||||
value: defaultValue,
|
||||
};
|
||||
const newFilter: TBaseFilter = {
|
||||
id: createId(),
|
||||
@@ -235,33 +253,46 @@ export function AddFilterModal({
|
||||
|
||||
{allFiltersFiltered.map((filters, index) => (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
{filters.attributes.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
attributeDataType: attributeKey.dataType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => (
|
||||
<FilterButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { Calendar1Icon, FingerprintIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter } from "@formbricks/types/segment";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AttributeTabContentProps {
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ function FilterButtonWithHandler({
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
contactAttributeKey,
|
||||
attributeDataType,
|
||||
}: {
|
||||
dataTestId: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -38,8 +40,10 @@ function FilterButtonWithHandler({
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) => void;
|
||||
contactAttributeKey?: string;
|
||||
attributeDataType?: TContactAttributeDataType;
|
||||
}) {
|
||||
return (
|
||||
<FilterButton
|
||||
@@ -51,7 +55,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -61,7 +65,7 @@ function FilterButtonWithHandler({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
...(type === "attribute" ? { contactAttributeKey, attributeDataType } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -104,19 +108,31 @@ function AttributeTabContent({
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
/>
|
||||
))}
|
||||
{contactAttributeKeys.map((attributeKey) => {
|
||||
const icon =
|
||||
attributeKey.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attributeKey.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={icon}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
attributeDataType={attributeKey.dataType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDateOperator, TSegmentFilterValue, TTimeUnit } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface DateFilterValueProps {
|
||||
operator: TDateOperator;
|
||||
value: TSegmentFilterValue;
|
||||
onChange: (value: TSegmentFilterValue) => void;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export function DateFilterValue({ operator, value, onChange, viewOnly }: DateFilterValueProps) {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Relative time operators: isOlderThan, isNewerThan
|
||||
if (operator === "isOlderThan" || operator === "isNewerThan") {
|
||||
const relativeValue =
|
||||
typeof value === "object" && "amount" in value && "unit" in value
|
||||
? value
|
||||
: { amount: 1, unit: "days" as TTimeUnit };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className={cn("h-9 w-20 bg-white", error && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.amount}
|
||||
onChange={(e) => {
|
||||
const amount = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isNaN(amount) || amount < 1) {
|
||||
setError(t("environments.segments.value_must_be_positive"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onChange({ amount, unit: relativeValue.unit });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
disabled={viewOnly}
|
||||
value={relativeValue.unit}
|
||||
onValueChange={(unit: TTimeUnit) => {
|
||||
onChange({ amount: relativeValue.amount, unit });
|
||||
}}>
|
||||
<SelectTrigger className="flex w-auto items-center justify-center bg-white" hideArrow>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="days">{t("common.days")}</SelectItem>
|
||||
<SelectItem value="weeks">{t("common.weeks")}</SelectItem>
|
||||
<SelectItem value="months">{t("common.months")}</SelectItem>
|
||||
<SelectItem value="years">{t("common.years")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Between operator: needs two date inputs
|
||||
if (operator === "isBetween") {
|
||||
const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[0] ? betweenValue[0].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([dateValue, betweenValue[1]]);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{t("common.and")}</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={betweenValue[1] ? betweenValue[1].split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange([betweenValue[0], dateValue]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Absolute date operators: isBefore, isAfter, isSameDay
|
||||
// Use a single date picker
|
||||
const dateValue = typeof value === "string" ? value : "";
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-auto bg-white"
|
||||
disabled={viewOnly}
|
||||
value={dateValue ? dateValue.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
onChange(dateValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,7 +20,7 @@ interface EditSegmentModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
currentSegment: TSegmentWithSurveyNames;
|
||||
currentSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
|
||||
@@ -8,16 +8,14 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface SegmentActivityTabProps {
|
||||
environmentId: string;
|
||||
currentSegment: TSegment & {
|
||||
activeSurveys: string[];
|
||||
inactiveSurveys: string[];
|
||||
};
|
||||
currentSegment: TSegment;
|
||||
}
|
||||
|
||||
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const activeSurveys = currentSegment?.activeSurveys;
|
||||
const inactiveSurveys = currentSegment?.inactiveSurveys;
|
||||
|
||||
const activeSurveys: string[] = [];
|
||||
const inactiveSurveys: string[] = [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
Calendar1Icon,
|
||||
FingerprintIcon,
|
||||
HashIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
MoreVertical,
|
||||
TagIcon,
|
||||
@@ -14,26 +16,27 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TArithmeticOperator,
|
||||
TAttributeOperator,
|
||||
TBaseFilter,
|
||||
TDeviceOperator,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
ARITHMETIC_OPERATORS,
|
||||
ATTRIBUTE_OPERATORS,
|
||||
DATE_OPERATORS,
|
||||
DEVICE_OPERATORS,
|
||||
NUMBER_TYPE_OPERATORS,
|
||||
PERSON_OPERATORS,
|
||||
STRING_TYPE_OPERATORS,
|
||||
type TArithmeticOperator,
|
||||
type TAttributeOperator,
|
||||
type TBaseFilter,
|
||||
type TDeviceOperator,
|
||||
type TSegment,
|
||||
type TSegmentAttributeFilter,
|
||||
type TSegmentConnector,
|
||||
type TSegmentDeviceFilter,
|
||||
type TSegmentFilter,
|
||||
type TSegmentFilterValue,
|
||||
type TSegmentOperator,
|
||||
type TSegmentPersonFilter,
|
||||
type TSegmentSegmentFilter,
|
||||
isDateOperator,
|
||||
} from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -64,6 +67,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { AddFilterModal } from "./add-filter-modal";
|
||||
import { DateFilterValue } from "./date-filter-value";
|
||||
|
||||
interface TSegmentFilterProps {
|
||||
connector: TSegmentConnector;
|
||||
@@ -204,7 +208,6 @@ type TAttributeSegmentFilterProps = TSegmentFilterProps & {
|
||||
resource: TSegmentAttributeFilter;
|
||||
updateValueInLocalSurvey: (filterId: string, newValue: TSegmentFilterValue) => void;
|
||||
};
|
||||
|
||||
function AttributeSegmentFilter({
|
||||
connector,
|
||||
resource,
|
||||
@@ -239,17 +242,32 @@ function AttributeSegmentFilter({
|
||||
}
|
||||
}, [resource.qualifier, resource.value, t]);
|
||||
|
||||
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
// Default to 'string' if dataType is undefined (for backwards compatibility)
|
||||
const attributeDataType = attributeKey?.dataType ?? "string";
|
||||
const isDateAttribute = attributeDataType === "date";
|
||||
|
||||
// Show operators based on attribute data type
|
||||
const getOperatorsForDataType = () => {
|
||||
switch (attributeDataType) {
|
||||
case "date":
|
||||
return DATE_OPERATORS;
|
||||
case "number":
|
||||
return NUMBER_TYPE_OPERATORS;
|
||||
case "string":
|
||||
default:
|
||||
return STRING_TYPE_OPERATORS;
|
||||
}
|
||||
};
|
||||
const availableOperators = getOperatorsForDataType();
|
||||
const operatorArr = availableOperators.map((operator) => {
|
||||
return {
|
||||
id: operator,
|
||||
name: convertOperatorToText(operator),
|
||||
};
|
||||
});
|
||||
|
||||
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
|
||||
|
||||
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
|
||||
|
||||
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
@@ -263,6 +281,15 @@ function AttributeSegmentFilter({
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateContactAttributeKeyInFilter(updatedSegment.filters, filterId, newAttributeClassName);
|
||||
|
||||
// When changing attribute, reset operator to appropriate default for the new attribute type
|
||||
const newAttributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === newAttributeClassName);
|
||||
const newAttributeDataType = newAttributeKey?.dataType ?? "string";
|
||||
const defaultOperator = newAttributeDataType === "date" ? "isOlderThan" : "equals";
|
||||
const defaultValue = newAttributeDataType === "date" ? { amount: 1, unit: "days" as const } : "";
|
||||
|
||||
updateOperatorInFilter(updatedSegment.filters, filterId, defaultOperator as any);
|
||||
updateFilterValue(updatedSegment.filters, filterId, defaultValue as any);
|
||||
}
|
||||
|
||||
setSegment(updatedSegment);
|
||||
@@ -315,11 +342,17 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
{isDateAttribute ? (
|
||||
<Calendar1Icon className="h-4 w-4 text-sm" />
|
||||
) : attributeDataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4 text-sm" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4 text-sm" />
|
||||
)}
|
||||
<p>{attrKeyValue}</p>
|
||||
</div>
|
||||
</SelectValue>
|
||||
@@ -328,7 +361,16 @@ function AttributeSegmentFilter({
|
||||
<SelectContent>
|
||||
{contactAttributeKeys.map((attrClass) => (
|
||||
<SelectItem key={attrClass.id} value={attrClass.key}>
|
||||
{attrClass.name ?? attrClass.key}
|
||||
<div className="flex items-center gap-2">
|
||||
{attrClass.dataType === "date" ? (
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
) : attrClass.dataType === "number" ? (
|
||||
<HashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<TagIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{attrClass.name ?? attrClass.key}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -356,23 +398,39 @@ function AttributeSegmentFilter({
|
||||
</Select>
|
||||
|
||||
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
/>
|
||||
<>
|
||||
{isDateAttribute && isDateOperator(resource.qualifier.operator) ? (
|
||||
<DateFilterValue
|
||||
operator={resource.qualifier.operator}
|
||||
value={resource.value}
|
||||
onChange={(newValue) => {
|
||||
updateValueInLocalSurvey(resource.id, newValue);
|
||||
}}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className={cn(
|
||||
"h-9 w-auto bg-white",
|
||||
valueError && "border border-red-500 focus:border-red-500"
|
||||
)}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{valueError ? (
|
||||
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
|
||||
{valueError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SegmentFilterItemContextMenu
|
||||
@@ -497,7 +555,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -544,7 +602,7 @@ function PersonSegmentFilter({
|
||||
if (viewOnly) return;
|
||||
checkValueAndUpdate(e);
|
||||
}}
|
||||
value={resource.value}
|
||||
value={resource.value as string | number}
|
||||
/>
|
||||
|
||||
{valueError ? (
|
||||
@@ -648,7 +706,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
@@ -660,7 +718,9 @@ function SegmentSegmentFilter({
|
||||
{segments
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<SelectItem value={segment.id}>{segment.title}</SelectItem>
|
||||
<SelectItem key={segment.id} value={segment.id}>
|
||||
{segment.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -803,7 +863,7 @@ export function SegmentFilter({
|
||||
}: TSegmentFilterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: string | number) => {
|
||||
const updateFilterValueInSegment = (filterId: string, newValue: TSegmentFilterValue) => {
|
||||
const updatedSegment = structuredClone(segment);
|
||||
if (updatedSegment.filters) {
|
||||
updateFilterValue(updatedSegment.filters, filterId, newValue);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -21,7 +21,7 @@ import { SegmentEditor } from "./segment-editor";
|
||||
interface TSegmentSettingsTabProps {
|
||||
environmentId: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
initialSegment: TSegmentWithSurveyNames;
|
||||
initialSegment: TSegment;
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
export const generateSegmentTableColumns = (): ColumnDef<TSegment>[] => {
|
||||
const titleColumn: ColumnDef<TSegment> = {
|
||||
id: "title",
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||
<UsersIcon className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium text-slate-900">{row.original.title}</div>
|
||||
{row.original.description && (
|
||||
<div className="text-xs text-slate-500">{row.original.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TSegment> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">
|
||||
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TSegment> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [titleColumn, updatedAtColumn, createdAtColumn];
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { generateSegmentTableColumns } from "./segment-table-columns";
|
||||
|
||||
interface SegmentTableUpdatedProps {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function SegmentTableUpdated({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: SegmentTableUpdatedProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingSegment, setEditingSegment] = useState<TSegment | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return generateSegmentTableColumns();
|
||||
}, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: segments,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="rounded-t-lg">
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const isFirstHeader = index === 0;
|
||||
const isLastHeader = index === headerGroup.headers.length - 1;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
|
||||
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
|
||||
}`}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row, rowIndex) => {
|
||||
const isLastRow = rowIndex === table.getRowModel().rows.length - 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => setEditingSegment(row.original)}
|
||||
className={`cursor-pointer hover:bg-slate-50 ${isLastRow ? "rounded-b-lg" : ""}`}>
|
||||
{row.getVisibleCells().map((cell, cellIndex) => {
|
||||
const isFirstCell = cellIndex === 0;
|
||||
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
isLastRow
|
||||
? isFirstCell
|
||||
? "rounded-bl-lg"
|
||||
: isLastCell
|
||||
? "rounded-br-lg"
|
||||
: ""
|
||||
: ""
|
||||
}>
|
||||
{typeof cell.column.columnDef.cell === "function"
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.column.columnDef.cell}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<p className="text-slate-400">{t("environments.segments.create_your_first_segment")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Edit Segment Modal */}
|
||||
{editingSegment && (
|
||||
<EditSegmentModal
|
||||
environmentId={editingSegment.environmentId}
|
||||
open={!!editingSegment}
|
||||
setOpen={(open) => !open && setEditingSegment(null)}
|
||||
currentSegment={editingSegment}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
|
||||
|
||||
type TSegmentTableProps = {
|
||||
segments: TSegment[];
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isContactsEnabled: boolean;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const SegmentTable = async ({
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
isContactsEnabled,
|
||||
isReadOnly,
|
||||
}: TSegmentTableProps) => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-4 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.surveys")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.segments.create_your_first_segment")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<SegmentTableDataRowContainer
|
||||
key={segment.id}
|
||||
currentSegment={segment}
|
||||
segments={segments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal file
114
apps/web/modules/ee/contacts/segments/lib/date-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal file
93
apps/web/modules/ee/contacts/segments/lib/date-utils.ts
Normal 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()
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { cache as reactCache } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils";
|
||||
import { getSegment } from "../segments";
|
||||
|
||||
// Type for the result of the segment filter to prisma query generation
|
||||
@@ -18,6 +21,108 @@ export type SegmentFilterQueryResult = {
|
||||
whereClause: Prisma.ContactWhereInput;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for date attribute filters
|
||||
* Uses the native valueDate column for performant DateTime comparisons
|
||||
*/
|
||||
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier as { operator: TDateOperator };
|
||||
const now = new Date();
|
||||
|
||||
let dateCondition: Prisma.DateTimeNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { lt: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// value should be { amount, unit }
|
||||
if (typeof value === "object" && "amount" in value && "unit" in value) {
|
||||
const threshold = subtractTimeUnit(now, value.amount, value.unit);
|
||||
dateCondition = { gte: threshold };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "isBefore":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { lt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isAfter":
|
||||
if (typeof value === "string") {
|
||||
dateCondition = { gt: new Date(value) };
|
||||
}
|
||||
break;
|
||||
case "isBetween":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
dateCondition = { gte: new Date(value[0]), lte: new Date(value[1]) };
|
||||
}
|
||||
break;
|
||||
case "isSameDay": {
|
||||
if (typeof value === "string") {
|
||||
const dayStart = startOfDay(new Date(value));
|
||||
const dayEnd = endOfDay(new Date(value));
|
||||
dateCondition = { gte: dayStart, lte: dayEnd };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueDate: dateCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for number attribute filters
|
||||
* Uses the native valueNumber column for performant numeric comparisons
|
||||
*/
|
||||
const buildNumberAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.ContactWhereInput => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
|
||||
let numberCondition: Prisma.FloatNullableFilter = {};
|
||||
|
||||
switch (operator) {
|
||||
case "greaterThan":
|
||||
numberCondition = { gt: numericValue };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
numberCondition = { gte: numericValue };
|
||||
break;
|
||||
case "lessThan":
|
||||
numberCondition = { lt: numericValue };
|
||||
break;
|
||||
case "lessEqual":
|
||||
numberCondition = { lte: numericValue };
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
valueNumber: numberCondition,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
@@ -60,6 +165,11 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
},
|
||||
} satisfies Prisma.ContactWhereInput;
|
||||
|
||||
// Handle date operators
|
||||
if (DATE_OPERATORS.includes(operator as TDateOperator)) {
|
||||
return buildDateAttributeFilterWhereClause(filter);
|
||||
}
|
||||
|
||||
// Apply the appropriate operator to the attribute value
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
@@ -81,17 +191,10 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
|
||||
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
|
||||
break;
|
||||
case "greaterThan":
|
||||
valueQuery.attributes.some.value = { gt: String(value) };
|
||||
break;
|
||||
case "greaterEqual":
|
||||
valueQuery.attributes.some.value = { gte: String(value) };
|
||||
break;
|
||||
case "lessThan":
|
||||
valueQuery.attributes.some.value = { lt: String(value) };
|
||||
break;
|
||||
case "lessEqual":
|
||||
valueQuery.attributes.some.value = { lte: String(value) };
|
||||
break;
|
||||
return buildNumberAttributeFilterWhereClause(filter);
|
||||
default:
|
||||
valueQuery.attributes.some.value = String(value);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DATE_OPERATORS,
|
||||
TAllOperators,
|
||||
TBaseFilters,
|
||||
TDateOperator,
|
||||
TEvaluateSegmentUserAttributeData,
|
||||
TEvaluateSegmentUserData,
|
||||
TSegment,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentCreateInput,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
TSegmentUpdateInput,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { isSameDay, subtractTimeUnit } from "./date-utils";
|
||||
|
||||
export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
include: {
|
||||
@@ -387,6 +391,12 @@ const evaluateAttributeFilter = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a date operator
|
||||
if (isDateOperator(qualifier.operator)) {
|
||||
return evaluateDateFilter(String(attributeValue), value, qualifier.operator);
|
||||
}
|
||||
|
||||
// Use standard comparison for non-date operators
|
||||
const attResult = compareValues(attributeValue, value, qualifier.operator);
|
||||
return attResult;
|
||||
};
|
||||
@@ -440,6 +450,86 @@ const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDevic
|
||||
return compareValues(device, value, qualifier.operator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an operator is a date-specific operator
|
||||
*/
|
||||
const isDateOperator = (operator: TAllOperators): operator is TDateOperator => {
|
||||
return DATE_OPERATORS.includes(operator as TDateOperator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a date filter against an attribute value
|
||||
*/
|
||||
const evaluateDateFilter = (
|
||||
attributeValue: string,
|
||||
filterValue: TSegmentFilterValue,
|
||||
operator: TDateOperator
|
||||
): boolean => {
|
||||
// Parse the attribute value as a date
|
||||
const attrDate = new Date(attributeValue);
|
||||
|
||||
// Validate the attribute value is a valid date
|
||||
if (isNaN(attrDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (operator) {
|
||||
case "isOlderThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate < threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isNewerThan": {
|
||||
// filterValue should be { amount, unit }
|
||||
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
|
||||
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
|
||||
return attrDate >= threshold;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBefore": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate < compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isAfter": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return attrDate > compareDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isBetween": {
|
||||
// filterValue should be a tuple [startDate, endDate]
|
||||
if (Array.isArray(filterValue) && filterValue.length === 2) {
|
||||
const startDate = new Date(filterValue[0]);
|
||||
const endDate = new Date(filterValue[1]);
|
||||
return attrDate >= startDate && attrDate <= endDate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case "isSameDay": {
|
||||
// filterValue should be an ISO date string
|
||||
if (typeof filterValue === "string") {
|
||||
const compareDate = new Date(filterValue);
|
||||
return isSameDay(attrDate, compareDate);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const compareValues = (
|
||||
a: string | number | undefined,
|
||||
b: string | number,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TSegmentConnector,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentFilterValue,
|
||||
TSegmentOperator,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "is older than";
|
||||
case "isNewerThan":
|
||||
return "is newer than";
|
||||
case "isBefore":
|
||||
return "is before";
|
||||
case "isAfter":
|
||||
return "is after";
|
||||
case "isBetween":
|
||||
return "is between";
|
||||
case "isSameDay":
|
||||
return "is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
|
||||
return "User is in";
|
||||
case "userIsNotIn":
|
||||
return "User is not in";
|
||||
case "isOlderThan":
|
||||
return "Is older than";
|
||||
case "isNewerThan":
|
||||
return "Is newer than";
|
||||
case "isBefore":
|
||||
return "Is before";
|
||||
case "isAfter":
|
||||
return "Is after";
|
||||
case "isBetween":
|
||||
return "Is between";
|
||||
case "isSameDay":
|
||||
return "Is same day";
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
|
||||
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const { resource } = group[i];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { SegmentTableUpdated } from "@/modules/ee/contacts/segments/components/segment-table-updated";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -46,7 +46,7 @@ export const SegmentsPage = async ({
|
||||
}
|
||||
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
|
||||
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
|
||||
<SegmentTable
|
||||
<SegmentTableUpdated
|
||||
segments={filteredSegments}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const ZContact = z.object({
|
||||
id: z.string().cuid2(),
|
||||
@@ -11,6 +12,7 @@ const ZContactTableAttributeData = z.object({
|
||||
key: z.string(),
|
||||
name: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
dataType: ZContactAttributeDataType,
|
||||
});
|
||||
|
||||
export const ZContactTableData = z.object({
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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() === "" &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
@@ -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[]
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 })}`
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user