chore: dockerize monorepo with multi-stage builds and update frontend

This commit is contained in:
s.esmaeili
2026-09-10 20:11:27 +03:30
parent d6f9d63e14
commit efb0051ab9
28 changed files with 4179 additions and 1069 deletions
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable pnpm
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @lucky-wheel/api build
RUN pnpm deploy --filter @lucky-wheel/api --prod /prod/api
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /prod/api .
EXPOSE 3000
CMD ["node", "dist/src/main"]
@@ -1,10 +1,11 @@
import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AdminAuthService } from './admin-auth.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AdminRefreshDto } from './dto/admin-refresh.dto';
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
@ApiTags('Admin Auth (مدیریت)')
@Controller('admin/auth')
@@ -30,6 +31,15 @@ export class AdminAuthController {
return this.adminAuthService.refreshToken(dto);
}
@Patch('profile')
@UseGuards(JwtAdminGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('AdminAuth')
@ApiOperation({ summary: 'ویرایش اطلاعات حساب کاربری مدیر جاری' })
async updateProfile(@Req() req: any, @Body() dto: UpdateAdminProfileDto) {
return this.adminAuthService.updateProfile(req.user.id, dto);
}
@Post('logout')
@UseGuards(JwtAdminGuard)
@HttpCode(HttpStatus.OK)
@@ -1,4 +1,5 @@
import {
BadRequestException,
Injectable,
Logger,
OnModuleInit,
@@ -11,6 +12,7 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AdminRefreshDto } from './dto/admin-refresh.dto';
import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto';
@Injectable()
export class AdminAuthService implements OnModuleInit {
@@ -152,4 +154,38 @@ export class AdminAuthService implements OnModuleInit {
refreshToken,
};
}
async updateProfile(adminId: string, dto: UpdateAdminProfileDto) {
const dataToUpdate: any = {};
if (dto.name) dataToUpdate.name = dto.name.trim();
if (dto.mobile) dataToUpdate.mobile = dto.mobile.trim();
if (dto.email) {
const emailLower = dto.email.toLowerCase().trim();
const existing = await this.prisma.admin.findFirst({
where: { email: emailLower, id: { not: adminId } },
});
if (existing) {
throw new BadRequestException('این ایمیل قبلاً توسط مدیر دیگری ثبت شده است.');
}
dataToUpdate.email = emailLower;
}
if (dto.password) {
dataToUpdate.passwordHash = await bcrypt.hash(dto.password, 10);
}
const updated = await this.prisma.admin.update({
where: { id: adminId },
data: dataToUpdate,
select: {
id: true,
name: true,
email: true,
mobile: true,
role: true,
status: true,
},
});
return updated;
}
}
@@ -0,0 +1,27 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, Length, Matches } from 'class-validator';
export class UpdateAdminProfileDto {
@ApiPropertyOptional({ description: 'نام و نام خانوادگی مدیر' })
@IsOptional()
@IsString()
@Length(2, 50, { message: 'نام باید بین ۲ تا ۵۰ کاراکتر باشد.' })
name?: string;
@ApiPropertyOptional({ description: 'شماره موبایل مدیر' })
@IsOptional()
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقم و با ۰۹ شروع شود.' })
mobile?: string;
@ApiPropertyOptional({ description: 'ایمیل مدیر' })
@IsOptional()
@IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' })
email?: string;
@ApiPropertyOptional({ description: 'کلمه عبور جدید (اختیاری)' })
@IsOptional()
@IsString()
@Length(6, 50, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' })
password?: string;
}
+31 -2
View File
@@ -22,6 +22,8 @@ export class ReportService {
totalDistributedPrizes,
consumedChancesAggr,
activePrizesCount,
activeCampaignsCount,
activeCampaignsList,
recentWinners,
] = await Promise.all([
this.prisma.user.count(),
@@ -37,13 +39,35 @@ export class ReportService {
_sum: { amount: true },
}),
this.prisma.prize.count({ where: { status: 'ACTIVE' } }),
this.prisma.campaign.count({ where: { status: 'ACTIVE' } }),
this.prisma.campaign.findMany({
where: { status: 'ACTIVE' },
take: 4,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
slug: true,
status: true,
startAt: true,
endAt: true,
wheelDesign: true,
_count: {
select: {
spins: true,
userPrizes: true,
prizes: true,
},
},
},
}),
this.prisma.userPrize.findMany({
take: 10,
orderBy: { wonAt: 'desc' },
include: {
user: { select: { mobile: true, name: true } },
user: { select: { id: true, mobile: true, name: true } },
prize: { select: { title: true, type: true, image: true } },
campaign: { select: { title: true } },
campaign: { select: { id: true, title: true } },
},
}),
]);
@@ -102,6 +126,8 @@ export class ReportService {
percentage: totalWon > 0 ? Number(((p._count.id / totalWon) * 100).toFixed(1)) : 0,
}));
const winRate = totalSpins > 0 ? Number(((totalWinners / totalSpins) * 100).toFixed(1)) : 0;
return {
stats: {
totalUsers,
@@ -112,7 +138,10 @@ export class ReportService {
totalDistributedPrizes,
consumedChances: Math.abs(consumedChancesAggr._sum.amount || 0),
activePrizesCount,
activeCampaignsCount,
winRate,
},
activeCampaigns: activeCampaignsList,
spinsByDay,
prizeDistribution,
recentWinners,