diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts index 9478e2cfbd..79365a8472 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts @@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache( export const createContactAttributeKey = async ( contactAttributeKey: TContactAttributeKeyInput ): Promise> => { - 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({ diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts index 11976ab45c..24ab16c89b 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -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; diff --git a/apps/web/modules/ee/contacts/lib/attributes.ts b/apps/web/modules/ee/contacts/lib/attributes.ts index ba216df064..15711ab27d 100644 --- a/apps/web/modules/ee/contacts/lib/attributes.ts +++ b/apps/web/modules/ee/contacts/lib/attributes.ts @@ -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 }, }, }, - }) - ) + }); + }) ); } } diff --git a/apps/web/modules/ee/contacts/lib/detect-attribute-type.test.ts b/apps/web/modules/ee/contacts/lib/detect-attribute-type.test.ts new file mode 100644 index 0000000000..820527e8f5 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/detect-attribute-type.test.ts @@ -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 + }); +}); diff --git a/apps/web/modules/ee/contacts/lib/detect-attribute-type.ts b/apps/web/modules/ee/contacts/lib/detect-attribute-type.ts new file mode 100644 index 0000000000..e72ac59bbb --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/detect-attribute-type.ts @@ -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"; +}; diff --git a/apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx b/apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx new file mode 100644 index 0000000000..5c3cdbe68a --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx @@ -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 ( +
+ { + 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 }); + }} + /> + + {t("common.ago")} +
+ ); + } + + // Between operator: needs two date inputs + if (operator === "isBetween") { + const betweenValue = Array.isArray(value) && value.length === 2 ? value : ["", ""]; + + return ( +
+ { + const dateValue = e.target.value ? new Date(e.target.value).toISOString() : ""; + onChange([dateValue, betweenValue[1]]); + }} + /> + {t("common.and")} + { + const dateValue = e.target.value ? new Date(e.target.value).toISOString() : ""; + onChange([betweenValue[0], dateValue]); + }} + /> +
+ ); + } + + // Absolute date operators: isBefore, isAfter, isSameDay + // Use a single date picker + const dateValue = typeof value === "string" ? value : ""; + + return ( + { + const dateValue = e.target.value ? new Date(e.target.value).toISOString() : ""; + onChange(dateValue); + }} + /> + ); +} diff --git a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx index 1bed1299c2..b5a639ebbe 100644 --- a/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx +++ b/apps/web/modules/ee/contacts/segments/components/segment-filter.tsx @@ -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({ {!["isSet", "isNotSet"].includes(resource.qualifier.operator) && ( -
- { - if (viewOnly) return; - checkValueAndUpdate(e); - }} - value={resource.value} - /> + <> + {isDateAttribute && DATE_OPERATORS.includes(resource.qualifier.operator as any) ? ( + { + updateValueInLocalSurvey(resource.id, newValue); + }} + viewOnly={viewOnly} + /> + ) : ( +
+ { + if (viewOnly) return; + checkValueAndUpdate(e); + }} + value={resource.value} + /> - {valueError ? ( -

- {valueError} -

- ) : null} -
+ {valueError ? ( +

+ {valueError} +

+ ) : null} +
+ )} + )} { + 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); + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/lib/date-utils.ts b/apps/web/modules/ee/contacts/segments/lib/date-utils.ts new file mode 100644 index 0000000000..472845f9d8 --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/lib/date-utils.ts @@ -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() + ); +}; diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts index ba3ffa4c1e..0a89631116 100644 --- a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts +++ b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts @@ -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": diff --git a/apps/web/modules/ee/contacts/segments/lib/segments.ts b/apps/web/modules/ee/contacts/segments/lib/segments.ts index dc3990226a..dbf4ab0a9c 100644 --- a/apps/web/modules/ee/contacts/segments/lib/segments.ts +++ b/apps/web/modules/ee/contacts/segments/lib/segments.ts @@ -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, diff --git a/apps/web/modules/ee/contacts/segments/lib/utils.ts b/apps/web/modules/ee/contacts/segments/lib/utils.ts index 59cb65dc94..914f8cc124 100644 --- a/apps/web/modules/ee/contacts/segments/lib/utils.ts +++ b/apps/web/modules/ee/contacts/segments/lib/utils.ts @@ -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; } diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index fa4863d1ff..2253e6a3b4 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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[] diff --git a/packages/database/zod/contact-attribute-keys.ts b/packages/database/zod/contact-attribute-keys.ts index 40902f18cf..1db24c1c21 100644 --- a/packages/database/zod/contact-attribute-keys.ts +++ b/packages/database/zod/contact-attribute-keys.ts @@ -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", }), diff --git a/packages/js-core/src/lib/user/attribute.ts b/packages/js-core/src/lib/user/attribute.ts index cdb3c67e2f..b5a6f439a3 100644 --- a/packages/js-core/src/lib/user/attribute.ts +++ b/packages/js-core/src/lib/user/attribute.ts @@ -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 + attributes: Record // eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here ): Promise> => { + // Convert Date objects to ISO strings + const normalizedAttributes: Record = {}; + 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(); }; diff --git a/packages/types/contact-attribute-key.ts b/packages/types/contact-attribute-key.ts index cf40c01ead..e6f09fc57d 100644 --- a/packages/types/contact-attribute-key.ts +++ b/packages/types/contact-attribute-key.ts @@ -4,6 +4,10 @@ export const ZContactAttributeKeyType = z.enum(["default", "custom"]); export type TContactAttributeKeyType = z.infer; +export const ZContactAttributeDataType = z.enum(["text", "number", "date"]); + +export type TContactAttributeDataType = z.infer; + 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(), }); diff --git a/packages/types/segment.ts b/packages/types/segment.ts index ce6d6c077a..3b9071104f 100644 --- a/packages/types/segment.ts +++ b/packages/types/segment.ts @@ -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; @@ -52,9 +65,27 @@ export type TSegmentOperator = z.infer; export const ZDeviceOperator = z.enum(DEVICE_OPERATORS); export type TDeviceOperator = z.infer; +export const ZDateOperator = z.enum(DATE_OPERATORS); +export type TDateOperator = z.infer; + +export const ZTimeUnit = z.enum(TIME_UNITS); +export type TTimeUnit = z.infer; + 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; + +export const ZSegmentFilterValue = z.union([ + z.string(), + z.number(), + ZRelativeDateValue, + z.tuple([z.string(), z.string()]), // for "isBetween" operator +]); export type TSegmentFilterValue = z.infer; // Each filter has a qualifier, which usually contains the operator for evaluating the filter.