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
+2
View File
@@ -6,6 +6,7 @@ import { TenantsModule } from './tenants/tenants.module';
import { GetUserModule } from './get-user/get-user.module';
import { SettingsProxyModule } from './settings-proxy/settings-proxy.module';
import { AuditModule } from './audit/audit.module';
import { SmsModule } from './sms/sms.module';
import { HealthController } from './health.controller';
@Module({
@@ -17,6 +18,7 @@ import { HealthController } from './health.controller';
GetUserModule,
SettingsProxyModule,
AuditModule,
SmsModule,
],
controllers: [HealthController],
})
+37 -11
View File
@@ -11,17 +11,11 @@ export class GetUserService {
throw new NotFoundException('domain required');
}
const tenant = await this.prisma.tenant.findFirst({
where: {
isActive: true,
OR: [
{ domain: host },
{ domain: host.replace(/^www\./, '') },
{ domain: `www.${host.replace(/^www\./, '')}` },
],
},
const tenants = await this.prisma.tenant.findMany({
where: { isActive: true },
});
const tenant = tenants.find((item) => this.matchesHost(item, host));
if (!tenant) {
throw new NotFoundException('tenant not found for domain');
}
@@ -31,7 +25,7 @@ export class GetUserService {
const branding = {
id: tenant.id,
name: tenant.slug,
name: tenant.nameEn || tenant.slug,
name_fa: tenant.nameFa,
name_en: tenant.nameEn,
avatar: tenant.avatarUrl,
@@ -43,7 +37,7 @@ export class GetUserService {
app_url: tenant.appUrl,
admin_url: tenant.adminUrl,
product: tenant.product,
theme: ui?.app?.theme?.mode === 'light' ? 'dynamicMode' : 'dynamicMode',
theme: 'dynamicMode',
mode: 'dynamicMode',
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) {
return value
.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) {
return this.prisma.tenant.findMany({
const rows = await this.prisma.tenant.findMany({
where: product ? { product } : undefined,
orderBy: { createdAt: 'desc' },
orderBy: { nameFa: 'asc' },
select: {
id: true,
product: true,
@@ -35,12 +35,18 @@ export class TenantsService {
nodeWs: true,
appUrl: true,
adminUrl: true,
appUiConfig: true,
isActive: true,
notes: true,
createdAt: true,
updatedAt: true,
},
});
return rows.map(({ appUiConfig, ...rest }) => ({
...rest,
hasAppUiConfig: Boolean(appUiConfig),
}));
}
async findOne(id: string) {