From 24dddb3b2b1fd845f1fbe7244e7d165ac271a33b Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 20 Jul 2026 16:50:06 -0700 Subject: [PATCH] Group move rules partner data addition --- .../settings/group-additional-settings.tsx | 2 +- .../[groupSlug]/settings/group-move-rules.tsx | 258 ++++++++++++++++-- .../groups/find-groups-with-matching-rules.ts | 65 ++++- .../lib/api/groups/upsert-group-move-rules.ts | 2 +- .../api/groups/validate-group-move-rules.ts | 30 +- .../workflows/evaluate-workflow-conditions.ts | 4 +- .../workflows/execute-move-group-workflow.ts | 16 +- apps/web/lib/types.ts | 8 +- apps/web/lib/zod/schemas/workflows.ts | 125 ++++++++- 9 files changed, 460 insertions(+), 50 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx index e788b3b2169..78b5a596fa6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx @@ -137,7 +137,7 @@ function GroupAdditionalSettingsForm({ .map((g) => g.name) .join(", "); toast.error( - `This rule is already in use by the ${groupNames} ${pluralize("group", groupsWithMatchingRules.length)}. Select a different activity or amount.`, + `This rule is already in use by the ${groupNames} ${pluralize("group", groupsWithMatchingRules.length)}. Select a different detail or amount.`, ); return; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx index 22c95b6b0f0..0b812bda76c 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx @@ -2,32 +2,51 @@ import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useGroup from "@/lib/swr/use-group"; +import useGroups from "@/lib/swr/use-groups"; import useWorkspace from "@/lib/swr/use-workspace"; -import { WorkflowCondition } from "@/lib/types"; -import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; +import { GroupProps, WorkflowCondition } from "@/lib/types"; +import { + WORKFLOW_COMPARISON_OPERATOR_LABELS, + WORKFLOW_ENUM_COMPARISON_OPERATORS, +} from "@/lib/zod/schemas/workflows"; import { useAdvancedUpsellModal } from "@/ui/partners/advanced-upsell-modal"; +import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { InlineBadgePopover, InlineBadgePopoverAmountInput, + InlineBadgePopoverContext, InlineBadgePopoverMenu, } from "@/ui/shared/inline-badge-popover"; -import { ArrowTurnRight2, Button, UserArrowRight, Users } from "@dub/ui"; -import { currencyFormatter, nFormatter } from "@dub/utils"; +import { + ArrowTurnRight2, + Button, + Check2, + UserArrowRight, + Users, +} from "@dub/ui"; +import { currencyFormatter, nFormatter, truncate } from "@dub/utils"; +import { Command } from "cmdk"; import { X } from "lucide-react"; -import { Fragment, useMemo } from "react"; +import { Fragment, useContext, useMemo } from "react"; import { Controller, useFieldArray, useFormContext } from "react-hook-form"; -const ATTRIBUTES = [ +const PERFORMANCE_ATTRIBUTES = [ { key: "totalLeads", text: "total leads", type: "number" }, { key: "totalConversions", text: "total conversions", type: "number" }, { key: "totalSaleAmount", text: "total revenue", type: "currency" }, { key: "totalCommissions", text: "total commissions", type: "currency" }, ] as const; +const PROFILE_ATTRIBUTES = [ + { key: "groupId", text: "group", type: "group" }, +] as const; + +const ATTRIBUTES = [...PERFORMANCE_ATTRIBUTES, ...PROFILE_ATTRIBUTES] as const; + type Attribute = (typeof ATTRIBUTES)[number]; type AttributeType = (typeof ATTRIBUTES)[number]["type"]; type RangeValue = { min: number; max?: number }; -type ValueType = number | RangeValue | undefined; +type ValueType = number | RangeValue | string | string[] | undefined; const ATTRIBUTE_BY_KEY = Object.fromEntries( ATTRIBUTES.map(({ key, text, type }) => [key, { text, type }]), @@ -202,33 +221,38 @@ function GroupRule({ )} - + {isFirst ? "If partner" : "And if partner"} {/* Select the attribute */} - ({ - value: a.key, - text: a.text, - }))} + { - // Reset to default gte operator when attribute changes + const isGroup = ATTRIBUTE_BY_KEY[value]?.type === "group"; + + // Reset the operator and value when the attribute changes onUpdate({ ...rule, attribute: value, - operator: "gte", + operator: (isGroup + ? undefined + : "gte") as WorkflowCondition["operator"], value: undefined, }); }} /> + {/* Select the condition + group(s) for group attributes */} + {rule.attribute && attributeType === "group" && ( + + )} {/* Select the attribute value */} - {rule.attribute && ( + {rule.attribute && attributeType !== "group" && ( <> is at least void; +}) { + const { setIsOpen } = useContext(InlineBadgePopoverContext); + + const sections = [ + { + label: "Performance data", + items: attributes.filter((a) => a.type !== "group"), + }, + { + label: "Profile data", + items: attributes.filter((a) => a.type === "group"), + }, + ].filter((section) => section.items.length > 0); + + return ( +
+ + + {sections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && ( +
+ )} +
+ {section.label} +
+
+ {section.items.map(({ key, text }) => ( + { + onSelect(key); + setIsOpen(false); + }} + className="flex cursor-pointer items-center justify-between rounded-md px-1.5 py-1 transition-colors duration-150 data-[selected=true]:bg-neutral-100" + > + + {text} + + {selectedValue === key && ( + + )} + + ))} +
+ + ))} + + +
+ ); +} + +function GroupConditionSelectors({ + rule, + onUpdate, +}: { + rule: WorkflowCondition; + onUpdate: (updates: Partial) => void; +}) { + const { group: currentGroup } = useGroup(); + const { groups } = useGroups(); + + const isEnumOperator = WORKFLOW_ENUM_COMPARISON_OPERATORS.includes( + rule.operator as (typeof WORKFLOW_ENUM_COMPARISON_OPERATORS)[number], + ); + const isMulti = rule.operator === "in" || rule.operator === "not_in"; + + const selectedGroupIds = Array.isArray(rule.value) + ? rule.value + : typeof rule.value === "string" && rule.value + ? [rule.value] + : []; + + // Exclude the current group: partners already in it can't be moved to it + const selectableGroups = (groups ?? []).filter( + (group) => group.id !== currentGroup?.id, + ); + + return ( + <> + + ({ + text: WORKFLOW_COMPARISON_OPERATOR_LABELS[operator], + value: operator, + }))} + onSelect={(operator) => { + const multi = operator === "in" || operator === "not_in"; + + // Preserve the selection when switching between single and multi operators + onUpdate({ + operator, + value: (multi + ? selectedGroupIds + : selectedGroupIds[0]) as WorkflowCondition["value"], + }); + }} + /> + + {isEnumOperator && ( + + ({ + text: group.name, + value: group.id, + icon: , + }))} + onSelect={(groupId) => { + if (isMulti) { + const newGroupIds = selectedGroupIds.includes(groupId) + ? selectedGroupIds.filter((id) => id !== groupId) + : [...selectedGroupIds, groupId]; + + onUpdate({ value: newGroupIds }); + } else { + onUpdate({ value: groupId }); + } + }} + /> + + )} + + ); +} + +const formatGroupValue = ( + selectedGroupIds: string[], + groups: GroupProps[] | undefined, + isMulti: boolean, +) => { + if (selectedGroupIds.length === 0) { + return isMulti ? "Select groups" : "Select group"; + } + + if (!isMulti) { + const group = groups?.find(({ id }) => id === selectedGroupIds[0]); + + if (!group) { + return "Select group"; + } + + return ( + + + {truncate(group.name, 24)} + + ); + } + + const names = selectedGroupIds.map( + (groupId) => groups?.find(({ id }) => id === groupId)?.name ?? "…", + ); + + return ( + names + .map((name) => truncate(name, 16)) + .slice(0, 2) + .join(", ") + (names.length > 2 ? ` +${names.length - 2}` : "") + ); +}; + function GroupMoveTarget() { const { group, loading } = useGroup(); @@ -414,7 +631,7 @@ function ValueInput({ return ( { return value; } - if (typeof value === "object" && value !== null && value.min != null) { + if (isRangeValue(value) && value.min != null) { return value.min; } @@ -503,7 +720,7 @@ const getMinValue = (value: ValueType): number | null => { }; const getMaxValue = (value: ValueType): number | null => { - if (typeof value === "object" && value !== null && value.max != null) { + if (isRangeValue(value) && value.max != null) { return value.max; } @@ -514,6 +731,7 @@ const isRangeValue = (value: ValueType): value is RangeValue => { return ( typeof value === "object" && value !== null && + !Array.isArray(value) && ("min" in value || "max" in value) ); }; diff --git a/apps/web/lib/api/groups/find-groups-with-matching-rules.ts b/apps/web/lib/api/groups/find-groups-with-matching-rules.ts index 2610f5d9715..bb7558b6161 100644 --- a/apps/web/lib/api/groups/find-groups-with-matching-rules.ts +++ b/apps/web/lib/api/groups/find-groups-with-matching-rules.ts @@ -91,7 +91,11 @@ const conditionToInterval = ( return null; case "between": - if (typeof condition.value === "object" && condition.value !== null) { + if ( + typeof condition.value === "object" && + condition.value !== null && + !Array.isArray(condition.value) + ) { return { min: condition.value.min, max: condition.value.max, @@ -113,6 +117,10 @@ const doConditionsOverlap = ( return false; } + if (condition1.attribute === "groupId") { + return doGroupConditionsOverlap(condition1, condition2); + } + const interval1 = conditionToInterval(condition1); const interval2 = conditionToInterval(condition2); @@ -122,3 +130,58 @@ const doConditionsOverlap = ( return interval1.min <= interval2.max && interval2.min <= interval1.max; }; + +// Normalize a group condition to either an allowed set (is / is one of) +// or an excluded set (is not / is not one of) of group IDs +const conditionToGroupSet = ( + condition: WorkflowCondition, +): { mode: "include" | "exclude"; groupIds: string[] } | null => { + const groupIds = Array.isArray(condition.value) + ? condition.value + : typeof condition.value === "string" + ? [condition.value] + : null; + + if (!groupIds) { + return null; + } + + switch (condition.operator) { + case "equals_to": + case "in": + return { mode: "include", groupIds }; + case "not_equals": + case "not_in": + return { mode: "exclude", groupIds }; + default: + return null; + } +}; + +// Two group conditions overlap if there exists a group that satisfies both +const doGroupConditionsOverlap = ( + condition1: WorkflowCondition, + condition2: WorkflowCondition, +): boolean => { + const set1 = conditionToGroupSet(condition1); + const set2 = conditionToGroupSet(condition2); + + if (!set1 || !set2) { + return false; + } + + if (set1.mode === "include" && set2.mode === "include") { + return set1.groupIds.some((id) => set2.groupIds.includes(id)); + } + + if (set1.mode === "include") { + return set1.groupIds.some((id) => !set2.groupIds.includes(id)); + } + + if (set2.mode === "include") { + return set2.groupIds.some((id) => !set1.groupIds.includes(id)); + } + + // Both are exclusions: any group outside both excluded sets satisfies both + return true; +}; diff --git a/apps/web/lib/api/groups/upsert-group-move-rules.ts b/apps/web/lib/api/groups/upsert-group-move-rules.ts index 99068de36b5..33bcacef0fa 100644 --- a/apps/web/lib/api/groups/upsert-group-move-rules.ts +++ b/apps/web/lib/api/groups/upsert-group-move-rules.ts @@ -58,7 +58,7 @@ export async function upsertGroupMoveRules({ throw new DubApiError({ code: "bad_request", - message: `This rule is already in use by the ${groupNames} ${pluralize("group", groupsWithMatchingRules.length)}. Select a different activity or amount.`, + message: `This rule is already in use by the ${groupNames} ${pluralize("group", groupsWithMatchingRules.length)}. Select a different detail or amount.`, }); } diff --git a/apps/web/lib/api/groups/validate-group-move-rules.ts b/apps/web/lib/api/groups/validate-group-move-rules.ts index d01e53e58cc..69b593ab336 100644 --- a/apps/web/lib/api/groups/validate-group-move-rules.ts +++ b/apps/web/lib/api/groups/validate-group-move-rules.ts @@ -10,7 +10,29 @@ export const validateGroupMoveRules = (rules?: WorkflowCondition[]) => { // Check if attribute is selected if (!rule.attribute) { - throw new Error(`Rule ${i + 1}: Please select an activity.`); + throw new Error(`Rule ${i + 1}: Please select a detail.`); + } + + // Group conditions have their own operator/value shape + if (rule.attribute === "groupId") { + if ( + !["equals_to", "not_equals", "in", "not_in"].includes(rule.operator) + ) { + throw new Error(`Rule ${i + 1}: Please select a condition.`); + } + + if (["equals_to", "not_equals"].includes(rule.operator)) { + if (typeof rule.value !== "string" || !rule.value) { + throw new Error(`Rule ${i + 1}: Please select a group.`); + } + } else if ( + !Array.isArray(rule.value) || + rule.value.filter(Boolean).length === 0 + ) { + throw new Error(`Rule ${i + 1}: Please select at least one group.`); + } + + continue; } // Check if value is set @@ -31,7 +53,11 @@ export const validateGroupMoveRules = (rules?: WorkflowCondition[]) => { // For between operator, check min and max if (rule.operator === "between") { - if (typeof rule.value !== "object" || rule.value === null) { + if ( + typeof rule.value !== "object" || + rule.value === null || + Array.isArray(rule.value) + ) { throw new Error(`Rule ${i + 1}: Please enter a valid value.`); } diff --git a/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts b/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts index 641a9f37af6..5eb80f271ce 100644 --- a/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts +++ b/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts @@ -6,7 +6,9 @@ export function evaluateWorkflowConditions({ attributes, }: { conditions: WorkflowCondition[]; - attributes: Partial>; + attributes: Partial< + Record + >; }): boolean { if (conditions.length === 0) return false; diff --git a/apps/web/lib/api/workflows/execute-move-group-workflow.ts b/apps/web/lib/api/workflows/execute-move-group-workflow.ts index df90e3ef904..874e6ea132b 100644 --- a/apps/web/lib/api/workflows/execute-move-group-workflow.ts +++ b/apps/web/lib/api/workflows/execute-move-group-workflow.ts @@ -62,13 +62,15 @@ export const executeMoveGroupWorkflow = async ({ return; } - const attributes: Partial> = - { - totalLeads: metrics?.aggregated?.leads ?? 0, - totalConversions: metrics?.aggregated?.conversions ?? 0, - totalSaleAmount: metrics?.aggregated?.saleAmount ?? 0, - totalCommissions: metrics?.aggregated?.commissions ?? 0, - }; + const attributes: Partial< + Record + > = { + totalLeads: metrics?.aggregated?.leads ?? 0, + totalConversions: metrics?.aggregated?.conversions ?? 0, + totalSaleAmount: metrics?.aggregated?.saleAmount ?? 0, + totalCommissions: metrics?.aggregated?.commissions ?? 0, + groupId: programEnrollment.groupId, + }; const shouldExecute = evaluateWorkflowConditions({ conditions, diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 3e34281ea53..c87c6649126 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -205,6 +205,7 @@ import { import { WORKFLOW_ATTRIBUTES, WORKFLOW_COMPARISON_OPERATORS, + WORKFLOW_CONDITION_ATTRIBUTES, workflowActionSchema, workflowConditionSchema, } from "./zod/schemas/workflows"; @@ -732,7 +733,8 @@ export type CampaignTriggerCondition = z.infer< typeof campaignTriggerConditionSchema >; -export type WorkflowConditionAttribute = (typeof WORKFLOW_ATTRIBUTES)[number]; +export type WorkflowConditionAttribute = + (typeof WORKFLOW_CONDITION_ATTRIBUTES)[number]; export type WorkflowComparisonOperator = (typeof WORKFLOW_COMPARISON_OPERATORS)[number]; @@ -740,8 +742,8 @@ export type WorkflowComparisonOperator = export type WorkflowAction = z.infer; export type OperatorFn = ( - aV: number, - cV: number | { min: number; max?: number }, + aV: number | string, + cV: number | { min: number; max?: number } | string | string[], ) => boolean; export type BountySubmissionsQueryFilters = z.infer< diff --git a/apps/web/lib/zod/schemas/workflows.ts b/apps/web/lib/zod/schemas/workflows.ts index 710990f5dab..6ba1b2400a7 100644 --- a/apps/web/lib/zod/schemas/workflows.ts +++ b/apps/web/lib/zod/schemas/workflows.ts @@ -15,6 +15,14 @@ export const WORKFLOW_ATTRIBUTES = [ "partnerJoined", ] as const; +// Partner profile attributes (only supported by group move rules for now) +export const WORKFLOW_PROFILE_ATTRIBUTES = ["groupId"] as const; + +export const WORKFLOW_CONDITION_ATTRIBUTES = [ + ...WORKFLOW_ATTRIBUTES, + ...WORKFLOW_PROFILE_ATTRIBUTES, +] as const; + export const WORKFLOW_ATTRIBUTE_TRIGGER: Record< WorkflowConditionAttribute, WorkflowTrigger @@ -25,9 +33,25 @@ export const WORKFLOW_ATTRIBUTE_TRIGGER: Record< totalCommissions: WorkflowTrigger.partnerMetricsUpdated, partnerEnrolledDays: WorkflowTrigger.partnerEnrolled, partnerJoined: WorkflowTrigger.partnerEnrolled, + groupId: WorkflowTrigger.partnerMetricsUpdated, } as const; -export const WORKFLOW_COMPARISON_OPERATORS = ["gte", "between"] as const; +export const WORKFLOW_NUMERIC_COMPARISON_OPERATORS = [ + "gte", + "between", +] as const; + +export const WORKFLOW_ENUM_COMPARISON_OPERATORS = [ + "equals_to", + "not_equals", + "in", + "not_in", +] as const; + +export const WORKFLOW_COMPARISON_OPERATORS = [ + ...WORKFLOW_NUMERIC_COMPARISON_OPERATORS, + ...WORKFLOW_ENUM_COMPARISON_OPERATORS, +] as const; export const SCHEDULED_WORKFLOW_TRIGGERS: WorkflowTrigger[] = [ "partnerEnrolled", @@ -42,14 +66,19 @@ export const OPERATOR_FUNCTIONS: Record< OperatorFn > = { gte: (aV, cV) => { - if (typeof cV !== "number") { + if (typeof aV !== "number" || typeof cV !== "number") { return false; } return aV >= cV; }, between: (aV, cV) => { - if (typeof cV !== "object" || cV === null) { + if ( + typeof aV !== "number" || + typeof cV !== "object" || + cV === null || + Array.isArray(cV) + ) { return false; } @@ -61,6 +90,12 @@ export const OPERATOR_FUNCTIONS: Record< return aV >= min && aV <= max; }, + equals_to: (aV, cV) => typeof cV === "string" && aV === cV, + not_equals: (aV, cV) => typeof cV === "string" && aV !== cV, + in: (aV, cV) => + Array.isArray(cV) && typeof aV === "string" && cV.includes(aV), + not_in: (aV, cV) => + Array.isArray(cV) && typeof aV === "string" && !cV.includes(aV), }; export const WORKFLOW_COMPARISON_OPERATOR_LABELS: Record< @@ -69,6 +104,10 @@ export const WORKFLOW_COMPARISON_OPERATOR_LABELS: Record< > = { gte: "more than", between: "between", + equals_to: "is", + not_equals: "is not", + in: "is one of", + not_in: "is not one of", } as const; export enum WORKFLOW_ACTION_TYPES { @@ -80,17 +119,75 @@ export enum WORKFLOW_ACTION_TYPES { export const WORKFLOW_LOGICAL_OPERATORS = ["AND"] as const; // Individual condition -export const workflowConditionSchema = z.object({ - attribute: z.enum(WORKFLOW_ATTRIBUTES), - operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"), - value: z.union([ - z.number(), - z.object({ - min: z.number(), - max: z.number(), - }), - ]), -}); +export const workflowConditionSchema = z + .object({ + attribute: z.enum(WORKFLOW_CONDITION_ATTRIBUTES), + operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"), + value: z.union([ + z.number(), + z.object({ + min: z.number(), + max: z.number(), + }), + z.string(), + z.array(z.string()).min(1), + ]), + }) + .superRefine((condition, ctx) => { + const { attribute, operator, value } = condition; + + if (attribute === "groupId") { + if ( + !WORKFLOW_ENUM_COMPARISON_OPERATORS.includes( + operator as (typeof WORKFLOW_ENUM_COMPARISON_OPERATORS)[number], + ) + ) { + ctx.addIssue({ + code: "custom", + message: `Operator ${operator} is not supported for the ${attribute} attribute.`, + }); + } else if ( + ["equals_to", "not_equals"].includes(operator) && + typeof value !== "string" + ) { + ctx.addIssue({ + code: "custom", + message: `Value must be a string for the ${operator} operator.`, + }); + } else if (["in", "not_in"].includes(operator) && !Array.isArray(value)) { + ctx.addIssue({ + code: "custom", + message: `Value must be an array for the ${operator} operator.`, + }); + } + + return; + } + + if ( + !WORKFLOW_NUMERIC_COMPARISON_OPERATORS.includes( + operator as (typeof WORKFLOW_NUMERIC_COMPARISON_OPERATORS)[number], + ) + ) { + ctx.addIssue({ + code: "custom", + message: `Operator ${operator} is not supported for the ${attribute} attribute.`, + }); + } else if (operator === "gte" && typeof value !== "number") { + ctx.addIssue({ + code: "custom", + message: `Value must be a number for the ${operator} operator.`, + }); + } else if ( + operator === "between" && + (typeof value !== "object" || value === null || Array.isArray(value)) + ) { + ctx.addIssue({ + code: "custom", + message: `Value must be a { min, max } object for the ${operator} operator.`, + }); + } + }); // Array of conditions with AND operator export const workflowConditionsSchema = z.object({