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:
2026-08-17 20:14:17 +03:30
co-authored by Cursor
parent 8f9988eb15
commit d57ce0a3cb
15 changed files with 2659 additions and 354 deletions
+2 -1
View File
@@ -103,7 +103,8 @@ sudo certbot --nginx -d adm.montzar.ir
1. Open `https://adm.montzar.ir` → login 1. Open `https://adm.montzar.ir` → login
2. `https://adm.montzar.ir/get_user?domain=montpar.ir` (or any known domain) 2. `https://adm.montzar.ir/get_user?domain=montpar.ir` (or any known domain)
3. Confirm uploads/logo URLs use `PUBLIC_API_URL` 3. `https://adm.montzar.ir/get_settings?domain=k.montzar.ir` (nginx must proxy `/get_settings` like `/get_user`)
4. Confirm uploads/logo URLs use `PUBLIC_API_URL`
## 8) Migrations with logical replication (failover replicas) ## 8) Migrations with logical replication (failover replicas)
+10 -1
View File
@@ -1,7 +1,7 @@
# Place under /etc/nginx/sites-available/adm.montzar.ir # Place under /etc/nginx/sites-available/adm.montzar.ir
# SSL: assume Certbot already issued for adm.montzar.ir (or enable after first HTTP). # SSL: assume Certbot already issued for adm.montzar.ir (or enable after first HTTP).
# #
# Single domain: SPA at / + Nest API under /api + public get_user/get_version + uploads. # Single domain: SPA at / + Nest API under /api + public get_user/get_settings/get_version + uploads.
upstream superadmin_api { upstream superadmin_api {
server 127.0.0.1:3010; server 127.0.0.1:3010;
@@ -49,6 +49,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /get_settings {
proxy_pass http://superadmin_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /get_version { location = /get_version {
proxy_pass http://superadmin_api; proxy_pass http://superadmin_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+2 -1
View File
@@ -36,7 +36,8 @@
"prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts", "prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts",
"prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts", "prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts",
"replicas:sync": "bash deploy/sync-replica-schema.sh", "replicas:sync": "bash deploy/sync-replica-schema.sh",
"replicas:check": "bash deploy/sync-replica-schema.sh --check" "replicas:check": "bash deploy/sync-replica-schema.sh --check",
"settings:seed-templates": "echo 'Templates seed on first GET /api/setting-templates or POST /api/setting-templates/seed'"
}, },
"prisma": { "prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts" "seed": "ts-node --transpile-only prisma/seed.ts"
@@ -0,0 +1,50 @@
-- Drop legacy JSON snapshot on tenants
ALTER TABLE "tenants" DROP COLUMN IF EXISTS "setting";
-- Template defaults (copied onto new tenants)
CREATE TABLE "setting_templates" (
"id" TEXT NOT NULL,
"key" VARCHAR(50) NOT NULL,
"title_fa" VARCHAR(255),
"can_edit" SMALLINT DEFAULT 0,
"value" TEXT,
"description" TEXT,
"type" VARCHAR(50),
"type_data" VARCHAR(255),
"is_exist" BOOLEAN NOT NULL DEFAULT true,
"is_active" SMALLINT DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "setting_templates_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "setting_templates_key_key" ON "setting_templates"("key");
-- Per-tenant settings (cascade on tenant delete)
CREATE TABLE "settings" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"key" VARCHAR(50) NOT NULL,
"title_fa" VARCHAR(255),
"can_edit" SMALLINT DEFAULT 0,
"value" TEXT,
"description" TEXT,
"type" VARCHAR(50),
"type_data" VARCHAR(255),
"is_exist" BOOLEAN NOT NULL DEFAULT true,
"is_active" SMALLINT DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "settings_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "settings_tenant_id_key_key" ON "settings"("tenant_id", "key");
CREATE INDEX "settings_key_idx" ON "settings"("key");
CREATE INDEX "settings_tenant_id_idx" ON "settings"("tenant_id");
ALTER TABLE "settings" ADD CONSTRAINT "settings_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+46 -2
View File
@@ -69,8 +69,6 @@ model Tenant {
/// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth /// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth
smsSettingsCache Json? @map("sms_settings_cache") smsSettingsCache Json? @map("sms_settings_cache")
smsSyncedAt DateTime? @map("sms_synced_at") smsSyncedAt DateTime? @map("sms_synced_at")
/// Khanegi deploy settings snapshot (can_edit=0) imported via admin login + get_settings.
setting Json?
/// 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")
/// Khanegi only: which appearance editor / get_user payload shape to use. /// Khanegi only: which appearance editor / get_user payload shape to use.
@@ -90,12 +88,58 @@ model Tenant {
serverAccounts ServerAccount[] serverAccounts ServerAccount[]
pluginSubscriptions PluginSubscription[] pluginSubscriptions PluginSubscription[]
supportSubscriptions SupportSubscription[] supportSubscriptions SupportSubscription[]
settings Setting[]
@@index([product]) @@index([product])
@@index([isActive]) @@index([isActive])
@@map("tenants") @@map("tenants")
} }
/// قالب پیش‌فرض تنظیمات (از deploy_settings_const). نسخهٔ جدید از اینجا کپی می‌شود.
model SettingTemplate {
id String @id @default(cuid())
key String @unique @db.VarChar(50)
titleFa String? @map("title_fa") @db.VarChar(255)
canEdit Int? @default(0) @map("can_edit") @db.SmallInt
value String? @db.Text
description String? @db.Text
type String? @db.VarChar(50)
/// ستون همکار — برای سازگاری آینده
typeData String? @map("type_data") @db.VarChar(255)
isExist Boolean @default(true) @map("is_exist")
isActive Int? @default(1) @map("is_active") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@map("setting_templates")
}
/// تنظیمات هر نسخه — مثل جدول settings خانگی + tenant_id
model Setting {
id String @id @default(cuid())
tenantId String @map("tenant_id")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
key String @db.VarChar(50)
titleFa String? @map("title_fa") @db.VarChar(255)
canEdit Int? @default(0) @map("can_edit") @db.SmallInt
value String? @db.Text
description String? @db.Text
type String? @db.VarChar(50)
/// ستون همکار — برای سازگاری آینده
typeData String? @map("type_data") @db.VarChar(255)
isExist Boolean @default(true) @map("is_exist")
isActive Int? @default(1) @map("is_active") @db.SmallInt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@unique([tenantId, key])
@@index([key])
@@index([tenantId])
@@map("settings")
}
/// SuperAdmin-owned SMS wallet + gateway config per sold version. /// SuperAdmin-owned SMS wallet + gateway config per sold version.
/// Does NOT replace Hamkar/Khanegi internal SMS panels yet. /// Does NOT replace Hamkar/Khanegi internal SMS panels yet.
model SmsAccount { model SmsAccount {
File diff suppressed because it is too large Load Diff
@@ -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 { export class ImportProgramSettingsDto {
@IsString() @IsString()
@@ -10,4 +21,15 @@ export class ImportProgramSettingsDto {
@MinLength(1) @MinLength(1)
@MaxLength(120) @MaxLength(120)
password!: string; 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, @Body() body: ImportProgramSettingsDto,
@CurrentUser() user: { id: string }, @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, tenantId,
body.mobile, body.mobile,
body.password, body.password,
body.decisions || [],
user.id, user.id,
); );
} }
@@ -5,6 +5,7 @@ import { ProgramSettingsController } from './program-settings.controller';
import { PublicProgramSettingsController } from './public-program-settings.controller'; import { PublicProgramSettingsController } from './public-program-settings.controller';
import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller'; import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller';
import { RemoteProgramSettingsController } from './remote-program-settings.controller'; import { RemoteProgramSettingsController } from './remote-program-settings.controller';
import { SettingTemplatesController } from './setting-templates.controller';
import { ProgramSettingsService } from './program-settings.service'; import { ProgramSettingsService } from './program-settings.service';
@Module({ @Module({
@@ -14,6 +15,7 @@ import { ProgramSettingsService } from './program-settings.service';
PublicProgramSettingsController, PublicProgramSettingsController,
ApiPublicProgramSettingsController, ApiPublicProgramSettingsController,
RemoteProgramSettingsController, RemoteProgramSettingsController,
SettingTemplatesController,
], ],
providers: [ProgramSettingsService], providers: [ProgramSettingsService],
exports: [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();
}
}
+8
View File
@@ -1,4 +1,5 @@
import { import {
IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsObject, IsObject,
@@ -37,6 +38,13 @@ export class CreateTenantDto {
@MaxLength(255) @MaxLength(255)
domain!: string; 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() @IsOptional()
@IsString() @IsString()
avatarUrl?: string; avatarUrl?: string;
+3 -2
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { ProgramSettingsModule } from '../program-settings/program-settings.module';
import { TenantsController } from './tenants.controller'; import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service'; import { TenantsService } from './tenants.service';
@Module({ @Module({
imports: [NotificationsModule, AuthModule], imports: [NotificationsModule, AuthModule, forwardRef(() => ProgramSettingsModule)],
controllers: [TenantsController], controllers: [TenantsController],
providers: [TenantsService], providers: [TenantsService],
exports: [TenantsService], exports: [TenantsService],
+121 -10
View File
@@ -7,6 +7,7 @@ import { Prisma, ProductType, Tenant } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service'; import { AdminNotifyService } from '../notifications/admin-notify.service';
import { ProgramSettingsService } from '../program-settings/program-settings.service';
import { CreateTenantDto } from './dto/create-tenant.dto'; import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto';
@@ -16,6 +17,7 @@ export class TenantsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly notify: AdminNotifyService, private readonly notify: AdminNotifyService,
private readonly programSettings: ProgramSettingsService,
) {} ) {}
async findAll(product?: ProductType) { async findAll(product?: ProductType) {
@@ -29,6 +31,7 @@ export class TenantsService {
nameFa: true, nameFa: true,
nameEn: true, nameEn: true,
domain: true, domain: true,
domains: true,
avatarUrl: true, avatarUrl: true,
primaryColor: true, primaryColor: true,
secondaryColor: true, secondaryColor: true,
@@ -38,20 +41,20 @@ export class TenantsService {
appUrl: true, appUrl: true,
adminUrl: true, adminUrl: true,
appUiConfig: true, appUiConfig: true,
setting: true,
uiVariant: true, uiVariant: true,
appVersion: true, appVersion: true,
isActive: true, isActive: true,
notes: true, notes: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
_count: { select: { settings: true } },
}, },
}); });
return rows.map(({ appUiConfig, setting, ...rest }) => ({ return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
...rest, ...rest,
hasAppUiConfig: Boolean(appUiConfig), hasAppUiConfig: Boolean(appUiConfig),
hasProgramSettings: Boolean(setting), hasProgramSettings: (_count?.settings ?? 0) > 0,
})); }));
} }
@@ -64,14 +67,17 @@ export class TenantsService {
} }
async create(dto: CreateTenantDto, userId?: string) { async create(dto: CreateTenantDto, userId?: string) {
const aliases = this.uniqueNormalizedHosts(dto.domains);
try { try {
const tenant = await this.prisma.tenant.create({ const tenant = await this.prisma.$transaction(async (tx: any) => {
const created = await tx.tenant.create({
data: { data: {
product: dto.product, product: dto.product,
slug: dto.slug.toLowerCase(), slug: dto.slug.toLowerCase(),
nameFa: dto.nameFa, nameFa: dto.nameFa,
nameEn: dto.nameEn, nameEn: dto.nameEn,
domain: this.normalizeDomain(dto.domain), domain: this.normalizeDomain(dto.domain),
domains: aliases as unknown as Prisma.InputJsonValue,
avatarUrl: dto.avatarUrl, avatarUrl: dto.avatarUrl,
primaryColor: dto.primaryColor, primaryColor: dto.primaryColor,
secondaryColor: dto.secondaryColor, secondaryColor: dto.secondaryColor,
@@ -93,6 +99,17 @@ export class TenantsService {
notes: dto.notes, 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({ await this.audit.log({
action: 'tenant.create', action: 'tenant.create',
@@ -120,6 +137,13 @@ export class TenantsService {
throw new NotFoundException('نسخه یافت نشد'); 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 = const brandingChanged =
(dto.avatarUrl !== undefined && dto.avatarUrl !== before.avatarUrl) || (dto.avatarUrl !== undefined && dto.avatarUrl !== before.avatarUrl) ||
(dto.primaryColor !== undefined && dto.primaryColor !== before.primaryColor) || (dto.primaryColor !== undefined && dto.primaryColor !== before.primaryColor) ||
@@ -130,7 +154,8 @@ export class TenantsService {
dto.appUiConfig !== undefined || dto.appUiConfig !== undefined ||
(dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) || (dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) ||
(dto.nameFa !== undefined && dto.nameFa !== before.nameFa) || (dto.nameFa !== undefined && dto.nameFa !== before.nameFa) ||
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn); (dto.nameEn !== undefined && dto.nameEn !== before.nameEn) ||
aliasesChanged;
const explicitVersion = const explicitVersion =
dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined; dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined;
@@ -139,7 +164,16 @@ export class TenantsService {
(brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined); (brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined);
try { try {
const tenant = await this.prisma.tenant.update({ 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 }, where: { id },
data: { data: {
...(dto.product !== undefined && { product: dto.product }), ...(dto.product !== undefined && { product: dto.product }),
@@ -149,6 +183,9 @@ export class TenantsService {
...(dto.domain !== undefined && { ...(dto.domain !== undefined && {
domain: this.normalizeDomain(dto.domain), domain: this.normalizeDomain(dto.domain),
}), }),
...(nextAliases !== undefined && {
domains: nextAliases as unknown as Prisma.InputJsonValue,
}),
...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }),
...(dto.primaryColor !== undefined && { ...(dto.primaryColor !== undefined && {
primaryColor: dto.primaryColor, primaryColor: dto.primaryColor,
@@ -177,6 +214,7 @@ export class TenantsService {
...(dto.notes !== undefined && { notes: dto.notes }), ...(dto.notes !== undefined && { notes: dto.notes }),
}, },
}); });
});
await this.audit.log({ await this.audit.log({
action: 'tenant.update', action: 'tenant.update',
@@ -198,6 +236,9 @@ export class TenantsService {
if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) { if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) {
bits.push('هویت/دامنه تغییر کرد'); bits.push('هویت/دامنه تغییر کرد');
} }
if (aliasesChanged) {
bits.push('دامنه‌های اضافی به‌روز شد');
}
this.notify.notifyAdmins( this.notify.notifyAdmins(
bits.length bits.length
? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}` ? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}`
@@ -236,20 +277,90 @@ export class TenantsService {
} }
private sanitize(tenant: Tenant) { private sanitize(tenant: Tenant) {
const { remoteApiToken, setting, ...rest } = tenant; const { remoteApiToken, ...rest } = tenant;
return { return {
...rest, ...rest,
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
hasRemoteApiToken: Boolean(remoteApiToken), hasRemoteApiToken: Boolean(remoteApiToken),
hasProgramSettings: Boolean(setting),
}; };
} }
private normalizeDomain(domain: string) { private normalizeDomain(domain: string) {
return domain return this.normalizeHost(domain);
}
/** هم‌تراز get_user: پروتکل، مسیر و پورت حذف می‌شود. */
private normalizeHost(value: string) {
return String(value || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/^https?:\/\//, '') .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 باطل شود */ /** با تغییر برندینگ، get_user کش فرانت با get_version باطل شود */