Creating a control panel infrastructure

This commit is contained in:
2026-07-23 19:41:21 +03:30
parent 7fbc47766c
commit ddf36f8470
18 changed files with 3377 additions and 35 deletions
+14 -3
View File
@@ -1,11 +1,22 @@
PORT=3001 PORT=3001
# Local (SQLite):
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
# MySQL example (after creating DB):
# DATABASE_URL="mysql://superadmin:superadmin@127.0.0.1:3306/superadmin"
JWT_SECRET="change-me-in-production-use-long-random-string" JWT_SECRET="change-me-in-production-use-long-random-string"
JWT_EXPIRES_IN="7d" JWT_EXPIRES_IN="7d"
CORS_ORIGIN="http://localhost:5173" CORS_ORIGIN="http://localhost:5173"
SEED_ADMIN_EMAIL="admin@abshode.local" SEED_ADMIN_EMAIL="admin@abshode.local"
SEED_ADMIN_PASSWORD="Admin@12345" SEED_ADMIN_PASSWORD="Admin@12345"
SEED_ADMIN_NAME="Super Admin" SEED_ADMIN_NAME="Super Admin"
HAMKAR_DB_HOST=127.0.0.1
HAMKAR_DB_PORT=3306
HAMKAR_DB_USER=root
HAMKAR_DB_PASSWORD=
HAMKAR_DB_DATABASE=laravel
HAMKAR_DB_NODE_SERVER=http://127.0.0.1:8000
# KHANEGI_DB_HOST=127.0.0.1
# KHANEGI_DB_PORT=3306
# KHANEGI_DB_USER=root
# KHANEGI_DB_PASSWORD=
# KHANEGI_DB_DATABASE=khanegi
# KHANEGI_DB_NODE_SERVER=http://127.0.0.1:8001
+104
View File
@@ -21,6 +21,7 @@
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"mysql2": "^3.23.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -4250,6 +4251,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.18.1", "version": "1.18.1",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz",
@@ -5265,6 +5275,15 @@
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -6356,6 +6375,15 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6884,6 +6912,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz",
@@ -8090,6 +8124,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8100,6 +8140,21 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
@@ -8579,6 +8634,40 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/mysql2": {
"version": "3.23.1",
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.23.1.tgz",
"integrity": "sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/napi-postinstall": { "node_modules/napi-postinstall": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "resolved": "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -9864,6 +9953,21 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/sql-escaper": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.5.1.tgz",
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stack-utils": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz",
+3 -1
View File
@@ -21,7 +21,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy", "prisma:deploy": "prisma migrate deploy",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts" "prisma:seed": "ts-node --transpile-only prisma/seed.ts",
"prisma:import-users": "ts-node --transpile-only prisma/import-users.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts" "seed": "ts-node --transpile-only prisma/seed.ts"
@@ -39,6 +40,7 @@
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"mysql2": "^3.23.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
File diff suppressed because one or more lines are too long
+325
View File
@@ -0,0 +1,325 @@
import * as fs from 'fs';
import * as path from 'path';
import { Prisma, PrismaClient, ProductType } from '@prisma/client';
const prisma = new PrismaClient();
type SourceUser = {
id: number;
name: string;
name_fa: string;
number?: string | null;
avatar?: string | null;
domains?: string | string[] | null;
node_server?: string | null;
node_ws?: string | null;
color?: string | null;
secondary_color?: string | null;
background_color?: string | null;
domain?: string | null;
version?: string | null;
app_ui_config?: unknown;
deleted_at?: string | null;
mode?: string | null;
};
/** Manual product corrections from SuperAdmin review */
const FORCE_KHANEGI: Array<{ domain?: string; name?: string; externalId?: number }> = [
{ domain: 'abshodehtabatabaei.ir' },
{ domain: 'boursemes.com' },
{ domain: 'abaeigold.ir' },
{ domain: 'niazitala.ir' },
{ domain: 'garoosigholaktala.ir' },
{ domain: 'kh.kashkolgold.ir' },
{ domain: 'goldsun.zaarbox.ir' },
{ domain: 'goldaliii.com' },
];
/** Canonical public domains after migrations */
const DOMAIN_OVERRIDES: Record<string, { domain: string; appUrl: string; nodeServer: string }> = {
'24gold.app': {
domain: '24gold.ir',
appUrl: 'https://24gold.ir',
nodeServer: 'https://24gold.ir/server',
},
'liman.gold': {
domain: 'limangold.ir',
appUrl: 'https://limangold.ir',
nodeServer: 'https://limangold.ir/server',
},
'hamkar2.vahid.gold': {
domain: 'vahid.gold',
appUrl: 'https://vahid.gold',
nodeServer: 'https://vahid.gold/server',
},
};
function parseDomains(raw: SourceUser['domains']): string[] {
if (!raw) return [];
try {
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(value)) return [];
return value
.map((item) => String(item).trim().toLowerCase())
.filter(Boolean)
.map((item) => item.replace(/^https?:\/\//, '').split('/')[0]);
} catch {
return [];
}
}
function isIp(host: string) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
}
function hostFromUrl(url?: string | null) {
if (!url) return '';
return url
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
.split(':')[0];
}
function classifyProduct(row: SourceUser, domains: string[]): {
product: ProductType;
confident: boolean;
reason: string;
} {
const forced = FORCE_KHANEGI.find((item) => {
if (item.externalId && item.externalId === row.id) return true;
if (item.domain && domains.includes(item.domain)) return true;
return false;
});
if (forced) {
return {
product: 'khanegi',
confident: true,
reason: `manual override (${forced.domain || forced.externalId})`,
};
}
if (domains.some((d) => d.startsWith('app.'))) {
return { product: 'khanegi', confident: true, reason: 'domain starts with app.' };
}
if (domains.some((d) => d.startsWith('khanegi.'))) {
return { product: 'khanegi', confident: true, reason: 'domain starts with khanegi.' };
}
if (domains.some((d) => d.startsWith('hamkar.'))) {
return { product: 'hamkar', confident: true, reason: 'domain starts with hamkar.' };
}
const nodeHost = hostFromUrl(row.node_server);
if (nodeHost.startsWith('app.')) {
return {
product: 'khanegi',
confident: true,
reason: 'node_server host starts with app.',
};
}
// Fallback guess — flagged for manual review
if (row.app_ui_config) {
return {
product: 'khanegi',
confident: false,
reason: 'has app_ui_config but no app./hamkar. domain',
};
}
return {
product: 'hamkar',
confident: false,
reason: 'no app./hamkar. domain — defaulted to hamkar',
};
}
function pickDomain(product: ProductType, domains: string[], row: SourceUser) {
for (const domain of domains) {
if (DOMAIN_OVERRIDES[domain]) return DOMAIN_OVERRIDES[domain].domain;
}
const preferredPrefix = product === 'khanegi' ? 'app.' : 'hamkar.';
const preferred = domains.find((d) => d.startsWith(preferredPrefix));
if (preferred) return preferred;
const nonIp = domains.find((d) => !isIp(d));
if (nonIp) return nonIp;
const fromNode = hostFromUrl(row.node_server);
if (fromNode && !isIp(fromNode)) return fromNode;
if (row.domain) return String(row.domain).toLowerCase();
return domains[0] || `${slugify(row.name)}.local`;
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '')
.replace(/\.+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'tenant';
}
function avatarUrl(raw?: string | null) {
if (!raw) return null;
if (/^https?:\/\//i.test(raw)) return raw;
return null;
}
function asUiConfig(value: unknown): Prisma.InputJsonValue | undefined {
if (value == null) return undefined;
if (typeof value === 'string') {
try {
return JSON.parse(value) as Prisma.InputJsonValue;
} catch {
return undefined;
}
}
if (typeof value === 'object') return value as Prisma.InputJsonValue;
return undefined;
}
async function uniqueSlug(base: string, product: ProductType, externalId: number) {
const candidates = [
base,
`${base}-${product}`,
`${base}-${product}-${externalId}`,
`${base}-${externalId}`,
];
for (const slug of candidates) {
const existing = await prisma.tenant.findUnique({ where: { slug } });
if (!existing) return slug;
// allow overwrite of previously imported same external id via notes marker
if (existing.notes?.includes(`externalId:${externalId}`)) return slug;
}
return `${base}-${product}-${externalId}-${Date.now()}`;
}
async function uniqueDomain(preferred: string, externalId: number) {
const candidates = [
preferred,
`${productSafeHost(preferred)}`,
`id${externalId}.${preferred}`,
`ext-${externalId}.${preferred}`,
];
for (const domain of candidates) {
const existing = await prisma.tenant.findUnique({ where: { domain } });
if (!existing) return domain;
if (existing.notes?.includes(`externalId:${externalId}`)) return domain;
}
return `ext-${externalId}-${Date.now()}.local`;
}
function productSafeHost(host: string) {
return host;
}
async function main() {
const file = path.join(__dirname, 'data', 'users.json');
if (!fs.existsSync(file)) {
throw new Error(`File not found: ${file}`);
}
const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as SourceUser[];
const summary = {
total: rows.length,
imported: 0,
skippedDeleted: 0,
hamkar: 0,
khanegi: 0,
uncertain: 0,
withUiConfig: 0,
};
for (const row of rows) {
if (row.deleted_at) {
summary.skippedDeleted += 1;
continue;
}
const domains = parseDomains(row.domains);
const { product, confident, reason } = classifyProduct(row, domains);
const baseSlug = slugify(row.name);
const slug = await uniqueSlug(baseSlug, product, row.id);
const preferredDomain = pickDomain(product, domains, row);
const domain = await uniqueDomain(preferredDomain, row.id);
const ui = asUiConfig(row.app_ui_config);
const domainOverride =
DOMAIN_OVERRIDES[domain] ||
domains.map((d) => DOMAIN_OVERRIDES[d]).find(Boolean);
const notes = [
`externalId:${row.id}`,
`import:users.json`,
`classify:${reason}`,
confident ? 'classifyConfidence:high' : 'classifyConfidence:low',
row.number ? `phone:${row.number}` : null,
row.version ? `version:${row.version}` : null,
row.mode ? `mode:${row.mode}` : null,
]
.filter(Boolean)
.join(' | ');
const data = {
product,
slug,
nameFa: (row.name_fa || row.name).replace(/\s+/g, ' ').trim(),
nameEn: row.name,
domain: domainOverride?.domain || domain,
domains: domains as unknown as Prisma.InputJsonValue,
avatarUrl: avatarUrl(row.avatar),
primaryColor: row.color || (product === 'hamkar' ? '#20c997' : '#ffb84d'),
secondaryColor: row.secondary_color || null,
backgroundColor: row.background_color || '#ffffff',
nodeServer:
domainOverride?.nodeServer || row.node_server || `https://${domain}/server`,
nodeWs: row.node_ws || null,
appUrl:
domainOverride?.appUrl || domains.find((d) => !isIp(d)) || domain,
adminUrl: null as string | null,
appUiConfig: ui,
isActive: true,
notes,
};
const existingByNotes = await prisma.tenant.findFirst({
where: { notes: { contains: `externalId:${row.id}` } },
});
if (existingByNotes) {
await prisma.tenant.update({
where: { id: existingByNotes.id },
data,
});
} else {
// Prefer update by slug if this is a known seed like bakhtargold
const bySlug = await prisma.tenant.findUnique({ where: { slug } });
if (bySlug) {
await prisma.tenant.update({ where: { id: bySlug.id }, data });
} else {
await prisma.tenant.create({ data });
}
}
summary.imported += 1;
summary[product] += 1;
if (!confident) summary.uncertain += 1;
if (ui) summary.withUiConfig += 1;
}
console.log('Import finished:', summary);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "sms_settings_cache" JSONB;
ALTER TABLE "tenants" ADD COLUMN "sms_synced_at" DATETIME;
@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "sms_accounts" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenant_id" TEXT NOT NULL,
"balance_rials" INTEGER NOT NULL DEFAULT 0,
"price_per_part_rials" INTEGER NOT NULL DEFAULT 2500,
"is_blocked" BOOLEAN NOT NULL DEFAULT false,
"alert_critical_rials" INTEGER NOT NULL DEFAULT 10000,
"alert_warning_rials" INTEGER NOT NULL DEFAULT 100000,
"gateway_provider" TEXT,
"melipayamak_user" TEXT,
"melipayamak_password" TEXT,
"melipayamak_from" TEXT,
"panel_username" TEXT,
"panel_password" TEXT,
"panel_from" TEXT,
"extra_settings" JSONB,
"today_messages" INTEGER NOT NULL DEFAULT 0,
"today_parts" INTEGER NOT NULL DEFAULT 0,
"today_cost_rials" INTEGER NOT NULL DEFAULT 0,
"month_messages" INTEGER NOT NULL DEFAULT 0,
"month_parts" INTEGER NOT NULL DEFAULT 0,
"month_cost_rials" INTEGER NOT NULL DEFAULT 0,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL,
CONSTRAINT "sms_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "sms_ledgers" (
"id" TEXT NOT NULL PRIMARY KEY,
"sms_account_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amount_rials" INTEGER NOT NULL,
"parts" INTEGER,
"note" TEXT,
"created_by_id" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sms_ledgers_sms_account_id_fkey" FOREIGN KEY ("sms_account_id") REFERENCES "sms_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "sms_accounts_tenant_id_key" ON "sms_accounts"("tenant_id");
-- CreateIndex
CREATE INDEX "sms_ledgers_sms_account_id_idx" ON "sms_ledgers"("sms_account_id");
-- CreateIndex
CREATE INDEX "sms_ledgers_created_at_idx" ON "sms_ledgers"("created_at");
+62
View File
@@ -18,6 +18,12 @@ enum UserRole {
viewer viewer
} }
enum SmsLedgerType {
charge
debit
adjust
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
@@ -49,6 +55,9 @@ model Tenant {
appUrl String? @map("app_url") appUrl String? @map("app_url")
adminUrl String? @map("admin_url") adminUrl String? @map("admin_url")
remoteApiToken String? @map("remote_api_token") remoteApiToken String? @map("remote_api_token")
/// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth
smsSettingsCache Json? @map("sms_settings_cache")
smsSyncedAt DateTime? @map("sms_synced_at")
/// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now. /// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now.
appUiConfig Json? @map("app_ui_config") appUiConfig Json? @map("app_ui_config")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
@@ -56,12 +65,65 @@ model Tenant {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[] auditLogs AuditLog[]
smsAccount SmsAccount?
@@index([product]) @@index([product])
@@index([isActive]) @@index([isActive])
@@map("tenants") @@map("tenants")
} }
/// SuperAdmin-owned SMS wallet + gateway config per sold version.
/// Does NOT replace Hamkar/Khanegi internal SMS panels yet.
model SmsAccount {
id String @id @default(cuid())
tenantId String @unique @map("tenant_id")
balanceRials Int @default(0) @map("balance_rials")
pricePerPartRials Int @default(2500) @map("price_per_part_rials")
isBlocked Boolean @default(false) @map("is_blocked")
/// below this => red/critical
alertCriticalRials Int @default(10000) @map("alert_critical_rials")
/// at/below this (and above critical) => yellow; above => green
alertWarningRials Int @default(100000) @map("alert_warning_rials")
gatewayProvider String? @map("gateway_provider")
melipayamakUser String? @map("melipayamak_user")
melipayamakPassword String? @map("melipayamak_password")
melipayamakFrom String? @map("melipayamak_from")
panelUsername String? @map("panel_username")
panelPassword String? @map("panel_password")
panelFrom String? @map("panel_from")
extraSettings Json? @map("extra_settings")
todayMessages Int @default(0) @map("today_messages")
todayParts Int @default(0) @map("today_parts")
todayCostRials Int @default(0) @map("today_cost_rials")
monthMessages Int @default(0) @map("month_messages")
monthParts Int @default(0) @map("month_parts")
monthCostRials Int @default(0) @map("month_cost_rials")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
ledgers SmsLedger[]
@@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")
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
@@index([smsAccountId])
@@index([createdAt])
@@map("sms_ledgers")
}
model AuditLog { model AuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
action String action String
+55 -17
View File
@@ -22,30 +22,68 @@ async function main() {
}, },
}); });
const sample = await prisma.tenant.upsert({ // Extracted from Hamkar SettingsSeeder (bakhtargold) — real project defaults found in code
where: { slug: 'demo-hamkar' }, const bakhtarSmsValues = {
update: {}, 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: { create: {
product: 'hamkar', product: 'hamkar',
slug: 'demo-hamkar', slug: 'bakhtargold',
nameFa: 'نسخه دمو همکار', nameFa: bakhtarSmsValues.name_fa,
nameEn: 'Demo Hamkar', nameEn: bakhtarSmsValues.name_en,
domain: 'demo-hamkar.local', domain: 'bakhtargold.com',
avatarUrl: null, primaryColor: '#20c997',
primaryColor: '#1d4ed8', nodeServer: 'https://bakhtargold.com/server',
secondaryColor: '#0f172a', appUrl: bakhtarSmsValues.app_url,
backgroundColor: '#ffffff', adminUrl: bakhtarSmsValues.admin_url,
nodeServer: 'https://demo-hamkar.local/server',
nodeWs: 'wss://demo-hamkar.local/prc/ws',
appUrl: 'https://demo-hamkar.local',
adminUrl: 'https://demo-hamkar.local/admin',
isActive: true, isActive: true,
notes: 'نمونه اولیه برای تست اسکفولد', 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, email: user.email }); console.log('Seeded admin:', { id: user.id, email: user.email });
console.log('Seeded tenant:', { id: sample.id, slug: sample.slug }); console.log('Seeded tenant:', { id: bakhtar.id, slug: bakhtar.slug });
console.log('Login with:', email, '/', password); console.log('Login with:', email, '/', password);
} }
+2
View File
@@ -6,6 +6,7 @@ import { TenantsModule } from './tenants/tenants.module';
import { GetUserModule } from './get-user/get-user.module'; import { GetUserModule } from './get-user/get-user.module';
import { SettingsProxyModule } from './settings-proxy/settings-proxy.module'; import { SettingsProxyModule } from './settings-proxy/settings-proxy.module';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
import { SmsModule } from './sms/sms.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@Module({ @Module({
@@ -17,6 +18,7 @@ import { HealthController } from './health.controller';
GetUserModule, GetUserModule,
SettingsProxyModule, SettingsProxyModule,
AuditModule, AuditModule,
SmsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+37 -11
View File
@@ -11,17 +11,11 @@ export class GetUserService {
throw new NotFoundException('domain required'); throw new NotFoundException('domain required');
} }
const tenant = await this.prisma.tenant.findFirst({ const tenants = await this.prisma.tenant.findMany({
where: { where: { isActive: true },
isActive: true,
OR: [
{ domain: host },
{ domain: host.replace(/^www\./, '') },
{ domain: `www.${host.replace(/^www\./, '')}` },
],
},
}); });
const tenant = tenants.find((item) => this.matchesHost(item, host));
if (!tenant) { if (!tenant) {
throw new NotFoundException('tenant not found for domain'); throw new NotFoundException('tenant not found for domain');
} }
@@ -31,7 +25,7 @@ export class GetUserService {
const branding = { const branding = {
id: tenant.id, id: tenant.id,
name: tenant.slug, name: tenant.nameEn || tenant.slug,
name_fa: tenant.nameFa, name_fa: tenant.nameFa,
name_en: tenant.nameEn, name_en: tenant.nameEn,
avatar: tenant.avatarUrl, avatar: tenant.avatarUrl,
@@ -43,7 +37,7 @@ export class GetUserService {
app_url: tenant.appUrl, app_url: tenant.appUrl,
admin_url: tenant.adminUrl, admin_url: tenant.adminUrl,
product: tenant.product, product: tenant.product,
theme: ui?.app?.theme?.mode === 'light' ? 'dynamicMode' : 'dynamicMode', theme: 'dynamicMode',
mode: 'dynamicMode', mode: 'dynamicMode',
app_ui_config: tenant.product === 'khanegi' ? ui : undefined, app_ui_config: tenant.product === 'khanegi' ? ui : undefined,
}; };
@@ -54,6 +48,38 @@ export class GetUserService {
}; };
} }
private matchesHost(
tenant: {
domain: string;
domains: unknown;
},
host: string,
) {
const candidates = new Set<string>();
for (const value of [tenant.domain, ...this.readDomains(tenant.domains)]) {
const normalized = this.normalizeHost(value);
if (!normalized) continue;
candidates.add(normalized);
candidates.add(normalized.replace(/^www\./, ''));
candidates.add(`www.${normalized.replace(/^www\./, '')}`);
}
return candidates.has(host) || candidates.has(host.replace(/^www\./, ''));
}
private readDomains(raw: unknown): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map(String);
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
return [];
}
private normalizeHost(value: string) { private normalizeHost(value: string) {
return value return value
.trim() .trim()
+63
View File
@@ -0,0 +1,63 @@
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, Min } from 'class-validator';
export class UpdateSmsAccountDto {
@IsOptional()
@IsInt()
@Min(0)
pricePerPartRials?: number;
@IsOptional()
@IsBoolean()
isBlocked?: boolean;
@IsOptional()
@IsInt()
@Min(0)
alertCriticalRials?: number;
@IsOptional()
@IsInt()
@Min(0)
alertWarningRials?: number;
@IsOptional()
@IsString()
gatewayProvider?: string;
@IsOptional()
@IsString()
melipayamakUser?: string;
@IsOptional()
@IsString()
melipayamakPassword?: string;
@IsOptional()
@IsString()
melipayamakFrom?: string;
@IsOptional()
@IsString()
panelUsername?: string;
@IsOptional()
@IsString()
panelPassword?: string;
@IsOptional()
@IsString()
panelFrom?: string;
@IsOptional()
@IsObject()
extraSettings?: Record<string, string>;
}
export class ChargeSmsWalletDto {
@IsInt()
amountRials!: number;
@IsOptional()
@IsString()
note?: string;
}
+142
View File
@@ -0,0 +1,142 @@
export type SmsKeyDef = {
key: string
label: string
group: 'provider' | 'billing' | 'feature' | 'charge'
secret?: boolean
hint?: string
products: Array<'hamkar' | 'khanegi' | 'both'>
}
/** Catalog only — does not change Hamkar/Khanegi code. */
export const SMS_KEY_CATALOG: SmsKeyDef[] = [
{
key: 'sms_panel',
label: 'حالت پنل / billing',
group: 'provider',
hint: 'همکار: central یا me | خانگی: true/false',
products: ['both'],
},
{
key: 'melipayamak_user',
label: 'ملی‌پیامک — کاربر',
group: 'provider',
products: ['both'],
},
{
key: 'melipayamak_password',
label: 'ملی‌پیامک — رمز',
group: 'provider',
secret: true,
products: ['both'],
},
{
key: 'melipayamak_from',
label: 'ملی‌پیامک — فرستنده',
group: 'provider',
products: ['both'],
},
{
key: 'sms_panel_username',
label: '0098 — کاربر',
group: 'provider',
hint: 'فقط همکار وقتی sms_panel=me',
products: ['hamkar'],
},
{
key: 'sms_panel_password',
label: '0098 — رمز',
group: 'provider',
secret: true,
products: ['hamkar'],
},
{
key: 'sms_panel_from',
label: '0098 — فرستنده',
group: 'provider',
products: ['hamkar'],
},
{
key: 'sms_long',
label: 'پیامک طولانی',
group: 'feature',
products: ['hamkar'],
},
{
key: 'send_sms_on_customer_order',
label: 'ارسال با سفارش مشتری',
group: 'feature',
products: ['hamkar'],
},
{
key: 'group_sms',
label: 'ارسال گروهی',
group: 'feature',
products: ['khanegi'],
},
{
key: 'charge_notif',
label: 'اعلان شارژ کیف پول',
group: 'feature',
products: ['khanegi'],
},
{
key: 'sms_charge_card',
label: 'کارت شارژ',
group: 'charge',
products: ['hamkar'],
},
{
key: 'sms_charge_iban',
label: 'شبا شارژ',
group: 'charge',
products: ['hamkar'],
},
{
key: 'sms_charge_account_holder',
label: 'صاحب حساب',
group: 'charge',
products: ['hamkar'],
},
{
key: 'sms_charge_instruction',
label: 'راهنمای شارژ',
group: 'charge',
products: ['hamkar'],
},
{
key: 'sms_charge_card_number',
label: 'شماره کارت شارژ',
group: 'charge',
products: ['khanegi'],
},
{
key: 'sms_charge_sheba',
label: 'شبا شارژ',
group: 'charge',
products: ['khanegi'],
},
{
key: 'sms_charge_account_holder',
label: 'صاحب حساب',
group: 'charge',
products: ['khanegi'],
},
{
key: 'sms_charge_note',
label: 'توضیح شارژ',
group: 'charge',
products: ['khanegi'],
},
{
key: 'name_en',
label: 'نام لاتین (host در API مرکزی)',
group: 'provider',
products: ['both'],
},
]
export function catalogForProduct(product: 'hamkar' | 'khanegi') {
return SMS_KEY_CATALOG.filter(
(k) => k.products.includes('both') || k.products.includes(product),
)
}
+54
View File
@@ -0,0 +1,54 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto';
import { SmsService } from './sms.service';
@Controller('sms')
@UseGuards(JwtAuthGuard)
export class SmsController {
constructor(private readonly smsService: SmsService) {}
@Get('tenants')
listTenants() {
return this.smsService.listTenants();
}
@Get('lowest')
lowest(@Query('limit') limit?: string) {
return this.smsService.getLowestBalances(Number(limit) || 5);
}
@Get('tenants/:id')
getAccount(@Param('id') id: string) {
return this.smsService.getAccount(id);
}
@Patch('tenants/:id')
updateAccount(
@Param('id') id: string,
@Body() dto: UpdateSmsAccountDto,
@CurrentUser() user: { id: string },
) {
return this.smsService.updateAccount(id, dto, user.id);
}
@Post('tenants/:id/charge')
charge(
@Param('id') id: string,
@Body() dto: ChargeSmsWalletDto,
@CurrentUser() user: { id: string },
) {
return this.smsService.chargeWallet(id, dto, user.id);
}
@Post('tenants/:id/block')
block(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, true, user.id);
}
@Post('tenants/:id/unblock')
unblock(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, false, user.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SmsController } from './sms.controller';
import { SmsService } from './sms.service';
@Module({
controllers: [SmsController],
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
+284
View File
@@ -0,0 +1,284 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, SmsAccount } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto';
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown';
@Injectable()
export class SmsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async listTenants() {
await this.ensureAccountsForAllTenants();
const rows = await this.prisma.tenant.findMany({
where: { isActive: true },
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
include: { smsAccount: true },
});
return rows.map((tenant) => {
const account = tenant.smsAccount!;
return {
id: tenant.id,
product: tenant.product,
slug: tenant.slug,
nameFa: tenant.nameFa,
nameEn: tenant.nameEn,
domain: tenant.domain,
avatarUrl: tenant.avatarUrl,
isActive: tenant.isActive,
isBlocked: account.isBlocked,
sms: this.toSummary(account),
};
});
}
async getLowestBalances(limit = 5) {
const rows = await this.listTenants();
return [...rows]
.sort((a, b) => a.sms.balanceRials - b.sms.balanceRials)
.slice(0, limit);
}
async getAccount(tenantId: string) {
const tenant = await this.requireTenant(tenantId);
const account = await this.ensureAccount(tenant.id);
const ledgers = await this.prisma.smsLedger.findMany({
where: { smsAccountId: account.id },
orderBy: { createdAt: 'desc' },
take: 20,
});
return {
tenant: {
id: tenant.id,
product: tenant.product,
slug: tenant.slug,
nameFa: tenant.nameFa,
nameEn: tenant.nameEn,
domain: tenant.domain,
avatarUrl: tenant.avatarUrl,
},
account: {
id: account.id,
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked,
alertCriticalRials: account.alertCriticalRials,
alertWarningRials: account.alertWarningRials,
gatewayProvider: account.gatewayProvider,
melipayamakUser: account.melipayamakUser,
melipayamakPassword: account.melipayamakPassword,
melipayamakFrom: account.melipayamakFrom,
panelUsername: account.panelUsername,
panelPassword: account.panelPassword,
panelFrom: account.panelFrom,
extraSettings: (account.extraSettings as Record<string, string>) || {},
todayMessages: account.todayMessages,
todayParts: account.todayParts,
todayCostRials: account.todayCostRials,
monthMessages: account.monthMessages,
monthParts: account.monthParts,
monthCostRials: account.monthCostRials,
},
summary: this.toSummary(account),
ledgers,
safeNote:
'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.',
};
}
async updateAccount(tenantId: string, dto: UpdateSmsAccountDto, userId?: string) {
const account = await this.ensureAccount(tenantId);
const before = { ...account };
if (
dto.alertCriticalRials != null &&
dto.alertWarningRials != null &&
dto.alertCriticalRials > dto.alertWarningRials
) {
throw new BadRequestException('آستانه قرمز نباید از آستانه زرد بزرگ‌تر باشد');
}
const updated = await this.prisma.smsAccount.update({
where: { id: account.id },
data: {
pricePerPartRials: dto.pricePerPartRials ?? undefined,
isBlocked: dto.isBlocked ?? undefined,
alertCriticalRials: dto.alertCriticalRials ?? undefined,
alertWarningRials: dto.alertWarningRials ?? undefined,
gatewayProvider: dto.gatewayProvider ?? undefined,
melipayamakUser: dto.melipayamakUser ?? undefined,
melipayamakPassword: dto.melipayamakPassword ?? undefined,
melipayamakFrom: dto.melipayamakFrom ?? undefined,
panelUsername: dto.panelUsername ?? undefined,
panelPassword: dto.panelPassword ?? undefined,
panelFrom: dto.panelFrom ?? undefined,
extraSettings:
dto.extraSettings === undefined
? undefined
: (dto.extraSettings as Prisma.InputJsonValue),
},
});
await this.audit.log({
action: 'sms.account.update',
entityType: 'sms_account',
entityId: updated.id,
tenantId,
userId,
before: this.publicAccount(before) as unknown as Prisma.InputJsonValue,
after: this.publicAccount(updated) as unknown as Prisma.InputJsonValue,
});
return this.getAccount(tenantId);
}
async chargeWallet(tenantId: string, dto: ChargeSmsWalletDto, userId?: string) {
if (!dto.amountRials || dto.amountRials === 0) {
throw new BadRequestException('مبلغ شارژ نامعتبر است');
}
const account = await this.ensureAccount(tenantId);
const nextBalance = account.balanceRials + dto.amountRials;
if (nextBalance < 0) {
throw new BadRequestException('موجودی نمی‌تواند منفی شود');
}
const type = dto.amountRials > 0 ? 'charge' : 'adjust';
const [updated] = await this.prisma.$transaction([
this.prisma.smsAccount.update({
where: { id: account.id },
data: { balanceRials: nextBalance },
}),
this.prisma.smsLedger.create({
data: {
smsAccountId: account.id,
type,
amountRials: dto.amountRials,
note: dto.note || (dto.amountRials > 0 ? 'شارژ کیف پول' : 'اصلاح موجودی'),
createdById: userId,
},
}),
]);
await this.audit.log({
action: 'sms.wallet.charge',
entityType: 'sms_account',
entityId: account.id,
tenantId,
userId,
after: {
amountRials: dto.amountRials,
balanceRials: updated.balanceRials,
note: dto.note || null,
} as unknown as Prisma.InputJsonValue,
});
return this.getAccount(tenantId);
}
async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) {
return this.updateAccount(tenantId, { isBlocked }, userId);
}
private async ensureAccountsForAllTenants() {
const tenants = await this.prisma.tenant.findMany({
where: { smsAccount: null },
select: { id: true },
});
for (const tenant of tenants) {
await this.ensureAccount(tenant.id);
}
}
private async ensureAccount(tenantId: string) {
await this.requireTenant(tenantId);
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
if (existing) return existing;
// Seed varied demo balances so color cards are visible until real charges exist
const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000];
const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
const balanceRials = demoBalances[hash % demoBalances.length];
return this.prisma.smsAccount.create({
data: {
tenantId,
balanceRials,
pricePerPartRials: 2500,
alertCriticalRials: 10000,
alertWarningRials: 100000,
},
});
}
private toSummary(account: SmsAccount) {
const level = this.balanceLevel(
account.balanceRials,
account.alertCriticalRials,
account.alertWarningRials,
);
return {
balanceRials: account.balanceRials,
balanceLabel: this.formatMoney(account.balanceRials),
balanceLevel: level,
perPartRials: account.pricePerPartRials,
todayMessages: account.todayMessages,
todayParts: account.todayParts,
todayCostRials: account.todayCostRials,
monthMessages: account.monthMessages,
monthParts: account.monthParts,
monthCostRials: account.monthCostRials,
isBlocked: account.isBlocked,
alertCriticalRials: account.alertCriticalRials,
alertWarningRials: account.alertWarningRials,
};
}
private balanceLevel(
balanceRials: number,
critical = 10000,
warning = 100000,
): BalanceLevel {
if (balanceRials < critical) return 'critical';
if (balanceRials <= warning) return 'warning';
return 'healthy';
}
private formatMoney(value: number) {
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`;
}
private publicAccount(account: SmsAccount) {
return {
balanceRials: account.balanceRials,
pricePerPartRials: account.pricePerPartRials,
isBlocked: account.isBlocked,
alertCriticalRials: account.alertCriticalRials,
alertWarningRials: account.alertWarningRials,
gatewayProvider: account.gatewayProvider,
melipayamakUser: account.melipayamakUser,
melipayamakFrom: account.melipayamakFrom,
panelUsername: account.panelUsername,
panelFrom: account.panelFrom,
};
}
private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
return tenant;
}
}
+8 -2
View File
@@ -17,9 +17,9 @@ export class TenantsService {
) {} ) {}
async findAll(product?: ProductType) { async findAll(product?: ProductType) {
return this.prisma.tenant.findMany({ const rows = await this.prisma.tenant.findMany({
where: product ? { product } : undefined, where: product ? { product } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { nameFa: 'asc' },
select: { select: {
id: true, id: true,
product: true, product: true,
@@ -35,12 +35,18 @@ export class TenantsService {
nodeWs: true, nodeWs: true,
appUrl: true, appUrl: true,
adminUrl: true, adminUrl: true,
appUiConfig: true,
isActive: true, isActive: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}); });
return rows.map(({ appUiConfig, ...rest }) => ({
...rest,
hasAppUiConfig: Boolean(appUiConfig),
}));
} }
async findOne(id: string) { async findOne(id: string) {
-1
View File
@@ -13,7 +13,6 @@
"target": "ES2023", "target": "ES2023",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": true, "strictNullChecks": true,