feat(cardex): implement cardex controller, service, and DTO for stock movements

feat(pos): add POS controller and service for order creation and stock retrieval
refactor(pos): enhance stock and product category retrieval with pagination and mapping
This commit is contained in:
2025-12-21 19:09:41 +03:30
parent d514267f58
commit c6a86719dd
42 changed files with 3769 additions and 119 deletions
+41
View File
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { ResponseMapper } from '../common/response/response-mapper'
import { AuthService } from './auth.service'
import { CurrentUser } from './current-user.decorator'
import { RefreshTokenDto } from './dto/refresh-token.dto'
import { SendCodeDto } from './dto/send-code.dto'
import { VerifyCodeDto } from './dto/verify-code.dto'
import { JwtAuthGuard } from './jwt-auth.guard'
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('send-code')
async sendCode(@Body() dto: SendCodeDto) {
return this.auth.sendCode(dto)
}
@Post('login')
async login(@Body() dto: VerifyCodeDto) {
return this.auth.loginWithCode(dto)
}
@Post('refresh')
async refresh(@Body() dto: RefreshTokenDto) {
return this.auth.refreshToken(dto)
}
@Post('logout')
async logout(@Body() dto: RefreshTokenDto) {
return this.auth.logout(dto.refreshToken)
}
@Get('me')
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: any) {
return ResponseMapper.single(user)
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import 'dotenv/config'
import { env } from 'prisma/config'
import { PrismaModule } from '../prisma/prisma.module'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { JwtAuthGuard } from './jwt-auth.guard'
@Module({
imports: [
PrismaModule,
JwtModule.register({
secret: env('JWT_SECRET') || 'secret',
signOptions: { expiresIn: Number(env('JWT_EXPIRES_IN')) || '15m' },
}),
],
providers: [AuthService, JwtAuthGuard],
controllers: [AuthController],
exports: [AuthService, JwtAuthGuard],
})
export class AuthModule {}
+126
View File
@@ -0,0 +1,126 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { createHash, randomBytes } from 'crypto'
import 'dotenv/config'
import { env } from 'prisma/config'
import { ResponseMapper } from '../common/response/response-mapper'
import { PrismaService } from '../prisma/prisma.service'
import { RefreshTokenDto } from './dto/refresh-token.dto'
import { SendCodeDto } from './dto/send-code.dto'
import { VerifyCodeDto } from './dto/verify-code.dto'
@Injectable()
export class AuthService {
private readonly OTP_TTL_MINUTES = 2
private readonly ACCESS_TOKEN_EXP = Number(env('JWT_EXPIRES_IN')) || '15m'
private readonly REFRESH_TOKEN_EXP_DAYS = 30
constructor(
private prisma: PrismaService,
private jwt: JwtService,
) {}
private generateCode() {
// For development you may want fixed code via env, otherwise random 5-digit
const staticCode = process.env.OTP_STATIC_CODE
if (staticCode) return staticCode
return Math.floor(10000 + Math.random() * 90000).toString()
}
async sendCode(dto: SendCodeDto) {
const { mobileNumber } = dto
const user = await this.prisma.user.findFirst({ where: { mobileNumber } })
if (!user) {
throw new NotFoundException('کاربری با این شماره تماس یافت نشد')
}
const code = this.generateCode()
const expiresAt = new Date(Date.now() + this.OTP_TTL_MINUTES * 60 * 1000)
await this.prisma.otpCode.create({
data: {
mobileNumber,
code,
expiresAt,
},
})
// TODO: integrate SMS provider. For now return the code for dev.
return {
ok: true,
}
}
async loginWithCode(dto: VerifyCodeDto) {
const { mobileNumber, code } = dto
const user = await this.prisma.user.findFirst({
where: { mobileNumber },
include: {
role: true,
},
})
if (!user) {
throw new NotFoundException('کاربری با این شماره تماس یافت نشد')
}
const otp = await this.prisma.otpCode.findFirst({
where: { mobileNumber, used: false },
})
if (!otp) throw new BadRequestException('اطلاعات ورودی درست نیست')
if (otp.used) throw new BadRequestException('کد قبلن استفاده شده است')
if (otp.expiresAt.getTime() < Date.now())
throw new BadRequestException('کد وارد شده منقضی شده است')
if (otp.code !== code) throw new BadRequestException('کد وارد شده اشتباه است')
// mark used
await this.prisma.otpCode.update({ where: { id: otp.id }, data: { used: true } })
const accessToken = this.jwt.sign(
{ sub: user.id, mobileNumber },
{ expiresIn: this.ACCESS_TOKEN_EXP },
)
const refreshTokenPlain = randomBytes(48).toString('hex')
const tokenHash = createHash('sha256').update(refreshTokenPlain).digest('hex')
const expiresAt = new Date(
Date.now() + this.REFRESH_TOKEN_EXP_DAYS * 24 * 60 * 60 * 1000,
)
await this.prisma.refreshToken.create({
data: { tokenHash, userId: user.id, expiresAt },
})
return ResponseMapper.single({ accessToken, refreshToken: refreshTokenPlain, user })
}
async refreshToken(dto: RefreshTokenDto) {
const { refreshToken } = dto
const tokenHash = createHash('sha256').update(refreshToken).digest('hex')
const record = await this.prisma.refreshToken.findFirst({ where: { tokenHash } })
if (!record || record.revoked || record.expiresAt.getTime() < Date.now()) {
throw new UnauthorizedException('Invalid refresh token')
}
const user = await this.prisma.user.findUnique({ where: { id: record.userId } })
if (!user) throw new UnauthorizedException('User not found')
const accessToken = this.jwt.sign(
{ sub: user.id, mobileNumber: user.mobileNumber },
{ expiresIn: this.ACCESS_TOKEN_EXP },
)
return { accessToken }
}
async logout(refreshToken: string) {
const tokenHash = createHash('sha256').update(refreshToken).digest('hex')
await this.prisma.refreshToken.updateMany({
where: { tokenHash },
data: { revoked: true },
})
return { ok: true }
}
}
+8
View File
@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest()
return req.user
},
)
+12
View File
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsString } from 'class-validator'
export class LoginDto {
@ApiProperty({ description: 'Mobile number used as username', example: '09123456789' })
@IsString()
mobileNumber: string
@ApiProperty({ description: 'OTP password (one-time password)', example: '12345' })
@IsString()
password: string
}
+8
View File
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsString } from 'class-validator'
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken: string
}
+8
View File
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsString } from 'class-validator'
export class SendCodeDto {
@ApiProperty({ example: '09123456789' })
@IsString()
mobileNumber: string
}
+12
View File
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsString } from 'class-validator'
export class VerifyCodeDto {
@ApiProperty({ example: '09123456789' })
@IsString()
mobileNumber: string
@ApiProperty({ example: '12345' })
@IsString()
code: string
}
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Request } from 'express'
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>()
const auth = req.headers.authorization
if (!auth) throw new UnauthorizedException('Missing Authorization header')
const parts = auth.split(' ')
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new UnauthorizedException('Invalid Authorization header format')
}
const token = parts[1]
try {
const payload = this.jwt.verify(token, {
secret: process.env.JWT_SECRET || 'secret',
})
;(req as any).user = payload
return true
} catch (err) {
throw new UnauthorizedException('Invalid or expired token')
}
}
}