Rename tiers and get working

This commit is contained in:
Owen
2026-02-08 17:55:26 -08:00
parent c41e8be3e8
commit 81ef2db7f8
36 changed files with 326 additions and 175 deletions

View File

@@ -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

14
drizzle.config.ts Normal file
View File

@@ -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
}
});

View File

@@ -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, {

View File

@@ -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", {

View File

@@ -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<string> {
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<Record<FeatureId, string>> = { // 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<Record<FeatureId, string>>;
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<Stripe.Checkout.SessionCreateParams.LineItem[]> {
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;
}

View File

@@ -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"

View File

@@ -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<Usage | null> {
if (noop()) {
return null;
}
await this.updateDaily(orgId, featureId); // Ensure daily usage is updated
return this.getUsage(orgId, featureId);
}
public async forceUpload(): Promise<void> {
if (this.events.length > 0) {
// Force upload regardless of time

View File

@@ -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 {

View File

@@ -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;

View File

@@ -22,7 +22,7 @@ export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
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;

View File

@@ -17,7 +17,7 @@ import { getOrgTierData } from "#private/lib/billing";
export async function isSubscribed(orgId: string): Promise<boolean> {
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;

View File

@@ -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,

View File

@@ -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}`

View File

@@ -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(

View File

@@ -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
);

View File

@@ -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<Stripe.Subscription>): SubscriptionType | null {
// Determine subscription type by checking subscription items
@@ -41,21 +41,21 @@ export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription
// Check if price ID matches home lab tier
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
if (homeLabPrices.includes(priceId)) {
return "home_lab";
return "tier1";
}
// Check if price ID matches starter tier
const starterPrices = Object.values(getStarterFeaturePriceSet());
if (starterPrices.includes(priceId)) {
return "starter";
// Check if price ID matches tier2 tier
const tier2Prices = Object.values(getStarterFeaturePriceSet());
if (tier2Prices.includes(priceId)) {
return "tier2";
}
// Check if price ID matches scale tier
const scalePrices = Object.values(getScaleFeaturePriceSet());
if (scalePrices.includes(priceId)) {
return "scale";
// Check if price ID matches tier3 tier
const tier3Prices = Object.values(getScaleFeaturePriceSet());
if (tier3Prices.includes(priceId)) {
return "tier3";
}
}
return null;
}
}

View File

@@ -91,6 +91,7 @@ export async function handleSubscriptionCreated(
}
return {
stripeSubscriptionItemId: item.id,
subscriptionId: subscription.id,
planId: item.plan.id,
priceId: item.price.id,
@@ -132,7 +133,7 @@ export async function handleSubscriptionCreated(
return;
}
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}`
);

View File

@@ -76,7 +76,7 @@ export async function handleSubscriptionDeleted(
}
const type = getSubType(fullSubscription);
if (type == "home_lab" || type == "starter" || type == "scale") {
if (type == "tier1" || type == "tier2" || type == "tier3") {
logger.debug(
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);

View File

@@ -82,6 +82,7 @@ export async function handleSubscriptionUpdated(
// Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
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}`
);

View File

@@ -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;
}

View File

@@ -224,7 +224,7 @@ export async function createRemoteExitNode(
});
if (numExitNodeOrgs) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length

View File

@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
});
if (numExitNodeOrgs) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.REMOTE_EXIT_NODES,
numExitNodeOrgs.length

View File

@@ -354,7 +354,7 @@ export async function createOrgDomain(
});
if (numOrgDomains) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.DOMAINS,
numOrgDomains.length

View File

@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
});
if (numOrgDomains) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.DOMAINS,
numOrgDomains.length

View File

@@ -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

View File

@@ -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);

View File

@@ -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,

View File

@@ -440,7 +440,7 @@ export async function createSite(
});
if (numSites) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.SITES,
numSites.length

View File

@@ -110,7 +110,7 @@ export async function deleteSite(
});
if (numSites) {
await usageService.updateDaily(
await usageService.updateCount(
site.orgId,
FeatureId.SITES,
numSites.length

View File

@@ -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

View File

@@ -254,7 +254,7 @@ export async function createOrgUser(
});
if (orgUsers) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.USERS,
orgUsers.length

View File

@@ -140,7 +140,7 @@ export async function removeUserOrg(
});
if (userCount) {
await usageService.updateDaily(
await usageService.updateCount(
orgId,
FeatureId.USERS,
userCount.length

162
server/setup/migrations.ts Normal file
View File

@@ -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<void>;
}[];
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;
}
}

View File

@@ -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;
};

View File

@@ -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 {}
}

View File

@@ -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
);
};