From 843536bab42c85230bed16d6467dff3c005efd8f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 20 Oct 2025 16:59:13 +0100 Subject: [PATCH] feat: deploy to production flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/register.ts | 4 +- .../features/featureActions/createFeature.ts | 54 +++ .../features/featureActions/updateFeature.ts | 147 +++++++ server/src/internal/features/featureUtils.ts | 25 +- .../features/handlers/handleCreateFeature.ts | 37 +- .../features/handlers/handleUpdateFeature.ts | 390 +----------------- .../handleFeatureIdChanged.ts | 127 ++++++ .../handleFeatureTypeChanged.ts | 4 +- .../handleFeatureUsageTypeChanged.ts | 111 +++++ .../internal/orgs/handlers/handleUpdateOrg.ts | 5 +- .../stripeHandlers/handleGetOAuthUrl.ts | 7 +- .../stripeHandlers/handleOAuthCallback.ts | 8 +- server/src/internal/orgs/orgUtils.ts | 1 + .../handlers/handleCopyEnvironment.ts | 52 +++ .../handleCopyFeatures.ts | 77 ++++ .../handleCopyProducts.ts | 106 +++++ .../handlers/productActions/createProduct.ts | 121 ++++++ .../handlers/productActions/updateProduct.ts | 214 ++++++++++ .../products/internalProductRouter.ts | 2 + .../productItemUtils/handleNewProductItems.ts | 4 +- .../productItemUtils/itemToPriceAndEnt.ts | 6 +- shared/models/cusModels/cusModels.ts | 3 - shared/models/cusModels/fullCusModel.ts | 25 +- shared/models/orgModels/frontendOrg.ts | 1 + shared/models/orgModels/orgTable.ts | 1 + vite/src/App.tsx | 2 + vite/src/app/layout.tsx | 12 +- vite/src/hooks/common/useOrg.tsx | 30 +- vite/src/hooks/common/useShowDeployButton.tsx | 88 ++++ vite/src/hooks/queries/useDevQuery.tsx | 2 +- vite/src/hooks/queries/useGeneralQuery.tsx | 9 +- vite/src/services/useAxiosInstance.tsx | 12 +- vite/src/views/auth/SignIn.tsx | 18 +- vite/src/views/command-bar/CommandBar.tsx | 30 +- .../customers/hooks/useCusSearchQuery.tsx | 38 +- .../hooks/useCustomersQueryStates.tsx | 11 +- vite/src/views/general/CloseScreen.tsx | 31 ++ vite/src/views/main-sidebar/MainSidebar.tsx | 49 +-- .../main-sidebar/components/OrgDropdown.tsx | 8 +- .../deploy-button/DeployToProdButton.tsx | 37 ++ .../deploy-button/DeployToProdDialog.tsx | 94 +++++ .../deploy-dialog/Step1ConnectStripe.tsx | 120 ++++++ .../deploy-dialog/Step2CopyProducts.tsx | 65 +++ .../deploy-dialog/Step3CreateApiKey.tsx | 112 +++++ .../components/plan-card/PlanCardToolbar.tsx | 2 - 45 files changed, 1712 insertions(+), 590 deletions(-) create mode 100644 server/src/internal/features/featureActions/createFeature.ts create mode 100644 server/src/internal/features/featureActions/updateFeature.ts create mode 100644 server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts create mode 100644 server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts create mode 100644 server/src/internal/products/handlers/handleCopyEnvironment.ts create mode 100644 server/src/internal/products/handlers/handleCopyEnvironment/handleCopyFeatures.ts create mode 100644 server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts create mode 100644 server/src/internal/products/handlers/productActions/createProduct.ts create mode 100644 server/src/internal/products/handlers/productActions/updateProduct.ts create mode 100644 vite/src/hooks/common/useShowDeployButton.tsx create mode 100644 vite/src/views/general/CloseScreen.tsx create mode 100644 vite/src/views/main-sidebar/components/deploy-button/DeployToProdButton.tsx create mode 100644 vite/src/views/main-sidebar/components/deploy-button/DeployToProdDialog.tsx create mode 100644 vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx create mode 100644 vite/src/views/main-sidebar/deploy-dialog/Step2CopyProducts.tsx create mode 100644 vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx diff --git a/server/register.ts b/server/register.ts index 13c15dd04..4435427a7 100644 --- a/server/register.ts +++ b/server/register.ts @@ -2,10 +2,10 @@ import "dotenv/config"; import Stripe from "stripe"; const main = async () => { - const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || ""); + const stripe = new Stripe(process.env.STRIPE_LIVE_SECRET_KEY || ""); const result = await stripe.webhookEndpoints.create({ - url: "https://api.useautumn.com/webhooks/connect/sandbox", + url: "https://express.dev.useautumn.com/webhooks/connect/live", enabled_events: [ "checkout.session.completed", "customer.subscription.created", diff --git a/server/src/internal/features/featureActions/createFeature.ts b/server/src/internal/features/featureActions/createFeature.ts new file mode 100644 index 000000000..99b9768f9 --- /dev/null +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -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 => { + 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; +}; diff --git a/server/src/internal/features/featureActions/updateFeature.ts b/server/src/internal/features/featureActions/updateFeature.ts new file mode 100644 index 000000000..afb065d8d --- /dev/null +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -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; +} + +/** + * Updates an existing feature with full validation logic + */ +export const updateFeature = async ({ + ctx, + featureId, + updates, +}: UpdateFeatureParams): Promise => { + // 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; +}; diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index 5a647d961..b721c2da1 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -40,29 +40,6 @@ export const validateMeteredConfig = (config: MeteredConfig) => { }); } - // Event names are now stored in feature.event_names, not in config.filters - // if (config.aggregate?.type === AggregateType.Count) { - // newConfig.aggregate = { - // type: AggregateType.Count, - // property: null, - // }; // to continue testing support for count... - // } else { - // newConfig.aggregate = { - // type: AggregateType.Sum, - // property: "value", - // }; - // } - - // if (newConfig?.filters?.length === 0 || !newConfig?.filters) { - // newConfig.filters = [ - // { - // property: "", - // operator: "", - // value: [], - // }, - // ]; - // } - return newConfig as MeteredConfig; }; @@ -137,7 +114,7 @@ export const runSaveFeatureDisplayTask = async ({ await FeatureService.update({ db, - internalId: feature.internal_id!, + internalId: feature.internal_id, updates: { display, }, diff --git a/server/src/internal/features/handlers/handleCreateFeature.ts b/server/src/internal/features/handlers/handleCreateFeature.ts index 86fc5cc4b..bb523ecb5 100644 --- a/server/src/internal/features/handlers/handleCreateFeature.ts +++ b/server/src/internal/features/handlers/handleCreateFeature.ts @@ -1,45 +1,16 @@ -import type { Feature } from "@autumn/shared"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { JobName } from "@/queue/JobName.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; import { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { generateId } from "@/utils/genUtils.js"; -import { FeatureService } from "../FeatureService.js"; -import { validateFeature } from "../internalFeatureRouter.js"; +import { createFeature } from "../featureActions/createFeature.js"; export const handleCreateFeature = async (req: any, res: any) => { try { console.log("Trying to create feature"); const data = req.body; - const { db, orgId, env, logger } = req; - const parsedFeature = validateFeature(data); - const feature: Feature = { - archived: false, - internal_id: generateId("fe"), - org_id: orgId, - created_at: Date.now(), - env: env, - ...parsedFeature, - }; - - const org = await OrgService.getFromReq(req); - const insertedData = await FeatureService.insert({ - db, - data: feature, - logger, + const insertedFeature = await createFeature({ + ctx: req, + data, }); - await addTaskToQueue({ - jobName: JobName.GenerateFeatureDisplay, - payload: { - feature, - org: org, - }, - }); - - const insertedFeature = - insertedData && insertedData.length > 0 ? insertedData[0] : null; res.status(200).json(insertedFeature); } catch (error) { handleFrontendReqError({ req, error, res, action: "Create feature" }); diff --git a/server/src/internal/features/handlers/handleUpdateFeature.ts b/server/src/internal/features/handlers/handleUpdateFeature.ts index 66fed96bd..0546ee4fc 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature.ts @@ -1,257 +1,11 @@ -import { - type AppEnv, - EntInterval, - type Entitlement, - type EntitlementWithFeature, - ErrCode, - type Feature, - FeatureType, - FeatureUsageType, - notNullish, - type Price, - type UsagePriceConfig, -} from "@autumn/shared"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { - validateCreditSystem, - validateMeteredConfig, -} from "@/internal/features/featureUtils.js"; -import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import { PriceService } from "@/internal/products/prices/PriceService.js"; -import { JobName } from "@/queue/JobName.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { keyToTitle } from "@/utils/genUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; +import { updateFeature } from "../featureActions/updateFeature.js"; import { toApiFeature } from "../utils/mapFeatureUtils.js"; -import { getObjectsUsingFeature } from "./handleUpdateFeature/getObjectsUsingFeature.js"; -import { handleFeatureTypeChanged } from "./handleUpdateFeature/handleFeatureTypeChanged.js"; - -const handleFeatureIdChanged = async ({ - db, - orgId, - env, - feature, - linkedEntitlements, - entitlements, - prices, - creditSystems, - newId, - logger, -}: { - db: DrizzleCli; - orgId: string; - env: AppEnv; - feature: Feature; - linkedEntitlements: Entitlement[]; - entitlements: Entitlement[]; - prices: Price[]; - creditSystems: Feature[]; - newId: string; - logger: any; -}) => { - // 1. Check if any customer entitlement linked to this feature - const cusEnts = await CusEntService.getByFeature({ - db, - internalFeatureId: feature.internal_id!, - }); - - if (cusEnts.length > 0) { - throw new RecaseError({ - message: `Cannot change id of feature ${feature.id} because a customer is using it`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } - - // 2. Update all linked objects - const batchUpdate = []; - - for (const entitlement of linkedEntitlements) { - batchUpdate.push( - EntitlementService.update({ - db, - id: entitlement.id!, - updates: { - entity_feature_id: newId, - }, - }), - ); - } - - await Promise.all(batchUpdate); - - // 3. Update all linked prices - const priceUpdate = []; - for (const price of prices) { - priceUpdate.push( - PriceService.update({ - db, - id: price.id!, - update: { - config: { - ...price.config, - feature_id: newId, - } as UsagePriceConfig, - }, - }), - ); - } - - await Promise.all(priceUpdate); - - // 4. Update all linked credit systems - const creditSystemUpdate = []; - for (const creditSystem of creditSystems) { - const newSchema = structuredClone(creditSystem.config.schema); - for (let i = 0; i < newSchema.length; i++) { - if (newSchema[i].metered_feature_id === feature.id) { - newSchema[i].metered_feature_id = newId; - } - } - creditSystemUpdate.push( - FeatureService.update({ - db, - id: creditSystem.id!, - orgId, - env, - updates: { - config: { - ...creditSystem.config, - schema: newSchema, - }, - }, - }), - ); - } - - await Promise.all(creditSystemUpdate); - - // 5. Update all linked entitlements - const entitlementUpdate = []; - - for (const entitlement of entitlements) { - entitlementUpdate.push( - EntitlementService.update({ - db, - id: entitlement.id!, - updates: { - feature_id: newId, - }, - }), - ); - } - - await Promise.all(entitlementUpdate); -}; - -const handleFeatureUsageTypeChanged = async ({ - db, - feature, - newUsageType, - linkedEntitlements, - entitlements, - prices, - creditSystems, -}: { - db: DrizzleCli; - feature: Feature; - newUsageType: FeatureUsageType; - linkedEntitlements: EntitlementWithFeature[]; - entitlements: EntitlementWithFeature[]; - prices: Price[]; - creditSystems: Feature[]; -}) => { - const usageTypeTitle = keyToTitle(newUsageType).toLowerCase(); - if (creditSystems.length > 0) { - throw new RecaseError({ - message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } - - if (linkedEntitlements.length > 0) { - throw new RecaseError({ - message: `Cannot set to ${usageTypeTitle} because it is used as an entity by ${linkedEntitlements[0].feature.name}`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } - - // Get cus product using feature... - const cusEnts = await CusEntService.getByFeature({ - db, - internalFeatureId: feature.internal_id!, - }); - - if (cusEnts && cusEnts.length > 0) { - throw new RecaseError({ - message: `Cannot set to ${usageTypeTitle} because it is / was used by customers`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } - - if (entitlements.length > 0) { - console.log( - `Feature usage type changed to ${newUsageType}, updating entitlements and prices`, - ); - if (newUsageType === FeatureUsageType.Continuous) { - const batchEntUpdate = []; - for (const entitlement of entitlements) { - batchEntUpdate.push( - EntitlementService.update({ - db, - id: entitlement.id!, - updates: { - interval: EntInterval.Lifetime, - }, - }), - ); - } - - await Promise.all(batchEntUpdate); - console.log(`Updated ${entitlements.length} entitlements`); - } - } - - if (prices.length > 0) { - const batchPriceUpdate = []; - for (const price of prices) { - const priceConfig = price.config as UsagePriceConfig; - - batchPriceUpdate.push( - PriceService.update({ - db, - id: price.id!, - update: { - config: { - ...priceConfig, - should_prorate: - newUsageType === FeatureUsageType.Continuous ? false : true, // if continuous, don't prorate -> get usage_in_arrear type... - stripe_price_id: null, - }, - }, - }), - ); - } - - await Promise.all(batchPriceUpdate); - console.log(`Updated ${prices.length} prices`); - } - - // // Allow update for entitlement / price? - // if (entitlements.length > 0) { - // } -}; export const handleUpdateFeature = async ( req: any, res: any, - fromApi: boolean = false, + _fromApi: boolean = false, ) => routeHandler({ req, @@ -260,144 +14,14 @@ export const handleUpdateFeature = async ( handler: async (req: any, res: any) => { const featureId = req.params.feature_id; const data = req.body; - const { db, orgId, env, logger } = req; - // 1. Get feature by ID - const features = await FeatureService.getFromReq(req); - const feature = features.find((f) => f.id === featureId); - - if (!feature) { - throw new RecaseError({ - message: `Feature ${featureId} not found`, - code: ErrCode.InvalidFeature, - statusCode: 404, - }); - } - - // If only archiving, skip other checks and just update - if (data.archived !== undefined && Object.keys(data).length === 1) { - console.log("Updating feature archived to: ", data.archived); - const updatedFeature = await FeatureService.update({ - db: req.db, - id: featureId, - orgId: req.orgId, - env: req.env, - updates: { - archived: data.archived, - }, - }); - - if (res) { - res - .status(200) - .json( - updatedFeature - ? toApiFeature({ feature: updatedFeature }) - : undefined, - ); - } - return; - } - - // 1. Check if changing type... - const isChangingType = - notNullish(data.type) && feature.type !== data.type; - - const isChangingId = notNullish(data.id) && feature.id !== data.id; - - const isChangingUsageType = - feature.type !== FeatureType.Boolean && - data.type !== FeatureType.Boolean && - feature.config?.usage_type !== data.config?.usage_type; - - const isChangingName = feature.name !== data.name; - - if (isChangingType || isChangingId || isChangingUsageType) { - const objectsUsingFeature = await getObjectsUsingFeature({ - db, - orgId: req.orgId, - env: req.env, - allFeatures: features, - feature, - }); - - // 1. Can't change type if any objects are linked to it - if (isChangingType) { - await handleFeatureTypeChanged({ - ctx: req, - objectsUsingFeature, - feature, - 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, - }, + // Use the abstracted updateFeature function + const updatedFeature = await updateFeature({ + ctx: req, + featureId, + updates: data, }); - if (isChangingName) { - await addTaskToQueue({ - jobName: JobName.GenerateFeatureDisplay, - payload: { - feature: updatedFeature, - org: req.org, - }, - }); - } - res .status(200) .json( diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts new file mode 100644 index 000000000..22d91d5c5 --- /dev/null +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts @@ -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); +}; diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts index 3fd51d53c..98c53c381 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts @@ -7,8 +7,8 @@ import { RecaseError, } from "@autumn/shared"; import { db } from "@/db/initDrizzle.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import type { ObjectsUsingFeature } from "./getObjectsUsingFeature.js"; export const handleFeatureTypeChanged = async ({ @@ -16,7 +16,7 @@ export const handleFeatureTypeChanged = async ({ feature, newType, }: { - ctx: ExtendedRequest; + ctx: AutumnContext; objectsUsingFeature: ObjectsUsingFeature; feature: Feature; newType: FeatureType; diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts new file mode 100644 index 000000000..12048ef2c --- /dev/null +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts @@ -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`); + } +}; diff --git a/server/src/internal/orgs/handlers/handleUpdateOrg.ts b/server/src/internal/orgs/handlers/handleUpdateOrg.ts index 38fb657d0..f0074bf0d 100644 --- a/server/src/internal/orgs/handlers/handleUpdateOrg.ts +++ b/server/src/internal/orgs/handlers/handleUpdateOrg.ts @@ -5,17 +5,18 @@ import { OrgService } from "../OrgService.js"; export const handleUpdateOrg = createRoute({ body: z.object({ onboarded: z.boolean().optional(), + deployed: z.boolean().optional(), }), handler: async (c) => { const ctx = c.get("ctx"); const { db, org } = ctx; - const { onboarded } = c.req.valid("json"); + const { onboarded, deployed } = c.req.valid("json"); await OrgService.update({ db, orgId: org.id, - updates: { onboarded }, + updates: { onboarded, deployed }, }); return c.json({ success: true }); diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index 5a66f69ec..2a0be9128 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -1,9 +1,14 @@ import { AppEnv, ErrCode, RecaseError } from "@autumn/shared"; +import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; export const handleGetOAuthUrl = createRoute({ + query: z.object({ + redirect_url: z.string().optional(), + }), handler: async (c) => { + const { redirect_url } = c.req.query(); const ctx = c.get("ctx"); const { org, env } = ctx; @@ -23,7 +28,7 @@ export const handleGetOAuthUrl = createRoute({ // Generate OAuth state and store in Redis const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirectUri = `${frontendUrl}/dev?tab=stripe`; + const redirectUri = redirect_url || `${frontendUrl}/dev?tab=stripe`; const stateKey = await generateOAuthState({ organizationSlug: org.slug, diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts index fd55f9603..a24f17b35 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -58,9 +58,11 @@ export const handleOAuthCallback = async (c: Context) => { if (isPlatformFlow) { redirectUrl = new URL(redirect_uri); } else { - redirectUrl = new URL( - `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`, - ); + redirectUrl = redirect_uri + ? new URL(redirect_uri) + : new URL( + `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`, + ); } // Fetch the organization by slug diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 4917cd68c..0fcbd8c66 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -234,6 +234,7 @@ export const createOrgResponse = ({ test_pkey: org.test_pkey, live_pkey: org.live_pkey, onboarded: org.onboarded ?? true, + deployed: org.deployed ?? true, }; }; diff --git a/server/src/internal/products/handlers/handleCopyEnvironment.ts b/server/src/internal/products/handlers/handleCopyEnvironment.ts new file mode 100644 index 000000000..690d8dbd8 --- /dev/null +++ b/server/src/internal/products/handlers/handleCopyEnvironment.ts @@ -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", + }); + }, +}); diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyFeatures.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyFeatures.ts new file mode 100644 index 000000000..6c0a973f2 --- /dev/null +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyFeatures.ts @@ -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); +}; diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts new file mode 100644 index 000000000..24123f8bf --- /dev/null +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts @@ -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 => { + 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); +}; diff --git a/server/src/internal/products/handlers/productActions/createProduct.ts b/server/src/internal/products/handlers/productActions/createProduct.ts new file mode 100644 index 000000000..18a315aab --- /dev/null +++ b/server/src/internal/products/handlers/productActions/createProduct.ts @@ -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; +}; diff --git a/server/src/internal/products/handlers/productActions/updateProduct.ts b/server/src/internal/products/handlers/productActions/updateProduct.ts new file mode 100644 index 000000000..bfae32a24 --- /dev/null +++ b/server/src/internal/products/handlers/productActions/updateProduct.ts @@ -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; +}; diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index d2a57d735..9f0356952 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -518,6 +518,7 @@ expressProductRouter.get( import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCopyEnvironment } from "./handlers/handleCopyEnvironment.js"; import { handleGetProductCount } from "./internalHandlers/handleGetProductCount.js"; import { handleGetProductInternal } from "./internalHandlers/handleGetProductInternal.js"; @@ -526,3 +527,4 @@ export const internalProductRouter = new Hono(); internalProductRouter.get("/:productId/count", ...handleGetProductCount); internalProductRouter.get("/:productId/data", ...handleGetProductInternal); +internalProductRouter.post("/copy_to_production", ...handleCopyEnvironment); diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index f06325dd4..8587e280d 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -218,8 +218,8 @@ export const handleNewProductItems = async ({ const { newPrice, newEnt, updatedPrice, updatedEnt, samePrice, sameEnt } = itemToPriceAndEnt({ item, - orgId: product.org_id!, - internalProductId: product.internal_id!, + orgId: product.org_id, + internalProductId: product.internal_id, feature: feature, curPrice, curEnt, diff --git a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts index 8ca283ae6..2512f0ba2 100644 --- a/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts +++ b/server/src/internal/products/product-items/productItemUtils/itemToPriceAndEnt.ts @@ -118,7 +118,7 @@ export const toFeature = ({ newVersion?: boolean; feature?: Feature; }) => { - const isBoolean = feature?.type == FeatureType.Boolean; + const isBoolean = feature?.type === FeatureType.Boolean; const resetUsage = getResetUsage({ item, feature }); @@ -132,10 +132,10 @@ export const toFeature = ({ internal_feature_id: internalFeatureId, feature_id: item.feature_id!, - allowance: item.included_usage == Infinite ? null : item.included_usage!, + allowance: item.included_usage === Infinite ? null : item.included_usage!, allowance_type: isBoolean ? null - : item.included_usage == Infinite + : item.included_usage === Infinite ? AllowanceType.Unlimited : AllowanceType.Fixed, diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index c241be108..abf4c2ffc 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -74,9 +74,6 @@ export const CustomerDataSchema = z.object({ }); export const CustomerResponseSchema = CustomerSchema.omit({ - // created_at: true, - // env: true, - // processor: true, org_id: true, }); diff --git a/shared/models/cusModels/fullCusModel.ts b/shared/models/cusModels/fullCusModel.ts index 93aad59bc..7c23e00b9 100644 --- a/shared/models/cusModels/fullCusModel.ts +++ b/shared/models/cusModels/fullCusModel.ts @@ -1,9 +1,14 @@ -import { FullCusProduct } from "../cusProductModels/cusProductModels.js"; -import { Event } from "../eventModels/eventTable.js"; -import { Subscription } from "../subModels/subModels.js"; -import { Customer } from "./cusModels.js"; -import { Entity } from "./entityModels/entityModels.js"; -import { Invoice } from "./invoiceModels/invoiceModels.js"; +import { ProductSchema } from "@models/productModels/productModels.js"; +import { z } from "zod/v4"; +import { + CusProductSchema, + type FullCusProduct, +} from "../cusProductModels/cusProductModels.js"; +import type { Event } from "../eventModels/eventTable.js"; +import type { Subscription } from "../subModels/subModels.js"; +import { type Customer, CustomerSchema } from "./cusModels.js"; +import type { Entity } from "./entityModels/entityModels.js"; +import type { Invoice } from "./invoiceModels/invoiceModels.js"; export type FullCustomer = Customer & { customer_products: FullCusProduct[]; @@ -18,3 +23,11 @@ export type FullCustomer = Customer & { subscriptions?: Subscription[]; events?: Event[]; }; + +export const CustomerWithProductsSchema = CustomerSchema.extend({ + customer_products: z.array( + CusProductSchema.extend({ product: ProductSchema }), + ), +}); + +export type CustomerWithProducts = z.infer; diff --git a/shared/models/orgModels/frontendOrg.ts b/shared/models/orgModels/frontendOrg.ts index 82fb0ff27..339add6c5 100644 --- a/shared/models/orgModels/frontendOrg.ts +++ b/shared/models/orgModels/frontendOrg.ts @@ -22,6 +22,7 @@ export const FrontendOrgSchema = z.object({ .nullable(), through_master: z.boolean(), onboarded: z.boolean(), + deployed: z.boolean(), }); export type FrontendOrg = z.infer; diff --git a/shared/models/orgModels/orgTable.ts b/shared/models/orgModels/orgTable.ts index 1df67889f..59f9cd680 100644 --- a/shared/models/orgModels/orgTable.ts +++ b/shared/models/orgModels/orgTable.ts @@ -86,6 +86,7 @@ export const organizations = pgTable( config: jsonb().default({}).notNull().$type(), created_by: text("created_by"), onboarded: boolean("onboarded").default(false), + deployed: boolean("deployed").default(false), }, (table) => [ unique("organizations_test_pkey_key").on(table.test_pkey), diff --git a/vite/src/App.tsx b/vite/src/App.tsx index e3345f40e..ec2060e34 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -18,6 +18,7 @@ import CustomerView from "./views/customers/customer/CustomerView"; import CustomerProductView from "./views/customers/customer/product/CustomerProductView"; import { DefaultView } from "./views/DefaultView"; import DevScreen from "./views/developer/DevView"; +import { CloseScreen } from "./views/general/CloseScreen"; import OnboardingView3 from "./views/onboarding3/OnboardingView3"; import ProductsView from "./views/products/ProductsView"; import PlanEditorView from "./views/products/plan/PlanEditorView"; @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> {/* Onboarding routes without sidebar */} }> diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index c2b56d14b..07e9f1d9f 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -46,8 +46,16 @@ export function MainLayout() { useEffect(() => { // Only redirect if org is loaded and user is not onboarded - if (!orgLoading && org && !org.onboarded) { - navigate("/sandbox/onboarding"); + if (!orgLoading && org) { + if (!org.onboarded) { + navigate("/sandbox/onboarding"); + } else if (!org.deployed) { + const pathname = window.location.pathname; + if (!pathname.startsWith("/sandbox")) { + const search = window.location.search; + navigate(`/sandbox${pathname}${search}`); + } + } } }, [org, orgLoading, navigate]); diff --git a/vite/src/hooks/common/useOrg.tsx b/vite/src/hooks/common/useOrg.tsx index 19264478f..da1049b42 100644 --- a/vite/src/hooks/common/useOrg.tsx +++ b/vite/src/hooks/common/useOrg.tsx @@ -1,30 +1,52 @@ -import type { FrontendOrg } from "@autumn/shared"; +import type { AppEnv, FrontendOrg } from "@autumn/shared"; import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; import { authClient, useListOrganizations } from "@/lib/auth-client"; import { useAxiosInstance } from "@/services/useAxiosInstance"; -export const useOrg = () => { - const axiosInstance = useAxiosInstance(); +const ORG_STORAGE_KEY = "autumn_org"; + +export const useOrg = (params?: { env?: AppEnv }) => { + const axiosInstance = useAxiosInstance({ env: params?.env }); const { data: orgList } = useListOrganizations(); const fetcher = async () => { try { const { data } = await axiosInstance.get("/organization"); + // Store in local storage + if (data) { + const storageKey = params?.env + ? `${ORG_STORAGE_KEY}_${params.env}` + : ORG_STORAGE_KEY; + localStorage.setItem(storageKey, JSON.stringify(data)); + } return data; } catch { return null; } }; + const getInitialData = () => { + try { + const storageKey = params?.env + ? `${ORG_STORAGE_KEY}_${params.env}` + : ORG_STORAGE_KEY; + const stored = localStorage.getItem(storageKey); + return stored ? JSON.parse(stored) : undefined; + } catch { + return undefined; + } + }; + const { data: org, isLoading, error, refetch, } = useQuery({ - queryKey: ["org"], + queryKey: params?.env ? ["org", params.env] : ["org"], queryFn: fetcher, + initialData: getInitialData(), }); useEffect(() => { diff --git a/vite/src/hooks/common/useShowDeployButton.tsx b/vite/src/hooks/common/useShowDeployButton.tsx new file mode 100644 index 000000000..503fc09a3 --- /dev/null +++ b/vite/src/hooks/common/useShowDeployButton.tsx @@ -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(() => { + 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 }; +}; diff --git a/vite/src/hooks/queries/useDevQuery.tsx b/vite/src/hooks/queries/useDevQuery.tsx index 64d6c262c..e0c642202 100644 --- a/vite/src/hooks/queries/useDevQuery.tsx +++ b/vite/src/hooks/queries/useDevQuery.tsx @@ -1,5 +1,5 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const useDevQuery = () => { const axiosInstance = useAxiosInstance(); diff --git a/vite/src/hooks/queries/useGeneralQuery.tsx b/vite/src/hooks/queries/useGeneralQuery.tsx index ed8de8a2f..c424205b4 100644 --- a/vite/src/hooks/queries/useGeneralQuery.tsx +++ b/vite/src/hooks/queries/useGeneralQuery.tsx @@ -1,19 +1,24 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; export const useGeneralQuery = ({ url, + method, queryKey, enabled, }: { url: string; + method: "GET" | "POST" | "PUT" | "DELETE"; queryKey?: string[]; enabled?: boolean; }) => { const axiosInstance = useAxiosInstance(); const fetcher = async () => { - const { data } = await axiosInstance.get(url); + const { data } = await axiosInstance.request({ + method, + url, + }); return data; }; diff --git a/vite/src/services/useAxiosInstance.tsx b/vite/src/services/useAxiosInstance.tsx index 5754df8b2..7d07946df 100644 --- a/vite/src/services/useAxiosInstance.tsx +++ b/vite/src/services/useAxiosInstance.tsx @@ -9,12 +9,8 @@ const defaultParams = { }; export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) { - const finalParams: any = { - ...defaultParams, - ...(params || {}), - }; - - const trueEnv = useEnv(); + const currentEnv = useEnv(); + const envToUse = params?.env ?? currentEnv; const axiosInstance = axios.create({ baseURL: import.meta.env.VITE_BACKEND_URL, @@ -23,7 +19,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) { axiosInstance.interceptors.request.use( async (config: any) => { - config.headers.app_env = trueEnv; + config.headers.app_env = envToUse; config.headers["x-api-version"] = "1.2"; config.headers["x-client-type"] = "dashboard"; @@ -56,7 +52,7 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) { organizationId: nextOrg.id, }); // Redirect to products page of the new organization - window.location.href = `/${trueEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`; + window.location.href = `/${currentEnv === AppEnv.Sandbox ? "sandbox" : "production"}/products`; return Promise.reject( new Error("Redirecting to available organization"), ); diff --git a/vite/src/views/auth/SignIn.tsx b/vite/src/views/auth/SignIn.tsx index 3f68ef30b..bc9a79dac 100644 --- a/vite/src/views/auth/SignIn.tsx +++ b/vite/src/views/auth/SignIn.tsx @@ -18,18 +18,22 @@ export const SignIn = () => { const [googleLoading, setGoogleLoading] = useState(false); const [sendOtpLoading, setSendOtpLoading] = useState(false); const [otpSent, setOtpSent] = useState(false); - // const { data: session } = useSession(); - const { org, isLoading: orgLoading } = useOrg(); + + const { org } = useOrg(); const navigate = useNavigate(); - // const [searchParams] = useSearchParams(); - // const token = searchParams.get("token"); const newPath = "/sandbox/onboarding"; - const callbackPath = "/customers"; + const callbackPath = "/sandbox/products?tab=products"; useEffect(() => { - if (org?.onboarded) { - navigate(callbackPath); + if (org) { + if (org.deployed) { + navigate("/products?tab=products"); + } else if (org.onboarded) { + navigate("/sandbox/products?tab=products"); + } else { + navigate("/sandbox/onboarding"); + } } }, [org, navigate]); diff --git a/vite/src/views/command-bar/CommandBar.tsx b/vite/src/views/command-bar/CommandBar.tsx index 83f895238..e49ce08a3 100644 --- a/vite/src/views/command-bar/CommandBar.tsx +++ b/vite/src/views/command-bar/CommandBar.tsx @@ -20,6 +20,7 @@ import { CommandList, } from "@/components/ui/command"; import { Skeleton } from "@/components/ui/skeleton"; +import { useOrg } from "@/hooks/common/useOrg"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useListOrganizations } from "@/lib/auth-client"; import { useAxiosInstance } from "@/services/useAxiosInstance"; @@ -69,6 +70,7 @@ const CommandBar = () => { const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations(); const axiosInstance = useAxiosInstance(); const { isAdmin } = useAdmin(); + const { org } = useOrg(); // Improved close dialog function with proper timing const closeDialog = useCallback(() => { @@ -320,18 +322,22 @@ const CommandBar = () => { closeDialog(); }, }, - { - title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`, - icon: , - shortcutKey: "4", - onSelect: () => { - handleEnvChange( - env === AppEnv.Sandbox ? AppEnv.Live : AppEnv.Sandbox, - true, - ); - closeDialog(); - }, - }, + ...(org?.deployed + ? [ + { + title: `Go to ${env === AppEnv.Sandbox ? "Production" : "Sandbox"}`, + icon: , + shortcutKey: "4", + onSelect: () => { + handleEnvChange( + env === AppEnv.Sandbox ? AppEnv.Live : AppEnv.Sandbox, + true, + ); + closeDialog(); + }, + }, + ] + : []), ...(!isLoadingOrgs && orgs && orgs.length > 1 ? [ { diff --git a/vite/src/views/customers/hooks/useCusSearchQuery.tsx b/vite/src/views/customers/hooks/useCusSearchQuery.tsx index 2c7e5dabb..94a0037b4 100644 --- a/vite/src/views/customers/hooks/useCusSearchQuery.tsx +++ b/vite/src/views/customers/hooks/useCusSearchQuery.tsx @@ -1,19 +1,8 @@ -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { useCustomersQueryStates } from "./useCustomersQueryStates"; -import { - CusProductSchema, - CustomerSchema, - ProductSchema, -} from "@autumn/shared"; -import { z } from "zod"; +import type { CustomerWithProducts } from "@autumn/shared"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; -const CustomerWithProductsSchema = CustomerSchema.extend({ - customer_products: z.array( - CusProductSchema.extend({ product: ProductSchema }), - ), -}); -type CustomerWithProducts = z.infer; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useCustomersQueryStates } from "./useCustomersQueryStates"; export const useCusSearchQuery = () => { const { queryStates } = useCustomersQueryStates(); @@ -72,22 +61,3 @@ export const useCusSearchQuery = () => { isFetchingUncached, }; }; - -// const { data, isLoading, error, mutate } = useAxiosPostSWR({ -// url: `/v1/customers/all/search`, -// env, -// data: { -// search: queryStates.q || "", -// filters: { -// status: queryStates.status, -// product_id: queryStates.product_id, -// version: queryStates.version, -// none: queryStates.none, -// }, -// page: queryStates.page, -// page_size: pageSize, -// last_item: queryStates.lastItemId -// ? { internal_id: queryStates.lastItemId } -// : null, -// }, -// }); diff --git a/vite/src/views/customers/hooks/useCustomersQueryStates.tsx b/vite/src/views/customers/hooks/useCustomersQueryStates.tsx index 2db028a1d..10dba90ae 100644 --- a/vite/src/views/customers/hooks/useCustomersQueryStates.tsx +++ b/vite/src/views/customers/hooks/useCustomersQueryStates.tsx @@ -1,15 +1,12 @@ +import { debounce } from "lodash"; import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, + useQueryStates, } from "nuqs"; - -import { useQueryStates } from "nuqs"; -import { useLocation } from "react-router"; -import { useCallback, useEffect, useState } from "react"; -import { debounce } from "lodash"; - +import { useEffect, useState } from "react"; export const useCustomersQueryStates = () => { const [queryStates, setQueryStates] = useQueryStates( { @@ -25,6 +22,8 @@ export const useCustomersQueryStates = () => { }, ); + // return { queryStates, setQueryStates }; + const [stableStates, setStableStates] = useState(queryStates); useEffect(() => { diff --git a/vite/src/views/general/CloseScreen.tsx b/vite/src/views/general/CloseScreen.tsx new file mode 100644 index 000000000..541600508 --- /dev/null +++ b/vite/src/views/general/CloseScreen.tsx @@ -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 = ` +
+

✓ Connection successful!

+

You can close this window now.

+
+ `; + } + }, 1000); + + return () => clearTimeout(timeout); + }, []); + + return ( +
+ +
+ ); +}; diff --git a/vite/src/views/main-sidebar/MainSidebar.tsx b/vite/src/views/main-sidebar/MainSidebar.tsx index 9686c751e..db1056846 100644 --- a/vite/src/views/main-sidebar/MainSidebar.tsx +++ b/vite/src/views/main-sidebar/MainSidebar.tsx @@ -9,8 +9,10 @@ import { useHotkeys } from "react-hotkeys-hook"; import { Button } from "@/components/ui/button"; import { useAutumnFlags } from "@/hooks/common/useAutumnFlags"; import { useLocalStorage } from "@/hooks/common/useLocalStorage"; +import { useOrg } from "@/hooks/common/useOrg"; import { cn } from "@/lib/utils"; import { useEnv } from "@/utils/envUtils"; +import { DeployToProdButton } from "./components/deploy-button/DeployToProdButton"; import { OrgDropdown } from "./components/OrgDropdown"; import { EnvDropdown } from "./EnvDropdown"; import { NavButton } from "./NavButton"; @@ -20,6 +22,7 @@ import { SidebarGroup } from "./SidebarGroup"; export const MainSidebar = () => { const env = useEnv(); + const { org } = useOrg(); const { webhooks } = useAutumnFlags(); @@ -74,7 +77,11 @@ export const MainSidebar = () => { - + {org?.deployed ? ( + + ) : ( + + )}
{ ); }; - -{ - /*
- {expanded && ( -
- - - -
- )} -
*/ -} diff --git a/vite/src/views/main-sidebar/components/OrgDropdown.tsx b/vite/src/views/main-sidebar/components/OrgDropdown.tsx index 645316645..69350636c 100644 --- a/vite/src/views/main-sidebar/components/OrgDropdown.tsx +++ b/vite/src/views/main-sidebar/components/OrgDropdown.tsx @@ -36,7 +36,7 @@ export const OrgDropdown = () => { const { org, isLoading, error } = useOrg(); const { expanded, setExpanded } = useSidebarContext(); - let { data: orgs, isPending } = useListOrganizations(); + let { data: orgs } = useListOrganizations(); const { data: activeOrganization } = authClient.useActiveOrganization(); // Exclude the active organization from the orgs list (this makes it easier for users to understand which org is active) @@ -50,12 +50,6 @@ export const OrgDropdown = () => { const { data: session } = useSession(); - // //remove the currect active org from the orgs data - // const inactiveOrgs = useMemo(() => { - // if (!orgs || !org) return []; - // return orgs.filter((orgItem: any) => orgItem.id !== org.id); - // }, [org, orgs]); - // To pre-fetch data useMemberships(); const [dropdownOpen, setDropdownOpen] = useState(false); diff --git a/vite/src/views/main-sidebar/components/deploy-button/DeployToProdButton.tsx b/vite/src/views/main-sidebar/components/deploy-button/DeployToProdButton.tsx new file mode 100644 index 000000000..709680c50 --- /dev/null +++ b/vite/src/views/main-sidebar/components/deploy-button/DeployToProdButton.tsx @@ -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 ( + <> +
+ {expanded && ( + } + iconOrientation="right" + onClick={() => setShowDeployDialog(true)} + className="w-fit" + > + Go to Production + + )} +
+ + + + ); +}; diff --git a/vite/src/views/main-sidebar/components/deploy-button/DeployToProdDialog.tsx b/vite/src/views/main-sidebar/components/deploy-button/DeployToProdDialog.tsx new file mode 100644 index 000000000..0eafdeb73 --- /dev/null +++ b/vite/src/views/main-sidebar/components/deploy-button/DeployToProdDialog.tsx @@ -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 ( + + + + Deploy to Production + + If you've set up your products and integrated Autumn into your + codebase, you can follow the steps below to deploy to Production + + + +
+ + + +
+ + + + + } + iconOrientation="right" + onClick={handleGoToProduction} + isLoading={loading} + > + Go to Production + + + + 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. + + + +
+
+ ); +}; diff --git a/vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx b/vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx new file mode 100644 index 000000000..7ece4702a --- /dev/null +++ b/vite/src/views/main-sidebar/deploy-dialog/Step1ConnectStripe.tsx @@ -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 ( +
+
+ +
+ +
+ {isStripeConnected ? ( +
+ } + className="!opacity-100" + > + Stripe Connected + +
+ ) : ( +
+ +
+ )} +
+
+ ); +}; diff --git a/vite/src/views/main-sidebar/deploy-dialog/Step2CopyProducts.tsx b/vite/src/views/main-sidebar/deploy-dialog/Step2CopyProducts.tsx new file mode 100644 index 000000000..a1c9a536c --- /dev/null +++ b/vite/src/views/main-sidebar/deploy-dialog/Step2CopyProducts.tsx @@ -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 ( +
+
+ +
+ +
+ {isCopied ? ( + } + className="!opacity-100" + > + Copied Products + + ) : ( +
+ +
+ )} +
+
+ ); +}; diff --git a/vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx b/vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx new file mode 100644 index 000000000..f7ffad254 --- /dev/null +++ b/vite/src/views/main-sidebar/deploy-dialog/Step3CreateApiKey.tsx @@ -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 ( +
+ + +
+ + {apiKey ? ( + +
+

{apiKey}

+ +
+

+ You won't be able to view this key anymore after closing the + dialog. +

+
+ ) : ( + + + + )} +
+
+
+ ); +}; diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx index afd06e3e7..544cecfdf 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx @@ -30,8 +30,6 @@ export const PlanCardToolbar = ({ const isEditingPlan = useIsEditingPlan(); const navigate = useNavigate(); - console.log("deleteOpen", deleteOpen); - return ( <>