diff --git a/ai b/ai index a6d3ad040..b1efb8d30 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit a6d3ad040325b2cf6ba4f171c367e4a95fbac8ed +Subproject commit b1efb8d303caf7fdc76d820b00ad87d305f6867f diff --git a/server/src/internal/customers/CusBatchService.ts b/server/src/internal/customers/CusBatchService.ts index 1a32150e7..490ae97ab 100644 --- a/server/src/internal/customers/CusBatchService.ts +++ b/server/src/internal/customers/CusBatchService.ts @@ -14,7 +14,10 @@ import { } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import type { AutumnContext, RequestContext } from "@/honoUtils/HonoEnv.js"; -import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; +import { + getOrgCusProductLimit, + getOrgEntitiesLimit, +} from "../misc/edgeConfig/orgLimitsStore.js"; import { triggerBatchResetCustomerEntitlements } from "./actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.js"; import { CusSearchService } from "./CusSearchService.js"; import { getCursorPaginatedFullCusQuery } from "./cursorPaginatedFullCusQuery.js"; @@ -324,6 +327,10 @@ export class CusBatchService { orgId: ctx.org.id, orgSlug: ctx.org.slug, }); + const entitiesLimit = getOrgEntitiesLimit({ + orgId: ctx.org.id, + orgSlug: ctx.org.slug, + }); const statusFilters = (filters?.status ?? []).filter( (s): s is DashboardStatusFilter => @@ -373,6 +380,9 @@ export class CusBatchService { env: ctx.env, inStatuses: RELEVANT_STATUSES, withSubs: true, + withEntities: true, + includeInvoices: true, + entitiesLimit, limit: internalIds ? internalIds.length : limit, cursor: !requiresResolveStep && cursor @@ -403,6 +413,8 @@ export class CusBatchService { replaceables: [], free_trials: [], subscriptions: [], + entities: [], + invoices: [], }) as unknown as FlattenedCustomerRow; const allCustomers = reassembleFlattenedCustomer(flat); diff --git a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts index 79254af5a..395ff8a0c 100644 --- a/server/src/internal/customers/cursorPaginatedFullCusQuery.ts +++ b/server/src/internal/customers/cursorPaginatedFullCusQuery.ts @@ -16,6 +16,10 @@ export type CursorPaginatedFullCusQueryArgs = { env: AppEnv; inStatuses?: CusProductStatus[]; withSubs?: boolean; + withEntities?: boolean; + includeInvoices?: boolean; + entitiesLimit?: number; + invoicesLimit?: number; limit: number; cursor?: StandardCursorFields; internalCustomerIds?: string[]; @@ -38,6 +42,10 @@ export const getCursorPaginatedFullCusQuery = ({ env, inStatuses, withSubs = true, + withEntities = false, + includeInvoices = false, + entitiesLimit = 300, + invoicesLimit = 10, limit, cursor, internalCustomerIds, @@ -86,6 +94,42 @@ export const getCursorPaginatedFullCusQuery = ({ ) AS subscriptions` : sql`'[]'::json AS subscriptions`; + const entitiesCte = withEntities + ? sql`, entities_all AS MATERIALIZED ( + SELECT e.internal_customer_id, row_to_json(e) AS row_json + FROM cr + JOIN LATERAL ( + SELECT e.* + FROM entities e + WHERE e.internal_customer_id = cr.internal_id + ORDER BY e.internal_id DESC + LIMIT ${entitiesLimit} + ) e ON true + )` + : sql``; + + const invoicesCte = includeInvoices + ? sql`, invoices_all AS MATERIALIZED ( + SELECT i.internal_customer_id, row_to_json(i) AS row_json + FROM cr + JOIN LATERAL ( + SELECT i.* + FROM invoices i + WHERE i.internal_customer_id = cr.internal_id + ORDER BY i.created_at DESC, i.id DESC + LIMIT ${invoicesLimit} + ) i ON true + )` + : sql``; + + const entitiesSelect = withEntities + ? sql`, (SELECT COALESCE(json_agg(row_json), '[]'::json) FROM entities_all) AS entities` + : sql``; + + const invoicesSelect = includeInvoices + ? sql`, (SELECT COALESCE(json_agg(row_json), '[]'::json) FROM invoices_all) AS invoices` + : sql``; + return sql` WITH cr AS MATERIALIZED ( SELECT @@ -155,6 +199,8 @@ export const getCursorPaginatedFullCusQuery = ({ UNION ALL SELECT id, entitlement_id FROM ces_loose ) + ${entitiesCte} + ${invoicesCte} SELECT (SELECT COALESCE(json_agg(row_json), '[]'::json) FROM cr) AS customers, (SELECT COALESCE(json_agg(row_json), '[]'::json) FROM cps_ranked) AS customer_products, @@ -184,5 +230,7 @@ export const getCursorPaginatedFullCusQuery = ({ JOIN free_trials ft ON ft.id = cps.free_trial_id ) AS free_trials, ${subscriptionsSelect} + ${entitiesSelect} + ${invoicesSelect} `; }; diff --git a/server/src/internal/customers/reassembleFlattenedCustomer/reassembleFlattenedCustomer.ts b/server/src/internal/customers/reassembleFlattenedCustomer/reassembleFlattenedCustomer.ts index 7df73478a..95c50ce20 100644 --- a/server/src/internal/customers/reassembleFlattenedCustomer/reassembleFlattenedCustomer.ts +++ b/server/src/internal/customers/reassembleFlattenedCustomer/reassembleFlattenedCustomer.ts @@ -49,16 +49,41 @@ export const reassembleFlattenedCustomer = ( subscriptionByStripeId: maps.subscriptionByStripeId, }); + const entitiesByCusId = groupByInternalCustomerId(flat.entities); + const invoicesByCusId = groupByInternalCustomerId(flat.invoices); + const out: FullCustomer[] = []; for (const c of flat.customers) { const internalId = c.internal_id as string; - out.push({ + const hydrated: Record = { ...c, created_at: toTimestamp(c.created_at), customer_products: cpsByCusId.get(internalId) ?? [], extra_customer_entitlements: looseCesByCusId.get(internalId) ?? [], subscriptions: subsByCusId.get(internalId) ?? [], - } as unknown as FullCustomer); + }; + if (flat.entities !== undefined) { + hydrated.entities = entitiesByCusId.get(internalId) ?? []; + } + if (flat.invoices !== undefined) { + hydrated.invoices = invoicesByCusId.get(internalId) ?? []; + } + out.push(hydrated as unknown as FullCustomer); + } + return out; +}; + +const groupByInternalCustomerId = ( + rows: { internal_customer_id?: string; [k: string]: unknown }[] | undefined, +): Map => { + const out = new Map(); + if (!rows) return out; + for (const row of rows) { + const cusId = row.internal_customer_id; + if (!cusId) continue; + const list = out.get(cusId); + if (list) list.push(row); + else out.set(cusId, [row]); } return out; }; diff --git a/server/src/internal/customers/reassembleFlattenedCustomer/types.ts b/server/src/internal/customers/reassembleFlattenedCustomer/types.ts index deeb0fb4e..f1a61b0c9 100644 --- a/server/src/internal/customers/reassembleFlattenedCustomer/types.ts +++ b/server/src/internal/customers/reassembleFlattenedCustomer/types.ts @@ -9,6 +9,8 @@ export type FlattenedCustomerRow = { replaceables: any[]; free_trials: any[]; subscriptions: any[]; + entities?: any[]; + invoices?: any[]; }; export type FlatCustomerEntitlement = {