Initial commit: NestJS backend for Child Gold Land

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 08:08:11 +02:00
co-authored by Cursor
commit edb79180a1
93 changed files with 11565 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common'
import { AuthService } from './auth.service'
@Injectable()
export class AuthSeedService implements OnModuleInit {
private readonly logger = new Logger(AuthSeedService.name)
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
onModuleInit(): void {
this.auth.seedDemoAccount()
this.logger.log('Auth seed completed (demo account if missing)')
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common'
import { Public } from '../common/decorators/public.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'
@Controller('api/auth')
export class AuthController {
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
@Public()
@HttpCode(200)
@Post('register')
register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
return this.auth.register(dto.username, dto.password, dto.name)
}
@Public()
@HttpCode(200)
@Post('login')
login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
return this.auth.login(dto.username, dto.password)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module, forwardRef } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { APP_GUARD } from '@nestjs/core'
import { JwtModule } from '@nestjs/jwt'
import { UsersModule } from '../users/users.module'
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { AuthSeedService } from './auth-seed.service'
@Module({
imports: [
forwardRef(() => UsersModule),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
AuthSeedService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+205
View File
@@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto'
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
BadRequestException,
forwardRef,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import bcrypt from 'bcryptjs'
import { DatabaseService } from '../database/database.service'
import { createDefaultUserState } from '../shared/catalog/default-user-state'
import type { AuthResponse } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
export interface AuthAccount {
id: string
username: string
passwordHash: string
userId: string
createdAt: string
}
@Injectable()
export class AuthService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(forwardRef(() => UsersService))
private readonly users: UsersService,
@Inject(JwtService) private readonly jwt: JwtService,
) {}
register(username: string, password: string, name?: string): AuthResponse {
const account = this.createAuthAccount(username, password, name ?? username)
return this.buildAuthResponse(account.userId, account.username)
}
login(username: string, password: string): AuthResponse {
const account = this.verifyLogin(username, password)
return this.buildAuthResponse(account.userId, account.username)
}
updatePassword(userId: string, currentPassword: string, newPassword: string): void {
const account = this.findAccountByUserId(userId)
if (!account) {
throw new NotFoundException('حساب کاربری پیدا نشد')
}
if (!bcrypt.compareSync(currentPassword, account.passwordHash)) {
throw new UnauthorizedException('رمز عبور فعلی اشتباه است')
}
if (newPassword.length < 6) {
throw new BadRequestException('رمز عبور جدید باید حداقل ۶ کاراکتر باشد')
}
this.database
.getDb()
.prepare('UPDATE auth_accounts SET password_hash = ? WHERE user_id = ?')
.run(bcrypt.hashSync(newPassword, 10), userId)
}
findAccountByUsername(username: string): AuthAccount | null {
const row = this.database
.getDb()
.prepare(
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE username = ?',
)
.get(username.trim().toLowerCase()) as
| {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}
| undefined
return row ? this.rowToAccount(row) : null
}
findAccountByUserId(userId: string): AuthAccount | null {
const row = this.database
.getDb()
.prepare(
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE user_id = ?',
)
.get(userId) as
| {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}
| undefined
return row ? this.rowToAccount(row) : null
}
seedDemoAccount(): void {
const demo = this.findAccountByUsername('demo')
if (demo) return
this.users.ensureUser('demo')
const account: AuthAccount = {
id: randomUUID(),
username: 'demo',
passwordHash: bcrypt.hashSync('123456', 10),
userId: 'demo',
createdAt: new Date().toISOString(),
}
this.database
.getDb()
.prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
)
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
}
private createAuthAccount(
username: string,
password: string,
displayName: string,
): AuthAccount {
const normalized = username.trim().toLowerCase()
if (normalized.length < 3) {
throw new BadRequestException('نام کاربری باید حداقل ۳ کاراکتر باشد')
}
if (password.length < 6) {
throw new BadRequestException('رمز عبور باید حداقل ۶ کاراکتر باشد')
}
if (this.findAccountByUsername(normalized)) {
throw new ConflictException('این نام کاربری قبلاً ثبت شده')
}
const userId = randomUUID()
const state = createDefaultUserState(userId)
state.name = displayName.trim() || username.trim()
const now = new Date().toISOString()
const account: AuthAccount = {
id: randomUUID(),
username: normalized,
passwordHash: bcrypt.hashSync(password, 10),
userId,
createdAt: now,
}
this.database.transaction(() => {
this.database
.getDb()
.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)')
.run(userId, JSON.stringify(state), now)
this.database
.getDb()
.prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
)
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
})
return account
}
private verifyLogin(username: string, password: string): AuthAccount {
const account = this.findAccountByUsername(username)
if (!account || !bcrypt.compareSync(password, account.passwordHash)) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است')
}
this.users.ensureUser(account.userId)
return account
}
private buildAuthResponse(userId: string, username: string): AuthResponse {
const state = this.users.getUserState(userId)
return {
token: this.jwt.sign({ userId }),
user: {
id: state.id,
name: state.name,
username,
},
me: this.users.toMeResponse(state),
}
}
private rowToAccount(row: {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}): AuthAccount {
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
userId: row.user_id,
createdAt: row.created_at,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { IsString } from 'class-validator'
export class LoginDto {
@IsString()
username!: string
@IsString()
password!: string
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator'
export class RegisterDto {
@IsString()
username!: string
@IsString()
password!: string
@IsOptional()
@IsString()
name?: string
}