This commit is contained in:
John Yeo
2025-10-09 22:27:35 +01:00
parent 89f5700cec
commit 669eaeb725
17 changed files with 204 additions and 203 deletions

View File

@@ -66,13 +66,11 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
body = await c.req.json();
}
logger.info(`${method} ${path}`, {
logger.info(`[HONO] ${method} ${path}`, {
context: {
body,
},
});
logger.info(`URL: ${c.req.url}`);
await next();
};

View File

@@ -1,18 +1,18 @@
import { Feature } from "@autumn/shared";
import { validateFeature } from "../internalFeatureRouter.js";
import { generateId } from "@/utils/genUtils.js";
import type { Feature } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { FeatureService } from "../FeatureService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import { validateFeature } from "../internalFeatureRouter.js";
export const handleCreateFeature = async (req: any, res: any) => {
try {
console.log("Trying to create feature");
const data = req.body;
let { db, orgId, env, logtail: logger } = req;
let parsedFeature = validateFeature(data);
const { db, orgId, env, logtail: logger } = req;
const parsedFeature = validateFeature(data);
const feature: Feature = {
archived: false,
@@ -23,8 +23,8 @@ export const handleCreateFeature = async (req: any, res: any) => {
...parsedFeature,
};
let org = await OrgService.getFromReq(req);
let insertedData = await FeatureService.insert({
const org = await OrgService.getFromReq(req);
const insertedData = await FeatureService.insert({
db,
data: feature,
logger,
@@ -38,7 +38,7 @@ export const handleCreateFeature = async (req: any, res: any) => {
},
});
let insertedFeature =
const insertedFeature =
insertedData && insertedData.length > 0 ? insertedData[0] : null;
res.status(200).json(insertedFeature);
} catch (error) {

View File

@@ -1,15 +1,15 @@
import express, { Router } from "express";
import { FeatureService } from "./FeatureService.js";
import { ErrCode, FeatureType, products, entitlements } from "@autumn/shared";
import { validateCreditSystem, validateFeatureId } from "./featureUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js";
import { CreateFeatureSchema, ErrCode, FeatureType } from "@autumn/shared";
import express, { type Router } from "express";
import { handleDeleteFeature } from "@/internal/features/handlers/handleDeleteFeature.js";
import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import { validateMeteredConfig } from "./featureUtils.js";
import { CreateFeatureSchema } from "@autumn/shared";
import { sql, eq, and } from "drizzle-orm";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "./FeatureService.js";
import {
validateCreditSystem,
validateFeatureId,
validateMeteredConfig,
} from "./featureUtils.js";
import { handleCreateFeature } from "./handlers/handleCreateFeature.js";
import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
@@ -17,20 +17,20 @@ export const internalFeatureRouter: Router = express.Router();
internalFeatureRouter.get("", async (req: any, res: any) => {
try {
let { showArchived } = req.query;
const { showArchived } = req.query;
if (showArchived !== undefined) {
// If showArchived is specified, use FeatureService.list with the parameter
let features = await FeatureService.list({
const features = await FeatureService.list({
db: req.db,
orgId: req.orgId,
env: req.env,
archived: showArchived === "true" ? true : false,
archived: showArchived === "true",
});
res.status(200).json({ features });
} else {
// If no showArchived parameter, use the original getFromReq method
let features = await FeatureService.getFromReq(req);
const features = await FeatureService.getFromReq(req);
res.status(200).json({ features });
}
} catch (error: any) {
@@ -40,14 +40,14 @@ internalFeatureRouter.get("", async (req: any, res: any) => {
});
export const validateFeature = (data: any) => {
let featureType = data.type;
const featureType = data.type;
validateFeatureId(data.id);
let config = data.config;
if (featureType == FeatureType.Metered) {
if (featureType === FeatureType.Metered) {
config = validateMeteredConfig(config);
} else if (featureType == FeatureType.CreditSystem) {
} else if (featureType === FeatureType.CreditSystem) {
config = validateCreditSystem(config);
}

View File

@@ -355,8 +355,6 @@ export class ProductService {
internalId: string;
update: any;
}) {
console.log("internalId", internalId);
console.log("update", update);
await db
.update(products)
.set(update)

View File

@@ -77,6 +77,7 @@ export const handleUpdateProductV2 = createRoute({
const newProductV2: ProductV2 = {
...curProductV2,
...body,
group: body.group || curProductV2.group || "",
items: body.items || [],
free_trial: newFreeTrial || curProductV2.free_trial || undefined,
};
@@ -149,7 +150,7 @@ export const handleUpdateProductV2 = createRoute({
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
idOrInternalId: body.id || fullProduct.id,
orgId: org.id,
env,
});
@@ -191,17 +192,17 @@ export const handleUpdateProductV2 = createRoute({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
productId: body.id || fullProduct.id,
orgId: org.id,
env,
},
});
return c.json(
getProductResponse({
product: newFullProduct,
features,
}),
);
const productResponse = await getProductResponse({
product: newFullProduct,
features,
});
return c.json(productResponse);
},
});

View File

@@ -205,9 +205,7 @@ export const handleUpdateProductDetails = async ({
}
}
if (productDetailsSame(curProduct, newProduct)) {
return;
}
if (productDetailsSame(curProduct, newProduct)) return;
if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) {
if (customersOnAllVersions.length > 0) {
@@ -225,6 +223,7 @@ export const handleUpdateProductDetails = async ({
}
// 2. Update product
await ProductService.updateByInternalId({
db,
internalId: curProduct.internal_id,

View File

@@ -126,6 +126,14 @@ productRouter.get("/:productId/data2", async (req: any, res) => {
const { version } = req.query;
const { db, orgId, env } = req;
console.log("[/data2] Request params:", {
productId,
version,
orgId,
env,
featuresLength: req.features?.length,
});
const [product, latestProduct] = await Promise.all([
ProductService.getFull({
db,
@@ -146,9 +154,16 @@ productRouter.get("/:productId/data2", async (req: any, res) => {
throw new ProductNotFoundError({ productId, version });
}
console.log(
"[/data2] Product found:",
product.id,
"Features available:",
req.features?.length || 0,
);
const productV2 = mapToProductV2({
product: product,
features: req.features,
features: req.features || [],
});
res.status(200).json({
@@ -158,8 +173,13 @@ productRouter.get("/:productId/data2", async (req: any, res) => {
},
numVersions: latestProduct.version,
});
} catch (error) {
} catch (error: any) {
console.error("Failed to get product", error);
console.error("Error details:", {
message: error?.message,
stack: error?.stack,
name: error?.name,
});
res.status(500).send(error);
}
});

View File

@@ -35,7 +35,7 @@ export const UpdateProductV2ParamsSchema = z.object({
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
version: z.number().optional(),
group: z.string().optional(),
group: z.string().nullish(),
archived: z.boolean().optional(),
items: z.array(CreateProductItemParamsSchema).optional(),

View File

@@ -45,7 +45,7 @@ export const UpdateProductSchema = z.object({
name: z.string().min(1, "Product name cannot be empty").optional(),
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
group: z.string().optional(),
group: z.string().nullish(),
archived: z.boolean().optional(),
});

View File

@@ -32,9 +32,11 @@ export const useFeatureCreationActions = ({
setProduct,
setBaseProduct,
}: FeatureCreationActionsProps) => {
const { refetch: refetchFeatures } = useFeaturesQuery();
const { features, refetch: refetchFeatures } = useFeaturesQuery();
// Create feature and add to product
const handleProceed = useCallback(async (): Promise<boolean> => {
// 1. If feature already exists, update it, if not create it
const createdFeature = await createFeature(
feature as CreateFeature,
axiosInstance,
@@ -45,22 +47,9 @@ export const useFeatureCreationActions = ({
await refetchFeatures(); // Refresh features list
// Debug: Log the feature data used to create product item
console.log("FeatureCreationActions - created feature data:", {
id: createdFeature.id,
type: createdFeature.type,
usage_type: createdFeature.config?.usage_type,
fullConfig: createdFeature.config,
});
// Create ProductItem and add to product immediately for live editing
const newItem = createProductItem(createdFeature);
console.log("FeatureCreationActions - newItem created:", {
feature_id: newItem.feature_id,
feature_type: newItem.feature_type,
});
setFeature(createdFeature);
// Add feature item to product (preserving any existing base price item)

View File

@@ -23,42 +23,34 @@ export const usePlanDetailsActions = ({
productCreatedRef,
setBaseProduct,
}: PlanDetailsActionsProps) => {
const { products } = useProductsQuery();
const { products, refetch: refetchProducts } = useProductsQuery();
// Create product and update base state
const handleProceed = useCallback(async (): Promise<boolean> => {
// console.log("Product:", product);
// Check if base product exists in products query
let newProduct: ProductV2;
if (products.find((p) => p.id === baseProduct.id)) {
// Update product
await updateProduct({
newProduct = await updateProduct({
axiosInstance,
productId: baseProduct.id,
product: product as ProductV2,
onSuccess: async () => {
// await handleRefetch();
},
onSuccess: async () => {},
});
console.log(
`Updating product, productId: ${baseProduct.id}, product:`,
product,
);
return true;
} else {
const createdProduct = await createProduct(
newProduct = await createProduct(
product,
axiosInstance,
productCreatedRef,
);
console.log(`Creating product, product:`, createdProduct);
if (!createdProduct) return false;
setBaseProduct(createdProduct);
return true;
}
if (!newProduct) return false;
setBaseProduct(newProduct);
await refetchProducts();
return true;
}, [
product,
baseProduct,

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
@@ -12,6 +12,7 @@ export const useOnboardingData = () => {
isLoading: productsLoading,
refetch: refetchProducts,
} = useProductsQuery();
const {
features,
isLoading: featuresLoading,
@@ -21,8 +22,10 @@ export const useOnboardingData = () => {
const {
baseProduct,
setBaseProduct,
feature,
setFeature,
productCreatedRef,
featureCreatedRef,
isButtonLoading,
@@ -65,6 +68,7 @@ export const useOnboardingData = () => {
latestId: firstProduct.id,
};
// loadProductData(firstProduct.id);
setBaseProduct(firstProduct);
}
@@ -143,15 +147,11 @@ export const useOnboardingData = () => {
// ]);
// Refetch product data
const handleRefetch = useCallback(async () => {
// if (product?.id) {
// await loadProductData(product.id);
// }
}, [product?.id]);
const handleRefetch = () => {};
useEffect(() => {
console.log("[useOnboardingData] Feature: ", feature);
}, [feature]);
// useEffect(() => {
// console.log("[useOnboardingData] Feature: ", feature);
// }, [feature]);
const isQueryLoading = useMemo(() => {
return productsLoading || featuresLoading;

View File

@@ -1,6 +1,7 @@
import {
AppEnv,
BillingInterval,
type Feature,
type ProductItem,
type ProductV2,
} from "@autumn/shared";
@@ -29,6 +30,8 @@ export const useOnboardingState = () => {
internal_id: "",
});
const [baseFeature, setBaseFeature] = useState<Feature>(getDefaultFeature());
// Feature creation state
const [feature, setFeature] = useState(() => getDefaultFeature());

View File

@@ -3,7 +3,6 @@ import {
AppEnv,
type CreateFeature,
CreateFeatureSchema,
CreateProductSchema,
type Feature,
FeatureType,
FeatureUsageType,
@@ -159,39 +158,39 @@ export const handleBackNavigation = async (
setSelectedProductId: (id: string) => void,
axiosInstance?: AxiosInstance,
) => {
// When going back to step 1 from step 2, load the created product
const currentStepNum = getStepNumber(step);
const willGoToStep1 = currentStepNum === 2;
if (willGoToStep1 && productCreatedRef.current.created && productCreatedRef.current.latestId) {
// Load the created product data
if (axiosInstance) {
try {
const response = await axiosInstance.get(
`/products/${productCreatedRef.current.latestId}/data2`,
);
setBaseProduct(response.data.product);
setSelectedProductId(productCreatedRef.current.latestId);
return;
} catch (error) {
console.error("Failed to load product on back navigation:", error);
}
}
}
const isInConflictState = checkForStateConflicts(
step,
productCreatedRef,
baseProduct,
);
if (isInConflictState) {
resetCreationTracking(productCreatedRef, featureCreatedRef);
const initialProduct = createInitialProductState(baseProduct.env);
setBaseProduct(initialProduct);
setSelectedProductId("");
}
// // When going back to step 1 from step 2, load the created product
// const currentStepNum = getStepNumber(step);
// const willGoToStep1 = currentStepNum === 2;
// if (
// willGoToStep1 &&
// productCreatedRef.current.created &&
// productCreatedRef.current.latestId
// ) {
// // Load the created product data
// if (axiosInstance) {
// try {
// const response = await axiosInstance.get(
// `/products/${productCreatedRef.current.latestId}/data2`,
// );
// setBaseProduct(response.data.product);
// setSelectedProductId(productCreatedRef.current.latestId);
// return;
// } catch (error) {
// console.error("Failed to load product on back navigation:", error);
// }
// }
// }
// const isInConflictState = checkForStateConflicts(
// step,
// productCreatedRef,
// baseProduct,
// );
// if (isInConflictState) {
// resetCreationTracking(productCreatedRef, featureCreatedRef);
// const initialProduct = createInitialProductState(baseProduct.env);
// setBaseProduct(initialProduct);
// setSelectedProductId("");
// }
};
// Handle plan selection logic
@@ -262,50 +261,55 @@ export const createProduct = async (
}>,
) => {
try {
const result = CreateProductSchema.safeParse({
name: product?.name,
id: product?.id,
items: product?.items || [],
});
// const result = CreateProductSchema.safeParse({
// name: product?.name,
// id: product?.id,
// items: product?.items || [],
// });
if (result.error) {
console.error("Product validation error:", result.error);
toast.error("Invalid product data");
return null;
}
// if (result.error) {
// console.error("Product validation error:", result.error);
// toast.error("Invalid product data");
// return null;
// }
let createdProduct: Awaited<
ReturnType<typeof ProductService.createProduct>
>;
if (!productCreatedRef.current.created) {
// First time creating the product
createdProduct = await ProductService.createProduct(
axiosInstance,
result.data,
);
productCreatedRef.current = {
created: true,
latestId: createdProduct.id,
};
toast.success(`Product "${product?.name}" created successfully!`);
} else {
// Product already exists, update it (supports ID changes)
// Note: Backend creates a new product if ID changed, archives old one
await ProductService.updateProduct(
axiosInstance,
productCreatedRef.current.latestId as string,
result.data,
);
// Fetch the full product after update (same as PlanEditorView does)
const response = await axiosInstance.get(
`/products/${result.data.id}/data2`,
);
createdProduct = response.data.product;
createdProduct = await ProductService.createProduct(axiosInstance, product);
productCreatedRef.current = {
created: true,
latestId: createdProduct.id,
};
toast.success(`Product "${product?.name}" created successfully!`);
productCreatedRef.current.latestId = result.data.id;
toast.success(`Product "${product?.name}" updated successfully!`);
}
// if (!productCreatedRef.current.created) {
// // First time creating the product
// createdProduct = await ProductService.createProduct(
// axiosInstance,
// product,
// );
// productCreatedRef.current = {
// created: true,
// latestId: createdProduct.id,
// };
// toast.success(`Product "${product?.name}" created successfully!`);
// } else {
// // Product already exists, update it (supports ID changes)
// // Note: Backend creates a new product if ID changed, archives old one
// await ProductService.updateProduct(
// axiosInstance,
// productCreatedRef.current.latestId as string,
// product,
// );
// // Fetch the full product after update (same as PlanEditorView does)
// const response = await axiosInstance.get(`/products/${product.id}/data2`);
// createdProduct = response.data.product;
// productCreatedRef.current.latestId = product.id;
// toast.success(`Product "${product?.name}" updated successfully!`);
// }
return {
...createdProduct,
@@ -393,15 +397,9 @@ export const createProductItem = (createdFeature: CreateFeature) => {
// Map feature type to product item feature type
let featureType: ProductItemFeatureType;
if (
createdFeature.type === FeatureType.Boolean ||
createdFeature.type === "boolean"
) {
if (createdFeature.type === FeatureType.Boolean) {
featureType = ProductItemFeatureType.Static;
} else if (
createdFeature.type === FeatureType.CreditSystem ||
createdFeature.type === "credit_system"
) {
} else if (createdFeature.type === FeatureType.CreditSystem) {
featureType = ProductItemFeatureType.SingleUse;
} else if (
createdFeature.type === FeatureType.Metered ||
@@ -428,10 +426,7 @@ export const createProductItem = (createdFeature: CreateFeature) => {
console.log("createProductItem - mapped to feature type:", featureType);
// Boolean features have a simplified structure with no pricing/billing properties
if (
createdFeature.type === FeatureType.Boolean ||
createdFeature.type === "boolean"
) {
if (createdFeature.type === FeatureType.Boolean) {
return {
feature_id: createdFeature.id,
feature_type: featureType,

View File

@@ -53,6 +53,7 @@ export const BasePriceSection = () => {
intervalCount?: number;
}) => {
const newItems = [...product.items];
// Find base price item by isBasePrice flag, not by price match
const basePriceIndex = newItems.findIndex((item: ProductItem) =>
isPriceItem(item),

View File

@@ -18,30 +18,31 @@ export function NewFeatureDetails({
targetKey: "id",
});
if (feature)
return (
<SheetSection title="Feature Details">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<div>
<FormLabel>Name</FormLabel>
<Input
placeholder="eg. Messages"
value={feature.name}
onChange={(e) => setSource(e.target.value)}
/>
</div>
if (!feature) return null;
<div>
<FormLabel>ID</FormLabel>
<Input
placeholder="eg. messages"
value={feature.id}
onChange={(e) => setTarget(e.target.value)}
/>
</div>
return (
<SheetSection title="Feature Details">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<div>
<FormLabel>Name</FormLabel>
<Input
placeholder="eg. Messages"
value={feature.name}
onChange={(e) => setSource(e.target.value)}
/>
</div>
<div>
<FormLabel>ID</FormLabel>
<Input
placeholder="eg. messages"
value={feature.id}
onChange={(e) => setTarget(e.target.value)}
/>
</div>
</div>
</SheetSection>
);
</div>
</SheetSection>
);
}

View File

@@ -1,7 +1,7 @@
import {
type FrontendProductItem,
type ProductV2,
UpdateProductSchema,
UpdateProductV2ParamsSchema,
} from "@autumn/shared";
import type { AxiosError, AxiosInstance } from "axios";
import { toast } from "sonner";
@@ -29,20 +29,24 @@ export const updateProduct = async ({
return false;
}
try {
const updateData = {
...UpdateProductSchema.parse(product),
const updateData = UpdateProductV2ParamsSchema.parse({
...product,
items: product.items,
free_trial: product.free_trial,
};
});
await ProductService.updateProduct(axiosInstance, productId, updateData);
const updatedProduct = await ProductService.updateProduct(
axiosInstance,
productId,
updateData,
);
toast.success("Product updated successfully");
await onSuccess();
return true;
return updatedProduct;
} catch (error) {
console.error(error);
console.error((error as ZodError).message);
toast.error(
getBackendErr(error as AxiosError | ZodError, "Failed to update product"),
);