Add sync-replica-data repair on deploy, a publisher-side replica watch (60s + after tenant/settings writes), REFRESH PUBLICATION after migrate, and Cache-Control no-store on program-settings and get_version. Co-authored-by: Cursor <cursoragent@cursor.com>
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
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 { ReplicaWatchService } from '../replica-watch/replica-watch.service';
|
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
|
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
|
|
|
@Injectable()
|
|
export class TenantsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly audit: AuditService,
|
|
private readonly notify: AdminNotifyService,
|
|
private readonly programSettings: ProgramSettingsService,
|
|
private readonly replicaWatch: ReplicaWatchService,
|
|
) {}
|
|
|
|
async findAll(product?: ProductType) {
|
|
const rows = await this.prisma.tenant.findMany({
|
|
where: product ? { product } : undefined,
|
|
orderBy: { nameFa: 'asc' },
|
|
select: {
|
|
id: true,
|
|
product: true,
|
|
slug: true,
|
|
nameFa: true,
|
|
nameEn: true,
|
|
domain: true,
|
|
domains: true,
|
|
avatarUrl: true,
|
|
primaryColor: true,
|
|
secondaryColor: true,
|
|
backgroundColor: true,
|
|
nodeServer: true,
|
|
nodeWs: true,
|
|
appUrl: true,
|
|
adminUrl: true,
|
|
appUiConfig: true,
|
|
uiVariant: true,
|
|
appVersion: true,
|
|
isActive: true,
|
|
notes: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
_count: { select: { settings: true } },
|
|
},
|
|
});
|
|
|
|
return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
|
|
...rest,
|
|
hasAppUiConfig: Boolean(appUiConfig),
|
|
hasProgramSettings: (_count?.settings ?? 0) > 0,
|
|
}));
|
|
}
|
|
|
|
async findOne(id: string) {
|
|
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
|
|
if (!tenant) {
|
|
throw new NotFoundException('نسخه یافت نشد');
|
|
}
|
|
return this.sanitize(tenant);
|
|
}
|
|
|
|
async create(dto: CreateTenantDto, userId?: string) {
|
|
const aliases = this.uniqueNormalizedHosts(dto.domains);
|
|
try {
|
|
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',
|
|
entityId: tenant.id,
|
|
tenantId: tenant.id,
|
|
userId,
|
|
after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue,
|
|
});
|
|
|
|
this.notify.notifyAdmins(
|
|
`سوپرادمین: نسخه جدید «${tenant.nameFa}» (${tenant.domain}) ایجاد شد.`,
|
|
);
|
|
|
|
this.replicaWatch.kick();
|
|
return this.sanitize(tenant);
|
|
} catch (error) {
|
|
this.handleUnique(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async update(id: string, dto: UpdateTenantDto, userId?: string) {
|
|
const before = await this.prisma.tenant.findUnique({ where: { id } });
|
|
if (!before) {
|
|
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) ||
|
|
(dto.secondaryColor !== undefined &&
|
|
dto.secondaryColor !== before.secondaryColor) ||
|
|
(dto.backgroundColor !== undefined &&
|
|
dto.backgroundColor !== before.backgroundColor) ||
|
|
dto.appUiConfig !== undefined ||
|
|
(dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) ||
|
|
(dto.nameFa !== undefined && dto.nameFa !== before.nameFa) ||
|
|
(dto.nameEn !== undefined && dto.nameEn !== before.nameEn) ||
|
|
aliasesChanged;
|
|
|
|
const explicitVersion =
|
|
dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined;
|
|
const nextAppVersion =
|
|
explicitVersion ??
|
|
(brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined);
|
|
|
|
try {
|
|
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({
|
|
action: 'tenant.update',
|
|
entityType: 'tenant',
|
|
entityId: tenant.id,
|
|
tenantId: tenant.id,
|
|
userId,
|
|
before: this.sanitize(before) as unknown as Prisma.InputJsonValue,
|
|
after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue,
|
|
});
|
|
|
|
const bits: string[] = [];
|
|
if (before.isActive !== tenant.isActive) {
|
|
bits.push(tenant.isActive ? 'فعال شد' : 'غیرفعال شد');
|
|
}
|
|
if (before.appVersion !== tenant.appVersion) {
|
|
bits.push(`ورژن ${before.appVersion} → ${tenant.appVersion}`);
|
|
}
|
|
if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) {
|
|
bits.push('هویت/دامنه تغییر کرد');
|
|
}
|
|
if (aliasesChanged) {
|
|
bits.push('دامنههای اضافی بهروز شد');
|
|
}
|
|
this.notify.notifyAdmins(
|
|
bits.length
|
|
? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}`
|
|
: `سوپرادمین: نسخه «${tenant.nameFa}» بهروزرسانی شد.`,
|
|
);
|
|
|
|
this.replicaWatch.kick();
|
|
return this.sanitize(tenant);
|
|
} catch (error) {
|
|
this.handleUnique(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async remove(id: string, userId?: string) {
|
|
const before = await this.prisma.tenant.findUnique({ where: { id } });
|
|
if (!before) {
|
|
throw new NotFoundException('نسخه یافت نشد');
|
|
}
|
|
|
|
await this.prisma.tenant.delete({ where: { id } });
|
|
|
|
await this.audit.log({
|
|
action: 'tenant.delete',
|
|
entityType: 'tenant',
|
|
entityId: id,
|
|
tenantId: id,
|
|
userId,
|
|
before: this.sanitize(before) as unknown as Prisma.InputJsonValue,
|
|
});
|
|
|
|
this.notify.notifyAdmins(
|
|
`سوپرادمین: نسخه «${before.nameFa}» (${before.domain}) حذف شد.`,
|
|
);
|
|
|
|
this.replicaWatch.kick();
|
|
return { ok: true };
|
|
}
|
|
|
|
private sanitize(tenant: Tenant) {
|
|
const { remoteApiToken, ...rest } = tenant;
|
|
return {
|
|
...rest,
|
|
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
|
|
hasRemoteApiToken: Boolean(remoteApiToken),
|
|
};
|
|
}
|
|
|
|
private normalizeDomain(domain: string) {
|
|
return this.normalizeHost(domain);
|
|
}
|
|
|
|
/** همتراز get_user: پروتکل، مسیر و پورت حذف میشود. */
|
|
private normalizeHost(value: string) {
|
|
return String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^https?:\/\//, '')
|
|
.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 باطل شود */
|
|
private bumpAppVersion(current?: string | null): string {
|
|
const v = String(current || '1').trim() || '1';
|
|
if (/^\d+$/.test(v)) {
|
|
return String(Number(v) + 1);
|
|
}
|
|
const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
if (semver) {
|
|
return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`;
|
|
}
|
|
return `${Date.now()}`;
|
|
}
|
|
|
|
private handleUnique(error: unknown) {
|
|
if (
|
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
error.code === 'P2002'
|
|
) {
|
|
throw new ConflictException('slug یا domain تکراری است');
|
|
}
|
|
}
|
|
}
|