feat(licenses): add license management and user authentication features

- Introduced a new SQL schema for managing licenses and users, including foreign key relationships.
- Implemented API routes for license validation, activation, and OTP-based user authentication.
- Updated the license creation process to associate licenses with users and handle server IPs.
- Added support for the nanoid package to generate unique license keys.
- Refactored existing code to improve modularity and maintainability, including the separation of license and stripe-related logic into dedicated API routes.
- Enhanced error handling and logging for better debugging and user feedback.
This commit is contained in:
Mauricio Siu
2025-03-23 18:32:50 -06:00
parent 5180c785b4
commit 39e6a98179
11 changed files with 724 additions and 365 deletions

View File

@@ -0,0 +1,28 @@
CREATE TABLE "licenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"product_id" text NOT NULL,
"license_key" text NOT NULL,
"server_ips" text[],
"activated_at" timestamp,
"last_verified_at" timestamp,
"stripeCustomerId" text NOT NULL,
"stripeSubscriptionId" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"metadata" text,
"user_id" uuid,
CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"otp_code" text,
"otp_code_expires_at" timestamp,
"temporal_id" uuid DEFAULT gen_random_uuid(),
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,196 @@
{
"id": "553c7c08-f9c6-4090-8372-8d27a389eaa7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.licenses": {
"name": "licenses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"product_id": {
"name": "product_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"license_key": {
"name": "license_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"server_ips": {
"name": "server_ips",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"activated_at": {
"name": "activated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_verified_at": {
"name": "last_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"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",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"licenses_user_id_user_id_fk": {
"name": "licenses_user_id_user_id_fk",
"tableFrom": "licenses",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"licenses_license_key_unique": {
"name": "licenses_license_key_unique",
"nullsNotDistinct": false,
"columns": [
"license_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"otp_code": {
"name": "otp_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"otp_code_expires_at": {
"name": "otp_code_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"temporal_id": {
"name": "temporal_id",
"type": "uuid",
"primaryKey": false,
"notNull": false,
"default": "gen_random_uuid()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,5 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": []
"entries": [
{
"idx": 0,
"version": "7",
"when": 1742773711509,
"tag": "0000_famous_vermin",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@
"studio": "drizzle-kit studio"
},
"dependencies": {
"nanoid": "5.1.5",
"@react-email/components": "^0.0.21",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",

View File

@@ -0,0 +1,217 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { activateLicense, validateLicense } from "../utils/license";
import { logger } from "../logger";
import { eq } from "drizzle-orm";
import { users } from "../schema";
import { db } from "../db";
import { transporter } from "../email";
import { nanoid } from "nanoid";
import { stripe } from "../stripe";
import type Stripe from "stripe";
const validateSchema = z.object({
licenseKey: z.string(),
serverIp: z.string(),
});
export const licenseRouter = new Hono();
licenseRouter.post(
"/validate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const result = await validateLicense(licenseKey, serverIp);
console.log("Result", result);
return c.json(result);
} catch (error) {
logger.error("Error validating license:", { error });
return c.json({ isValid: false, error: "Error validating license" }, 500);
}
},
);
licenseRouter.post(
"/activate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await activateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error activating license:", error);
if (error instanceof Error) {
return c.json({ success: false, error: error.message }, 400);
}
return c.json({ success: false, error: "Unknown error occurred" }, 400);
}
},
);
// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
// const { licenseKey } = c.req.valid("json");
// try {
// const license = await db.query.licenses.findFirst({
// where: eq(licenses.licenseKey, licenseKey),
// });
// if (!license) {
// return c.json({ success: false, error: "License not found" }, 404);
// }
// const suscription = await stripe.subscriptions.retrieve(
// license.stripeSubscriptionId,
// );
// const priceId = suscription.items.data[0].price.id;
// const { type } = getLicenseTypeFromPriceId(priceId);
// const emailHtml = await render(
// ResendLicenseEmail({
// licenseKey: license.licenseKey,
// productName: `Dokploy Self Hosted ${type}`,
// requestDate: new Date(),
// customerName: license.email,
// }),
// );
// await transporter.sendMail({
// from: process.env.SMTP_FROM_ADDRESS,
// to: license.email,
// subject: "Your Dokploy License Key",
// html: emailHtml,
// });
// return c.json({ success: true });
// } catch (error) {
// logger.error("Error resending license:", error);
// return c.json({ success: false, error: "Error resending license" }, 500);
// }
// });
licenseRouter.post(
"/send-otp",
zValidator("json", z.object({ email: z.string().email() })),
async (c) => {
const { email } = c.req.valid("json");
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
const generateOtpCode = Math.floor(100000 + Math.random() * 900000);
const otpCodeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
await db
.update(users)
.set({ otpCode: generateOtpCode.toString(), otpCodeExpiresAt })
.where(eq(users.id, user.id));
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: user.email,
subject: "Your Dokploy License Key ",
html: `Your OTP code is ${generateOtpCode}, it will expire in 10 minutes`,
});
return c.json({ success: true });
},
);
licenseRouter.post(
"/verify-otp",
zValidator(
"json",
z.object({ email: z.string().email(), otpCode: z.string().length(6) }),
),
async (c) => {
const { email, otpCode } = c.req.valid("json");
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
if (user.otpCode !== otpCode) {
return c.json({ success: false, error: "Invalid code" }, 400);
}
if (user.otpCodeExpiresAt && user.otpCodeExpiresAt < new Date()) {
return c.json({ success: false, error: "Code expired" }, 400);
}
const result = await db
.update(users)
.set({
otpCode: null,
otpCodeExpiresAt: null,
temporalId: nanoid(),
temporalIdExpiresAt: new Date(Date.now() + 20 * 60 * 1000),
})
.where(eq(users.id, user.id))
.returning();
return c.json({ success: true, temporalId: result[0].temporalId });
},
);
licenseRouter.get(
"/all",
zValidator("query", z.object({ temporalId: z.string() })),
async (c) => {
const { temporalId } = c.req.valid("query");
const user = await db.query.users.findFirst({
where: eq(users.temporalId, temporalId),
with: {
licenses: true,
},
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
if (user.temporalIdExpiresAt && user.temporalIdExpiresAt < new Date()) {
return c.json({ success: false, error: "Session expired" }, 400);
}
const suscriptions: Stripe.Subscription[] = [];
for (const license of user.licenses) {
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
suscriptions.push(suscription);
}
const formated = user.licenses.map((license) => {
const suscription = suscriptions.find(
(suscription) => suscription.id === license.stripeSubscriptionId,
);
return {
license: license,
stripeSuscription: {
quantity: suscription?.items.data[0].quantity,
billingType: suscription?.items.data[0].price.recurring?.interval,
},
};
});
return c.json({ success: true, licenses: formated });
},
);

View File

@@ -0,0 +1,180 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createCheckoutSessionSchema } from "../validators/stripe";
import { getStripeItems } from "../utils/license";
import { stripe } from "../stripe";
import { WEBSITE_URL } from "../constants";
import { logger } from "../logger";
import { eq } from "drizzle-orm";
import { licenses } from "../schema";
import { db } from "../db";
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "../utils";
import { z } from "zod";
import type Stripe from "stripe";
import { createLicense } from "../utils/license";
import { render } from "@react-email/render";
import { LicenseEmail } from "../../templates/emails/license-email";
import { transporter } from "../email";
export const stripeRouter = new Hono();
stripeRouter.post(
"/create-checkout-session",
zValidator("json", createCheckoutSessionSchema),
async (c) => {
const { type, serverQuantity, isAnnual } = c.req.valid("json");
const items = getStripeItems(type, serverQuantity, isAnnual);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/license/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBSITE_URL}#pricing`,
});
return c.json({ sessionId: session.id });
},
);
stripeRouter.get(
"/get-license-from-session",
zValidator("query", z.object({ sessionId: z.string().min(1) })),
async (c) => {
const { sessionId } = c.req.valid("query");
console.log("Session ID", sessionId);
if (!sessionId) {
return c.json({ error: "Session ID is required" }, 400);
}
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.status !== "complete") {
return c.json({ error: "Session is not complete" }, 400);
}
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, subscription.id),
});
const priceId = subscription.items.data[0].price.id;
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
return c.json({ type, billingType, key: license?.licenseKey });
} catch (error) {
logger.error("Error retrieving session:", error);
return c.json({ error: "Error retrieving session" }, 400);
}
},
);
stripeRouter.post("/webhook", async (c) => {
const rawBody = await c.req.raw.text();
const sig = c.req.header("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
logger.error("Webhook signature verification failed:", err);
return c.json({ error: "Webhook signature verification failed" }, 400);
}
const allowedEvents = ["invoice.paid"];
if (!allowedEvents.includes(event.type)) {
return c.json({ error: "Event not allowed" }, 400);
}
try {
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
if (invoice.billing_reason === "subscription_create") {
const customerResponse = await stripe.customers.retrieve(
invoice.customer as string,
);
if (customerResponse.deleted) {
throw new Error("Customer was deleted");
}
const subscriptionId = invoice.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const { type } = getLicenseTypeFromPriceId(priceId);
const { license, user } = await createLicense({
productId: subscriptionId,
email: customerResponse.email!.toLowerCase(),
stripeCustomerId: customerResponse.id,
stripeSubscriptionId: subscriptionId,
});
const features = getLicenseFeatures(type);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${type}`,
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: user.email,
subject: "Your Dokploy License Key ",
html: emailHtml,
});
}
break;
}
}
return c.json({ received: true });
} catch (error) {
console.error("Error processing webhook:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 500);
}
return c.json({ error: "Unknown error occurred" }, 500);
}
});
stripeRouter.post(
"/create-customer-portal-session",
zValidator("json", z.object({ customerId: z.string().min(1) })),
async (c) => {
try {
const { customerId } = c.req.valid("json");
console.log("Customer ID", customerId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
});
return c.json({ url: session.url });
} catch (error) {
logger.error("Error creating customer portal session:", error);
return c.json({ error: "Error creating customer portal session" }, 500);
}
},
);

View File

@@ -1,29 +1,12 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { logger } from "./logger";
import { render } from "@react-email/render";
import { LicenseEmail } from "../templates/emails/license-email";
import { ResendLicenseEmail } from "../templates/emails/resend-license-email";
import {
createLicense,
validateLicense,
activateLicense,
deactivateLicense,
getStripeItems,
} from "./utils/license";
import { db } from "./db";
import { eq, sql } from "drizzle-orm";
import { licenses } from "./schema";
import { sql } from "drizzle-orm";
import "dotenv/config";
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
import { transporter } from "./email";
import type Stripe from "stripe";
import { stripe } from "./stripe";
import { WEBSITE_URL } from "./constants";
import { createCheckoutSessionSchema } from "./validators/stripe";
import { licenseRouter } from "./api/license";
import { stripeRouter } from "./api/stripe";
const app = new Hono();
const router = new Hono();
@@ -34,15 +17,6 @@ router.use(
}),
);
const validateSchema = z.object({
licenseKey: z.string(),
serverIp: z.string(),
});
const resendSchema = z.object({
licenseKey: z.string(),
});
router.get("/health", async (c) => {
try {
await db.execute(sql`SELECT 1`);
@@ -53,264 +27,9 @@ router.get("/health", async (c) => {
}
});
router.post("/validate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const result = await validateLicense(licenseKey, serverIp);
console.log("Result", result);
return c.json(result);
} catch (error) {
logger.error("Error validating license:", { error });
return c.json({ isValid: false, error: "Error validating license" }, 500);
}
});
router.post("/activate", zValidator("json", validateSchema), async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await activateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error activating license:", error);
if (error instanceof Error) {
return c.json({ success: false, error: error.message }, 400);
}
return c.json({ success: false, error: "Unknown error occurred" }, 400);
}
});
router.post(
"/create-checkout-session",
zValidator("json", createCheckoutSessionSchema),
async (c) => {
const { type, serverQuantity, isAnnual } = c.req.valid("json");
const items = getStripeItems(type, serverQuantity, isAnnual);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/license/success`,
cancel_url: `${WEBSITE_URL}#pricing`,
});
return c.json({ sessionId: session.id });
},
);
router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
const { licenseKey } = c.req.valid("json");
try {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
return c.json({ success: false, error: "License not found" }, 404);
}
const emailHtml = await render(
ResendLicenseEmail({
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${license.type}`,
// TODO: Add expiration date
expirationDate: new Date(),
requestDate: new Date(),
customerName: license.email,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: license.email,
subject: "Your Dokploy License Key",
html: emailHtml,
});
return c.json({ success: true });
} catch (error) {
logger.error("Error resending license:", error);
return c.json({ success: false, error: "Error resending license" }, 500);
}
});
router.post("/stripe/webhook", async (c) => {
const rawBody = await c.req.raw.text();
const sig = c.req.header("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
logger.error("Webhook signature verification failed:", err);
return c.json({ error: "Webhook signature verification failed" }, 400);
}
const allowedEvents = [
"checkout.session.completed",
"customer.subscription.updated",
"invoice.payment_succeeded",
"invoice.payment_failed",
"customer.subscription.deleted",
"invoice.paid",
];
if (!allowedEvents.includes(event.type)) {
return c.json({ error: "Event not allowed" }, 400);
}
try {
switch (event.type) {
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.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
if (invoice.billing_reason === "subscription_create") {
const customerResponse = await stripe.customers.retrieve(
invoice.customer as string,
);
if (customerResponse.deleted) {
throw new Error("Customer was deleted");
}
const subscriptionId = invoice.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
const license = await createLicense({
productId: subscriptionId,
type,
billingType,
email: customerResponse.email!,
stripeCustomerId: customerResponse.id,
stripeSubscriptionId: subscriptionId,
});
const features = getLicenseFeatures(type);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${type}`,
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: license.email,
subject: "Your Dokploy License Key ",
html: emailHtml,
});
}
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) {
await deactivateLicense(invoice.subscription as string);
break;
}
const existingLicense = await db.query.licenses.findFirst({
where: eq(licenses.stripeCustomerId, invoice.customer as string),
});
if (!existingLicense) break;
await db
.update(licenses)
.set({
status: "active",
})
.where(eq(licenses.id, existingLicense.id));
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 });
} catch (error) {
console.error("Error processing webhook:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 500);
}
return c.json({ error: "Unknown error occurred" }, 500);
}
});
app.route("/api", router);
app.route("/api/license", licenseRouter);
app.route("/api/stripe", stripeRouter);
const port = process.env.PORT || 4002;
console.log(`Server is running on port http://localhost:${port}`);

