feat(settings): store Khanegi keys in settings tables instead of tenant JSON
Template defaults seed from deploy_settings_const; new tenants copy them, import decides per key, and /get_settings reads the per-tenant rows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { Allow, IsArray, IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ImportDecisionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
key!: string;
|
||||
|
||||
@IsIn(['none', 'this', 'all'])
|
||||
scope!: 'none' | 'this' | 'all';
|
||||
}
|
||||
|
||||
export class ImportProgramSettingsDto {
|
||||
@IsString()
|
||||
@@ -10,4 +21,15 @@ export class ImportProgramSettingsDto {
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportDecisionDto)
|
||||
decisions?: ImportDecisionDto[];
|
||||
|
||||
/** اگر true فقط پیشنمایش برگردد */
|
||||
@IsOptional()
|
||||
@Allow()
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Allow, IsArray, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class AddSettingKeysDto {
|
||||
@IsArray()
|
||||
@Allow()
|
||||
items!: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export class DeleteSettingKeyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
key!: string;
|
||||
}
|
||||
@@ -35,10 +35,14 @@ export class ProgramSettingsController {
|
||||
@Body() body: ImportProgramSettingsDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.programSettings.importFromKhanegi(
|
||||
if (body.preview) {
|
||||
return this.programSettings.previewImport(tenantId, body.mobile, body.password);
|
||||
}
|
||||
return this.programSettings.applyImport(
|
||||
tenantId,
|
||||
body.mobile,
|
||||
body.password,
|
||||
body.decisions || [],
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProgramSettingsController } from './program-settings.controller';
|
||||
import { PublicProgramSettingsController } from './public-program-settings.controller';
|
||||
import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller';
|
||||
import { RemoteProgramSettingsController } from './remote-program-settings.controller';
|
||||
import { SettingTemplatesController } from './setting-templates.controller';
|
||||
import { ProgramSettingsService } from './program-settings.service';
|
||||
|
||||
@Module({
|
||||
@@ -14,6 +15,7 @@ import { ProgramSettingsService } from './program-settings.service';
|
||||
PublicProgramSettingsController,
|
||||
ApiPublicProgramSettingsController,
|
||||
RemoteProgramSettingsController,
|
||||
SettingTemplatesController,
|
||||
],
|
||||
providers: [ProgramSettingsService],
|
||||
exports: [ProgramSettingsService],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Delete, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AddSettingKeysDto, DeleteSettingKeyDto } from './dto/setting-keys.dto';
|
||||
import { ProgramSettingsService } from './program-settings.service';
|
||||
|
||||
@Controller('setting-templates')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class SettingTemplatesController {
|
||||
constructor(private readonly programSettings: ProgramSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('khanegi:read')
|
||||
list() {
|
||||
return this.programSettings.listTemplates();
|
||||
}
|
||||
|
||||
@Post('keys')
|
||||
@RequirePermissions('khanegi:write')
|
||||
addKeys(@Body() body: AddSettingKeysDto, @CurrentUser() user: { id: string }) {
|
||||
return this.programSettings.addKeys(body.items || [], user.id);
|
||||
}
|
||||
|
||||
@Delete('keys')
|
||||
@RequirePermissions('khanegi:write')
|
||||
removeKey(
|
||||
@Query('key') keyQuery: string | undefined,
|
||||
@Body() body: DeleteSettingKeyDto | undefined,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
const key = body?.key || keyQuery || '';
|
||||
return this.programSettings.deleteKey(key, user.id);
|
||||
}
|
||||
|
||||
@Post('seed')
|
||||
@RequirePermissions('khanegi:write')
|
||||
seed() {
|
||||
return this.programSettings.ensureTemplatesSeeded();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
@@ -37,6 +38,13 @@ export class CreateTenantDto {
|
||||
@MaxLength(255)
|
||||
domain!: string;
|
||||
|
||||
/** Extra hosts / IPs for /get_user (aliases). Unique per product, not across hamkar/khanegi. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(255, { each: true })
|
||||
domains?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl?: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ProgramSettingsModule } from '../program-settings/program-settings.module';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
imports: [NotificationsModule, AuthModule, forwardRef(() => ProgramSettingsModule)],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
exports: [TenantsService],
|
||||
|
||||
+183
-72
@@ -7,6 +7,7 @@ import { Prisma, ProductType, Tenant } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||
import { ProgramSettingsService } from '../program-settings/program-settings.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
|
||||
@@ -16,6 +17,7 @@ export class TenantsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notify: AdminNotifyService,
|
||||
private readonly programSettings: ProgramSettingsService,
|
||||
) {}
|
||||
|
||||
async findAll(product?: ProductType) {
|
||||
@@ -29,6 +31,7 @@ export class TenantsService {
|
||||
nameFa: true,
|
||||
nameEn: true,
|
||||
domain: true,
|
||||
domains: true,
|
||||
avatarUrl: true,
|
||||
primaryColor: true,
|
||||
secondaryColor: true,
|
||||
@@ -38,20 +41,20 @@ export class TenantsService {
|
||||
appUrl: true,
|
||||
adminUrl: true,
|
||||
appUiConfig: true,
|
||||
setting: true,
|
||||
uiVariant: true,
|
||||
appVersion: true,
|
||||
isActive: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { settings: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map(({ appUiConfig, setting, ...rest }) => ({
|
||||
return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
|
||||
...rest,
|
||||
hasAppUiConfig: Boolean(appUiConfig),
|
||||
hasProgramSettings: Boolean(setting),
|
||||
hasProgramSettings: (_count?.settings ?? 0) > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -64,36 +67,50 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateTenantDto, userId?: string) {
|
||||
const aliases = this.uniqueNormalizedHosts(dto.domains);
|
||||
try {
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
product: dto.product,
|
||||
slug: dto.slug.toLowerCase(),
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn,
|
||||
domain: this.normalizeDomain(dto.domain),
|
||||
avatarUrl: dto.avatarUrl,
|
||||
primaryColor: dto.primaryColor,
|
||||
secondaryColor: dto.secondaryColor,
|
||||
backgroundColor: dto.backgroundColor,
|
||||
nodeServer: dto.nodeServer,
|
||||
nodeWs: dto.nodeWs,
|
||||
appUrl: dto.appUrl,
|
||||
adminUrl: dto.adminUrl,
|
||||
remoteApiToken: dto.remoteApiToken,
|
||||
appUiConfig: dto.appUiConfig
|
||||
? (dto.appUiConfig as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
uiVariant:
|
||||
dto.product === ProductType.khanegi
|
||||
? dto.uiVariant ?? 'modular'
|
||||
: 'modular',
|
||||
appVersion: dto.appVersion?.trim() || '1',
|
||||
isActive: dto.isActive ?? true,
|
||||
notes: dto.notes,
|
||||
},
|
||||
const tenant = await this.prisma.$transaction(async (tx: any) => {
|
||||
const created = await tx.tenant.create({
|
||||
data: {
|
||||
product: dto.product,
|
||||
slug: dto.slug.toLowerCase(),
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn,
|
||||
domain: this.normalizeDomain(dto.domain),
|
||||
domains: aliases as unknown as Prisma.InputJsonValue,
|
||||
avatarUrl: dto.avatarUrl,
|
||||
primaryColor: dto.primaryColor,
|
||||
secondaryColor: dto.secondaryColor,
|
||||
backgroundColor: dto.backgroundColor,
|
||||
nodeServer: dto.nodeServer,
|
||||
nodeWs: dto.nodeWs,
|
||||
appUrl: dto.appUrl,
|
||||
adminUrl: dto.adminUrl,
|
||||
remoteApiToken: dto.remoteApiToken,
|
||||
appUiConfig: dto.appUiConfig
|
||||
? (dto.appUiConfig as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
uiVariant:
|
||||
dto.product === ProductType.khanegi
|
||||
? dto.uiVariant ?? 'modular'
|
||||
: 'modular',
|
||||
appVersion: dto.appVersion?.trim() || '1',
|
||||
isActive: dto.isActive ?? true,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.reclaimAliasesFromSameProduct(tx, {
|
||||
tenantId: created.id,
|
||||
product: created.product,
|
||||
aliases,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
if (tenant.product === ProductType.khanegi) {
|
||||
await this.programSettings.seedTenantFromTemplates(tenant.id);
|
||||
}
|
||||
|
||||
await this.audit.log({
|
||||
action: 'tenant.create',
|
||||
entityType: 'tenant',
|
||||
@@ -120,6 +137,13 @@ export class TenantsService {
|
||||
throw new NotFoundException('نسخه یافت نشد');
|
||||
}
|
||||
|
||||
const nextAliases =
|
||||
dto.domains !== undefined ? this.uniqueNormalizedHosts(dto.domains) : undefined;
|
||||
const aliasesChanged =
|
||||
nextAliases !== undefined &&
|
||||
JSON.stringify(this.uniqueNormalizedHosts(this.readDomains(before.domains))) !==
|
||||
JSON.stringify(nextAliases);
|
||||
|
||||
const brandingChanged =
|
||||
(dto.avatarUrl !== undefined && dto.avatarUrl !== before.avatarUrl) ||
|
||||
(dto.primaryColor !== undefined && dto.primaryColor !== before.primaryColor) ||
|
||||
@@ -130,7 +154,8 @@ export class TenantsService {
|
||||
dto.appUiConfig !== undefined ||
|
||||
(dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) ||
|
||||
(dto.nameFa !== undefined && dto.nameFa !== before.nameFa) ||
|
||||
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn);
|
||||
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn) ||
|
||||
aliasesChanged;
|
||||
|
||||
const explicitVersion =
|
||||
dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined;
|
||||
@@ -139,43 +164,56 @@ export class TenantsService {
|
||||
(brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined);
|
||||
|
||||
try {
|
||||
const tenant = await this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.product !== undefined && { product: dto.product }),
|
||||
...(dto.slug !== undefined && { slug: dto.slug.toLowerCase() }),
|
||||
...(dto.nameFa !== undefined && { nameFa: dto.nameFa }),
|
||||
...(dto.nameEn !== undefined && { nameEn: dto.nameEn }),
|
||||
...(dto.domain !== undefined && {
|
||||
domain: this.normalizeDomain(dto.domain),
|
||||
}),
|
||||
...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }),
|
||||
...(dto.primaryColor !== undefined && {
|
||||
primaryColor: dto.primaryColor,
|
||||
}),
|
||||
...(dto.secondaryColor !== undefined && {
|
||||
secondaryColor: dto.secondaryColor,
|
||||
}),
|
||||
...(dto.backgroundColor !== undefined && {
|
||||
backgroundColor: dto.backgroundColor,
|
||||
}),
|
||||
...(dto.nodeServer !== undefined && { nodeServer: dto.nodeServer }),
|
||||
...(dto.nodeWs !== undefined && { nodeWs: dto.nodeWs }),
|
||||
...(dto.appUrl !== undefined && { appUrl: dto.appUrl }),
|
||||
...(dto.adminUrl !== undefined && { adminUrl: dto.adminUrl }),
|
||||
...(dto.remoteApiToken !== undefined && {
|
||||
remoteApiToken: dto.remoteApiToken,
|
||||
}),
|
||||
...(dto.appUiConfig !== undefined && {
|
||||
appUiConfig: dto.appUiConfig as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(dto.uiVariant !== undefined && { uiVariant: dto.uiVariant }),
|
||||
...(nextAppVersion !== undefined && {
|
||||
appVersion: nextAppVersion,
|
||||
}),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
},
|
||||
const tenant = await this.prisma.$transaction(async (tx: any) => {
|
||||
if (nextAliases !== undefined) {
|
||||
await this.reclaimAliasesFromSameProduct(tx, {
|
||||
tenantId: id,
|
||||
product: dto.product ?? before.product,
|
||||
aliases: nextAliases,
|
||||
});
|
||||
}
|
||||
|
||||
return tx.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.product !== undefined && { product: dto.product }),
|
||||
...(dto.slug !== undefined && { slug: dto.slug.toLowerCase() }),
|
||||
...(dto.nameFa !== undefined && { nameFa: dto.nameFa }),
|
||||
...(dto.nameEn !== undefined && { nameEn: dto.nameEn }),
|
||||
...(dto.domain !== undefined && {
|
||||
domain: this.normalizeDomain(dto.domain),
|
||||
}),
|
||||
...(nextAliases !== undefined && {
|
||||
domains: nextAliases as unknown as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }),
|
||||
...(dto.primaryColor !== undefined && {
|
||||
primaryColor: dto.primaryColor,
|
||||
}),
|
||||
...(dto.secondaryColor !== undefined && {
|
||||
secondaryColor: dto.secondaryColor,
|
||||
}),
|
||||
...(dto.backgroundColor !== undefined && {
|
||||
backgroundColor: dto.backgroundColor,
|
||||
}),
|
||||
...(dto.nodeServer !== undefined && { nodeServer: dto.nodeServer }),
|
||||
...(dto.nodeWs !== undefined && { nodeWs: dto.nodeWs }),
|
||||
...(dto.appUrl !== undefined && { appUrl: dto.appUrl }),
|
||||
...(dto.adminUrl !== undefined && { adminUrl: dto.adminUrl }),
|
||||
...(dto.remoteApiToken !== undefined && {
|
||||
remoteApiToken: dto.remoteApiToken,
|
||||
}),
|
||||
...(dto.appUiConfig !== undefined && {
|
||||
appUiConfig: dto.appUiConfig as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(dto.uiVariant !== undefined && { uiVariant: dto.uiVariant }),
|
||||
...(nextAppVersion !== undefined && {
|
||||
appVersion: nextAppVersion,
|
||||
}),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
@@ -198,6 +236,9 @@ export class TenantsService {
|
||||
if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) {
|
||||
bits.push('هویت/دامنه تغییر کرد');
|
||||
}
|
||||
if (aliasesChanged) {
|
||||
bits.push('دامنههای اضافی بهروز شد');
|
||||
}
|
||||
this.notify.notifyAdmins(
|
||||
bits.length
|
||||
? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}`
|
||||
@@ -236,20 +277,90 @@ export class TenantsService {
|
||||
}
|
||||
|
||||
private sanitize(tenant: Tenant) {
|
||||
const { remoteApiToken, setting, ...rest } = tenant;
|
||||
const { remoteApiToken, ...rest } = tenant;
|
||||
return {
|
||||
...rest,
|
||||
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
|
||||
hasRemoteApiToken: Boolean(remoteApiToken),
|
||||
hasProgramSettings: Boolean(setting),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeDomain(domain: string) {
|
||||
return domain
|
||||
return this.normalizeHost(domain);
|
||||
}
|
||||
|
||||
/** همتراز get_user: پروتکل، مسیر و پورت حذف میشود. */
|
||||
private normalizeHost(value: string) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '');
|
||||
.split('/')[0]
|
||||
.split(':')[0];
|
||||
}
|
||||
|
||||
private hostKey(value: string) {
|
||||
return this.normalizeHost(value).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 uniqueNormalizedHosts(list: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of Array.isArray(list) ? list : []) {
|
||||
const host = this.normalizeHost(String(item || ''));
|
||||
if (!host) continue;
|
||||
const key = this.hostKey(host);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(host);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* دامنه/IP در aliases این نسخه، از domains[] نسخههای دیگر همان محصول برداشته میشود.
|
||||
* خانگی و همکار جدا هستند؛ دامنهٔ اصلی (ستون domain) جابهجا نمیشود.
|
||||
*/
|
||||
private async reclaimAliasesFromSameProduct(
|
||||
tx: any,
|
||||
opts: { tenantId: string; product: ProductType; aliases: string[] },
|
||||
) {
|
||||
const keys = new Set(opts.aliases.map((item) => this.hostKey(item)).filter(Boolean));
|
||||
if (!keys.size) return;
|
||||
|
||||
const others = await tx.tenant.findMany({
|
||||
where: { product: opts.product, id: { not: opts.tenantId } },
|
||||
select: { id: true, nameFa: true, domain: true, domains: true },
|
||||
});
|
||||
|
||||
for (const other of others) {
|
||||
if (keys.has(this.hostKey(other.domain))) {
|
||||
throw new ConflictException(
|
||||
`«${other.domain}» دامنهٔ اصلی نسخه «${other.nameFa}» است و نمیتوان آن را به این پروژه منتقل کرد`,
|
||||
);
|
||||
}
|
||||
const current = this.readDomains(other.domains);
|
||||
const next = current.filter((item) => !keys.has(this.hostKey(item)));
|
||||
if (next.length === current.length) continue;
|
||||
await tx.tenant.update({
|
||||
where: { id: other.id },
|
||||
data: { domains: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** با تغییر برندینگ، get_user کش فرانت با get_version باطل شود */
|
||||
|
||||
Reference in New Issue
Block a user