فهرست منبع

subscription check

Bernt Christian Egeland 7 ماه پیش
والد
کامیت
7adf44959d

+ 45 - 0
package-lock.json

@@ -21,6 +21,7 @@
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "cmdk": "^1.1.1",
+        "cron": "^4.4.0",
         "date-fns": "^4.1.0",
         "form-data": "^4.0.5",
         "jszip": "^3.10.1",
@@ -51,6 +52,7 @@
         "@biomejs/biome": "2.3.15",
         "@tailwindcss/postcss": "^4",
         "@types/archiver": "^7.0.0",
+        "@types/cron": "^2.0.1",
         "@types/node": "^20",
         "@types/nodemailer": "^7.0.9",
         "@types/react": "^19",
@@ -5829,6 +5831,17 @@
         "@types/readdir-glob": "*"
       }
     },
+    "node_modules/@types/cron": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.1.tgz",
+      "integrity": "sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/luxon": "*",
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/d3-array": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -5913,6 +5926,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/luxon": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
+      "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
+      "license": "MIT"
+    },
     "node_modules/@types/node": {
       "version": "20.19.33",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
@@ -7979,6 +7998,23 @@
         "node": ">= 14"
       }
     },
+    "node_modules/cron": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
+      "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/luxon": "~3.7.0",
+        "luxon": "~3.7.0"
+      },
+      "engines": {
+        "node": ">=18.x"
+      },
+      "funding": {
+        "type": "ko-fi",
+        "url": "https://ko-fi.com/intcreator"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -11864,6 +11900,15 @@
         "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
       }
     },
+    "node_modules/luxon": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+      "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.30.21",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

+ 2 - 0
package.json

@@ -26,6 +26,7 @@
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "cmdk": "^1.1.1",
+    "cron": "^4.4.0",
     "date-fns": "^4.1.0",
     "form-data": "^4.0.5",
     "jszip": "^3.10.1",
@@ -56,6 +57,7 @@
     "@biomejs/biome": "2.3.15",
     "@tailwindcss/postcss": "^4",
     "@types/archiver": "^7.0.0",
+    "@types/cron": "^2.0.1",
     "@types/node": "^20",
     "@types/nodemailer": "^7.0.9",
     "@types/react": "^19",

+ 121 - 0
src/cronTasks.ts