View File

@@ -1,38 +1,43 @@
import { sql } from "drizzle-orm";
import { pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
otpCode: text("otp_code"),
otpCodeExpiresAt: timestamp("otp_code_expires_at"),
temporalId: text("temporal_id"),
temporalIdExpiresAt: timestamp("temporal_id_expires_at"),
});
export const licenseStatusEnum = pgEnum("license_status", [
"active",
"expired",
"cancelled",
"payment_pending",
]);
export const licenseTypeEnum = pgEnum("license_type", [
"basic",
"premium",
"business",
]);
export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
export const usersRelations = relations(users, ({ many }) => ({
licenses: many(licenses),
}));
export const licenses = pgTable("licenses", {
id: uuid("id").defaultRandom().primaryKey(),
productId: text("product_id").notNull(),
licenseKey: text("license_key").notNull().unique(),
status: licenseStatusEnum("status").notNull().default("active"),
type: licenseTypeEnum("type").notNull(),
billingType: billingTypeEnum("billing_type").notNull(),
serverIp: text("server_ip"),
serverIps: text("server_ips").array(),
activatedAt: timestamp("activated_at"),
lastVerifiedAt: timestamp("last_verified_at"),
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"),
email: text("email").notNull(),
userId: uuid("user_id").references(() => users.id),
});
export const licensesRelations = relations(licenses, ({ one }) => ({
user: one(users, {
fields: [licenses.userId],
references: [users.id],
}),
}));
export type License = typeof licenses.$inferSelect;
export type NewLicense = typeof licenses.$inferInsert;

