|
@@ -2,119 +2,89 @@ import { CronJob } from 'cron'
|
|
|
import { db } from '@/lib/db'
|
|
import { db } from '@/lib/db'
|
|
|
import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
|
|
import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
|
|
|
import { notify } from '@/lib/notify'
|
|
import { notify } from '@/lib/notify'
|
|
|
|
|
+import { revalidateLicense } from '@/lib/license/revalidate'
|
|
|
|
|
+import {
|
|
|
|
|
+ LICENSE_TOKEN_MAX_AGE_DAYS,
|
|
|
|
|
+ LICENSE_TOKEN_WARN_AGE_DAYS,
|
|
|
|
|
+ verifyLicenseToken,
|
|
|
|
|
+} from '@/lib/license/token'
|
|
|
|
|
|
|
|
-const TORQVOICE_COM_URL = process.env.NEXT_PUBLIC_TORQVOICE_COM_URL || 'https://torqvoice.com'
|
|
|
|
|
const EXPIRY_WARNING_DAYS = 14
|
|
const EXPIRY_WARNING_DAYS = 14
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Re-checks one org's key against torqvoice.com and stores the signed token
|
|
|
|
|
+ * that comes back. Then warns about whichever clock is running out: the
|
|
|
|
|
+ * licence term, or the token's age when torqvoice.com has been unreachable.
|
|
|
|
|
+ */
|
|
|
export async function revalidateOrganizationLicense(organizationId: string, licenseKey: string) {
|
|
export 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 { verification } = await revalidateLicense(organizationId, licenseKey)
|
|
|
|
|
+ const { status, daysUntilExpiry, ageDays } = verification
|
|
|
|
|
+
|
|
|
|
|
+ if (
|
|
|
|
|
+ status === 'valid' &&
|
|
|
|
|
+ daysUntilExpiry !== null &&
|
|
|
|
|
+ daysUntilExpiry <= EXPIRY_WARNING_DAYS &&
|
|
|
|
|
+ daysUntilExpiry > 0
|
|
|
|
|
+ ) {
|
|
|
|
|
+ await sendExpiryWarning(organizationId, daysUntilExpiry)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const now = new Date().toISOString()
|
|
|
|
|
- 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)
|
|
|
|
|
-
|
|
|
|
|
- // Send expiry warning if within threshold
|
|
|
|
|
- if (valid && expiresAt) {
|
|
|
|
|
- const diff = new Date(expiresAt).getTime() - Date.now()
|
|
|
|
|
- const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
|
|
|
|
-
|
|
|
|
|
- if (daysLeft <= EXPIRY_WARNING_DAYS && daysLeft > 0) {
|
|
|
|
|
- await sendExpiryWarning(organizationId, daysLeft)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Signature fine, licence not over, but no fresh token for a week: the
|
|
|
|
|
+ // daily refresh has been failing. Say so before the ceiling cuts branding.
|
|
|
|
|
+ if (
|
|
|
|
|
+ (status === 'valid' || status === 'stale') &&
|
|
|
|
|
+ ageDays !== null &&
|
|
|
|
|
+ ageDays >= LICENSE_TOKEN_WARN_AGE_DAYS
|
|
|
|
|
+ ) {
|
|
|
|
|
+ await sendVerificationWarning(organizationId, Math.max(0, LICENSE_TOKEN_MAX_AGE_DAYS - ageDays))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export async function sendExpiryWarning(organizationId: string, daysLeft: number) {
|
|
|
|
|
- // Check if we already warned today
|
|
|
|
|
- const lastWarning = await db.appSetting.findUnique({
|
|
|
|
|
- where: { organizationId_key: { organizationId, key: 'license.lastExpiryWarning' } },
|
|
|
|
|
|
|
+async function alreadyWarnedToday(organizationId: string, key: string): Promise<boolean> {
|
|
|
|
|
+ const last = await db.appSetting.findUnique({
|
|
|
|
|
+ where: { organizationId_key: { organizationId, key } },
|
|
|
select: { value: true },
|
|
select: { value: true },
|
|
|
})
|
|
})
|
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
- if (lastWarning?.value === today) return
|
|
|
|
|
|
|
+ if (last?.value === today) return true
|
|
|
|
|
|
|
|
- // Record that we warned today
|
|
|
|
|
const orgMember = await db.organizationMember.findFirst({
|
|
const orgMember = await db.organizationMember.findFirst({
|
|
|
where: { organizationId },
|
|
where: { organizationId },
|
|
|
select: { userId: true },
|
|
select: { userId: true },
|
|
|
})
|
|
})
|
|
|
- if (!orgMember) return
|
|
|
|
|
|
|
+ if (!orgMember) return true
|
|
|
|
|
|
|
|
await db.appSetting.upsert({
|
|
await db.appSetting.upsert({
|
|
|
- where: { organizationId_key: { organizationId, key: 'license.lastExpiryWarning' } },
|
|
|
|
|
|
|
+ where: { organizationId_key: { organizationId, key } },
|
|
|
update: { value: today },
|
|
update: { value: today },
|
|
|
- create: {
|
|
|
|
|
- userId: orgMember.userId,
|
|
|
|
|
- organizationId,
|
|
|
|
|
- key: 'license.lastExpiryWarning',
|
|
|
|
|
- value: today,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ create: { userId: orgMember.userId, organizationId, key, value: today },
|
|
|
})
|
|
})
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function emailOwner(organizationId: string, subject: string, html: string) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const owner = await db.organizationMember.findFirst({
|
|
|
|
|
+ where: { organizationId, role: 'owner' },
|
|
|
|
|
+ include: { user: { select: { email: true } } },
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!owner?.user.email) return
|
|
|
|
|
+
|
|
|
|
|
+ const from = await getOrgFromAddress(organizationId)
|
|
|
|
|
+ await sendOrgMail(organizationId, { from, to: owner.user.email, subject, html })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn(`[cron] Failed to send license email for org ${organizationId}:`, error)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function sendExpiryWarning(organizationId: string, daysLeft: number) {
|
|
|
|
|
+ if (await alreadyWarnedToday(organizationId, 'license.lastExpiryWarning')) return
|
|
|
|
|
+
|
|
|
|
|
+ const dayWord = `${daysLeft} day${daysLeft === 1 ? '' : 's'}`
|
|
|
|
|
|
|
|
- // In-app notification
|
|
|
|
|
await notify({
|
|
await notify({
|
|
|
type: 'license_expiring',
|
|
type: 'license_expiring',
|
|
|
- title: `License expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`,
|
|
|
|
|
|
|
+ title: `License expires in ${dayWord}`,
|
|
|
message: 'Please renew your license to maintain full access to all features.',
|
|
message: 'Please renew your license to maintain full access to all features.',
|
|
|
entityType: 'license',
|
|
entityType: 'license',
|
|
|
entityId: organizationId,
|
|
entityId: organizationId,
|
|
@@ -122,30 +92,99 @@ export async function sendExpiryWarning(organizationId: string, daysLeft: number
|
|
|
organizationId,
|
|
organizationId,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- // Email notification to org owner
|
|
|
|
|
- try {
|
|
|
|
|
- const owner = await db.organizationMember.findFirst({
|
|
|
|
|
- where: { organizationId, role: 'owner' },
|
|
|
|
|
- include: { user: { select: { email: true } } },
|
|
|
|
|
- })
|
|
|
|
|
- if (!owner?.user.email) return
|
|
|
|
|
-
|
|
|
|
|
- const from = await getOrgFromAddress(organizationId)
|
|
|
|
|
- await sendOrgMail(organizationId, {
|
|
|
|
|
- from,
|
|
|
|
|
- to: owner.user.email,
|
|
|
|
|
- subject: `Your license expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`,
|
|
|
|
|
- html: `
|
|
|
|
|
|
|
+ await emailOwner(
|
|
|
|
|
+ organizationId,
|
|
|
|
|
+ `Your license expires in ${dayWord}`,
|
|
|
|
|
+ `
|
|
|
<div style="font-family: sans-serif; max-width: 600px;">
|
|
<div style="font-family: sans-serif; max-width: 600px;">
|
|
|
<h2 style="color: #d97706;">License Expiration Notice</h2>
|
|
<h2 style="color: #d97706;">License Expiration Notice</h2>
|
|
|
- <p>Your license will expire in <strong>${daysLeft} day${daysLeft === 1 ? '' : 's'}</strong>.</p>
|
|
|
|
|
|
|
+ <p>Your license will expire in <strong>${dayWord}</strong>.</p>
|
|
|
<p>Please renew your license to continue using all features without interruption.</p>
|
|
<p>Please renew your license to continue using all features without interruption.</p>
|
|
|
<p>You can manage your license in <strong>Settings > License</strong>.</p>
|
|
<p>You can manage your license in <strong>Settings > License</strong>.</p>
|
|
|
</div>
|
|
</div>
|
|
|
- `,
|
|
|
|
|
|
|
+ `
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function sendVerificationWarning(organizationId: string, daysUntilCutoff: number) {
|
|
|
|
|
+ if (await alreadyWarnedToday(organizationId, 'license.lastVerificationWarning')) return
|
|
|
|
|
+
|
|
|
|
|
+ const dayWord = `${daysUntilCutoff} day${daysUntilCutoff === 1 ? '' : 's'}`
|
|
|
|
|
+ const title =
|
|
|
|
|
+ daysUntilCutoff > 0
|
|
|
|
|
+ ? `License could not be verified, branding returns in ${dayWord}`
|
|
|
|
|
+ : 'License could not be verified, branding has returned'
|
|
|
|
|
+
|
|
|
|
|
+ await notify({
|
|
|
|
|
+ type: 'license_unverified',
|
|
|
|
|
+ title,
|
|
|
|
|
+ message:
|
|
|
|
|
+ 'This server has not been able to reach torqvoice.com to confirm the license. Check outbound access, then open Settings > License and choose Validate.',
|
|
|
|
|
+ entityType: 'license',
|
|
|
|
|
+ entityId: organizationId,
|
|
|
|
|
+ entityUrl: '/settings/license',
|
|
|
|
|
+ organizationId,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ await emailOwner(
|
|
|
|
|
+ organizationId,
|
|
|
|
|
+ title,
|
|
|
|
|
+ `
|
|
|
|
|
+ <div style="font-family: sans-serif; max-width: 600px;">
|
|
|
|
|
+ <h2 style="color: #d97706;">License Verification Notice</h2>
|
|
|
|
|
+ <p>Your Torqvoice server has not been able to reach torqvoice.com to confirm its white-label license for more than ${LICENSE_TOKEN_WARN_AGE_DAYS} days.</p>
|
|
|
|
|
+ <p>A license that stays unverified for ${LICENSE_TOKEN_MAX_AGE_DAYS} days is treated as inactive and Torqvoice branding returns until it is verified again. Nothing else changes and no data is affected.</p>
|
|
|
|
|
+ <p>Please check that the server can reach <strong>torqvoice.com</strong>, then open <strong>Settings > License</strong> and choose <strong>Validate</strong>.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ `
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type KeyedOrg = { organizationId: string; key: string }
|
|
|
|
|
+
|
|
|
|
|
+async function orgsWithKeys(): Promise<KeyedOrg[]> {
|
|
|
|
|
+ const rows = await db.appSetting.findMany({
|
|
|
|
|
+ where: { key: 'license.key' },
|
|
|
|
|
+ select: { organizationId: true, value: true },
|
|
|
|
|
+ })
|
|
|
|
|
+ return rows
|
|
|
|
|
+ .filter((r): r is typeof r & { organizationId: string } => Boolean(r.organizationId))
|
|
|
|
|
+ .map((r) => ({ organizationId: r.organizationId, key: r.value }))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function revalidateAll(orgs: KeyedOrg[]) {
|
|
|
|
|
+ for (const org of orgs) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await revalidateOrganizationLicense(org.organizationId, org.key)
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error(`[cron] Failed to revalidate license for org ${org.organizationId}:`, error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Runs once at boot. Any org holding a key without a currently valid token
|
|
|
|
|
+ * gets refreshed straight away rather than waiting up to a day for the cron.
|
|
|
|
|
+ * That is the whole upgrade path for installs licensed before signed tokens
|
|
|
|
|
+ * existed: they boot into the new release and fetch their token immediately.
|
|
|
|
|
+ */
|
|
|
|
|
+export async function refreshLicensesMissingTokens() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const orgs = await orgsWithKeys()
|
|
|
|
|
+ if (orgs.length === 0) return
|
|
|
|
|
+ const tokens = await db.appSetting.findMany({
|
|
|
|
|
+ where: { key: 'license.token', organizationId: { in: orgs.map((o) => o.organizationId) } },
|
|
|
|
|
+ select: { organizationId: true, value: true },
|
|
|
})
|
|
})
|
|
|
|
|
+ const tokenByOrg = new Map(tokens.map((t) => [t.organizationId, t.value]))
|
|
|
|
|
+ const needing = orgs.filter(
|
|
|
|
|
+ (o) =>
|
|
|
|
|
+ verifyLicenseToken(tokenByOrg.get(o.organizationId), o.organizationId).status !== 'valid'
|
|
|
|
|
+ )
|
|
|
|
|
+ if (needing.length === 0) return
|
|
|
|
|
+ await revalidateAll(needing)
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- console.warn(`[cron] Failed to send license expiry email for org ${organizationId}:`, error)
|
|
|
|
|
|
|
+ console.error('[license] Boot-time licence refresh failed:', error)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -153,26 +192,15 @@ export async function sendExpiryWarning(organizationId: string, daysLeft: number
|
|
|
export function checkLicenses() {
|
|
export function checkLicenses() {
|
|
|
const job = new CronJob('0 0 * * *', async () => {
|
|
const job = new CronJob('0 0 * * *', async () => {
|
|
|
try {
|
|
try {
|
|
|
- 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
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await revalidateAll(await orgsWithKeys())
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('[cron] License revalidation failed:', error)
|
|
console.error('[cron] License revalidation failed:', error)
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
job.start()
|
|
job.start()
|
|
|
|
|
+
|
|
|
|
|
+ // Give the database and the rest of boot a moment before going out to the
|
|
|
|
|
+ // network. Not awaited: a slow torqvoice.com must never delay startup.
|
|
|
|
|
+ setTimeout(() => void refreshLicensesMissingTokens(), 15_000).unref()
|
|
|
}
|
|
}
|