added none filter
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }] } });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user