View File

@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { licenses } from "../schema";
import { licenses, users } from "../schema";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import type Stripe from "stripe";
@@ -11,8 +11,6 @@ export const generateLicenseKey = () => {
interface CreateLicenseProps {
productId: string;
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
email: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
@@ -20,34 +18,54 @@ interface CreateLicenseProps {
export const createLicense = async ({
productId,
type,
billingType,
email,
stripeCustomerId,
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
const license = await db
.insert(licenses)
.values({
productId,
licenseKey,
type,
billingType,
email,
stripeCustomerId,
stripeSubscriptionId,
})
.returning();
return await db.transaction(async (tx) => {
let user = await tx
.insert(users)
.values({ email })
.onConflictDoNothing()
.returning()
.then((res) => res[0]);
return license[0];
if (!user) {
const result = await tx.query.users.findFirst({
where: eq(users.email, email),
});
if (!result) {
throw new Error("User not found");
}
user = result;
}
console.log("User", user);
const license = await tx
.insert(licenses)
.values({
productId,
licenseKey,
stripeCustomerId,
stripeSubscriptionId,
userId: user.id,
})
.returning()
.then((res) => res[0]);
return {
license,
user,
};
});
};
export const validateLicense = async (
licenseKey: string,
serverIp?: string,
) => {
export const validateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
@@ -60,8 +78,6 @@ export const validateLicense = async (
license.stripeSubscriptionId,
);
console.log("Suscription", suscription);
if (suscription.status !== "active") {
return {
isValid: false,
@@ -69,7 +85,7 @@ export const validateLicense = async (
};
}
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
if (license.serverIps && !license.serverIps.includes(serverIp)) {
return { isValid: false, error: "Invalid server IP" };
}
@@ -97,8 +113,16 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
if (suscription.status !== "active") {
throw new Error(`License is ${getLicenseStatus(suscription)}`);
}
const currentServerQuantity = license.serverIps?.length || 0;
const serversQuantity = suscription.items.data[0].quantity || 0;
if (license.serverIp && license.serverIp !== serverIp) {
if (currentServerQuantity >= serversQuantity) {
throw new Error(
"You have reached the maximum number of servers, please upgrade your license to add more servers",
);
}
if (license.serverIps && !license.serverIps.includes(serverIp)) {
throw new Error("License is already activated on a different server");
}
@@ -106,7 +130,7 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
const updatedLicense = await db
.update(licenses)
.set({
serverIp,
serverIps: [...(license.serverIps || []), serverIp],
activatedAt: new Date(),
lastVerifiedAt: new Date(),
})
@@ -116,21 +140,6 @@ 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 = async (license: Stripe.Subscription) => {
if (license.status === "active") {
return "active";

View File

@@ -18,7 +18,6 @@ interface ResendLicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
expirationDate: Date;
requestDate?: Date;
}
@@ -28,15 +27,8 @@ export const ResendLicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
expirationDate = new Date(),
requestDate = new Date(),
}: ResendLicenseEmailProps): React.ReactElement => {
const formattedDate = expirationDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
@@ -75,12 +67,6 @@ export const ResendLicenseEmail = ({
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={validitySection}>
<Text style={validityText}>
🗓️ Valid until: <strong>{formattedDate}</strong>
</Text>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Quick Activation Guide

14
pnpm-lock.yaml generated
View File

@@ -554,6 +554,9 @@ importers:
hono:
specifier: ^4.5.8
version: 4.5.8
nanoid:
specifier: 5.1.5
version: 5.1.5
nodemailer:
specifier: 6.9.14
version: 6.9.14
@@ -6059,6 +6062,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
nanostores@0.11.3:
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -13247,6 +13255,8 @@ snapshots:
nanoid@3.3.8: {}
nanoid@5.1.5: {}
nanostores@0.11.3: {}
napi-build-utils@1.0.2:
@@ -13718,13 +13728,13 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0
postcss@8.4.40:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0