Compare commits

...

1 Commits

Author SHA1 Message Date
Johannes
403d837430 feat: enhance contact attributes and refactor date picker
- Contact Attributes: Fixed TypeScript errors in attribute actions by strictly validating organizationId and projectId existence.
- Date Picker: Refactored DatePicker component to use native HTML input, removing the react-calendar dependency for reduced complexity and better performance.
- General Fixes: Resolved various TypeScript errors and missing imports across contact management and segment filtering modules.
- New Features: Added initial structure for contact attribute management.
2025-12-14 08:26:15 +01:00
37 changed files with 2110 additions and 209 deletions

View File

@@ -0,0 +1,29 @@
import { getTranslate } from "@/lingodotdev/server";
import { AttributeKeysManager } from "@/modules/ee/contacts/attributes/attribute-keys-manager";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export default async function AttributeKeysPage({
params: paramsProps,
}: {
params: Promise<{ environmentId: string }>;
}) {
const params = await paramsProps;
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const attributeKeys = await getContactAttributeKeys(params.environmentId);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.contacts")}>
<ContactsSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
</PageHeader>
<AttributeKeysManager environmentId={environment.id} attributeKeys={attributeKeys} />
</PageContentWrapper>
);
}

View File

@@ -1,13 +1,121 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
const ZUpdateContactAttributeAction = z.object({
contactId: ZId,
attributes: ZContactAttributes,
});
export const updateContactAttributeAction = authenticatedActionClient
.schema(ZUpdateContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
const contact = await prisma.contact.findUnique({
where: { id: parsedInput.contactId },
select: { environmentId: true },
});
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const result = await updateAttributes(
parsedInput.contactId,
ctx.user.id,
contact.environmentId,
parsedInput.attributes
);
return result;
});
const ZDeleteContactAttributeAction = z.object({
contactId: ZId,
attributeKey: z.string(),
});
export const deleteContactAttributeAction = authenticatedActionClient
.schema(ZDeleteContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
const contact = await prisma.contact.findUnique({
where: { id: parsedInput.contactId },
select: { environmentId: true },
});
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Find the attribute key
const attributeKey = await prisma.contactAttributeKey.findFirst({
where: {
key: parsedInput.attributeKey,
environmentId: contact.environmentId,
},
});
if (!attributeKey) {
// If key doesn't exist, nothing to delete.
return { success: true };
}
await prisma.contactAttribute.deleteMany({
where: {
contactId: parsedInput.contactId,
attributeKeyId: attributeKey.id,
},
});
return { success: true };
});
const ZGeneratePersonalSurveyLinkAction = z.object({
contactId: ZId,
surveyId: ZId,

View File

@@ -1,10 +1,17 @@
import { format } from "date-fns";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getResponsesByContactId } from "@/lib/response/service";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
interface AttributesSectionProps {
contactId: string;
attributeKeys: TContactAttributeKey[];
}
export const AttributesSection = async ({ contactId, attributeKeys }: AttributesSectionProps) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
@@ -22,7 +29,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
<span>{attributes.email as string}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -32,7 +39,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
<span>{attributes.language as string}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -42,7 +49,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
<IdBadge id={attributes.userId as string} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -56,10 +63,26 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
const attributeKey = attributeKeys.find((ak) => ak.key === key);
let displayValue = attributeData;
if (attributeKey?.dataType === "date" && displayValue) {
try {
// assume attributeData is string ISO date or Date object
displayValue = format(new Date(displayValue as string | number | Date), "do 'of' MMMM, yyyy");
} catch (e) {
// fallback
}
}
if (displayValue instanceof Date) {
displayValue = displayValue.toLocaleDateString();
}
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
<dt className="text-sm font-medium text-slate-500">{attributeKey?.name ?? key}</dt>
<dd className="mt-1 text-sm text-slate-900">{displayValue}</dd>
</div>
);
})}

View File

