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:
2026-07-30 18:19:53 +03:30
co-authored by Cursor
parent ddf36f8470
commit a6b3457779
54 changed files with 3770 additions and 156 deletions
+33
View File
@@ -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();
});
+272
View File
@@ -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()
})
+1
View File
@@ -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,
};
+165
View File
@@ -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
View File
@@ -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
View File
@@ -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()