Initial SuperAdmin API (NestJS + Prisma)

This commit is contained in:
SuperAdmin
2026-07-23 10:25:32 +03:30
commit 7fbc47766c
43 changed files with 12902 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { TenantsModule } from './tenants/tenants.module';
import { GetUserModule } from './get-user/get-user.module';
import { SettingsProxyModule } from './settings-proxy/settings-proxy.module';
import { AuditModule } from './audit/audit.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
TenantsModule,
GetUserModule,
SettingsProxyModule,
AuditModule,
],
controllers: [HealthController],
})
export class AppModule {}
+19
View File
@@ -0,0 +1,19 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AuditService } from './audit.service';
@Controller('audit')
@UseGuards(JwtAuthGuard)
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
findAll(@Query('limit') limit?: string) {
return this.auditService.findAll(limit ? Number(limit) : 50);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.auditService.findOne(id);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Global()
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
+59
View File
@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
type AuditInput = {
action: string;
entityType: string;
entityId?: string;
tenantId?: string;
userId?: string;
before?: Prisma.InputJsonValue;
after?: Prisma.InputJsonValue;
meta?: Prisma.InputJsonValue;
};
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
log(input: AuditInput) {
return this.prisma.auditLog.create({
data: {
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
tenantId: input.tenantId,
userId: input.userId,
before: input.before,
after: input.after,
meta: input.meta,
},
});
}
findAll(limit = 50) {
return this.prisma.auditLog.findMany({
take: Math.min(limit, 200),
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
tenant: { select: { id: true, slug: true, nameFa: true } },
},
});
}
async findOne(id: string) {
const row = await this.prisma.auditLog.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true, email: true } },
tenant: { select: { id: true, slug: true, nameFa: true } },
},
});
if (!row) {
throw new NotFoundException('لاگ یافت نشد');
}
return row;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: { id: string }) {
return this.authService.me(user.id);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET', 'dev-secret'),
signOptions: {
// 7 days
expiresIn: 60 * 60 * 24 * 7,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است');
}
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
return {
accessToken: await this.jwt.signAsync(payload),
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
};
}
async me(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
},
});
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
return user;
}
}
+11
View File
@@ -0,0 +1,11 @@
import {
createParamDecorator,
ExecutionContext,
} from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
+11
View File
@@ -0,0 +1,11 @@
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@MinLength(3)
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../prisma/prisma.service';
export type JwtPayload = {
sub: string;
email: string;
role: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET', 'dev-secret'),
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
},
});
if (!user || !user.isActive) {
return null;
}
return user;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Controller, Get, Headers, Query } from '@nestjs/common';
import { GetUserService } from './get-user.service';
@Controller()
export class GetUserController {
constructor(private readonly getUserService: GetUserService) {}
@Get('get_user')
getUser(
@Headers('host') host?: string,
@Headers('x-forwarded-host') forwardedHost?: string,
@Query('domain') domain?: string,
) {
return this.getUserService.resolveByHost(
domain || forwardedHost || host,
domain,
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GetUserController } from './get-user.controller';
import { GetUserService } from './get-user.service';
@Module({
controllers: [GetUserController],
providers: [GetUserService],
})
export class GetUserModule {}
+65
View File
@@ -0,0 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class GetUserService {
constructor(private readonly prisma: PrismaService) {}
async resolveByHost(hostHeader?: string, domainQuery?: string) {
const host = this.normalizeHost(domainQuery || hostHeader || '');
if (!host) {
throw new NotFoundException('domain required');
}
const tenant = await this.prisma.tenant.findFirst({
where: {
isActive: true,
OR: [
{ domain: host },
{ domain: host.replace(/^www\./, '') },
{ domain: `www.${host.replace(/^www\./, '')}` },
],
},
});
if (!tenant) {
throw new NotFoundException('tenant not found for domain');
}
const ui = (tenant.appUiConfig as Record<string, any> | null) || null;
const themeColors = ui?.app?.theme?.colors;
const branding = {
id: tenant.id,
name: tenant.slug,
name_fa: tenant.nameFa,
name_en: tenant.nameEn,
avatar: tenant.avatarUrl,
color: themeColors?.primary || tenant.primaryColor,
secondary_color: themeColors?.secondary || tenant.secondaryColor,
background_color: themeColors?.background || tenant.backgroundColor,
node_server: tenant.nodeServer,
node_ws: tenant.nodeWs,
app_url: tenant.appUrl,
admin_url: tenant.adminUrl,
product: tenant.product,
theme: ui?.app?.theme?.mode === 'light' ? 'dynamicMode' : 'dynamicMode',
mode: 'dynamicMode',
app_ui_config: tenant.product === 'khanegi' ? ui : undefined,
};
return {
...branding,
user: { ...branding },
};
}
private normalizeHost(value: string) {
return value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
.split(':')[0];
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
ok: true,
service: 'super-admin-api',
time: new Date().toISOString(),
};
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NestFactory } from '@nestjs/core';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
app.setGlobalPrefix('api', {
exclude: [{ path: 'get_user', method: RequestMethod.GET }],
});
app.enableCors({
origin: config.get<string>('CORS_ORIGIN', 'http://localhost:5173'),
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
const port = Number(config.get('PORT') ?? 3001);
await app.listen(port);
console.log(`SuperAdmin API listening on http://localhost:${port}`);
}
bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { SettingsProxyService } from './settings-proxy.service';
@Controller('tenants/:tenantId/settings')
@UseGuards(JwtAuthGuard)
export class SettingsProxyController {
constructor(private readonly settingsProxy: SettingsProxyService) {}
@Get()
getSettings(@Param('tenantId') tenantId: string) {
return this.settingsProxy.getSettings(tenantId);
}
@Post()
updateSettings(
@Param('tenantId') tenantId: string,
@Body() body: Record<string, unknown>,
@CurrentUser() user: { id: string },
) {
return this.settingsProxy.updateSettings(tenantId, body, user.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SettingsProxyController } from './settings-proxy.controller';
import { SettingsProxyService } from './settings-proxy.service';
@Module({
controllers: [SettingsProxyController],
providers: [SettingsProxyService],
})
export class SettingsProxyModule {}
@@ -0,0 +1,135 @@
import {
BadGatewayException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import axios from 'axios';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
@Injectable()
export class SettingsProxyService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async getSettings(tenantId: string) {
const tenant = await this.requireTenant(tenantId);
if (!tenant.remoteApiToken) {
return {
stub: true,
message:
'remoteApiToken تنظیم نشده. فعلاً stub است تا endpoint سوپر روی Laravel اضافه شود.',
tenant: {
id: tenant.id,
slug: tenant.slug,
nodeServer: tenant.nodeServer,
},
settings: [],
};
}
try {
const { data } = await axios.get(
this.joinUrl(tenant.nodeServer, '/super/settings'),
{
headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`,
Accept: 'application/json',
},
timeout: 15000,
},
);
return { stub: false, settings: data };
} catch (error) {
throw new BadGatewayException(
this.axiosMessage(error, 'خواندن تنظیمات از سرور نسخه ناموفق بود'),
);
}
}
async updateSettings(
tenantId: string,
payload: Record<string, unknown>,
userId?: string,
) {
const tenant = await this.requireTenant(tenantId);
if (!tenant.remoteApiToken) {
await this.audit.log({
action: 'settings.proxy.stub_update',
entityType: 'tenant_settings',
entityId: tenantId,
tenantId,
userId,
after: payload as Prisma.InputJsonValue,
meta: { stub: true },
});
return {
stub: true,
message:
'remoteApiToken تنظیم نشده؛ تغییر فقط در audit ثبت شد (stub).',
accepted: payload,
};
}
try {
const { data } = await axios.post(
this.joinUrl(tenant.nodeServer, '/super/settings'),
payload,
{
headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
timeout: 20000,
},
);
await this.audit.log({
action: 'settings.proxy.update',
entityType: 'tenant_settings',
entityId: tenantId,
tenantId,
userId,
after: payload as Prisma.InputJsonValue,
});
return { stub: false, result: data };
} catch (error) {
throw new BadGatewayException(
this.axiosMessage(error, 'ذخیره تنظیمات روی سرور نسخه ناموفق بود'),
);
}
}
private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new NotFoundException('نسخه یافت نشد');
}
return tenant;
}
private joinUrl(base: string, path: string) {
return `${base.replace(/\/$/, '')}${path}`;
}
private axiosMessage(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const body = error.response?.data;
return `${fallback}${status ? ` (HTTP ${status})` : ''}${
body ? `: ${JSON.stringify(body)}` : ''
}`;
}
return fallback;
}
}
+90
View File
@@ -0,0 +1,90 @@
import {
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ProductType } from '@prisma/client';
export class CreateTenantDto {
@IsEnum(ProductType)
product!: ProductType;
@IsString()
@MinLength(2)
@MaxLength(64)
@Matches(/^[a-z0-9_-]+$/i, {
message: 'slug must be alphanumeric (a-z, 0-9, _-)',
})
slug!: string;
@IsString()
@MinLength(2)
@MaxLength(120)
nameFa!: string;
@IsOptional()
@IsString()
@MaxLength(120)
nameEn?: string;
@IsString()
@MinLength(3)
@MaxLength(255)
domain!: string;
@IsOptional()
@IsString()
avatarUrl?: string;
@IsOptional()
@IsString()
@MaxLength(32)
primaryColor?: string;
@IsOptional()
@IsString()
@MaxLength(32)
secondaryColor?: string;
@IsOptional()
@IsString()
@MaxLength(32)
backgroundColor?: string;
@IsString()
@MinLength(8)
nodeServer!: string;
@IsOptional()
@IsString()
nodeWs?: string;
@IsOptional()
@IsString()
appUrl?: string;
@IsOptional()
@IsString()
adminUrl?: string;
@IsOptional()
@IsString()
remoteApiToken?: string;
@IsOptional()
@IsObject()
appUiConfig?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsString()
notes?: string;
}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTenantDto } from './create-tenant.dto';
export class UpdateTenantDto extends PartialType(CreateTenantDto) {}
+55
View File
@@ -0,0 +1,55 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ProductType } from '@prisma/client';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { TenantsService } from './tenants.service';
@Controller('tenants')
@UseGuards(JwtAuthGuard)
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
@Get()
findAll(@Query('product') product?: ProductType) {
return this.tenantsService.findAll(product);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tenantsService.findOne(id);
}
@Post()
create(
@Body() dto: CreateTenantDto,
@CurrentUser() user: { id: string },
) {
return this.tenantsService.create(dto, user.id);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateTenantDto,
@CurrentUser() user: { id: string },
) {
return this.tenantsService.update(id, dto, user.id);
}
@Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.tenantsService.remove(id, user.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}
+199
View File
@@ -0,0 +1,199 @@
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 { 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,
) {}
async findAll(product?: ProductType) {
return this.prisma.tenant.findMany({
where: product ? { product } : undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
product: true,
slug: true,
nameFa: true,
nameEn: true,
domain: true,
avatarUrl: true,
primaryColor: true,
secondaryColor: true,
backgroundColor: true,
nodeServer: true,
nodeWs: true,
appUrl: true,
adminUrl: true,
isActive: true,
notes: true,
createdAt: true,
updatedAt: true,
},
});
}
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) {
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,
isActive: dto.isActive ?? true,
notes: dto.notes,
},
});
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,
});
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('نسخه یافت نشد');
}
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.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,
});
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,
});
return { ok: true };
}
private sanitize(tenant: Tenant) {
const { remoteApiToken, ...rest } = tenant;
return {
...rest,
hasRemoteApiToken: Boolean(remoteApiToken),
};
}
private normalizeDomain(domain: string) {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
}
private handleUnique(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException('slug یا domain تکراری است');
}
}
}