vibe draft

This commit is contained in:
Johannes
2025-12-10 20:37:42 +01:00
parent c3d97c2932
commit e90bb93dfb
17 changed files with 705 additions and 41 deletions

View File

@@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache(
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
const { environmentId, name, description, key, dataType } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
@@ -49,6 +49,8 @@ export const createContactAttributeKey = async (
name,
description,
key,
// If dataType is provided, use it; otherwise Prisma will use the default (text)
...(dataType && { dataType }),
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({

View File

@@ -27,10 +27,15 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
dataType: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
})
.extend({
dataType: ZContactAttributeKey.shape.dataType.optional(),
})
.openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;

View File

@@ -5,6 +5,7 @@ import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
export const updateAttributes = async (
contactId: string,
@@ -95,19 +96,22 @@ export const updateAttributes = async (
);
} else {
// Create new attributes since we're under the limit
// Auto-detect the data type based on the first value
await prisma.$transaction(
newAttributes.map(({ key, value }) =>
prisma.contactAttributeKey.create({
newAttributes.map(({ key, value }) => {
const dataType = detectAttributeDataType(value);
return prisma.contactAttributeKey.create({
data: {
key,
type: "custom",
dataType,
environment: { connect: { id: environmentId } },
attributes: {
create: { contactId, value },
},
},
})
)
});
})
);
}
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { detectAttributeDataType } from "./detect-attribute-type";
describe("detectAttributeDataType", () => {
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 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 text values", () => {
expect(detectAttributeDataType("hello")).toBe("text");
expect(detectAttributeDataType("john@example.com")).toBe("text");
expect(detectAttributeDataType("123abc")).toBe("text");
expect(detectAttributeDataType("")).toBe("text");
});
test("handles invalid date strings as text", () => {
expect(detectAttributeDataType("2024-13-01")).toBe("text"); // Invalid month
expect(detectAttributeDataType("not-a-date")).toBe("text");
expect(detectAttributeDataType("2024/01/15")).toBe("text"); // Wrong format
});
test("handles edge cases", () => {
expect(detectAttributeDataType(" ")).toBe("text"); // Whitespace only
expect(detectAttributeDataType("NaN")).toBe("text");
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
});
});

View File

@@ -0,0 +1,28 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
/**
* Detects the data type of an attribute value based on its format
* @param value - The attribute value to detect the type of
* @returns The detected data type (text, number, or date)
*/
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
// Check if valid ISO 8601 date format
// Must match YYYY-MM-DD at minimum (with optional time component)
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
// Verify it's a valid date and not "Invalid Date"
if (!isNaN(date.getTime())) {
return "date";
}
}
// Check if numeric (integer or decimal)
// Trim whitespace and check if it's a valid number
const trimmedValue = value.trim();
if (trimmedValue !== "" && !isNaN(Number(trimmedValue))) {
return "number";
}
// Default to text for everything else
return "text";
};

View File

@@ -0,0 +1,120 @@
"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 = parseInt(e.target.value, 10);
if (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>
<span className="text-sm text-slate-600">{t("common.ago")}</span>
</div>
);
}
// Between operator: needs two date inputs
if (operator === "isBetween") {
const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""];
return (
<div className="flex items-center gap-2">
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={betweenValue[0] ? betweenValue[0].split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange([dateValue, betweenValue[1]]);
}}
/>
<span className="text-sm text-slate-600">{t("common.and")}</span>
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={betweenValue[1] ? betweenValue[1].split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange([betweenValue[0], dateValue]);
}}
/>
</div>
);
}
// Absolute date operators: isBefore, isAfter, isSameDay
// Use a single date picker
const dateValue = typeof value === "string" ? value : "";
return (
<Input
type="date"
className="h-9 w-auto bg-white"
disabled={viewOnly}
value={dateValue ? dateValue.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
onChange(dateValue);
}}
/>
);
}

View File

