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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { IsString } from 'class-validator'
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { IsString } from 'class-validator'
|
||||
|
||||
export class SendCodeDto {
|
||||
@ApiProperty({ example: '09123456789' })
|
||||
@IsString()
|
||||
mobileNumber: string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user