diff --git a/frontend/src/views/customers/CustomersTable.tsx b/frontend/src/views/customers/CustomersTable.tsx index 73e494ca7..273b986aa 100644 --- a/frontend/src/views/customers/CustomersTable.tsx +++ b/frontend/src/views/customers/CustomersTable.tsx @@ -30,7 +30,9 @@ import { } from "@/components/ui/tooltip"; const CustomerWithProductsSchema = CustomerSchema.extend({ - customer_products: z.array(CusProductSchema.extend({ product: ProductSchema })), + customer_products: z.array( + CusProductSchema.extend({ product: ProductSchema }) + ), }); type CustomerWithProducts = z.infer; @@ -42,10 +44,15 @@ export const CustomersTable = ({ const { env } = useCustomersContext(); const router = useRouter(); - - // console.log("customers", customers); const getCusProductsInfo = (customer: CustomerWithProducts) => { + if ( + !customer.customer_products || + customer.customer_products.length === 0 + ) { + return <>; + } + // Filter out expired products first const activeProducts = customer.customer_products.filter( (cusProduct) => cusProduct.status !== CusProductStatus.Expired @@ -103,14 +110,17 @@ export const CustomersTable = ({ return ( <>
- {activeProducts.slice(0, 1).map((cusProduct: any) => ( -
+ {activeProducts.slice(0, 1).map((cusProduct: any, index: number) => ( +
{getProductBadge(cusProduct)} {activeProducts.length > 1 && ( - + +{activeProducts.length - 1} @@ -143,24 +153,16 @@ export const CustomersTable = ({ - {customers.map((customer) => ( + {customers.map((customer, index) => ( navigateTo(`/customers/${customer.id}`, router, env)} > - - {customer.name} - - - {customer.id}{" "} - - - {customer.email}{" "} - - - {getCusProductsInfo(customer)} - + {customer.name} + {customer.id} + {customer.email} + {getCusProductsInfo(customer)} {formatUnixToDateTime(customer.created_at).date} diff --git a/frontend/src/views/customers/CustomersView.tsx b/frontend/src/views/customers/CustomersView.tsx index 8333e66fb..5712eb39f 100644 --- a/frontend/src/views/customers/CustomersView.tsx +++ b/frontend/src/views/customers/CustomersView.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { AppEnv } from "@autumn/shared"; import { useAxiosPostSWR, useAxiosSWR } from "@/services/useAxiosSwr"; import { CustomersContext } from "./CustomersContext"; @@ -17,18 +17,24 @@ import CreateCustomer from "./CreateCustomer"; import { SearchBar } from "./SearchBar"; import LoadingScreen from "../general/LoadingScreen"; import FilterButton from "./FilterButton"; +import { debounce } from "lodash"; +import SmallSpinner from "@/components/general/SmallSpinner"; function CustomersView({ env }: { env: AppEnv }) { // const [debouncedSearch, setDebouncedSearch] = React.useState(""); const pageSize = 50; - const [currentPage, setCurrentPage] = React.useState(1); const [searchQuery, setSearchQuery] = React.useState(""); const [filters, setFilters] = React.useState({}); - const [lastItemStack, setLastItemStack] = React.useState([]); - // url: debouncedSearch - // ? `/customers/search?search=${debouncedSearch}&page=${currentPage}` - // : `/customers?page=${currentPage}`, - // Get products + // const [currentPage, setCurrentPage] = React.useState(1); + // const [lastItemStack, setLastItemStack] = React.useState([]); + const [pagination, setPagination] = React.useState<{ + page: number; + lastItemStack: any; + }>({ + page: 1, + lastItemStack: [], + }); + const [paginationLoading, setPaginationLoading] = React.useState(false); const { data: productsData, isLoading: productsLoading } = useAxiosSWR({ url: `/products/data`, @@ -36,34 +42,26 @@ function CustomersView({ env }: { env: AppEnv }) { }); const { data, isLoading, error, mutate } = useAxiosPostSWR({ - url: `/customers/search`, + url: `/v1/customers/search`, env, data: { - page: currentPage, + page: pagination.page, + page_size: pageSize, search: searchQuery, filters, - last_item: lastItemStack[lastItemStack.length - 1], + last_item: pagination.lastItemStack[pagination.lastItemStack.length - 1], }, }); useEffect(() => { + console.log(pagination.lastItemStack[pagination.lastItemStack.length - 1]); const fetchData = async () => { - // If filters changed (not on mount), reset to page 1 and clear lastItem - if (Object.keys(filters).length > 0) { - setCurrentPage(1); - setLastItemStack([]); - } + setPaginationLoading(true); await mutate(); + setPaginationLoading(false); }; fetchData(); - }, [currentPage, filters, mutate]); - - // useEffect(() => { - // const fetchData = async () => { - // await mutate(); - // }; - // fetchData(); - // }, [currentPage, mutate]); + }, [pagination, filters, mutate]); // useEffect(() => { // const updateFilters = async () => { @@ -76,22 +74,35 @@ function CustomersView({ env }: { env: AppEnv }) { const totalPages = Math.ceil((data?.totalCount || 0) / pageSize); - // return ; if (isLoading || productsLoading) { return ; } - const handleNextPage = () => { - const lastItem = data?.customers[data?.customers.length - 1]; - const newLastItemStack = [...lastItemStack, lastItem]; - setLastItemStack(newLastItemStack); - setCurrentPage(currentPage + 1); + const handleNextPage = async () => { + if (pagination.page === totalPages) return; + setPagination((prev) => { + const lastItem = data?.customers[data?.customers.length - 1]; + const newItem = { + created_at: lastItem.created_at, + name: lastItem.name, + internal_id: lastItem.internal_id, + }; + + const newLastItemStack = [...prev.lastItemStack, newItem]; + return { + page: prev.page + 1, + lastItemStack: newLastItemStack, + }; + }); }; - const handlePreviousPage = () => { - const newLastItemStack = lastItemStack.slice(0, -1); - setLastItemStack(newLastItemStack); - setCurrentPage(currentPage - 1); + const handlePreviousPage = async () => { + if (pagination.page === 1) return; + const newLastItemStack = pagination.lastItemStack.slice(0, -1); + setPagination({ + page: pagination.page - 1, + lastItemStack: newLastItemStack, + }); }; return ( @@ -114,7 +125,13 @@ function CustomersView({ env }: { env: AppEnv }) { { + setPagination({ + page: page, + lastItemStack: [], + }); + mutate(); + }} mutate={mutate} /> @@ -125,27 +142,33 @@ function CustomersView({ env }: { env: AppEnv }) { {data?.totalCount} {data?.totalCount === 1 ? "Customer" : "Customers"}

- - - - - - - {currentPage} / {totalPages} - - - - - - + {paginationLoading ? ( +
+ +
+ ) : ( + + + + + + + {pagination.page} / {totalPages} + + + + + + + )}
)}
diff --git a/frontend/src/views/features/metered-features/FeatureConfig.tsx b/frontend/src/views/features/metered-features/FeatureConfig.tsx index dadec6413..338a320f7 100644 --- a/frontend/src/views/features/metered-features/FeatureConfig.tsx +++ b/frontend/src/views/features/metered-features/FeatureConfig.tsx @@ -70,7 +70,7 @@ export function FeatureConfig({ setFeature({ ...feature, name: fields.name, - id: fields.id, + id: isUpdate ? feature.id : fields.id, type: featureType, config: meteredConfig, }); diff --git a/server/env.sh b/server/env.sh new file mode 100755 index 000000000..b6299a2ab --- /dev/null +++ b/server/env.sh @@ -0,0 +1,26 @@ +# Print existing env: +if [ -f .env.prod ]; then + echo "Current env: local" +elif [ -f .env.local ]; then + echo "Current env: local" +else + echo "Current env: none" +fi + +# If arg1 is prod: +if [ "$1" = "prod" ]; then + # If .env and .env.prod exists, then switch + if [ -f .env ] && [ -f .env.prod ]; then + mv .env .env.local + mv .env.prod .env + fi +fi + +# If arg1 is local: +if [ "$1" = "local" ]; then + if [ -f .env ] && [ -f .env.local ]; then + cp .env .env.prod # Copy current .env to .env.prod first + cp .env.local .env # Copy .env.local to .env + rm .env.local # Remove the .env.local file + fi +fi diff --git a/server/run.sh b/server/run.sh index e1c187ef9..1948cce5a 100755 --- a/server/run.sh +++ b/server/run.sh @@ -2,5 +2,13 @@ # npx tsx scripts/alex.ts filename=$1 npx tsx $filename +# # If filename ends with .sh, then run it +# if [ "${filename##*.}" = "sh" ]; then +# ./$filename +# else if [ "${filename##*.}" = "ts" ]; then +# npx tsx $filename +# else +# echo "Invalid file extension" +# fi -# npm run test \ No newline at end of file +# # npm run test \ No newline at end of file diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index 33d3d6e28..c29a4f7c0 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -107,29 +107,21 @@ export const getCustomerDetails = async ({ cusRouter.post("/:search", async (req: any, res: any) => { try { - const { - search, - page_size = 100, - // page = 1, - last_item, - first_item, - } = req.body; + const { search, page_size = 50, page = 1, last_item, filters } = req.body; const { data: customers, count } = await CusService.searchCustomers({ sb: req.sb, orgId: req.orgId, env: req.env, search, - page: null, - pageSize: page_size, - filters: {}, + filters, lastItem: last_item, - firstItem: first_item, + pg: req.pg, + pageNumber: page, + pageSize: page_size, }); - res - .status(200) - .json({ customers, totalCount: count, count: customers.length }); + res.status(200).json({ customers, totalCount: count }); } catch (error) { handleRequestError({ error, res, action: "search customers" }); } diff --git a/server/src/internal/api/customers/cusUtils.ts b/server/src/internal/api/customers/cusUtils.ts index ad6738ced..44d48f565 100644 --- a/server/src/internal/api/customers/cusUtils.ts +++ b/server/src/internal/api/customers/cusUtils.ts @@ -1,8 +1,10 @@ import { CreateCustomerSchema, + CusProductSchema, Customer, + CustomerSchema, Organization, - ProcessorType, + ProductSchema, } from "@autumn/shared"; import { CreateCustomer } from "@autumn/shared"; @@ -13,10 +15,9 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; import { CusService } from "@/internal/customers/CusService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; -import { createStripeCustomer } from "@/external/stripe/stripeCusUtils.js"; import { generateId } from "@/utils/genUtils.js"; -import Stripe from "stripe"; +import { z } from "zod"; export const createNewCustomer = async ({ sb, @@ -134,3 +135,22 @@ export const attachDefaultProducts = async ({ }); } }; + +const CusProductResultSchema = CusProductSchema.extend({ + customer: CustomerSchema, + product: ProductSchema, +}); + +export const flipProductResults = ( + cusProducts: z.infer[] +) => { + const customers = []; + + for (const cusProduct of cusProducts) { + customers.push({ + ...cusProduct.customer, + customer_products: [cusProduct], + }); + } + return customers; +}; diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 0ff5c9164..83bf6f456 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -5,6 +5,8 @@ import { ErrCode } from "@/errors/errCodes.js"; import { StatusCodes } from "http-status-codes"; import { Client } from "pg"; import { CusProductService } from "./products/CusProductService.js"; +import { flipProductResults } from "../api/customers/cusUtils.js"; +import { format } from "date-fns"; export class CusService { static async getById({ @@ -205,107 +207,215 @@ export class CusService { } //search customers - static async searchCustomers({ + + static addPaginationAndSearch = ({ + query, + search, + pageNumber, + pageSize, + lastItem, + customerPrefix = "", + }: { + query: any; + search: string; + pageNumber: number | null; + pageSize: number; + lastItem: any; + customerPrefix: string; + }) => { + if (search && search !== "") { + query.or( + `"name".ilike.%${search}%, ` + + `"email".ilike.%${search}%, ` + + `"id".ilike.%${search}%`, + customerPrefix && { + foreignTable: "customers", + referencedTable: "customers", + } + ); + } + + console.log("pageNumber", pageNumber); + if (pageNumber) { + const from = (pageNumber - 1) * pageSize; + const to = from + pageSize - 1; + query.range(from, to); + } else if (lastItem) { + console.log("Using last item"); + query.or( + `"created_at".lt.${lastItem.created_at},` + + `and("created_at".eq.${lastItem.created_at},"internal_id".gt.${lastItem.internal_id})`, + customerPrefix && { + foreignTable: "customers", + referencedTable: "customers", + } + ); + } + + query.order("created_at", { + foreignTable: customerPrefix.slice(0, -1), + ascending: false, + }); + + query.order("internal_id", { + foreignTable: customerPrefix.slice(0, -1), + ascending: true, + }); + query.limit(pageSize); + }; + + static async searchCustomersByProduct({ sb, + pg, + orgId, + env, + search, + filters, + pageSize, + lastItem, + pageNumber, + }: { + sb: SupabaseClient; + pg: Client; + orgId: string; + env: AppEnv; + search: string; + filters: any; + pageSize: number; + lastItem: any; + pageNumber: number; + }) { + const query = sb + .from("customer_products") + .select( + "*, customer:customers!inner(*), product:products!inner(id, name)", + { + count: "exact", + } + ) + .eq("customer.org_id", orgId) + .eq("customer.env", env); + + if (filters.product_id) { + query.eq("product.id", filters.product_id); + } + + if (filters?.status === "canceled") { + console.log("Adding canceled filter"); + query + .eq("status", CusProductStatus.Active) + .not("canceled_at", "is", null); + } else if (filters?.status === "free_trial") { + console.log("Adding free trial filter"); + query + .eq("status", CusProductStatus.Active) + .gt("trial_ends_at", Date.now()); + } + + this.addPaginationAndSearch({ + query, + search, + pageNumber, + pageSize, + lastItem, + customerPrefix: "customers.", + }); + + const { data, count, error } = await query; + + if (error) { + throw error; + } + + // Flip + + const customers = flipProductResults(data); + + return { data: customers, count }; + } + static async searchCustomers({ + sb, + pg, orgId, env, search, - page, pageSize = 50, filters, lastItem, - firstItem, + pageNumber, }: { + pg: Client; sb: SupabaseClient; orgId: string; env: AppEnv; search: string; - page?: number | null; - pageSize?: number; lastItem?: { created_at: string; name: string; internal_id: string } | null; - firstItem?: { - created_at: string; - name: string; - internal_id: string; - } | null; - filters: any; + pageSize?: number; + pageNumber: number; }) { - let from, to; - if (page) { - from = (page - 1) * pageSize; - to = from + pageSize - 1; + if (filters.product_id || filters.status) { + return await this.searchCustomersByProduct({ + sb, + pg, + orgId, + env, + search, + filters, + pageSize, + lastItem, + pageNumber, + }); } let select = "*, customer_products:customer_products(*, product:products(*))"; if (filters.status || filters.product_id) { - select = `*, customer_products:customer_products!inner(*, product:products!inner(*))`; + select = `*, customer_products:customer_products!inner(*, product:products(*))`; } let query = sb .from("customers") .select(select, { - // count: "exact", - count: "planned", + count: "exact", + // count: "planned", // use for 1M rows...? }) .eq("org_id", orgId) - .eq("env", env) - .order("created_at", { ascending: false }) - .order("name", { ascending: true }) - .order("internal_id", { ascending: true }) - .limit(pageSize); + .eq("env", env); - if (page) { - query.range(from!, to!); - } else if (firstItem) { - query.or( - `created_at.gt.${firstItem.created_at},` + - `and(created_at.eq.${firstItem.created_at},name.lt.${firstItem.name}),` + - `and(created_at.eq.${firstItem.created_at},name.eq.${firstItem.name},internal_id.lt.${firstItem.internal_id})` - ); - } else if (lastItem) { - query.or( - `created_at.lt.${lastItem.created_at},` + - `and(created_at.eq.${lastItem.created_at},name.gt.${lastItem.name}),` + - `and(created_at.eq.${lastItem.created_at},name.eq.${lastItem.name},internal_id.gt.${lastItem.internal_id})` - ); - } + this.addPaginationAndSearch({ + query, + search, + pageNumber: null, + pageSize, + lastItem, + customerPrefix: "", + }); + // if (filters?.status === "canceled") { + // console.log("Adding canceled filter"); + // query + // .not("customer_products.canceled_at", "is", null) + // .gt("customer_products.canceled_at", Date.now()); + // } else if (filters?.status === "free_trial") { + // console.log("Adding free trial filter"); + // query + // .eq("customer_products.status", CusProductStatus.Active) + // .gt("customer_products.trial_ends_at", Date.now()); + // } - if (search && search !== "") { - console.log("Adding search filter:", search); - query.or( - `name.ilike.%${search}%,email.ilike.%${search}%,id.ilike.%${search}%` - ); - } - - if (filters?.status === "canceled") { - console.log("Adding canceled filter"); - query - .not("customer_products.canceled_at", "is", null) - .gt("customer_products.canceled_at", Date.now()); - } else if (filters?.status === "free_trial") { - console.log("Adding free trial filter"); - query - .eq("customer_products.status", CusProductStatus.Active) - .gt("customer_products.trial_ends_at", Date.now()); - } - - if (filters?.product_id) { - query.eq("customer_products.product.id", filters.product_id); - } + // if (filters?.product_id) { + // console.log("Filtering for product:", filters.product_id); + // query.eq("customer_products.product.id", filters.product_id); + // } const { data, count, error } = await query; - // console.log(data); - // return { data: [], count: 0 }; - if (error) { throw error; } - - return { data, count }; + const totalCount = count && count + pageSize * (pageNumber - 1); + return { data, count: totalCount }; } static async getCustomers( diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 667ef3d71..8c2727b17 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -30,6 +30,7 @@ cusRouter.post("/search", async (req: any, res: any) => { try { const { data: customers, count } = await CusService.searchCustomers({ sb, + pg, orgId: orgId, env, search: cleanedQuery, diff --git a/server/src/internal/customers/invoices/InvoiceService.ts b/server/src/internal/customers/invoices/InvoiceService.ts index b43cdcb6a..0760e190d 100644 --- a/server/src/internal/customers/invoices/InvoiceService.ts +++ b/server/src/internal/customers/invoices/InvoiceService.ts @@ -132,29 +132,29 @@ export class InvoiceService { return; } - // console.log(" ✅ Created invoice from stripe"); + console.log(" ✅ Created invoice from stripe"); - // // Send monthly_revenue event - // try { - // if (!stripeInvoice.livemode) { - // return; - // } + // Send monthly_revenue event + try { + if (!stripeInvoice.livemode) { + return; + } - // const autumn = new Autumn(); - // await autumn.sendEvent({ - // customerId: org.id, - // eventName: "revenue", - // properties: { - // value: stripeInvoice.total / 100, - // }, - // customer_data: { - // name: org.slug, - // }, - // }); - // console.log(" ✅ Sent revenue event"); - // } catch (error) { - // console.log("Failed to send revenue event", error); - // } + const autumn = new Autumn(); + await autumn.sendEvent({ + customerId: org.id, + eventName: "revenue", + properties: { + value: stripeInvoice.total / 100, + }, + customer_data: { + name: org.slug, + }, + }); + console.log(" ✅ Sent revenue event"); + } catch (error) { + console.log("Failed to send revenue event", error); + } } static async updateByStripeId({ diff --git a/server/trigger.config.d.ts b/server/trigger.config.d.ts deleted file mode 100644 index 8066cec34..000000000 --- a/server/trigger.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("@trigger.dev/sdk/v3").TriggerConfig; -export default _default; diff --git a/server/trigger.config.js b/server/trigger.config.js deleted file mode 100644 index 740780fb5..000000000 --- a/server/trigger.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "@trigger.dev/sdk/v3"; -export default defineConfig({ - project: "proj_yqrybepbgrhzmnbaccat", - runtime: "node", - logLevel: "log", - // The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped. - // You can override this on an individual task. - // See https://trigger.dev/docs/runs/max-duration - maxDuration: 3600, - retries: { - enabledInDev: true, - default: { - maxAttempts: 3, - minTimeoutInMs: 1000, - maxTimeoutInMs: 10000, - factor: 2, - randomize: true, - }, - }, - dirs: ["./src/trigger"], -}); diff --git a/server/trigger.config.ts b/server/trigger.config.ts deleted file mode 100644 index 534b18afd..000000000 --- a/server/trigger.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "@trigger.dev/sdk/v3"; - -export default defineConfig({ - project: "proj_yqrybepbgrhzmnbaccat", - runtime: "node", - logLevel: "log", - // The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped. - // You can override this on an individual task. - // See https://trigger.dev/docs/runs/max-duration - maxDuration: 3600, - retries: { - enabledInDev: true, - default: { - maxAttempts: 3, - minTimeoutInMs: 1000, - maxTimeoutInMs: 10000, - factor: 2, - randomize: true, - }, - }, - dirs: ["./src/trigger"], -});