@@ -32,6 +32,7 @@ import type {
import {
ARITHMETIC_OPERATORS,
ATTRIBUTE_OPERATORS,
DATE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
@@ -64,6 +65,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
import { DateFilterValue } from "./date-filter-value";
interface TSegmentFilterProps {
connector: TSegmentConnector;
@@ -239,17 +241,19 @@ 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 ?? "";
const isDateAttribute = attributeKey?.dataType === "date";
// Show date operators for date attributes, otherwise show standard attribute operators
const availableOperators = isDateAttribute ? DATE_OPERATORS : ATTRIBUTE_OPERATORS;
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) {
@@ -356,23 +360,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 && DATE_OPERATORS.includes(resource.qualifier.operator as any) ? (
<DateFilterValue
operator={resource.qualifier.operator as any}
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}
/>
{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

View File

@@ -0,0 +1,114 @@
import { describe, expect, test } from "vitest";
import { addTimeUnit, endOfDay, isSameDay, startOfDay, subtractTimeUnit } from "./date-utils";
describe("date-utils", () => {
describe("subtractTimeUnit", () => {
test("subtracts days correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 5, "days");
expect(result.getDate()).toBe(10);
expect(result.getMonth()).toBe(0); // January
});
test("subtracts weeks correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 2, "weeks");
expect(result.getDate()).toBe(1);
});
test("subtracts months correctly", () => {
const date = new Date("2024-03-15T12:00:00Z");
const result = subtractTimeUnit(date, 2, "months");
expect(result.getMonth()).toBe(0); // January
});
test("subtracts years correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = subtractTimeUnit(date, 1, "years");
expect(result.getFullYear()).toBe(2023);
});
test("does not modify original date", () => {
const date = new Date("2024-01-15T12:00:00Z");
const original = date.getTime();
subtractTimeUnit(date, 5, "days");
expect(date.getTime()).toBe(original);
});
});
describe("addTimeUnit", () => {
test("adds days correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 5, "days");
expect(result.getDate()).toBe(20);
});
test("adds weeks correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 2, "weeks");
expect(result.getDate()).toBe(29);
});
test("adds months correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 2, "months");
expect(result.getMonth()).toBe(2); // March
});
test("adds years correctly", () => {
const date = new Date("2024-01-15T12:00:00Z");
const result = addTimeUnit(date, 1, "years");
expect(result.getFullYear()).toBe(2025);
});
});
describe("startOfDay", () => {
test("sets time to 00:00:00.000", () => {
const date = new Date("2024-01-15T14:30:45.123Z");
const result = startOfDay(date);
expect(result.getHours()).toBe(0);
expect(result.getMinutes()).toBe(0);
expect(result.getSeconds()).toBe(0);
expect(result.getMilliseconds()).toBe(0);
expect(result.getDate()).toBe(date.getDate());
});
});
describe("endOfDay", () => {
test("sets time to 23:59:59.999", () => {
const date = new Date("2024-01-15T14:30:45.123Z");
const result = endOfDay(date);
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(59);
expect(result.getSeconds()).toBe(59);
expect(result.getMilliseconds()).toBe(999);
expect(result.getDate()).toBe(date.getDate());
});
});
describe("isSameDay", () => {
test("returns true for dates on the same day", () => {
const date1 = new Date("2024-01-15T10:00:00Z");
const date2 = new Date("2024-01-15T22:00:00Z");
expect(isSameDay(date1, date2)).toBe(true);
});
test("returns false for dates on different days", () => {
const date1 = new Date("2024-01-15T23:59:59Z");
const date2 = new Date("2024-01-16T00:00:01Z");
expect(isSameDay(date1, date2)).toBe(false);
});
test("returns false for dates in different months", () => {
const date1 = new Date("2024-01-31T12:00:00Z");
const date2 = new Date("2024-02-01T12:00:00Z");
expect(isSameDay(date1, date2)).toBe(false);
});
test("returns false for dates in different years", () => {
const date1 = new Date("2023-12-31T12:00:00Z");
const date2 = new Date("2024-01-01T12:00:00Z");
expect(isSameDay(date1, date2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,93 @@
import { TTimeUnit } from "@formbricks/types/segment";
/**
* Subtracts a time unit from a date
* @param date - The date to subtract from
* @param amount - The amount of time units to subtract
* @param unit - The time unit (days, weeks, months, years)
* @returns A new Date object with the time subtracted
*/
export const subtractTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
const result = new Date(date);
switch (unit) {
case "days":
result.setDate(result.getDate() - amount);
break;
case "weeks":
result.setDate(result.getDate() - amount * 7);
break;
case "months":
result.setMonth(result.getMonth() - amount);
break;
case "years":
result.setFullYear(result.getFullYear() - amount);
break;
}
return result;
};
/**
* Adds a time unit to a date
* @param date - The date to add to
* @param amount - The amount of time units to add
* @param unit - The time unit (days, weeks, months, years)
* @returns A new Date object with the time added
*/
export const addTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
const result = new Date(date);
switch (unit) {
case "days":
result.setDate(result.getDate() + amount);
break;
case "weeks":
result.setDate(result.getDate() + amount * 7);
break;
case "months":
result.setMonth(result.getMonth() + amount);
break;
case "years":
result.setFullYear(result.getFullYear() + amount);
break;
}
return result;
};
/**
* Gets the start of a day (00:00:00.000)
* @param date - The date to get the start of
* @returns A new Date object at the start of the day
*/
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
/**
* Gets the end of a day (23:59:59.999)
* @param date - The date to get the end of
* @returns A new Date object at the end of the day
*/
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
/**
* Checks if two dates are on the same day (ignoring time)
* @param date1 - The first date
* @param date2 - The second date
* @returns True if the dates are on the same day
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};

View File

@@ -3,7 +3,9 @@ import { cache as reactCache } from "react";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import {
DATE_OPERATORS,
TBaseFilters,
TDateOperator,
TSegmentAttributeFilter,
TSegmentDeviceFilter,
TSegmentFilter,
@@ -11,6 +13,7 @@ import {
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { endOfDay, startOfDay, subtractTimeUnit } from "../date-utils";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
@@ -18,6 +21,70 @@ export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
};
/**
* Builds a Prisma where clause for date attribute filters
* Since dates are stored as ISO 8601 strings, lexicographic comparison works correctly
*/
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.StringFilter = {};
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.toISOString() };
}
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.toISOString() };
}
break;
}
case "isBefore":
if (typeof value === "string") {
dateCondition = { lt: value };
}
break;
case "isAfter":
if (typeof value === "string") {
dateCondition = { gt: value };
}
break;
case "isBetween":
if (Array.isArray(value) && value.length === 2) {
dateCondition = { gte: value[0], lte: value[1] };
}
break;
case "isSameDay": {
if (typeof value === "string") {
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: contactAttributeKey },
value: dateCondition,
},
},
};
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
@@ -60,6 +127,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":

