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"."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 TABLE "licenses" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"product_id" text NOT NULL,
|
||||
"license_key" text NOT NULL,
|
||||
"status" "license_status" DEFAULT 'active' NOT NULL,
|
||||
@@ -13,6 +12,8 @@ CREATE TABLE "licenses" (
|
||||
"activated_at" timestamp,
|
||||
"last_verified_at" timestamp,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"stripeCustomerId" text NOT NULL,
|
||||
"stripeSubscriptionId" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" text,
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "41745f43-6627-49f6-afa3-ab192559b5a7",
|
||||
"id": "5a996744-b11f-4f1a-b4b0-91f6bf5c2bed",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -15,12 +15,6 @@
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"customer_id": {
|
||||
"name": "customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"product_id": {
|
||||
"name": "product_id",
|
||||
"type": "text",
|
||||
@@ -79,6 +73,18 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"stripeSubscriptionId": {
|
||||
"name": "stripeSubscriptionId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@@ -138,7 +144,8 @@
|
||||
"values": [
|
||||
"active",
|
||||
"expired",
|
||||
"cancelled"
|
||||
"cancelled",
|
||||
"payment_pending"
|
||||
]
|
||||
},
|
||||
"public.license_type": {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1742364501431,
|
||||
"tag": "0000_furry_nico_minoru",
|
||||
"when": 1742369437742,
|
||||
"tag": "0000_noisy_epoch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
import "dotenv/config";
|
||||
import * as schema from "./schema";
|
||||
import * as schema from "./src/schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const sql = postgres(connectionString, { max: 1 });
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate": "drizzle-kit generate",
|
||||
"drop": "drizzle-kit drop",
|
||||
"migrate": "tsx migrate.ts",
|
||||
"truncate": "tsx truncate.ts",
|
||||
"reset:all": "tsx truncate.ts && tsx migrate.ts",
|
||||
"migrate": "tsx ./migrate.ts",
|
||||
"truncate": "tsx ./truncate.ts",
|
||||
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
|
||||
"studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
createLicense,
|
||||
validateLicense,
|
||||
activateLicense,
|
||||
deactivateLicense,
|
||||
} from "./utils/license";
|
||||
import { db } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { licenses } from "./schema";
|
||||
import "dotenv/config";
|
||||
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
|
||||
@@ -33,6 +34,16 @@ const resendSchema = z.object({
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// Generar el email
|
||||
const emailHtml = await render(
|
||||
ResendLicenseEmail({
|
||||
customerName: license.customerId,
|
||||
licenseKey: license.licenseKey,
|
||||
productName: `Dokploy Self Hosted ${license.type}`,
|
||||
expirationDate: new Date(license.expiresAt),
|
||||
requestDate: new Date(),
|
||||
customerName: license.email,
|
||||
}),
|
||||
);
|
||||
|
||||
// Enviar el email
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: license.email,
|
||||
@@ -136,11 +145,12 @@ app.post("/stripe/webhook", async (c) => {
|
||||
const { type, billingType } = getLicenseTypeFromPriceId(priceId!);
|
||||
|
||||
const license = await createLicense({
|
||||
customerId: customerResponse.id,
|
||||
productId: session.id,
|
||||
type,
|
||||
billingType,
|
||||
email: session.customer_details?.email!,
|
||||
stripeCustomerId: customerResponse.id,
|
||||
stripeSubscriptionId: session.id,
|
||||
});
|
||||
|
||||
const features = getLicenseFeatures(type);
|
||||
@@ -163,6 +173,110 @@ app.post("/stripe/webhook", async (c) => {
|
||||
|
||||
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 });
|
||||
|
||||
@@ -5,6 +5,7 @@ export const licenseStatusEnum = pgEnum("license_status", [
|
||||
"active",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"payment_pending",
|
||||
]);
|
||||
|
||||
export const licenseTypeEnum = pgEnum("license_type", [
|
||||
@@ -17,7 +18,6 @@ export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
|
||||
|
||||
export const licenses = pgTable("licenses", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
customerId: text("customer_id").notNull(),
|
||||
productId: text("product_id").notNull(),
|
||||
licenseKey: text("license_key").notNull().unique(),
|
||||
status: licenseStatusEnum("status").notNull().default("active"),
|
||||
@@ -27,6 +27,8 @@ export const licenses = pgTable("licenses", {
|
||||
activatedAt: timestamp("activated_at"),
|
||||
lastVerifiedAt: timestamp("last_verified_at"),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
stripeCustomerId: text("stripeCustomerId").notNull(),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||
metadata: text("metadata"),
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { db } from "../db";
|
||||
import { licenses } from "../schema";
|
||||
import { type License, licenses } from "../schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const generateLicenseKey = () => {
|
||||
return randomBytes(32).toString("hex");
|
||||
};
|
||||
|
||||
export const createLicense = async ({
|
||||
customerId,
|
||||
productId,
|
||||
type,
|
||||
billingType,
|
||||
email,
|
||||
}: {
|
||||
customerId: string;
|
||||
interface CreateLicenseProps {
|
||||
productId: string;
|
||||
type: "basic" | "premium" | "business";
|
||||
billingType: "monthly" | "annual";
|
||||
email: string;
|
||||
}) => {
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
}
|
||||
|
||||
export const createLicense = async ({
|
||||
productId,
|
||||
type,
|
||||
billingType,
|
||||
email,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
}: CreateLicenseProps) => {
|
||||
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMonth(
|
||||
@@ -29,13 +33,14 @@ export const createLicense = async ({
|
||||
const license = await db
|
||||
.insert(licenses)
|
||||
.values({
|
||||
customerId,
|
||||
productId,
|
||||
licenseKey,
|
||||
type,
|
||||
billingType,
|
||||
expiresAt,
|
||||
email,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -55,7 +60,10 @@ export const validateLicense = async (
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -117,3 +125,36 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
||||
|
||||
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