From 5d746849458f248f2019d25fda22647050f8552a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 8 May 2026 14:38:52 +0800 Subject: [PATCH] feat: list entities --- .../v2.1/contracts/entitiesContract.ts | 59 ++ packages/openapi/v2.1/contracts/index.ts | 2 + packages/openapi/v2.1/jsDocs/entityJsDocs.ts | 28 + server/src/external/autumn/autumnCli.ts | 19 + .../getEntityAggregateFragments.ts | 20 +- .../getFullSubject/getFullSubjectQuery.ts | 543 +++++------------- .../getFullSubject/getFullSubjectRowsQuery.ts | 462 +++++++++++++++ server/src/internal/entities/entityRouter.ts | 2 + .../entities/handlers/handleListEntitiesV2.ts | 140 +++++ .../entities/repos/listEntitiesQuery.ts | 215 +++++++ .../misc/rateLimiter/rateLimitConfigs.ts | 1 + .../misc/replicaDb/replicaDbConfigs.ts | 1 + .../crud/entities/list-entities.test.ts | 197 +++++++ .../full-subject-ordering.test.ts | 335 +++++++++++ .../integration/scopes/scope-403.test.ts | 19 + shared/api/entities/crud/index.ts | 1 + .../api/entities/crud/listEntitiesParams.ts | 38 ++ 17 files changed, 1682 insertions(+), 400 deletions(-) create mode 100644 server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts create mode 100644 server/src/internal/entities/handlers/handleListEntitiesV2.ts create mode 100644 server/src/internal/entities/repos/listEntitiesQuery.ts create mode 100644 server/tests/integration/crud/entities/list-entities.test.ts create mode 100644 server/tests/integration/db/full-subject/full-subject-ordering.test.ts create mode 100644 shared/api/entities/crud/listEntitiesParams.ts diff --git a/packages/openapi/v2.1/contracts/entitiesContract.ts b/packages/openapi/v2.1/contracts/entitiesContract.ts index 3a12e02f9..31158e294 100644 --- a/packages/openapi/v2.1/contracts/entitiesContract.ts +++ b/packages/openapi/v2.1/contracts/entitiesContract.ts @@ -1,17 +1,21 @@ import { SuccessResponseSchema } from "@api/common/commonResponses.js"; +import { createPagePaginatedResponseSchema } from "@api/common/pagePaginationSchemas.js"; import { API_BALANCE_V1_EXAMPLE, ApiEntityV2Schema, CreateEntityParamsV1Schema, DeleteEntityParamsV0Schema, GetEntityParamsV0Schema, + ListEntitiesParamsSchema, UpdateEntityParamsSchema, } from "@autumn/shared"; import { oc } from "@orpc/contract"; +import { z } from "zod/v4"; import { createEntityJsDoc, deleteEntityJsDoc, getEntityJsDoc, + listEntityJsDoc, updateEntityJsDoc, } from "../jsDocs/entityJsDocs"; @@ -108,6 +112,61 @@ export const getEntityContract = oc }), ); +export const listEntitiesContract = oc + .route({ + method: "POST", + path: "/v1/entities.list", + operationId: "listEntities", + tags: ["entities"], + description: listEntityJsDoc, + spec: (spec) => ({ + ...spec, + "x-speakeasy-name-override": "list", + }), + }) + .input( + ListEntitiesParamsSchema.optional().meta({ + title: "ListEntitiesParams", + examples: [ + { + limit: 10, + offset: 0, + }, + { + plans: [{ id: "pro_plan" }], + }, + ], + }), + ) + .output( + createPagePaginatedResponseSchema(ApiEntityV2Schema) + .extend({ + total_count: z + .number() + .describe( + "Total number of entities available in the current organization and environment", + ), + total_filtered_count: z + .number() + .describe( + "Total number of entities matching the current filter before pagination is applied", + ), + }) + .meta({ + examples: [ + { + list: [API_ENTITY_V2_EXAMPLE], + has_more: false, + offset: 0, + total: 1, + limit: 10, + total_count: 100, + total_filtered_count: 42, + }, + ], + }), + ); + export const updateEntityContract = oc .route({ method: "POST", diff --git a/packages/openapi/v2.1/contracts/index.ts b/packages/openapi/v2.1/contracts/index.ts index 02c03004c..1997ec401 100644 --- a/packages/openapi/v2.1/contracts/index.ts +++ b/packages/openapi/v2.1/contracts/index.ts @@ -29,6 +29,7 @@ import { createEntityContract, deleteEntityContract, getEntityContract, + listEntitiesContract, updateEntityContract, } from "./entitiesContract.js"; import { @@ -102,6 +103,7 @@ export const v2_1ContractRouter = oc.router({ // Entities entitiesCreate: createEntityContract, entitiesGet: getEntityContract, + entitiesList: listEntitiesContract, entitiesUpdate: updateEntityContract, entitiesDelete: deleteEntityContract, diff --git a/packages/openapi/v2.1/jsDocs/entityJsDocs.ts b/packages/openapi/v2.1/jsDocs/entityJsDocs.ts index fc9b6fc57..f628fbbfc 100644 --- a/packages/openapi/v2.1/jsDocs/entityJsDocs.ts +++ b/packages/openapi/v2.1/jsDocs/entityJsDocs.ts @@ -2,6 +2,7 @@ import { CreateEntityParamsV1Schema, DeleteEntityParamsV0Schema, GetEntityParamsV0Schema, + ListEntitiesParamsSchema, UpdateEntityParamsSchema, } from "@autumn/shared"; import { createJSDocDescription, example } from "../../utils/jsDocs/index.js"; @@ -53,6 +54,33 @@ export const getEntityJsDoc = createJSDocDescription({ "The entity object including its current subscriptions, purchases, and balances.", }); +export const listEntityJsDoc = createJSDocDescription({ + description: + "Lists entities across the organization with pagination and optional filters.", + whenToUse: + "Use this to page through entities globally, including filtering by plans inherited from parent customers or attached directly to entities.", + body: ListEntitiesParamsSchema, + examples: [ + example({ + description: "List entities on a plan", + values: { + plans: [{ id: "pro_plan" }], + limit: 10, + offset: 0, + }, + }), + example({ + description: "Search entities by ID or name", + values: { + search: "workspace", + }, + }), + ], + methodName: "entities.list", + returns: + "A paginated list of entity objects including their current subscriptions, purchases, balances, and flags.", +}); + export const deleteEntityJsDoc = createJSDocDescription({ description: "Deletes an entity by entity ID.", whenToUse: diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 6b8874da6..d79073db3 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -33,6 +33,7 @@ import { ErrCode, type FinalizeLockParamsV0, type LegacyVersion, + type ListEntitiesParams, type OrgConfig, type ProductItem, type RestoreParamsV1, @@ -659,6 +660,24 @@ export class AutumnInt { }; entitiesV2 = { + list: async ( + params?: Partial & { + keepInternalFields?: boolean; + }, + ) => { + const { keepInternalFields, ...listParams } = params ?? {}; + const headers: Record = {}; + if (keepInternalFields) { + headers["x-strip-internal"] = "false"; + } + + return (await this.post( + `/entities.list`, + listParams, + Object.keys(headers).length > 0 ? headers : undefined, + )) as TResponse; + }, + create: async ({ customer_id, entity_id, diff --git a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts index 3534fa661..2890f51fc 100644 --- a/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts +++ b/server/src/internal/customers/repos/getFullSubject/getEntityAggregateFragments.ts @@ -50,7 +50,9 @@ export const getEntityAggregateFragments = ({ const featureFilter = internalFeatureIds && internalFeatureIds.length > 0 ? sql`AND ce.internal_feature_id = ANY(ARRAY[${sql.join( - internalFeatureIds.map((internalFeatureId) => sql`${internalFeatureId}`), + internalFeatureIds.map( + (internalFeatureId) => sql`${internalFeatureId}`, + ), sql`, `, )}])` : sql``; @@ -237,13 +239,17 @@ export const getEntityAggregateFragments = ({ const productRefsUnion = sql` UNION ALL - SELECT ecp.internal_customer_id, ecp.internal_product_id + SELECT + ecp.internal_customer_id AS subject_key, + ecp.internal_customer_id, + ecp.internal_product_id FROM entity_distinct_cus_products ecp `; const entitlementRefsUnion = sql` UNION SELECT DISTINCT + ce.internal_customer_id AS subject_key, ce.internal_customer_id, ce.entitlement_id FROM entity_level_cus_ents ce @@ -251,7 +257,10 @@ export const getEntityAggregateFragments = ({ const priceRefsUnion = sql` UNION ALL - SELECT ecpr.price_id, ecp.internal_customer_id + SELECT + ecp.internal_customer_id AS subject_key, + ecpr.price_id, + ecp.internal_customer_id FROM entity_cus_prices ecpr JOIN entity_distinct_cus_products ecp ON ecp.id = ecpr.customer_product_id @@ -259,7 +268,10 @@ export const getEntityAggregateFragments = ({ const freeTrialRefsUnion = sql` UNION ALL - SELECT ecp.free_trial_id, ecp.internal_customer_id + SELECT + ecp.internal_customer_id AS subject_key, + ecp.free_trial_id, + ecp.internal_customer_id FROM entity_distinct_cus_products ecp WHERE ecp.free_trial_id IS NOT NULL `; diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts index bb09215fa..e608246d5 100644 --- a/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectQuery.ts @@ -1,71 +1,152 @@ import type { AppEnv, CusProductStatus } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js"; -import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js"; +import { getFullSubjectRowsQuery } from "./getFullSubjectRowsQuery.js"; -const getCustomerOrEntityCTE = ({ +const getCustomerSubjectRecordsCte = ({ orgId, env, - entityId, - entityOnlyLookup, + customerId, customerFilter, customerPagination, }: { orgId: string; env: AppEnv; - entityId?: string; - entityOnlyLookup: boolean; + customerId?: string; customerFilter: SQL; customerPagination: SQL; -}): SQL => { - if (entityOnlyLookup && entityId) { +}) => sql` + WITH customer_records AS ( + SELECT c.* + FROM customers c + WHERE c.org_id = ${orgId} + AND c.env = ${env} + ${customerFilter} + ${customerPagination} + ), + + subject_records AS ( + SELECT + c.internal_id AS subject_key, + c.internal_id AS internal_customer_id, + NULL::text AS internal_entity_id, + ROW_NUMBER() OVER ( + ORDER BY ${ + customerId ? sql`(c.id = ${customerId}) DESC` : sql`c.created_at DESC` + } + ) AS subject_order + FROM customer_records c + ) +`; + +const getEntityOnlySubjectRecordsCte = ({ + orgId, + env, + entityId, +}: { + orgId: string; + env: AppEnv; + entityId: string; +}) => sql` + WITH entity_record AS ( + SELECT e.* + FROM entities e + WHERE e.org_id = ${orgId} + AND e.env = ${env} + AND (e.id = ${entityId} OR e.internal_id = ${entityId}) + LIMIT 1 + ), + + subject_records AS ( + SELECT + e.internal_id AS subject_key, + e.internal_customer_id, + e.internal_id AS internal_entity_id, + 1 AS subject_order + FROM entity_record e + ) +`; + +const getCustomerEntitySubjectRecordsCte = ({ + orgId, + env, + entityId, + customerFilter, + customerPagination, + allowMissingEntity, +}: { + orgId: string; + env: AppEnv; + entityId: string; + customerFilter: SQL; + customerPagination: SQL; + allowMissingEntity: boolean; +}) => { + if (allowMissingEntity) { return sql` - WITH entity_record AS ( - SELECT e.* - FROM entities e - WHERE e.org_id = ${orgId} - AND e.env = ${env} - AND (e.id = ${entityId} OR e.internal_id = ${entityId}) - LIMIT 1 - ), - subject_customer_records AS ( - SELECT c.* - FROM customers c - WHERE c.internal_id = (SELECT internal_customer_id FROM entity_record LIMIT 1) - )`; + WITH customer_records AS ( + SELECT c.* + FROM customers c + WHERE c.org_id = ${orgId} + AND c.env = ${env} + ${customerFilter} + ${customerPagination} + ), + + entity_record AS ( + SELECT e.* + FROM entities e + WHERE e.internal_customer_id IN ( + SELECT internal_id + FROM customer_records + ) + AND (e.id = ${entityId} OR e.internal_id = ${entityId}) + LIMIT 1 + ), + + subject_records AS ( + SELECT + COALESCE(e.internal_id, c.internal_id) AS subject_key, + c.internal_id AS internal_customer_id, + e.internal_id AS internal_entity_id, + 1 AS subject_order + FROM customer_records c + LEFT JOIN entity_record e + ON e.internal_customer_id = c.internal_id + ) + `; } - if (entityId) { - return sql` - WITH subject_customer_records AS ( - SELECT * + return sql` + WITH customer_records AS ( + SELECT c.* FROM customers c WHERE c.org_id = ${orgId} AND c.env = ${env} ${customerFilter} ${customerPagination} ), + entity_record AS ( SELECT e.* FROM entities e WHERE e.internal_customer_id IN ( SELECT internal_id - FROM subject_customer_records + FROM customer_records ) AND (e.id = ${entityId} OR e.internal_id = ${entityId}) LIMIT 1 - )`; - } + ), - return sql` - WITH subject_customer_records AS ( - SELECT * - FROM customers c - WHERE c.org_id = ${orgId} - AND c.env = ${env} - ${customerFilter} - ${customerPagination} - )`; + subject_records AS ( + SELECT + e.internal_id AS subject_key, + e.internal_customer_id, + e.internal_id AS internal_entity_id, + 1 AS subject_order + FROM entity_record e + ) + `; }; export const getFullSubjectQuery = ({ @@ -98,14 +179,6 @@ export const getFullSubjectQuery = ({ const offset = pagination.offset ?? 0; const entityOnlyLookup = !!entityId && !customerId; - const statusFilter = - inStatuses.length > 0 - ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), - sql`, `, - )}])` - : sql``; - const customerFilter = customerId ? sql`AND (c.id = ${customerId} OR c.internal_id = ${customerId})` : sql``; @@ -121,356 +194,34 @@ export const getFullSubjectQuery = ({ OFFSET ${offset} `; - const leadingCtes = getCustomerOrEntityCTE({ - orgId, - env, - entityId, - entityOnlyLookup, - customerFilter, - customerPagination, + const leadingCtes = + entityOnlyLookup && entityId + ? getEntityOnlySubjectRecordsCte({ + orgId, + env, + entityId, + }) + : entityId + ? getCustomerEntitySubjectRecordsCte({ + orgId, + env, + entityId, + customerFilter, + customerPagination, + allowMissingEntity, + }) + : getCustomerSubjectRecordsCte({ + orgId, + env, + customerId, + customerFilter, + customerPagination, + }); + + return getFullSubjectRowsQuery({ + leadingCtes, + inStatuses, + includeInvoices: !entityId, + includeEntityAggregations: !entityId, }); - - const customerProductEntityFilter = entityId - ? sql`AND (cp.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) - OR cp.internal_entity_id IS NULL)` - : sql`AND cp.internal_entity_id IS NULL`; - - const entityFragments = getEntityAggregateFragments({ - entityId, - statusFilter, - }); - - const allowEntityFallback = - allowMissingEntity && !!customerId && !entityOnlyLookup; - - const subjectCustomerFilter = - entityId && !allowEntityFallback - ? sql` - WHERE scr.internal_id = ( - SELECT internal_customer_id - FROM entity_record - LIMIT 1 - ) - ` - : sql``; - - const extraCustomerEntitlementEntityFilter = entityId - ? sql` - AND ( - ce.internal_entity_id IS NULL - OR ce.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) - ) - ` - : sql`AND ce.internal_entity_id IS NULL`; - - const subscriptionsCte = sql`, - - customer_subscriptions AS ( - SELECT DISTINCT s.* - FROM cus_products cp - JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true - JOIN subscriptions s ON s.stripe_id = cp_sub.stripe_id - )`; - - const invoicesCte = entityId - ? sql`` - : sql`, - - customer_invoices AS ( - SELECT * - FROM invoices i - WHERE i.internal_customer_id IN (SELECT internal_id FROM subject_customer_records) - ORDER BY i.created_at DESC, i.id DESC - LIMIT 10 - )`; - - const subscriptionsSelect = sql`, - - COALESCE( - ( - SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL) - FROM customer_subscriptions cs - ), - '[]'::json - ) AS subscriptions`; - - const invoicesSelect = entityId - ? sql`` - : sql`, - - COALESCE( - ( - SELECT json_agg(row_to_json(ci) ORDER BY ci.created_at DESC, ci.id DESC) - FILTER (WHERE ci.id IS NOT NULL) - FROM customer_invoices ci - WHERE ci.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS invoices`; - - const entitySelect = entityId - ? sql`, - - (SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity` - : sql``; - - return sql` - ${leadingCtes} - , - - cus_products AS ( - SELECT cp.* - FROM customer_products cp - JOIN subject_customer_records scr - ON cp.internal_customer_id = scr.internal_id - WHERE 1 = 1 - ${customerProductEntityFilter} - ${statusFilter} - ), - - cus_entitlements AS ( - SELECT ce.* - FROM customer_entitlements ce - WHERE ce.customer_product_id IN (SELECT id FROM cus_products) - ), - - extra_cus_entitlements AS ( - SELECT ce.* - FROM customer_entitlements ce - JOIN subject_customer_records scr - ON ce.internal_customer_id = scr.internal_id - WHERE ce.customer_product_id IS NULL - AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - AND ( - ce.balance != 0 - OR ce.unlimited IS TRUE - OR EXISTS ( - SELECT 1 - FROM entitlements e - JOIN features f ON f.internal_id = e.internal_feature_id - WHERE e.id = ce.entitlement_id - AND f.type = 'boolean' - ) - ) - ${extraCustomerEntitlementEntityFilter} - LIMIT 20 - ), - - all_cus_ent_ids AS ( - SELECT id FROM cus_entitlements - UNION ALL - SELECT id FROM extra_cus_entitlements - ), - - cus_rollovers AS ( - SELECT ro.* - FROM rollovers ro - WHERE ro.cus_ent_id IN (SELECT id FROM all_cus_ent_ids) - AND (ro.expires_at IS NULL OR ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000) - ), - - cus_replaceables AS ( - SELECT rep.* - FROM replaceables rep - WHERE rep.cus_ent_id IN (SELECT id FROM all_cus_ent_ids) - ), - - cus_prices AS ( - SELECT cpr.* - FROM customer_prices cpr - WHERE cpr.customer_product_id IN (SELECT id FROM cus_products) - ) - - ${subscriptionsCte} - ${invoicesCte} - ${entityFragments.ctes} - , - - distinct_products AS ( - SELECT DISTINCT ON (src.internal_customer_id, p.internal_id) - src.internal_customer_id, - p.* - FROM products p - JOIN ( - SELECT cp.internal_customer_id, cp.internal_product_id FROM cus_products cp - ${entityFragments.productRefsUnion} - ) src ON p.internal_id = src.internal_product_id - ORDER BY src.internal_customer_id, p.internal_id - ), - - relevant_entitlement_records AS ( - SELECT DISTINCT - ce.internal_customer_id, - ce.entitlement_id - FROM cus_entitlements ce - UNION - SELECT DISTINCT - ece.internal_customer_id, - ece.entitlement_id - FROM extra_cus_entitlements ece - ${entityFragments.entitlementRefsUnion} - ), - - distinct_entitlements AS ( - SELECT - rer.internal_customer_id, - e.*, - row_to_json(f) AS feature - FROM relevant_entitlement_records rer - JOIN entitlements e - ON e.id = rer.entitlement_id - JOIN features f - ON e.internal_feature_id = f.internal_id - ), - - distinct_prices AS ( - SELECT DISTINCT ON (src.internal_customer_id, p.id) - src.internal_customer_id, - p.* - FROM prices p - JOIN ( - SELECT cpr.price_id, cp.internal_customer_id - FROM cus_prices cpr - JOIN cus_products cp ON cp.id = cpr.customer_product_id - ${entityFragments.priceRefsUnion} - ) src ON p.id = src.price_id - ORDER BY src.internal_customer_id, p.id - ), - - distinct_free_trials AS ( - SELECT DISTINCT ON (src.internal_customer_id, ft.id) - src.internal_customer_id, - ft.* - FROM free_trials ft - JOIN ( - SELECT cp.free_trial_id, cp.internal_customer_id - FROM cus_products cp - WHERE cp.free_trial_id IS NOT NULL - ${entityFragments.freeTrialRefsUnion} - ) src ON ft.id = src.free_trial_id - ORDER BY src.internal_customer_id, ft.id - ) - - SELECT - row_to_json(scr) AS customer, - - COALESCE( - ( - SELECT json_agg(row_to_json(cp)) - FROM cus_products cp - WHERE cp.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS customer_products, - - COALESCE( - ( - SELECT json_agg(row_to_json(ce)) - FROM cus_entitlements ce - WHERE ce.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS customer_entitlements, - - COALESCE( - ( - SELECT json_agg(row_to_json(cpr)) - FROM cus_prices cpr - JOIN cus_products cp - ON cp.id = cpr.customer_product_id - WHERE cp.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS customer_prices, - - COALESCE( - ( - SELECT json_agg(row_to_json(ece)) - FROM extra_cus_entitlements ece - WHERE ece.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS extra_customer_entitlements, - - COALESCE( - ( - SELECT json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC) - FROM cus_replaceables rep - WHERE rep.cus_ent_id IN ( - SELECT ce.id - FROM cus_entitlements ce - WHERE ce.internal_customer_id = scr.internal_id - UNION ALL - SELECT ece.id - FROM extra_cus_entitlements ece - WHERE ece.internal_customer_id = scr.internal_id - ) - ), - '[]'::json - ) AS replaceables, - - COALESCE( - ( - SELECT json_agg( - row_to_json(ro) - ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC - ) - FROM cus_rollovers ro - WHERE ro.cus_ent_id IN ( - SELECT ce.id - FROM cus_entitlements ce - WHERE ce.internal_customer_id = scr.internal_id - UNION ALL - SELECT ece.id - FROM extra_cus_entitlements ece - WHERE ece.internal_customer_id = scr.internal_id - ) - ), - '[]'::json - ) AS rollovers, - - COALESCE( - ( - SELECT json_agg((row_to_json(p)::jsonb - 'internal_customer_id')::json) - FROM distinct_products p - WHERE p.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS products, - - COALESCE( - ( - SELECT json_agg((row_to_json(ent)::jsonb - 'internal_customer_id')::json) - FROM distinct_entitlements ent - WHERE ent.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS entitlements, - - COALESCE( - ( - SELECT json_agg((row_to_json(pr)::jsonb - 'internal_customer_id')::json) - FROM distinct_prices pr - WHERE pr.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS prices, - - COALESCE( - ( - SELECT json_agg((row_to_json(ft)::jsonb - 'internal_customer_id')::json) - FROM distinct_free_trials ft - WHERE ft.internal_customer_id = scr.internal_id - ), - '[]'::json - ) AS free_trials - - ${subscriptionsSelect} - ${invoicesSelect} - ${entitySelect} - ${entityFragments.selectColumns} - - FROM subject_customer_records scr - ${subjectCustomerFilter} - `; }; diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts new file mode 100644 index 000000000..c6a481ebc --- /dev/null +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts @@ -0,0 +1,462 @@ +import { type CusProductStatus, RELEVANT_STATUSES } from "@autumn/shared"; +import { type SQL, sql } from "drizzle-orm"; +import { getEntityAggregateFragments } from "./getEntityAggregateFragments.js"; + +const CUSTOMER_PRODUCT_LIMIT = 50; +const EXTRA_CUSTOMER_ENTITLEMENT_LIMIT = 30; + +const emptyEntityFragments = { + ctes: sql``, + productRefsUnion: sql``, + entitlementRefsUnion: sql``, + priceRefsUnion: sql``, + freeTrialRefsUnion: sql``, + selectColumns: sql``, +}; + +export const getFullSubjectRowsQuery = ({ + leadingCtes, + inStatuses, + includeInvoices, + includeEntityAggregations, +}: { + leadingCtes: SQL; + inStatuses: CusProductStatus[]; + includeInvoices: boolean; + includeEntityAggregations: boolean; +}) => { + const statusFilter = + inStatuses.length > 0 + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + + const relevantStatusFirst = sql`CASE WHEN cp.status = ANY(ARRAY[${sql.join( + RELEVANT_STATUSES.map((status) => sql`${status}`), + sql`, `, + )}]) THEN 0 ELSE 1 END`; + + const hasCustomerPrices = sql`EXISTS ( + SELECT 1 + FROM customer_prices cpr_exists + WHERE cpr_exists.customer_product_id = cp.id + )`; + + const entityFragments = includeEntityAggregations + ? getEntityAggregateFragments({ + statusFilter, + }) + : emptyEntityFragments; + + const invoicesCte = includeInvoices + ? sql`, + + customer_invoices AS ( + SELECT * + FROM invoices i + WHERE i.internal_customer_id IN ( + SELECT internal_customer_id + FROM subject_records + ) + ORDER BY i.created_at DESC, i.id DESC + LIMIT 10 + )` + : sql``; + + const invoicesSelect = includeInvoices + ? sql`, + + COALESCE( + ( + SELECT json_agg(row_to_json(ci) ORDER BY ci.created_at DESC, ci.id DESC) + FILTER (WHERE ci.id IS NOT NULL) + FROM customer_invoices ci + WHERE ci.internal_customer_id = sr.internal_customer_id + ), + '[]'::json + ) AS invoices` + : sql``; + + return sql` + ${leadingCtes} + , + + subject_customer_records AS ( + SELECT DISTINCT c.* + FROM customers c + JOIN subject_records sr + ON sr.internal_customer_id = c.internal_id + ), + + all_cus_products AS ( + SELECT + cp_candidates.*, + ROW_NUMBER() OVER ( + PARTITION BY cp_candidates.subject_key + ORDER BY + cp_candidates.subject_entity_priority ASC, + cp_candidates.status_priority ASC, + cp_candidates.has_customer_prices DESC, + cp_candidates.product_is_add_on ASC, + cp_candidates.created_at DESC + ) AS subject_rank + FROM subject_records sr + JOIN LATERAL ( + SELECT + sr.subject_key, + CASE + WHEN sr.internal_entity_id IS NOT NULL + AND cp.internal_entity_id = sr.internal_entity_id + THEN 0 + ELSE 1 + END AS subject_entity_priority, + ${relevantStatusFirst} AS status_priority, + ${hasCustomerPrices} AS has_customer_prices, + prod.is_add_on AS product_is_add_on, + cp.* + FROM customer_products cp + JOIN products prod + ON prod.internal_id = cp.internal_product_id + WHERE cp.internal_customer_id = sr.internal_customer_id + AND ( + (sr.internal_entity_id IS NULL AND cp.internal_entity_id IS NULL) + OR + (sr.internal_entity_id IS NOT NULL AND ( + cp.internal_entity_id IS NULL + OR cp.internal_entity_id = sr.internal_entity_id + )) + ) + ${statusFilter} + ) cp_candidates ON true + ), + + cus_products AS ( + SELECT * + FROM all_cus_products + WHERE subject_rank <= ${CUSTOMER_PRODUCT_LIMIT} + ), + + cus_entitlements AS ( + SELECT + cp.subject_key, + ce.* + FROM customer_entitlements ce + JOIN cus_products cp + ON cp.id = ce.customer_product_id + ), + + extra_cus_entitlements AS ( + SELECT ce_ordered.* + FROM subject_records sr + JOIN LATERAL ( + SELECT + sr.subject_key, + CASE + WHEN sr.internal_entity_id IS NOT NULL + AND ce.internal_entity_id = sr.internal_entity_id + THEN 0 + ELSE 1 + END AS subject_entity_priority, + ce.* + FROM customer_entitlements ce + WHERE ce.internal_customer_id = sr.internal_customer_id + AND ce.customer_product_id IS NULL + AND (ce.expires_at IS NULL OR ce.expires_at > EXTRACT(EPOCH FROM now()) * 1000) + AND ( + ce.balance != 0 + OR ce.unlimited IS TRUE + OR EXISTS ( + SELECT 1 + FROM entitlements e + JOIN features f ON f.internal_id = e.internal_feature_id + WHERE e.id = ce.entitlement_id + AND f.type = 'boolean' + ) + ) + AND ( + (sr.internal_entity_id IS NULL AND ce.internal_entity_id IS NULL) + OR + (sr.internal_entity_id IS NOT NULL AND ( + ce.internal_entity_id IS NULL + OR ce.internal_entity_id = sr.internal_entity_id + )) + ) + ORDER BY subject_entity_priority ASC, ce.id DESC + LIMIT ${EXTRA_CUSTOMER_ENTITLEMENT_LIMIT} + ) ce_ordered ON true + ), + + all_cus_ent_ids AS ( + SELECT subject_key, id FROM cus_entitlements + UNION ALL + SELECT subject_key, id FROM extra_cus_entitlements + ), + + cus_rollovers AS ( + SELECT ro.* + FROM rollovers ro + WHERE ro.cus_ent_id IN (SELECT id FROM all_cus_ent_ids) + AND (ro.expires_at IS NULL OR ro.expires_at > EXTRACT(EPOCH FROM now()) * 1000) + ), + + cus_replaceables AS ( + SELECT rep.* + FROM replaceables rep + WHERE rep.cus_ent_id IN (SELECT id FROM all_cus_ent_ids) + ), + + cus_prices AS ( + SELECT + cp.subject_key, + cpr.* + FROM customer_prices cpr + JOIN cus_products cp + ON cp.id = cpr.customer_product_id + ) + + ${invoicesCte} + ${entityFragments.ctes} + , + + distinct_products AS ( + SELECT DISTINCT ON (src.subject_key, p.internal_id) + src.subject_key, + src.internal_customer_id, + p.* + FROM products p + JOIN ( + SELECT + cp.subject_key, + cp.internal_customer_id, + cp.internal_product_id + FROM cus_products cp + ${entityFragments.productRefsUnion} + ) src ON p.internal_id = src.internal_product_id + ORDER BY src.subject_key, p.internal_id + ), + + relevant_entitlement_records AS ( + SELECT DISTINCT + ce.subject_key, + ce.internal_customer_id, + ce.entitlement_id + FROM cus_entitlements ce + UNION + SELECT DISTINCT + ece.subject_key, + ece.internal_customer_id, + ece.entitlement_id + FROM extra_cus_entitlements ece + ${entityFragments.entitlementRefsUnion} + ), + + distinct_entitlements AS ( + SELECT + rer.subject_key, + rer.internal_customer_id, + e.*, + row_to_json(f) AS feature + FROM relevant_entitlement_records rer + JOIN entitlements e + ON e.id = rer.entitlement_id + JOIN features f + ON e.internal_feature_id = f.internal_id + ), + + distinct_prices AS ( + SELECT DISTINCT ON (src.subject_key, p.id) + src.subject_key, + src.internal_customer_id, + p.* + FROM prices p + JOIN ( + SELECT + cpr.subject_key, + cpr.price_id, + cp.internal_customer_id + FROM cus_prices cpr + JOIN cus_products cp ON cp.id = cpr.customer_product_id + AND cp.subject_key = cpr.subject_key + ${entityFragments.priceRefsUnion} + ) src ON p.id = src.price_id + ORDER BY src.subject_key, p.id + ), + + distinct_free_trials AS ( + SELECT DISTINCT ON (src.subject_key, ft.id) + src.subject_key, + src.internal_customer_id, + ft.* + FROM free_trials ft + JOIN ( + SELECT + cp.subject_key, + cp.free_trial_id, + cp.internal_customer_id + FROM cus_products cp + WHERE cp.free_trial_id IS NOT NULL + ${entityFragments.freeTrialRefsUnion} + ) src ON ft.id = src.free_trial_id + ORDER BY src.subject_key, ft.id + ) + + SELECT + row_to_json(scr) AS customer, + + COALESCE( + ( + SELECT json_agg( + ( + row_to_json(cp)::jsonb + - 'subject_key' + - 'subject_entity_priority' + - 'status_priority' + - 'has_customer_prices' + - 'product_is_add_on' + - 'subject_rank' + )::json + ORDER BY + cp.subject_entity_priority ASC, + cp.status_priority ASC, + cp.has_customer_prices DESC, + cp.product_is_add_on ASC, + cp.created_at DESC + ) + FROM cus_products cp + WHERE cp.subject_key = sr.subject_key + ), + '[]'::json + ) AS customer_products, + + COALESCE( + ( + SELECT json_agg((row_to_json(ce)::jsonb - 'subject_key')::json) + FROM cus_entitlements ce + WHERE ce.subject_key = sr.subject_key + ), + '[]'::json + ) AS customer_entitlements, + + COALESCE( + ( + SELECT json_agg((row_to_json(cpr)::jsonb - 'subject_key')::json) + FROM cus_prices cpr + WHERE cpr.subject_key = sr.subject_key + ), + '[]'::json + ) AS customer_prices, + + COALESCE( + ( + SELECT json_agg( + ( + row_to_json(ece)::jsonb + - 'subject_key' + - 'subject_entity_priority' + )::json + ORDER BY ece.subject_entity_priority ASC, ece.id DESC + ) + FROM extra_cus_entitlements ece + WHERE ece.subject_key = sr.subject_key + ), + '[]'::json + ) AS extra_customer_entitlements, + + COALESCE( + ( + SELECT json_agg(row_to_json(rep) ORDER BY rep.created_at ASC, rep.id ASC) + FROM cus_replaceables rep + WHERE rep.cus_ent_id IN ( + SELECT ace.id + FROM all_cus_ent_ids ace + WHERE ace.subject_key = sr.subject_key + ) + ), + '[]'::json + ) AS replaceables, + + COALESCE( + ( + SELECT json_agg( + row_to_json(ro) + ORDER BY ro.expires_at ASC NULLS LAST, ro.id ASC + ) + FROM cus_rollovers ro + WHERE ro.cus_ent_id IN ( + SELECT ace.id + FROM all_cus_ent_ids ace + WHERE ace.subject_key = sr.subject_key + ) + ), + '[]'::json + ) AS rollovers, + + COALESCE( + ( + SELECT json_agg((row_to_json(p)::jsonb - 'internal_customer_id' - 'subject_key')::json) + FROM distinct_products p + WHERE p.subject_key = sr.subject_key + ), + '[]'::json + ) AS products, + + COALESCE( + ( + SELECT json_agg((row_to_json(ent)::jsonb - 'internal_customer_id' - 'subject_key')::json) + FROM distinct_entitlements ent + WHERE ent.subject_key = sr.subject_key + ), + '[]'::json + ) AS entitlements, + + COALESCE( + ( + SELECT json_agg((row_to_json(pr)::jsonb - 'internal_customer_id' - 'subject_key')::json) + FROM distinct_prices pr + WHERE pr.subject_key = sr.subject_key + ), + '[]'::json + ) AS prices, + + COALESCE( + ( + SELECT json_agg((row_to_json(ft)::jsonb - 'internal_customer_id' - 'subject_key')::json) + FROM distinct_free_trials ft + WHERE ft.subject_key = sr.subject_key + ), + '[]'::json + ) AS free_trials, + + COALESCE( + ( + SELECT json_agg(row_to_json(cs)) FILTER (WHERE cs.stripe_id IS NOT NULL) + FROM ( + SELECT DISTINCT s.* + FROM cus_products cp + JOIN LATERAL unnest(cp.subscription_ids) AS cp_sub(stripe_id) ON true + JOIN subscriptions s + ON s.stripe_id = cp_sub.stripe_id + WHERE cp.subject_key = sr.subject_key + ) cs + ), + '[]'::json + ) AS subscriptions + + ${invoicesSelect}, + + CASE + WHEN er.internal_id IS NULL THEN NULL + ELSE row_to_json(er) + END AS entity + ${entityFragments.selectColumns} + + FROM subject_records sr + JOIN subject_customer_records scr + ON scr.internal_id = sr.internal_customer_id + LEFT JOIN entities er + ON er.internal_id = sr.internal_entity_id + ORDER BY sr.subject_order + `; +}; diff --git a/server/src/internal/entities/entityRouter.ts b/server/src/internal/entities/entityRouter.ts index 101d86c46..054c4c174 100644 --- a/server/src/internal/entities/entityRouter.ts +++ b/server/src/internal/entities/entityRouter.ts @@ -7,6 +7,7 @@ import { handleDeleteEntityV2 } from "./handlers/handleDeleteEntity/handleDelete import { handleGetEntity } from "./handlers/handleGetEntity/handleGetEntity.js"; import { handleGetEntityV2 } from "./handlers/handleGetEntity/handleGetEntityV2.js"; import { handleListEntities } from "./handlers/handleListEntities.js"; +import { handleListEntitiesV2 } from "./handlers/handleListEntitiesV2.js"; import { handleUpdateEntity } from "./handlers/handleUpdateEntity/handleUpdateEntity.js"; export const entityRouter = new Hono(); @@ -30,5 +31,6 @@ entityRouter.get("/customers/:customer_id/entities", ...handleListEntities); export const entityRpcRouter = new Hono(); entityRpcRouter.post("/entities.create", ...handleCreateEntityV2); entityRpcRouter.post("/entities.get", ...handleGetEntityV2); +entityRpcRouter.post("/entities.list", ...handleListEntitiesV2); entityRpcRouter.post("/entities.delete", ...handleDeleteEntityV2); entityRpcRouter.post("/entities.update", ...handleUpdateEntity); diff --git a/server/src/internal/entities/handlers/handleListEntitiesV2.ts b/server/src/internal/entities/handlers/handleListEntitiesV2.ts new file mode 100644 index 000000000..4fa3d5414 --- /dev/null +++ b/server/src/internal/entities/handlers/handleListEntitiesV2.ts @@ -0,0 +1,140 @@ +import { + AffectedResource, + ApiVersion, + type ApiEntityV2, + applyResponseVersionChanges, + type CusProductStatus, + type EntityLegacyData, + ListEntitiesParamsSchema, + type PagePaginatedResponse, + Scopes, + type SubjectQueryRow, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + ACTIVE_STATUSES, + RELEVANT_STATUSES, +} from "@/internal/customers/cusProducts/CusProductService.js"; +import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; +import { resultToFullSubject } from "@/internal/customers/repos/getFullSubject/index.js"; +import { getApiEntityBaseV2 } from "../entityUtils/getApiEntityV2/getApiEntityBaseV2.js"; +import { + countEntitiesByOrgIdAndEnv, + countFilteredEntitiesByOrgIdAndEnv, + getPaginatedEntitySubjectsQuery, + hasEntityListFilters, +} from "../repos/listEntitiesQuery.js"; + +const getListEntitiesStatuses = ({ + subscriptionStatus, +}: { + subscriptionStatus?: "active" | "scheduled"; +}): CusProductStatus[] => { + if (subscriptionStatus === "active") { + return ACTIVE_STATUSES; + } + + if (subscriptionStatus) { + return [subscriptionStatus as CusProductStatus]; + } + + return RELEVANT_STATUSES; +}; + +export const handleListEntitiesV2 = createRoute({ + scopes: [Scopes.Customers.Read], + versionedBody: { + latest: ListEntitiesParamsSchema, + [ApiVersion.V2_0]: ListEntitiesParamsSchema, + }, + resource: AffectedResource.Entity, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + const inStatuses = getListEntitiesStatuses({ + subscriptionStatus: body.subscription_status, + }); + const hasFilteredQuery = hasEntityListFilters({ + plans: body.plans, + processors: body.processors, + search: body.search, + }); + + const [subjectRows, totalCount] = await Promise.all([ + ctx.db.execute( + getPaginatedEntitySubjectsQuery({ + orgId: ctx.org.id, + env: ctx.env, + query: body, + inStatuses, + }), + ), + countEntitiesByOrgIdAndEnv({ ctx }), + ]); + + const totalFilteredCount = hasFilteredQuery + ? await countFilteredEntitiesByOrgIdAndEnv({ + ctx, + query: { + plans: body.plans, + processors: body.processors, + search: body.search, + }, + inStatuses, + }) + : totalCount; + + const entities = []; + for (const row of subjectRows) { + const fullSubject = resultToFullSubject({ + row: row as unknown as SubjectQueryRow, + entityIdRequested: true, + }); + + await lazyResetSubjectEntitlements({ + ctx, + fullSubject, + }); + + const { apiEntity: baseEntity, legacyData } = await getApiEntityBaseV2({ + ctx, + fullSubject, + withAutumnId: false, + }); + + const cleanedEntity: ApiEntityV2 = { + ...baseEntity, + feature_id: baseEntity.feature_id || undefined, + autumn_id: undefined, + invoices: undefined, + }; + + entities.push( + applyResponseVersionChanges({ + input: cleanedEntity, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Entity, + legacyData, + ctx, + }), + ); + } + + const hasMore = subjectRows.length === body.limit; + + return c.json< + PagePaginatedResponse<(typeof entities)[number]> & { + total_count: number; + total_filtered_count: number; + } + >({ + list: entities, + total: entities.length, + total_count: totalCount, + total_filtered_count: totalFilteredCount, + limit: body.limit, + offset: body.offset, + has_more: hasMore, + }); + }, +}); diff --git a/server/src/internal/entities/repos/listEntitiesQuery.ts b/server/src/internal/entities/repos/listEntitiesQuery.ts new file mode 100644 index 000000000..3d6755b02 --- /dev/null +++ b/server/src/internal/entities/repos/listEntitiesQuery.ts @@ -0,0 +1,215 @@ +import type { + AppEnv, + CusProductStatus, + ListEntitiesParams, +} from "@autumn/shared"; +import { type SQL, sql } from "drizzle-orm"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getFullSubjectRowsQuery } from "@/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.js"; + +export const hasEntityListFilters = ({ + plans, + processors, + search, +}: Pick) => { + return Boolean( + (plans && plans.length > 0) || + (processors && processors.length > 0) || + search?.trim(), + ); +}; + +const getEntityListFilterSql = ({ + plans, + processors, + search, + inStatuses, +}: Pick & { + inStatuses: CusProductStatus[]; +}) => { + const filters: SQL[] = []; + + if (plans && plans.length > 0) { + const planConditions = plans.map((plan) => { + if (plan.versions && plan.versions.length > 0) { + return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join( + plan.versions.map((version) => sql`${version}`), + sql`, `, + )}))`; + } + + return sql`p_filter.id = ${plan.id}`; + }); + + filters.push(sql`AND EXISTS ( + SELECT 1 + FROM customer_products cp_filter + JOIN products p_filter + ON p_filter.internal_id = cp_filter.internal_product_id + WHERE cp_filter.internal_customer_id = e.internal_customer_id + AND ( + cp_filter.internal_entity_id IS NULL + OR cp_filter.internal_entity_id = e.internal_id + ) + AND cp_filter.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}]) + AND (${sql.join(planConditions, sql` OR `)}) + )`); + } + + const trimmedSearch = search?.trim(); + if (trimmedSearch) { + const pattern = `%${trimmedSearch}%`; + filters.push(sql`AND ( + e.id ILIKE ${pattern} + OR e.name ILIKE ${pattern} + )`); + } + + if (processors && processors.length > 0) { + const processorConditions = processors + .map((proc) => { + if (proc === "stripe") return sql`(c.processor->>'id' IS NOT NULL)`; + if (proc === "revenuecat") + return sql`EXISTS ( + SELECT 1 + FROM customer_products cp_processor + WHERE cp_processor.internal_customer_id = c.internal_id + AND cp_processor.processor->>'type' = 'revenuecat' + )`; + if (proc === "vercel") + return sql`(c.processors->>'vercel' IS NOT NULL)`; + return null; + }) + .filter((condition): condition is SQL => condition !== null); + + if (processorConditions.length > 0) { + filters.push(sql`AND (${sql.join(processorConditions, sql` OR `)})`); + } + } + + return sql.join(filters, sql` `); +}; + +const getEntityListBaseSql = ({ + orgId, + env, + filterSql, +}: { + orgId: string; + env: AppEnv; + filterSql: SQL; +}) => sql` + FROM entities e + JOIN customers c + ON c.internal_id = e.internal_customer_id + WHERE e.org_id = ${orgId} + AND e.env = ${env} + AND c.org_id = ${orgId} + AND c.env = ${env} + ${filterSql} +`; + +export const getPaginatedEntitySubjectsQuery = ({ + orgId, + env, + query, + inStatuses, +}: { + orgId: string; + env: AppEnv; + query: ListEntitiesParams; + inStatuses: CusProductStatus[]; +}) => { + const filterSql = getEntityListFilterSql({ + plans: query.plans, + processors: query.processors, + search: query.search, + inStatuses, + }); + + const leadingCtes = sql` + WITH entity_records AS ( + SELECT e.* + ${getEntityListBaseSql({ + orgId, + env, + filterSql, + })} + ORDER BY e.internal_id DESC + LIMIT ${query.limit} + OFFSET ${query.offset} + ), + + subject_records AS ( + SELECT + er.internal_id AS subject_key, + er.internal_customer_id, + er.internal_id AS internal_entity_id, + ROW_NUMBER() OVER (ORDER BY er.internal_id DESC) AS subject_order + FROM entity_records er + ) + `; + + return getFullSubjectRowsQuery({ + leadingCtes, + inStatuses, + includeInvoices: false, + includeEntityAggregations: false, + }); +}; + +const countEntities = async ({ + ctx, + filterSql, +}: { + ctx: AutumnContext; + filterSql: SQL; +}) => { + const rows = await ctx.db.execute(sql` + SELECT COUNT(*) AS total_count + ${getEntityListBaseSql({ + orgId: ctx.org.id, + env: ctx.env, + filterSql, + })} + `); + const rawCount = (rows[0] as { total_count?: string | number } | undefined) + ?.total_count; + + return Number(rawCount ?? 0); +}; + +export const countEntitiesByOrgIdAndEnv = async ({ + ctx, +}: { + ctx: AutumnContext; +}) => { + return countEntities({ ctx, filterSql: sql`` }); +}; + +export const countFilteredEntitiesByOrgIdAndEnv = async ({ + ctx, + query, + inStatuses, +}: { + ctx: AutumnContext; + query: Pick; + inStatuses: CusProductStatus[]; +}) => { + if (!hasEntityListFilters(query)) { + return countEntitiesByOrgIdAndEnv({ ctx }); + } + + return countEntities({ + ctx, + filterSql: getEntityListFilterSql({ + plans: query.plans, + processors: query.processors, + search: query.search, + inStatuses, + }), + }); +}; diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts index 3c5496d44..a3a3b8212 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -58,6 +58,7 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [ route({ method: "GET", url: "/v1/customers" }), route({ method: "POST", url: "/v1/customers/list" }), route({ method: "POST", url: "/v1/customers.list" }), + route({ method: "POST", url: "/v1/entities.list" }), ], }, { diff --git a/server/src/internal/misc/replicaDb/replicaDbConfigs.ts b/server/src/internal/misc/replicaDb/replicaDbConfigs.ts index 5a3bd9910..297cb0524 100644 --- a/server/src/internal/misc/replicaDb/replicaDbConfigs.ts +++ b/server/src/internal/misc/replicaDb/replicaDbConfigs.ts @@ -15,6 +15,7 @@ const route = ({ method, url }: RoutePattern): RoutePattern => ({ const REPLICA_ROUTE_PATTERNS: RoutePattern[] = [ route({ method: "POST", url: "/v1/customers/list" }), route({ method: "POST", url: "/v1/customers.list" }), + route({ method: "POST", url: "/v1/entities.list" }), ]; export const shouldUseReplicaDb = (c: Context) => { diff --git a/server/tests/integration/crud/entities/list-entities.test.ts b/server/tests/integration/crud/entities/list-entities.test.ts new file mode 100644 index 000000000..42e10e235 --- /dev/null +++ b/server/tests/integration/crud/entities/list-entities.test.ts @@ -0,0 +1,197 @@ +/** + * TDD test for entities.list. + * + * Contract under test: + * New endpoints: + * - POST /v1/entities.list -> page-paginated full entity responses. + * New behaviors: + * - search matches entity id/name only, not parent customer fields. + * - plan filters include inherited customer-level products. + * - plan filters include matching entity-level products only for that entity. + * - trialing entity products are returned in full entity list responses. + * - subscription_status alone shapes hydrated products but does not reduce entity selection. + * Side effects: + * - none; endpoint is read-only. + * + * Pre-impl red: the RPC route and list handler do not exist. + * Post-impl green: all assertions pass through the FullSubject-backed entity list path. + */ + +import { expect, test } from "bun:test"; +import { + type ApiEntityV1, + ApiEntityV1Schema, + type ApiEntityV2, + type PagePaginatedResponse, +} from "@autumn/shared"; +import { ApiEntityV2Schema } from "@shared/api/entities/apiEntityV2.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +type ListEntitiesResponse = PagePaginatedResponse & { + total_count: number; + total_filtered_count: number; +}; + +test.concurrent(`${chalk.yellowBright("list entities: filters inherited and entity-level plans with trial entities")}`, async () => { + const customerId = "list-entities-contract"; + const entityPrefix = `${customerId}-entity`; + const alphaEntityId = `${entityPrefix}-alpha`; + const betaEntityId = `${entityPrefix}-beta`; + const gammaEntityId = `${entityPrefix}-gamma`; + const customerOnlyNeedle = "list-entities-customer-only-needle"; + + const inheritedProduct = products.base({ + id: "list-inherited", + items: [items.dashboard(), items.monthlyMessages({ includedUsage: 100 })], + }); + const trialEntityProduct = products.baseWithTrial({ + id: "list-trial-entity", + trialDays: 7, + cardRequired: false, + items: [items.monthlyCredits({ includedUsage: 200 })], + }); + const regularEntityProduct = products.base({ + id: "list-regular-entity", + items: [items.monthlyCredits({ includedUsage: 50 })], + }); + + const { autumnV1, autumnV2, autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ + name: customerOnlyNeedle, + }), + s.products({ + list: [inheritedProduct, trialEntityProduct, regularEntityProduct], + }), + ], + actions: [s.billing.attach({ productId: inheritedProduct.id })], + }); + + await autumnV2_1.entitiesV2.create({ + customer_id: customerId, + entity_id: alphaEntityId, + feature_id: TestFeature.Users, + name: "List Entities Alpha", + }); + await autumnV2_1.entitiesV2.create({ + customer_id: customerId, + entity_id: betaEntityId, + feature_id: TestFeature.Users, + name: "List Entities Beta Trial", + }); + await autumnV2_1.entitiesV2.create({ + customer_id: customerId, + entity_id: gammaEntityId, + feature_id: TestFeature.Users, + name: "List Entities Gamma", + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: trialEntityProduct.id, + entity_id: betaEntityId, + }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: regularEntityProduct.id, + entity_id: gammaEntityId, + }); + + // Contract: full V2.1 entity responses, pagination, and entity-only search. + const firstPage = await autumnV2_1.entitiesV2.list< + ListEntitiesResponse + >({ + search: entityPrefix, + limit: 2, + offset: 0, + keepInternalFields: true, + }); + expect(firstPage.total).toBe(2); + expect(firstPage.limit).toBe(2); + expect(firstPage.offset).toBe(0); + expect(firstPage.has_more).toBe(true); + expect(firstPage.total_filtered_count).toBe(3); + expect(firstPage.total_count).toBeGreaterThanOrEqual(3); + for (const entity of firstPage.list) { + ApiEntityV2Schema.parse(entity); + expect(entity.customer_id).toBe(customerId); + } + + const customerSearch = await autumnV2_1.entitiesV2.list< + ListEntitiesResponse + >({ + search: customerOnlyNeedle, + keepInternalFields: true, + }); + expect(customerSearch.total).toBe(0); + expect(customerSearch.total_filtered_count).toBe(0); + + // Contract: inherited customer-level product makes all three entities match. + const inheritedPlanPage = await autumnV2_1.entitiesV2.list< + ListEntitiesResponse + >({ + search: entityPrefix, + plans: [{ id: inheritedProduct.id }], + limit: 10, + keepInternalFields: true, + }); + expect(inheritedPlanPage.total_filtered_count).toBe(3); + expect(inheritedPlanPage.list.map((entity) => entity.id).sort()).toEqual([ + alphaEntityId, + betaEntityId, + gammaEntityId, + ]); + + // Contract: entity-level product only matches its owning entity, including trial products. + const trialPlanPage = await autumnV2_1.entitiesV2.list< + ListEntitiesResponse + >({ + search: entityPrefix, + plans: [{ id: trialEntityProduct.id }], + keepInternalFields: true, + }); + expect(trialPlanPage.total).toBe(1); + expect(trialPlanPage.total_filtered_count).toBe(1); + const trialEntity = trialPlanPage.list[0]; + ApiEntityV2Schema.parse(trialEntity); + expect(trialEntity.id).toBe(betaEntityId); + const trialSubscription = trialEntity.subscriptions.find( + (subscription) => subscription.plan_id === trialEntityProduct.id, + ); + expect(trialSubscription).toBeDefined(); + expect(trialSubscription?.trial_ends_at).toBeNumber(); + expect(trialEntity.balances[TestFeature.Credits]).toMatchObject({ + remaining: 200, + usage: 0, + }); + + // Contract: subscription_status alone does not reduce selected entities. + const scheduledOnlySelection = await autumnV2_1.entitiesV2.list< + ListEntitiesResponse + >({ + search: entityPrefix, + subscription_status: "scheduled", + limit: 10, + keepInternalFields: true, + }); + expect(scheduledOnlySelection.total).toBe(3); + expect(scheduledOnlySelection.total_filtered_count).toBe(3); + + // Contract: response versioning applies to each listed entity. + await timeout(1100); + const v2_0Page = await autumnV2.entitiesV2.list< + ListEntitiesResponse + >({ + search: betaEntityId, + keepInternalFields: true, + }); + expect(v2_0Page.total).toBe(1); + ApiEntityV1Schema.parse(v2_0Page.list[0]); + expect(v2_0Page.list[0].id).toBe(betaEntityId); +}); diff --git a/server/tests/integration/db/full-subject/full-subject-ordering.test.ts b/server/tests/integration/db/full-subject/full-subject-ordering.test.ts new file mode 100644 index 000000000..eafd9ae16 --- /dev/null +++ b/server/tests/integration/db/full-subject/full-subject-ordering.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from "bun:test"; +import { CusProductStatus } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { getFullSubject } from "@/internal/customers/repos/getFullSubject/index.js"; +import { buildEntitySubjectScenario } from "./utils/fullSubjectScenarioBuilders.js"; +import { withInsertedScenario } from "./utils/withInsertedScenario.js"; + +describe(`${chalk.yellowBright("fullSubject ordering and limits")}`, () => { + test("orders and limits customer products and loose entitlements per subject", async () => { + const scenario = buildEntitySubjectScenario({ + ctx, + name: "fullsubject-order-limit", + }); + + const baseTime = Date.now(); + const key = scenario.ids.internalCustomerId; + const parentProduct = scenario.products[0]!; + const entityProduct = scenario.products[1]!; + const parentEntitlement = scenario.entitlements[0]!; + const parentPrice = scenario.prices[0]!; + const parentCustomerProduct = { + ...scenario.customerProducts[0]!, + created_at: baseTime - 20_000, + status: CusProductStatus.Active, + }; + const entityCustomerProduct = { + ...scenario.customerProducts[1]!, + created_at: baseTime - 100_000, + status: CusProductStatus.Active, + }; + const unrelatedEntityCustomerProduct = { + ...scenario.customerProducts[2]!, + created_at: baseTime, + status: CusProductStatus.Active, + }; + + const addOnProduct = { + ...parentProduct, + internal_id: `${parentProduct.internal_id}_addon`, + id: `${parentProduct.id}_addon`, + name: `${parentProduct.name} Add-on`, + is_add_on: true, + created_at: baseTime, + }; + const freeProduct = { + ...parentProduct, + internal_id: `${parentProduct.internal_id}_free`, + id: `${parentProduct.id}_free`, + name: `${parentProduct.name} Free`, + is_add_on: false, + created_at: baseTime, + }; + const expiredProduct = { + ...parentProduct, + internal_id: `${parentProduct.internal_id}_expired`, + id: `${parentProduct.id}_expired`, + name: `${parentProduct.name} Expired`, + is_add_on: false, + created_at: baseTime, + }; + + const addOnEntitlement = { + ...parentEntitlement, + id: `${parentEntitlement.id}_addon`, + internal_product_id: addOnProduct.internal_id, + created_at: baseTime, + }; + const expiredEntitlement = { + ...parentEntitlement, + id: `${parentEntitlement.id}_expired`, + internal_product_id: expiredProduct.internal_id, + created_at: baseTime, + }; + const addOnPrice = { + ...parentPrice, + id: `${parentPrice.id}_addon`, + internal_product_id: addOnProduct.internal_id, + entitlement_id: addOnEntitlement.id, + created_at: baseTime, + }; + const expiredPrice = { + ...parentPrice, + id: `${parentPrice.id}_expired`, + internal_product_id: expiredProduct.internal_id, + entitlement_id: expiredEntitlement.id, + created_at: baseTime, + }; + + const addOnCustomerProduct = { + ...parentCustomerProduct, + id: `${parentCustomerProduct.id}_addon`, + internal_product_id: addOnProduct.internal_id, + product_id: addOnProduct.id, + created_at: baseTime, + status: CusProductStatus.Active, + }; + const freeCustomerProduct = { + ...parentCustomerProduct, + id: `${parentCustomerProduct.id}_free`, + internal_product_id: freeProduct.internal_id, + product_id: freeProduct.id, + created_at: baseTime + 1_000, + status: CusProductStatus.Active, + }; + const expiredCustomerProduct = { + ...parentCustomerProduct, + id: `${parentCustomerProduct.id}_expired`, + internal_product_id: expiredProduct.internal_id, + product_id: expiredProduct.id, + created_at: baseTime + 2_000, + status: CusProductStatus.Expired, + }; + const addOnCustomerPrice = { + ...scenario.customerPrices[0]!, + id: `${scenario.customerPrices[0]!.id}_addon`, + customer_product_id: addOnCustomerProduct.id, + price_id: addOnPrice.id, + created_at: baseTime, + }; + const expiredCustomerPrice = { + ...scenario.customerPrices[0]!, + id: `${scenario.customerPrices[0]!.id}_expired`, + customer_product_id: expiredCustomerProduct.id, + price_id: expiredPrice.id, + created_at: baseTime, + }; + const addOnCustomerEntitlement = { + ...scenario.customerEntitlements[0]!, + id: `${scenario.customerEntitlements[0]!.id}_addon`, + customer_product_id: addOnCustomerProduct.id, + entitlement_id: addOnEntitlement.id, + created_at: baseTime, + external_id: `${scenario.customerEntitlements[0]!.external_id}_addon`, + }; + const expiredCustomerEntitlement = { + ...scenario.customerEntitlements[0]!, + id: `${scenario.customerEntitlements[0]!.id}_expired`, + customer_product_id: expiredCustomerProduct.id, + entitlement_id: expiredEntitlement.id, + created_at: baseTime, + external_id: `${scenario.customerEntitlements[0]!.external_id}_expired`, + }; + + const fillerProducts = Array.from({ length: 55 }, (_, index) => { + const suffix = index.toString().padStart(2, "0"); + return { + ...parentProduct, + internal_id: `${parentProduct.internal_id}_filler_${suffix}`, + id: `${parentProduct.id}_filler_${suffix}`, + name: `${parentProduct.name} Filler ${suffix}`, + is_add_on: false, + created_at: baseTime - index, + }; + }); + const fillerCustomerProducts = fillerProducts.map((product, index) => ({ + ...parentCustomerProduct, + id: `${parentCustomerProduct.id}_filler_${index + .toString() + .padStart(2, "0")}`, + internal_product_id: product.internal_id, + product_id: product.id, + created_at: baseTime - index, + status: CusProductStatus.Expired, + })); + + const customerLooseEntitlements = Array.from({ length: 35 }, (_, index) => { + const suffix = index.toString().padStart(2, "0"); + return { + ...scenario.customerEntitlements[0]!, + id: `ce_${key}_loose_customer_${suffix}`, + customer_product_id: null, + entitlement_id: parentEntitlement.id, + internal_entity_id: null, + balance: index + 1, + created_at: baseTime + index, + external_id: `bal_${key}_loose_customer_${suffix}`, + }; + }); + const entityLooseEntitlements = Array.from({ length: 2 }, (_, index) => { + const suffix = index.toString().padStart(2, "0"); + return { + ...scenario.customerEntitlements[0]!, + id: `ce_${key}_loose_entity_${suffix}`, + customer_product_id: null, + entitlement_id: parentEntitlement.id, + internal_entity_id: scenario.ids.internalEntityIds[0]!, + balance: index + 1, + created_at: baseTime + index, + external_id: `bal_${key}_loose_entity_${suffix}`, + }; + }); + + const orderingScenario = { + ...scenario, + products: [ + parentProduct, + entityProduct, + scenario.products[2]!, + addOnProduct, + freeProduct, + expiredProduct, + ...fillerProducts, + ], + entitlements: [ + parentEntitlement, + scenario.entitlements[1]!, + scenario.entitlements[2]!, + addOnEntitlement, + expiredEntitlement, + ], + prices: [ + parentPrice, + scenario.prices[1]!, + scenario.prices[2]!, + addOnPrice, + expiredPrice, + ], + customerProducts: [ + parentCustomerProduct, + entityCustomerProduct, + unrelatedEntityCustomerProduct, + addOnCustomerProduct, + freeCustomerProduct, + expiredCustomerProduct, + ...fillerCustomerProducts, + ], + customerPrices: [ + scenario.customerPrices[0]!, + scenario.customerPrices[1]!, + scenario.customerPrices[2]!, + addOnCustomerPrice, + expiredCustomerPrice, + ], + customerEntitlements: [ + scenario.customerEntitlements[0]!, + scenario.customerEntitlements[1]!, + scenario.customerEntitlements[2]!, + addOnCustomerEntitlement, + expiredCustomerEntitlement, + ...customerLooseEntitlements, + ...entityLooseEntitlements, + ], + ids: { + ...scenario.ids, + productInternalIds: [ + ...scenario.ids.productInternalIds, + addOnProduct.internal_id, + freeProduct.internal_id, + expiredProduct.internal_id, + ...fillerProducts.map((product) => product.internal_id), + ], + productIds: [ + ...scenario.ids.productIds, + addOnProduct.id, + freeProduct.id, + expiredProduct.id, + ...fillerProducts.map((product) => product.id), + ], + }, + }; + + await withInsertedScenario({ + ctx, + scenario: orderingScenario, + run: async () => { + const inStatuses = [CusProductStatus.Active, CusProductStatus.Expired]; + const customerSubject = (await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + inStatuses, + }))!; + const entitySubject = (await getFullSubject({ + ctx, + customerId: scenario.ids.customerId, + entityId: scenario.ids.entityIds[0], + inStatuses, + }))!; + + expect(customerSubject.customer_products).toHaveLength(50); + expect( + customerSubject.customer_products + .slice(0, 4) + .map((customerProduct) => customerProduct.id), + ).toEqual([ + parentCustomerProduct.id, + addOnCustomerProduct.id, + freeCustomerProduct.id, + expiredCustomerProduct.id, + ]); + + expect(entitySubject.customer_products).toHaveLength(50); + expect( + entitySubject.customer_products + .slice(0, 5) + .map((customerProduct) => customerProduct.id), + ).toEqual([ + entityCustomerProduct.id, + parentCustomerProduct.id, + addOnCustomerProduct.id, + freeCustomerProduct.id, + expiredCustomerProduct.id, + ]); + expect( + entitySubject.customer_products.map( + (customerProduct) => customerProduct.id, + ), + ).not.toContain(unrelatedEntityCustomerProduct.id); + + expect(customerSubject.extra_customer_entitlements).toHaveLength(30); + expect( + customerSubject.extra_customer_entitlements + .slice(0, 3) + .map((customerEntitlement) => customerEntitlement.id), + ).toEqual([ + `ce_${key}_loose_customer_34`, + `ce_${key}_loose_customer_33`, + `ce_${key}_loose_customer_32`, + ]); + + expect(entitySubject.extra_customer_entitlements).toHaveLength(30); + expect( + entitySubject.extra_customer_entitlements + .slice(0, 4) + .map((customerEntitlement) => customerEntitlement.id), + ).toEqual([ + `ce_${key}_loose_entity_01`, + `ce_${key}_loose_entity_00`, + `ce_${key}_loose_customer_34`, + `ce_${key}_loose_customer_33`, + ]); + }, + }); + }); +}); diff --git a/server/tests/integration/scopes/scope-403.test.ts b/server/tests/integration/scopes/scope-403.test.ts index 5c2c8a72c..8a28408dc 100644 --- a/server/tests/integration/scopes/scope-403.test.ts +++ b/server/tests/integration/scopes/scope-403.test.ts @@ -1976,6 +1976,19 @@ const ROUTES = [ needsScopes: true, isWebhookExempt: false, }, + { + handlerName: "handleListEntitiesV2", + handlerFile: "src/internal/entities/handlers/handleListEntitiesV2.ts", + method: "POST", + path: "/v1/entities.list", + style: "RPC", + group: "v1/entities", + mountChain: ["/v1", "", "", "/entities.list"], + sourceRouterFile: "src/internal/entities/entityRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, { handlerName: "handleUpdateEntity", handlerFile: @@ -4456,6 +4469,12 @@ const SCOPE_DECISIONS: Record< shape: "array", decidedAt: "2026-04-24T15:39:15.066Z", }, + "POST|/v1/entities.list|handleListEntitiesV2": { + decision: "decided", + scopes: ["customers:read"], + shape: "array", + decidedAt: "2026-05-08T00:00:00.000Z", + }, "POST|/v1/entities.update|handleUpdateEntity": { decision: "decided", scopes: ["customers:write"], diff --git a/shared/api/entities/crud/index.ts b/shared/api/entities/crud/index.ts index 4385790db..bfa9884ba 100644 --- a/shared/api/entities/crud/index.ts +++ b/shared/api/entities/crud/index.ts @@ -1,4 +1,5 @@ export * from "./createEntityParams.js"; export * from "./deleteEntityParams.js"; export * from "./getEntityParams.js"; +export * from "./listEntitiesParams.js"; export * from "./updateEntityParams.js"; diff --git a/shared/api/entities/crud/listEntitiesParams.ts b/shared/api/entities/crud/listEntitiesParams.ts new file mode 100644 index 000000000..3be9622f3 --- /dev/null +++ b/shared/api/entities/crud/listEntitiesParams.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; +import { createPaginationParamsSchema } from "../../common/pagePaginationSchemas"; + +export const ListEntitiesParamsSchema = createPaginationParamsSchema({ + defaultLimit: 10, +}).extend({ + plans: z + .array( + z.object({ + id: z.string(), + versions: z.number().array().optional(), + }), + ) + .optional() + .meta({ + description: + "Filter by plan ID and version. Returns entities with active subscriptions to this plan, including plans inherited from the parent customer.", + }), + + subscription_status: z.enum(["active", "scheduled"]).optional().meta({ + description: + "Filter customer products used for entity hydration and plan matching. Defaults to active and scheduled.", + }), + + search: z.string().optional().meta({ + description: "Search entities by id or name.", + }), + + processors: z + .array(z.enum(["stripe", "revenuecat", "vercel"])) + .optional() + .meta({ + description: + "Filter by parent customer processor type (stripe, revenuecat, vercel).", + }), +}); + +export type ListEntitiesParams = z.infer;