diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 136e639a6..ab81ba3df 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -15,8 +15,8 @@ import { mapToProductV2 } from "./productV2Utils.js"; import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; import RecaseError, { - handleFrontendReqError, - handleRequestError, + handleFrontendReqError, + handleRequestError, } from "@/utils/errorUtils.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; import { sortFullProducts } from "./productUtils/sortProductUtils.js"; @@ -24,349 +24,343 @@ import { sortFullProducts } from "./productUtils/sortProductUtils.js"; export const productRouter: Router = Router({ mergeParams: true }); productRouter.get("/data", async (req: any, res) => { - try { - let { db } = req; + try { + let { db } = req; - const [products, features, org, coupons, rewardPrograms] = - await Promise.all([ - ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - returnAll: true, - showOnlyArchived: false, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - RewardService.list({ db, orgId: req.orgId, env: req.env }), - RewardProgramService.list({ - db, - orgId: req.orgId, - env: req.env, - }), - ]); + const [products, features, org, coupons, rewardPrograms] = + await Promise.all([ + ProductService.listFull({ + db, + orgId: req.orgId, + env: req.env, + returnAll: true, + showOnlyArchived: false, + }), + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + RewardService.list({ db, orgId: req.orgId, env: req.env }), + RewardProgramService.list({ + db, + orgId: req.orgId, + env: req.env, + }), + ]); - sortFullProducts({ - products: getLatestProducts(products), - }); + sortFullProducts({ + products: getLatestProducts(products), + }); - res.status(200).json({ - products: products.map((product) => { - return mapToProductV2({ product, features }); - }), - versionCounts: getProductVersionCounts(products), - features, - org: createOrgResponse(org), - rewards: coupons, - rewardPrograms, - }); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } + res.status(200).json({ + products: products.map((product) => { + return mapToProductV2({ product, features }); + }), + versionCounts: getProductVersionCounts(products), + features, + org: createOrgResponse(org), + rewards: coupons, + rewardPrograms, + }); + } catch (error) { + console.error("Failed to get products", error); + res.status(500).send(error); + } }); productRouter.post("/data", async (req: any, res) => { - try { - let { db } = req; - let { showArchived } = req.body; - console.log("showArchived", showArchived); + try { + let { db } = req; + let { showArchived } = req.body; + console.log("showArchived", showArchived); - const [products, features, org, coupons, rewardPrograms] = - await Promise.all([ - ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - returnAll: true, - showOnlyArchived: showArchived, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - RewardService.list({ db, orgId: req.orgId, env: req.env }), - RewardProgramService.list({ - db, - orgId: req.orgId, - env: req.env, - }), - ]); + const [products, features, org, coupons, rewardPrograms] = + await Promise.all([ + ProductService.listFull({ + db, + orgId: req.orgId, + env: req.env, + returnAll: true, + showOnlyArchived: showArchived, + }), + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + RewardService.list({ db, orgId: req.orgId, env: req.env }), + RewardProgramService.list({ + db, + orgId: req.orgId, + env: req.env, + }), + ]); - res.status(200).json({ - products: sortFullProducts({ products }).map((product) => { - return mapToProductV2({ product, features }); - }), - versionCounts: getProductVersionCounts(products), - features, - org: createOrgResponse(org), - rewards: coupons, - rewardPrograms, - }); - } catch (error) { - console.error("Failed to get products", error); - res.status(500).send(error); - } + res.status(200).json({ + products: sortFullProducts({ products }).map((product) => { + return mapToProductV2({ product, features }); + }), + versionCounts: getProductVersionCounts(products), + features, + org: createOrgResponse(org), + rewards: coupons, + rewardPrograms, + }); + } catch (error) { + console.error("Failed to get products", error); + res.status(500).send(error); + } }); -productRouter.get("/data/deletion_text/:internal_product_id", async (req: any, res) => { - try { - let { db, orgId, env } = req; - let { internal_product_id } = req.params; +productRouter.get( + "/data/deletion_text/:internal_product_id", + async (req: any, res) => { + try { + let { db, orgId, env } = req; + let { internal_product_id } = req.params; - let deletionText = await ProductService.getDeletionText({ - db, - internal_product_id, - orgId, - env, - }); + let deletionText = await ProductService.getDeletionText({ + db, + internal_product_id, + orgId, + env, + }); - console.log("deletionText", deletionText); - - res.status(200).send({ - customerName: deletionText[0]?.customerName, - totalCount: deletionText[0]?.totalCount, - }); - } catch (error) { - console.error("Failed to get deletion text", error); - res.status(500).send(error); - } -}); + res.status(200).send({ + customerName: deletionText[0]?.customerName, + totalCount: deletionText[0]?.totalCount, + }); + } catch (error) { + console.error("Failed to get deletion text", error); + res.status(500).send(error); + } + } +); productRouter.get("/counts", async (req: any, res) => { - try { - let { db } = req; - let products = await ProductService.listFull({ - db, - orgId: req.orgId, - env: req.env, - returnAll: true, - }); + try { + let { db } = req; + let products = await ProductService.listFull({ + db, + orgId: req.orgId, + env: req.env, + // returnAll: true, + }); - let counts = await Promise.all( - products.map(async (product) => { - return CusProdReadService.getCounts({ - db, - internalProductId: product.internal_id, - }); - }) - ); + let counts = await Promise.all( + products.map(async (product) => { + return CusProdReadService.getCountsForAllVersions({ + db, + productId: product.id, + orgId: req.orgId, + env: req.env, + }); + }) + ); - let result: { [key: string]: any } = {}; - for (let i = 0; i < products.length; i++) { - if (!result[products[i].id]) { - result[products[i].id] = counts[i]; - } else { - for (let key in counts[i]) { - let countVal = - counts[i][key as keyof (typeof counts)[number]] || 0; - result[products[i].id][key] += countVal; - } - } - } + let result: { [key: string]: any } = {}; + for (let i = 0; i < products.length; i++) { + if (!result[products[i].id]) { + result[products[i].id] = counts[i]; + } + } - res.status(200).send(result); - } catch (error) { - console.error("Failed to get product counts", error); - res.status(500).send(error); - } + res.status(200).send(result); + } catch (error) { + console.error("Failed to get product counts", error); + res.status(500).send(error); + } }); productRouter.get("/:productId/data", async (req: any, res) => { - try { - const { productId } = req.params; - const { version } = req.query; - const { db, orgId, env } = req; + try { + const { productId } = req.params; + const { version } = req.query; + const { db, orgId, env } = req; - const [product, features, org, numVersions, existingMigrations] = - await Promise.all([ - ProductService.getFull({ - db, - idOrInternalId: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - ProductService.getProductVersionCount({ - db, - productId, - orgId, - env, - }), - MigrationService.getExistingJobs({ - db, - orgId, - env, - }), - ]); + const [product, features, org, numVersions, existingMigrations] = + await Promise.all([ + ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + }), + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + ProductService.getProductVersionCount({ + db, + productId, + orgId, + env, + }), + MigrationService.getExistingJobs({ + db, + orgId, + env, + }), + ]); - if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - let entitlements = product.entitlements; - let prices = product.prices; + if (!product) { + throw new RecaseError({ + message: `Product ${productId} ${ + version ? `(v${version})` : "" + } not found`, + code: ErrCode.ProductNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + let entitlements = product.entitlements; + let prices = product.prices; - entitlements = entitlements.sort((a: any, b: any) => { - return b.feature.id.localeCompare(a.feature.id); - }); + entitlements = entitlements.sort((a: any, b: any) => { + return b.feature.id.localeCompare(a.feature.id); + }); - prices = prices.sort((a: any, b: any) => { - return b.id.localeCompare(a.id); - }); + prices = prices.sort((a: any, b: any) => { + return b.id.localeCompare(a.id); + }); - let productV2 = mapToProductV2({ product, features }); + let productV2 = mapToProductV2({ product, features }); - res.status(200).send({ - product: productV2, - entitlements, - prices, - features, - org: { - id: org.id, - name: org.name, - test_pkey: org.test_pkey, - live_pkey: org.live_pkey, - default_currency: org.default_currency, - }, - numVersions, - existingMigrations, - }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get product data (internal)", - }); - } + res.status(200).send({ + product: productV2, + entitlements, + prices, + features, + org: { + id: org.id, + name: org.name, + test_pkey: org.test_pkey, + live_pkey: org.live_pkey, + default_currency: org.default_currency, + }, + numVersions, + existingMigrations, + }); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get product data (internal)", + }); + } }); productRouter.get("/:productId/count", async (req: any, res) => { - try { - const { db, orgId, env } = req; - const { productId } = req.params; - const { version } = req.query; + try { + const { db, orgId, env } = req; + const { productId } = req.params; + const { version } = req.query; - const product = await ProductService.get({ - db, - id: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - }); + const product = await ProductService.get({ + db, + id: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + }); - if (!product) { - throw new RecaseError({ - message: `Product ${productId} ${ - version ? `(v${version})` : "" - } not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } + if (!product) { + throw new RecaseError({ + message: `Product ${productId} ${ + version ? `(v${version})` : "" + } not found`, + code: ErrCode.ProductNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } - // Get counts from postgres - const counts = await CusProdReadService.getCounts({ - db, - internalProductId: product.internal_id, - }); + // Get counts from postgres + const counts = await CusProdReadService.getCounts({ + db, + internalProductId: product.internal_id, + }); - res.status(200).send(counts); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get product counts (internal)", - }); - } + res.status(200).send(counts); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get product counts (internal)", + }); + } }); productRouter.post("/product_options", async (req: any, res: any) => { - try { - const { items } = req.body; + try { + const { items } = req.body; - const features = await FeatureService.getFromReq(req); - const featureToOptions: { [key: string]: FeatureOptions } = {}; + const features = await FeatureService.getFromReq(req); + const featureToOptions: { [key: string]: FeatureOptions } = {}; - for (const item of items) { - if ( - isFeaturePriceItem(item) && - item.usage_model == UsageModel.Prepaid - ) { - featureToOptions[item.feature_id] = { - feature_id: item.feature_id, - quantity: 0, - }; - } - } + for (const item of items) { + if (isFeaturePriceItem(item) && item.usage_model == UsageModel.Prepaid) { + featureToOptions[item.feature_id] = { + feature_id: item.feature_id, + quantity: 0, + }; + } + } - res.status(200).send({ options: Object.values(featureToOptions) }); - } catch (error) { - handleFrontendReqError({ - error, - req, - res, - action: "Get product options", - }); - } + res.status(200).send({ options: Object.values(featureToOptions) }); + } catch (error) { + handleFrontendReqError({ + error, + req, + res, + action: "Get product options", + }); + } }); productRouter.get("/:productId/info", async (req: any, res: any) => { - try { - // 1. Get number of versions - const { db, orgId, env } = req; - let product = await ProductService.get({ - db, - id: req.params.productId, - orgId: req.orgId, - env: req.env, - }); + try { + // 1. Get number of versions + const { db, orgId, env } = req; + let product = await ProductService.get({ + db, + id: req.params.productId, + orgId: req.orgId, + env: req.env, + }); - // let numVersions = await ProductService.getProductVersionCount({ - // db, - // productId: req.params.productId, - // orgId: req.orgId, - // env: req.env, - // }); - if (!product) { - throw new RecaseError({ - message: `Product ${req.params.productId} not found`, - code: ErrCode.ProductNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } + // let numVersions = await ProductService.getProductVersionCount({ + // db, + // productId: req.params.productId, + // orgId: req.orgId, + // env: req.env, + // }); + if (!product) { + throw new RecaseError({ + message: `Product ${req.params.productId} not found`, + code: ErrCode.ProductNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } - let [allVersions, latestVersion] = await Promise.all([ - CusProdReadService.existsForProduct({ - db, - productId: req.params.productId, - }), - CusProdReadService.existsForProduct({ - db, - internalProductId: product.internal_id, - }), - ]); + let [allVersions, latestVersion] = await Promise.all([ + CusProdReadService.existsForProduct({ + db, + productId: req.params.productId, + }), + CusProdReadService.existsForProduct({ + db, + internalProductId: product.internal_id, + }), + ]); - // 2. Get cus products + // 2. Get cus products - res.status(200).send({ - numVersion: product.version, - hasCusProducts: allVersions, - hasCusProductsLatest: latestVersion, - }); - } catch (error) { - handleRequestError({ - error, - req, - res, - action: "Get product info", - }); - } + res.status(200).send({ + numVersion: product.version, + hasCusProducts: allVersions, + hasCusProductsLatest: latestVersion, + }); + } catch (error) { + handleRequestError({ + error, + req, + res, + action: "Get product info", + }); + } }); diff --git a/server/src/utils/scriptUtils/clearOrg.ts b/server/src/utils/scriptUtils/clearOrg.ts index 83513d8d2..59e0ccc10 100644 --- a/server/src/utils/scriptUtils/clearOrg.ts +++ b/server/src/utils/scriptUtils/clearOrg.ts @@ -6,7 +6,50 @@ import { Organization, products, } from "@autumn/shared"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; + +const clearCustomersInBatches = async ({ + db, + org, + batchSize = 450, +}: { + db: DrizzleCli; + org: Organization; + batchSize?: number; +}) => { + let deletedCount = 0; + + while (true) { + // Get a batch of customer IDs to delete + const customerBatch = await db + .select({ internalId: customers.internal_id }) + .from(customers) + .where( + and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)) + ) + .limit(batchSize); + + if (customerBatch.length === 0) { + break; // No more customers to delete + } + + // Delete the batch + const customerIds = customerBatch + .map((c) => c.internalId) + .filter((id) => id !== null); + + await db + .delete(customers) + .where(inArray(customers.internal_id, customerIds)); + + deletedCount += customerBatch.length; + console.log( + `Deleted ${customerBatch.length} customers (total: ${deletedCount})` + ); + } + + return deletedCount; +}; export const clearOrg = async ({ db, @@ -15,13 +58,8 @@ export const clearOrg = async ({ db: DrizzleCli; org: Organization; }) => { - await db - .delete(customers) - .where( - and(eq(customers.org_id, org.id), eq(customers.env, AppEnv.Sandbox)), - ); - - console.log("Cleared customers"); + const deletedCount = await clearCustomersInBatches({ db, org }); + console.log(`Cleared ${deletedCount} customers`); await db .delete(products) diff --git a/shared/models/productModels/productModels.ts b/shared/models/productModels/productModels.ts index 215bd39d7..6c143f965 100644 --- a/shared/models/productModels/productModels.ts +++ b/shared/models/productModels/productModels.ts @@ -57,7 +57,7 @@ export const FrontendProductSchema = ProductSchema.omit({ entitlements: z.array( EntitlementSchema.extend({ feature: FeatureSchema, - }), + }) ), free_trial: FreeTrialSchema, options: z.any(), @@ -68,13 +68,21 @@ export const FullProductSchema = ProductSchema.extend({ entitlements: z.array( EntitlementSchema.extend({ feature: FeatureSchema, - }), + }) ), free_trial: FreeTrialSchema.nullish(), free_trials: z.array(FreeTrialSchema).nullish(), free_trial_ids: z.array(z.string()).nullish(), }); +export type ProductCounts = { + active: number; + canceled: number; + custom: number; + trialing: number; + all: number; +}; + export type Product = z.infer; export type FrontendProduct = z.infer; export type FullProduct = z.infer; diff --git a/vite/src/views/products/ProductsTable.tsx b/vite/src/views/products/ProductsTable.tsx index ab3be43ed..662a6f228 100644 --- a/vite/src/views/products/ProductsTable.tsx +++ b/vite/src/views/products/ProductsTable.tsx @@ -12,179 +12,172 @@ import { ProductCountsTooltip } from "./components/ProductCountsTooltip"; import { ProductTypeBadge } from "./components/ProductTypeBadge"; export const ProductsTable = ({ - products, - onRowClick, + products, + onRowClick, }: { - products: Product[]; - onRowClick?: (id: string) => void; + products: Product[]; + onRowClick?: (id: string) => void; }) => { - const { env, onboarding, showArchived } = useProductsContext(); - const navigate = useNavigate(); - const { allCounts } = useProductsContext(); + const { env, onboarding, showArchived } = useProductsContext(); + const navigate = useNavigate(); + const { allCounts } = useProductsContext(); - return ( - <> - {products && products.length > 0 ? ( - - Name - Product ID - {!onboarding && ( - <> - Active - Type - Group - Created At - - )} - - - ) : ( - !onboarding && ( -
- {showArchived ? ( - You haven't archived any products yet. - ) : ( - <> - {/* + {products && products.length > 0 ? ( + + Name + Product ID + {!onboarding && ( + <> + Active + Type + Group + Created At + + )} + + + ) : ( + !onboarding && ( +
+ {showArchived ? ( + You haven't archived any products yet. + ) : ( + <> + {/* Products */} - - Each product defines features your customers - get access to and how much they cost. Create - separate products for any free plans, paid - plans and any add-on or top up products ☝️ - - - )} -
- ) - )} + + Each product defines features your customers get access to and + how much they cost. Create separate products for any free + plans, paid plans and any add-on or top up products ☝️ + + + )} +
+ ) + )} - {products && - products - .reduce((acc, product) => { - const existingIndex = acc.findIndex(p => p.id === product.id); - - if (existingIndex === -1) { - acc.push(product); - } else { - const existing = acc[existingIndex]; - - if (showArchived) { - // If showing archived, always keep the newest version - if (product.version > existing.version) { - acc[existingIndex] = product; - } - } else { - // If not showing archived, prefer non-archived versions - if (product.archived && !existing.archived) { - // Keep existing non-archived version - } else if (!product.archived && existing.archived) { - // Replace archived with non-archived - acc[existingIndex] = product; - } else if (product.version > existing.version) { - // Both have same archived status, keep newer version - acc[existingIndex] = product; - } - } - } - - return acc; - }, [] as typeof products) - .map((product) => ( - { - if (onRowClick) { - onRowClick(product.id); - } else { - navigateTo( - `/products/${product.id}`, - navigate, - env - ); - } - }} - > - - - {product.name} - - - - - {product.id} - - - {!onboarding && ( - <> - - - - - - - - {!onboarding && product.group} - - - { - formatUnixToDateTime(product.created_at) - .date - } - - - )} - - - - - ))} - - ); + {products && + products + .reduce( + (acc, product) => { + const existingIndex = acc.findIndex((p) => p.id === product.id); + + if (existingIndex === -1) { + acc.push(product); + } else { + const existing = acc[existingIndex]; + + if (showArchived) { + // If showing archived, always keep the newest version + if (product.version > existing.version) { + acc[existingIndex] = product; + } + } else { + // If not showing archived, prefer non-archived versions + if (product.archived && !existing.archived) { + // Keep existing non-archived version + } else if (!product.archived && existing.archived) { + // Replace archived with non-archived + acc[existingIndex] = product; + } else if (product.version > existing.version) { + // Both have same archived status, keep newer version + acc[existingIndex] = product; + } + } + } + + return acc; + }, + [] as typeof products + ) + .map((product) => ( + { + if (onRowClick) { + onRowClick(product.id); + } else { + navigateTo(`/products/${product.id}`, navigate, env); + } + }} + > + + + {product.name} + + + + + {product.id} + + + {!onboarding && ( + <> + + + + + + + + {!onboarding && product.group} + + + {formatUnixToDateTime(product.created_at).date} + + + )} + + + + + ))} + + ); }; diff --git a/vite/src/views/products/components/ProductCountsTooltip.tsx b/vite/src/views/products/components/ProductCountsTooltip.tsx index ee5274ea6..5bacd9869 100644 --- a/vite/src/views/products/components/ProductCountsTooltip.tsx +++ b/vite/src/views/products/components/ProductCountsTooltip.tsx @@ -18,7 +18,7 @@ export const ProductCountsTooltip = ({ -

+

{(allCounts && allCounts[product.id]?.active) || 0}

diff --git a/vite/src/views/products/components/ProductRowToolbar.tsx b/vite/src/views/products/components/ProductRowToolbar.tsx index faa8976f0..9380b2caf 100644 --- a/vite/src/views/products/components/ProductRowToolbar.tsx +++ b/vite/src/views/products/components/ProductRowToolbar.tsx @@ -9,26 +9,24 @@ import { import { useState } from "react"; import { toast } from "sonner"; -import { Product } from "@autumn/shared"; +import { Product, ProductCounts } from "@autumn/shared"; import { ProductService } from "@/services/products/ProductService"; import { useProductsContext } from "../ProductsContext"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { getBackendErr } from "@/utils/genUtils"; import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; import { Dialog, DialogTrigger } from "@/components/ui/dialog"; import { UpdateProductDialog } from "../UpdateProduct"; import { CopyDialog } from "./CopyDialog"; -import { Copy, Delete, Pen, ArchiveRestore } from "lucide-react"; +import { Copy, Delete, Pen, ArchiveRestore, Archive } from "lucide-react"; import { DeleteProductDialog } from "./DeleteProductDialog"; export const ProductRowToolbar = ({ product, + productCounts, }: { className?: string; product: Product; + productCounts: ProductCounts; }) => { - const { mutate, env } = useProductsContext(); - const axiosInstance = useAxiosInstance({ env }); const [deleteLoading, setDeleteLoading] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [copyLoading, setCopyLoading] = useState(false); @@ -38,18 +36,9 @@ export const ProductRowToolbar = ({ const [dialogType, setDialogType] = useState<"update" | "copy">("update"); const [deleteOpen, setDeleteOpen] = useState(false); - const handleDelete = async () => { - setDeleteLoading(true); - try { - await ProductService.deleteProduct(axiosInstance, product.id); - await mutate(); - } catch (error) { - console.log("Error deleting product", error); - toast.error(getBackendErr(error, "Failed to delete product")); - } - setDeleteLoading(false); - setDeleteOpen(false); - }; + const allCount = productCounts.all; + const deleteText = allCount > 0 ? "Archive" : "Delete"; + const DeleteIcon = allCount > 0 ? Archive : Delete; return ( <> @@ -122,13 +111,13 @@ export const ProductRowToolbar = ({ }} >
- {product.archived ? 'Unarchive' : 'Delete'} + {deleteText} {deleteLoading ? ( ) : product.archived ? ( ) : ( - + )}