diff --git a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts index 51e7b4170..bd0588cc1 100644 --- a/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts +++ b/server/src/internal/migrations/v2/handlers/handlePreviewMigrationFilter.ts @@ -4,9 +4,10 @@ import { customers, MigrationItemKind, products, + RELEVANT_STATUSES, Scopes, } from "@autumn/shared"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod/v4"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -58,6 +59,15 @@ export const handlePreviewMigrationFilter = createRoute({ const searchTerm = search || undefined; + // An empty customer scope compiles to nothing (wrapAnd throws). Treat "no + // active filter" as selecting nobody rather than 500ing the preview. + const hasAnyField = Object.values(filter ?? {}).some( + (v) => v !== undefined, + ); + if (!hasAnyField) { + return c.json({ count: 0, customers: [], page, pageSize }); + } + let includeProcessed: IncludeProcessed | undefined; let migrationInternalId: string | undefined; if (migrationId) { @@ -162,7 +172,10 @@ async function enrichCustomers(db: DrizzleCli, ids: string[]) { .from(customers) .leftJoin( customerProducts, - eq(customers.internal_id, customerProducts.internal_customer_id), + and( + eq(customers.internal_id, customerProducts.internal_customer_id), + inArray(customerProducts.status, RELEVANT_STATUSES), + ), ) .leftJoin( products, diff --git a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts index 8ffc8df6c..ead6b4e99 100644 --- a/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts +++ b/server/src/internal/migrations/v2/run/preProcess/preProcessMigrationFilter.ts @@ -40,7 +40,7 @@ export const preProcessMigrationFilter = ({ if (!filter.customer) return filter; const planRule = filter.customer.plan; - if (planRule === undefined || planRule === "$none") return filter; + if (planRule === undefined) return filter; const nextPlan: PlanFilter | PlanQuantifier = isQuantifierObject(planRule) ? { diff --git a/server/src/trigger/migrations/runMigrationTask.ts b/server/src/trigger/migrations/runMigrationTask.ts index 96473c16f..4dbc7138d 100644 --- a/server/src/trigger/migrations/runMigrationTask.ts +++ b/server/src/trigger/migrations/runMigrationTask.ts @@ -40,7 +40,8 @@ export const runMigrationTask = task({ id: "run-migration", queue: runMigrationTaskQueue, machine: "medium-1x", - maxDuration: 3600, + // Trigger.dev has no true "disable" — set very high to effectively remove the timeout. + maxDuration: 86400, run: async (rawPayload: unknown, { ctx: triggerCtx }) => { const { orgId, diff --git a/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts index 5b4107ab9..7e46b2623 100644 --- a/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts +++ b/server/tests/unit/migrations-v2/compiler/none-quantifier.test.ts @@ -19,11 +19,12 @@ describe("$none quantifier", () => { expect(sql).toContain("NOT EXISTS"); }); - test("string shorthand '$none' is equivalent to { $none: {} }", () => { - const full = compile({ plan: { $none: {} } }); - const shorthand = compile({ plan: "$none" }); - expect(shorthand.sql).toBe(full.sql); - expect(shorthand.params).toEqual(full.params); + test("$none with plan_id $in is the empty-inclusive 'not on plan' negation", () => { + const { sql, params } = compile({ + plan: { $none: { plan_id: { $in: ["pro"] } } }, + }); + expect(sql).toContain("NOT EXISTS"); + expect(params).toContain("pro"); }); test("$none with plan_id filter selects customers without that plan", () => { diff --git a/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts b/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts new file mode 100644 index 000000000..97b0b4010 --- /dev/null +++ b/server/tests/unit/migrations-v2/filters/array-filter-quantifier.test.ts @@ -0,0 +1,31 @@ +import { CustomerFilterSchema } from "@autumn/shared/api/migrations/filters/customerFilter.js"; +import { describe, expect, it } from "bun:test"; + +// Regression: the quantifier wrapper must win over the permissive element in +// arrayFilter's union, otherwise PlanFilterSchema strips `$none`/`$some`/ +// `$every` down to `{}` and the filter silently degrades to "has any plan". +describe("arrayFilter quantifier preservation", () => { + it("preserves $none with an empty inner filter", () => { + const parsed = CustomerFilterSchema.parse({ plan: { $none: {} } }); + expect(parsed).toEqual({ plan: { $none: {} } }); + }); + + it("preserves $none with an inner plan_id matcher", () => { + const parsed = CustomerFilterSchema.parse({ + plan: { $none: { plan_id: { $in: ["pro"] } } }, + }); + expect(parsed).toEqual({ plan: { $none: { plan_id: { $in: ["pro"] } } } }); + }); + + it("keeps a bare element filter as implicit $some", () => { + const parsed = CustomerFilterSchema.parse({ plan: { plan_id: "pro" } }); + expect(parsed).toEqual({ plan: { plan_id: "pro" } }); + }); + + it("keeps an $or element filter (not mistaken for a quantifier)", () => { + const parsed = CustomerFilterSchema.parse({ + plan: { $or: [{ paid: true }] }, + }); + expect(parsed).toEqual({ plan: { $or: [{ paid: true }] } }); + }); +}); diff --git a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts index 0914821e8..1c472b37f 100644 --- a/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts +++ b/shared/api/customers/utils/match/customerFilterMatchesFullCustomer.ts @@ -40,10 +40,9 @@ export const customerFilterMatchesFullCustomer = ({ const relevantProducts = fullCustomer.customer_products.filter( customerProductHasRelevantStatus, ); - const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan; if ( !arrayFilterMatches({ - filter: planFilter, + filter: filter.plan, items: relevantProducts, matchesElement: ({ filter: planFilter, item: customerProduct }) => planFilterMatchesCustomerProduct({ diff --git a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts index a67ed73b4..e32d31a6e 100644 --- a/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts +++ b/shared/api/migrations/compiler/filterToIr/navs/parsePlanNav.ts @@ -17,9 +17,6 @@ export function parsePlanNav({ raw: NonNullable; ctx: ResolutionContext; }): IRNav { - if (raw === "$none") - return buildNav({ quantifier: "none", filter: {} as PlanFilter, ctx }); - if (!isQuantifierWrapper(raw)) return buildNav({ quantifier: "some", filter: raw as PlanFilter, ctx }); diff --git a/shared/api/migrations/filters/arrayFilter.ts b/shared/api/migrations/filters/arrayFilter.ts index 850ca08e2..79045328c 100644 --- a/shared/api/migrations/filters/arrayFilter.ts +++ b/shared/api/migrations/filters/arrayFilter.ts @@ -12,10 +12,21 @@ import { z } from "zod/v4"; */ export const arrayFilter = (element: T) => z.union([ + // Quantifier wrapper must come first and assert a `$`-key is present: + // `element` is a permissive object that would otherwise strip `$some`/ + // `$none`/`$every` down to `{}` and silently swallow the quantifier. + z + .object({ + $some: element.optional(), + $every: element.optional(), + $none: element.optional(), + }) + .refine( + (v) => + v.$some !== undefined || + v.$every !== undefined || + v.$none !== undefined, + { message: "quantifier object requires $some, $every, or $none" }, + ), element, - z.object({ - $some: element.optional(), - $every: element.optional(), - $none: element.optional(), - }), ]); diff --git a/shared/api/migrations/filters/customerFilter.ts b/shared/api/migrations/filters/customerFilter.ts index a49f6177f..79027f3e8 100644 --- a/shared/api/migrations/filters/customerFilter.ts +++ b/shared/api/migrations/filters/customerFilter.ts @@ -17,7 +17,7 @@ import { PlanItemFilterSchema } from "./planItemFilter.js"; */ export const CustomerFilterSchema = z.object({ customer_id: StringMatcherSchema.optional(), - plan: z.union([arrayFilter(PlanFilterSchema), z.literal("$none")]).optional(), + plan: arrayFilter(PlanFilterSchema).optional(), item: arrayFilter(PlanItemFilterSchema).optional(), }); diff --git a/vite/src/views/migrations/migration/filters/CustomerPreview.tsx b/vite/src/views/migrations/migration/filters/CustomerPreview.tsx index 886faf48a..e31fc5f3f 100644 --- a/vite/src/views/migrations/migration/filters/CustomerPreview.tsx +++ b/vite/src/views/migrations/migration/filters/CustomerPreview.tsx @@ -1,12 +1,14 @@ import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared"; import { + ArrowSquareOutIcon, CaretLeftIcon, CaretRightIcon, ListMagnifyingGlassIcon, } from "@phosphor-icons/react"; -import type { PaginationState } from "@tanstack/react-table"; +import type { ColumnDef, PaginationState, Row } from "@tanstack/react-table"; import { debounce } from "lodash"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; import { Table } from "@/components/general/table"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { Input } from "@/components/v2/inputs/Input"; @@ -20,11 +22,44 @@ import { import { Separator } from "@/components/v2/separator"; import { useMigrationFilterPreview } from "@/hooks/queries/useMigrationFilterPreview"; import { cn } from "@/lib/utils"; +import { pushPage } from "@/utils/genUtils"; import { createCustomerListColumns } from "@/views/customers2/components/table/customer-list/CustomerListColumns"; import { useProductTable } from "@/views/products/hooks/useProductTable"; const PAGE_SIZE_OPTIONS = [10, 50, 100, 250]; +const previewColumns = createCustomerListColumns() + .filter((col) => col.id !== "actions") + .map((column) => { + if (column.id !== "name") return column; + return { + ...column, + cell: ({ row }: { row: Row }) => { + const customer = row.original; + const customerId = customer.id || customer.internal_id; + return ( + event.stopPropagation()} + className="group/link inline-flex max-w-full items-center gap-1.5 text-foreground hover:text-primary" + > + + {customer.name || customerId} + + + + ); + }, + } satisfies ColumnDef; + }) as ColumnDef[]; + export function CustomerPreview({ filter }: { filter: CustomerFilter }) { const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); @@ -63,14 +98,10 @@ export function CustomerPreview({ filter }: { filter: CustomerFilter }) { const pageCount = count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1; - const columns = useMemo( - () => createCustomerListColumns().filter((col) => col.id !== "actions"), - [], - ); const table = useProductTable({ data: customers, - columns, + columns: previewColumns, options: { manualPagination: true, pageCount, @@ -148,7 +179,7 @@ export function CustomerPreview({ filter }: { filter: CustomerFilter }) { 1 ? "in" : "is"; } -function buildGroups(value: MigrationFilter): FilterGroupData[] { - const planFilter = - (value.customer?.plan as PlanFilter) ?? DEFAULT_PLAN_FILTER; - const planGroups = planFilterToGroups(planFilter); +/** `customer.plan` is `{ $none: {} }` — "has no active plans at all". */ +function planRawIsNone(plan: unknown): boolean { + const inner = planNoneInner(plan); + return inner !== null && Object.keys(inner).length === 0; +} + +/** + * Inner filter of a `{ $none: ... }` plan quantifier, or null if `plan` isn't + * a `$none`. An empty inner means "has no plans"; a non-empty inner (e.g. + * `{ plan_id: { $in: [...] } }`) is the empty-inclusive "not on plan X". + */ +function planNoneInner(plan: unknown): PlanFilter | null { + if (plan && typeof plan === "object" && "$none" in plan) { + const inner = (plan as { $none?: unknown }).$none; + if (inner == null) return {}; + if (typeof inner === "object") return inner as PlanFilter; + } + return null; +} + +/** Flip a `plan_id` rule between the `in` (positive) and `not_in` forms. */ +function flipPlanIdInToNotIn(groups: FilterGroupData[]): FilterGroupData[] { + return groups.map((g) => ({ + rules: g.rules.map((r) => + r.field === "plan_id" && r.operator === "in" + ? { ...r, operator: "not_in" as FilterOperator } + : r, + ), + })); +} + +function customerIdRuleFromValue(value: MigrationFilter): FilterRule | null { const matcher = value.customer?.customer_id as StringMatcher | undefined; const ids = customerIdToStrings(matcher); - if (ids.length === 0) return planGroups; - const rule: FilterRule = { + if (ids.length === 0) return null; + return { field: "customer_id", operator: inferCustomerIdOperator(matcher, ids.length), values: ids, }; - const [first, ...rest] = planGroups; - return [{ rules: [rule, ...(first?.rules ?? [])] }, ...rest]; +} + +function prependCustomerId( + groups: FilterGroupData[], + customerIdRule: FilterRule | null, +): FilterGroupData[] { + if (!customerIdRule) return groups; + const [first, ...rest] = groups; + return [{ rules: [customerIdRule, ...(first?.rules ?? [])] }, ...rest]; +} + +function buildGroups(value: MigrationFilter): FilterGroupData[] { + const customerIdRule = customerIdRuleFromValue(value); + const plan = value.customer?.plan; + + // "has no plans at all" → single `none` rule. + if (planRawIsNone(plan)) { + const noneRule: FilterRule = { + field: "plan_id", + operator: "none", + values: [], + }; + const rules = customerIdRule ? [customerIdRule, noneRule] : [noneRule]; + return [{ rules }]; + } + + // `{ $none: }` is the empty-inclusive "not on plan X" — decode the + // inner filter and flip its `plan_id` rule back to `not_in`. + const noneInner = planNoneInner(plan); + if (noneInner) { + const groups = flipPlanIdInToNotIn(planFilterToGroups(noneInner)); + return prependCustomerId(groups, customerIdRule); + } + + const planFilter = (plan as PlanFilter) ?? DEFAULT_PLAN_FILTER; + return prependCustomerId(planFilterToGroups(planFilter), customerIdRule); } function ruleToCustomerIdMatcher(rule: FilterRule): StringMatcher | undefined { @@ -65,6 +127,34 @@ function ruleToCustomerIdMatcher(rule: FilterRule): StringMatcher | undefined { } } +/** + * Inner filter for a customer-level `$none` quantifier, or null when the groups + * carry no plan negation. "has none" → `{}`; a `plan_id` "not in [X]" rule → + * the group's plan filter with `plan_id` flipped to `$in` (negated by `$none`). + */ +function groupsToPlanNone(groups: FilterGroupData[]): PlanFilter | null { + if (groups.some((g) => g.rules.some((r) => r.operator === "none"))) return {}; + + const hasPlanNotIn = groups.some((g) => + g.rules.some( + (r) => + r.field === "plan_id" && + r.operator === "not_in" && + r.values.length > 0, + ), + ); + if (!hasPlanNotIn) return null; + + const flipped = groups.map((g) => ({ + rules: g.rules.map((r) => + r.field === "plan_id" && r.operator === "not_in" + ? { ...r, operator: "in" as FilterOperator } + : r, + ), + })); + return groupsToPlanFilter(flipped); +} + function groupsToMigrationFilter( groups: FilterGroupData[], base: MigrationFilter, @@ -79,6 +169,21 @@ function groupsToMigrationFilter( return true; }), })); + // Plan negation is a customer-level quantifier ($none), not a per-plan + // matcher: "has none" → $none: {}, and "plan_id not in [X]" → + // $none: { plan_id: { $in: [X] } } so zero-plan customers are included. + const noneInner = groupsToPlanNone(cleaned); + if (noneInner) { + return { + ...base, + customer: { + ...base.customer, + customer_id: customerIdMatcher, + plan: { $none: noneInner }, + }, + }; + } + const planFilter = groupsToPlanFilter(cleaned); const hasPlanFilter = Object.keys(planFilter).length > 0; return { @@ -99,7 +204,10 @@ function isEmptyFilter(groups: FilterGroupData[]): boolean { if (groups.length !== 1) return false; const rules = groups[0].rules; if (rules.length === 0) return true; - return rules.length === 1 && rules[0].values.length === 0; + if (rules.length !== 1) return false; + // A `none` rule is fully specified without any values. + if (rules[0].operator === "none") return false; + return rules[0].values.length === 0; } export function FilterForm({ diff --git a/vite/src/views/migrations/migration/filters/FilterRow.tsx b/vite/src/views/migrations/migration/filters/FilterRow.tsx index 4445fb6cd..dec67396f 100644 --- a/vite/src/views/migrations/migration/filters/FilterRow.tsx +++ b/vite/src/views/migrations/migration/filters/FilterRow.tsx @@ -165,6 +165,8 @@ function FilterValueInput({ onChipRemove: (value: string) => void; }) { if (config.valueType === "none") return null; + // `none` (has no plans) takes no value. + if (rule.operator === "none") return null; if (config.valueType === "boolean") return ( diff --git a/vite/src/views/migrations/migration/filters/filterRowTypes.ts b/vite/src/views/migrations/migration/filters/filterRowTypes.ts index 1bdcd23a3..073d16087 100644 --- a/vite/src/views/migrations/migration/filters/filterRowTypes.ts +++ b/vite/src/views/migrations/migration/filters/filterRowTypes.ts @@ -22,6 +22,7 @@ export type FilterOperator = | "starts_with" | "exists" | "not_exists" + | "none" | "gt" | "gte" | "lt" @@ -69,6 +70,13 @@ const STRING_OPERATORS: OperatorOption[] = [ { value: "starts_with", label: "starts with" }, ]; +// Plan adds "has none" — selects customers with no active plans at all +// (compiles to the `$none` quantifier, not a per-plan matcher). +const PLAN_OPERATORS: OperatorOption[] = [ + ...STRING_OPERATORS, + { value: "none", label: "has none" }, +]; + const STRING_MATCH_OPERATORS: OperatorOption[] = [ { value: "is", label: "is" }, { value: "is_not", label: "is not" }, @@ -102,7 +110,7 @@ const NULLABLE_ONLY: FieldConfig = { export const FIELD_CONFIGS: Record = { customer_id: { operators: STRING_MATCH_OPERATORS, valueType: "string" }, - plan_id: { operators: STRING_OPERATORS, valueType: "string" }, + plan_id: { operators: PLAN_OPERATORS, valueType: "string" }, version: { operators: NUMBER_OPERATORS, valueType: "number" }, paid: BOOLEAN_ONLY, recurring: BOOLEAN_ONLY, diff --git a/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts b/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts index 3753eefa7..7ed089ca1 100644 --- a/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts +++ b/vite/src/views/migrations/migration/live/useMigrationSheetStore.ts @@ -15,6 +15,6 @@ interface MigrationSheetState { export const useMigrationSheetStore = create((set) => ({ selectedCustomer: null, setSelectedCustomer: (customer) => set({ selectedCustomer: customer }), - liveFormState: { operations: {}, noBillingChanges: false }, + liveFormState: { operations: {}, noBillingChanges: true }, setLiveFormState: (liveFormState) => set({ liveFormState }), })); diff --git a/vite/src/views/migrations/migration/useMigrationEditorForm.ts b/vite/src/views/migrations/migration/useMigrationEditorForm.ts index d3a9a4c23..b80b9bae3 100644 --- a/vite/src/views/migrations/migration/useMigrationEditorForm.ts +++ b/vite/src/views/migrations/migration/useMigrationEditorForm.ts @@ -44,7 +44,7 @@ export function useMigrationEditorForm({ defaultValues: { filter: (migration.filter ?? {}) as MigrationFilter, operations: (migration.operations ?? {}) as Operations, - noBillingChanges: migration.no_billing_changes ?? false, + noBillingChanges: migration.no_billing_changes ?? true, }, onSubmit: async ({ value }) => { try {