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
+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 تکراری است');
}
}
}