diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 764c0915..9e6b2c5a 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - volumes: - - ./config/postgres:/var/lib/postgresql/data + # volumes: + # - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..ba4ca8fe --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; +import path from "path"; + +const schema = [path.join("server", "db", "pg", "schema")]; + +export default defineConfig({ + dialect: "postgresql", + schema: schema, + out: path.join("server", "migrations"), + verbose: true, + dbCredentials: { + url: process.env.DATABASE_URL as string + } +}); diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 2ebb145b..de5bb1ca 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -82,12 +82,14 @@ export const subscriptions = pgTable("subscriptions", { canceledAt: bigint("canceledAt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }), + version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), - type: varchar("type", { length: 50 }) // home_lab, starter, scale, or license + type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); export const subscriptionItems = pgTable("subscriptionItems", { subscriptionItemId: serial("subscriptionItemId").primaryKey(), + stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }), subscriptionId: varchar("subscriptionId", { length: 255 }) .notNull() .references(() => subscriptions.subscriptionId, { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 27979460..1fa8654b 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -70,8 +70,9 @@ export const subscriptions = sqliteTable("subscriptions", { canceledAt: integer("canceledAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), + version: integer("version"), billingCycleAnchor: integer("billingCycleAnchor"), - type: text("type") // home_lab, starter, scale, or license + type: text("type") // tier1, tier2, tier3, or license }); export const subscriptionItems = sqliteTable("subscriptionItems", { diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index b2eb2f0a..a9b652a9 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -7,9 +7,29 @@ export enum FeatureId { EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", - HOME_LAB = "home_lab" + TIER1 = "tier1" } +export async function getFeatureDisplayName(featureId: FeatureId): Promise { + switch (featureId) { + case FeatureId.USERS: + return "Users"; + case FeatureId.SITES: + return "Sites"; + case FeatureId.EGRESS_DATA_MB: + return "Egress Data (MB)"; + case FeatureId.DOMAINS: + return "Domains"; + case FeatureId.REMOTE_EXIT_NODES: + return "Remote Exit Nodes"; + case FeatureId.TIER1: + return "Home Lab"; + default: + return featureId; + } +} + +// this is from the old system export const FeatureMeterIds: Partial> = { // right now we are not charging for any data // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; @@ -40,11 +60,11 @@ export function getFeatureIdByMetricId( export type FeaturePriceSet = Partial>; export const homeLabFeaturePriceSet: FeaturePriceSet = { - [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.HOME_LAB]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export function getHomeLabFeaturePriceSet(): FeaturePriceSet { @@ -58,11 +78,11 @@ export function getHomeLabFeaturePriceSet(): FeaturePriceSet { } } -export const starterFeaturePriceSet: FeaturePriceSet = { +export const tier2FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; -export const starterFeaturePriceSetSandbox: FeaturePriceSet = { +export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; @@ -71,17 +91,17 @@ export function getStarterFeaturePriceSet(): FeaturePriceSet { process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return starterFeaturePriceSet; + return tier2FeaturePriceSet; } else { - return starterFeaturePriceSetSandbox; + return tier2FeaturePriceSetSandbox; } } -export const scaleFeaturePriceSet: FeaturePriceSet = { +export const tier3FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; -export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { +export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; @@ -90,9 +110,9 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet { process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return scaleFeaturePriceSet; + return tier3FeaturePriceSet; } else { - return scaleFeaturePriceSetSandbox; + return tier3FeaturePriceSetSandbox; } } @@ -100,14 +120,14 @@ export async function getLineItems( featurePriceSet: FeaturePriceSet, orgId: string, ): Promise { - const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); + const users = await usageService.getUsage(orgId, FeatureId.USERS); return Object.entries(featurePriceSet).map(([featureId, priceId]) => { let quantity: number | undefined; if (featureId === FeatureId.USERS) { quantity = users?.instantaneousValue || 1; - } else if (featureId === FeatureId.HOME_LAB) { + } else if (featureId === FeatureId.TIER1) { quantity = 1; } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index a95b607f..47dbe1b8 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -37,7 +37,7 @@ export const homeLabLimitSet: LimitSet = { [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home lab limit" } }; -export const starterLimitSet: LimitSet = { +export const tier2LimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, description: "Starter limit" @@ -60,7 +60,7 @@ export const starterLimitSet: LimitSet = { } }; -export const scaleLimitSet: LimitSet = { +export const tier3LimitSet: LimitSet = { [FeatureId.SITES]: { value: 10, description: "Scale limit" diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 2a50737b..50519a68 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -50,7 +50,8 @@ export class UsageService { this.bucketName = process.env.S3_BUCKET || undefined; - if ( // Only set up event uploading if usage reporting is enabled and bucket name is configured + if ( + // Only set up event uploading if usage reporting is enabled and bucket name is configured privateConfig.getRawPrivateConfig().flags.usage_reporting && this.bucketName ) { @@ -220,7 +221,7 @@ export class UsageService { return new Date(date * 1000).toISOString().split("T")[0]; } - async updateDaily( + async updateCount( orgId: string, featureId: FeatureId, value?: number, @@ -246,8 +247,6 @@ export class UsageService { value = this.truncateValue(value); } - const today = this.getTodayDateString(); - let currentUsage: Usage | null = null; await db.transaction(async (trx) => { @@ -261,57 +260,23 @@ export class UsageService { .limit(1); if (currentUsage) { - const lastUpdateDate = this.getDateString( - currentUsage.updatedAt - ); - const currentRunningTotal = currentUsage.latestValue; - const lastDailyValue = currentUsage.instantaneousValue || 0; - - if (value == undefined || value === null) { - value = currentUsage.instantaneousValue || 0; - } - - if (lastUpdateDate === today) { - // Same day update: replace the daily value - // Remove old daily value from running total, add new value - const newRunningTotal = this.truncateValue( - currentRunningTotal - lastDailyValue + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } else { - // New day: add to running total - const newRunningTotal = this.truncateValue( - currentRunningTotal + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } + await trx + .update(usage) + .set({ + instantaneousValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .where(eq(usage.usageId, usageId)); } else { // First record for this meter const meterId = getFeatureMeterId(featureId); - const truncatedValue = this.truncateValue(value || 0); await trx.insert(usage).values({ usageId, featureId, orgId, meterId, - instantaneousValue: truncatedValue, - latestValue: truncatedValue, + instantaneousValue: value || 0, + latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }); } @@ -322,7 +287,7 @@ export class UsageService { } } catch (error) { logger.error( - `Failed to update daily usage for ${orgId}/${featureId}:`, + `Failed to update count usage for ${orgId}/${featureId}:`, error ); } @@ -542,17 +507,6 @@ export class UsageService { } } - public async getUsageDaily( - orgId: string, - featureId: FeatureId - ): Promise { - if (noop()) { - return null; - } - await this.updateDaily(orgId, featureId); // Ensure daily usage is updated - return this.getUsage(orgId, featureId); - } - public async forceUpload(): Promise { if (this.events.length > 0) { // Force upload regardless of time diff --git a/server/lib/createUserAccountOrg.ts b/server/lib/createUserAccountOrg.ts index 11f4e247..53f2ea3d 100644 --- a/server/lib/createUserAccountOrg.ts +++ b/server/lib/createUserAccountOrg.ts @@ -182,7 +182,7 @@ export async function createUserAccountOrg( const customerId = await createCustomer(orgId, userEmail); if (customerId) { - await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org + await usageService.updateCount(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org } return { diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index 174148f6..89734fcd 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -17,8 +17,8 @@ import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: "home_lab" | "starter" | "scale" | null; active: boolean }> { - let tier: "home_lab" | "starter" | "scale" | null = null; +): Promise<{ tier: "tier1" | "tier2" | "tier3" | null; active: boolean }> { + let tier: "tier1" | "tier2" | "tier3" | null = null; let active = false; if (build !== "saas") { @@ -50,9 +50,9 @@ export async function getOrgTierData( if (subscription) { // Validate that subscription.type is one of the expected tier values if ( - subscription.type === "home_lab" || - subscription.type === "starter" || - subscription.type === "scale" + subscription.type === "tier1" || + subscription.type === "tier2" || + subscription.type === "tier3" ) { tier = subscription.type; active = true; diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts index 2e8c04fa..3b2f6592 100644 --- a/server/private/lib/isLicencedOrSubscribed.ts +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -22,7 +22,7 @@ export async function isLicensedOrSubscribed(orgId: string): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); - return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; + return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; } return false; diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts index 9ff71bca..12bbb965 100644 --- a/server/private/lib/isSubscribed.ts +++ b/server/private/lib/isSubscribed.ts @@ -17,7 +17,7 @@ import { getOrgTierData } from "#private/lib/billing"; export async function isSubscribed(orgId: string): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); - return (tier == "home_lab" || tier == "starter" || tier == "scale") && active; + return (tier == "tier1" || tier == "tier2" || tier == "tier3") && active; } return false; diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index f1b7a0ce..fec9241a 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -39,7 +39,7 @@ export async function verifyValidSubscription( } const { tier, active } = await getOrgTierData(orgId); - if ((tier == "home_lab" || tier == "starter" || tier == "scale") && active) { + if ((tier == "tier1" || tier == "tier2" || tier == "tier3") && active) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index 0d966346..a33a9164 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -35,7 +35,7 @@ const changeTierSchema = z.strictObject({ }); const changeTierBodySchema = z.strictObject({ - tier: z.enum(["home_lab", "starter", "scale"]) + tier: z.enum(["tier1", "tier2", "tier3"]) }); export async function changeTier( @@ -93,9 +93,9 @@ export async function changeTier( eq(subscriptions.customerId, customer.customerId), eq(subscriptions.status, "active"), or( - eq(subscriptions.type, "home_lab"), - eq(subscriptions.type, "starter"), - eq(subscriptions.type, "scale") + eq(subscriptions.type, "tier1"), + eq(subscriptions.type, "tier2"), + eq(subscriptions.type, "tier3") ) ) ) @@ -112,11 +112,11 @@ export async function changeTier( // Get the target tier's price set let targetPriceSet: FeaturePriceSet; - if (tier === "home_lab") { + if (tier === "tier1") { targetPriceSet = getHomeLabFeaturePriceSet(); - } else if (tier === "starter") { + } else if (tier === "tier2") { targetPriceSet = getStarterFeaturePriceSet(); - } else if (tier === "scale") { + } else if (tier === "tier3") { targetPriceSet = getScaleFeaturePriceSet(); } else { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); @@ -148,11 +148,11 @@ export async function changeTier( ); // Determine if we're switching between different products - // home_lab uses HOME_LAB product, starter/scale use USERS product + // tier1 uses TIER1 product, tier2/tier3 use USERS product const currentTier = subscription.type; const switchingProducts = - (currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || - ((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); + (currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) || + ((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1"); let updatedSubscription; @@ -189,7 +189,7 @@ export async function changeTier( } ); } else { - // Same product, different price tier (starter <-> scale) + // Same product, different price tier (tier2 <-> tier3) // We can simply update the price logger.info( `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index 1a1c5c41..67eaa37e 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -31,7 +31,7 @@ const createCheckoutSessionSchema = z.strictObject({ }); const createCheckoutSessionBodySchema = z.strictObject({ - tier: z.enum(["home_lab", "starter", "scale"]), + tier: z.enum(["tier1", "tier2", "tier3"]), }); export async function createCheckoutSession( @@ -83,11 +83,11 @@ export async function createCheckoutSession( } let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; - if (tier === "home_lab") { + if (tier === "tier1") { lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId); - } else if (tier === "starter") { + } else if (tier === "tier2") { lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId); - } else if (tier === "scale") { + } else if (tier === "tier3") { lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId); } else { return next( diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 9d65e98b..cf4e7585 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -78,16 +78,10 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const sites = await usageService.getUsage( - orgId, - FeatureId.SITES - ); - const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); - const domains = await usageService.getUsageDaily( - orgId, - FeatureId.DOMAINS - ); - const remoteExitNodes = await usageService.getUsageDaily( + const sites = await usageService.getUsage(orgId, FeatureId.SITES); + const users = await usageService.getUsage(orgId, FeatureId.USERS); + const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const remoteExitNodes = await usageService.getUsage( orgId, FeatureId.REMOTE_EXIT_NODES ); diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts index 3618747d..fbaab23c 100644 --- a/server/private/routers/billing/hooks/getSubType.ts +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -21,7 +21,7 @@ import { } from "@server/lib/billing/features"; import Stripe from "stripe"; -export type SubscriptionType = "home_lab" | "starter" | "scale" | "license"; +export type SubscriptionType = "tier1" | "tier2" | "tier3" | "license"; export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { // Determine subscription type by checking subscription items @@ -41,21 +41,21 @@ export function getSubType(fullSubscription: Stripe.Response ({ + stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, @@ -237,7 +238,7 @@ export async function handleSubscriptionUpdated( } // --- end usage update --- - if (type === "home_lab" || type === "starter" || type === "scale") { + if (type === "tier1" || type === "tier2" || type === "tier3") { logger.debug( `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 73a58748..6ac0fe2b 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -14,8 +14,8 @@ import { freeLimitSet, homeLabLimitSet, - starterLimitSet, - scaleLimitSet, + tier2LimitSet, + tier3LimitSet, limitsService, LimitSet } from "@server/lib/billing"; @@ -24,16 +24,16 @@ import { SubscriptionType } from "./hooks/getSubType"; function getLimitSetForSubscriptionType(subType: SubscriptionType | null): LimitSet { switch (subType) { - case "home_lab": + case "tier1": return homeLabLimitSet; - case "starter": - return starterLimitSet; - case "scale": - return scaleLimitSet; + case "tier2": + return tier2LimitSet; + case "tier3": + return tier3LimitSet; case "license": - // License subscriptions use starter limits by default + // License subscriptions use tier2 limits by default // This can be adjusted based on your business logic - return starterLimitSet; + return tier2LimitSet; default: return freeLimitSet; } diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index ed7b82a6..ba09d8e5 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -224,7 +224,7 @@ export async function createRemoteExitNode( }); if (numExitNodeOrgs) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.REMOTE_EXIT_NODES, numExitNodeOrgs.length diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index a23363fc..8337f05d 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -106,7 +106,7 @@ export async function deleteRemoteExitNode( }); if (numExitNodeOrgs) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.REMOTE_EXIT_NODES, numExitNodeOrgs.length diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 6558d748..0bd18f41 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -354,7 +354,7 @@ export async function createOrgDomain( }); if (numOrgDomains) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.DOMAINS, numOrgDomains.length diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index fa916beb..04829a13 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -86,7 +86,7 @@ export async function deleteAccountDomain( }); if (numOrgDomains) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.DOMAINS, numOrgDomains.length diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e87fe3ce..22c49f42 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -587,7 +587,7 @@ export async function validateOidcCallback( }); for (const orgCount of orgUserCounts) { - await usageService.updateDaily( + await usageService.updateCount( orgCount.orgId, FeatureId.USERS, orgCount.userCount diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 3a018fdc..4355b98d 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -107,7 +107,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { FeatureId.EGRESS_DATA_MB ); - // Do we need to check the users and domains daily limits here? + // Do we need to check the users and domains count limits here? // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index e93af889..29468ca1 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -271,7 +271,7 @@ export async function createOrg( // make sure we have the stripe customer const customerId = await createCustomer(orgId, req.user?.email); if (customerId) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, 1, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index d9837f30..e150ddec 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -440,7 +440,7 @@ export async function createSite( }); if (numSites) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.SITES, numSites.length diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 29159352..2ce900fd 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -110,7 +110,7 @@ export async function deleteSite( }); if (numSites) { - await usageService.updateDaily( + await usageService.updateCount( site.orgId, FeatureId.SITES, numSites.length diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index fd79d849..34b8be15 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -155,17 +155,19 @@ export async function acceptInvite( .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); + await calculateUserClientsForOrgs(existingUser[0].userId, trx); + // Get the total number of users in the org now - totalUsers = await db + totalUsers = await trx .select() .from(userOrgs) .where(eq(userOrgs.orgId, existingInvite.orgId)); - await calculateUserClientsForOrgs(existingUser[0].userId, trx); + logger.debug(`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}. Total users in org: ${totalUsers.length}`); }); if (totalUsers) { - await usageService.updateDaily( + await usageService.updateCount( existingInvite.orgId, FeatureId.USERS, totalUsers.length diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index c061ef27..04282ea0 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -254,7 +254,7 @@ export async function createOrgUser( }); if (orgUsers) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, orgUsers.length diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 97045e92..768d5fff 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -140,7 +140,7 @@ export async function removeUserOrg( }); if (userCount) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, userCount.length diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts new file mode 100644 index 00000000..7ae21836 --- /dev/null +++ b/server/setup/migrations.ts @@ -0,0 +1,162 @@ +#! /usr/bin/env node +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { db } from "../db/pg"; +import semver from "semver"; +import { versionMigrations } from "../db/pg"; +import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; +import path from "path"; +import m1 from "./scriptsPg/1.6.0"; +import m2 from "./scriptsPg/1.7.0"; +import m3 from "./scriptsPg/1.8.0"; +import m4 from "./scriptsPg/1.9.0"; +import m5 from "./scriptsPg/1.10.0"; +import m6 from "./scriptsPg/1.10.2"; +import m7 from "./scriptsPg/1.11.0"; +import m8 from "./scriptsPg/1.11.1"; +import m9 from "./scriptsPg/1.12.0"; +import m10 from "./scriptsPg/1.13.0"; +import m11 from "./scriptsPg/1.14.0"; +import m12 from "./scriptsPg/1.15.0"; + +// THIS CANNOT IMPORT ANYTHING FROM THE SERVER +// EXCEPT FOR THE DATABASE AND THE SCHEMA + +// Define the migration list with versions and their corresponding functions +const migrations = [ + { version: "1.6.0", run: m1 }, + { version: "1.7.0", run: m2 }, + { version: "1.8.0", run: m3 }, + { version: "1.9.0", run: m4 }, + { version: "1.10.0", run: m5 }, + { version: "1.10.2", run: m6 }, + { version: "1.11.0", run: m7 }, + { version: "1.11.1", run: m8 }, + { version: "1.12.0", run: m9 }, + { version: "1.13.0", run: m10 }, + { version: "1.14.0", run: m11 }, + { version: "1.15.0", run: m12 } + // Add new migrations here as they are created +] as { + version: string; + run: () => Promise; +}[]; + +await run(); + +async function run() { + // run the migrations + await runMigrations(); +} + +export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } + try { + const appVersion = APP_VERSION; + + // determine if the migrations table exists + const exists = await db + .select() + .from(versionMigrations) + .limit(1) + .execute() + .then((res) => res.length > 0) + .catch(() => false); + + if (exists) { + console.log("Migrations table exists, running scripts..."); + await executeScripts(); + } else { + console.log("Migrations table does not exist, creating it..."); + console.log("Running migrations..."); + try { + await migrate(db, { + migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build + }); + console.log("Migrations completed successfully."); + } catch (error) { + console.error("Error running migrations:", error); + } + + await db + .insert(versionMigrations) + .values({ + version: appVersion, + executedAt: Date.now() + }) + .execute(); + } + } catch (e) { + console.error("Error running migrations:", e); + await new Promise((resolve) => + setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) + ); + } +} + +async function executeScripts() { + try { + // Get the last executed version from the database + const lastExecuted = await db.select().from(versionMigrations); + + // Filter and sort migrations + const pendingMigrations = lastExecuted + .map((m) => m) + .sort((a, b) => semver.compare(b.version, a.version)); + const startVersion = pendingMigrations[0]?.version ?? "0.0.0"; + console.log(`Starting migrations from version ${startVersion}`); + + const migrationsToRun = migrations.filter((migration) => + semver.gt(migration.version, startVersion) + ); + + console.log( + "Migrations to run:", + migrationsToRun.map((m) => m.version).join(", ") + ); + + // Run migrations in order + for (const migration of migrationsToRun) { + console.log(`Running migration ${migration.version}`); + + try { + await migration.run(); + + // Update version in database + await db + .insert(versionMigrations) + .values({ + version: migration.version, + executedAt: Date.now() + }) + .execute(); + + console.log( + `Successfully completed migration ${migration.version}` + ); + } catch (e) { + if ( + e instanceof Error && + typeof (e as any).code === "string" && + (e as any).code === "23505" + ) { + console.error("Migration has already run! Skipping..."); + continue; // or return, depending on context + } + + console.error( + `Failed to run migration ${migration.version}:`, + e + ); + throw e; + } + } + + console.log("All migrations completed successfully"); + } catch (error) { + console.error("Migration process failed:", error); + throw error; + } +} diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 803e8199..d0002cba 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -58,7 +58,7 @@ interface PlanOption { name: string; price: string; priceDetail?: string; - tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types + tierType: "tier1" | "tier2" | "tier3" | null; // Maps to backend tier types } // Tier limits for display in confirmation dialog @@ -69,20 +69,20 @@ interface TierLimits { remoteNodes: number; } -const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = { - home_lab: { +const tierLimits: Record<"tier1" | "tier2" | "tier3", TierLimits> = { + tier1: { sites: 3, users: 3, domains: 3, remoteNodes: 1 }, - starter: { + tier2: { sites: 10, users: 150, domains: 250, remoteNodes: 5 }, - scale: { + tier3: { sites: 10, users: 150, domains: 250, @@ -102,21 +102,21 @@ const planOptions: PlanOption[] = [ name: "Homelab", price: "$15", priceDetail: "/ month", - tierType: "home_lab" + tierType: "tier1" }, { id: "team", name: "Team", price: "$5", priceDetail: "per user / month", - tierType: "starter" + tierType: "tier2" }, { id: "business", name: "Business", price: "$10", priceDetail: "per user / month", - tierType: "scale" + tierType: "tier3" }, { id: "enterprise", @@ -155,7 +155,7 @@ export default function BillingPage() { const [hasSubscription, setHasSubscription] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState< - "home_lab" | "starter" | "scale" | null + "tier1" | "tier2" | "tier3" | null >(null); // Usage IDs @@ -167,7 +167,7 @@ export default function BillingPage() { // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTier, setPendingTier] = useState<{ - tier: "home_lab" | "starter" | "scale"; + tier: "tier1" | "tier2" | "tier3"; action: "upgrade" | "downgrade"; planName: string; price: string; @@ -186,18 +186,18 @@ export default function BillingPage() { // Find tier subscription const tierSub = subscriptions.find( ({ subscription }) => - subscription?.type === "home_lab" || - subscription?.type === "starter" || - subscription?.type === "scale" + subscription?.type === "tier1" || + subscription?.type === "tier2" || + subscription?.type === "tier3" ); setTierSubscription(tierSub || null); if (tierSub?.subscription) { setCurrentTier( tierSub.subscription.type as - | "home_lab" - | "starter" - | "scale" + | "tier1" + | "tier2" + | "tier3" ); setHasSubscription( tierSub.subscription.status === "active" @@ -243,7 +243,7 @@ export default function BillingPage() { }, [org.org.orgId]); const handleStartSubscription = async ( - tier: "home_lab" | "starter" | "scale" + tier: "tier1" | "tier2" | "tier3" ) => { setIsLoading(true); try { @@ -300,7 +300,7 @@ export default function BillingPage() { } }; - const handleChangeTier = async (tier: "home_lab" | "starter" | "scale") => { + const handleChangeTier = async (tier: "tier1" | "tier2" | "tier3") => { if (!hasSubscription) { // If no subscription, start a new one handleStartSubscription(tier); @@ -343,7 +343,7 @@ export default function BillingPage() { }; const showTierConfirmation = ( - tier: "home_lab" | "starter" | "scale", + tier: "tier1" | "tier2" | "tier3", action: "upgrade" | "downgrade", planName: string, price: string @@ -453,8 +453,8 @@ export default function BillingPage() { // Calculate current usage cost for display const getUserCount = () => getUsageValue(USERS); const getPricePerUser = () => { - if (currentTier === "starter") return 5; - if (currentTier === "scale") return 10; + if (currentTier === "tier2") return 5; + if (currentTier === "tier3") return 10; return 0; }; diff --git a/src/lib/api/isOrgSubscribed.ts b/src/lib/api/isOrgSubscribed.ts index 8eb4b8e8..b57810cb 100644 --- a/src/lib/api/isOrgSubscribed.ts +++ b/src/lib/api/isOrgSubscribed.ts @@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => { try { const subRes = await getCachedSubscription(orgId); subscribed = - (subRes.data.data.tier == "home_lab" || subRes.data.data.tier == "starter" || subRes.data.data.tier == "scale") && + (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") && subRes.data.data.active; } catch {} } diff --git a/src/providers/SubscriptionStatusProvider.tsx b/src/providers/SubscriptionStatusProvider.tsx index f9d8ef8b..fad6469d 100644 --- a/src/providers/SubscriptionStatusProvider.tsx +++ b/src/providers/SubscriptionStatusProvider.tsx @@ -46,9 +46,9 @@ export function SubscriptionStatusProvider({ // Iterate through all subscriptions for (const { subscription } of subscriptionStatus.subscriptions) { if ( - subscription.type == "home_lab" || - subscription.type == "starter" || - subscription.type == "scale" + subscription.type == "tier1" || + subscription.type == "tier2" || + subscription.type == "tier3" ) { return { tier: subscription.type, @@ -70,7 +70,7 @@ export function SubscriptionStatusProvider({ } const { tier, active } = getTier(); return ( - (tier == "home_lab" || tier == "starter" || tier == "scale") && + (tier == "tier1" || tier == "tier2" || tier == "tier3") && active ); };