added none filter

This commit is contained in:
johnyeo
2026-06-03 15:15:09 +01:00
committed by Charlie Lamb
parent 22c3583382
commit bbcfdffee3
15 changed files with 241 additions and 39 deletions

View File

@@ -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,

View File

@@ -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)
? {

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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 }] } });
});
});

View File

@@ -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({

View File

@@ -17,9 +17,6 @@ export function parsePlanNav({
raw: NonNullable<CustomerFilter["plan"]>;
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 });

View File

@@ -12,10 +12,21 @@ import { z } from "zod/v4";
*/
export const arrayFilter = <T extends z.ZodTypeAny>(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(),
}),
]);

View File

@@ -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(),
});

View File

@@ -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<CustomerWithProducts> }) => {
const customer = row.original;
const customerId = customer.id || customer.internal_id;
return (
<Link
to={pushPage({
path: `/customers/${customerId}`,
preserveParams: false,
})}
onClick={(event) => event.stopPropagation()}
className="group/link inline-flex max-w-full items-center gap-1.5 text-foreground hover:text-primary"
>
<span className="truncate font-medium">
{customer.name || customerId}
</span>
<ArrowSquareOutIcon
size={12}
weight="bold"
className="shrink-0 opacity-0 transition-opacity group-hover/link:opacity-70"
/>
</Link>
);
},
} satisfies ColumnDef<CustomerWithProducts, unknown>;
}) as ColumnDef<CustomerWithProducts, unknown>[];
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<CustomerWithProducts>({
data: customers,
columns,
columns: previewColumns,
options: {
manualPagination: true,
pageCount,
@@ -148,7 +179,7 @@ export function CustomerPreview({ filter }: { filter: CustomerFilter }) {
<Table.Provider
config={{
table,
numberOfColumns: columns.length,
numberOfColumns: previewColumns.length,
enableSorting: false,
isLoading: isLoading && customers.length === 0,
rowClassName: "h-10",

View File

@@ -33,20 +33,82 @@ function inferCustomerIdOperator(
return count > 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: <inner> }` 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({

View File

@@ -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 (

View File

@@ -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<FilterField, FieldConfig> = {
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,

View File

@@ -15,6 +15,6 @@ interface MigrationSheetState {
export const useMigrationSheetStore = create<MigrationSheetState>((set) => ({
selectedCustomer: null,
setSelectedCustomer: (customer) => set({ selectedCustomer: customer }),
liveFormState: { operations: {}, noBillingChanges: false },
liveFormState: { operations: {}, noBillingChanges: true },
setLiveFormState: (liveFormState) => set({ liveFormState }),
}));

View File

@@ -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 {