mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-10 15:50:42 -06:00
vibe draft
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
28
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal file
28
apps/web/modules/ee/contacts/lib/detect-attribute-type.ts
Normal 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";
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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,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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user