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:
+2
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 باطل شود */
|
||||||
|
|||||||
Reference in New Issue
Block a user