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";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
const main = async () => {
|
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({
|
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: [
|
enabled_events: [
|
||||||
"checkout.session.completed",
|
"checkout.session.completed",
|
||||||
"customer.subscription.created",
|
"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;
|
return newConfig as MeteredConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +114,7 @@ export const runSaveFeatureDisplayTask = async ({
|
|||||||
|
|
||||||
await FeatureService.update({
|
await FeatureService.update({
|
||||||
db,
|
db,
|
||||||
internalId: feature.internal_id!,
|
internalId: feature.internal_id,
|
||||||
updates: {
|
updates: {
|
||||||
display,
|
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 { handleFrontendReqError } from "@/utils/errorUtils.js";
|
||||||
import { generateId } from "@/utils/genUtils.js";
|
import { createFeature } from "../featureActions/createFeature.js";
|
||||||
import { FeatureService } from "../FeatureService.js";
|
|
||||||
import { validateFeature } from "../internalFeatureRouter.js";
|
|
||||||
|
|
||||||
export const handleCreateFeature = async (req: any, res: any) => {
|
export const handleCreateFeature = async (req: any, res: any) => {
|
||||||
try {
|
try {
|
||||||
console.log("Trying to create feature");
|
console.log("Trying to create feature");
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
const { db, orgId, env, logger } = req;
|
|
||||||
const parsedFeature = validateFeature(data);
|
|
||||||
|
|
||||||
const feature: Feature = {
|
const insertedFeature = await createFeature({
|
||||||
archived: false,
|
ctx: req,
|
||||||
internal_id: generateId("fe"),
|
data,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await addTaskToQueue({
|
|
||||||
jobName: JobName.GenerateFeatureDisplay,
|
|
||||||
payload: {
|
|
||||||
feature,
|
|
||||||
org: org,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertedFeature =
|
|
||||||
insertedData && insertedData.length > 0 ? insertedData[0] : null;
|
|
||||||
res.status(200).json(insertedFeature);
|
res.status(200).json(insertedFeature);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleFrontendReqError({ req, error, res, action: "Create feature" });
|
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 { routeHandler } from "@/utils/routerUtils.js";
|
||||||
|
import { updateFeature } from "../featureActions/updateFeature.js";
|
||||||
import { toApiFeature } from "../utils/mapFeatureUtils.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 (
|
export const handleUpdateFeature = async (
|
||||||
req: any,
|
req: any,
|
||||||
res: any,
|
res: any,
|
||||||
fromApi: boolean = false,
|
_fromApi: boolean = false,
|
||||||
) =>
|
) =>
|
||||||
routeHandler({
|
routeHandler({
|
||||||
req,
|
req,
|
||||||
@@ -260,143 +14,13 @@ export const handleUpdateFeature = async (
|
|||||||
handler: async (req: any, res: any) => {
|
handler: async (req: any, res: any) => {
|
||||||
const featureId = req.params.feature_id;
|
const featureId = req.params.feature_id;
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
const { db, orgId, env, logger } = req;
|
|
||||||
|
|
||||||
// 1. Get feature by ID
|
// Use the abstracted updateFeature function
|
||||||
const features = await FeatureService.getFromReq(req);
|
const updatedFeature = await updateFeature({
|
||||||
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({
|
|
||||||
ctx: req,
|
ctx: req,
|
||||||
objectsUsingFeature,
|
featureId,
|
||||||
feature,
|
updates: data,
|
||||||
newType: data.type,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
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
|
res
|
||||||
.status(200)
|
.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,
|
RecaseError,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { db } from "@/db/initDrizzle.js";
|
import { db } from "@/db/initDrizzle.js";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
|
||||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
|
||||||
import type { ObjectsUsingFeature } from "./getObjectsUsingFeature.js";
|
import type { ObjectsUsingFeature } from "./getObjectsUsingFeature.js";
|
||||||
|
|
||||||
export const handleFeatureTypeChanged = async ({
|
export const handleFeatureTypeChanged = async ({
|
||||||
@@ -16,7 +16,7 @@ export const handleFeatureTypeChanged = async ({
|
|||||||
feature,
|
feature,
|
||||||
newType,
|
newType,
|
||||||
}: {
|
}: {
|
||||||
ctx: ExtendedRequest;
|
ctx: AutumnContext;
|
||||||
objectsUsingFeature: ObjectsUsingFeature;
|
objectsUsingFeature: ObjectsUsingFeature;
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
newType: FeatureType;
|
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({
|
export const handleUpdateOrg = createRoute({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
onboarded: z.boolean().optional(),
|
onboarded: z.boolean().optional(),
|
||||||
|
deployed: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
handler: async (c) => {
|
handler: async (c) => {
|
||||||
const ctx = c.get("ctx");
|
const ctx = c.get("ctx");
|
||||||
const { db, org } = ctx;
|
const { db, org } = ctx;
|
||||||
|
|
||||||
const { onboarded } = c.req.valid("json");
|
const { onboarded, deployed } = c.req.valid("json");
|
||||||
|
|
||||||
await OrgService.update({
|
await OrgService.update({
|
||||||
db,
|
db,
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
updates: { onboarded },
|
updates: { onboarded, deployed },
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { AppEnv, ErrCode, RecaseError } from "@autumn/shared";
|
import { AppEnv, ErrCode, RecaseError } from "@autumn/shared";
|
||||||
|
import { z } from "zod/v4";
|
||||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||||
import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
|
import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
|
||||||
|
|
||||||
export const handleGetOAuthUrl = createRoute({
|
export const handleGetOAuthUrl = createRoute({
|
||||||
|
query: z.object({
|
||||||
|
redirect_url: z.string().optional(),
|
||||||
|
}),
|
||||||
handler: async (c) => {
|
handler: async (c) => {
|
||||||
|
const { redirect_url } = c.req.query();
|
||||||
const ctx = c.get("ctx");
|
const ctx = c.get("ctx");
|
||||||
const { org, env } = ctx;
|
const { org, env } = ctx;
|
||||||
|
|
||||||
@@ -23,7 +28,7 @@ export const handleGetOAuthUrl = createRoute({
|
|||||||
// Generate OAuth state and store in Redis
|
// Generate OAuth state and store in Redis
|
||||||
const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173";
|
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({
|
const stateKey = await generateOAuthState({
|
||||||
organizationSlug: org.slug,
|
organizationSlug: org.slug,
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export const handleOAuthCallback = async (c: Context<HonoEnv>) => {
|
|||||||
if (isPlatformFlow) {
|
if (isPlatformFlow) {
|
||||||
redirectUrl = new URL(redirect_uri);
|
redirectUrl = new URL(redirect_uri);
|
||||||
} else {
|
} else {
|
||||||
redirectUrl = new URL(
|
redirectUrl = redirect_uri
|
||||||
|
? new URL(redirect_uri)
|
||||||
|
: new URL(
|
||||||
`${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`,
|
`${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const createOrgResponse = ({
|
|||||||
test_pkey: org.test_pkey,
|
test_pkey: org.test_pkey,
|
||||||
live_pkey: org.live_pkey,
|
live_pkey: org.live_pkey,
|
||||||
onboarded: org.onboarded ?? true,
|
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 { Hono } from "hono";
|
||||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||||
|
import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment.js";
|
||||||
import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js";
|
import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js";
|
||||||
import { handleGetProductInternal } from "./internalHandlers/handleGetProductInternal.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/count", ...handleGetProductCount);
|
||||||
internalProductRouter.get("/:productId/data", ...handleGetProductInternal);
|
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 } =
|
const { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } =
|
||||||
itemToPriceAndEnt({
|
itemToPriceAndEnt({
|
||||||
item,
|
item,
|
||||||
orgId: product.org_id!,
|
orgId: product.org_id,
|
||||||
internalProductId: product.internal_id!,
|
internalProductId: product.internal_id,
|
||||||
feature: feature,
|
feature: feature,
|
||||||
curPrice,
|
curPrice,
|
||||||
curEnt,
|
curEnt,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const toFeature = ({
|
|||||||
newVersion?: boolean;
|
newVersion?: boolean;
|
||||||
feature?: Feature;
|
feature?: Feature;
|
||||||
}) => {
|
}) => {
|
||||||
const isBoolean = feature?.type == FeatureType.Boolean;
|
const isBoolean = feature?.type === FeatureType.Boolean;
|
||||||
|
|
||||||
const resetUsage = getResetUsage({ item, feature });
|
const resetUsage = getResetUsage({ item, feature });
|
||||||
|
|
||||||
@@ -132,10 +132,10 @@ export const toFeature = ({
|
|||||||
internal_feature_id: internalFeatureId,
|
internal_feature_id: internalFeatureId,
|
||||||
feature_id: item.feature_id!,
|
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
|
allowance_type: isBoolean
|
||||||
? null
|
? null
|
||||||
: item.included_usage == Infinite
|
: item.included_usage === Infinite
|
||||||
? AllowanceType.Unlimited
|
? AllowanceType.Unlimited
|
||||||
: AllowanceType.Fixed,
|
: AllowanceType.Fixed,
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ export const CustomerDataSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const CustomerResponseSchema = CustomerSchema.omit({
|
export const CustomerResponseSchema = CustomerSchema.omit({
|
||||||
// created_at: true,
|
|
||||||
// env: true,
|
|
||||||
// processor: true,
|
|
||||||
org_id: true,
|
org_id: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { FullCusProduct } from "../cusProductModels/cusProductModels.js";
|
import { ProductSchema } from "@models/productModels/productModels.js";
|
||||||
import { Event } from "../eventModels/eventTable.js";
|
import { z } from "zod/v4";
|
||||||
import { Subscription } from "../subModels/subModels.js";
|
import {
|
||||||
import { Customer } from "./cusModels.js";
|
CusProductSchema,
|
||||||
import { Entity } from "./entityModels/entityModels.js";
|
type FullCusProduct,
|
||||||
import { Invoice } from "./invoiceModels/invoiceModels.js";
|
} 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 & {
|
export type FullCustomer = Customer & {
|
||||||
customer_products: FullCusProduct[];
|
customer_products: FullCusProduct[];
|
||||||
@@ -18,3 +23,11 @@ export type FullCustomer = Customer & {
|
|||||||
subscriptions?: Subscription[];
|
subscriptions?: Subscription[];
|
||||||
events?: Event[];
|
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(),
|
.nullable(),
|
||||||
through_master: z.boolean(),
|
through_master: z.boolean(),
|
||||||
onboarded: z.boolean(),
|
onboarded: z.boolean(),
|
||||||
|
deployed: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FrontendOrg = z.infer<typeof FrontendOrgSchema>;
|
export type FrontendOrg = z.infer<typeof FrontendOrgSchema>;
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const organizations = pgTable(
|
|||||||
config: jsonb().default({}).notNull().$type<OrgConfig>(),
|
config: jsonb().default({}).notNull().$type<OrgConfig>(),
|
||||||
created_by: text("created_by"),
|
created_by: text("created_by"),
|
||||||
onboarded: boolean("onboarded").default(false),
|
onboarded: boolean("onboarded").default(false),
|
||||||
|
deployed: boolean("deployed").default(false),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
unique("organizations_test_pkey_key").on(table.test_pkey),
|
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 CustomerProductView from "./views/customers/customer/product/CustomerProductView";
|
||||||
import { DefaultView } from "./views/DefaultView";
|
import { DefaultView } from "./views/DefaultView";
|
||||||
import DevScreen from "./views/developer/DevView";
|
import DevScreen from "./views/developer/DevView";
|
||||||
|
import { CloseScreen } from "./views/general/CloseScreen";
|
||||||
import OnboardingView3 from "./views/onboarding3/OnboardingView3";
|
import OnboardingView3 from "./views/onboarding3/OnboardingView3";
|
||||||
import ProductsView from "./views/products/ProductsView";
|
import ProductsView from "./views/products/ProductsView";
|
||||||
import PlanEditorView from "./views/products/plan/PlanEditorView";
|
import PlanEditorView from "./views/products/plan/PlanEditorView";
|
||||||
@@ -45,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="/sign-in" element={<SignIn />} />
|
<Route path="/sign-in" element={<SignIn />} />
|
||||||
<Route path="/pw-sign-in" element={<PasswordSignIn />} />
|
<Route path="/pw-sign-in" element={<PasswordSignIn />} />
|
||||||
<Route path="/accept" element={<AcceptInvitation />} />
|
<Route path="/accept" element={<AcceptInvitation />} />
|
||||||
|
<Route path="/close" element={<CloseScreen />} />
|
||||||
|
|
||||||
{/* Onboarding routes without sidebar */}
|
{/* Onboarding routes without sidebar */}
|
||||||
<Route element={<OnboardingLayout />}>
|
<Route element={<OnboardingLayout />}>
|
||||||
|
|||||||
@@ -46,8 +46,16 @@ export function MainLayout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only redirect if org is loaded and user is not onboarded
|
// 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");
|
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]);
|
}, [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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { authClient, useListOrganizations } from "@/lib/auth-client";
|
import { authClient, useListOrganizations } from "@/lib/auth-client";
|
||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const useOrg = () => {
|
const ORG_STORAGE_KEY = "autumn_org";
|
||||||
const axiosInstance = useAxiosInstance();
|
|
||||||
|
export const useOrg = (params?: { env?: AppEnv }) => {
|
||||||
|
const axiosInstance = useAxiosInstance({ env: params?.env });
|
||||||
const { data: orgList } = useListOrganizations();
|
const { data: orgList } = useListOrganizations();
|
||||||
|
|
||||||
const fetcher = async () => {
|
const fetcher = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axiosInstance.get("/organization");
|
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;
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 {
|
const {
|
||||||
data: org,
|
data: org,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["org"],
|
queryKey: params?.env ? ["org", params.env] : ["org"],
|
||||||
queryFn: fetcher,
|
queryFn: fetcher,
|
||||||
|
initialData: getInitialData(),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const useDevQuery = () => {
|
export const useDevQuery = () => {
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
|
||||||
export const useGeneralQuery = ({
|
export const useGeneralQuery = ({
|
||||||
url,
|
url,
|
||||||
|
method,
|
||||||
queryKey,
|
queryKey,
|
||||||
enabled,
|
enabled,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
queryKey?: string[];
|
queryKey?: string[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
|
|
||||||
const fetcher = async () => {
|
const fetcher = async () => {
|
||||||
const { data } = await axiosInstance.get(url);
|
const { data } = await axiosInstance.request({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ const defaultParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
||||||
const finalParams: any = {
|
const currentEnv = useEnv();
|
||||||
...defaultParams,
|
const envToUse = params?.env ?? currentEnv;
|
||||||
...(params || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const trueEnv = useEnv();
|
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_BACKEND_URL,
|
baseURL: import.meta.env.VITE_BACKEND_URL,
|
||||||
@@ -23,7 +19,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
|||||||
|
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
async (config: any) => {
|
async (config: any) => {
|
||||||
config.headers.app_env = trueEnv;
|
config.headers.app_env = envToUse;
|
||||||
config.headers["x-api-version"] = "1.2";
|
config.headers["x-api-version"] = "1.2";
|
||||||
config.headers["x-client-type"] = "dashboard";
|
config.headers["x-client-type"] = "dashboard";
|
||||||
|
|
||||||
@@ -56,7 +52,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) {
|
|||||||
organizationId: nextOrg.id,
|
organizationId: nextOrg.id,
|
||||||
});
|
});
|
||||||
// Redirect to products page of the new organization
|
// 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(
|
return Promise.reject(
|
||||||
new Error("Redirecting to available organization"),
|
new Error("Redirecting to available organization"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,18 +18,22 @@ export const SignIn = () => {
|
|||||||
const [googleLoading, setGoogleLoading] = useState(false);
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
const [sendOtpLoading, setSendOtpLoading] = useState(false);
|
const [sendOtpLoading, setSendOtpLoading] = useState(false);
|
||||||
const [otpSent, setOtpSent] = useState(false);
|
const [otpSent, setOtpSent] = useState(false);
|
||||||
// const { data: session } = useSession();
|
|
||||||
const { org, isLoading: orgLoading } = useOrg();
|
const { org } = useOrg();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// const [searchParams] = useSearchParams();
|
|
||||||
// const token = searchParams.get("token");
|
|
||||||
|
|
||||||
const newPath = "/sandbox/onboarding";
|
const newPath = "/sandbox/onboarding";
|
||||||
const callbackPath = "/customers";
|
const callbackPath = "/sandbox/products?tab=products";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (org?.onboarded) {
|
if (org) {
|
||||||
navigate(callbackPath);
|
if (org.deployed) {
|
||||||
|
navigate("/products?tab=products");
|
||||||
|
} else if (org.onboarded) {
|
||||||
|
navigate("/sandbox/products?tab=products");
|
||||||
|
} else {
|
||||||
|
navigate("/sandbox/onboarding");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [org, navigate]);
|
}, [org, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useOrg } from "@/hooks/common/useOrg";
|
||||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||||
import { useListOrganizations } from "@/lib/auth-client";
|
import { useListOrganizations } from "@/lib/auth-client";
|
||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
@@ -69,6 +70,7 @@ const CommandBar = () => {
|
|||||||
const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations();
|
const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations();
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
const { org } = useOrg();
|
||||||
|
|
||||||
// Improved close dialog function with proper timing
|
// Improved close dialog function with proper timing
|
||||||
const closeDialog = useCallback(() => {
|
const closeDialog = useCallback(() => {
|
||||||
@@ -320,6 +322,8 @@ const CommandBar = () => {
|
|||||||
closeDialog();
|
closeDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(org?.deployed
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`,
|
title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`,
|
||||||
icon: <ArrowsClockwiseIcon />,
|
icon: <ArrowsClockwiseIcon />,
|
||||||
@@ -332,6 +336,8 @@ const CommandBar = () => {
|
|||||||
closeDialog();
|
closeDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(!isLoadingOrgs && orgs && orgs.length > 1
|
...(!isLoadingOrgs && orgs && orgs.length > 1
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import type { CustomerWithProducts } from "@autumn/shared";
|
||||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { useCustomersQueryStates } from "./useCustomersQueryStates";
|
|
||||||
import {
|
|
||||||
CusProductSchema,
|
|
||||||
CustomerSchema,
|
|
||||||
ProductSchema,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const CustomerWithProductsSchema = CustomerSchema.extend({
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
customer_products: z.array(
|
import { useCustomersQueryStates } from "./useCustomersQueryStates";
|
||||||
CusProductSchema.extend({ product: ProductSchema }),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
type CustomerWithProducts = z.infer<typeof CustomerWithProductsSchema>;
|
|
||||||
|
|
||||||
export const useCusSearchQuery = () => {
|
export const useCusSearchQuery = () => {
|
||||||
const { queryStates } = useCustomersQueryStates();
|
const { queryStates } = useCustomersQueryStates();
|
||||||
@@ -72,22 +61,3 @@ export const useCusSearchQuery = () => {
|
|||||||
isFetchingUncached,
|
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 {
|
import {
|
||||||
parseAsArrayOf,
|
parseAsArrayOf,
|
||||||
parseAsBoolean,
|
parseAsBoolean,
|
||||||
parseAsInteger,
|
parseAsInteger,
|
||||||
parseAsString,
|
parseAsString,
|
||||||
|
useQueryStates,
|
||||||
} from "nuqs";
|
} from "nuqs";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useQueryStates } from "nuqs";
|
|
||||||
import { useLocation } from "react-router";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { debounce } from "lodash";
|
|
||||||
|
|
||||||
export const useCustomersQueryStates = () => {
|
export const useCustomersQueryStates = () => {
|
||||||
const [queryStates, setQueryStates] = useQueryStates(
|
const [queryStates, setQueryStates] = useQueryStates(
|
||||||
{
|
{
|
||||||
@@ -25,6 +22,8 @@ export const useCustomersQueryStates = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// return { queryStates, setQueryStates };
|
||||||
|
|
||||||
const [stableStates, setStableStates] = useState(queryStates);
|
const [stableStates, setStableStates] = useState(queryStates);
|
||||||
|
|
||||||
useEffect(() => {
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
|
||||||
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
|
||||||
|
import { useOrg } from "@/hooks/common/useOrg";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEnv } from "@/utils/envUtils";
|
import { useEnv } from "@/utils/envUtils";
|
||||||
|
import { DeployToProdButton } from "./components/deploy-button/DeployToProdButton";
|
||||||
import { OrgDropdown } from "./components/OrgDropdown";
|
import { OrgDropdown } from "./components/OrgDropdown";
|
||||||
import { EnvDropdown } from "./EnvDropdown";
|
import { EnvDropdown } from "./EnvDropdown";
|
||||||
import { NavButton } from "./NavButton";
|
import { NavButton } from "./NavButton";
|
||||||
@@ -20,6 +22,7 @@ import { SidebarGroup } from "./SidebarGroup";
|
|||||||
|
|
||||||
export const MainSidebar = () => {
|
export const MainSidebar = () => {
|
||||||
const env = useEnv();
|
const env = useEnv();
|
||||||
|
const { org } = useOrg();
|
||||||
|
|
||||||
const { webhooks } = useAutumnFlags();
|
const { webhooks } = useAutumnFlags();
|
||||||
|
|
||||||
@@ -74,7 +77,11 @@ export const MainSidebar = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<OrgDropdown />
|
<OrgDropdown />
|
||||||
|
|
||||||
|
{org?.deployed ? (
|
||||||
<EnvDropdown env={env} />
|
<EnvDropdown env={env} />
|
||||||
|
) : (
|
||||||
|
<DeployToProdButton expanded={expanded} />
|
||||||
|
)}
|
||||||
<div className="flex flex-col px-2 gap-1">
|
<div className="flex flex-col px-2 gap-1">
|
||||||
<div>
|
<div>
|
||||||
<NavButton
|
<NavButton
|
||||||
@@ -160,43 +167,3 @@ export const MainSidebar = () => {
|
|||||||
</SidebarContext.Provider>
|
</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 { org, isLoading, error } = useOrg();
|
||||||
const { expanded, setExpanded } = useSidebarContext();
|
const { expanded, setExpanded } = useSidebarContext();
|
||||||
|
|
||||||
let { data: orgs, isPending } = useListOrganizations();
|
let { data: orgs } = useListOrganizations();
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
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)
|
// 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();
|
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
|
// To pre-fetch data
|
||||||
useMemberships();
|
useMemberships();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
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 isEditingPlan = useIsEditingPlan();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
console.log("deleteOpen", deleteOpen);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeletePlanDialog
|
<DeletePlanDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user