@@ -0,0 +1,121 @@
+import { CronJob } from 'cron'
+import { db } from './lib/db'
+
+const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
+
+export function checkLicenses() {
+  const job = new CronJob('0 0 * * *', async () => {
+    try {
+      // Find all organizations that have a license key stored
+      const licenseSettings = await db.appSetting.findMany({
+        where: { key: 'license.key' },
+        select: { organizationId: true, value: true },
+      })
+
+      for (const setting of licenseSettings) {
+        if (!setting.organizationId) continue
+
+        try {
+          await revalidateOrganizationLicense(setting.organizationId, setting.value)
+        } catch (error) {
+          console.error(
+            `[cron] Failed to revalidate license for org ${setting.organizationId}:`,
+            error
+          )
+        }
+      }
+    } catch (error) {
+      console.error('[cron] License revalidation failed:', error)
+    }
+  })
+
+  job.start()
+}
+
+async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
+  let valid = false
+  let plan = 'free'
+  let expiresAt = ''
+
+  const response = await fetch(`${TORQVOICE_COM_URL}/api/license/validate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ key: licenseKey, organizationId }),
+    signal: AbortSignal.timeout(10000),
+  })
+
+  if (response.ok) {
+    const data = await response.json()
+    valid = data.valid === true
+    if (valid && data.plan) {
+      plan = data.plan
+    }
+    if (data.expiresAt) {
+      expiresAt = data.expiresAt
+    }
+  }
+
+  const now = new Date().toISOString()
+
+  // Find any user in this org to use as the setting owner
+  const orgMember = await db.organizationMember.findFirst({
+    where: { organizationId },
+    select: { userId: true },
+  })
+
+  if (!orgMember) return
+
+  const upserts = [
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.valid' } },
+      update: { value: String(valid) },
+      create: {
+        userId: orgMember.userId,
+        organizationId,
+        key: 'license.valid',
+        value: String(valid),
+      },
+    }),
+    db.appSetting.upsert({
+      where: {
+        organizationId_key: { organizationId, key: 'license.checkedAt' },
+      },
+      update: { value: now },
+      create: {
+        userId: orgMember.userId,
+        organizationId,
+        key: 'license.checkedAt',
+        value: now,
+      },
+    }),
+    db.appSetting.upsert({
+      where: { organizationId_key: { organizationId, key: 'license.plan' } },
+      update: { value: plan },
+      create: {
+        userId: orgMember.userId,
+        organizationId,
+        key: 'license.plan',
+        value: plan,
+      },
+    }),
+  ]
+
+  if (expiresAt) {
+    upserts.push(
+      db.appSetting.upsert({
+        where: {
+          organizationId_key: { organizationId, key: 'license.expiresAt' },
+        },
+        update: { value: expiresAt },
+        create: {
+          userId: orgMember.userId,
+          organizationId,
+          key: 'license.expiresAt',
+          value: expiresAt,
+        },
+      })
+    )
+  }
+
+  await db.$transaction(upserts)
+}

+ 31 - 1
src/features/settings/Actions/validateLicense.ts

@@ -16,6 +16,7 @@ export async function validateLicense(licenseKey: string) {
 
       let valid = false;
       let plan = "free";
+      let expiresAt = "";
 
       try {
         const response = await fetch(
@@ -34,6 +35,9 @@ export async function validateLicense(licenseKey: string) {
           if (valid && data.plan) {
             plan = data.plan;
           }
+          if (data.expiresAt) {
+            expiresAt = data.expiresAt;
+          }
         }
       } catch {
         // API unreachable — fall back to cached result
@@ -41,7 +45,11 @@ export async function validateLicense(licenseKey: string) {
           where: {
             organizationId: ctx.organizationId,
             key: {
-              in: [SETTING_KEYS.LICENSE_VALID, SETTING_KEYS.LICENSE_PLAN],
+              in: [
+                SETTING_KEYS.LICENSE_VALID,
+                SETTING_KEYS.LICENSE_PLAN,
+                SETTING_KEYS.LICENSE_EXPIRES_AT,
+              ],
             },
           },
           select: { key: true, value: true },
@@ -53,6 +61,9 @@ export async function validateLicense(licenseKey: string) {
           if (setting.key === SETTING_KEYS.LICENSE_PLAN) {
             plan = setting.value;
           }
+          if (setting.key === SETTING_KEYS.LICENSE_EXPIRES_AT) {
+            expiresAt = setting.value;
+          }
         }
       }
 
@@ -119,6 +130,25 @@ export async function validateLicense(licenseKey: string) {
             value: plan,
           },
         }),
+        ...(expiresAt
+          ? [
+              db.appSetting.upsert({
+                where: {
+                  organizationId_key: {
+                    organizationId: ctx.organizationId,
+                    key: SETTING_KEYS.LICENSE_EXPIRES_AT,
+                  },
+                },
+                update: { value: expiresAt },
+                create: {
+                  userId: ctx.userId,
+                  organizationId: ctx.organizationId,
+                  key: SETTING_KEYS.LICENSE_EXPIRES_AT,
+                  value: expiresAt,
+                },
+              }),
+            ]
+          : []),
       ]);
 
       revalidatePath("/settings");

+ 1 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -47,6 +47,7 @@ export const SETTING_KEYS = {
   LICENSE_VALID: "license.valid",
   LICENSE_CHECKED_AT: "license.checkedAt",
   LICENSE_PLAN: "license.plan",
+  LICENSE_EXPIRES_AT: "license.expiresAt",
   DATE_FORMAT: "workshop.dateFormat",
   TIME_FORMAT: "workshop.timeFormat",
   TIMEZONE: "workshop.timezone",

+ 6 - 0
src/instrumentation.ts

@@ -0,0 +1,6 @@
+export async function register() {
+  if (process.env.NEXT_RUNTIME === 'nodejs') {
+    const { checkLicenses } = await import('./cronTasks')
+    checkLicenses()
+  }
+}

+ 4 - 2
src/lib/features.ts

@@ -95,12 +95,14 @@ export const getFeatures = cache(async (organizationId: string): Promise<PlanFea
   const settings = await db.appSetting.findMany({
     where: {
       organizationId,
-      key: { in: ['license.valid'] },
+      key: { in: ['license.valid', 'license.expiresAt'] },
     },
   })
 
   const map = new Map(settings.map((s) => [s.key, s.value]))
-  const hasLicense = map.get('license.valid') === 'true'
+  const isValid = map.get('license.valid') === 'true'
+  const expiresAt = map.get('license.expiresAt')
+  const hasLicense = isValid && (!expiresAt || new Date(expiresAt) > new Date())
 
   return {
     ...PLAN_FEATURES['white-label'],