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
+11
View File
@@ -0,0 +1,11 @@
PORT=3001
# Local (SQLite):
DATABASE_URL="file:./dev.db"
# MySQL example (after creating DB):
# DATABASE_URL="mysql://superadmin:superadmin@127.0.0.1:3306/superadmin"
JWT_SECRET="change-me-in-production-use-long-random-string"
JWT_EXPIRES_IN="7d"
CORS_ORIGIN="http://localhost:5173"
SEED_ADMIN_EMAIL="admin@abshode.local"
SEED_ADMIN_PASSWORD="Admin@12345"
SEED_ADMIN_NAME="Super Admin"
+23
View File
@@ -0,0 +1,23 @@
# dependencies
node_modules
# build
dist
coverage
# env & local
.env
.env.local
.env.*.local
# prisma / sqlite
prisma/dev.db
prisma/dev.db-journal
*.db
# logs & ide
*.log
.DS_Store
.idea
.vscode
*.tsbuildinfo
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11357
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "api",
"version": "0.0.1",
"description": "SuperAdmin Abshode API",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts"
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "6.16.0",
"axios": "^1.11.0",
"bcrypt": "5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "6.16.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
@@ -0,0 +1,75 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'super_admin',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL PRIMARY KEY,
"product" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name_fa" TEXT NOT NULL,
"name_en" TEXT,
"domain" TEXT NOT NULL,
"domains" JSONB,
"avatar_url" TEXT,
"primary_color" TEXT,
"secondary_color" TEXT,
"background_color" TEXT,
"node_server" TEXT NOT NULL,
"node_ws" TEXT,
"app_url" TEXT,
"admin_url" TEXT,
"remote_api_token" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"notes" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"action" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT,
"tenant_id" TEXT,
"user_id" TEXT,
"before" JSONB,
"after" JSONB,
"meta" JSONB,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_domain_key" ON "tenants"("domain");
-- CreateIndex
CREATE INDEX "tenants_product_idx" ON "tenants"("product");
-- CreateIndex
CREATE INDEX "tenants_is_active_idx" ON "tenants"("is_active");
-- CreateIndex
CREATE INDEX "audit_logs_tenant_id_idx" ON "audit_logs"("tenant_id");
-- CreateIndex
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "app_ui_config" JSONB;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+84
View File
@@ -0,0 +1,84 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
// Local scaffold default: SQLite. For MySQL set provider = "mysql" and DATABASE_URL accordingly.
provider = "sqlite"
url = env("DATABASE_URL")
}
enum ProductType {
hamkar
khanegi
}
enum UserRole {
super_admin
viewer
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String
role UserRole @default(super_admin)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[]
@@map("users")
}
model Tenant {
id String @id @default(cuid())
product ProductType
slug String @unique
nameFa String @map("name_fa")
nameEn String? @map("name_en")
domain String @unique
domains Json?
avatarUrl String? @map("avatar_url")
primaryColor String? @map("primary_color")
secondaryColor String? @map("secondary_color")
backgroundColor String? @map("background_color")
nodeServer String @map("node_server")
nodeWs String? @map("node_ws")
appUrl String? @map("app_url")
adminUrl String? @map("admin_url")
remoteApiToken String? @map("remote_api_token")
/// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now.
appUiConfig Json? @map("app_ui_config")
isActive Boolean @default(true) @map("is_active")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[]
@@index([product])
@@index([isActive])
@@map("tenants")
}
model AuditLog {
id String @id @default(cuid())
action String
entityType String @map("entity_type")
entityId String? @map("entity_id")
tenantId String? @map("tenant_id")
userId String? @map("user_id")
before Json?
after Json?
meta Json?
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([userId])
@@index([createdAt])
@@map("audit_logs")
}
+59
View File
@@ -0,0 +1,59 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const email = (process.env.SEED_ADMIN_EMAIL || 'admin@abshode.local').toLowerCase();
const password = process.env.SEED_ADMIN_PASSWORD || 'Admin@12345';
const name = process.env.SEED_ADMIN_NAME || 'Super Admin';
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, name, isActive: true, role: 'super_admin' },
create: {
email,
passwordHash,
name,
role: 'super_admin',
isActive: true,
},
});
const sample = await prisma.tenant.upsert({
where: { slug: 'demo-hamkar' },
update: {},
create: {
product: 'hamkar',
slug: 'demo-hamkar',
nameFa: 'نسخه دمو همکار',
nameEn: 'Demo Hamkar',
domain: 'demo-hamkar.local',
avatarUrl: null,
primaryColor: '#1d4ed8',
secondaryColor: '#0f172a',
backgroundColor: '#ffffff',
nodeServer: 'https://demo-hamkar.local/server',
nodeWs: 'wss://demo-hamkar.local/prc/ws',
appUrl: 'https://demo-hamkar.local',
adminUrl: 'https://demo-hamkar.local/admin',
isActive: true,
notes: 'نمونه اولیه برای تست اسکفولد',
},
});
console.log('Seeded admin:', { id: user.id, email: user.email });
console.log('Seeded tenant:', { id: sample.id, slug: sample.slug });
console.log('Login with:', email, '/', password);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+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 تکراری است');
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('HealthController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/api/health (GET)', () => {
return request(app.getHttpServer()).get('/health').expect(200);
});
afterEach(async () => {
await app.close();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}