fixed customer search

This commit is contained in:
John Yeo
2025-02-06 13:38:47 +00:00
parent 312b2bbac3
commit cf888e0812
13 changed files with 362 additions and 225 deletions

View File

@@ -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<typeof CustomerWithProductsSchema>;
@@ -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 (
<>
<div className="flex flex-wrap gap-2">
{activeProducts.slice(0, 1).map((cusProduct: any) => (
<div key={cusProduct.id}>
{activeProducts.slice(0, 1).map((cusProduct: any, index: number) => (
<div key={index}>
{getProductBadge(cusProduct)}
{activeProducts.length > 1 && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Badge variant="status" className="ml-1 bg-stone-100 text-primary">
<Badge
variant="status"
className="ml-1 bg-stone-100 text-primary"
>
+{activeProducts.length - 1}
</Badge>
</TooltipTrigger>
@@ -143,24 +153,16 @@ export const CustomersTable = ({
</TableRow>
</TableHeader>
<TableBody>
{customers.map((customer) => (
{customers.map((customer, index) => (
<TableRow
key={customer.id}
key={index}
className="cursor-pointer"
onClick={() => navigateTo(`/customers/${customer.id}`, router, env)}
>
<TableCell>
{customer.name}
</TableCell>
<TableCell className="font-mono">
{customer.id}{" "}
</TableCell>
<TableCell>
{customer.email}{" "}
</TableCell>
<TableCell>
{getCusProductsInfo(customer)}
</TableCell>
<TableCell>{customer.name}</TableCell>
<TableCell className="font-mono">{customer.id} </TableCell>
<TableCell>{customer.email} </TableCell>
<TableCell>{getCusProductsInfo(customer)}</TableCell>
<TableCell className="min-w-20 w-24">
{formatUnixToDateTime(customer.created_at).date}
<span className="text-t3">

View File

@@ -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<any>({});
const [lastItemStack, setLastItemStack] = React.useState<any[]>([]);
// url: debouncedSearch
// ? `/customers/search?search=${debouncedSearch}&page=${currentPage}`
// : `/customers?page=${currentPage}`,
// Get products
// const [currentPage, setCurrentPage] = React.useState(1);
// const [lastItemStack, setLastItemStack] = React.useState<any[]>([]);
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 <LoadingScreen />;
if (isLoading || productsLoading) {
return <LoadingScreen />;
}
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 }) {
<SearchBar
query={searchQuery}
setQuery={setSearchQuery}
setCurrentPage={setCurrentPage}
setCurrentPage={(page: number) => {
setPagination({
page: page,
lastItemStack: [],
});
mutate();
}}
mutate={mutate}
/>
<FilterButton />
@@ -125,27 +142,33 @@ function CustomersView({ env }: { env: AppEnv }) {
<span className="font-semibold">{data?.totalCount} </span>
{data?.totalCount === 1 ? "Customer" : "Customers"}
</p>
<Pagination className="w-[100px] h-8 text-xs">
<PaginationContent className="w-full flex justify-between ">
<PaginationItem>
<PaginationPrevious
onClick={handlePreviousPage}
isActive={currentPage !== 1}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
<PaginationItem className="">
{currentPage} / {totalPages}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNextPage}
isActive={currentPage !== totalPages}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
{paginationLoading ? (
<div className="w-[120px] h-8 flex items-center justify-center">
<SmallSpinner />
</div>
) : (
<Pagination className="w-[120px] h-8 text-xs">
<PaginationContent className="w-full flex justify-between ">
<PaginationItem>
<PaginationPrevious
onClick={handlePreviousPage}
isActive={pagination.page !== 1}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
<PaginationItem className="">
{pagination.page} / {totalPages}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNextPage}
isActive={pagination.page !== totalPages}
className="text-xs cursor-pointer p-1 h-6"
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)}
</div>

View File

@@ -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,
});

26
server/env.sh Executable file
View File

@@ -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

View File

@@ -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
# # npm run test

View File

@@ -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" });
}

View File

@@ -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<typeof CusProductResultSchema>[]
) => {
const customers = [];
for (const cusProduct of cusProducts) {
customers.push({
...cusProduct.customer,
customer_products: [cusProduct],
});
}
return customers;
};

View File

@@ -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(

View File

@@ -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,

View File

@@ -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({

View File

@@ -1,2 +0,0 @@
declare const _default: import("@trigger.dev/sdk/v3").TriggerConfig;
export default _default;

View File

@@ -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"],
});

View File

@@ -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"],
});