@@ -1,15 +1,18 @@
"use client";
import { LinkIcon, TrashIcon } from "lucide-react";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditAttributesModal } from "./edit-attributes-modal";
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
interface ContactControlBarProps {
@@ -18,6 +21,8 @@ interface ContactControlBarProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
publishedLinkSurveys: PublishedLinkSurvey[];
attributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const ContactControlBar = ({
@@ -26,12 +31,15 @@ export const ContactControlBar = ({
isReadOnly,
isQuotasAllowed,
publishedLinkSurveys,
attributes,
attributeKeys,
}: ContactControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
const handleDeletePerson = async () => {
setIsDeletingPerson(true);
@@ -61,6 +69,14 @@ export const ContactControlBar = ({
},
isVisible: true,
},
{
icon: PencilIcon,
tooltip: t("common.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
@@ -94,6 +110,13 @@ export const ContactControlBar = ({
contactId={contactId}
publishedLinkSurveys={publishedLinkSurveys}
/>
<EditAttributesModal
open={isEditAttributesModalOpen}
setOpen={setIsEditAttributesModalOpen}
contactId={contactId}
attributes={attributes}
attributeKeys={attributeKeys}
/>
</>
);
};

View File

@@ -0,0 +1,254 @@
"use client";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { deleteContactAttributeAction, updateContactAttributeAction } from "../actions";
interface EditAttributesModalProps {
open: boolean;
setOpen: (open: boolean) => void;
contactId: string;
attributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const EditAttributesModal = ({
open,
setOpen,
contactId,
attributes,
attributeKeys,
}: EditAttributesModalProps) => {
const router = useRouter();
const { t } = useTranslation();
// Local state for editing. explicit key-value pairs array for easier rendering
const [localAttributes, setLocalAttributes] = useState<
{ key: string; value: string | number | Date; isNew?: boolean }[]
>(
Object.entries(attributes)
.filter(([key]) => key !== "email" && key !== "userId" && key !== "language") // exclude standard/read-only attributes from generic editor?
.map(([key, value]) => ({ key, value }))
);
const [isSaving, setIsSaving] = useState(false);
// Deletion state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [attributeToDelete, setAttributeToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const availableKeys = useMemo(() => {
// Filter out keys that are already used (except for the one being edited if we supported key change, but we lock key for existing)
// Actually for new rows we want to show unused keys.
const usedKeys = new Set(localAttributes.map((a) => a.key));
return attributeKeys.filter((ak) => !usedKeys.has(ak.key));
}, [attributeKeys, localAttributes]);
const handleUpdateAttribute = (index: number, field: "key" | "value", newValue: any) => {
const updated = [...localAttributes];
updated[index] = { ...updated[index], [field]: newValue };
setLocalAttributes(updated);
};
const handleAddAttribute = () => {
setLocalAttributes([...localAttributes, { key: "", value: "", isNew: true }]);
};
const handleRemoveAttribute = (index: number) => {
const attribute = localAttributes[index];
if (attribute.isNew) {
// Just remove from state
const updated = [...localAttributes];
updated.splice(index, 1);
setLocalAttributes(updated);
} else {
// Trigger delete confirmation for existing attributes
setAttributeToDelete(attribute.key);
setDeleteDialogOpen(true);
}
};
const confirmDelete = async () => {
if (!attributeToDelete) return;
setIsDeleting(true);
const result = await deleteContactAttributeAction({ contactId, attributeKey: attributeToDelete });
setIsDeleting(false);
setDeleteDialogOpen(false);
if (result?.data?.success) {
toast.success("Attribute deleted successfully");
setLocalAttributes(localAttributes.filter((a) => a.key !== attributeToDelete));
setAttributeToDelete(null);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
const handleSave = async () => {
setIsSaving(true);
// Convert array back to record
const attributesRecord: TContactAttributes = {};
for (const attr of localAttributes) {
if (attr.key && attr.value !== "") {
attributesRecord[attr.key] = attr.value;
}
}
const result = await updateContactAttributeAction({
contactId,
attributes: attributesRecord,
});
setIsSaving(false);
if (result?.data?.success) {
toast.success("Attributes updated successfully");
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
// Helper to determine input type based on key
const getInputType = (key: string) => {
const attributeKey = attributeKeys.find((ak) => ak.key === key);
return attributeKey?.dataType ?? "text";
};
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl bg-white p-6">
<DialogHeader>
<DialogTitle>{t("common.edit_attributes")}</DialogTitle>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto pr-2">
<div className="space-y-4">
{localAttributes.length === 0 && (
<p className="text-sm italic text-slate-500">No custom attributes found.</p>
)}
{localAttributes.map((attr, index) => {
const dataType = getInputType(attr.key);
return (
<div key={index} className="flex items-start gap-3">
<div className="flex-1">
<Label className="mb-1 block text-xs">Key</Label>
{attr.isNew ? (
<div className="relative">
<Input
value={attr.key}
onChange={(e) => handleUpdateAttribute(index, "key", e.target.value)}
placeholder="Attribute Key"
list={`keys-${index}`}
/>
{/* Simple datalist for suggestion */}
<datalist id={`keys-${index}`}>
{availableKeys.map((ak) => (
<option key={ak.id} value={ak.key} />
))}
</datalist>
</div>
) : (
<Input value={attr.key} disabled className="bg-slate-50" />
)}
</div>
<div className="flex-1">
<Label className="mb-1 flex items-center gap-2 text-xs">
Value
<AttributeIcon dataType={dataType} className="h-3 w-3 text-slate-400" />
</Label>
{dataType === "date" ? (
<DatePicker
date={
attr.value instanceof Date
? attr.value
: attr.value
? new Date(attr.value as string)
: null
}
updateSurveyDate={(date) => handleUpdateAttribute(index, "value", date ?? "")}
/>
) : (
<Input
type={dataType === "number" ? "number" : "text"}
value={attr.value instanceof Date ? "" : attr.value}
onChange={(e) => {
const val = e.target.value;
if (dataType === "number") {
handleUpdateAttribute(index, "value", val === "" ? "" : Number(val));
} else {
handleUpdateAttribute(index, "value", val);
}
}}
/>
)}
</div>
<div className="mt-6">
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveAttribute(index)}
type="button">
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-500" />
</Button>
</div>
</div>
);
})}
<Button variant="outline" size="sm" onClick={handleAddAttribute} className="mt-2">
<PlusIcon className="mr-2 h-4 w-4" />
Add Attribute
</Button>
</div>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t("common.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="attribute"
onDelete={confirmDelete}
isDeleting={isDeleting}
text={`Are you sure you want to delete the attribute "${attributeToDelete}"? This action cannot be undone.`}
/>
</>
);
};

View File

@@ -2,6 +2,7 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
@@ -21,12 +22,14 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
]);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, attributeKeys] =
await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -42,6 +45,8 @@ export const SingleContactPage = async (props: {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
publishedLinkSurveys={publishedLinkSurveys}
attributes={contactAttributes}
attributeKeys={attributeKeys}
/>
);
};
@@ -52,7 +57,7 @@ export const SingleContactPage = async (props: {
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} />
<AttributesSection contactId={params.contactId} attributeKeys={attributeKeys} />
<ResponseSection
environment={environment}
contactId={params.contactId}

View File

@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -12,6 +13,7 @@ import {
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createContactAttributeKey, updateContactAttributeKey } from "./lib/contact-attribute-keys";
import { createContactsFromCSV, deleteContact, getContacts } from "./lib/contacts";
import {
ZContactCSVAttributeMap,
@@ -129,3 +131,71 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
}
)
);
const ZUpdateAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
name: z.string(),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
export const updateAttributeKeyAction = authenticatedActionClient
.schema(ZUpdateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await updateContactAttributeKey(parsedInput.environmentId, parsedInput.id, {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
});
});
const ZCreateAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string(),
name: z.string(),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
export const createAttributeKeyAction = authenticatedActionClient
.schema(ZCreateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await createContactAttributeKey(parsedInput.environmentId, parsedInput.key, "custom", {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
});
});

View File

@@ -0,0 +1,185 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType, ZContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
// I need proper helpers for auth. usually checkAuthorizationUpdated wrapper handles project/environment checks via getters?
// Or I manually fetch.
const ZCreateAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
type: ZContactAttributeKeyType.optional(), // custom usually
dataType: ZContactAttributeDataType.optional(),
});
export const createAttributeKeyAction = authenticatedActionClient
.schema(ZCreateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Environment", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
// Auth check
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const existingKey = await prisma.contactAttributeKey.findFirst({
where: {
environmentId: parsedInput.environmentId,
key: parsedInput.key,
},
});
if (existingKey) {
throw new Error("Attribute key already exists");
}
const attributeKey = await prisma.contactAttributeKey.create({
data: {
environmentId: parsedInput.environmentId,
key: parsedInput.key,
name: parsedInput.name,
description: parsedInput.description,
type: parsedInput.type ?? "custom",
dataType: parsedInput.dataType ?? "text",
},
});
return attributeKey;
});
const ZUpdateAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(), // allowing update?
});
export const updateAttributeKeyAction = authenticatedActionClient
.schema(ZUpdateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
// Auth check
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// check if data type update is safe?
// For now trusting the user or UI to warn.
const attributeKey = await prisma.contactAttributeKey.update({
where: {
id: parsedInput.id,
},
data: {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
},
});
return attributeKey;
});
const ZDeleteAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
});
export const deleteAttributeKeyAction = authenticatedActionClient
.schema(ZDeleteAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
// Auth check
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
await prisma.contactAttributeKey.delete({
where: {
id: parsedInput.id,
},
});
return { success: true };
});
// Helpers (mocked or should be imported from somewhere)
// I need `getOrganizationIdByEnvironmentId` and `getProjectIdByEnvironmentId`.
// Usually in `@/lib/project/service` or similar. I'll need to confirm imports.
// `getProjectIdByEnvironmentId` is usually available.
// `getOrganizationIdByEnvironmentId` - I might need to fetch environment then project then org.
// Or helper exists.
async function getOrganizationIdByEnvironmentId(environmentId: string) {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: { project: { select: { organizationId: true } } },
});
return environment?.project.organizationId;
}
async function getProjectIdByEnvironmentId(environmentId: string) {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: { projectId: true },
});
return environment?.projectId;
}

