feat: new migration filters
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -39,5 +39,6 @@
|
||||
".cursor": true,
|
||||
".mcp.json": true,
|
||||
".zed": true
|
||||
}
|
||||
},
|
||||
"typescript.native-preview.tsdk": "/Users/johnyeocx/autumn/main/node_modules/@typescript/native-preview"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ const getRewards = async ({
|
||||
|
||||
for (const reward of rewardArray) {
|
||||
const corresponding = rewards.find(
|
||||
(r) => r.id === reward || r.promo_codes.some((c) => c.code === reward),
|
||||
(r) =>
|
||||
r.id === reward ||
|
||||
r.internal_id === reward ||
|
||||
r.promo_codes.some((c) => c.code === reward),
|
||||
);
|
||||
|
||||
if (!corresponding) {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const getRewardsByIdOrCode = async ({
|
||||
eq(rewards.env, env),
|
||||
or(
|
||||
inArray(rewards.id, codes),
|
||||
inArray(rewards.internal_id, codes),
|
||||
...codes.map(
|
||||
(code) => sql`EXISTS (
|
||||
SELECT 1 FROM unnest("promo_codes") AS elem
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* TDD coverage for MigrationFilter.customer.plan.version.
|
||||
*
|
||||
* Contract under test:
|
||||
* Filter:
|
||||
* - PlanFilter.version is a NumberMatcher (bare number, $eq, $gt,
|
||||
* $gte, $lt, $lte, $in, $nin, $ne).
|
||||
* Behavior:
|
||||
* - When `plan.version` matches the customer's product version, the
|
||||
* customer is included in the migration run.
|
||||
* - When `plan.version` does NOT match, the customer is excluded
|
||||
* (no migration_item_runs row created).
|
||||
* Side effects:
|
||||
* - migration_item_runs rows reflect the customer set actually
|
||||
* selected by the filter — empty when no customer matches.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrationItemRuns } from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { migrationRunRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
import { waitForMigrationResult } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
const waitForRunCompleted = async ({
|
||||
ctx,
|
||||
runId,
|
||||
}: {
|
||||
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
|
||||
runId: string;
|
||||
}) =>
|
||||
waitForMigrationResult({
|
||||
timeoutMs: 60_000,
|
||||
pollIntervalMs: 1_000,
|
||||
waitFor: async () => {
|
||||
const [run] = await migrationRunRepo.list({ ctx, internalId: runId });
|
||||
if (!run) throw new Error("Run not found");
|
||||
if (run.status !== "succeeded" && run.status !== "failed")
|
||||
throw new Error(`Run still ${run.status}`);
|
||||
},
|
||||
});
|
||||
|
||||
const countItemRuns = async ({
|
||||
ctx,
|
||||
migrationInternalId,
|
||||
migrationRunId,
|
||||
}: {
|
||||
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
|
||||
migrationInternalId: string;
|
||||
migrationRunId: string;
|
||||
}) => {
|
||||
const rows = await ctx.db
|
||||
.select({ id: migrationItemRuns.migration_item_run_id })
|
||||
.from(migrationItemRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationItemRuns.migration_internal_id, migrationInternalId),
|
||||
eq(migrationItemRuns.migration_run_id, migrationRunId),
|
||||
eq(migrationItemRuns.dry_run, true),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
};
|
||||
|
||||
test(`${chalk.yellowBright("migrations plan-filter: version filter restricts customer selection by product version")}`, async () => {
|
||||
const suffix = Date.now();
|
||||
const customerId = `mig-plan-filter-version-${suffix}`;
|
||||
const plan = products.base({
|
||||
id: `mig-plan-filter-version-plan-${suffix}`,
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [plan] })],
|
||||
actions: [s.billing.attach({ productId: plan.id })],
|
||||
});
|
||||
|
||||
// Bump plan to v2. Customer stays on v1.
|
||||
await autumnV1.products.update(plan.id, {
|
||||
items: [items.monthlyMessages({ includedUsage: 200 })],
|
||||
});
|
||||
|
||||
// ── Assertion 1: version: 2 filter excludes the v1 customer ──
|
||||
const noMatchMigration = await autumnV2_2.migrationsV2.deleteAndCreate({
|
||||
id: `${customerId}-mig-nomatch`,
|
||||
filter: {
|
||||
customer: { plan: { plan_id: plan.id, version: 2 } },
|
||||
},
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: plan.id },
|
||||
customize: { add_items: [itemsV2.dashboard()] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const noMatchRun = await autumnV2_2.migrationsV2.run({
|
||||
id: noMatchMigration.id,
|
||||
dry_run: true,
|
||||
});
|
||||
await waitForRunCompleted({ ctx, runId: noMatchRun.run_id });
|
||||
expect(
|
||||
await countItemRuns({
|
||||
ctx,
|
||||
migrationInternalId: noMatchMigration.internal_id,
|
||||
migrationRunId: noMatchRun.run_id,
|
||||
}),
|
||||
"customer on v1 must NOT match version: 2 filter",
|
||||
).toBe(0);
|
||||
|
||||
// ── Assertion 2: version: 1 filter selects the v1 customer ──
|
||||
const matchMigration = await autumnV2_2.migrationsV2.deleteAndCreate({
|
||||
id: `${customerId}-mig-match`,
|
||||
filter: {
|
||||
customer: { plan: { plan_id: plan.id, version: 1 } },
|
||||
},
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: plan.id },
|
||||
customize: { add_items: [itemsV2.dashboard()] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const matchRun = await autumnV2_2.migrationsV2.run({
|
||||
id: matchMigration.id,
|
||||
dry_run: true,
|
||||
});
|
||||
await waitForRunCompleted({ ctx, runId: matchRun.run_id });
|
||||
expect(
|
||||
await countItemRuns({
|
||||
ctx,
|
||||
migrationInternalId: matchMigration.internal_id,
|
||||
migrationRunId: matchRun.run_id,
|
||||
}),
|
||||
"customer on v1 must match version: 1 filter",
|
||||
).toBe(1);
|
||||
|
||||
// ── Assertion 3: $lt: 2 operator also selects the v1 customer ──
|
||||
const ltMigration = await autumnV2_2.migrationsV2.deleteAndCreate({
|
||||
id: `${customerId}-mig-lt`,
|
||||
filter: {
|
||||
customer: { plan: { plan_id: plan.id, version: { $lt: 2 } } },
|
||||
},
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: plan.id },
|
||||
customize: { add_items: [itemsV2.dashboard()] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const ltRun = await autumnV2_2.migrationsV2.run({
|
||||
id: ltMigration.id,
|
||||
dry_run: true,
|
||||
});
|
||||
await waitForRunCompleted({ ctx, runId: ltRun.run_id });
|
||||
expect(
|
||||
await countItemRuns({
|
||||
ctx,
|
||||
migrationInternalId: ltMigration.internal_id,
|
||||
migrationRunId: ltRun.run_id,
|
||||
}),
|
||||
"customer on v1 must match version: { $lt: 2 } filter",
|
||||
).toBe(1);
|
||||
});
|
||||
190
server/tests/unit/compiler/plan/version.test.ts
Normal file
190
server/tests/unit/compiler/plan/version.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* TDD coverage for PlanFilter.version (NumberMatcher).
|
||||
*
|
||||
* Contract under test:
|
||||
* Filter:
|
||||
* - PlanFilter.version accepts bare number (eq), $eq, $ne, $in, $gt,
|
||||
* $gte, $lt, $lte.
|
||||
* Compilation:
|
||||
* - Customer-rooted: emits `p.version <op> ?` inside the planScope
|
||||
* EXISTS subquery.
|
||||
* - Plan-rooted: emits `p.version <op> ?` at the root.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js";
|
||||
import { compilePlanFilter } from "@autumn/shared/api/migrations/compiler/compilePlanFilter.js";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
|
||||
const ctx = contexts.create({ features: [] });
|
||||
const ambient = { orgId: "org_test", env: "live" };
|
||||
|
||||
const ROOT_AMBIENT = "c.org_id = ? AND c.env = ?";
|
||||
const PLAN_AMBIENT = "cp.status IN (?, ?)";
|
||||
const PLAN_AMBIENT_PARAMS = ["active", "past_due"];
|
||||
const PLAN_ROOT_AMBIENT = "p.org_id = ? AND p.env = ?";
|
||||
|
||||
const normalize = (sql: string) =>
|
||||
sql.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
|
||||
|
||||
describe("PlanFilter.version — customer-rooted compilation", () => {
|
||||
test("plan.version bare number eq", () => {
|
||||
const result = compileFilter({
|
||||
filter: { plan: { version: 1 } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`
|
||||
${ROOT_AMBIENT} AND EXISTS (
|
||||
SELECT 1
|
||||
FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
WHERE cp.internal_customer_id = c.internal_id
|
||||
AND ${PLAN_AMBIENT}
|
||||
AND p.version = ?
|
||||
)
|
||||
`),
|
||||
);
|
||||
expect(result.params).toEqual([
|
||||
"org_test",
|
||||
"live",
|
||||
...PLAN_AMBIENT_PARAMS,
|
||||
1,
|
||||
]);
|
||||
});
|
||||
|
||||
test("plan.version $gte", () => {
|
||||
const result = compileFilter({
|
||||
filter: { plan: { version: { $gte: 2 } } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`
|
||||
${ROOT_AMBIENT} AND EXISTS (
|
||||
SELECT 1
|
||||
FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
WHERE cp.internal_customer_id = c.internal_id
|
||||
AND ${PLAN_AMBIENT}
|
||||
AND p.version >= ?
|
||||
)
|
||||
`),
|
||||
);
|
||||
expect(result.params).toEqual([
|
||||
"org_test",
|
||||
"live",
|
||||
...PLAN_AMBIENT_PARAMS,
|
||||
2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("plan.version $lt", () => {
|
||||
const result = compileFilter({
|
||||
filter: { plan: { version: { $lt: 3 } } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`
|
||||
${ROOT_AMBIENT} AND EXISTS (
|
||||
SELECT 1
|
||||
FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
WHERE cp.internal_customer_id = c.internal_id
|
||||
AND ${PLAN_AMBIENT}
|
||||
AND p.version < ?
|
||||
)
|
||||
`),
|
||||
);
|
||||
expect(result.params).toEqual([
|
||||
"org_test",
|
||||
"live",
|
||||
...PLAN_AMBIENT_PARAMS,
|
||||
3,
|
||||
]);
|
||||
});
|
||||
|
||||
test("plan.version $in", () => {
|
||||
const result = compileFilter({
|
||||
filter: { plan: { version: { $in: [1, 2] } } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`
|
||||
${ROOT_AMBIENT} AND EXISTS (
|
||||
SELECT 1
|
||||
FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
WHERE cp.internal_customer_id = c.internal_id
|
||||
AND ${PLAN_AMBIENT}
|
||||
AND p.version IN (?, ?)
|
||||
)
|
||||
`),
|
||||
);
|
||||
expect(result.params).toEqual([
|
||||
"org_test",
|
||||
"live",
|
||||
...PLAN_AMBIENT_PARAMS,
|
||||
1,
|
||||
2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("plan.version combined $gte + $lte (range)", () => {
|
||||
const result = compileFilter({
|
||||
filter: { plan: { version: { $gte: 2, $lte: 4 } } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`
|
||||
${ROOT_AMBIENT} AND EXISTS (
|
||||
SELECT 1
|
||||
FROM customer_products cp JOIN products p ON p.internal_id = cp.internal_product_id
|
||||
WHERE cp.internal_customer_id = c.internal_id
|
||||
AND ${PLAN_AMBIENT}
|
||||
AND (p.version >= ? AND p.version <= ?)
|
||||
)
|
||||
`),
|
||||
);
|
||||
expect(result.params).toEqual([
|
||||
"org_test",
|
||||
"live",
|
||||
...PLAN_AMBIENT_PARAMS,
|
||||
2,
|
||||
4,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlanFilter.version — plan-rooted compilation", () => {
|
||||
test("version bare number eq", () => {
|
||||
const result = compilePlanFilter({
|
||||
filter: { version: 2 },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`${PLAN_ROOT_AMBIENT} AND p.version = ?`),
|
||||
);
|
||||
expect(result.params).toEqual(["org_test", "live", 2]);
|
||||
});
|
||||
|
||||
test("version $gt", () => {
|
||||
const result = compilePlanFilter({
|
||||
filter: { version: { $gt: 1 } },
|
||||
ctx: { features: ctx.features },
|
||||
ambient,
|
||||
});
|
||||
|
||||
expect(normalize(result.sql)).toBe(
|
||||
normalize(`${PLAN_ROOT_AMBIENT} AND p.version > ?`),
|
||||
);
|
||||
expect(result.params).toEqual(["org_test", "live", 1]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import { translateValue } from "./translateValue.js";
|
||||
|
||||
/**
|
||||
* Parse a single field's matcher value into one IR leaf or a small AND of
|
||||
* leaves. Handles the four supported operators: eq, ne, in, exists.
|
||||
* leaves. Handles the supported operators: eq, ne, in, nin, exists, gt,
|
||||
* gte, lt, lte.
|
||||
*
|
||||
* Spelling normalization:
|
||||
* - bare value → eq
|
||||
@@ -12,6 +13,7 @@ import { translateValue } from "./translateValue.js";
|
||||
* - { $ne: null } → exists (true)
|
||||
* - { $eq: null } → eq null
|
||||
* - { $in: [...] } → in
|
||||
* - { $gt: n } → gt (and same for $gte / $lt / $lte)
|
||||
*
|
||||
* Multiple operators on one field are combined with AND.
|
||||
*/
|
||||
@@ -37,6 +39,14 @@ export function parseLeaf({
|
||||
else leaves.push(makeLeaf(field, "ne", ops.$ne, ctx) as IRLeaf);
|
||||
}
|
||||
if ("$in" in ops) leaves.push(makeLeaf(field, "in", ops.$in, ctx) as IRLeaf);
|
||||
if ("$nin" in ops)
|
||||
leaves.push(makeLeaf(field, "nin", ops.$nin, ctx) as IRLeaf);
|
||||
if ("$gt" in ops) leaves.push(makeLeaf(field, "gt", ops.$gt, ctx) as IRLeaf);
|
||||
if ("$gte" in ops)
|
||||
leaves.push(makeLeaf(field, "gte", ops.$gte, ctx) as IRLeaf);
|
||||
if ("$lt" in ops) leaves.push(makeLeaf(field, "lt", ops.$lt, ctx) as IRLeaf);
|
||||
if ("$lte" in ops)
|
||||
leaves.push(makeLeaf(field, "lte", ops.$lte, ctx) as IRLeaf);
|
||||
|
||||
if (leaves.length === 0)
|
||||
throw new Error(`No supported operator found on field "${field}"`);
|
||||
|
||||
@@ -19,6 +19,10 @@ export function parsePlanFilter({
|
||||
children.push(
|
||||
parseLeaf({ field: "plan_id", rawValue: filter.plan_id, ctx }),
|
||||
);
|
||||
if (filter.version !== undefined)
|
||||
children.push(
|
||||
parseLeaf({ field: "version", rawValue: filter.version, ctx }),
|
||||
);
|
||||
if (filter.price !== undefined)
|
||||
children.push(parsePriceExistence(filter.price));
|
||||
if (filter.addon !== undefined)
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
* one form per concept.
|
||||
*/
|
||||
|
||||
export type LeafOp = "eq" | "ne" | "in" | "exists";
|
||||
export type LeafOp =
|
||||
| "eq"
|
||||
| "ne"
|
||||
| "in"
|
||||
| "nin"
|
||||
| "exists"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte";
|
||||
|
||||
export type LeafValue =
|
||||
| string
|
||||
|
||||
@@ -179,17 +179,32 @@ function compileLeaf({
|
||||
params.push(leaf.value);
|
||||
return `${col} <> ?`;
|
||||
}
|
||||
if (leaf.op === "in") {
|
||||
if (leaf.op === "in" || leaf.op === "nin") {
|
||||
if (!Array.isArray(leaf.value))
|
||||
throw new Error(`$in expects an array on field "${leaf.field}"`);
|
||||
if (leaf.value.length === 0) return "FALSE";
|
||||
throw new Error(`$${leaf.op} expects an array on field "${leaf.field}"`);
|
||||
const keyword = leaf.op === "in" ? "IN" : "NOT IN";
|
||||
if (leaf.value.length === 0) return leaf.op === "in" ? "FALSE" : "TRUE";
|
||||
const placeholders = leaf.value
|
||||
.map((v) => {
|
||||
params.push(v);
|
||||
return "?";
|
||||
})
|
||||
.join(", ");
|
||||
return `${col} IN (${placeholders})`;
|
||||
return `${col} ${keyword} (${placeholders})`;
|
||||
}
|
||||
if (
|
||||
leaf.op === "gt" ||
|
||||
leaf.op === "gte" ||
|
||||
leaf.op === "lt" ||
|
||||
leaf.op === "lte"
|
||||
) {
|
||||
if (leaf.value === null || Array.isArray(leaf.value))
|
||||
throw new Error(
|
||||
`$${leaf.op} requires a scalar value on field "${leaf.field}"`,
|
||||
);
|
||||
const symbol = { gt: ">", gte: ">=", lt: "<", lte: "<=" }[leaf.op];
|
||||
params.push(leaf.value);
|
||||
return `${col} ${symbol} ?`;
|
||||
}
|
||||
throw new Error(`Unsupported op: ${(leaf as IRLeaf).op}`);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ const planScope: NavScope = {
|
||||
],
|
||||
fields: {
|
||||
plan_id: { kind: "leaf", sql: "p.id" },
|
||||
version: { kind: "leaf", sql: "p.version" },
|
||||
addon: { kind: "leaf", sql: "p.is_add_on" },
|
||||
custom: { kind: "leaf", sql: "cp.is_custom" },
|
||||
// Base price existence: a leaf whose SQL is a scalar subquery that
|
||||
|
||||
@@ -12,6 +12,7 @@ export const planRegistry: RootScope = {
|
||||
],
|
||||
fields: {
|
||||
plan_id: { kind: "leaf", sql: "p.id" },
|
||||
version: { kind: "leaf", sql: "p.version" },
|
||||
addon: { kind: "leaf", sql: "p.is_add_on" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { arrayFilter } from "./arrayFilter.js";
|
||||
import {
|
||||
BooleanMatcherSchema,
|
||||
nullableObjectFilter,
|
||||
NumberMatcherSchema,
|
||||
StringMatcherSchema,
|
||||
} from "./matcher.js";
|
||||
import { PlanItemFilterSchema } from "./planItemFilter.js";
|
||||
@@ -40,6 +41,8 @@ const PlanPriceFilterInner = z.object({});
|
||||
|
||||
export type PlanFilter = {
|
||||
plan_id?: z.infer<typeof StringMatcherSchema>;
|
||||
/** Mirrors `products.version`. */
|
||||
version?: z.infer<typeof NumberMatcherSchema>;
|
||||
price?:
|
||||
| null
|
||||
| { $eq?: null; $ne?: null }
|
||||
@@ -66,6 +69,7 @@ export type PlanFilter = {
|
||||
export const PlanFilterSchema: z.ZodType<PlanFilter> = z.lazy(() =>
|
||||
z.object({
|
||||
plan_id: StringMatcherSchema.optional(),
|
||||
version: NumberMatcherSchema.optional(),
|
||||
price: nullableObjectFilter(PlanPriceFilterInner).optional(),
|
||||
addon: BooleanMatcherSchema.optional(),
|
||||
paid: BooleanMatcherSchema.optional(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isCustomerProductPaid,
|
||||
isCustomerProductPaidRecurring,
|
||||
} from "../../../../utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.js";
|
||||
import { numberMatcherMatches } from "../../../migrations/filters/match/numberMatcherMatches.js";
|
||||
import { stringMatcherMatches } from "../../../migrations/filters/match/index.js";
|
||||
import type { PlanFilter } from "../../../migrations/filters/planFilter.js";
|
||||
|
||||
@@ -42,6 +43,16 @@ export const planFilterMatchesCustomerProduct = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.version !== undefined) {
|
||||
if (
|
||||
!numberMatcherMatches({
|
||||
matcher: filter.version,
|
||||
value: cusProduct.product?.version ?? null,
|
||||
})
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filter.addon !== undefined &&
|
||||
isCustomerProductAddOn(cusProduct) !== filter.addon
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isFreeProduct,
|
||||
isOneOffProduct,
|
||||
} from "../../../../utils/productUtils/classifyProduct/classifyProductUtils.js";
|
||||
import { numberMatcherMatches } from "../../../migrations/filters/match/numberMatcherMatches.js";
|
||||
import { stringMatcherMatches } from "../../../migrations/filters/match/index.js";
|
||||
import type { PlanFilter } from "../../../migrations/filters/planFilter.js";
|
||||
|
||||
@@ -34,6 +35,17 @@ export const planFilterMatchesProduct = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.version !== undefined) {
|
||||
if (
|
||||
!numberMatcherMatches({
|
||||
matcher: filter.version,
|
||||
value: product.version ?? null,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const paid = !isFreeProduct({ prices: product.prices });
|
||||
if (filter.paid !== undefined && paid !== filter.paid) {
|
||||
return false;
|
||||
|
||||
@@ -176,6 +176,41 @@ function FilterValueInput({
|
||||
</div>
|
||||
);
|
||||
|
||||
if (config.valueType === "number") {
|
||||
const isMulti = rule.operator === "in" || rule.operator === "not_in";
|
||||
if (isMulti)
|
||||
return (
|
||||
<input
|
||||
className="h-8 text-sm rounded-xl px-3 input-base flex-1 min-w-0 text-foreground placeholder:text-tertiary-foreground"
|
||||
placeholder="1, 2, 3"
|
||||
value={rule.values.join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...rule,
|
||||
values: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="h-8 text-sm rounded-xl px-3 input-base flex-1 min-w-0 text-foreground placeholder:text-tertiary-foreground"
|
||||
placeholder="Number"
|
||||
value={rule.values[0] ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...rule,
|
||||
values: e.target.value === "" ? [] : [e.target.value],
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestions && suggestions.length > 0)
|
||||
return (
|
||||
<ValuePicker
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { PlanFilter, StringMatcher } from "@autumn/shared";
|
||||
import type { NumberMatcher, PlanFilter, StringMatcher } from "@autumn/shared";
|
||||
|
||||
export type FilterField =
|
||||
| "customer_id"
|
||||
| "plan_id"
|
||||
| "version"
|
||||
| "paid"
|
||||
| "recurring"
|
||||
| "price"
|
||||
@@ -20,7 +21,11 @@ export type FilterOperator =
|
||||
| "regex"
|
||||
| "starts_with"
|
||||
| "exists"
|
||||
| "not_exists";
|
||||
| "not_exists"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte";
|
||||
|
||||
export type FilterRule = {
|
||||
field: FilterField;
|
||||
@@ -38,6 +43,7 @@ export const FILTER_FIELD_OPTIONS: {
|
||||
}[] = [
|
||||
{ value: "customer_id", label: "Customer" },
|
||||
{ value: "plan_id", label: "Plan" },
|
||||
{ value: "version", label: "Version" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
{ value: "recurring", label: "Recurring" },
|
||||
{ value: "price", label: "Base Price" },
|
||||
@@ -51,7 +57,7 @@ export const FILTER_FIELD_OPTIONS: {
|
||||
type OperatorOption = { value: FilterOperator; label: string };
|
||||
export type FieldConfig = {
|
||||
operators: OperatorOption[];
|
||||
valueType: "string" | "boolean" | "none";
|
||||
valueType: "string" | "boolean" | "number" | "none";
|
||||
};
|
||||
|
||||
const STRING_OPERATORS: OperatorOption[] = [
|
||||
@@ -70,6 +76,17 @@ const STRING_MATCH_OPERATORS: OperatorOption[] = [
|
||||
{ value: "not_in", label: "not in" },
|
||||
];
|
||||
|
||||
const NUMBER_OPERATORS: OperatorOption[] = [
|
||||
{ value: "is", label: "is" },
|
||||
{ value: "is_not", label: "is not" },
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "gte", label: "≥" },
|
||||
{ value: "lt", label: "<" },
|
||||
{ value: "lte", label: "≤" },
|
||||
{ value: "in", label: "in" },
|
||||
{ value: "not_in", label: "not in" },
|
||||
];
|
||||
|
||||
const BOOLEAN_ONLY: FieldConfig = {
|
||||
operators: [{ value: "is", label: "is" }],
|
||||
valueType: "boolean",
|
||||
@@ -86,6 +103,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" },
|
||||
version: { operators: NUMBER_OPERATORS, valueType: "number" },
|
||||
paid: BOOLEAN_ONLY,
|
||||
recurring: BOOLEAN_ONLY,
|
||||
price: NULLABLE_ONLY,
|
||||
@@ -137,6 +155,61 @@ function stringMatcherToRule(
|
||||
return { field, operator: "is", values: [] };
|
||||
}
|
||||
|
||||
function numberMatcherToRule(
|
||||
field: FilterField,
|
||||
matcher: NumberMatcher | undefined,
|
||||
): FilterRule | null {
|
||||
if (matcher === undefined) return null;
|
||||
if (matcher === null) return { field, operator: "is", values: [] };
|
||||
if (typeof matcher === "number")
|
||||
return { field, operator: "is", values: [String(matcher)] };
|
||||
if (matcher.$eq !== undefined && matcher.$eq !== null)
|
||||
return { field, operator: "is", values: [String(matcher.$eq)] };
|
||||
if (matcher.$ne !== undefined && matcher.$ne !== null)
|
||||
return { field, operator: "is_not", values: [String(matcher.$ne)] };
|
||||
if (matcher.$in !== undefined)
|
||||
return { field, operator: "in", values: matcher.$in.map(String) };
|
||||
if (matcher.$nin !== undefined)
|
||||
return { field, operator: "not_in", values: matcher.$nin.map(String) };
|
||||
if (matcher.$gt !== undefined)
|
||||
return { field, operator: "gt", values: [String(matcher.$gt)] };
|
||||
if (matcher.$gte !== undefined)
|
||||
return { field, operator: "gte", values: [String(matcher.$gte)] };
|
||||
if (matcher.$lt !== undefined)
|
||||
return { field, operator: "lt", values: [String(matcher.$lt)] };
|
||||
if (matcher.$lte !== undefined)
|
||||
return { field, operator: "lte", values: [String(matcher.$lte)] };
|
||||
return { field, operator: "is", values: [] };
|
||||
}
|
||||
|
||||
function ruleToNumberMatcher(rule: FilterRule): NumberMatcher | undefined {
|
||||
const nums = rule.values
|
||||
.map((v) => Number.parseFloat(v))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
if (nums.length === 0) return undefined;
|
||||
const first = nums[0];
|
||||
switch (rule.operator) {
|
||||
case "is":
|
||||
return nums.length > 1 ? { $in: nums } : first;
|
||||
case "is_not":
|
||||
return { $ne: first };
|
||||
case "in":
|
||||
return { $in: nums };
|
||||
case "not_in":
|
||||
return { $nin: nums };
|
||||
case "gt":
|
||||
return { $gt: first };
|
||||
case "gte":
|
||||
return { $gte: first };
|
||||
case "lt":
|
||||
return { $lt: first };
|
||||
case "lte":
|
||||
return { $lte: first };
|
||||
default:
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
function ruleToStringMatcher(rule: FilterRule): StringMatcher {
|
||||
if (
|
||||
rule.operator === "in" ||
|
||||
@@ -192,6 +265,9 @@ export function planFilterToGroups(filter: PlanFilter): FilterGroupData[] {
|
||||
const planIdRule = stringMatcherToRule("plan_id", filter.plan_id);
|
||||
if (planIdRule) mainRules.push(planIdRule);
|
||||
|
||||
const versionRule = numberMatcherToRule("version", filter.version);
|
||||
if (versionRule) mainRules.push(versionRule);
|
||||
|
||||
if (filter.paid !== undefined)
|
||||
mainRules.push(booleanRule("paid", filter.paid));
|
||||
|
||||
@@ -272,6 +348,9 @@ export function groupsToPlanFilter(groups: FilterGroupData[]): PlanFilter {
|
||||
case "plan_id":
|
||||
filter.plan_id = ruleToStringMatcher(rule);
|
||||
break;
|
||||
case "version":
|
||||
filter.version = ruleToNumberMatcher(rule);
|
||||
break;
|
||||
case "paid":
|
||||
filter.paid = rule.values[0] === "true";
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user