Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins.
Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const rows = await prisma.tenant.findMany({
|
||||
select: { id: true, notes: true, appVersion: true },
|
||||
});
|
||||
|
||||
let n = 0;
|
||||
for (const row of rows) {
|
||||
const match = String(row.notes || '').match(/version:([^|\s]+)/i);
|
||||
if (!match?.[1]) continue;
|
||||
if (row.appVersion && row.appVersion !== '1') continue;
|
||||
|
||||
await prisma.tenant.update({
|
||||
where: { id: row.id },
|
||||
data: { appVersion: match[1].trim() },
|
||||
});
|
||||
n += 1;
|
||||
}
|
||||
|
||||
console.log(`backfilled ${n} of ${rows.length}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Backfill SuperAdmin tenant appearance from live registries (by domain Origin)
|
||||
* and users.json fallback. Preserves named CSS modes (e.g. limangold).
|
||||
*
|
||||
* Usage:
|
||||
* npm run prisma:backfill-appearance
|
||||
*/
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { Prisma, PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
buildHamkarUiFromBrand,
|
||||
buildKhanegiUiFromColors,
|
||||
pickRegistryPayload,
|
||||
resolveAvatarUrl,
|
||||
type RegistryUser,
|
||||
} from './lib/appearance-builders'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const REGISTRY_URLS = [
|
||||
'https://nerkh.gold/get_user',
|
||||
'https://ah.aygingold.ir/get_user',
|
||||
'https://tablo.talasevin.ir/get_user',
|
||||
'https://abshode-service.montgit.ir/server/get_user',
|
||||
]
|
||||
|
||||
type JsonUser = {
|
||||
id: number
|
||||
name?: string
|
||||
name_fa?: string
|
||||
avatar?: string | null
|
||||
domains?: string | string[] | null
|
||||
color?: string | null
|
||||
secondary_color?: string | null
|
||||
background_color?: string | null
|
||||
mode?: string | null
|
||||
app_ui_config?: unknown
|
||||
deleted_at?: string | null
|
||||
node_server?: string | null
|
||||
}
|
||||
|
||||
function normalizeHost(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.split('/')[0]
|
||||
.split(':')[0]
|
||||
}
|
||||
|
||||
function parseDomains(raw: unknown): string[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) return raw.map(String).map(normalizeHost).filter(Boolean)
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed.map(String).map(normalizeHost).filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function readTenantHosts(tenant: { domain: string; domains: unknown; appUrl?: string | null }) {
|
||||
const hosts = new Set<string>()
|
||||
hosts.add(normalizeHost(tenant.domain))
|
||||
for (const d of parseDomains(tenant.domains)) hosts.add(d)
|
||||
if (tenant.appUrl) hosts.add(normalizeHost(tenant.appUrl))
|
||||
return [...hosts].filter(Boolean)
|
||||
}
|
||||
|
||||
function loadUsersJson(): JsonUser[] {
|
||||
const file = path.join(__dirname, 'data', 'users.json')
|
||||
if (!fs.existsSync(file)) return []
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8')) as JsonUser[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function findJsonUser(tenant: { domain: string; domains: unknown; notes?: string | null; slug: string }, rows: JsonUser[]) {
|
||||
const hosts = new Set(readTenantHosts(tenant))
|
||||
const external = tenant.notes?.match(/externalId:(\d+)/)?.[1]
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at) continue
|
||||
if (external && String(row.id) === external) return row
|
||||
const rowHosts = parseDomains(row.domains)
|
||||
if (rowHosts.some((h) => hosts.has(h))) return row
|
||||
if (row.name && normalizeHost(row.name) === normalizeHost(tenant.slug)) return row
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchRegistryByOrigin(domain: string): Promise<{ data: RegistryUser; base: string } | null> {
|
||||
const origin = `https://${domain}`
|
||||
for (const base of REGISTRY_URLS) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
const res = await fetch(base, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Origin: origin,
|
||||
Referer: `${origin}/`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) continue
|
||||
const json = await res.json()
|
||||
const data = pickRegistryPayload(json)
|
||||
// Reject empty / unrelated responses: must mention domain in node_server/domains-ish or have name
|
||||
const nodeHost = normalizeHost(String(data.node_server || ''))
|
||||
const related =
|
||||
nodeHost.includes(domain.replace(/^www\./, '')) ||
|
||||
domain.includes(normalizeHost(String(data.name || ''))) ||
|
||||
Boolean(data.avatar || data.color || data.secondary_color || data.app_ui_config)
|
||||
if (!related && !data.name) continue
|
||||
return { data, base }
|
||||
} catch {
|
||||
/* next */
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function jsonToRegistry(row: JsonUser): RegistryUser {
|
||||
return {
|
||||
avatar: row.avatar,
|
||||
color: row.color,
|
||||
secondary_color: row.secondary_color,
|
||||
background_color: row.background_color,
|
||||
name: row.name,
|
||||
name_fa: row.name_fa,
|
||||
mode: row.mode,
|
||||
theme: row.mode,
|
||||
app_ui_config: row.app_ui_config,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jsonRows = loadUsersJson()
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
|
||||
})
|
||||
|
||||
const summary = {
|
||||
total: tenants.length,
|
||||
updated: 0,
|
||||
fromLive: 0,
|
||||
fromJson: 0,
|
||||
skipped: 0,
|
||||
hamkar: 0,
|
||||
khanegi: 0,
|
||||
namedModes: 0,
|
||||
}
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const hosts = readTenantHosts(tenant)
|
||||
let hit: { data: RegistryUser; base: string; source: 'live' | 'json' } | null = null
|
||||
|
||||
for (const host of hosts) {
|
||||
const live = await fetchRegistryByOrigin(host)
|
||||
if (live) {
|
||||
hit = { ...live, source: 'live' }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hit) {
|
||||
const row = findJsonUser(tenant, jsonRows)
|
||||
if (row) {
|
||||
hit = {
|
||||
data: jsonToRegistry(row),
|
||||
base: 'https://nerkh.gold/get_user',
|
||||
source: 'json',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hit) {
|
||||
summary.skipped += 1
|
||||
console.warn(`SKIP ${tenant.product} ${tenant.domain}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { data, base, source } = hit
|
||||
if (source === 'live') summary.fromLive += 1
|
||||
else summary.fromJson += 1
|
||||
|
||||
// Prefer live/json values only — never keep invented SA defaults like #1d4ed8
|
||||
const primary = data.color ?? null
|
||||
const secondary = data.secondary_color ?? null
|
||||
const background = data.background_color ?? null
|
||||
const avatar = resolveAvatarUrl(data.avatar, base) || tenant.avatarUrl
|
||||
const registryMode = String(data.mode || data.theme || '').trim() || null
|
||||
if (registryMode && registryMode !== 'dynamicMode') summary.namedModes += 1
|
||||
|
||||
const existingUi = (tenant.appUiConfig as Record<string, any> | null) || null
|
||||
let appUiConfig: Prisma.InputJsonValue
|
||||
|
||||
if (tenant.product === 'hamkar') {
|
||||
const managed = existingUi?.kind === 'hamkar' && existingUi?.settingsManaged === true
|
||||
const built = buildHamkarUiFromBrand({
|
||||
primaryColor: primary,
|
||||
secondaryColor: secondary,
|
||||
backgroundColor: background,
|
||||
settingsManaged: managed,
|
||||
registryMode: registryMode || existingUi?.registryMode || 'dynamicMode',
|
||||
settings: managed ? existingUi.settings : undefined,
|
||||
})
|
||||
appUiConfig = built as unknown as Prisma.InputJsonValue
|
||||
summary.hamkar += 1
|
||||
} else {
|
||||
const built = buildKhanegiUiFromColors({
|
||||
primary: primary || tenant.primaryColor,
|
||||
secondary: secondary || tenant.secondaryColor,
|
||||
background: background || tenant.backgroundColor,
|
||||
existing:
|
||||
data.app_ui_config ||
|
||||
(existingUi?.schemaId === 'ui-config' ? existingUi : null),
|
||||
})
|
||||
appUiConfig = {
|
||||
...(built as object),
|
||||
registryMode: registryMode || existingUi?.registryMode || null,
|
||||
} as unknown as Prisma.InputJsonValue
|
||||
summary.khanegi += 1
|
||||
}
|
||||
|
||||
const notes = [
|
||||
tenant.notes || '',
|
||||
`appearance:backfill-${source}`,
|
||||
registryMode ? `registryMode:${registryMode}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenant.id },
|
||||
data: {
|
||||
avatarUrl: avatar,
|
||||
primaryColor: primary,
|
||||
secondaryColor: secondary,
|
||||
backgroundColor: background,
|
||||
appUiConfig,
|
||||
notes,
|
||||
},
|
||||
})
|
||||
|
||||
summary.updated += 1
|
||||
console.log(
|
||||
`OK ${tenant.product} ${tenant.domain} ← ${source}${registryMode ? ` mode=${registryMode}` : ''} color=${primary || '-'}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nSummary:', summary)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -283,6 +283,7 @@ async function main() {
|
||||
domainOverride?.appUrl || domains.find((d) => !isIp(d)) || domain,
|
||||
adminUrl: null as string | null,
|
||||
appUiConfig: ui,
|
||||
appVersion: String(row.version || '1').trim() || '1',
|
||||
isActive: true,
|
||||
notes,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/** Shared appearance helpers for import/backfill scripts (no Vue deps). */
|
||||
|
||||
export type RegistryUser = {
|
||||
avatar?: string | null
|
||||
color?: string | null
|
||||
secondary_color?: string | null
|
||||
background_color?: string | null
|
||||
name?: string | null
|
||||
name_fa?: string | null
|
||||
theme?: string | null
|
||||
mode?: string | null
|
||||
node_server?: string | null
|
||||
app_ui_config?: unknown
|
||||
user?: RegistryUser
|
||||
}
|
||||
|
||||
export function pickRegistryPayload(raw: any): RegistryUser {
|
||||
if (raw?.user && typeof raw.user === 'object') {
|
||||
return { ...raw, ...raw.user }
|
||||
}
|
||||
return raw || {}
|
||||
}
|
||||
|
||||
export function resolveAvatarUrl(raw: string | null | undefined, registryBase: string) {
|
||||
if (!raw) return null
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
if (/^data:/i.test(raw)) return raw
|
||||
const base = registryBase.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||
return `${base}/${String(raw).replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export function buildHamkarUiFromBrand(input: {
|
||||
primaryColor?: string | null
|
||||
secondaryColor?: string | null
|
||||
backgroundColor?: string | null
|
||||
settingsManaged?: boolean
|
||||
registryMode?: string | null
|
||||
settings?: {
|
||||
pricing_box_theme?: string
|
||||
price_font_size?: string
|
||||
orderUI?: string
|
||||
}
|
||||
}) {
|
||||
return {
|
||||
kind: 'hamkar' as const,
|
||||
settingsManaged: Boolean(input.settingsManaged),
|
||||
registryMode: input.registryMode || 'dynamicMode',
|
||||
brand: {
|
||||
// Keep nulls — do NOT invent #1d4ed8 (causes wrong accent vs live registry)
|
||||
primaryColor: input.primaryColor || null,
|
||||
secondaryColor: input.secondaryColor || null,
|
||||
backgroundColor: input.backgroundColor || null,
|
||||
},
|
||||
settings: {
|
||||
pricing_box_theme: String(input.settings?.pricing_box_theme || '1'),
|
||||
price_font_size: String(input.settings?.price_font_size || 'بزرگ'),
|
||||
orderUI: String(input.settings?.orderUI || 'old'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKhanegiUiFromColors(input: {
|
||||
primary?: string | null
|
||||
secondary?: string | null
|
||||
background?: string | null
|
||||
existing?: any
|
||||
}) {
|
||||
if (
|
||||
input.existing &&
|
||||
typeof input.existing === 'object' &&
|
||||
(input.existing.schemaId === 'ui-config' || input.existing.app)
|
||||
) {
|
||||
return input.existing
|
||||
}
|
||||
|
||||
const primary = input.primary || '#0A1B37'
|
||||
const secondary = input.secondary || '#e3b056'
|
||||
const pageBg = input.background || '#121216'
|
||||
const surface = '#222228'
|
||||
|
||||
const dark = {
|
||||
primary,
|
||||
secondary,
|
||||
tertiary: secondary,
|
||||
background: '#ffffff',
|
||||
surface,
|
||||
surfaceHover: '#2c2c34',
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.45)',
|
||||
textSubtle: 'rgba(255, 255, 255, 0.35)',
|
||||
border: 'rgba(255, 255, 255, 0.16)',
|
||||
borderSubtle: 'rgba(255, 255, 255, 0.12)',
|
||||
pageBg,
|
||||
pageBgMobile: pageBg,
|
||||
onAccent: '#0a0a0a',
|
||||
success: '#16A34A',
|
||||
warning: '#F59E0B',
|
||||
danger: '#DC2626',
|
||||
}
|
||||
|
||||
const light = {
|
||||
...dark,
|
||||
surface: '#ffffff',
|
||||
surfaceHover: '#fffefb',
|
||||
textPrimary: '#1c1916',
|
||||
textSecondary: '#5c564d',
|
||||
textSubtle: '#8a8278',
|
||||
border: 'rgba(0, 0, 0, 0.08)',
|
||||
borderSubtle: 'rgba(0, 0, 0, 0.06)',
|
||||
pageBg: pageBg === '#121216' ? '#f5f2ed' : pageBg,
|
||||
pageBgMobile: pageBg === '#121216' ? '#faf8f5' : pageBg,
|
||||
onAccent: '#ffffff',
|
||||
}
|
||||
|
||||
return {
|
||||
schemaId: 'ui-config',
|
||||
version: '1.0.0',
|
||||
app: {
|
||||
schemaId: 'app',
|
||||
version: '1.0.0',
|
||||
direction: 'rtl',
|
||||
locale: 'fa-IR',
|
||||
startRoute: 'home',
|
||||
theme: {
|
||||
presetId: 'custom',
|
||||
mode: 'dark',
|
||||
colors: { ...dark },
|
||||
schemes: { dark: { ...dark }, light: { ...light } },
|
||||
borderRadius: { sm: 8, md: 12, lg: 16 },
|
||||
},
|
||||
navigation: {
|
||||
type: 'sidebar+bottom',
|
||||
sidebar: [
|
||||
{ id: 'home', label: 'داشبورد', icon: 'vuesax-linear:chart', route: 'home', order: 1, visible: true },
|
||||
{ id: 'products', label: 'معاملات', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true },
|
||||
{ id: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
||||
{ id: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
||||
],
|
||||
bottom: [
|
||||
{ id: 'home', label: 'خانه', icon: 'vuesax-linear:home-2', route: 'home', order: 1, visible: true },
|
||||
{ id: 'products', label: 'معامله', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true },
|
||||
{ id: 'transactions', label: 'سوابق', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
||||
{ id: 'user', label: 'پروفایل', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
||||
],
|
||||
sidebarReferral: {
|
||||
visible: true,
|
||||
title: 'دعوت از دوستان',
|
||||
subtitle: 'کسب درآمد',
|
||||
route: 'earnMoney',
|
||||
},
|
||||
},
|
||||
header: {
|
||||
visible: true,
|
||||
showLogo: true,
|
||||
showNotifications: true,
|
||||
showProfile: true,
|
||||
brandSubtitle: 'پنل کاربری',
|
||||
},
|
||||
},
|
||||
pages: {},
|
||||
_backfill: 'colors-from-live-registry',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_releases" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"product" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_releases_product_key" ON "product_releases"("product");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tenants" ADD COLUMN "app_version" TEXT NOT NULL DEFAULT '1';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "product_releases";
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Rebuild users for mobile login (SQLite)
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "users_new" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"mobile" TEXT NOT NULL,
|
||||
"first_name" TEXT NOT NULL,
|
||||
"last_name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'super_admin',
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO "users_new" (
|
||||
"id", "mobile", "first_name", "last_name", "email",
|
||||
"password_hash", "role", "is_active", "created_at", "updated_at"
|
||||
)
|
||||
SELECT
|
||||
"id",
|
||||
CASE
|
||||
WHEN "email" = 'admin@abshode.local' THEN '09128081392'
|
||||
ELSE printf('09%09d', abs(random()) % 1000000000)
|
||||
END,
|
||||
CASE
|
||||
WHEN instr("name", ' ') > 0 THEN substr("name", 1, instr("name", ' ') - 1)
|
||||
ELSE "name"
|
||||
END,
|
||||
CASE
|
||||
WHEN instr("name", ' ') > 0 THEN substr("name", instr("name", ' ') + 1)
|
||||
ELSE ''
|
||||
END,
|
||||
"email",
|
||||
"password_hash",
|
||||
"role",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "users";
|
||||
|
||||
DROP TABLE "users";
|
||||
ALTER TABLE "users_new" RENAME TO "users";
|
||||
|
||||
CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile");
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "server_accounts" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"is_dedicated" BOOLEAN NOT NULL DEFAULT false,
|
||||
"server_name" TEXT,
|
||||
"specs" TEXT,
|
||||
"price_rials" INTEGER,
|
||||
"charged_at" DATETIME,
|
||||
"plan_months" INTEGER,
|
||||
"expires_at" DATETIME,
|
||||
"description" TEXT,
|
||||
"alert_days_before" INTEGER NOT NULL DEFAULT 3,
|
||||
"last_alert_date" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "server_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "server_charges" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"server_account_id" TEXT NOT NULL,
|
||||
"charged_at" DATETIME NOT NULL,
|
||||
"plan_months" INTEGER NOT NULL,
|
||||
"price_rials" INTEGER NOT NULL,
|
||||
"expires_at" DATETIME NOT NULL,
|
||||
"note" TEXT,
|
||||
"created_by_id" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "server_charges_server_account_id_fkey" FOREIGN KEY ("server_account_id") REFERENCES "server_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "server_accounts_tenant_id_key" ON "server_accounts"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_accounts_expires_at_idx" ON "server_accounts"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_accounts_is_dedicated_idx" ON "server_accounts"("is_dedicated");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_charges_server_account_id_idx" ON "server_charges"("server_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_charges_created_at_idx" ON "server_charges"("created_at");
|
||||
@@ -0,0 +1,47 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "plugin_subscriptions" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"specs" TEXT,
|
||||
"price_rials" INTEGER,
|
||||
"charged_at" DATETIME,
|
||||
"plan_months" INTEGER,
|
||||
"expires_at" DATETIME,
|
||||
"description" TEXT,
|
||||
"alert_days_before" INTEGER NOT NULL DEFAULT 3,
|
||||
"last_alert_date" DATETIME,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "plugin_subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "plugin_charges" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"plugin_id" TEXT NOT NULL,
|
||||
"charged_at" DATETIME NOT NULL,
|
||||
"plan_months" INTEGER NOT NULL,
|
||||
"price_rials" INTEGER NOT NULL,
|
||||
"expires_at" DATETIME NOT NULL,
|
||||
"note" TEXT,
|
||||
"created_by_id" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "plugin_charges_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugin_subscriptions" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "plugin_subscriptions_tenant_id_idx" ON "plugin_subscriptions"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "plugin_subscriptions_expires_at_idx" ON "plugin_subscriptions"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "plugin_subscriptions_is_active_idx" ON "plugin_subscriptions"("is_active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "plugin_charges_plugin_id_idx" ON "plugin_charges"("plugin_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "plugin_charges_created_at_idx" ON "plugin_charges"("created_at");
|
||||
@@ -0,0 +1,36 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sms_accounts" ADD COLUMN "charge_card" TEXT;
|
||||
ALTER TABLE "sms_accounts" ADD COLUMN "charge_iban" TEXT;
|
||||
ALTER TABLE "sms_accounts" ADD COLUMN "charge_holder" TEXT;
|
||||
ALTER TABLE "sms_accounts" ADD COLUMN "charge_instruction" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sms_ledgers" ADD COLUMN "balance_after_rials" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_ledgers_type_idx" ON "sms_ledgers"("type");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sms_send_logs" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sms_account_id" TEXT NOT NULL,
|
||||
"receiver" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"parts" INTEGER NOT NULL DEFAULT 1,
|
||||
"price_per_part_rials" INTEGER NOT NULL,
|
||||
"total_amount_rials" INTEGER NOT NULL,
|
||||
"provider" TEXT,
|
||||
"source" TEXT,
|
||||
"is_alert" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "sms_send_logs_sms_account_id_fkey" FOREIGN KEY ("sms_account_id") REFERENCES "sms_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_send_logs_sms_account_id_idx" ON "sms_send_logs"("sms_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_send_logs_created_at_idx" ON "sms_send_logs"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_send_logs_provider_idx" ON "sms_send_logs"("provider");
|
||||
+145
-12
@@ -26,9 +26,12 @@ enum SmsLedgerType {
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
mobile String @unique
|
||||
firstName String @map("first_name")
|
||||
lastName String @map("last_name")
|
||||
/// Optional legacy/contact email — login uses mobile
|
||||
email String? @unique
|
||||
passwordHash String @map("password_hash")
|
||||
name String
|
||||
role UserRole @default(super_admin)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -60,12 +63,16 @@ model Tenant {
|
||||
smsSyncedAt DateTime? @map("sms_synced_at")
|
||||
/// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now.
|
||||
appUiConfig Json? @map("app_ui_config")
|
||||
/// Client cache-bust / force-reload signal for this sold version (GET /get_version).
|
||||
appVersion String @default("1") @map("app_version")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
auditLogs AuditLog[]
|
||||
smsAccount SmsAccount?
|
||||
serverAccount ServerAccount?
|
||||
pluginSubscriptions PluginSubscription[]
|
||||
|
||||
@@index([product])
|
||||
@@index([isActive])
|
||||
@@ -91,6 +98,11 @@ model SmsAccount {
|
||||
panelUsername String? @map("panel_username")
|
||||
panelPassword String? @map("panel_password")
|
||||
panelFrom String? @map("panel_from")
|
||||
/// Deposit instructions shown to tenant managers (parity with Hamkar sms-panel)
|
||||
chargeCard String? @map("charge_card")
|
||||
chargeIban String? @map("charge_iban")
|
||||
chargeHolder String? @map("charge_holder")
|
||||
chargeInstruction String? @map("charge_instruction")
|
||||
extraSettings Json? @map("extra_settings")
|
||||
todayMessages Int @default(0) @map("today_messages")
|
||||
todayParts Int @default(0) @map("today_parts")
|
||||
@@ -101,29 +113,54 @@ model SmsAccount {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ledgers SmsLedger[]
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ledgers SmsLedger[]
|
||||
sendLogs SmsSendLog[]
|
||||
|
||||
@@map("sms_accounts")
|
||||
}
|
||||
|
||||
model SmsLedger {
|
||||
id String @id @default(cuid())
|
||||
smsAccountId String @map("sms_account_id")
|
||||
type SmsLedgerType
|
||||
amountRials Int @map("amount_rials")
|
||||
parts Int?
|
||||
note String?
|
||||
createdById String? @map("created_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id String @id @default(cuid())
|
||||
smsAccountId String @map("sms_account_id")
|
||||
type SmsLedgerType
|
||||
amountRials Int @map("amount_rials")
|
||||
balanceAfterRials Int? @map("balance_after_rials")
|
||||
parts Int?
|
||||
note String?
|
||||
createdById String? @map("created_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([smsAccountId])
|
||||
@@index([createdAt])
|
||||
@@index([type])
|
||||
@@map("sms_ledgers")
|
||||
}
|
||||
|
||||
/// Per-tenant SMS send history (filled when products hook consume; empty until then)
|
||||
model SmsSendLog {
|
||||
id String @id @default(cuid())
|
||||
smsAccountId String @map("sms_account_id")
|
||||
receiver String
|
||||
body String
|
||||
parts Int @default(1)
|
||||
pricePerPartRials Int @map("price_per_part_rials")
|
||||
totalAmountRials Int @map("total_amount_rials")
|
||||
provider String?
|
||||
source String?
|
||||
isAlert Boolean @default(false) @map("is_alert")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([smsAccountId])
|
||||
@@index([createdAt])
|
||||
@@index([provider])
|
||||
@@map("sms_send_logs")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
action String
|
||||
@@ -144,3 +181,99 @@ model AuditLog {
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
/// Server hosting status for a sold version.
|
||||
/// isDedicated=true → customer-owned; SuperAdmin only tracks that flag.
|
||||
/// isDedicated=false → we manage charge/renewal/expiry alerts.
|
||||
model ServerAccount {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
isDedicated Boolean @default(false) @map("is_dedicated")
|
||||
serverName String? @map("server_name")
|
||||
specs String?
|
||||
/// Price of the current/last plan period (rials)
|
||||
priceRials Int? @map("price_rials")
|
||||
/// Start of current paid period
|
||||
chargedAt DateTime? @map("charged_at")
|
||||
/// Plan length in months: 1 | 3 | 6 | 12
|
||||
planMonths Int? @map("plan_months")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
description String?
|
||||
/// Days before expiry to start SMS alerts (default 3)
|
||||
alertDaysBefore Int @default(3) @map("alert_days_before")
|
||||
/// Last calendar day an expiry alert was sent (avoid multi-send same day)
|
||||
lastAlertDate DateTime? @map("last_alert_date")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
charges ServerCharge[]
|
||||
|
||||
@@index([expiresAt])
|
||||
@@index([isDedicated])
|
||||
@@map("server_accounts")
|
||||
}
|
||||
|
||||
model ServerCharge {
|
||||
id String @id @default(cuid())
|
||||
serverAccountId String @map("server_account_id")
|
||||
chargedAt DateTime @map("charged_at")
|
||||
planMonths Int @map("plan_months")
|
||||
priceRials Int @map("price_rials")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
note String?
|
||||
createdById String? @map("created_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
serverAccount ServerAccount @relation(fields: [serverAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverAccountId])
|
||||
@@index([createdAt])
|
||||
@@map("server_charges")
|
||||
}
|
||||
|
||||
/// Paid plugin/addon subscription for a sold version (0..N per tenant).
|
||||
model PluginSubscription {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
/// Plugin display name (required; each tenant can have many)
|
||||
name String
|
||||
specs String?
|
||||
priceRials Int? @map("price_rials")
|
||||
chargedAt DateTime? @map("charged_at")
|
||||
/// Plan length in months: 1 | 3 | 6 | 12
|
||||
planMonths Int? @map("plan_months")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
description String?
|
||||
alertDaysBefore Int @default(3) @map("alert_days_before")
|
||||
lastAlertDate DateTime? @map("last_alert_date")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
charges PluginCharge[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([expiresAt])
|
||||
@@index([isActive])
|
||||
@@map("plugin_subscriptions")
|
||||
}
|
||||
|
||||
model PluginCharge {
|
||||
id String @id @default(cuid())
|
||||
pluginId String @map("plugin_id")
|
||||
chargedAt DateTime @map("charged_at")
|
||||
planMonths Int @map("plan_months")
|
||||
priceRials Int @map("price_rials")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
note String?
|
||||
createdById String? @map("created_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
plugin PluginSubscription @relation(fields: [pluginId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([pluginId])
|
||||
@@index([createdAt])
|
||||
@@map("plugin_charges")
|
||||
}
|
||||
|
||||
+27
-67
@@ -4,87 +4,47 @@ import * as bcrypt from 'bcrypt';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const email = (process.env.SEED_ADMIN_EMAIL || 'admin@abshode.local').toLowerCase();
|
||||
const mobile = (process.env.SEED_ADMIN_MOBILE || '09128081392').replace(/\D/g, '');
|
||||
const normalized =
|
||||
mobile.startsWith('98') && mobile.length >= 12
|
||||
? `0${mobile.slice(2)}`
|
||||
: mobile.startsWith('9') && mobile.length === 10
|
||||
? `0${mobile}`
|
||||
: mobile;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD || 'Admin@12345';
|
||||
const name = process.env.SEED_ADMIN_NAME || 'Super Admin';
|
||||
const firstName = process.env.SEED_ADMIN_FIRST_NAME || 'مدیر';
|
||||
const lastName = process.env.SEED_ADMIN_LAST_NAME || 'اصلی';
|
||||
const email = (process.env.SEED_ADMIN_EMAIL || 'admin@abshode.local').toLowerCase();
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { passwordHash, name, isActive: true, role: 'super_admin' },
|
||||
where: { mobile: normalized },
|
||||
update: {
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
isActive: true,
|
||||
role: 'super_admin',
|
||||
},
|
||||
create: {
|
||||
mobile: normalized,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
role: 'super_admin',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Extracted from Hamkar SettingsSeeder (bakhtargold) — real project defaults found in code
|
||||
const bakhtarSmsValues = {
|
||||
name_fa: 'باختر گلد',
|
||||
name_en: 'bakhtargold',
|
||||
app_url: 'bakhtargold.com',
|
||||
admin_url: 'https://bakhtargold.com/admin',
|
||||
sms_long: 'true',
|
||||
sms_panel: '',
|
||||
melipayamak_user: '',
|
||||
melipayamak_password: '',
|
||||
melipayamak_from: '',
|
||||
sms_panel_username: '',
|
||||
sms_panel_password: '',
|
||||
sms_panel_from: '',
|
||||
send_sms_on_customer_order: '',
|
||||
sms_charge_card: '',
|
||||
sms_charge_iban: '',
|
||||
sms_charge_account_holder: '',
|
||||
sms_charge_instruction: '',
|
||||
};
|
||||
|
||||
const bakhtar = await prisma.tenant.upsert({
|
||||
where: { slug: 'bakhtargold' },
|
||||
update: {
|
||||
nameFa: bakhtarSmsValues.name_fa,
|
||||
nameEn: bakhtarSmsValues.name_en,
|
||||
domain: 'bakhtargold.com',
|
||||
appUrl: bakhtarSmsValues.app_url,
|
||||
adminUrl: bakhtarSmsValues.admin_url,
|
||||
smsSettingsCache: {
|
||||
values: bakhtarSmsValues,
|
||||
wallet: null,
|
||||
source: 'hamkar/database/seeders/SettingsSeeder.php',
|
||||
syncedAt: new Date().toISOString(),
|
||||
note: 'استخراج از سیدر کد؛ برای داده زنده DB باید همگامسازی MySQL انجام شود',
|
||||
},
|
||||
smsSyncedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
product: 'hamkar',
|
||||
slug: 'bakhtargold',
|
||||
nameFa: bakhtarSmsValues.name_fa,
|
||||
nameEn: bakhtarSmsValues.name_en,
|
||||
domain: 'bakhtargold.com',
|
||||
primaryColor: '#20c997',
|
||||
nodeServer: 'https://bakhtargold.com/server',
|
||||
appUrl: bakhtarSmsValues.app_url,
|
||||
adminUrl: bakhtarSmsValues.admin_url,
|
||||
isActive: true,
|
||||
notes: 'نسخه استخراجشده از کد/سیدر همکار',
|
||||
smsSettingsCache: {
|
||||
values: bakhtarSmsValues,
|
||||
wallet: null,
|
||||
source: 'hamkar/database/seeders/SettingsSeeder.php',
|
||||
syncedAt: new Date().toISOString(),
|
||||
},
|
||||
smsSyncedAt: new Date(),
|
||||
},
|
||||
console.log('Seeded admin:', {
|
||||
id: user.id,
|
||||
mobile: user.mobile,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
});
|
||||
|
||||
console.log('Seeded admin:', { id: user.id, email: user.email });
|
||||
console.log('Seeded tenant:', { id: bakhtar.id, slug: bakhtar.slug });
|
||||
console.log('Login with:', email, '/', password);
|
||||
console.log('Login with:', normalized, '/', password);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user