View File

@@ -0,0 +1,99 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { deleteAttributeKeyAction } from "./actions";
// updated imports
import { AttributeKeysTable } from "./attribute-keys-table";
import { CreateAttributeKeyModal } from "./create-attribute-key-modal";
import { EditAttributeKeyModal } from "./edit-attribute-key-modal";
interface AttributeKeysManagerProps {
environmentId: string;
attributeKeys: TContactAttributeKey[];
}
export const AttributeKeysManager = ({ environmentId, attributeKeys }: AttributeKeysManagerProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingKey, setEditingKey] = useState<TContactAttributeKey | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [deletingKey, setDeletingKey] = useState<TContactAttributeKey | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleEdit = (key: TContactAttributeKey) => {
setEditingKey(key);
setIsEditOpen(true);
};
const handleDelete = (key: TContactAttributeKey) => {
setDeletingKey(key);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deletingKey) return;
setIsDeleting(true);
const result = await deleteAttributeKeyAction({ id: deletingKey.id, environmentId });
setIsDeleting(false);
if (result?.data?.success) {
toast.success("Attribute key deleted successfully");
setDeletingKey(null);
setIsDeleteOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
setIsDeleteOpen(false); // Close anyway?
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
{t("environments.contacts.attributes.title")}
</h2>
<p className="text-sm text-slate-500">{t("environments.contacts.attributes.description")}</p>
</div>
<Button onClick={() => setIsCreateOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.contacts.attributes.create_attribute_key")}
</Button>
</div>
<AttributeKeysTable attributeKeys={attributeKeys} onEdit={handleEdit} onDelete={handleDelete} />
<CreateAttributeKeyModal open={isCreateOpen} setOpen={setIsCreateOpen} environmentId={environmentId} />
<EditAttributeKeyModal
open={isEditOpen}
setOpen={setIsEditOpen}
environmentId={environmentId}
attributeKey={editingKey}
/>
<DeleteDialog
open={isDeleteOpen}
setOpen={setIsDeleteOpen}
deleteWhat="attribute key"
onDelete={confirmDelete}
isDeleting={isDeleting}
text={`Are you sure you want to delete the attribute key "${deletingKey?.key}"? This will delete all data associated with this key.`}
/>
</div>
);
};

View File

