mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(licenses): update database schema and enhance license management
- Refactored migration script to import schema from the correct path. - Updated package.json scripts for improved execution of migration and truncation tasks. - Added new SQL file to define license-related types and table structure. - Enhanced license management with new fields for Stripe customer and subscription IDs. - Implemented license deactivation logic and improved error handling in license validation. - Introduced health check endpoint for database connectivity verification.
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
CREATE TYPE "public"."billing_type" AS ENUM('monthly', 'annual');--> statement-breakpoint
|
CREATE TYPE "public"."billing_type" AS ENUM('monthly', 'annual');--> statement-breakpoint
|
||||||
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled');--> statement-breakpoint
|
CREATE TYPE "public"."license_status" AS ENUM('active', 'expired', 'cancelled', 'payment_pending');--> statement-breakpoint
|
||||||
CREATE TYPE "public"."license_type" AS ENUM('basic', 'premium', 'business');--> statement-breakpoint
|
CREATE TYPE "public"."license_type" AS ENUM('basic', 'premium', 'business');--> statement-breakpoint
|
||||||
CREATE TABLE "licenses" (
|
CREATE TABLE "licenses" (
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
"customer_id" text NOT NULL,
|
|
||||||
"product_id" text NOT NULL,
|
"product_id" text NOT NULL,
|
||||||
"license_key" text NOT NULL,
|
"license_key" text NOT NULL,
|
||||||
"status" "license_status" DEFAULT 'active' NOT NULL,
|
"status" "license_status" DEFAULT 'active' NOT NULL,
|
||||||
@@ -13,6 +12,8 @@ CREATE TABLE "licenses" (
|
|||||||
"activated_at" timestamp,
|
"activated_at" timestamp,
|
||||||
"last_verified_at" timestamp,
|
"last_verified_at" timestamp,
|
||||||
"expires_at" timestamp NOT NULL,
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"stripeCustomerId" text NOT NULL,
|
||||||
|
"stripeSubscriptionId" text NOT NULL,
|
||||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
"metadata" text,
|
"metadata" text,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "41745f43-6627-49f6-afa3-ab192559b5a7",
|
"id": "5a996744-b11f-4f1a-b4b0-91f6bf5c2bed",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -15,12 +15,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "gen_random_uuid()"
|
"default": "gen_random_uuid()"
|
||||||
},
|
},
|
||||||
"customer_id": {
|
|
||||||
"name": "customer_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"product_id": {
|
"product_id": {
|
||||||
"name": "product_id",
|
"name": "product_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -79,6 +73,18 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"stripeCustomerId": {
|
||||||
|
"name": "stripeCustomerId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"stripeSubscriptionId": {
|
||||||
|
"name": "stripeSubscriptionId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
@@ -138,7 +144,8 @@
|
|||||||
"values": [
|
"values": [
|
||||||
"active",
|
"active",
|
||||||
"expired",
|
"expired",
|
||||||
"cancelled"
|
"cancelled",
|
||||||
|
"payment_pending"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"public.license_type": {
|
"public.license_type": {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1742364501431,
|
"when": 1742369437742,
|
||||||
"tag": "0000_furry_nico_minoru",
|
"tag": "0000_noisy_epoch",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|||||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./src/schema";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
const sql = postgres(connectionString, { max: 1 });
|
const sql = postgres(connectionString, { max: 1 });
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"drop": "drizzle-kit drop",
|
"drop": "drizzle-kit drop",
|
||||||
"migrate": "tsx migrate.ts",
|
"migrate": "tsx ./migrate.ts",
|
||||||
"truncate": "tsx truncate.ts",
|
"truncate": "tsx ./truncate.ts",
|
||||||
"reset:all": "tsx truncate.ts && tsx migrate.ts",
|
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
|
||||||
"studio": "drizzle-kit studio"
|
"studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
createLicense,
|
createLicense,
|
||||||
validateLicense,
|
validateLicense,
|
||||||
activateLicense,
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
} from "./utils/license";
|
} from "./utils/license";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { licenses } from "./schema";
|
import { licenses } from "./schema";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
|
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
|
||||||
@@ -33,6 +34,16 @@ const resendSchema = z.object({
|
|||||||
licenseKey: z.string(),
|
licenseKey: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/health", async (c) => {
|
||||||
|
try {
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Database connection error:", error);
|
||||||
|
return c.json({ status: "error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/validate", zValidator("json", validateSchema), async (c) => {
|
app.post("/validate", zValidator("json", validateSchema), async (c) => {
|
||||||
const { licenseKey, serverIp } = c.req.valid("json");
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
@@ -72,18 +83,16 @@ app.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
|||||||
return c.json({ success: false, error: "License not found" }, 404);
|
return c.json({ success: false, error: "License not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generar el email
|
|
||||||
const emailHtml = await render(
|
const emailHtml = await render(
|
||||||
ResendLicenseEmail({
|
ResendLicenseEmail({
|
||||||
customerName: license.customerId,
|
|
||||||
licenseKey: license.licenseKey,
|
licenseKey: license.licenseKey,
|
||||||
productName: `Dokploy Self Hosted ${license.type}`,
|
productName: `Dokploy Self Hosted ${license.type}`,
|
||||||
expirationDate: new Date(license.expiresAt),
|
expirationDate: new Date(license.expiresAt),
|
||||||
requestDate: new Date(),
|
requestDate: new Date(),
|
||||||
|
customerName: license.email,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enviar el email
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
from: process.env.SMTP_FROM,
|
||||||
to: license.email,
|
to: license.email,
|
||||||
@@ -136,11 +145,12 @@ app.post("/stripe/webhook", async (c) => {
|
|||||||
const { type, billingType } = getLicenseTypeFromPriceId(priceId!);
|
const { type, billingType } = getLicenseTypeFromPriceId(priceId!);
|
||||||
|
|
||||||
const license = await createLicense({
|
const license = await createLicense({
|
||||||
customerId: customerResponse.id,
|
|
||||||
productId: session.id,
|
productId: session.id,
|
||||||
type,
|
type,
|
||||||
billingType,
|
billingType,
|
||||||
email: session.customer_details?.email!,
|
email: session.customer_details?.email!,
|
||||||
|
stripeCustomerId: customerResponse.id,
|
||||||
|
stripeSubscriptionId: session.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const features = getLicenseFeatures(type);
|
const features = getLicenseFeatures(type);
|
||||||
@@ -163,6 +173,110 @@ app.post("/stripe/webhook", async (c) => {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.updated": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const customerResponse = await stripe.customers.retrieve(
|
||||||
|
subscription.customer as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscription.status !== "active" || customerResponse.deleted) {
|
||||||
|
await deactivateLicense(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_succeeded": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!invoice.subscription) break;
|
||||||
|
|
||||||
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
invoice.subscription as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const customerResponse = await stripe.customers.retrieve(
|
||||||
|
invoice.customer as string,
|
||||||
|
);
|
||||||
|
if (suscription.status !== "active" || customerResponse.deleted) break;
|
||||||
|
|
||||||
|
const existingLicense = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.stripeCustomerId, invoice.customer as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingLicense) break;
|
||||||
|
|
||||||
|
const newExpirationDate = new Date();
|
||||||
|
newExpirationDate.setMonth(
|
||||||
|
newExpirationDate.getMonth() +
|
||||||
|
(existingLicense.billingType === "annual" ? 12 : 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({
|
||||||
|
expiresAt: newExpirationDate,
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
.where(eq(licenses.id, existingLicense.id));
|
||||||
|
|
||||||
|
const features = getLicenseFeatures(existingLicense.type);
|
||||||
|
const emailHtml = await render(
|
||||||
|
LicenseEmail({
|
||||||
|
customerName: customerResponse.name || "Customer",
|
||||||
|
licenseKey: existingLicense.licenseKey,
|
||||||
|
productName: `Dokploy Self Hosted ${existingLicense.type}`,
|
||||||
|
expirationDate: new Date(newExpirationDate),
|
||||||
|
features: features,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM,
|
||||||
|
to: existingLicense.email,
|
||||||
|
subject: "Your Dokploy License Has Been Renewed",
|
||||||
|
html: emailHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_failed": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!invoice.subscription) break;
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
invoice.subscription as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscription.status !== "active") {
|
||||||
|
await deactivateLicense(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const existingLicense = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.stripeCustomerId, subscription.customer as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingLicense) break;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({
|
||||||
|
status: "cancelled",
|
||||||
|
})
|
||||||
|
.where(eq(licenses.id, existingLicense.id));
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ received: true });
|
return c.json({ received: true });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const licenseStatusEnum = pgEnum("license_status", [
|
|||||||
"active",
|
"active",
|
||||||
"expired",
|
"expired",
|
||||||
"cancelled",
|
"cancelled",
|
||||||
|
"payment_pending",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const licenseTypeEnum = pgEnum("license_type", [
|
export const licenseTypeEnum = pgEnum("license_type", [
|
||||||
@@ -17,7 +18,6 @@ export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
|
|||||||
|
|
||||||
export const licenses = pgTable("licenses", {
|
export const licenses = pgTable("licenses", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
customerId: text("customer_id").notNull(),
|
|
||||||
productId: text("product_id").notNull(),
|
productId: text("product_id").notNull(),
|
||||||
licenseKey: text("license_key").notNull().unique(),
|
licenseKey: text("license_key").notNull().unique(),
|
||||||
status: licenseStatusEnum("status").notNull().default("active"),
|
status: licenseStatusEnum("status").notNull().default("active"),
|
||||||
@@ -27,6 +27,8 @@ export const licenses = pgTable("licenses", {
|
|||||||
activatedAt: timestamp("activated_at"),
|
activatedAt: timestamp("activated_at"),
|
||||||
lastVerifiedAt: timestamp("last_verified_at"),
|
lastVerifiedAt: timestamp("last_verified_at"),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
stripeCustomerId: text("stripeCustomerId").notNull(),
|
||||||
|
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
|
||||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { licenses } from "../schema";
|
import { type License, licenses } from "../schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const generateLicenseKey = () => {
|
export const generateLicenseKey = () => {
|
||||||
return randomBytes(32).toString("hex");
|
return randomBytes(32).toString("hex");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createLicense = async ({
|
interface CreateLicenseProps {
|
||||||
customerId,
|
|
||||||
productId,
|
|
||||||
type,
|
|
||||||
billingType,
|
|
||||||
email,
|
|
||||||
}: {
|
|
||||||
customerId: string;
|
|
||||||
productId: string;
|
productId: string;
|
||||||
type: "basic" | "premium" | "business";
|
type: "basic" | "premium" | "business";
|
||||||
billingType: "monthly" | "annual";
|
billingType: "monthly" | "annual";
|
||||||
email: string;
|
email: string;
|
||||||
}) => {
|
stripeCustomerId: string;
|
||||||
|
stripeSubscriptionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createLicense = async ({
|
||||||
|
productId,
|
||||||
|
type,
|
||||||
|
billingType,
|
||||||
|
email,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
}: CreateLicenseProps) => {
|
||||||
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setMonth(
|
expiresAt.setMonth(
|
||||||
@@ -29,13 +33,14 @@ export const createLicense = async ({
|
|||||||
const license = await db
|
const license = await db
|
||||||
.insert(licenses)
|
.insert(licenses)
|
||||||
.values({
|
.values({
|
||||||
customerId,
|
|
||||||
productId,
|
productId,
|
||||||
licenseKey,
|
licenseKey,
|
||||||
type,
|
type,
|
||||||
billingType,
|
billingType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
email,
|
email,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -55,7 +60,10 @@ export const validateLicense = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (license.status !== "active") {
|
if (license.status !== "active") {
|
||||||
return { isValid: false, error: "License is not active" };
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: `License is ${getLicenseStatus(license)}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date() > license.expiresAt) {
|
if (new Date() > license.expiresAt) {
|
||||||
@@ -117,3 +125,36 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
|
|
||||||
return updatedLicense[0];
|
return updatedLicense[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deactivateLicense = async (stripeSubscriptionId: string) => {
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.stripeSubscriptionId, stripeSubscriptionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new Error("License not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({ status: "cancelled" })
|
||||||
|
.where(eq(licenses.id, license.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLicenseStatus = (license: License) => {
|
||||||
|
if (license.status === "active") {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "expired") {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "cancelled") {
|
||||||
|
return "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "payment_pending") {
|
||||||
|
return "pending payment";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user