fix: deleting products query includes org_id and env

This commit is contained in:
John Yeo
2025-07-30 14:48:27 -07:00
parent 156f616af2
commit 434980b528
14 changed files with 229 additions and 43 deletions

8
.gitignore vendored
View File

@@ -95,4 +95,10 @@ server/test.sh
# bun (keep main repo clean betweeen different npm flavours)
bun.lock
bun.lockb
.dev.vars
.dev.vars
supabase/
# To load supabase
# 1. Start supabase locally
# 2. Load files

View File

@@ -36,8 +36,17 @@ const { db, client } = initDrizzle({ maxConnections: 5 });
let orgSlugs = process.env.ORG_SLUGS!.split(",");
const skipEmails = process.env.SKIP_EMAILS!.split(",");
// orgSlugs = ["athenahq"];
// "athenahq",
// orgSlugs = ["sweep-ai-1739822644", "zero"];
orgSlugs = [
// "circlemind-zero"
// 'zero',
// 'sweep-ai-1739822644',
// 'alex',
// 'supermemory',
// 'lingo',
// 'lucid',
];
const getSingleCustomer = async ({
stripeCli,

View File

@@ -8,6 +8,9 @@ import { schemas as schema } from "@autumn/shared";
export let client = postgres(process.env.DATABASE_URL!);
export let db = drizzle(client, { schema });
// export const localClient = postgres("postgresql://postgres:postgres@localhost:54322/postgres")
// export const localDb = drizzle(localClient, { schema });
export const initDrizzle = (params?: { maxConnections?: number }) => {
let maxConnections = params?.maxConnections || 10;
const client = postgres(process.env.DATABASE_URL!, {

View File

@@ -42,15 +42,18 @@ export class AutumnInt {
constructor({
apiKey,
secretKey,
baseUrl,
version,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string | APIVersion;
} = {}) {
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
this.apiKey = apiKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
this.apiKey =
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
@@ -415,7 +418,7 @@ export class AutumnInt {
},
};
track = async (params: TrackParams) => {
track = async (params: TrackParams & { timestamp?: number }) => {
const data = await this.post(`/track`, params);
return data;
};

View File

@@ -77,7 +77,7 @@ const handleInArrearProrated = async ({
let feature = cusEnt.entitlement.feature;
logger.info(
`Handling invoice.created for in arrear prorated, feature: ${feature.id}`,
`Handling invoice.created for in arrear prorated, feature: ${feature.id}`
);
let deletedEntities = await EntityService.list({
@@ -93,12 +93,12 @@ const handleInArrearProrated = async ({
}
logger.info(
`✨ Handling in arrear prorated, customer ${customer.name}, org: ${org.slug}`,
`✨ Handling in arrear prorated, customer ${customer.name}, org: ${org.slug}`
);
logger.info(
`Deleting entities, feature ${feature.id}, customer ${customer.id}, org ${org.slug}`,
deletedEntities,
deletedEntities
);
// Get linked cus ents
@@ -112,7 +112,7 @@ const handleInArrearProrated = async ({
}
logger.info(
`Linked cus ent: ${linkedCusEnt.feature_id}, isLinked: ${isLinked}`,
`Linked cus ent: ${linkedCusEnt.feature_id}, isLinked: ${isLinked}`
);
// Delete cus ent ids
@@ -136,7 +136,7 @@ const handleInArrearProrated = async ({
console.log(`Updated ${updated.length} cus ents`);
logger.info(
`Feature: ${feature.id}, customer: ${customer.id}, deleted entities from cus ent`,
`Feature: ${feature.id}, customer: ${customer.id}, deleted entities from cus ent`
);
linkedCusEnt.entities = newEntities;
}
@@ -150,7 +150,7 @@ const handleInArrearProrated = async ({
logger.info(
`Feature: ${feature.id}, Deleted ${
deletedEntities.length
}, entities: ${deletedEntities.map((e) => `${e.id}`).join(", ")}`,
}, entities: ${deletedEntities.map((e) => `${e.id}`).join(", ")}`
);
// Increase balance
@@ -229,7 +229,7 @@ export const sendUsageAndReset = async ({
if (billingType == BillingType.UsageInArrear) {
logger.info(
`✨ Handling usage prices for ${customer.name || customer.id}, org: ${org.slug}`,
`✨ Handling usage prices for ${customer.name || customer.id}, org: ${org.slug}`
);
await handleUsagePrices({
@@ -306,13 +306,13 @@ export const handleInvoiceCreated = async ({
if (activeProducts.length == 0) {
logger.warn(
`Stripe invoice.created -- no active products found (${org.slug})`,
`Stripe invoice.created -- no active products found (${org.slug})`
);
return;
}
let internalEntityId = activeProducts.find(
(p) => p.internal_entity_id,
(p) => p.internal_entity_id
)?.internal_entity_id;
let features = await FeatureService.list({
@@ -330,7 +330,7 @@ export const handleInvoiceCreated = async ({
});
let feature = features.find(
(f) => f.internal_id == entity?.internal_feature_id,
(f) => f.internal_id == entity?.internal_feature_id
);
let entDetails = "";

View File

@@ -28,7 +28,7 @@ analyticsRouter.get("/event_names", async (req: any, res: any) =>
const { db, org, env, features } = req;
const { interval, event_names, customer_id } = req.body;
const result = await queryWithCache({
let result = await queryWithCache({
action: CacheType.TopEvents,
key: `${org.id}_${env}`,
fn: async () => {
@@ -40,6 +40,12 @@ analyticsRouter.get("/event_names", async (req: any, res: any) =>
},
});
// const topEventNamesRes = await AnalyticsService.getTopEventNames({
// req,
// });
// let result = topEventNamesRes?.eventNames;
let featureIds: string[] = [];
let eventNames: string[] = [];
@@ -73,18 +79,24 @@ analyticsRouter.get("/event_names", async (req: any, res: any) =>
const getTopEvents = async ({ req }: { req: ExtendedRequest }) => {
const { org, env, features } = req;
const result = await queryWithCache({
action: CacheType.TopEvents,
key: `${org.id}_${env}`,
fn: async () => {
const res = await AnalyticsService.getTopEventNames({
req,
});
// const result = await queryWithCache({
// action: CacheType.TopEvents,
// key: `${org.id}_${env}`,
// fn: async () => {
// const res = await AnalyticsService.getTopEventNames({
// req,
// });
return res?.eventNames;
},
// return res?.eventNames;
// },
// });
const topEventNamesRes = await AnalyticsService.getTopEventNames({
req,
});
let result = topEventNamesRes?.eventNames;
let featureIds: string[] = [];
let eventNames: string[] = [];
@@ -122,6 +134,7 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
let topEvents:
| { featureIds: string[]; eventNames: string[] }
| undefined = undefined;
if (!event_names || event_names.length === 0) {
topEvents = await getTopEvents({ req });
event_names = [...topEvents.eventNames, ...topEvents.featureIds];

View File

@@ -566,10 +566,14 @@ export class CusProductService {
db,
productId,
internalProductId,
orgId,
env,
}: {
db: DrizzleCli;
productId?: string;
internalProductId?: string;
orgId: string;
env: AppEnv;
}) {
if (productId) {
let res = await db
@@ -577,10 +581,24 @@ export class CusProductService {
internal_id: products.internal_id,
})
.from(products)
.where(eq(products.id, productId));
.where(
and(
eq(products.id, productId),
eq(products.org_id, orgId),
eq(products.env, env)
)
);
let internalProductIds = res.map((r) => r.internal_id);
if (internalProductIds.length > 100) {
throw new RecaseError({
message: "Something went wrong... please try again later.",
code: ErrCode.ProductHasCustomers,
statusCode: 400,
});
}
await db
.delete(customerProducts)
.where(

View File

@@ -69,6 +69,12 @@ export const featuresToObject = ({
// console.log(`Feature ${featureId} list:`, relatedEnts);
let hasRollovers = relatedEnts.some((e) => notNullish(e.rollovers));
let rollovers = hasRollovers
? (relatedEnts
.flatMap((e) => e.rollovers)
.filter(notNullish) as CusRollover[])
: undefined;
let cusFeature: CusEntResponseV2 = {
id: featureId,
name: feature.name,
@@ -99,9 +105,7 @@ export const featuresToObject = ({
}))
: undefined,
rollovers: relatedEnts
.flatMap((e) => e.rollovers)
.filter(notNullish) as CusRollover[],
rollovers,
};
featureObject[featureId] = cusFeature;

View File

@@ -16,7 +16,10 @@ import {
import { ExtendedRequest } from "@/utils/models/Request.js";
import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js";
import { refreshCusCache } from "../cusCache/updateCachedCus.js";
import {
deleteCusCache,
refreshCusCache,
} from "../cusCache/updateCachedCus.js";
import { getCusWithCache } from "../cusCache/getCusWithCache.js";
export const getOrCreateCustomer = async ({
@@ -107,6 +110,13 @@ export const getOrCreateCustomer = async ({
expand,
withSubs: true,
});
await deleteCusCache({
db,
customerId: customer.id!,
org,
env,
});
} catch (error: any) {
if (error?.data?.code == "23505") {
customer = await CusService.getFull({

View File

@@ -69,8 +69,12 @@ export const handleDeleteProduct = (req: any, res: any) =>
await CusProductService.deleteByProduct({
db,
productId: all_versions ? productId : undefined,
internalProductId: all_versions ? undefined : product.internal_id,
productId: deleteAllVersions ? productId : undefined,
internalProductId: deleteAllVersions
? undefined
: product.internal_id,
orgId,
env,
});
}

View File

@@ -0,0 +1,112 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AppEnv, FullCusProduct } from "@autumn/shared";
import { sql } from "drizzle-orm";
let cusProductsQuery = ({
lastProductId,
internalProductId,
pageSize = 250,
}: {
lastProductId?: string;
internalProductId?: string;
pageSize?: number;
}) => {
// const withStatusFilter = () => {
// return inStatuses
// ? sql`AND cp.status = ANY(ARRAY[${sql.join(
// inStatuses.map((status) => sql`${status}`),
// sql`, `,
// )}])`
// : sql``;
// };
return sql`
SELECT
cp.*,
row_to_json(prod) AS product,
-- Spread customer_prices fields + add price field
COALESCE(
json_agg(DISTINCT (
to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*))
)) FILTER (WHERE cpr.id IS NOT NULL),
'[]'::json
) AS customer_prices,
-- Spread customer_entitlements fields + add entitlement and replaceables
COALESCE(
json_agg(DISTINCT (
to_jsonb(ce.*) || jsonb_build_object(
'entitlement', (
SELECT row_to_json(ent_with_feature)
FROM (
SELECT e.*, row_to_json(f) AS feature
FROM entitlements e
JOIN features f ON e.internal_feature_id = f.internal_id
WHERE e.id = ce.entitlement_id
) AS ent_with_feature
),
'replaceables', (
SELECT COALESCE(
json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL),
'[]'::json
)
FROM replaceables r
WHERE r.cus_ent_id = ce.id
)
)
)) FILTER (WHERE ce.id IS NOT NULL),
'[]'::json
) AS customer_entitlements,
-- free_trial
(
SELECT row_to_json(ft)
FROM free_trials ft
WHERE ft.id = cp.free_trial_id
) AS free_trial
FROM customer_products cp
JOIN products prod ON cp.internal_product_id = prod.internal_id
LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
LEFT JOIN prices p ON cpr.price_id = p.id
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id
WHERE cp.internal_product_id = ${internalProductId}
${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``}
GROUP BY cp.id, prod.*
ORDER BY cp.id DESC
LIMIT ${pageSize}
`;
};
export const getAllFullCusProducts = async ({
db,
internalProductId,
}: {
db: DrizzleCli;
internalProductId: string;
}) => {
let lastProductId = "";
let allData: any[] = [];
let pageSize = 500;
while (true) {
const data = await db.execute(
cusProductsQuery({
lastProductId,
pageSize,
internalProductId,
}),
);
if (data.length === 0) break;
console.log(`Fetched ${data.length} customer products`);
allData.push(...data);
lastProductId = data[data.length - 1].id as string;
}
return allData as FullCusProduct[];
};

View File

@@ -81,11 +81,11 @@ describe(`${chalk.yellowBright("basic1: Testing attach free product")}`, () => {
const entitled = (await AutumnCli.entitled(
customerId,
features.metered1.id,
features.metered1.id
)) as any;
const metered1Balance = entitled.balances.find(
(balance: any) => balance.feature_id === features.metered1.id,
(balance: any) => balance.feature_id === features.metered1.id
);
expect(entitled.allowed).to.be.true;

View File

@@ -30,7 +30,7 @@ const checkEntitledOnProduct = async ({
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
})
);
}
@@ -42,7 +42,7 @@ const checkEntitledOnProduct = async ({
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
true,
true
);
try {
@@ -70,7 +70,7 @@ const checkEntitledOnProduct = async ({
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
})
);
}
await Promise.all(batchUpdates2);
@@ -103,7 +103,7 @@ const checkEntitledOnProduct = async ({
const testCase = "others5";
describe(`${chalk.yellowBright(
"others5: Testing /events and /entitled, for pro, one time top up",
"others5: Testing /events and /entitled, for pro, one time top up"
)}`, () => {
const customerId = testCase;
@@ -175,7 +175,7 @@ describe(`${chalk.yellowBright(
});
describe(`${chalk.yellowBright(
"others5: Testing /entitled & /events, for pro with overage",
"others5: Testing /entitled & /events, for pro with overage"
)}`, () => {
const customerId = testCase;
@@ -213,7 +213,7 @@ describe(`${chalk.yellowBright(
const { allowed, balanceObj }: any = await AutumnCli.entitled(
customerId,
features.metered1.id,
true,
true
);
expect(allowed).to.be.true;
@@ -226,12 +226,12 @@ describe(`${chalk.yellowBright(
AutumnCli.sendEvent({
customerId: customerId,
eventName: features.metered1.eventName,
}),
})
);
}
await Promise.all(batchUpdates);
await timeout(5000);
await timeout(10000);
const { allowed: allowed2, balanceObj: balanceObj2 }: any =
await AutumnCli.entitled(customerId, features.metered1.id, true);

View File

@@ -54,7 +54,11 @@ export const DeleteProductDialog = ({
const handleDelete = async () => {
setDeleteLoading(true);
try {
await ProductService.deleteProduct(axiosInstance, product.id);
await ProductService.deleteProduct(
axiosInstance,
product.id,
deleteAllVersions
);
await mutate();
setOpen(false);
} catch (error) {