Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins.
Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user