View File

@@ -10,8 +10,10 @@ import {
ValidationError,
} from "@formbricks/types/errors";
import {
DATE_OPERATORS,
TAllOperators,
TBaseFilters,
TDateOperator,
TEvaluateSegmentUserAttributeData,
TEvaluateSegmentUserData,
TSegment,
@@ -19,6 +21,7 @@ import {
TSegmentConnector,
TSegmentCreateInput,
TSegmentDeviceFilter,
TSegmentFilterValue,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
@@ -29,6 +32,7 @@ import {
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
import { isSameDay, subtractTimeUnit } from "./date-utils";
export type PrismaSegment = Prisma.SegmentGetPayload<{
include: {
@@ -387,6 +391,12 @@ const evaluateAttributeFilter = (
return false;
}
// Check if this is a date operator
if (isDateOperator(qualifier.operator)) {
return evaluateDateFilter(String(attributeValue), value, qualifier.operator);
}
// Use standard comparison for non-date operators
const attResult = compareValues(attributeValue, value, qualifier.operator);
return attResult;
};
@@ -440,6 +450,86 @@ const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDevic
return compareValues(device, value, qualifier.operator);
};
/**
* Checks if an operator is a date-specific operator
*/
const isDateOperator = (operator: TAllOperators): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
/**
* Evaluates a date filter against an attribute value
*/
const evaluateDateFilter = (
attributeValue: string,
filterValue: TSegmentFilterValue,
operator: TDateOperator
): boolean => {
// Parse the attribute value as a date
const attrDate = new Date(attributeValue);
// Validate the attribute value is a valid date
if (isNaN(attrDate.getTime())) {
return false;
}
const now = new Date();
switch (operator) {
case "isOlderThan": {
// filterValue should be { amount, unit }
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate < threshold;
}
return false;
}
case "isNewerThan": {
// filterValue should be { amount, unit }
if (typeof filterValue === "object" && "amount" in filterValue && "unit" in filterValue) {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate >= threshold;
}
return false;
}
case "isBefore": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return attrDate < compareDate;
}
return false;
}
case "isAfter": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return attrDate > compareDate;
}
return false;
}
case "isBetween": {
// filterValue should be a tuple [startDate, endDate]
if (Array.isArray(filterValue) && filterValue.length === 2) {
const startDate = new Date(filterValue[0]);
const endDate = new Date(filterValue[1]);
return attrDate >= startDate && attrDate <= endDate;
}
return false;
}
case "isSameDay": {
// filterValue should be an ISO date string
if (typeof filterValue === "string") {
const compareDate = new Date(filterValue);
return isSameDay(attrDate, compareDate);
}
return false;
}
default:
return false;
}
};
export const compareValues = (
a: string | number | undefined,
b: string | number,

View File

@@ -50,6 +50,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 +97,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;
}

