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
+48
View File
@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CentralSmsService } from './central-sms.service';
import { RuntimeSettingsService } from './runtime-settings.service';
@Injectable()
export class AdminNotifyService {
private readonly logger = new Logger(AdminNotifyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: CentralSmsService,
private readonly runtime: RuntimeSettingsService,
) {}
/** Fire-and-forget SMS to all active managers with a mobile. */
notifyAdmins(message: string): void {
void this.notifyAdminsAsync(message);
}
async notifyAdminsAsync(message: string) {
const text = message.trim();
if (!text) return { sent: 0, failed: 0, skipped: false };
if (!this.runtime.getAdminSmsEnabled()) {
this.logger.log(`Admin notify skipped (SMS toggle off): ${text.slice(0, 80)}`);
return { sent: 0, failed: 0, skipped: true };
}
const admins = await this.prisma.user.findMany({
where: { isActive: true },
select: { id: true, mobile: true, firstName: true },
});
let sent = 0;
let failed = 0;
for (const admin of admins) {
if (!admin.mobile) continue;
const result = await this.sms.send(admin.mobile, text);
if (result.success) sent += 1;
else failed += 1;
}
this.logger.log(`Admin notify: sent=${sent} failed=${failed}`);
return { sent, failed, skipped: false };
}
}
+83
View File
@@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { normalizeMobile } from '../common/mobile.util';
export type CentralSmsResult = {
success: boolean;
raw?: unknown;
error?: string;
};
@Injectable()
export class CentralSmsService {
private readonly logger = new Logger(CentralSmsService.name);
constructor(private readonly config: ConfigService) {}
async send(phone: string, text: string): Promise<CentralSmsResult> {
const to = normalizeMobile(phone);
const message = text.trim();
if (!to) {
return { success: false, error: 'شماره موبایل نامعتبر است' };
}
if (!message) {
return { success: false, error: 'متن پیامک خالی است' };
}
const url =
this.config.get<string>('CENTRAL_SMS_URL') ||
'http://185.231.112.101/api/createsms';
const secret = this.config.get<string>('CENTRAL_SMS_SECRET')?.trim();
if (!secret) {
this.logger.error('CENTRAL_SMS_SECRET is not configured');
return { success: false, error: 'سرویس پیامک پیکربندی نشده است' };
}
const host =
this.config.get<string>('CENTRAL_SMS_HOST') || 'superadmin';
const platform =
this.config.get<string>('CENTRAL_SMS_PLATFORM') || 'superadmin';
try {
const response = await axios.post(
url,
{
phone: to,
host,
text: message,
platform,
},
{
timeout: 15000,
headers: {
'Content-Type': 'application/json',
secret_key: secret,
},
// Prefer IPv4 like Hamkar curl CURLOPT_IPRESOLVE_V4
family: 4,
validateStatus: () => true,
},
);
const body = response.data;
let success = response.status >= 200 && response.status < 300;
if (body && typeof body === 'object') {
if (body.success === false || body.ok === false) success = false;
const status = String(body.status ?? '').toLowerCase();
if (['error', 'failed', 'fail'].includes(status)) success = false;
}
if (!success) {
this.logger.warn(`Central SMS failed to ${to} (HTTP ${response.status})`);
}
return { success, raw: body };
} catch (error: any) {
const msg = error?.message || 'SMS send error';
this.logger.error(`Central SMS exception to ${to}: ${msg}`);
return { success: false, error: msg };
}
}
}
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateAdminSmsDto {
@IsBoolean()
enabled!: boolean;
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminNotifyService } from './admin-notify.service';
import { CentralSmsService } from './central-sms.service';
import { RuntimeSettingsController } from './runtime-settings.controller';
import { RuntimeSettingsService } from './runtime-settings.service';
@Module({
controllers: [RuntimeSettingsController],
providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
})
export class NotificationsModule {}
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UpdateAdminSmsDto } from './dto/update-admin-sms.dto';
import { RuntimeSettingsService } from './runtime-settings.service';
@Controller('settings')
@UseGuards(JwtAuthGuard)
export class RuntimeSettingsController {
constructor(private readonly settings: RuntimeSettingsService) {}
@Get('admin-sms')
getAdminSms() {
return this.settings.getPublic();
}
@Patch('admin-sms')
setAdminSms(@Body() dto: UpdateAdminSmsDto) {
this.settings.setAdminSmsEnabled(dto.enabled);
return this.settings.getPublic();
}
}
@@ -0,0 +1,62 @@
import { Injectable, Logger } from '@nestjs/common';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
type RuntimeSettings = {
/** When false, SuperAdmin stops SMS alerts to managers */
adminSmsEnabled: boolean;
};
const DEFAULTS: RuntimeSettings = {
adminSmsEnabled: true,
};
@Injectable()
export class RuntimeSettingsService {
private readonly logger = new Logger(RuntimeSettingsService.name);
private readonly filePath = join(process.cwd(), 'data', 'runtime-settings.json');
private cache: RuntimeSettings | null = null;
getAdminSmsEnabled(): boolean {
return this.read().adminSmsEnabled !== false;
}
setAdminSmsEnabled(enabled: boolean) {
const next = { ...this.read(), adminSmsEnabled: Boolean(enabled) };
this.write(next);
this.logger.log(`Admin SMS alerts ${next.adminSmsEnabled ? 'enabled' : 'disabled'}`);
return next;
}
getPublic() {
return {
adminSmsEnabled: this.getAdminSmsEnabled(),
};
}
private read(): RuntimeSettings {
if (this.cache) return this.cache;
try {
if (!existsSync(this.filePath)) {
this.cache = { ...DEFAULTS };
return this.cache;
}
const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial<RuntimeSettings>;
this.cache = {
adminSmsEnabled: raw.adminSmsEnabled !== false,
};
return this.cache;
} catch (error: any) {
this.logger.warn(`Failed to read runtime settings: ${error?.message || error}`);
this.cache = { ...DEFAULTS };
return this.cache;
}
}
private write(settings: RuntimeSettings) {
const dir = join(process.cwd(), 'data');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(this.filePath, JSON.stringify(settings, null, 2), 'utf8');
this.cache = settings;
}
}