From a3f22bc1a598a667bf45f99ad3e4ada8c81761e9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 23 Jul 2026 21:46:16 +0530 Subject: [PATCH] wip --- .../(ee)/api/groups/[groupIdOrSlug]/route.ts | 15 +- .../settings/group-additional-settings.tsx | 41 +-- .../[groupSlug]/settings/group-move-rules.tsx | 294 ++++++++++++++++-- .../groups/find-groups-with-matching-rules.ts | 66 +++- .../scrub-from-partner-group-references.ts | 141 +++++++++ .../lib/api/groups/upsert-group-move-rules.ts | 3 +- .../api/groups/validate-group-move-rules.ts | 87 +++++- .../workflows/evaluate-workflow-conditions.ts | 9 +- .../workflows/execute-move-group-workflow.ts | 23 +- .../lib/api/workflows/execute-workflows.ts | 28 +- apps/web/lib/api/workflows/operators.ts | 97 +++++- .../parse-move-group-workflow-config.ts | 40 +++ .../lib/zod/schemas/group-move-workflows.ts | 29 +- apps/web/lib/zod/schemas/workflows.ts | 18 +- 14 files changed, 801 insertions(+), 90 deletions(-) create mode 100644 apps/web/lib/api/groups/scrub-from-partner-group-references.ts create mode 100644 apps/web/lib/api/workflows/parse-move-group-workflow-config.ts diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index 568ac481044..bd95a03830e 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -2,6 +2,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { movePartnersToGroup } from "@/lib/api/groups/move-partners-to-group"; +import { scrubFromPartnerGroupReferences } from "@/lib/api/groups/scrub-from-partner-group-references"; import { upsertGroupMoveRules } from "@/lib/api/groups/upsert-group-move-rules"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; @@ -365,7 +366,17 @@ export const DELETE = withWorkspace( // for `remap-discount-codes` that runs in movePartnersToGroup // but we will delete the Discount in `remap-discount-codes` once there are no remaining discount codes. - // 2. Delete the group move workflow + // TODO: + // Move this to a background job using defineJob + + // 2. Remove this group from other groups' fromPartnerGroup move-rule conditions + await scrubFromPartnerGroupReferences({ + programId, + deletedGroupId: group.id, + tx, + }); + + // 3. Delete the group move workflow if (group.workflowId) { await tx.workflow.delete({ where: { @@ -374,7 +385,7 @@ export const DELETE = withWorkspace( }); } - // 3. Delete the group + // 4. Delete the group await tx.partnerGroup.delete({ where: { id: group.id, 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 526426711ed..dbe2483e2d6 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 @@ -1,7 +1,6 @@ "use client"; import { findGroupsWithMatchingRules } from "@/lib/api/groups/find-groups-with-matching-rules"; -import { validateGroupMoveRules } from "@/lib/api/groups/validate-group-move-rules"; import { PAYOUT_HOLDING_PERIOD_DAYS } from "@/lib/constants/payouts"; import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; @@ -117,34 +116,22 @@ function GroupAdditionalSettingsForm({ const onSubmit = async (data: FormData) => { if (!group) return; - if (data.moveRules && data.moveRules.length > 0) { - try { - validateGroupMoveRules({ - rules: data.moveRules, - destinationGroupId: group.id, - }); - } catch (error) { - toast.error(error.message); + if (data.moveRules && data.moveRules.length > 0 && groups) { + const groupsWithMatchingRules = findGroupsWithMatchingRules({ + groups, + currentRules: data.moveRules, + currentGroupId: group.id, + }); + + if (groupsWithMatchingRules.length > 0) { + const groupNames = groupsWithMatchingRules + .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.`, + ); return; } - - if (groups) { - const groupsWithMatchingRules = findGroupsWithMatchingRules({ - groups, - currentRules: data.moveRules, - currentGroupId: group.id, - }); - - if (groupsWithMatchingRules.length > 0) { - const groupNames = groupsWithMatchingRules - .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.`, - ); - return; - } - } } await updateGroup(`/api/groups/${group.id}`, { 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 55d70fe351e..6a6ab16dcbc 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,10 +2,12 @@ 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 { GROUP_MOVE_ATTRIBUTE_CONFIG, - GROUP_MOVE_ATTRIBUTES, + GROUP_MOVE_OPERATOR_LABELS, + GROUP_MOVE_PERFORMANCE_ATTRIBUTES, type GroupMoveAttribute, type GroupMoveAttributeConfig, type GroupMoveCondition, @@ -19,7 +21,7 @@ import { InlineBadgePopoverMenu, } from "@/ui/shared/inline-badge-popover"; import { ArrowTurnRight2, Button, UserArrowRight, Users } from "@dub/ui"; -import { currencyFormatter, nFormatter } from "@dub/utils"; +import { currencyFormatter, nFormatter, pluralize } from "@dub/utils"; import { X } from "lucide-react"; import { Fragment, useMemo } from "react"; import { Controller, useFieldArray, useFormContext } from "react-hook-form"; @@ -56,43 +58,70 @@ export function GroupMoveRules() { const usedAttributes = useMemo( () => moveRules - ?.map((r) => r.attribute) + .filter((r) => r.attribute && r.attribute !== "fromPartnerGroup") + .map((r) => r.attribute) .filter((a): a is NonNullable => a != null), [moveRules], ); + const fromPartnerGroupIndex = moveRules.findIndex( + (r) => r.attribute === "fromPartnerGroup", + ); + + const hasFromPartnerGroup = fromPartnerGroupIndex !== -1; + + const hasPerformanceAttribute = moveRules.some( + (r) => + r.attribute && + r.attribute !== "fromPartnerGroup" && + GROUP_MOVE_PERFORMANCE_ATTRIBUTES.includes( + r.attribute as (typeof GROUP_MOVE_PERFORMANCE_ATTRIBUTES)[number], + ), + ); + + const performanceRuleCount = moveRules.filter( + (r) => r.attribute !== "fromPartnerGroup", + ).length; + const disableAddRuleButton = - ruleFields.length >= GROUP_MOVE_ATTRIBUTES.length; + performanceRuleCount >= GROUP_MOVE_PERFORMANCE_ATTRIBUTES.length; const { canUseGroupMoveRule } = getPlanCapabilities(plan); + const showRules = ruleFields.length > 0; + return ( <> {!canUseGroupMoveRule ? ( - ) : ruleFields.length === 0 ? ( + ) : !showRules ? ( ) : (
{ruleFields.map((field, index) => { const rule = moveRules?.[index]; - if (!rule) { + if (!rule || rule.attribute === "fromPartnerGroup") { return null; } - // Filter out attributes already used by other rules - const availableAttributes = GROUP_MOVE_ATTRIBUTES.filter( - (attribute) => - attribute === rule.attribute || - !usedAttributes?.includes(attribute), - ); + const availableAttributes = + GROUP_MOVE_PERFORMANCE_ATTRIBUTES.filter( + (attribute) => + attribute === rule.attribute || + !usedAttributes?.includes(attribute), + ); + + const isFirstPerformanceRule = + moveRules.findIndex((r) => r.attribute !== "fromPartnerGroup") === + index; return ( { updateRule(index, { ...rule, @@ -109,6 +138,41 @@ export function GroupMoveRules() { ); })} + {hasFromPartnerGroup ? ( + <> + { + updateRule(fromPartnerGroupIndex, { + ...moveRules[fromPartnerGroupIndex], + ...updatedRule, + }); + }} + onRemove={() => { + removeRule(fromPartnerGroupIndex); + }} + /> +
+ + ) : hasPerformanceAttribute ? ( + <> + +
+ {groupsLoading && availableGroups.length === 0 ? ( +
Loading groups
+ ) : null} +
+ ); +} + function GroupRule({ rule, onUpdate, onRemove, index, + isFirst, availableAttributes, }: { rule: GroupMoveCondition; onUpdate: (updates: Partial) => void; onRemove: () => void; index: number; + isFirst: boolean; availableAttributes: GroupMoveAttribute[]; }) { - const isFirst = index === 0; const attributeConfig = rule.attribute ? GROUP_MOVE_ATTRIBUTE_CONFIG[rule.attribute] : undefined; - const attributeType = attributeConfig?.inputType || "number"; + const attributeType = + attributeConfig?.inputType === "group" + ? "number" + : attributeConfig?.inputType || "number"; // Determine if "and less than" is selected based on operator // If operator is "between", it means "and less than" was selected (even if max is not set yet) @@ -366,7 +590,7 @@ function ValueInput({ }: { index: number; rule: GroupMoveCondition; - attributeType: GroupMoveAttributeConfig["inputType"]; + attributeType: Exclude; part: "min" | "max"; onUpdate: (updates: Partial) => void; }) { @@ -391,7 +615,7 @@ function ValueInput({ const inputValue = e.target.value; if (inputValue === "") { - field.onChange(handleClearValue(field.value, isMin)); + field.onChange(handleClearValue(field.value as ValueType, isMin)); return; } @@ -401,9 +625,13 @@ function ValueInput({ ); const newValue = isMin - ? handleUpdateMinValue(field.value, convertedValue, rule.operator) + ? handleUpdateMinValue( + field.value as ValueType, + convertedValue, + rule.operator, + ) : handleUpdateMaxValue( - field.value, + field.value as ValueType, convertedValue, onUpdate, rule.operator, @@ -490,20 +718,36 @@ const handleUpdateMaxValue = ( return newRange as any; }; -const getMinValue = (value: ValueType): number | null => { +const getMinValue = ( + value: GroupMoveCondition["value"] | ValueType, +): number | null => { if (typeof value === "number") { return value; } - if (typeof value === "object" && value !== null && value.min != null) { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "min" in value && + value.min != null + ) { return value.min; } return null; }; -const getMaxValue = (value: ValueType): number | null => { - if (typeof value === "object" && value !== null && value.max != null) { +const getMaxValue = ( + value: GroupMoveCondition["value"] | ValueType, +): number | null => { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "max" in value && + value.max != null + ) { return value.max; } @@ -536,8 +780,8 @@ const convertFromDisplayValue = ( // Format the value based on the attribute type const formatValue = ( - value: ValueType, - type: GroupMoveAttributeConfig["inputType"] | undefined, + value: GroupMoveCondition["value"] | ValueType, + type: Exclude | undefined, part: "min" | "max" = "min", ) => { const numValue = part === "min" ? getMinValue(value) : getMaxValue(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 39b6a9010c7..ecac7cd8636 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 @@ -94,7 +94,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, @@ -107,6 +111,62 @@ const conditionToInterval = ( } }; +const getGroupIdSet = (condition: GroupMoveCondition): Set | null => { + if (typeof condition.value === "string") { + return new Set([condition.value]); + } + + if (Array.isArray(condition.value)) { + return new Set(condition.value); + } + + return null; +}; + +const doFromPartnerGroupConditionsOverlap = ( + condition1: GroupMoveCondition, + condition2: GroupMoveCondition, +): boolean => { + const set1 = getGroupIdSet(condition1); + const set2 = getGroupIdSet(condition2); + + if (!set1 || !set2 || set1.size === 0 || set2.size === 0) { + return false; + } + + const isPositive1 = + condition1.operator === "equals_to" || condition1.operator === "in"; + const isPositive2 = + condition2.operator === "equals_to" || condition2.operator === "in"; + + // Both positive: overlap if value sets intersect + if (isPositive1 && isPositive2) { + for (const id of set1) { + if (set2.has(id)) { + return true; + } + } + return false; + } + + // Both negative (not_equals / not_in): a partner outside both exclusion sets can match both + if (!isPositive1 && !isPositive2) { + return true; + } + + // One positive, one negative: overlap unless the positive set is entirely contained in the exclusion set + const positiveSet = isPositive1 ? set1 : set2; + const negativeSet = isPositive1 ? set2 : set1; + + for (const id of positiveSet) { + if (!negativeSet.has(id)) { + return true; + } + } + + return false; +}; + const doConditionsOverlap = ( condition1: GroupMoveCondition, condition2: GroupMoveCondition, @@ -116,6 +176,10 @@ const doConditionsOverlap = ( return false; } + if (condition1.attribute === "fromPartnerGroup") { + return doFromPartnerGroupConditionsOverlap(condition1, condition2); + } + const interval1 = conditionToInterval(condition1); const interval2 = conditionToInterval(condition2); diff --git a/apps/web/lib/api/groups/scrub-from-partner-group-references.ts b/apps/web/lib/api/groups/scrub-from-partner-group-references.ts new file mode 100644 index 00000000000..b61be411a7e --- /dev/null +++ b/apps/web/lib/api/groups/scrub-from-partner-group-references.ts @@ -0,0 +1,141 @@ +import type { GroupMoveCondition } from "@/lib/zod/schemas/group-move-workflows"; +import { Prisma } from "@prisma/client"; + +type TransactionClient = Prisma.TransactionClient; + +export async function scrubFromPartnerGroupReferences({ + programId, + deletedGroupId, + tx, +}: { + programId: string; + deletedGroupId: string; + tx: TransactionClient; +}) { + const groups = await tx.partnerGroup.findMany({ + where: { + programId, + id: { + not: deletedGroupId, + }, + workflowId: { + not: null, + }, + }, + select: { + id: true, + workflowId: true, + workflow: { + select: { + id: true, + triggerConditions: true, + }, + }, + }, + }); + + for (const group of groups) { + if (!group.workflow || !group.workflowId) { + continue; + } + + const conditions = group.workflow.triggerConditions; + if (!Array.isArray(conditions)) { + continue; + } + + const nextConditions = scrubConditions({ + conditions: conditions as GroupMoveCondition[], + deletedGroupId, + }); + + if (nextConditions === null) { + continue; + } + + if (nextConditions.length === 0) { + await tx.partnerGroup.update({ + where: { + id: group.id, + }, + data: { + workflowId: null, + }, + }); + await tx.workflow.delete({ + where: { + id: group.workflowId, + }, + }); + continue; + } + + await tx.workflow.update({ + where: { + id: group.workflowId, + }, + data: { + triggerConditions: nextConditions, + }, + }); + } +} + +/** Returns null when nothing changed; otherwise the scrubbed conditions array. */ +export function scrubConditions({ + conditions, + deletedGroupId, +}: { + conditions: GroupMoveCondition[]; + deletedGroupId: string; +}): GroupMoveCondition[] | null { + let changed = false; + const nextConditions: GroupMoveCondition[] = []; + + for (const condition of conditions) { + if (condition.attribute !== "fromPartnerGroup") { + nextConditions.push(condition); + continue; + } + + if ( + condition.operator === "equals_to" || + condition.operator === "not_equals" + ) { + if (condition.value === deletedGroupId) { + changed = true; + continue; + } + nextConditions.push(condition); + continue; + } + + if (condition.operator === "in" || condition.operator === "not_in") { + if (!Array.isArray(condition.value)) { + nextConditions.push(condition); + continue; + } + + if (!condition.value.includes(deletedGroupId)) { + nextConditions.push(condition); + continue; + } + + changed = true; + const filtered = condition.value.filter((id) => id !== deletedGroupId); + if (filtered.length === 0) { + continue; + } + + nextConditions.push({ + ...condition, + value: filtered, + }); + continue; + } + + nextConditions.push(condition); + } + + return changed ? nextConditions : null; +} 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 b87a9a9cc25..a7f8fb977c4 100644 --- a/apps/web/lib/api/groups/upsert-group-move-rules.ts +++ b/apps/web/lib/api/groups/upsert-group-move-rules.ts @@ -50,9 +50,10 @@ export async function upsertGroupMoveRules({ } try { - validateGroupMoveRules({ + await validateGroupMoveRules({ rules: moveRules, destinationGroupId: group.id, + programId: group.programId, }); } catch (error) { throw new DubApiError({ 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 75842ab31fe..a5cff88e5fa 100644 --- a/apps/web/lib/api/groups/validate-group-move-rules.ts +++ b/apps/web/lib/api/groups/validate-group-move-rules.ts @@ -1,7 +1,9 @@ import { COMPARISON_OPERATORS } from "@/lib/api/workflows/operators"; +import { prisma } from "@/lib/prisma"; import { GROUP_MOVE_ATTRIBUTE_CONFIG, GROUP_MOVE_OPERATOR_LABELS, + GROUP_MOVE_PERFORMANCE_ATTRIBUTES, GroupMoveAttribute, type GroupMoveCondition, type GroupMoveRules, @@ -11,10 +13,14 @@ type GroupMoveAttributeValidator = (params: { rule: GroupMoveCondition; ruleIndex: number; destinationGroupId: string; -}) => void; + programId: string; +}) => void | Promise; + +const PERFORMANCE_ATTRIBUTES = new Set( + GROUP_MOVE_PERFORMANCE_ATTRIBUTES, +); // Add business rules for each attribute -// NOTE: Keeping this empty for now, next PR will use this const GROUP_MOVE_ATTRIBUTE_VALIDATORS: Record< GroupMoveAttribute, GroupMoveAttributeValidator @@ -24,37 +30,105 @@ const GROUP_MOVE_ATTRIBUTE_VALIDATORS: Record< totalConversions: () => {}, totalSaleAmount: () => {}, totalCommissions: () => {}, + + fromPartnerGroup: async ({ + rule, + ruleIndex, + destinationGroupId, + programId, + }) => { + const groupIds = + typeof rule.value === "string" + ? [rule.value] + : Array.isArray(rule.value) + ? rule.value + : []; + + if (groupIds.includes(destinationGroupId)) { + throw new Error( + `Rule ${ruleIndex + 1}: Source group cannot be the same as the destination group.`, + ); + } + + if (groupIds.length === 0) { + return; + } + + const partnerGroups = await prisma.partnerGroup.findMany({ + where: { + programId, + id: { + in: groupIds, + }, + }, + select: { + id: true, + }, + }); + + const validGroupIds = new Set(partnerGroups.map((group) => group.id)); + const invalidGroupIds = groupIds.filter((id) => !validGroupIds.has(id)); + + if (invalidGroupIds.length > 0) { + throw new Error( + `Rule ${ruleIndex + 1}: One or more selected groups do not exist.`, + ); + } + }, }; -export const validateGroupMoveRules = ({ +export const validateGroupMoveRules = async ({ rules, destinationGroupId, + programId, }: { rules?: GroupMoveRules; destinationGroupId: string; + programId: string; }) => { if (!rules || rules.length === 0) { return; } + const performanceRules = rules.filter((rule) => + PERFORMANCE_ATTRIBUTES.has(rule.attribute), + ); + + const fromPartnerGroupRules = rules.filter( + (rule) => rule.attribute === "fromPartnerGroup", + ); + + if (fromPartnerGroupRules.length > 0 && performanceRules.length === 0) { + throw new Error( + "A performance condition is required to use a partner group condition.", + ); + } + + if (fromPartnerGroupRules.length > 1) { + throw new Error("Only one partner group condition is allowed."); + } + for (let i = 0; i < rules.length; i++) { - validateRule({ + await validateRule({ rule: rules[i], ruleIndex: i, destinationGroupId, + programId, }); } }; // Validates a single group move rule -const validateRule = ({ +const validateRule = async ({ rule, ruleIndex, destinationGroupId, + programId, }: { rule: GroupMoveCondition; ruleIndex: number; destinationGroupId: string; + programId: string; }) => { if (!rule.attribute) { throw new Error(`Rule ${ruleIndex + 1}: Please select an activity.`); @@ -91,9 +165,10 @@ const validateRule = ({ } // Validate attribute-specific constraints (business rules) - GROUP_MOVE_ATTRIBUTE_VALIDATORS[rule.attribute]({ + await GROUP_MOVE_ATTRIBUTE_VALIDATORS[rule.attribute]({ rule, ruleIndex, destinationGroupId, + programId, }); }; diff --git a/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts b/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts index 299c81000a3..4a264170685 100644 --- a/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts +++ b/apps/web/lib/api/workflows/evaluate-workflow-conditions.ts @@ -1,12 +1,15 @@ -import { WorkflowCondition, WorkflowConditionAttribute } from "@/lib/types"; +import { WorkflowCondition } from "@/lib/types"; +import type { GroupMoveCondition } from "@/lib/zod/schemas/group-move-workflows"; import { COMPARISON_OPERATORS } from "./operators"; +type EvaluableCondition = WorkflowCondition | GroupMoveCondition; + export function evaluateWorkflowConditions({ conditions, attributes, }: { - conditions: WorkflowCondition[]; - attributes: Partial>; + conditions: EvaluableCondition[]; + attributes: Partial>; }): 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..3599e2f1179 100644 --- a/apps/web/lib/api/workflows/execute-move-group-workflow.ts +++ b/apps/web/lib/api/workflows/execute-move-group-workflow.ts @@ -1,11 +1,12 @@ import { prisma } from "@/lib/prisma"; -import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; +import { WorkflowContext } from "@/lib/types"; +import type { GroupMoveAttribute } from "@/lib/zod/schemas/group-move-workflows"; import { redis } from "@/lib/upstash/redis"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { Workflow } from "@prisma/client"; import { movePartnersToGroup } from "../groups/move-partners-to-group"; import { evaluateWorkflowConditions } from "./evaluate-workflow-conditions"; -import { parseWorkflowConfig } from "./parse-workflow-config"; +import { parseMoveGroupWorkflowConfig } from "./parse-move-group-workflow-config"; export const executeMoveGroupWorkflow = async ({ workflow, @@ -14,7 +15,7 @@ export const executeMoveGroupWorkflow = async ({ workflow: Workflow; context: WorkflowContext; }) => { - const { conditions, action } = parseWorkflowConfig(workflow); + const { conditions, action } = parseMoveGroupWorkflowConfig(workflow); if (action.type !== WORKFLOW_ACTION_TYPES.MoveGroup) { console.error( @@ -62,13 +63,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, + fromPartnerGroup: programEnrollment.groupId, + }; const shouldExecute = evaluateWorkflowConditions({ conditions, diff --git a/apps/web/lib/api/workflows/execute-workflows.ts b/apps/web/lib/api/workflows/execute-workflows.ts index aa209ab8d08..d625a45f0f6 100644 --- a/apps/web/lib/api/workflows/execute-workflows.ts +++ b/apps/web/lib/api/workflows/execute-workflows.ts @@ -1,11 +1,16 @@ import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; -import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; +import { + WORKFLOW_ACTION_TYPES, + workflowActionSchema, +} from "@/lib/zod/schemas/workflows"; import { Workflow } from "@prisma/client"; +import * as z from "zod/v4"; import { executeCompleteBountyWorkflow } from "./execute-complete-bounty-workflow"; import { executeMoveGroupWorkflow } from "./execute-move-group-workflow"; import { executeSendCampaignWorkflow } from "./execute-send-campaign-workflow"; +import { parseMoveGroupWorkflowConfig } from "./parse-move-group-workflow-config"; import { parseWorkflowConfig } from "./parse-workflow-config"; interface WorkflowActionHandler { @@ -40,6 +45,21 @@ const REASON_TO_ATTRIBUTES: Record< commission: ["totalCommissions"], }; +type ParsedWorkflowConfig = + | ReturnType + | ReturnType; + +function parseWorkflowForExecution(workflow: Workflow): ParsedWorkflowConfig { + const actions = z.array(workflowActionSchema).parse(workflow.actions); + const action = actions[0]; + + if (action?.type === WORKFLOW_ACTION_TYPES.MoveGroup) { + return parseMoveGroupWorkflowConfig(workflow); + } + + return parseWorkflowConfig(workflow); +} + export async function executeWorkflows({ trigger, reason, @@ -69,7 +89,7 @@ export async function executeWorkflows({ try { return { workflow, - config: parseWorkflowConfig(workflow), + config: parseWorkflowForExecution(workflow), }; } catch (error) { console.error( @@ -84,7 +104,7 @@ export async function executeWorkflows({ item, ): item is { workflow: Workflow; - config: ReturnType; + config: ParsedWorkflowConfig; } => item !== null, ); @@ -101,7 +121,7 @@ export async function executeWorkflows({ const expectedAttributes = REASON_TO_ATTRIBUTES[reason]; filteredWorkflows = parsedWorkflows.filter(({ config }) => config.conditions.some(({ attribute }) => - expectedAttributes.includes(attribute), + (expectedAttributes as string[]).includes(attribute), ), ); diff --git a/apps/web/lib/api/workflows/operators.ts b/apps/web/lib/api/workflows/operators.ts index 4113dbf7254..4c1cecf592a 100644 --- a/apps/web/lib/api/workflows/operators.ts +++ b/apps/web/lib/api/workflows/operators.ts @@ -1,10 +1,17 @@ import { WorkflowComparisonOperator } from "@/lib/types"; -type ConditionValue = number | { min: number; max?: number }; +type ConditionValue = + | number + | string + | string[] + | { min: number; max?: number }; type ComparisonOperator = { validate: (value: ConditionValue) => void; - evaluate: (attributeValue: number, conditionValue: ConditionValue) => boolean; + evaluate: ( + attributeValue: number | string, + conditionValue: ConditionValue, + ) => boolean; }; export const COMPARISON_OPERATORS: Record< @@ -19,7 +26,10 @@ export const COMPARISON_OPERATORS: Record< } }, evaluate(attributeValue, conditionValue) { - if (typeof conditionValue !== "number") { + if ( + typeof attributeValue !== "number" || + typeof conditionValue !== "number" + ) { return false; } @@ -30,7 +40,7 @@ export const COMPARISON_OPERATORS: Record< // Between (inclusive) between: { validate(value) { - if (typeof value !== "object" || value === null) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("Please enter a valid value."); } @@ -50,7 +60,12 @@ export const COMPARISON_OPERATORS: Record< } }, evaluate(attributeValue, conditionValue) { - if (typeof conditionValue !== "object" || conditionValue === null) { + if ( + typeof attributeValue !== "number" || + typeof conditionValue !== "object" || + conditionValue === null || + Array.isArray(conditionValue) + ) { return false; } @@ -63,4 +78,76 @@ export const COMPARISON_OPERATORS: Record< return attributeValue >= min && attributeValue <= max; }, }, + + // Equals (string) + equals_to: { + validate(value) { + if (typeof value !== "string" || !value) { + throw new Error("Please select a group."); + } + }, + evaluate(attributeValue, conditionValue) { + if (typeof conditionValue !== "string") { + return false; + } + + return attributeValue === conditionValue; + }, + }, + + // Not equals (string) + not_equals: { + validate(value) { + if (typeof value !== "string" || !value) { + throw new Error("Please select a group."); + } + }, + evaluate(attributeValue, conditionValue) { + if (typeof conditionValue !== "string") { + return false; + } + + return attributeValue !== conditionValue; + }, + }, + + // In (string array) + in: { + validate(value) { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((id) => !id) + ) { + throw new Error("Please select at least one group."); + } + }, + evaluate(attributeValue, conditionValue) { + if (!Array.isArray(conditionValue)) { + return false; + } + + return conditionValue.includes(attributeValue as string); + }, + }, + + // Not in (string array) + not_in: { + validate(value) { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((id) => !id) + ) { + throw new Error("Please select at least one group."); + } + }, + evaluate(attributeValue, conditionValue) { + if (!Array.isArray(conditionValue)) { + return false; + } + + return !conditionValue.includes(attributeValue as string); + }, + }, }; diff --git a/apps/web/lib/api/workflows/parse-move-group-workflow-config.ts b/apps/web/lib/api/workflows/parse-move-group-workflow-config.ts new file mode 100644 index 00000000000..4bb7f74dfda --- /dev/null +++ b/apps/web/lib/api/workflows/parse-move-group-workflow-config.ts @@ -0,0 +1,40 @@ +import { + groupMoveRulesSchema, + type GroupMoveRules, +} from "@/lib/zod/schemas/group-move-workflows"; +import { + WORKFLOW_ACTION_TYPES, + workflowActionSchema, +} from "@/lib/zod/schemas/workflows"; +import { Workflow } from "@prisma/client"; +import * as z from "zod/v4"; + +export function parseMoveGroupWorkflowConfig( + workflow: Pick, +) { + const conditions = groupMoveRulesSchema.parse(workflow.triggerConditions); + + const actions = z.array(workflowActionSchema).parse(workflow.actions); + + if (conditions.length === 0) { + throw new Error("No conditions found in workflow."); + } + + if (actions.length === 0) { + throw new Error("No actions found in workflow."); + } + + const action = actions[0]; + + if (action.type !== WORKFLOW_ACTION_TYPES.MoveGroup) { + throw new Error( + `Expected moveGroup action, got ${action.type} for workflow ${workflow.id}.`, + ); + } + + return { + conditions: conditions as GroupMoveRules, + condition: conditions[0], + action, + }; +} diff --git a/apps/web/lib/zod/schemas/group-move-workflows.ts b/apps/web/lib/zod/schemas/group-move-workflows.ts index 12ec7c097b7..052ca04ad01 100644 --- a/apps/web/lib/zod/schemas/group-move-workflows.ts +++ b/apps/web/lib/zod/schemas/group-move-workflows.ts @@ -1,13 +1,25 @@ import * as z from "zod/v4"; -export const GROUP_MOVE_ATTRIBUTES = [ +export const GROUP_MOVE_PERFORMANCE_ATTRIBUTES = [ "totalLeads", "totalConversions", "totalSaleAmount", "totalCommissions", ] as const; -export const GROUP_MOVE_OPERATORS = ["gte", "between"] as const; +export const GROUP_MOVE_ATTRIBUTES = [ + ...GROUP_MOVE_PERFORMANCE_ATTRIBUTES, + "fromPartnerGroup", +] as const; + +export const GROUP_MOVE_OPERATORS = [ + "gte", + "between", + "equals_to", + "not_equals", + "in", + "not_in", +] as const; export const GROUP_MOVE_ATTRIBUTE_CONFIG: Record< GroupMoveAttribute, @@ -33,11 +45,20 @@ export const GROUP_MOVE_ATTRIBUTE_CONFIG: Record< inputType: "currency", operators: ["gte", "between"], }, + fromPartnerGroup: { + label: "group", + inputType: "group", + operators: ["equals_to", "not_equals", "in", "not_in"], + }, }; export const GROUP_MOVE_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 const groupMoveConditionSchema = z.object({ @@ -49,6 +70,8 @@ export const groupMoveConditionSchema = z.object({ min: z.number(), max: z.number(), }), + z.string(), + z.array(z.string()), ]), }); @@ -64,6 +87,6 @@ type GroupMoveOperator = (typeof GROUP_MOVE_OPERATORS)[number]; export type GroupMoveAttributeConfig = { label: string; - inputType: "number" | "currency"; + inputType: "number" | "currency" | "group"; operators: readonly GroupMoveOperator[]; }; diff --git a/apps/web/lib/zod/schemas/workflows.ts b/apps/web/lib/zod/schemas/workflows.ts index 8db5a8f4c3f..e6b57083f53 100644 --- a/apps/web/lib/zod/schemas/workflows.ts +++ b/apps/web/lib/zod/schemas/workflows.ts @@ -23,7 +23,19 @@ export const WORKFLOW_ATTRIBUTE_TRIGGER: Record< partnerJoined: WorkflowTrigger.partnerEnrolled, } as const; -export const WORKFLOW_COMPARISON_OPERATORS = ["gte", "between"] as const; +export const WORKFLOW_NUMERIC_OPERATORS = ["gte", "between"] as const; + +export const WORKFLOW_ENUM_OPERATORS = [ + "equals_to", + "not_equals", + "in", + "not_in", +] as const; + +export const WORKFLOW_COMPARISON_OPERATORS = [ + ...WORKFLOW_NUMERIC_OPERATORS, + ...WORKFLOW_ENUM_OPERATORS, +] as const; export const SCHEDULED_WORKFLOW_TRIGGERS: WorkflowTrigger[] = [ "partnerEnrolled", @@ -41,10 +53,10 @@ export enum WORKFLOW_ACTION_TYPES { export const WORKFLOW_LOGICAL_OPERATORS = ["AND"] as const; -// Individual condition +// Individual condition (bounty/campaign workflows stay numeric-only) export const workflowConditionSchema = z.object({ attribute: z.enum(WORKFLOW_ATTRIBUTES), - operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"), + operator: z.enum(WORKFLOW_NUMERIC_OPERATORS).default("gte"), value: z.union([ z.number(), z.object({