View File

@@ -81,6 +81,12 @@ enum ContactAttributeType {
custom
}
enum ContactAttributeDataType {
text
number
date
}
/// Defines the possible attributes that can be assigned to contacts.
/// Acts as a schema for contact attributes within an environment.
///
@@ -89,17 +95,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 (text, 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(text)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
attributes ContactAttribute[]
attributeFilters SurveyAttributeFilter[]

View File

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

View File

@@ -2,11 +2,17 @@ import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
attributes: Record<string, string | Date>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
// Convert Date objects to ISO strings
const normalizedAttributes: Record<string, string> = {};
for (const [key, value] of Object.entries(attributes)) {
normalizedAttributes[key] = value instanceof Date ? value.toISOString() : value;
}
const updateQueue = UpdateQueue.getInstance();
updateQueue.updateAttributes(attributes);
updateQueue.updateAttributes(normalizedAttributes);
void updateQueue.processUpdates();
return okVoid();
};

View File

@@ -4,6 +4,10 @@ export const ZContactAttributeKeyType = z.enum(["default", "custom"]);
export type TContactAttributeKeyType = z.infer<typeof ZContactAttributeKeyType>;
export const ZContactAttributeDataType = z.enum(["text", "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("text"),
environmentId: z.string(),
});

View File

@@ -37,8 +37,21 @@ export const SEGMENT_OPERATORS = ["userIsIn", "userIsNotIn"] as const;
// operators for device filters
export const DEVICE_OPERATORS = ["equals", "notEquals"] as const;
// operators for date filters
export const DATE_OPERATORS = [
"isOlderThan",
"isNewerThan",
"isBefore",
"isAfter",
"isBetween",
"isSameDay",
] as const;
// time units for relative date operators
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
// all operators
export const ALL_OPERATORS = [...ATTRIBUTE_OPERATORS, ...SEGMENT_OPERATORS] as const;
export const ALL_OPERATORS = [...ATTRIBUTE_OPERATORS, ...SEGMENT_OPERATORS, ...DATE_OPERATORS] as const;
export const ZAttributeOperator = z.enum(ATTRIBUTE_OPERATORS);
export type TAttributeOperator = z.infer<typeof ZAttributeOperator>;
@@ -52,9 +65,27 @@ 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>;
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.