@@ -0,0 +1,89 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Edit2Icon, Trash2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
import { Button } from "@/modules/ui/components/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
interface AttributeKeysTableProps {
attributeKeys: TContactAttributeKey[];
onEdit: (key: TContactAttributeKey) => void;
onDelete: (key: TContactAttributeKey) => void;
isDeleting?: boolean;
}
export const AttributeKeysTable = ({
attributeKeys,
onEdit,
onDelete,
isDeleting,
}: AttributeKeysTableProps) => {
const { t } = useTranslation();
if (attributeKeys.length === 0) {
return (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50">
<p className="text-slate-500">{t("environments.contacts.attributes.no_keys_found")}</p>
</div>
);
}
return (
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("common.name")} / {t("common.key")}
</TableHead>
<TableHead>{t("common.description")}</TableHead>
<TableHead>{t("common.type")}</TableHead>
<TableHead>{t("common.last_updated")}</TableHead>
<TableHead className="text-right">{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attributeKeys.map((attributeKey) => (
<TableRow key={attributeKey.key}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-slate-900">{attributeKey.name}</span>
<span className="font-mono text-xs text-slate-500">{attributeKey.key}</span>
</div>
</TableCell>
<TableCell className="max-w-[200px] truncate text-slate-500">
{attributeKey.description || "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4 text-slate-500" />
<span className="capitalize text-slate-700">{attributeKey.dataType}</span>
</div>
</TableCell>
<TableCell className="text-slate-500">
{formatDistanceToNow(new Date(attributeKey.updatedAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" onClick={() => onEdit(attributeKey)}>
<Edit2Icon className="h-4 w-4 text-slate-500" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(attributeKey)}
disabled={isDeleting}>
<Trash2Icon className="h-4 w-4 text-slate-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import {
TContactAttributeDataType,
ZContactAttributeDataType,
} from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { createAttributeKeyAction } from "../actions";
const ZCreateAttributeKeyForm = z.object({
key: z.string().min(1, "Key is required"),
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
type TCreateAttributeKeyForm = z.infer<typeof ZCreateAttributeKeyForm>;
interface CreateAttributeKeyModalProps {
open: boolean;
setOpen: (open: boolean) => void;
environmentId: string;
}
export const CreateAttributeKeyModal = ({ open, setOpen, environmentId }: CreateAttributeKeyModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting, errors },
} = useForm<TCreateAttributeKeyForm>({
resolver: zodResolver(ZCreateAttributeKeyForm),
defaultValues: {
key: "",
name: "",
description: "",
dataType: "text",
},
});
const dataType = watch("dataType");
const onSubmit = async (data: TCreateAttributeKeyForm) => {
const result = await createAttributeKeyAction({
environmentId,
key: data.key,
name: data.name,
description: data.description,
dataType: data.dataType,
});
if (result?.data) {
toast.success("Attribute key created successfully");
reset();
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Attribute Key</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key">Key</Label>
<Input id="key" {...register("key")} placeholder="e.g. date_of_birth" />
{errors.key && <p className="text-sm text-red-500">{errors.key.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} placeholder="e.g. Date of Birth" />
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<textarea
id="description"
{...register("description")}
placeholder="Short description"
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dataType">Data Type</Label>
<Select
value={dataType}
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
Create Key
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,166 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import {
TContactAttributeDataType,
TContactAttributeKey,
ZContactAttributeDataType,
} from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { updateAttributeKeyAction } from "../actions";
const ZEditAttributeKeyForm = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
type TEditAttributeKeyForm = z.infer<typeof ZEditAttributeKeyForm>;
interface EditAttributeKeyModalProps {
open: boolean;
setOpen: (open: boolean) => void;
environmentId: string;
attributeKey: TContactAttributeKey | null;
}
export const EditAttributeKeyModal = ({
open,
setOpen,
environmentId,
attributeKey,
}: EditAttributeKeyModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting, errors },
} = useForm<TEditAttributeKeyForm>({
resolver: zodResolver(ZEditAttributeKeyForm),
defaultValues: {
name: "",
description: "",
dataType: "text",
},
});
useEffect(() => {
if (attributeKey) {
reset({
name: attributeKey.name ?? "",
description: attributeKey.description ?? "",
dataType: attributeKey.dataType ?? "text",
});
}
}, [attributeKey, reset]);
const dataType = watch("dataType");
const onSubmit = async (data: TEditAttributeKeyForm) => {
if (!attributeKey) return;
const result = await updateAttributeKeyAction({
id: attributeKey.id,
environmentId,
name: data.name,
description: data.description,
dataType: data.dataType,
});
if (result?.data) {
toast.success("Attribute key updated successfully");
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Attribute Key</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-edit">Key</Label>
<Input id="key-edit" value={attributeKey?.key ?? ""} disabled className="bg-slate-50" />
<p className="text-xs text-slate-500">The key cannot be changed once created.</p>
</div>
<div className="space-y-2">
<Label htmlFor="name-edit">Name</Label>
<Input id="name-edit" {...register("name")} placeholder="e.g. Date of Birth" />
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description-edit">Description (Optional)</Label>
<textarea
id="description-edit"
{...register("description")}
placeholder="Short description"
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dataType-edit">Data Type</Label>
<Select
value={dataType}
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
<SelectTrigger id="dataType-edit">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Changing data type may affect existing data.</p>
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
Save Changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -35,6 +35,11 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

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 { detectAttributeType } from "@/modules/ee/contacts/lib/detect-attribute-type";
export const updateAttributes = async (
contactId: string,
@@ -24,7 +25,7 @@ export const updateAttributes = async (
// Fetch contact attribute keys and email check in parallel
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributeKeys(environmentId),
contactAttributesParam.email
contactAttributesParam.email && typeof contactAttributesParam.email === "string"
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
]);
@@ -42,6 +43,14 @@ export const updateAttributes = async (
(acc, [key, value]) => {
const attributeKey = contactAttributeKeyMap.get(key);
if (attributeKey) {
// Validate type
if (attributeKey.dataType === "number") {
const num = Number(value);
if (isNaN(num)) return acc; // Skip invalid number
} else if (attributeKey.dataType === "date") {
const date = new Date(value as string | number | Date);
if (isNaN(date.getTime())) return acc; // Skip invalid date
}
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
} else {
acc.newAttributes.push({ key, value });
@@ -49,8 +58,8 @@ export const updateAttributes = async (
return acc;
},
{ existingAttributes: [], newAttributes: [] } as {
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
newAttributes: { key: string; value: string }[];
existingAttributes: { key: string; value: string | number | Date; attributeKeyId: string }[];
newAttributes: { key: string; value: string | number | Date }[];
}
);
@@ -65,22 +74,29 @@ export const updateAttributes = async (
// First, update all existing attributes
if (existingAttributes.length > 0) {
await prisma.$transaction(
existingAttributes.map(({ attributeKeyId, value }) =>
prisma.contactAttribute.upsert({
existingAttributes.map(({ attributeKeyId, value }) => {
let stringValue = value;
if (value instanceof Date) {
stringValue = value.toISOString();
} else {
stringValue = String(value);
}
return prisma.contactAttribute.upsert({
where: {
contactId_attributeKeyId: {
contactId,
attributeKeyId,
},
},
update: { value },
update: { value: stringValue },
create: {
contactId,
attributeKeyId,
value,
value: stringValue,
},
})
)
});
})
);
}
@@ -96,18 +112,26 @@ export const updateAttributes = async (
} else {
// Create new attributes since we're under the limit
await prisma.$transaction(
newAttributes.map(({ key, value }) =>
prisma.contactAttributeKey.create({
newAttributes.map(({ key, value }) => {
let stringValue = value;
if (value instanceof Date) {
stringValue = value.toISOString();
} else {
stringValue = String(value);
}
return prisma.contactAttributeKey.create({
data: {
key,
type: "custom",
dataType: detectAttributeType(value),
environment: { connect: { id: environmentId } },
attributes: {
create: { contactId, value },
create: { contactId, value: stringValue },
},
},
})
)
});
})
);
}
}

View File

@@ -1,6 +1,6 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeys = reactCache(
async (environmentId: string): Promise<TContactAttributeKey[]> => {
@@ -9,3 +9,41 @@ export const getContactAttributeKeys = reactCache(
});
}
);
export const updateContactAttributeKey = async (
environmentId: string,
keyId: string,
data: {
name: string;
description?: string;
dataType: TContactAttributeDataType;
}
): Promise<TContactAttributeKey> => {
return await prisma.contactAttributeKey.update({
where: {
id: keyId,
environmentId,
},
data,
});
};
export const createContactAttributeKey = async (
environmentId: string,
key: string,
type: "default" | "custom",
data: {
name: string;
description?: string;
dataType: TContactAttributeDataType;
}
): Promise<TContactAttributeKey> => {
return await prisma.contactAttributeKey.create({
data: {
key,
type,
environmentId,
...data,
},
});
};

View File

@@ -0,0 +1,36 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
export const detectAttributeType = (value: string | number | Date): TContactAttributeDataType => {
// if the value is a number, return "number"
if (typeof value === "number") {
return "number";
}
// if the value is a string and looks like a number, return "number"
if (typeof value === "string") {
const trimmedValue = value.trim();
if (trimmedValue !== "" && !isNaN(Number(trimmedValue))) {
return "number";
}
}
// if the value is a date, return "date"
if (value instanceof Date) {
return "date";
}
// if the value is a string and looks like a date, return "date"
if (typeof value === "string") {
// Check if it starts with YYYY-MM-DD (ISO 8601 partial match is enough for our needs)
// we want to avoid treating arbitrary strings as dates even if Date.parse accepts them
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return "date";
}
}
}
// otherwise, return "text"
return "text";
};

View File

@@ -5,6 +5,7 @@ import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "luc
import React, { type JSX, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import type {
TBaseFilter,
TSegment,
@@ -15,6 +16,7 @@ import { cn } from "@/lib/cn";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { AttributeIcon } from "./attribute-icon";
import AttributeTabContent from "./attribute-tab-content";
import FilterButton from "./filter-button";
@@ -35,6 +37,7 @@ export const handleAddFilter = ({
contactAttributeKey,
deviceType,
segmentId,
dataType,
}: {
type: TFilterType;
onAddFilter: (filter: TBaseFilter) => void;
@@ -42,10 +45,22 @@ export const handleAddFilter = ({
contactAttributeKey?: string;
segmentId?: string;
deviceType?: string;
dataType?: TContactAttributeDataType;
}): void => {
if (type === "attribute") {
if (!contactAttributeKey) return;
let operator: string = "equals";
let value: any = "";
if (dataType === "date") {
operator = "isOlderThan";
value = { amount: 7, unit: "days" };
} else if (dataType === "number") {
operator = "equals";
value = 0;
}
const newFilterResource: TSegmentAttributeFilter = {
id: createId(),
root: {
@@ -53,9 +68,9 @@ export const handleAddFilter = ({
contactAttributeKey,
},
qualifier: {
operator: "equals",
operator: operator as any,
},
value: "",
value,
};
const newFilter: TBaseFilter = {
id: createId(),
@@ -239,7 +254,7 @@ export function AddFilterModal({
<FilterButton
key={attributeKey.id}
data-testid={`filter-btn-attribute-${attributeKey.key}`}
icon={<TagIcon className="h-4 w-4" />}
icon={<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4" />}
label={attributeKey.name ?? attributeKey.key}
onClick={() => {
handleAddFilter({
@@ -247,6 +262,7 @@ export function AddFilterModal({
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
dataType: attributeKey.dataType,
});
}}
onKeyDown={(e) => {
@@ -257,6 +273,7 @@ export function AddFilterModal({
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
dataType: attributeKey.dataType,
});
}
}}

View File

@@ -0,0 +1,19 @@
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
interface AttributeIconProps {
dataType?: TContactAttributeDataType;
className?: string;
}
export const AttributeIcon = ({ dataType, className }: AttributeIconProps) => {
switch (dataType) {
case "date":
return <CalendarIcon className={className} />;
case "number":
return <HashIcon className={className} />;
case "text":
default:
return <TagIcon className={className} />;
}
};

View File

@@ -0,0 +1,112 @@
import { CalendarIcon } from "lucide-react";
import { TTimeUnit } from "@formbricks/types/segment";
import { DatePicker } from "@/modules/ui/components/date-picker";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface DateFilterValueProps {
filterId: string;
operator: string;
value: string | number | { amount: number; unit: TTimeUnit } | [string, string];
onChange: (value: string | number | { amount: number; unit: TTimeUnit } | [string, string]) => void;
}
export const DateFilterValue = ({ operator, value, onChange }: DateFilterValueProps) => {
// Handle relative operators (isOlderThan, isNewerThan)
if (operator === "isOlderThan" || operator === "isNewerThan") {
const relativeValue =
typeof value === "object" && !Array.isArray(value)
? (value as { amount: number; unit: TTimeUnit })
: { amount: 1, unit: "days" as TTimeUnit };
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={relativeValue.amount}
onChange={(e) =>
onChange({
...relativeValue,
amount: parseInt(e.target.value) || 0,
})
}
className="w-20 bg-white"
/>
<Select
value={relativeValue.unit}
onValueChange={(unit) =>
onChange({
...relativeValue,
unit: unit as TTimeUnit,
})
}>
<SelectTrigger className="w-32 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">days</SelectItem>
<SelectItem value="weeks">weeks</SelectItem>
<SelectItem value="months">months</SelectItem>
<SelectItem value="years">years</SelectItem>
</SelectContent>
</Select>
</div>
);
}
// Handle isBetween (Range)
if (operator === "isBetween") {
const rangeValue = Array.isArray(value) ? (value as [string, string]) : ["", ""];
const [startDate, endDate] = rangeValue;
return (
<div className="flex items-center gap-2">
<div className="relative w-full">
<DatePicker
date={startDate ? new Date(startDate) : null}
updateSurveyDate={(date) => {
if (date) {
onChange([date.toISOString(), endDate]);
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
<span className="text-sm text-slate-500">and</span>
<div className="relative w-full">
<DatePicker
date={endDate ? new Date(endDate) : null}
updateSurveyDate={(date) => {
if (date) {
onChange([startDate, date.toISOString()]);
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
</div>
);
}
// Handle absolute operators (isBefore, isAfter, isSameDay)
return (
<div className="relative w-full">
<DatePicker
date={typeof value === "string" && value ? new Date(value) : null}
updateSurveyDate={(date) => {
if (date) {
onChange(date.toISOString());
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
);
};

View File

@@ -6,7 +6,6 @@ import {
FingerprintIcon,
MonitorSmartphoneIcon,
MoreVertical,
TagIcon,
Trash2,
Users2Icon,
} from "lucide-react";
@@ -14,11 +13,16 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type {
import {
ARITHMETIC_OPERATORS,
DATE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
TArithmeticOperator,
TAttributeOperator,
TBaseFilter,
TDeviceOperator,
TEXT_ATTRIBUTE_OPERATORS,
TSegment,
TSegmentAttributeFilter,
TSegmentConnector,
@@ -29,14 +33,9 @@ import type {
TSegmentPersonFilter,
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import {
ARITHMETIC_OPERATORS,
ATTRIBUTE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { DateFilterValue } from "@/modules/ee/contacts/segments/components/date-filter-value";
import {
convertOperatorToText,
convertOperatorToTitle,
@@ -64,6 +63,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
import { AttributeIcon } from "./attribute-icon";
interface TSegmentFilterProps {
connector: TSegmentConnector;
@@ -224,6 +224,10 @@ function AttributeSegmentFilter({
const [valueError, setValueError] = useState("");
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
const dataType = attributeKey?.dataType ?? "text";
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
// when the operator changes, we need to check if the value is valid
useEffect(() => {
const { operator } = resource.qualifier;
@@ -239,17 +243,27 @@ function AttributeSegmentFilter({
}
}, [resource.qualifier, resource.value, t]);
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
const getAvailableOperators = () => {
switch (dataType) {
case "number":
return [...ARITHMETIC_OPERATORS, "isSet", "isNotSet"] as const;
case "date":
return [...DATE_OPERATORS, "isSet", "isNotSet"] as const;
default:
// Text is default
return TEXT_ATTRIBUTE_OPERATORS;
}
};
const availableOperators = getAvailableOperators();
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) {
@@ -279,7 +293,7 @@ function AttributeSegmentFilter({
const { operator } = resource.qualifier;
if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
if (dataType === "number" && ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
const isNumber = z.coerce.number().safeParse(value);
if (isNumber.success) {
@@ -319,7 +333,7 @@ function AttributeSegmentFilter({
hideArrow>
<SelectValue>
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-sm" />
<AttributeIcon dataType={dataType} className="h-4 w-4 text-sm" />
<p>{attrKeyValue}</p>
</div>
</SelectValue>
@@ -355,25 +369,37 @@ function AttributeSegmentFilter({
</SelectContent>
</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);
}}
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) &&
(dataType === "date" ? (
<DateFilterValue
filterId={resource.id}
operator={resource.qualifier.operator}
value={resource.value}
onChange={(newValue) => updateValueInLocalSurvey(resource.id, newValue)}
/>
) : (
<div className="relative flex flex-col gap-1">
<Input
className={cn(
"h-9 w-auto bg-white",
valueError && "border border-red-500 focus:border-red-500"
)}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value as any}
type={dataType === "number" ? "number" : "text"}
/>
{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
filterId={resource.id}
@@ -544,7 +570,7 @@ function PersonSegmentFilter({
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value}
value={resource.value as any}
/>
{valueError ? (

View File

@@ -0,0 +1,63 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
export const segmentTableColumns: ColumnDef<TSegmentWithSurveyNames>[] = [
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => {
const segment = row.original;
return (
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<UsersIcon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{segment.title}</div>
{segment.description && (
<div className="ph-no-capture max-w-[300px] truncate text-xs font-medium text-slate-500">
{segment.description}
</div>
)}
</div>
</div>
);
},
},
{
accessorKey: "surveys",
header: "Surveys",
cell: ({ row }) => {
// segments table data row had this hidden on small screens
return <div className="text-center text-slate-900">{row.original.surveys?.length ?? 0}</div>;
},
},
{
accessorKey: "updatedAt",
header: "Updated",
cell: ({ row }) => {
return (
<div className="text-center text-slate-900">
{formatDistanceToNow(row.original.updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
);
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<div className="text-center text-slate-900">
{format(row.original.createdAt, "do 'of' MMMM, yyyy")}
</div>
);
},
},
];

View File

@@ -0,0 +1,95 @@
"use client";
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { segmentTableColumns } from "./segment-table-columns";
interface SegmentsDataTableProps {
segments: TSegmentWithSurveyNames[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
export const SegmentsDataTable = ({
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentsDataTableProps) => {
const { t } = useTranslation();
const [selectedSegment, setSelectedSegment] = useState<TSegmentWithSurveyNames | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const table = useReactTable({
data: segments,
columns: segmentTableColumns,
getCoreRowModel: getCoreRowModel(),
});
const handleRowClick = (segment: TSegmentWithSurveyNames) => {
setSelectedSegment(segment);
setIsEditModalOpen(true);
};
return (
<div className="rounded-xl border border-slate-200 bg-white">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell key={header.id} className="font-semibold text-slate-900">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleRowClick(row.original)}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{segments.length === 0 && (
<TableRow>
<TableCell
colSpan={segmentTableColumns.length}
className="py-6 text-center text-sm text-slate-400">
{t("environments.segments.create_your_first_segment")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{selectedSegment && (
<EditSegmentModal
environmentId={selectedSegment.environmentId}
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
currentSegment={selectedSegment}
contactAttributeKeys={contactAttributeKeys}
segments={segments as TSegment[]} // types might slightly differ if WithSurveyNames extends TSegment
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,59 @@
import { TTimeUnit } from "@formbricks/types/segment";
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;
};
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;
};
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};

View File

@@ -3,19 +3,76 @@ 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,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TTimeUnit,
} from "@formbricks/types/segment";
import { endOfDay, startOfDay, subtractTimeUnit } from "@/modules/ee/contacts/segments/lib/date-utils";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
const isDateOperator = (operator: string): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.StringFilter => {
const { qualifier, value } = filter;
const { operator } = qualifier;
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (typeof value !== "object" || Array.isArray(value) || !("amount" in value) || !("unit" in value)) {
return {};
}
const { amount, unit } = value as { amount: number; unit: TTimeUnit };
const now = new Date();
const thresholdDate = subtractTimeUnit(now, amount, unit);
if (operator === "isOlderThan") {
return { lt: thresholdDate.toISOString() };
} else {
return { gt: thresholdDate.toISOString() };
}
}
if (operator === "isBetween") {
if (!Array.isArray(value) || value.length !== 2) {
return {};
}
const [startStr, endStr] = value as [string, string];
const startDate = startOfDay(new Date(startStr));
const endDate = endOfDay(new Date(endStr));
return {
gte: startDate.toISOString(),
lte: endDate.toISOString(),
};
}
if (typeof value !== "string") {
return {};
}
const compareDate = new Date(value);
switch (operator) {
case "isBefore":
return { lt: startOfDay(compareDate).toISOString() };
case "isAfter":
return { gt: endOfDay(compareDate).toISOString() };
case "isSameDay":
return {
gte: startOfDay(compareDate).toISOString(),
lte: endOfDay(compareDate).toISOString(),
};
default:
return {};
}
};
/**
@@ -60,39 +117,56 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
},
} satisfies Prisma.ContactWhereInput;
if (isDateOperator(operator)) {
// @ts-ignore
valueQuery.attributes.some.value = buildDateAttributeFilterWhereClause(filter);
return valueQuery;
}
// Apply the appropriate operator to the attribute value
switch (operator) {
case "equals":
// @ts-ignore
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
break;
case "notEquals":
// @ts-ignore
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
break;
case "contains":
// @ts-ignore
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
break;
case "doesNotContain":
// @ts-ignore
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
break;
case "startsWith":
// @ts-ignore
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
break;
case "endsWith":
// @ts-ignore
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
break;
case "greaterThan":
// @ts-ignore
valueQuery.attributes.some.value = { gt: String(value) };
break;
case "greaterEqual":
// @ts-ignore
valueQuery.attributes.some.value = { gte: String(value) };
break;
case "lessThan":
// @ts-ignore
valueQuery.attributes.some.value = { lt: String(value) };
break;
case "lessEqual":
// @ts-ignore
valueQuery.attributes.some.value = { lte: String(value) };
break;
default:
// @ts-ignore
valueQuery.attributes.some.value = String(value);
}

View File

@@ -22,12 +22,20 @@ import {
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
ZSegmentCreateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import { DATE_OPERATORS, TDateOperator, TTimeUnit } from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
import {
endOfDay,
isSameDay,
startOfDay,
subtractTimeUnit,
} from "@/modules/ee/contacts/segments/lib/date-utils";
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
export type PrismaSegment = Prisma.SegmentGetPayload<{
@@ -35,6 +43,8 @@ export type PrismaSegment = Prisma.SegmentGetPayload<{
surveys: {
select: {
id: true;
name: true;
status: true;
};
};
};
@@ -65,6 +75,21 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegment => {
};
};
export const transformPrismaSegmentWithSurveyNames = (segment: PrismaSegment): TSegmentWithSurveyNames => {
const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name);
const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name);
return {
...transformPrismaSegment(segment),
activeSurveys,
inactiveSurveys,
};
};
export const getSegment = reactCache(async (segmentId: string): Promise<TSegment> => {
validateInputs([segmentId, ZId]);
try {
@@ -89,7 +114,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegment[]> => {
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
@@ -103,7 +128,7 @@ export const getSegments = reactCache(async (environmentId: string): Promise<TSe
return [];
}
return segments.map((segment) => transformPrismaSegment(segment));
return segments.map((segment) => transformPrismaSegmentWithSurveyNames(segment));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -375,6 +400,77 @@ export const getSegmentsByAttributeKey = reactCache(async (environmentId: string
}
});
const isDateOperator = (operator: string): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
const evaluateDateFilter = (
attributeValue: string | number,
filterValue: TSegmentAttributeFilter["value"],
operator: TDateOperator
): boolean => {
const date = new Date(attributeValue);
if (isNaN(date.getTime())) {
return false;
}
// Handle relative date operators
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (
typeof filterValue !== "object" ||
Array.isArray(filterValue) ||
!("amount" in filterValue) ||
!("unit" in filterValue)
) {
return false;
}
const { amount, unit } = filterValue as { amount: number; unit: TTimeUnit };
const now = new Date();
const thresholdDate = subtractTimeUnit(now, amount, unit);
if (operator === "isOlderThan") {
// Something is older than 5 days if valid < (now - 5 days)
return date < thresholdDate;
} else {
// Something is newer than 5 days if valid > (now - 5 days)
return date > thresholdDate;
}
}
// Handle between operator
if (operator === "isBetween") {
if (!Array.isArray(filterValue) || filterValue.length !== 2) {
return false;
}
const [startStr, endStr] = filterValue as [string, string];
const startDate = startOfDay(new Date(startStr));
const endDate = endOfDay(new Date(endStr));
return date >= startDate && date <= endDate;
}
// Handle absolute operators
if (typeof filterValue !== "string") {
return false;
}
const compareDate = new Date(filterValue);
if (isNaN(compareDate.getTime())) {
return false;
}
switch (operator) {
case "isBefore":
return date < startOfDay(compareDate);
case "isAfter":
return date > endOfDay(compareDate);
case "isSameDay":
return isSameDay(date, compareDate);
default:
return false;
}
};
const evaluateAttributeFilter = (
attributes: TEvaluateSegmentUserAttributeData,
filter: TSegmentAttributeFilter
@@ -384,10 +480,21 @@ const evaluateAttributeFilter = (
const attributeValue = attributes[contactAttributeKey];
if (!attributeValue) {
// Special handling for isSet/isNotSet if needed, but compareValues handles it generically
// However, if checks below depend on value existence, we might need to delegate earlier
// For date operators, if no value, usually false (unless we add isNotSet for dates)
// But compareValues handles isSet/isNotSet.
// We should check operator type first.
if (qualifier.operator === "isNotSet") return true;
if (qualifier.operator === "isSet") return false;
return false;
}
const attResult = compareValues(attributeValue, value, qualifier.operator);
if (isDateOperator(qualifier.operator)) {
return evaluateDateFilter(attributeValue, value, qualifier.operator);
}
const attResult = compareValues(attributeValue, value as string | number, qualifier.operator);
return attResult;
};
@@ -396,7 +503,7 @@ const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boo
const { personIdentifier } = root;
if (personIdentifier === "userId") {
const attResult = compareValues(userId, value, qualifier.operator);
const attResult = compareValues(userId, value as string | number, qualifier.operator);
return attResult;
}
@@ -437,7 +544,7 @@ const evaluateSegmentFilter = async (
const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDeviceFilter): boolean => {
const { value, qualifier } = filter;
return compareValues(device, value, qualifier.operator);
return compareValues(device, value as string | number, qualifier.operator);
};
export const compareValues = (

View File

@@ -10,6 +10,7 @@ import {
TSegmentConnector,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentFilterValue,
TSegmentOperator,
TSegmentPersonFilter,
TSegmentSegmentFilter,
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
return "User is in";
case "userIsNotIn":
return "User is not in";
case "isOlderThan":
return "is older than";
case "isNewerThan":
return "is newer than";
case "isBefore":
return "is before";
case "isAfter":
return "is after";
case "isBetween":
return "is between";
case "isSameDay":
return "is on";
default:
return operator;
}
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
return "User is in";
case "userIsNotIn":
return "User is not in";
case "isOlderThan":
return "Is older than";
case "isNewerThan":
return "Is newer than";
case "isBefore":
return "Is before";
case "isAfter":
return "Is after";
case "isBetween":
return "Is between";
case "isSameDay":
return "Is on";
default:
return operator;
}
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
}
};
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];

View File

@@ -2,14 +2,14 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
import { CreateSegmentModal } from "@/modules/ee/contacts/segments/components/create-segment-modal";
import { SegmentsDataTable } from "@/modules/ee/contacts/segments/components/segments-data-table";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { CreateSegmentModal } from "./components/create-segment-modal";
export const SegmentsPage = async ({
params: paramsProps,
@@ -17,22 +17,11 @@ export const SegmentsPage = async ({
params: Promise<{ environmentId: string }>;
}) => {
const params = await paramsProps;
const t = await getTranslate();
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [segments, contactAttributeKeys] = await Promise.all([
getSegments(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
const t = await getTranslate();
const isContactsEnabled = await getIsContactsEnabled();
if (!segments) {
throw new Error("Failed to fetch segments");
}
const filteredSegments = segments.filter((segment) => !segment.isPrivate);
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
const segments = await getSegments(params.environmentId);
return (
<PageContentWrapper>
@@ -43,7 +32,7 @@ export const SegmentsPage = async ({
<CreateSegmentModal
environmentId={params.environmentId}
contactAttributeKeys={contactAttributeKeys}
segments={filteredSegments}
segments={segments}
/>
) : undefined
}>
@@ -51,8 +40,8 @@ export const SegmentsPage = async ({
</PageHeader>
{isContactsEnabled ? (
<SegmentTable
segments={filteredSegments}
<SegmentsDataTable
segments={segments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}

View File

@@ -1,28 +1,10 @@
"use client";
import { format } from "date-fns";
import { CalendarCheckIcon, CalendarIcon, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import Calendar from "react-calendar";
import { XIcon } from "lucide-react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import "./styles.css";
const getOrdinalSuffix = (day: number) => {
if (day > 3 && day < 21) return "th"; // 11th, 12th, 13th, etc.
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
};
interface DatePickerProps {
date: Date | null;
@@ -33,103 +15,52 @@ interface DatePickerProps {
export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: DatePickerProps) => {
const { t } = useTranslation();
const [value, onChange] = useState<Date | undefined>(date ? new Date(date) : undefined);
const [formattedDate, setFormattedDate] = useState<string | undefined>(
date ? format(new Date(date), "do MMM, yyyy") : undefined
);
const [isOpen, setIsOpen] = useState(false);
const dateInputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const onDateChange = (date: Date) => {
if (date) {
updateSurveyDate(date);
const day = date.getDate();
const ordinalSuffix = getOrdinalSuffix(day);
const formatted = format(date, `d'${ordinalSuffix}' MMM, yyyy`);
setFormattedDate(formatted);
onChange(date);
setIsOpen(false);
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
updateSurveyDate(new Date(e.target.value));
}
};
const handleClearDate = () => {
if (onClearDate) {
onClearDate();
setFormattedDate(undefined);
onChange(undefined);
if (dateInputRef.current) {
dateInputRef.current.value = "";
}
}
};
return (
<div className="flex items-center gap-2">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
{formattedDate ? (
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground bg-slate-800"
)}
ref={btnRef}>
<CalendarCheckIcon className="mr-2 h-4 w-4" />
{formattedDate}
</Button>
) : (
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground"
)}
onClick={() => setIsOpen(true)}
ref={btnRef}>
<CalendarIcon className="mr-2 h-4 w-4" />
<span>{t("common.pick_a_date")}</span>
</Button>
<div className="relative flex w-full items-center">
<div className="relative w-full">
<input
ref={dateInputRef}
type="date"
min={minDate ? format(minDate, "yyyy-MM-dd") : undefined}
value={date ? format(date, "yyyy-MM-dd") : ""}
onChange={handleDateChange}
placeholder={t("common.pick_a_date")}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
!date && "text-slate-400"
)}
</PopoverTrigger>
<PopoverContent align="start" className="min-w-96 rounded-lg px-4 py-3">
<Calendar
value={value}
onChange={(date) => onDateChange(date as Date)}
minDate={minDate || new Date()}
className="!border-0"
tileClassName={({ date }: { date: Date }) => {
const baseClass =
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
// today's date class
if (
date.getDate() === new Date().getDate() &&
date.getMonth() === new Date().getMonth() &&
date.getFullYear() === new Date().getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`;
}
// active date class
if (
date.getDate() === value?.getDate() &&
date.getMonth() === value?.getMonth() &&
date.getFullYear() === value?.getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
}
/>
{!date && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center px-3 text-slate-400">
{t("common.pick_a_date")}
</div>
)}
</div>
return baseClass;
}}
showNeighboringMonth={false}
/>
</PopoverContent>
</Popover>
{formattedDate && onClearDate && (
<Button
variant="outline"
size="sm"
{date && onClearDate && (
<button
type="button"
onClick={handleClearDate}
className="h-8 w-8 p-0 hover:bg-slate-200">
className="absolute right-3 rounded-sm opacity-50 hover:opacity-100 focus:outline-none">
<XIcon className="h-4 w-4" />
</Button>
</button>
)}
</div>
);

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

@@ -69,7 +69,7 @@ export class ApiClient {
async createOrUpdateUser(userUpdateInput: {
userId: string;
attributes?: Record<string, string>;
attributes?: Record<string, string | number | Date>;
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};

View File

@@ -46,8 +46,9 @@ const migrateLocalStorage = (): { changed: boolean; newState?: TConfig } => {
...personState,
data: {
...personState.data,
// Copy over language from attributes if it exists
...(attributes?.language && { language: attributes.language }),
// Copy over language from attributes if it exists and is a string
...(attributes?.language &&
typeof attributes.language === "string" && { language: attributes.language }),
},
},
}),

View File

@@ -1,8 +1,9 @@
import { UpdateQueue } from "@/lib/user/update-queue";
import type { TAttributes } from "@/types/config";
import { type NetworkError, type Result, okVoid } from "@/types/error";
export const setAttributes = async (
attributes: Record<string, string>
attributes: TAttributes
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
const updateQueue = UpdateQueue.getInstance();

View File

@@ -93,7 +93,10 @@ export class UpdateQueue {
...config.get().user,
data: {
...config.get().user.data,
language: currentUpdates.attributes?.language,
language:
typeof currentUpdates.attributes?.language === "string"
? currentUpdates.attributes.language
: undefined,
},
},
});

View File

@@ -81,7 +81,7 @@ export type TConfigUpdateInput = Omit<TConfig, "status"> & {
};
};
export type TAttributes = Record<string, string>;
export type TAttributes = Record<string, string | number | Date>;
export interface TConfigInput {
environmentId: string;
@@ -133,7 +133,7 @@ export interface TLegacyConfigInput {
apiHost: string;
environmentId: string;
userId?: string;
attributes?: Record<string, string>;
attributes?: TAttributes;
}
export type TLegacyConfig = TConfig & {

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

@@ -19,5 +19,5 @@ export const ZContactAttributeUpdateInput = z.object({
export type TContactAttributeUpdateInput = z.infer<typeof ZContactAttributeUpdateInput>;
export const ZContactAttributes = z.record(z.string());
export const ZContactAttributes = z.record(z.union([z.string(), z.number(), z.date()]));
export type TContactAttributes = z.infer<typeof ZContactAttributes>;

View File

@@ -13,12 +13,27 @@ export const ARITHMETIC_OPERATORS = ["lessThan", "lessEqual", "greaterThan", "gr
export type TArithmeticOperator = (typeof ARITHMETIC_OPERATORS)[number];
export const STRING_OPERATORS = ["contains", "doesNotContain", "startsWith", "endsWith"] as const;
export type TStringOperator = (typeof STRING_OPERATORS)[number];
export const DATE_OPERATORS = [
"isOlderThan",
"isNewerThan",
"isBefore",
"isAfter",
"isBetween",
"isSameDay",
] as const;
export type TDateOperator = (typeof DATE_OPERATORS)[number];
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
export type TTimeUnit = (typeof TIME_UNITS)[number];
export const ZBaseOperator = z.enum(BASE_OPERATORS);
export type TBaseOperator = z.infer<typeof ZBaseOperator>;
// An attribute filter can have these operators
export const ATTRIBUTE_OPERATORS = [
...BASE_OPERATORS,
...DATE_OPERATORS,
"isSet",
"isNotSet",
"contains",
@@ -30,6 +45,15 @@ export const ATTRIBUTE_OPERATORS = [
// the person filter currently has the same operators as the attribute filter
// but we might want to add more operators in the future, so we keep it separated
export const PERSON_OPERATORS = ATTRIBUTE_OPERATORS;
export const TEXT_ATTRIBUTE_OPERATORS = [
...BASE_OPERATORS,
"isSet",
"isNotSet",
"contains",
"doesNotContain",
"startsWith",
"endsWith",
] as const;
// operators for segment filters
export const SEGMENT_OPERATORS = ["userIsIn", "userIsNotIn"] as const;
@@ -54,7 +78,12 @@ export type TDeviceOperator = z.infer<typeof ZDeviceOperator>;
export type TAllOperators = (typeof ALL_OPERATORS)[number];
export const ZSegmentFilterValue = z.union([z.string(), z.number()]);
export const ZSegmentFilterValue = z.union([
z.string(),
z.number(),
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
z.tuple([z.string(), z.string()]),
]);
export type TSegmentFilterValue = z.infer<typeof ZSegmentFilterValue>;
// Each filter has a qualifier, which usually contains the operator for evaluating the filter.
@@ -143,6 +172,41 @@ export const ZSegmentFilter = z
message: "Value must be a string for string operators and a number for arithmetic operators",
}
)
.refine(
(filter) => {
// Relative date operators
if (
["isOlderThan", "isNewerThan"].includes(filter.qualifier.operator as string) &&
(typeof filter.value !== "object" ||
Array.isArray(filter.value) ||
!("amount" in filter.value) ||
!("unit" in filter.value))
) {
return false;
}
// Absolute date operators that expect string/date
if (
["isBefore", "isAfter", "isSameDay"].includes(filter.qualifier.operator as string) &&
typeof filter.value !== "string"
) {
return false;
}
// Between operator
if (
filter.qualifier.operator === "isBetween" &&
(!Array.isArray(filter.value) || filter.value.length !== 2)
) {
return false;
}
return true;
},
{
message: "Invalid value type for the selected operator",
}
)
.refine(
(filter) => {
const { value, qualifier } = filter;