feat: deploy to production flow
- Added 3-step deploy dialog (Connect Stripe, Copy Products, Create API Key) - Implemented backend endpoints for copying products/features between environments - Added localStorage caching for organization data and deploy button visibility - Created useShowDeployButton hook to conditionally show deploy button - Hide environment switch in CommandBar when organization not deployed - Abstracted feature and product creation/update logic into reusable actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,10 @@ import "dotenv/config";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const main = async () => {
|
||||
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
|
||||
const stripe = new Stripe(process.env.STRIPE_LIVE_SECRET_KEY || "");
|
||||
|
||||
const result = await stripe.webhookEndpoints.create({
|
||||
url: "https://api.useautumn.com/webhooks/connect/sandbox",
|
||||
url: "https://express.dev.useautumn.com/webhooks/connect/live",
|
||||
enabled_events: [
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
|
||||
54
server/src/internal/features/featureActions/createFeature.ts
Normal file
54
server/src/internal/features/featureActions/createFeature.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Feature } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
import { FeatureService } from "../FeatureService.js";
|
||||
import { validateFeature } from "../internalFeatureRouter.js";
|
||||
|
||||
interface CreateFeatureParams {
|
||||
ctx: AutumnContext;
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config?: any;
|
||||
event_names?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new feature in the database
|
||||
* Used by both the API handler and internal operations like product copying
|
||||
*/
|
||||
export const createFeature = async ({
|
||||
ctx,
|
||||
data,
|
||||
}: CreateFeatureParams): Promise<Feature | null> => {
|
||||
const parsedFeature = validateFeature(data);
|
||||
|
||||
const feature: Feature = {
|
||||
archived: false,
|
||||
internal_id: generateId("fe"),
|
||||
org_id: ctx.org.id,
|
||||
created_at: Date.now(),
|
||||
env: ctx.env,
|
||||
...parsedFeature,
|
||||
};
|
||||
|
||||
const insertedData = await FeatureService.insert({
|
||||
db: ctx.db,
|
||||
data: feature,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
payload: {
|
||||
feature,
|
||||
org: ctx.org,
|
||||
},
|
||||
});
|
||||
|
||||
return insertedData && insertedData.length > 0 ? insertedData[0] : null;
|
||||
};
|
||||
147
server/src/internal/features/featureActions/updateFeature.ts
Normal file
147
server/src/internal/features/featureActions/updateFeature.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { ErrCode, type Feature, FeatureType, notNullish } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { FeatureService } from "../FeatureService.js";
|
||||
import {
|
||||
validateCreditSystem,
|
||||
validateMeteredConfig,
|
||||
} from "../featureUtils.js";
|
||||
import { getObjectsUsingFeature } from "../handlers/handleUpdateFeature/getObjectsUsingFeature.js";
|
||||
import { handleFeatureIdChanged } from "../handlers/handleUpdateFeature/handleFeatureIdChanged.js";
|
||||
import { handleFeatureTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureTypeChanged.js";
|
||||
import { handleFeatureUsageTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.js";
|
||||
|
||||
interface UpdateFeatureParams {
|
||||
ctx: AutumnContext;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing feature with full validation logic
|
||||
*/
|
||||
export const updateFeature = async ({
|
||||
ctx,
|
||||
featureId,
|
||||
updates,
|
||||
}: UpdateFeatureParams): Promise<Feature | null> => {
|
||||
// 1. Get all features and find the one to update
|
||||
const allFeatures = await FeatureService.list({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const feature = allFeatures.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${featureId} not found`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if changing type, id, or usage type
|
||||
const isChangingType =
|
||||
notNullish(updates.type) && feature.type !== updates.type;
|
||||
|
||||
const isChangingId = notNullish(updates.id) && feature.id !== updates.id;
|
||||
|
||||
const isChangingUsageType =
|
||||
feature.type !== FeatureType.Boolean &&
|
||||
updates.type !== FeatureType.Boolean &&
|
||||
feature.config?.usage_type !== updates.config?.usage_type;
|
||||
|
||||
const isChangingName = updates.name && feature.name !== updates.name;
|
||||
|
||||
if (isChangingType || isChangingId || isChangingUsageType) {
|
||||
const objectsUsingFeature = await getObjectsUsingFeature({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
allFeatures,
|
||||
feature,
|
||||
});
|
||||
|
||||
// Handle type change
|
||||
if (isChangingType && updates.type) {
|
||||
await handleFeatureTypeChanged({
|
||||
ctx,
|
||||
objectsUsingFeature,
|
||||
feature,
|
||||
newType: updates.type,
|
||||
});
|
||||
}
|
||||
|
||||
const { linkedEntitlements, entitlements, prices, creditSystems } =
|
||||
objectsUsingFeature;
|
||||
|
||||
// Handle ID change
|
||||
if (isChangingId && updates.id) {
|
||||
await handleFeatureIdChanged({
|
||||
ctx,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newId: updates.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle usage type change
|
||||
if (isChangingUsageType && updates.config?.usage_type) {
|
||||
await handleFeatureUsageTypeChanged({
|
||||
db: ctx.db,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newUsageType: updates.config.usage_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate config based on feature type
|
||||
const newConfig =
|
||||
updates.config !== undefined
|
||||
? feature.type === FeatureType.CreditSystem
|
||||
? validateCreditSystem(updates.config)
|
||||
: feature.type === FeatureType.Metered
|
||||
? validateMeteredConfig(updates.config)
|
||||
: updates.config
|
||||
: feature.config;
|
||||
|
||||
// Update the feature
|
||||
const updatedFeature = await FeatureService.update({
|
||||
db: ctx.db,
|
||||
id: featureId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
updates: {
|
||||
id: updates.id,
|
||||
name: updates.name,
|
||||
type: updates.type,
|
||||
archived: updates.archived,
|
||||
event_names: updates.event_names,
|
||||
config: newConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Queue display generation if name changed
|
||||
if (isChangingName && updatedFeature) {
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
payload: {
|
||||
feature: updatedFeature,
|
||||
org: ctx.org,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedFeature;
|
||||
};
|
||||
@@ -40,29 +40,6 @@ export const validateMeteredConfig = (config: MeteredConfig) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Event names are now stored in feature.event_names, not in config.filters
|
||||
// if (config.aggregate?.type === AggregateType.Count) {
|
||||
// newConfig.aggregate = {
|
||||
// type: AggregateType.Count,
|
||||
// property: null,
|
||||
// }; // to continue testing support for count...
|
||||
// } else {
|
||||
// newConfig.aggregate = {
|
||||
// type: AggregateType.Sum,
|
||||
// property: "value",
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (newConfig?.filters?.length === 0 || !newConfig?.filters) {
|
||||
// newConfig.filters = [
|
||||
// {
|
||||
// property: "",
|
||||
// operator: "",
|
||||
// value: [],
|
||||
// },
|
||||
// ];
|
||||
// }
|
||||
|
||||
return newConfig as MeteredConfig;
|
||||
};
|
||||
|
||||
@@ -137,7 +114,7 @@ export const runSaveFeatureDisplayTask = async ({
|
||||
|
||||
await FeatureService.update({
|
||||
db,
|
||||
internalId: feature.internal_id!,
|
||||
internalId: feature.internal_id,
|
||||
updates: {
|
||||
display,
|
||||
},
|
||||
|
||||
@@ -1,45 +1,16 @@
|
||||
import type { Feature } from "@autumn/shared";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.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";
|
||||
import { createFeature } from "../featureActions/createFeature.js";
|
||||
|
||||
export const handleCreateFeature = async (req: any, res: any) => {
|
||||
try {
|
||||
console.log("Trying to create feature");
|
||||
const data = req.body;
|
||||
const { db, orgId, env, logger } = req;
|
||||
const parsedFeature = validateFeature(data);
|
||||
|
||||
const feature: Feature = {
|
||||
archived: false,
|
||||
internal_id: generateId("fe"),
|
||||
org_id: orgId,
|
||||
created_at: Date.now(),
|
||||
env: env,
|
||||
...parsedFeature,
|
||||
};
|
||||
|
||||
const org = await OrgService.getFromReq(req);
|
||||
const insertedData = await FeatureService.insert({
|
||||
db,
|
||||
data: feature,
|
||||
logger,
|
||||
const insertedFeature = await createFeature({
|
||||
ctx: req,
|
||||
data,
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
payload: {
|
||||
feature,
|
||||
org: org,
|
||||
},
|
||||
});
|
||||
|
||||
const insertedFeature =
|
||||
insertedData && insertedData.length > 0 ? insertedData[0] : null;
|
||||
res.status(200).json(insertedFeature);
|
||||
} catch (error) {
|
||||
handleFrontendReqError({ req, error, res, action: "Create feature" });
|
||||
|
||||
@@ -1,257 +1,11 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
EntInterval,
|
||||
type Entitlement,
|
||||
type EntitlementWithFeature,
|
||||
ErrCode,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
FeatureUsageType,
|
||||
notNullish,
|
||||
type Price,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import {
|
||||
validateCreditSystem,
|
||||
validateMeteredConfig,
|
||||
} from "@/internal/features/featureUtils.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { keyToTitle } from "@/utils/genUtils.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { updateFeature } from "../featureActions/updateFeature.js";
|
||||
import { toApiFeature } from "../utils/mapFeatureUtils.js";
|
||||
import { getObjectsUsingFeature } from "./handleUpdateFeature/getObjectsUsingFeature.js";
|
||||
import { handleFeatureTypeChanged } from "./handleUpdateFeature/handleFeatureTypeChanged.js";
|
||||
|
||||
const handleFeatureIdChanged = async ({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newId,
|
||||
logger,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
feature: Feature;
|
||||
linkedEntitlements: Entitlement[];
|
||||
entitlements: Entitlement[];
|
||||
prices: Price[];
|
||||
creditSystems: Feature[];
|
||||
newId: string;
|
||||
logger: any;
|
||||
}) => {
|
||||
// 1. Check if any customer entitlement linked to this feature
|
||||
const cusEnts = await CusEntService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id!,
|
||||
});
|
||||
|
||||
if (cusEnts.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot change id of feature ${feature.id} because a customer is using it`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Update all linked objects
|
||||
const batchUpdate = [];
|
||||
|
||||
for (const entitlement of linkedEntitlements) {
|
||||
batchUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id!,
|
||||
updates: {
|
||||
entity_feature_id: newId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchUpdate);
|
||||
|
||||
// 3. Update all linked prices
|
||||
const priceUpdate = [];
|
||||
for (const price of prices) {
|
||||
priceUpdate.push(
|
||||
PriceService.update({
|
||||
db,
|
||||
id: price.id!,
|
||||
update: {
|
||||
config: {
|
||||
...price.config,
|
||||
feature_id: newId,
|
||||
} as UsagePriceConfig,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(priceUpdate);
|
||||
|
||||
// 4. Update all linked credit systems
|
||||
const creditSystemUpdate = [];
|
||||
for (const creditSystem of creditSystems) {
|
||||
const newSchema = structuredClone(creditSystem.config.schema);
|
||||
for (let i = 0; i < newSchema.length; i++) {
|
||||
if (newSchema[i].metered_feature_id === feature.id) {
|
||||
newSchema[i].metered_feature_id = newId;
|
||||
}
|
||||
}
|
||||
creditSystemUpdate.push(
|
||||
FeatureService.update({
|
||||
db,
|
||||
id: creditSystem.id!,
|
||||
orgId,
|
||||
env,
|
||||
updates: {
|
||||
config: {
|
||||
...creditSystem.config,
|
||||
schema: newSchema,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(creditSystemUpdate);
|
||||
|
||||
// 5. Update all linked entitlements
|
||||
const entitlementUpdate = [];
|
||||
|
||||
for (const entitlement of entitlements) {
|
||||
entitlementUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id!,
|
||||
updates: {
|
||||
feature_id: newId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(entitlementUpdate);
|
||||
};
|
||||
|
||||
const handleFeatureUsageTypeChanged = async ({
|
||||
db,
|
||||
feature,
|
||||
newUsageType,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
feature: Feature;
|
||||
newUsageType: FeatureUsageType;
|
||||
linkedEntitlements: EntitlementWithFeature[];
|
||||
entitlements: EntitlementWithFeature[];
|
||||
prices: Price[];
|
||||
creditSystems: Feature[];
|
||||
}) => {
|
||||
const usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
|
||||
if (creditSystems.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedEntitlements.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is used as an entity by ${linkedEntitlements[0].feature.name}`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Get cus product using feature...
|
||||
const cusEnts = await CusEntService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id!,
|
||||
});
|
||||
|
||||
if (cusEnts && cusEnts.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is / was used by customers`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (entitlements.length > 0) {
|
||||
console.log(
|
||||
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
|
||||
);
|
||||
if (newUsageType === FeatureUsageType.Continuous) {
|
||||
const batchEntUpdate = [];
|
||||
for (const entitlement of entitlements) {
|
||||
batchEntUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id!,
|
||||
updates: {
|
||||
interval: EntInterval.Lifetime,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchEntUpdate);
|
||||
console.log(`Updated ${entitlements.length} entitlements`);
|
||||
}
|
||||
}
|
||||
|
||||
if (prices.length > 0) {
|
||||
const batchPriceUpdate = [];
|
||||
for (const price of prices) {
|
||||
const priceConfig = price.config as UsagePriceConfig;
|
||||
|
||||
batchPriceUpdate.push(
|
||||
PriceService.update({
|
||||
db,
|
||||
id: price.id!,
|
||||
update: {
|
||||
config: {
|
||||
...priceConfig,
|
||||
should_prorate:
|
||||
newUsageType === FeatureUsageType.Continuous ? false : true, // if continuous, don't prorate -> get usage_in_arrear type...
|
||||
stripe_price_id: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchPriceUpdate);
|
||||
console.log(`Updated ${prices.length} prices`);
|
||||
}
|
||||
|
||||
// // Allow update for entitlement / price?
|
||||
// if (entitlements.length > 0) {
|
||||
// }
|
||||
};
|
||||
|
||||
export const handleUpdateFeature = async (
|
||||
req: any,
|
||||
res: any,
|
||||
fromApi: boolean = false,
|
||||
_fromApi: boolean = false,
|
||||
) =>
|
||||
routeHandler({
|
||||
req,
|
||||
@@ -260,143 +14,13 @@ export const handleUpdateFeature = async (
|
||||
handler: async (req: any, res: any) => {
|
||||
const featureId = req.params.feature_id;
|
||||
const data = req.body;
|
||||
const { db, orgId, env, logger } = req;
|
||||
|
||||
// 1. Get feature by ID
|
||||
const features = await FeatureService.getFromReq(req);
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
throw new RecaseError({
|
||||
message: `Feature ${featureId} not found`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// If only archiving, skip other checks and just update
|
||||
if (data.archived !== undefined && Object.keys(data).length === 1) {
|
||||
console.log("Updating feature archived to: ", data.archived);
|
||||
const updatedFeature = await FeatureService.update({
|
||||
db: req.db,
|
||||
id: featureId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
updates: {
|
||||
archived: data.archived,
|
||||
},
|
||||
});
|
||||
|
||||
if (res) {
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
updatedFeature
|
||||
? toApiFeature({ feature: updatedFeature })
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Check if changing type...
|
||||
const isChangingType =
|
||||
notNullish(data.type) && feature.type !== data.type;
|
||||
|
||||
const isChangingId = notNullish(data.id) && feature.id !== data.id;
|
||||
|
||||
const isChangingUsageType =
|
||||
feature.type !== FeatureType.Boolean &&
|
||||
data.type !== FeatureType.Boolean &&
|
||||
feature.config?.usage_type !== data.config?.usage_type;
|
||||
|
||||
const isChangingName = feature.name !== data.name;
|
||||
|
||||
if (isChangingType || isChangingId || isChangingUsageType) {
|
||||
const objectsUsingFeature = await getObjectsUsingFeature({
|
||||
db,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
allFeatures: features,
|
||||
feature,
|
||||
});
|
||||
|
||||
// 1. Can't change type if any objects are linked to it
|
||||
if (isChangingType) {
|
||||
await handleFeatureTypeChanged({
|
||||
// Use the abstracted updateFeature function
|
||||
const updatedFeature = await updateFeature({
|
||||
ctx: req,
|
||||
objectsUsingFeature,
|
||||
feature,
|
||||
newType: data.type,
|
||||
featureId,
|
||||
updates: data,
|
||||
});
|
||||
}
|
||||
|
||||
const { linkedEntitlements, entitlements, prices, creditSystems } =
|
||||
objectsUsingFeature;
|
||||
|
||||
if (isChangingId) {
|
||||
await handleFeatureIdChanged({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newId: data.id,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
if (isChangingUsageType && data.config?.usage_type) {
|
||||
await handleFeatureUsageTypeChanged({
|
||||
db,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newUsageType: data.config.usage_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newConfig =
|
||||
data.config !== undefined
|
||||
? feature.type === FeatureType.CreditSystem
|
||||
? validateCreditSystem(data.config)
|
||||
: feature.type === FeatureType.Metered
|
||||
? validateMeteredConfig(data.config)
|
||||
: data.config
|
||||
: feature.config;
|
||||
|
||||
const updatedFeature = await FeatureService.update({
|
||||
db: req.db,
|
||||
id: featureId,
|
||||
orgId: req.orgId,
|
||||
env: req.env,
|
||||
updates: {
|
||||
id: data.id !== undefined ? data.id : feature.id,
|
||||
name: data.name !== undefined ? data.name : feature.name,
|
||||
type: data.type !== undefined ? data.type : feature.type,
|
||||
archived:
|
||||
data.archived !== undefined ? data.archived : feature.archived,
|
||||
|
||||
event_names: data.event_names,
|
||||
config: newConfig,
|
||||
},
|
||||
});
|
||||
|
||||
if (isChangingName) {
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
payload: {
|
||||
feature: updatedFeature,
|
||||
org: req.org,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res
|
||||
.status(200)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
type Entitlement,
|
||||
ErrCode,
|
||||
type Feature,
|
||||
type Price,
|
||||
RecaseError,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
import { FeatureService } from "../../FeatureService.js";
|
||||
|
||||
export const handleFeatureIdChanged = async ({
|
||||
ctx,
|
||||
feature,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
newId,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
feature: Feature;
|
||||
linkedEntitlements: Entitlement[];
|
||||
entitlements: Entitlement[];
|
||||
prices: Price[];
|
||||
creditSystems: Feature[];
|
||||
newId: string;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
|
||||
// 1. Check if any customer entitlement linked to this feature
|
||||
const cusEnts = await CusEntService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id,
|
||||
});
|
||||
|
||||
if (cusEnts.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot change id of feature ${feature.id} because a customer is using it`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Update all linked objects
|
||||
const batchUpdate = [];
|
||||
|
||||
for (const entitlement of linkedEntitlements) {
|
||||
batchUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id!,
|
||||
updates: {
|
||||
entity_feature_id: newId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchUpdate);
|
||||
|
||||
// 3. Update all linked prices
|
||||
const priceUpdate = [];
|
||||
for (const price of prices) {
|
||||
priceUpdate.push(
|
||||
PriceService.update({
|
||||
db,
|
||||
id: price.id!,
|
||||
update: {
|
||||
config: {
|
||||
...price.config,
|
||||
feature_id: newId,
|
||||
} as UsagePriceConfig,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(priceUpdate);
|
||||
|
||||
// 4. Update all linked credit systems
|
||||
const creditSystemUpdate = [];
|
||||
for (const creditSystem of creditSystems) {
|
||||
const newSchema = structuredClone(creditSystem.config.schema);
|
||||
for (let i = 0; i < newSchema.length; i++) {
|
||||
if (newSchema[i].metered_feature_id === feature.id) {
|
||||
newSchema[i].metered_feature_id = newId;
|
||||
}
|
||||
}
|
||||
creditSystemUpdate.push(
|
||||
FeatureService.update({
|
||||
db,
|
||||
id: creditSystem.id!,
|
||||
orgId: org.id,
|
||||
env,
|
||||
updates: {
|
||||
config: {
|
||||
...creditSystem.config,
|
||||
schema: newSchema,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(creditSystemUpdate);
|
||||
|
||||
// 5. Update all linked entitlements
|
||||
const entitlementUpdate = [];
|
||||
|
||||
for (const entitlement of entitlements) {
|
||||
entitlementUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id!,
|
||||
updates: {
|
||||
feature_id: newId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(entitlementUpdate);
|
||||
};
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { db } from "@/db/initDrizzle.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import type { ObjectsUsingFeature } from "./getObjectsUsingFeature.js";
|
||||
|
||||
export const handleFeatureTypeChanged = async ({
|
||||
@@ -16,7 +16,7 @@ export const handleFeatureTypeChanged = async ({
|
||||
feature,
|
||||
newType,
|
||||
}: {
|
||||
ctx: ExtendedRequest;
|
||||
ctx: AutumnContext;
|
||||
objectsUsingFeature: ObjectsUsingFeature;
|
||||
feature: Feature;
|
||||
newType: FeatureType;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
EntInterval,
|
||||
type EntitlementWithFeature,
|
||||
ErrCode,
|
||||
type Feature,
|
||||
FeatureUsageType,
|
||||
keyToTitle,
|
||||
type Price,
|
||||
RecaseError,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||
import { PriceService } from "@/internal/products/prices/PriceService.js";
|
||||
|
||||
export const handleFeatureUsageTypeChanged = async ({
|
||||
db,
|
||||
feature,
|
||||
newUsageType,
|
||||
linkedEntitlements,
|
||||
entitlements,
|
||||
prices,
|
||||
creditSystems,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
feature: Feature;
|
||||
newUsageType: FeatureUsageType;
|
||||
linkedEntitlements: EntitlementWithFeature[];
|
||||
entitlements: EntitlementWithFeature[];
|
||||
prices: Price[];
|
||||
creditSystems: Feature[];
|
||||
}) => {
|
||||
const usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
|
||||
if (creditSystems.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedEntitlements.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is used as an entity by ${linkedEntitlements[0].feature.name}`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Get cus product using feature...
|
||||
const cusEnts = await CusEntService.getByFeature({
|
||||
db,
|
||||
internalFeatureId: feature.internal_id,
|
||||
});
|
||||
|
||||
if (cusEnts && cusEnts.length > 0) {
|
||||
throw new RecaseError({
|
||||
message: `Cannot set to ${usageTypeTitle} because it is / was used by customers`,
|
||||
code: ErrCode.InvalidFeature,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (entitlements.length > 0) {
|
||||
console.log(
|
||||
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
|
||||
);
|
||||
if (newUsageType === FeatureUsageType.Continuous) {
|
||||
const batchEntUpdate = [];
|
||||
for (const entitlement of entitlements) {
|
||||
batchEntUpdate.push(
|
||||
EntitlementService.update({
|
||||
db,
|
||||
id: entitlement.id,
|
||||
updates: {
|
||||
interval: EntInterval.Lifetime,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchEntUpdate);
|
||||
console.log(`Updated ${entitlements.length} entitlements`);
|
||||
}
|
||||
}
|
||||
|
||||
if (prices.length > 0) {
|
||||
const batchPriceUpdate = [];
|
||||
for (const price of prices) {
|
||||
const priceConfig = price.config as UsagePriceConfig;
|
||||
|
||||
batchPriceUpdate.push(
|
||||
PriceService.update({
|
||||
db,
|
||||
id: price.id,
|
||||
update: {
|
||||
config: {
|
||||
...priceConfig,
|
||||
should_prorate: newUsageType === FeatureUsageType.Continuous,
|
||||
stripe_price_id: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(batchPriceUpdate);
|
||||
console.log(`Updated ${prices.length} prices`);
|
||||
}
|
||||
};
|
||||
@@ -5,17 +5,18 @@ import { OrgService } from "../OrgService.js";
|
||||
export const handleUpdateOrg = createRoute({
|
||||
body: z.object({
|
||||
onboarded: z.boolean().optional(),
|
||||
deployed: z.boolean().optional(),
|
||||
}),
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { db, org } = ctx;
|
||||
|
||||
const { onboarded } = c.req.valid("json");
|
||||
const { onboarded, deployed } = c.req.valid("json");
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId: org.id,
|
||||
updates: { onboarded },
|
||||
updates: { onboarded, deployed },
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { AppEnv, ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { z } from "zod/v4";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
|
||||
|
||||
export const handleGetOAuthUrl = createRoute({
|
||||
query: z.object({
|
||||
redirect_url: z.string().optional(),
|
||||
}),
|
||||
handler: async (c) => {
|
||||
const { redirect_url } = c.req.query();
|
||||
const ctx = c.get("ctx");
|
||||
const { org, env } = ctx;
|
||||
|
||||
@@ -23,7 +28,7 @@ export const handleGetOAuthUrl = createRoute({
|
||||
// Generate OAuth state and store in Redis
|
||||
const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173";
|
||||
|
||||
const redirectUri = `${frontendUrl}/dev?tab=stripe`;
|
||||
const redirectUri = redirect_url || `${frontendUrl}/dev?tab=stripe`;
|
||||
|
||||
const stateKey = await generateOAuthState({
|
||||
organizationSlug: org.slug,
|
||||
|
||||
@@ -58,7 +58,9 @@ export const handleOAuthCallback = async (c: Context<HonoEnv>) => {
|
||||
if (isPlatformFlow) {
|
||||
redirectUrl = new URL(redirect_uri);
|
||||
} else {
|
||||
redirectUrl = new URL(
|
||||
redirectUrl = redirect_uri
|
||||
? new URL(redirect_uri)
|
||||
: new URL(
|
||||
`${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export const createOrgResponse = ({
|
||||
test_pkey: org.test_pkey,
|
||||
live_pkey: org.live_pkey,
|
||||
onboarded: org.onboarded ?? true,
|
||||
deployed: org.deployed ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { handleCopyFeatures } from "./handleCopyEnvironment/handleCopyFeatures.js";
|
||||
import { handleCopyProducts } from "./handleCopyEnvironment/handleCopyProducts.js";
|
||||
|
||||
/**
|
||||
* POST /copy_to_production
|
||||
* Copies all products and features from sandbox to production
|
||||
*/
|
||||
export const handleCopyEnvironment = createRoute({
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { db, org, logger } = ctx;
|
||||
|
||||
// Always copy from sandbox to live
|
||||
const fromEnv = AppEnv.Sandbox;
|
||||
const toEnv = AppEnv.Live;
|
||||
|
||||
// 1. Get all sandbox products and features
|
||||
const [sandboxFeatures, liveFeatures] = await Promise.all([
|
||||
FeatureService.list({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env: fromEnv,
|
||||
}),
|
||||
|
||||
FeatureService.list({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env: toEnv,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 2. Copy features first
|
||||
await handleCopyFeatures({
|
||||
ctx,
|
||||
sandboxFeatures,
|
||||
liveFeatures,
|
||||
});
|
||||
|
||||
await handleCopyProducts({
|
||||
ctx,
|
||||
fromEnv,
|
||||
toEnv,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
message: "Products copied to production",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AppEnv, type Feature, FeatureType } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { createFeature } from "@/internal/features/featureActions/createFeature.js";
|
||||
import { updateFeature } from "@/internal/features/featureActions/updateFeature.js";
|
||||
|
||||
export const handleCopyFeatures = async ({
|
||||
ctx,
|
||||
sandboxFeatures,
|
||||
liveFeatures,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
sandboxFeatures: Feature[];
|
||||
liveFeatures: Feature[];
|
||||
}) => {
|
||||
const newContext = {
|
||||
...ctx,
|
||||
features: liveFeatures,
|
||||
env: AppEnv.Live,
|
||||
};
|
||||
|
||||
// Separate features by type: Boolean/Metered must be created before CreditSystem
|
||||
// since credit systems can reference metered features in their credit_schema
|
||||
const booleanAndMeteredFeatures = sandboxFeatures.filter(
|
||||
(f) => f.type === FeatureType.Boolean || f.type === FeatureType.Metered,
|
||||
);
|
||||
const creditSystemFeatures = sandboxFeatures.filter(
|
||||
(f) => f.type === FeatureType.CreditSystem,
|
||||
);
|
||||
|
||||
// First, process boolean and metered features
|
||||
const firstBatchPromises = [];
|
||||
for (const sandboxFeature of booleanAndMeteredFeatures) {
|
||||
const liveFeature = liveFeatures.find((f) => f.id === sandboxFeature.id);
|
||||
|
||||
if (liveFeature) {
|
||||
firstBatchPromises.push(
|
||||
updateFeature({
|
||||
ctx: newContext,
|
||||
featureId: sandboxFeature.id,
|
||||
updates: sandboxFeature,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
firstBatchPromises.push(
|
||||
createFeature({
|
||||
ctx: newContext,
|
||||
data: sandboxFeature,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(firstBatchPromises);
|
||||
|
||||
// Then, process credit system features
|
||||
const secondBatchPromises = [];
|
||||
for (const sandboxFeature of creditSystemFeatures) {
|
||||
const liveFeature = liveFeatures.find((f) => f.id === sandboxFeature.id);
|
||||
|
||||
if (liveFeature) {
|
||||
secondBatchPromises.push(
|
||||
updateFeature({
|
||||
ctx: newContext,
|
||||
featureId: sandboxFeature.id,
|
||||
updates: sandboxFeature,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
secondBatchPromises.push(
|
||||
createFeature({
|
||||
ctx: newContext,
|
||||
data: sandboxFeature,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(secondBatchPromises);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
type CreateProductV2Params,
|
||||
mapToProductV2,
|
||||
type ProductV2,
|
||||
type UpdateProductV2Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { ProductService } from "../../ProductService.js";
|
||||
import { createProduct } from "../productActions/createProduct.js";
|
||||
import { updateProduct } from "../productActions/updateProduct.js";
|
||||
|
||||
const conformProductToSchema = (
|
||||
product: ProductV2,
|
||||
): UpdateProductV2Params & Omit<CreateProductV2Params, "version"> => {
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
is_add_on: product.is_add_on,
|
||||
is_default: product.is_default,
|
||||
group: product.group ?? undefined,
|
||||
archived: product.archived ?? undefined,
|
||||
items: product.items,
|
||||
free_trial: product.free_trial
|
||||
? {
|
||||
length: product.free_trial.length,
|
||||
unique_fingerprint: product.free_trial.unique_fingerprint,
|
||||
duration: product.free_trial.duration,
|
||||
card_required: product.free_trial.card_required,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleCopyProducts = async ({
|
||||
ctx,
|
||||
fromEnv,
|
||||
toEnv,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
fromEnv: AppEnv;
|
||||
toEnv: AppEnv;
|
||||
}) => {
|
||||
const { db, org } = ctx;
|
||||
|
||||
const [sandboxFeatures, liveFeatures, sandboxProducts, liveProducts] =
|
||||
await Promise.all([
|
||||
FeatureService.list({ db, orgId: org.id, env: fromEnv }),
|
||||
FeatureService.list({ db, orgId: org.id, env: toEnv }),
|
||||
ProductService.listFull({ db, orgId: org.id, env: fromEnv }),
|
||||
ProductService.listFull({ db, orgId: org.id, env: toEnv }),
|
||||
]);
|
||||
|
||||
const liveProductsV2 = liveProducts.map((p) =>
|
||||
mapToProductV2({ product: p, features: liveFeatures }),
|
||||
);
|
||||
|
||||
const sandboxProductsV2 = sandboxProducts.map((p) => {
|
||||
const productV2 = mapToProductV2({
|
||||
product: p,
|
||||
features: sandboxFeatures,
|
||||
});
|
||||
productV2.items = productV2.items.map((i) => {
|
||||
const {
|
||||
price_id: _price_id,
|
||||
entitlement_id: _ent_id,
|
||||
price_config: _price_config,
|
||||
...rest
|
||||
} = i;
|
||||
return rest;
|
||||
});
|
||||
|
||||
return productV2;
|
||||
});
|
||||
|
||||
const newContext = {
|
||||
...ctx,
|
||||
features: liveFeatures,
|
||||
env: toEnv,
|
||||
};
|
||||
|
||||
const operations = sandboxProductsV2.map((sandboxProductV2) => {
|
||||
const liveProductV2 = liveProductsV2.find(
|
||||
(p) => p.id === sandboxProductV2.id,
|
||||
);
|
||||
|
||||
const conformedProduct = conformProductToSchema(sandboxProductV2);
|
||||
|
||||
if (liveProductV2) {
|
||||
return updateProduct({
|
||||
ctx: newContext,
|
||||
productId: sandboxProductV2.id,
|
||||
query: { disable_version: true },
|
||||
updates: conformedProduct,
|
||||
});
|
||||
} else {
|
||||
return createProduct({
|
||||
ctx: newContext,
|
||||
data: conformedProduct,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(operations);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
CreateProductV2Params,
|
||||
Entitlement,
|
||||
FreeTrial,
|
||||
FullProduct,
|
||||
Price,
|
||||
} from "@autumn/shared";
|
||||
import { ProductAlreadyExistsError } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js";
|
||||
import {
|
||||
handleNewFreeTrial,
|
||||
validateOneOffTrial,
|
||||
} from "../../free-trials/freeTrialUtils.js";
|
||||
import { ProductService } from "../../ProductService.js";
|
||||
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
|
||||
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
|
||||
import { constructProduct, initProductInStripe } from "../../productUtils.js";
|
||||
import { disableCurrentDefault } from "../handleCreateProduct.js";
|
||||
|
||||
export const createProduct = async ({
|
||||
ctx,
|
||||
data,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
data: CreateProductV2Params;
|
||||
}) => {
|
||||
const { logger, org, features, env, db } = ctx;
|
||||
|
||||
const existing = await ProductService.get({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
id: data.id,
|
||||
});
|
||||
|
||||
// 1. If existing product, throw error
|
||||
if (existing) throw new ProductAlreadyExistsError({ productId: data.id });
|
||||
|
||||
await disableCurrentDefault({
|
||||
req: ctx,
|
||||
newProduct: data,
|
||||
});
|
||||
|
||||
const product = await ProductService.insert({
|
||||
db,
|
||||
product: constructProduct({
|
||||
productData: data,
|
||||
orgId: org.id,
|
||||
env,
|
||||
}),
|
||||
});
|
||||
|
||||
const { items, free_trial } = data;
|
||||
|
||||
let prices: Price[] = [];
|
||||
let entitlements: Entitlement[] = [];
|
||||
if (items) {
|
||||
const res = await handleNewProductItems({
|
||||
db,
|
||||
product,
|
||||
features,
|
||||
curPrices: [],
|
||||
curEnts: [],
|
||||
newItems: items,
|
||||
logger,
|
||||
isCustom: false,
|
||||
newVersion: false,
|
||||
});
|
||||
prices = res.prices;
|
||||
entitlements = res.entitlements;
|
||||
}
|
||||
|
||||
await validateOneOffTrial({
|
||||
prices,
|
||||
freeTrial: free_trial || null,
|
||||
});
|
||||
|
||||
let newFreeTrial: FreeTrial | null = null;
|
||||
if (free_trial) {
|
||||
newFreeTrial =
|
||||
(await handleNewFreeTrial({
|
||||
db,
|
||||
newFreeTrial: free_trial,
|
||||
curFreeTrial: null,
|
||||
internalProductId: product.internal_id,
|
||||
isCustom: false,
|
||||
})) || null;
|
||||
}
|
||||
|
||||
const newFullProduct: FullProduct = {
|
||||
...product,
|
||||
prices,
|
||||
entitlements: getEntsWithFeature({ ents: entitlements, features }),
|
||||
free_trial: newFreeTrial,
|
||||
};
|
||||
|
||||
await initProductInStripe({
|
||||
db,
|
||||
product: newFullProduct,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.DetectBaseVariant,
|
||||
payload: {
|
||||
curProduct: newFullProduct,
|
||||
},
|
||||
});
|
||||
|
||||
const productResponse = await getProductResponse({
|
||||
product: newFullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
return productResponse;
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
type FreeTrial,
|
||||
mapToProductV2,
|
||||
notNullish,
|
||||
ProductNotFoundError,
|
||||
type ProductV2,
|
||||
productsAreSame,
|
||||
RecaseError,
|
||||
UpdateProductSchema,
|
||||
type UpdateProductV2Params,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
|
||||
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import {
|
||||
handleNewFreeTrial,
|
||||
validateOneOffTrial,
|
||||
} from "../../free-trials/freeTrialUtils.js";
|
||||
import { ProductService } from "../../ProductService.js";
|
||||
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
|
||||
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
|
||||
import { initProductInStripe } from "../../productUtils.js";
|
||||
import { disableCurrentDefault } from "../handleCreateProduct.js";
|
||||
import { handleUpdateProductDetails } from "../handleUpdateProduct/updateProductDetails.js";
|
||||
import { handleVersionProductV2 } from "../handleVersionProduct.js";
|
||||
|
||||
export interface UpdateProductParams {
|
||||
ctx: AutumnContext;
|
||||
productId: string;
|
||||
query: {
|
||||
upsert?: boolean;
|
||||
version?: number;
|
||||
disable_version?: boolean;
|
||||
};
|
||||
updates: UpdateProductV2Params;
|
||||
}
|
||||
export const updateProduct = async ({
|
||||
ctx,
|
||||
query,
|
||||
productId,
|
||||
updates,
|
||||
}: UpdateProductParams) => {
|
||||
const { db, org, env, features, logger } = ctx;
|
||||
const { version, upsert, disable_version } = query;
|
||||
|
||||
const [fullProduct, rewardPrograms, _defaultProds] = await Promise.all([
|
||||
ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: productId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
version: version,
|
||||
allowNotFound: upsert === true,
|
||||
}),
|
||||
RewardProgramService.getByProductId({
|
||||
db,
|
||||
productIds: [productId],
|
||||
orgId: org.id,
|
||||
env,
|
||||
}),
|
||||
ProductService.listDefault({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fullProduct) throw new ProductNotFoundError({ productId: productId });
|
||||
|
||||
const cusProductsCurVersion = await CusProductService.getByInternalProductId({
|
||||
db,
|
||||
internalProductId: fullProduct.internal_id,
|
||||
});
|
||||
|
||||
const curProductV2 = mapToProductV2({
|
||||
product: fullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
const newFreeTrial = updates.free_trial as FreeTrial | undefined;
|
||||
const newProductV2: ProductV2 = {
|
||||
...curProductV2,
|
||||
...updates,
|
||||
group: updates.group || curProductV2.group || "",
|
||||
items: updates.items || [],
|
||||
free_trial: newFreeTrial || curProductV2.free_trial || undefined,
|
||||
};
|
||||
|
||||
await disableCurrentDefault({
|
||||
req: ctx,
|
||||
newProduct: newProductV2,
|
||||
});
|
||||
|
||||
await handleUpdateProductDetails({
|
||||
db,
|
||||
curProduct: fullProduct,
|
||||
newProduct: UpdateProductSchema.parse(updates),
|
||||
newFreeTrial: updates.free_trial || curProductV2.free_trial || undefined,
|
||||
items: updates.items || curProductV2.items,
|
||||
org,
|
||||
rewardPrograms,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
const itemsExist = notNullish(updates.items);
|
||||
|
||||
const cusProductExists = cusProductsCurVersion.length > 0;
|
||||
|
||||
if (cusProductExists && itemsExist) {
|
||||
if (disable_version) {
|
||||
throw new RecaseError({
|
||||
message: "Cannot auto save product as there are existing customers",
|
||||
});
|
||||
}
|
||||
|
||||
const { itemsSame, freeTrialsSame } = productsAreSame({
|
||||
newProductV2: newProductV2,
|
||||
curProductV1: fullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
const productSame = itemsSame && freeTrialsSame;
|
||||
|
||||
if (!productSame) {
|
||||
const newProduct = await handleVersionProductV2({
|
||||
ctx,
|
||||
newProductV2: newProductV2,
|
||||
latestProduct: fullProduct,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
return newProduct;
|
||||
}
|
||||
|
||||
return fullProduct;
|
||||
}
|
||||
|
||||
const { free_trial } = updates;
|
||||
|
||||
if (updates.items) {
|
||||
await handleNewProductItems({
|
||||
db,
|
||||
curPrices: fullProduct.prices,
|
||||
curEnts: fullProduct.entitlements,
|
||||
newItems: updates.items,
|
||||
features,
|
||||
product: fullProduct,
|
||||
logger: ctx.logger,
|
||||
isCustom: false,
|
||||
});
|
||||
}
|
||||
|
||||
// New full product
|
||||
const newFullProduct = await ProductService.getFull({
|
||||
db,
|
||||
idOrInternalId: fullProduct.id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
if (free_trial !== undefined) {
|
||||
await validateOneOffTrial({
|
||||
prices: newFullProduct.prices,
|
||||
freeTrial: free_trial,
|
||||
});
|
||||
|
||||
await handleNewFreeTrial({
|
||||
db,
|
||||
curFreeTrial: fullProduct.free_trial,
|
||||
newFreeTrial: free_trial,
|
||||
internalProductId: fullProduct.internal_id,
|
||||
isCustom: false,
|
||||
product: fullProduct,
|
||||
});
|
||||
}
|
||||
|
||||
// New full product
|
||||
|
||||
await initProductInStripe({
|
||||
db,
|
||||
product: newFullProduct,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info("Adding task to queue to detect base variant");
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.DetectBaseVariant,
|
||||
payload: {
|
||||
curProduct: newFullProduct,
|
||||
},
|
||||
});
|
||||
|
||||
await addTaskToQueue({
|
||||
jobName: JobName.RewardMigration,
|
||||
payload: {
|
||||
oldPrices: fullProduct.prices,
|
||||
productId: fullProduct.id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
},
|
||||
});
|
||||
|
||||
const productResponse = await getProductResponse({
|
||||
product: newFullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
return productResponse;
|
||||
};
|
||||
@@ -518,6 +518,7 @@ expressProductRouter.get(
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment.js";
|
||||
import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js";
|
||||
import { handleGetProductInternal } from "./internalHandlers/handleGetProductInternal.js";
|
||||
|
||||
@@ -526,3 +527,4 @@ export const internalProductRouter = new Hono<HonoEnv>();
|
||||
|
||||
internalProductRouter.get("/:productId/count", ...handleGetProductCount);
|
||||
internalProductRouter.get("/:productId/data", ...handleGetProductInternal);
|
||||
internalProductRouter.post("/copy_to_production", ...handleCopyEnvironment);
|
||||
|
||||
@@ -218,8 +218,8 @@ export const handleNewProductItems = async ({
|
||||
const { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } =
|
||||
itemToPriceAndEnt({
|
||||
item,
|
||||
orgId: product.org_id!,
|
||||
internalProductId: product.internal_id!,
|
||||
orgId: product.org_id,
|
||||
internalProductId: product.internal_id,
|
||||
feature: feature,
|
||||
curPrice,
|
||||
curEnt,
|
||||
|
||||
@@ -118,7 +118,7 @@ export const toFeature = ({
|
||||
newVersion?: boolean;
|
||||
feature?: Feature;
|
||||
}) => {
|
||||
const isBoolean = feature?.type == FeatureType.Boolean;
|
||||
const isBoolean = feature?.type === FeatureType.Boolean;
|
||||
|
||||
const resetUsage = getResetUsage({ item, feature });
|
||||
|
||||
@@ -132,10 +132,10 @@ export const toFeature = ({
|
||||
internal_feature_id: internalFeatureId,
|
||||
feature_id: item.feature_id!,
|
||||
|
||||
allowance: item.included_usage == Infinite ? null : item.included_usage!,
|
||||
allowance: item.included_usage === Infinite ? null : item.included_usage!,
|
||||
allowance_type: isBoolean
|
||||
? null
|
||||
: item.included_usage == Infinite
|
||||
: item.included_usage === Infinite
|
||||
? AllowanceType.Unlimited
|
||||
: AllowanceType.Fixed,
|
||||
|
||||
|
||||
@@ -74,9 +74,6 @@ export const CustomerDataSchema = z.object({
|
||||
});
|
||||
|
||||
export const CustomerResponseSchema = CustomerSchema.omit({
|
||||
// created_at: true,
|
||||
// env: true,
|
||||
// processor: true,
|
||||
org_id: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { FullCusProduct } from "../cusProductModels/cusProductModels.js";
|
||||
import { Event } from "../eventModels/eventTable.js";
|
||||
import { Subscription } from "../subModels/subModels.js";
|
||||
import { Customer } from "./cusModels.js";
|
||||
import { Entity } from "./entityModels/entityModels.js";
|
||||
import { Invoice } from "./invoiceModels/invoiceModels.js";
|
||||
import { ProductSchema } from "@models/productModels/productModels.js";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
CusProductSchema,
|
||||
type FullCusProduct,
|
||||
} from "../cusProductModels/cusProductModels.js";
|
||||
import type { Event } from "../eventModels/eventTable.js";
|
||||
import type { Subscription } from "../subModels/subModels.js";
|
||||
import { type Customer, CustomerSchema } from "./cusModels.js";
|
||||
import type { Entity } from "./entityModels/entityModels.js";
|
||||
import type { Invoice } from "./invoiceModels/invoiceModels.js";
|
||||
|
||||
export type FullCustomer = Customer & {
|
||||
customer_products: FullCusProduct[];
|
||||
@@ -18,3 +23,11 @@ export type FullCustomer = Customer & {
|
||||
subscriptions?: Subscription[];
|
||||
events?: Event[];
|
||||
};
|
||||
|
||||
export const CustomerWithProductsSchema = CustomerSchema.extend({
|
||||
customer_products: z.array(
|
||||
CusProductSchema.extend({ product: ProductSchema }),
|
||||
),
|
||||
});
|
||||
|
||||
export type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
|
||||
|
||||
@@ -22,6 +22,7 @@ export const FrontendOrgSchema = z.object({
|
||||
.nullable(),
|
||||
through_master: z.boolean(),
|
||||
onboarded: z.boolean(),
|
||||
deployed: z.boolean(),
|
||||
});
|
||||
|
||||
export type FrontendOrg = z.infer<typeof FrontendOrgSchema>;
|
||||
|
||||
@@ -86,6 +86,7 @@ export const organizations = pgTable(
|
||||
config: jsonb().default({}).notNull().$type<OrgConfig>(),
|
||||
created_by: text("created_by"),
|
||||
onboarded: boolean("onboarded").default(false),
|
||||
deployed: boolean("deployed").default(false),
|
||||
},
|
||||
(table) => [
|
||||
unique("organizations_test_pkey_key").on(table.test_pkey),
|
||||
|
||||
@@ -18,6 +18,7 @@ import CustomerView from "./views/customers/customer/CustomerView";
|
||||
import CustomerProductView from "./views/customers/customer/product/CustomerProductView";
|
||||
import { DefaultView } from "./views/DefaultView";
|
||||
import DevScreen from "./views/developer/DevView";
|
||||
import { CloseScreen } from "./views/general/CloseScreen";
|
||||
import OnboardingView3 from "./views/onboarding3/OnboardingView3";
|
||||
import ProductsView from "./views/products/ProductsView";
|
||||
import PlanEditorView from "./views/products/plan/PlanEditorView";
|
||||
@@ -45,6 +46,7 @@ export default function App() {
|
||||
<Route path="/sign-in" element={<SignIn />} />
|
||||
<Route path="/pw-sign-in" element={<PasswordSignIn />} />
|
||||
<Route path="/accept" element={<AcceptInvitation />} />
|
||||
<Route path="/close" element={<CloseScreen />} />
|
||||
|
||||
{/* Onboarding routes without sidebar */}
|
||||
<Route element={<OnboardingLayout />}>
|
||||
|
||||
@@ -46,8 +46,16 @@ export function MainLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if org is loaded and user is not onboarded
|
||||
if (!orgLoading && org && !org.onboarded) {
|
||||
if (!orgLoading && org) {
|
||||
if (!org.onboarded) {
|
||||
navigate("/sandbox/onboarding");
|
||||
} else if (!org.deployed) {
|
||||
const pathname = window.location.pathname;
|
||||
if (!pathname.startsWith("/sandbox")) {
|
||||
const search = window.location.search;
|
||||
navigate(`/sandbox${pathname}${search}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [org, orgLoading, navigate]);
|
||||
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
import type { FrontendOrg } from "@autumn/shared";
|
||||
import type { AppEnv, FrontendOrg } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { authClient, useListOrganizations } from "@/lib/auth-client";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
export const useOrg = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const ORG_STORAGE_KEY = "autumn_org";
|
||||
|
||||
export const useOrg = (params?: { env?: AppEnv }) => {
|
||||
const axiosInstance = useAxiosInstance({ env: params?.env });
|
||||
const { data: orgList } = useListOrganizations();
|
||||
|
||||
const fetcher = async () => {
|
||||
try {
|
||||
const { data } = await axiosInstance.get("/organization");
|
||||
// Store in local storage
|
||||
if (data) {
|
||||
const storageKey = params?.env
|
||||
? `${ORG_STORAGE_KEY}_${params.env}`
|
||||
: ORG_STORAGE_KEY;
|
||||
localStorage.setItem(storageKey, JSON.stringify(data));
|
||||
}
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialData = () => {
|
||||
try {
|
||||
const storageKey = params?.env
|
||||
? `${ORG_STORAGE_KEY}_${params.env}`
|
||||
: ORG_STORAGE_KEY;
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return stored ? JSON.parse(stored) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
data: org,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["org"],
|
||||
queryKey: params?.env ? ["org", params.env] : ["org"],
|
||||
queryFn: fetcher,
|
||||
initialData: getInitialData(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
88
vite/src/hooks/common/useShowDeployButton.tsx
Normal file
88
vite/src/hooks/common/useShowDeployButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { CustomerWithProducts } from "@autumn/shared";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useGeneralQuery } from "../queries/useGeneralQuery";
|
||||
|
||||
const DEPLOY_BUTTON_STORAGE_KEY = "autumn_show_deploy_button";
|
||||
|
||||
export const useShowDeployButton = () => {
|
||||
const { products } = useProductsQuery();
|
||||
const { data: customersData, isLoading: isLoadingCustomers } =
|
||||
useGeneralQuery({
|
||||
url: "/customers/all/search",
|
||||
method: "POST",
|
||||
queryKey: ["customers", "all", "search"],
|
||||
});
|
||||
|
||||
const getStoredValue = useCallback((): boolean | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(DEPLOY_BUTTON_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [showDeployButton, setShowDeployButton] = useState<boolean>(() => {
|
||||
const stored = getStoredValue();
|
||||
return stored !== null ? stored : false;
|
||||
});
|
||||
|
||||
// Only show checking state if we don't have a stored value AND data is still loading
|
||||
const isChecking = getStoredValue() === null && isLoadingCustomers;
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for data to be available before checking
|
||||
if (!customersData) return;
|
||||
|
||||
const checkConditions = () => {
|
||||
try {
|
||||
// Check if at least 1 product exists
|
||||
const hasProduct = products.length > 0;
|
||||
|
||||
if (!hasProduct) {
|
||||
const newValue = false;
|
||||
// Only update if different
|
||||
setShowDeployButton((prev) => {
|
||||
if (prev !== newValue) {
|
||||
localStorage.setItem(
|
||||
DEPLOY_BUTTON_STORAGE_KEY,
|
||||
JSON.stringify(newValue),
|
||||
);
|
||||
return newValue;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if at least 1 non-demo customer exists
|
||||
const hasNonDemoCustomer = customersData.customers.some(
|
||||
(customer: CustomerWithProducts) =>
|
||||
customer.id !== "onboarding_demo_user",
|
||||
);
|
||||
|
||||
const shouldShow = hasProduct && hasNonDemoCustomer;
|
||||
|
||||
// Only update if the value changed
|
||||
setShowDeployButton((prev) => {
|
||||
if (prev !== shouldShow) {
|
||||
localStorage.setItem(
|
||||
DEPLOY_BUTTON_STORAGE_KEY,
|
||||
JSON.stringify(shouldShow),
|
||||
);
|
||||
return shouldShow;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error checking deploy button conditions:", error);
|
||||
setShowDeployButton(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkConditions();
|
||||
}, [products.length, customersData]);
|
||||
|
||||
return { showDeployButton, isChecking };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
export const useDevQuery = () => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
export const useGeneralQuery = ({
|
||||
url,
|
||||
method,
|
||||
queryKey,
|
||||
enabled,
|
||||
}: {
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
queryKey?: string[];
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
|
||||
const fetcher = async () => {
|
||||
const { data } = await axiosInstance.get(url);
|
||||
const { data } = await axiosInstance.request({
|
||||
method,
|
||||
url,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,8 @@ const defaultParams = {
|
||||
};
|
||||
|
||||
export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
||||
const finalParams: any = {
|
||||
...defaultParams,
|
||||
...(params || {}),
|
||||
};
|
||||
|
||||
const trueEnv = useEnv();
|
||||
const currentEnv = useEnv();
|
||||
const envToUse = params?.env ?? currentEnv;
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_BACKEND_URL,
|
||||
@@ -23,7 +19,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
async (config: any) => {
|
||||
config.headers.app_env = trueEnv;
|
||||
config.headers.app_env = envToUse;
|
||||
config.headers["x-api-version"] = "1.2";
|
||||
config.headers["x-client-type"] = "dashboard";
|
||||
|
||||
@@ -56,7 +52,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
||||
organizationId: nextOrg.id,
|
||||
});
|
||||
// Redirect to products page of the new organization
|
||||
window.location.href = `/${trueEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`;
|
||||
window.location.href = `/${currentEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`;
|
||||
return Promise.reject(
|
||||
new Error("Redirecting to available organization"),
|
||||
);
|
||||
|
||||
@@ -18,18 +18,22 @@ export const SignIn = () => {
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [sendOtpLoading, setSendOtpLoading] = useState(false);
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
// const { data: session } = useSession();
|
||||
const { org, isLoading: orgLoading } = useOrg();
|
||||
|
||||
const { org } = useOrg();
|
||||
const navigate = useNavigate();
|
||||
// const [searchParams] = useSearchParams();
|
||||
// const token = searchParams.get("token");
|
||||
|
||||
const newPath = "/sandbox/onboarding";
|
||||
const callbackPath = "/customers";
|
||||
const callbackPath = "/sandbox/products?tab=products";
|
||||
|
||||
useEffect(() => {
|
||||
if (org?.onboarded) {
|
||||
navigate(callbackPath);
|
||||
if (org) {
|
||||
if (org.deployed) {
|
||||
navigate("/products?tab=products");
|
||||
} else if (org.onboarded) {
|
||||
navigate("/sandbox/products?tab=products");
|
||||
} else {
|
||||
navigate("/sandbox/onboarding");
|
||||
}
|
||||
}
|
||||
}, [org, navigate]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useListOrganizations } from "@/lib/auth-client";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
@@ -69,6 +70,7 @@ const CommandBar = () => {
|
||||
const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const { isAdmin } = useAdmin();
|
||||
const { org } = useOrg();
|
||||
|
||||
// Improved close dialog function with proper timing
|
||||
const closeDialog = useCallback(() => {
|
||||
@@ -320,6 +322,8 @@ const CommandBar = () => {
|
||||
closeDialog();
|
||||
},
|
||||
},
|
||||
...(org?.deployed
|
||||
? [
|
||||
{
|
||||
title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`,
|
||||
icon: <ArrowsClockwiseIcon />,
|
||||
@@ -332,6 +336,8 @@ const CommandBar = () => {
|
||||
closeDialog();
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isLoadingOrgs && orgs && orgs.length > 1
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useCustomersQueryStates } from "./useCustomersQueryStates";
|
||||
import {
|
||||
CusProductSchema,
|
||||
CustomerSchema,
|
||||
ProductSchema,
|
||||
} from "@autumn/shared";
|
||||
import { z } from "zod";
|
||||
import type { CustomerWithProducts } from "@autumn/shared";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
const CustomerWithProductsSchema = CustomerSchema.extend({
|
||||
customer_products: z.array(
|
||||
CusProductSchema.extend({ product: ProductSchema }),
|
||||
),
|
||||
});
|
||||
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useCustomersQueryStates } from "./useCustomersQueryStates";
|
||||
|
||||
export const useCusSearchQuery = () => {
|
||||
const { queryStates } = useCustomersQueryStates();
|
||||
@@ -72,22 +61,3 @@ export const useCusSearchQuery = () => {
|
||||
isFetchingUncached,
|
||||
};
|
||||
};
|
||||
|
||||
// const { data, isLoading, error, mutate } = useAxiosPostSWR({
|
||||
// url: `/v1/customers/all/search`,
|
||||
// env,
|
||||
// data: {
|
||||
// search: queryStates.q || "",
|
||||
// filters: {
|
||||
// status: queryStates.status,
|
||||
// product_id: queryStates.product_id,
|
||||
// version: queryStates.version,
|
||||
// none: queryStates.none,
|
||||
// },
|
||||
// page: queryStates.page,
|
||||
// page_size: pageSize,
|
||||
// last_item: queryStates.lastItemId
|
||||
// ? { internal_id: queryStates.lastItemId }
|
||||
// : null,
|
||||
// },
|
||||
// });
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
parseAsArrayOf,
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from "nuqs";
|
||||
|
||||
import { useQueryStates } from "nuqs";
|
||||
import { useLocation } from "react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
export const useCustomersQueryStates = () => {
|
||||
const [queryStates, setQueryStates] = useQueryStates(
|
||||
{
|
||||
@@ -25,6 +22,8 @@ export const useCustomersQueryStates = () => {
|
||||
},
|
||||
);
|
||||
|
||||
// return { queryStates, setQueryStates };
|
||||
|
||||
const [stableStates, setStableStates] = useState(queryStates);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
31
vite/src/views/general/CloseScreen.tsx
Normal file
31
vite/src/views/general/CloseScreen.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react";
|
||||
import LoadingScreen from "./LoadingScreen";
|
||||
|
||||
export const CloseScreen = () => {
|
||||
useEffect(() => {
|
||||
// Attempt to close immediately
|
||||
window.close();
|
||||
|
||||
// If still open after 1 second, it means close() was blocked
|
||||
const timeout = setTimeout(() => {
|
||||
// Show the fallback UI
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 16px;">
|
||||
<p style="font-size: 18px; color: #10b981;">✓ Connection successful!</p>
|
||||
<p style="color: #6b7280;">You can close this window now.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center">
|
||||
<LoadingScreen />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,8 +9,10 @@ import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
||||
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { DeployToProdButton } from "./components/deploy-button/DeployToProdButton";
|
||||
import { OrgDropdown } from "./components/OrgDropdown";
|
||||
import { EnvDropdown } from "./EnvDropdown";
|
||||
import { NavButton } from "./NavButton";
|
||||
@@ -20,6 +22,7 @@ import { SidebarGroup } from "./SidebarGroup";
|
||||
|
||||
export const MainSidebar = () => {
|
||||
const env = useEnv();
|
||||
const { org } = useOrg();
|
||||
|
||||
const { webhooks } = useAutumnFlags();
|
||||
|
||||
@@ -74,7 +77,11 @@ export const MainSidebar = () => {
|
||||
</Button>
|
||||
<OrgDropdown />
|
||||
|
||||
{org?.deployed ? (
|
||||
<EnvDropdown env={env} />
|
||||
) : (
|
||||
<DeployToProdButton expanded={expanded} />
|
||||
)}
|
||||
<div className="flex flex-col px-2 gap-1">
|
||||
<div>
|
||||
<NavButton
|
||||
@@ -160,43 +167,3 @@ export const MainSidebar = () => {
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
{
|
||||
/* <div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-150 ease-in-out",
|
||||
showProductTab ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
{expanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden flex flex-col my-0 gap-0.5 border-l border-zinc-300 ml-4 -translate-x-[1px] pl-0 transition-opacity duration-150",
|
||||
showProductTab ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
<NavButton
|
||||
value="products"
|
||||
subValue="products"
|
||||
title="Plans"
|
||||
env={env}
|
||||
isSubNav
|
||||
/>
|
||||
<NavButton
|
||||
value="products"
|
||||
subValue="features"
|
||||
title="Features"
|
||||
env={env}
|
||||
isSubNav
|
||||
/>
|
||||
<NavButton
|
||||
value="products"
|
||||
subValue="rewards"
|
||||
title="Rewards"
|
||||
env={env}
|
||||
isSubNav
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div> */
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const OrgDropdown = () => {
|
||||
const { org, isLoading, error } = useOrg();
|
||||
const { expanded, setExpanded } = useSidebarContext();
|
||||
|
||||
let { data: orgs, isPending } = useListOrganizations();
|
||||
let { data: orgs } = useListOrganizations();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
|
||||
// Exclude the active organization from the orgs list (this makes it easier for users to understand which org is active)
|
||||
@@ -50,12 +50,6 @@ export const OrgDropdown = () => {
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
// //remove the currect active org from the orgs data
|
||||
// const inactiveOrgs = useMemo(() => {
|
||||
// if (!orgs || !org) return [];
|
||||
// return orgs.filter((orgItem: any) => orgItem.id !== org.id);
|
||||
// }, [org, orgs]);
|
||||
|
||||
// To pre-fetch data
|
||||
useMemberships();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DeployToProdDialog } from "./DeployToProdDialog";
|
||||
|
||||
interface DeployToProdButtonProps {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export const DeployToProdButton = ({ expanded }: DeployToProdButtonProps) => {
|
||||
const [showDeployDialog, setShowDeployDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex text-t2 text-xs gap-1 pl-3")}>
|
||||
{expanded && (
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<ArrowRightIcon />}
|
||||
iconOrientation="right"
|
||||
onClick={() => setShowDeployDialog(true)}
|
||||
className="w-fit"
|
||||
>
|
||||
Go to Production
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeployToProdDialog
|
||||
open={showDeployDialog}
|
||||
onOpenChange={setShowDeployDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/v2/dialogs/Dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/v2/tooltips/Tooltip";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { Step1ConnectStripe } from "../../deploy-dialog/Step1ConnectStripe";
|
||||
import { Step2CopyProducts } from "../../deploy-dialog/Step2CopyProducts";
|
||||
import { Step3CreateApiKey } from "../../deploy-dialog/Step3CreateApiKey";
|
||||
|
||||
interface DeployToProdDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DeployToProdDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DeployToProdDialogProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const { mutate: mutateOrg } = useOrg();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoToProduction = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axiosInstance.patch("/v1/organization", {
|
||||
deployed: true,
|
||||
});
|
||||
|
||||
await mutateOrg();
|
||||
|
||||
window.location.href = "/products?tab=products";
|
||||
} catch (error) {
|
||||
console.error("Failed to deploy to production:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deploy to Production</DialogTitle>
|
||||
<DialogDescription>
|
||||
If you've set up your products and integrated Autumn into your
|
||||
codebase, you can follow the steps below to deploy to Production
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-10 mt-4 [>&_.atmn-sep]:ml-[32px]">
|
||||
<Step1ConnectStripe isDialogOpen={open} />
|
||||
<Step2CopyProducts />
|
||||
<Step3CreateApiKey />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
icon={<ArrowRightIcon />}
|
||||
iconOrientation="right"
|
||||
onClick={handleGoToProduction}
|
||||
isLoading={loading}
|
||||
>
|
||||
Go to Production
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
Make sure you've completed all the steps above before clicking
|
||||
this button. You won't be able to see this dialog again after
|
||||
doing so.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
120
vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx
Normal file
120
vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { Check } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
|
||||
|
||||
interface Step1ConnectStripeProps {
|
||||
isDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export const Step1ConnectStripe = ({
|
||||
isDialogOpen,
|
||||
}: Step1ConnectStripeProps) => {
|
||||
const { org, mutate } = useOrg({ env: AppEnv.Live });
|
||||
const axiosInstance = useAxiosInstance({ env: AppEnv.Live });
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [isConnectingStripe, setIsConnectingStripe] = useState(false);
|
||||
|
||||
// Check if Stripe is connected for production
|
||||
const isStripeConnected =
|
||||
org?.stripe_connection === "oauth" ||
|
||||
org?.stripe_connection === "secret_key";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPolling || !isDialogOpen) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
await mutate();
|
||||
|
||||
// Check if stripe is now connected
|
||||
if (
|
||||
org?.stripe_connection === "oauth" ||
|
||||
org?.stripe_connection === "secret_key"
|
||||
) {
|
||||
setIsPolling(false);
|
||||
setIsConnectingStripe(false);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [isPolling, isDialogOpen, mutate, org?.stripe_connection]);
|
||||
|
||||
// Reset polling state when dialog opens
|
||||
useEffect(() => {
|
||||
if (isDialogOpen) {
|
||||
setIsPolling(false);
|
||||
setIsConnectingStripe(false);
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
const handleConnectStripe = async () => {
|
||||
setIsConnectingStripe(true);
|
||||
setIsPolling(true);
|
||||
|
||||
try {
|
||||
const { data } = await axiosInstance.get(
|
||||
`/v1/organization/stripe/oauth_url`,
|
||||
{
|
||||
params: {
|
||||
redirect_url: `${import.meta.env.VITE_FRONTEND_URL}/close`,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Open in a popup window (not "_blank") so window.close() will work
|
||||
window.open(
|
||||
data.oauth_url,
|
||||
"stripe_oauth",
|
||||
"width=600,height=800,popup=yes",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(getBackendErr(error, "Failed to get OAuth URL"));
|
||||
setIsPolling(false);
|
||||
setIsConnectingStripe(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionHeader
|
||||
stepNumber={1}
|
||||
title="Connect your Stripe account"
|
||||
description="Connect your Stripe production account via OAuth to accept live payments"
|
||||
className="gap-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pl-[32px] flex flex-col gap-2">
|
||||
{isStripeConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
disabled
|
||||
icon={<Check size={16} className="text-green-600" />}
|
||||
className="!opacity-100"
|
||||
>
|
||||
Stripe Connected
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleConnectStripe}
|
||||
isLoading={isConnectingStripe}
|
||||
>
|
||||
Connect Stripe
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
|
||||
|
||||
export const Step2CopyProducts = () => {
|
||||
const sandboxAxios = useAxiosInstance({ env: AppEnv.Sandbox });
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopyProducts = async () => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const { data } = await sandboxAxios.post("/products/copy_to_production");
|
||||
console.log("Data:", data);
|
||||
setIsCopied(true);
|
||||
toast.success(`Successfully copied products to production`);
|
||||
} catch (error) {
|
||||
toast.error(getBackendErr(error, "Failed to copy products"));
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionHeader
|
||||
stepNumber={2}
|
||||
title="Copy your products to production"
|
||||
description="Sync all your configured products and features from sandbox to production"
|
||||
className="gap-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pl-[32px] flex flex-col gap-2">
|
||||
{isCopied ? (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
disabled
|
||||
icon={<Check size={16} className="text-green-600" />}
|
||||
className="!opacity-100"
|
||||
>
|
||||
Copied Products
|
||||
</IconButton>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCopyProducts}
|
||||
isLoading={isCopying}
|
||||
>
|
||||
Copy Products
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx
Normal file
112
vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { useDevQuery } from "@/hooks/queries/useDevQuery";
|
||||
import { DevService } from "@/services/DevService";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { SectionHeader } from "@/views/onboarding3/components/integration-step/SectionHeader";
|
||||
|
||||
export const Step3CreateApiKey = () => {
|
||||
const { refetch } = useDevQuery();
|
||||
const axiosInstance = useAxiosInstance({ env: AppEnv.Live });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { api_key } = await DevService.createAPIKey(axiosInstance, {
|
||||
name: "Production Secret Key",
|
||||
});
|
||||
|
||||
setApiKey(api_key);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.log("Error:", error);
|
||||
toast.error("Failed to create API key");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
stepNumber={3}
|
||||
title="Create Production Secret Key"
|
||||
description="Generate a live secret key for use in your production environment"
|
||||
className="gap-0"
|
||||
/>
|
||||
|
||||
<div className="pl-[32px] flex flex-col gap-3 min-h-[70px]">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{apiKey ? (
|
||||
<motion.div
|
||||
key="api-key-display"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.15,
|
||||
duration: 0.3,
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex justify-between bg-zinc-100 p-2 px-3 text-t2 rounded-md items-center w-fit">
|
||||
<p className="text-sm font-mono">{apiKey}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-t2 hover:text-t2/80 ml-4"
|
||||
onClick={() => {
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-t3">
|
||||
You won't be able to view this key anymore after closing the
|
||||
dialog.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="name-input"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.15,
|
||||
duration: 0.3,
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
isLoading={loading}
|
||||
onClick={handleCreate}
|
||||
variant="secondary"
|
||||
className="w-fit"
|
||||
>
|
||||
Generate Key
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,8 +30,6 @@ export const PlanCardToolbar = ({
|
||||
const isEditingPlan = useIsEditingPlan();
|
||||
const navigate = useNavigate();
|
||||
|
||||
console.log("deleteOpen", deleteOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePlanDialog
|
||||
|
||||
Reference in New Issue
Block a user