From 8c19587baa6f5645a400cad2c320ade0a7cf3312 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:39:58 -0700 Subject: [PATCH] fix: ensure at least one filter is required for segments (#7503) Co-authored-by: Dhruwang --- .../contacts-secondary-navigation.tsx | 10 +-- .../segments/components/segment-settings.tsx | 4 + .../segments/lib/segment-schema.test.ts | 73 +++++++++++++++++++ .../invite-member/invite-member-modal.tsx | 2 +- packages/types/segment.ts | 8 +- 5 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts diff --git a/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx b/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx index 141715185e..31fdb67e09 100644 --- a/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx +++ b/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx @@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({ label: t("common.contacts"), href: `/environments/${environmentId}/contacts`, }, - { - id: "segments", - label: t("common.segments"), - href: `/environments/${environmentId}/segments`, - }, { id: "attributes", label: t("common.attributes"), href: `/environments/${environmentId}/attributes`, }, + { + id: "segments", + label: t("common.segments"), + href: `/environments/${environmentId}/segments`, + }, ]; return ; diff --git a/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx b/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx index fb01fa17f5..026e51f726 100644 --- a/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx +++ b/apps/web/modules/ee/contacts/segments/components/segment-settings.tsx @@ -133,6 +133,10 @@ export function SegmentSettings({ return true; } + if (segment.filters.length === 0) { + return true; + } + // parse the filters to check if they are valid const parsedFilters = ZSegmentFilters.safeParse(segment.filters); if (!parsedFilters.success) { diff --git a/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts b/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts new file mode 100644 index 0000000000..85572bed25 --- /dev/null +++ b/apps/web/modules/ee/contacts/segments/lib/segment-schema.test.ts @@ -0,0 +1,73 @@ +import { createId } from "@paralleldrive/cuid2"; +import { describe, expect, test } from "vitest"; +import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment"; + +const validFilters = [ + { + id: createId(), + connector: null, + resource: { + id: createId(), + root: { + type: "attribute" as const, + contactAttributeKey: "email", + }, + value: "user@example.com", + qualifier: { + operator: "equals" as const, + }, + }, + }, +]; + +describe("segment schema validation", () => { + test("keeps base segment filters compatible with empty arrays", () => { + const result = ZSegmentFilters.safeParse([]); + + expect(result.success).toBe(true); + }); + + test("requires at least one filter when creating a segment", () => { + const result = ZSegmentCreateInput.safeParse({ + environmentId: "environmentId", + title: "Power users", + description: "Users with a matching email", + isPrivate: false, + filters: [], + surveyId: "surveyId", + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("At least one filter is required"); + }); + + test("accepts segment creation with a valid filter", () => { + const result = ZSegmentCreateInput.safeParse({ + environmentId: "environmentId", + title: "Power users", + description: "Users with a matching email", + isPrivate: false, + filters: validFilters, + surveyId: "surveyId", + }); + + expect(result.success).toBe(true); + }); + + test("requires at least one filter when updating a segment", () => { + const result = ZSegmentUpdateInput.safeParse({ + filters: [], + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toBe("At least one filter is required"); + }); + + test("accepts segment updates with a valid filter", () => { + const result = ZSegmentUpdateInput.safeParse({ + filters: validFilters, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx index f2ac6ca7c3..1e569f18f1 100644 --- a/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx +++ b/apps/web/modules/organization/settings/teams/components/invite-member/invite-member-modal.tsx @@ -89,7 +89,7 @@ export const InviteMemberModal = ({ {t("environments.settings.teams.invite_member_description")} - + {!showTeamAdminRestrictions && ( = z error: "Invalid filters applied", }); +const ZRequiredSegmentFilters = ZSegmentFilters.refine((filters) => filters.length > 0, { + error: "At least one filter is required", +}); + export const ZSegment = z.object({ id: z.string(), title: z.string(), @@ -350,7 +354,7 @@ export const ZSegmentCreateInput = z.object({ title: z.string(), description: z.string().optional(), isPrivate: z.boolean().prefault(true), - filters: ZSegmentFilters, + filters: ZRequiredSegmentFilters, surveyId: z.string(), }); @@ -367,7 +371,7 @@ export const ZSegmentUpdateInput = z title: z.string(), description: z.string().nullable(), isPrivate: z.boolean().prefault(true), - filters: ZSegmentFilters, + filters: ZRequiredSegmentFilters, surveys: z.array(z.string()), }) .partial();