feat: add P0/P1/P2 features + Swagger enrichment for MVP completeness
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Closes four gaps the Swagger audit flagged as blocking a full MVP demo,
plus a general documentation pass.

P0 — Forgot/Reset password (auth)
- POST /auth/forgot-password (anti-enumeration: always 200)
- POST /auth/reset-password
- Reuses the Redis-OTP pattern from email/phone change; new key prefix
  auth:password_reset_otp with 15-min TTL.
- Emits PasswordResetRequestedEvent; new listener in notifications
  dispatches the existing password.reset email template (otp +
  expiryMinutes variables already in template.service.ts).
- UserEntity gains changePassword(HashedPassword) domain method; reset
  also revokes all refresh tokens for the user.

P0 — Favorites module
- New SavedListing Prisma model (unique(userId, listingId)) with User
  and Listing back-relations; schema pushed via db push since the
  remote DB was out of sync with migration history.
- New apps/api/src/modules/favorites/ module following the reviews
  module's shape (DDD/CQRS: domain repo + Prisma impl + 2 commands
  + 2 queries + controller).
- POST /favorites/:listingId, DELETE /favorites/:listingId,
  GET /favorites (paginated), GET /favorites/:listingId/check. All
  guarded by JwtAuthGuard.
- FavoritesModule wired into AppModule.

P1 — Resend OTP (auth)
- POST /auth/resend-otp for EMAIL_CHANGE | PHONE_CHANGE. Reads the
  pending OTP payload out of Redis and re-emits the original event
  without minting a new code, so TTL semantics stay intact. Password
  reset resend is done by re-POSTing /auth/forgot-password and is
  deliberately not in this enum.

P1 — Agent self-upgrade (agents)
- POST /agents/me/upgrade lets a BUYER/SELLER convert to AGENT. Creates
  an Agent row (isVerified=false) and flips User.role in one
  $transaction. Rejects if already AGENT/ADMIN or if an Agent row
  already exists.

P2 — Swagger enrichment
- @ApiConsumes('multipart/form-data') + body schema on listings media
  upload.
- GET /subscriptions/quota/:metric now enumerates the real metric
  values from METRIC_TO_PLAN_FIELD.
- POST /avm/batch and /analytics/valuation/batch document the max=50
  batch size from their DTO's @ArrayMaxSize.
- GET /admin/dashboard gains a realistic response example schema.
- Admin-gated endpoints in projects/transfer/industrial gain concrete
  400/401/403/404 responses.

Swagger endpoint count: 170 → 178. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 00:19:37 +07:00
parent 832e9a4eab
commit 3be106074d
46 changed files with 1672 additions and 8 deletions

View File

@@ -0,0 +1,3 @@
export class ForgotPasswordCommand {
constructor(public readonly emailOrPhone: string) {}
}

View File

@@ -0,0 +1,71 @@
import { randomInt } from 'crypto';
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, RedisService } from '@modules/shared';
import { PasswordResetRequestedEvent } from '../../../domain/events/password-reset-requested.event';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { ForgotPasswordCommand } from './forgot-password.command';
export const PASSWORD_RESET_OTP_PREFIX = 'auth:password_reset_otp';
export const PASSWORD_RESET_OTP_TTL = 900;
export const PASSWORD_RESET_OTP_EXPIRY_MINUTES = PASSWORD_RESET_OTP_TTL / 60;
export interface ForgotPasswordResultDto {
sent: true;
}
@CommandHandler(ForgotPasswordCommand)
export class ForgotPasswordHandler implements ICommandHandler<ForgotPasswordCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly redis: RedisService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: ForgotPasswordCommand): Promise<ForgotPasswordResultDto> {
try {
const identifier = command.emailOrPhone.trim();
const user = identifier.includes('@')
? await this.userRepo.findByEmail(identifier)
: await this.userRepo.findByPhone(identifier);
if (user?.email) {
const code = String(randomInt(100_000, 1_000_000));
const redisKey = `${PASSWORD_RESET_OTP_PREFIX}:${user.id}`;
await this.redis.set(
redisKey,
JSON.stringify({ code }),
PASSWORD_RESET_OTP_TTL,
);
this.eventBus.publish(
new PasswordResetRequestedEvent(user.id, user.email.value, code),
);
this.logger.log(
`Password reset OTP issued for user ${user.id}`,
this.constructor.name,
);
} else {
this.logger.log(
`Forgot password requested for unknown identifier (masked)`,
this.constructor.name,
);
}
return { sent: true };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to process forgot password: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể gửi mã đặt lại mật khẩu');
}
}
}

View File

@@ -0,0 +1,8 @@
export type ResendOtpContext = 'EMAIL_CHANGE' | 'PHONE_CHANGE';
export class ResendOtpCommand {
constructor(
public readonly userId: string,
public readonly context: ResendOtpContext,
) {}
}

View File

@@ -0,0 +1,74 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, LoggerService, RedisService, ValidationException } from '@modules/shared';
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
import { PhoneChangeRequestedEvent } from '../../../domain/events/phone-change-requested.event';
import { EMAIL_CHANGE_OTP_PREFIX, PHONE_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
import { ResendOtpCommand } from './resend-otp.command';
export interface ResendOtpResultDto {
sent: true;
}
interface EmailChangePayload {
newEmail: string;
code: string;
}
interface PhoneChangePayload {
newPhone: string;
code: string;
}
@CommandHandler(ResendOtpCommand)
export class ResendOtpHandler implements ICommandHandler<ResendOtpCommand> {
constructor(
private readonly redis: RedisService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: ResendOtpCommand): Promise<ResendOtpResultDto> {
try {
const redisKey = this.buildRedisKey(command);
const raw = await this.redis.get(redisKey);
if (!raw) {
throw new ValidationException('Không có yêu cầu OTP nào đang chờ xử lý');
}
if (command.context === 'EMAIL_CHANGE') {
const { newEmail, code } = JSON.parse(raw) as EmailChangePayload;
this.eventBus.publish(
new EmailChangeRequestedEvent(command.userId, newEmail, code),
);
} else {
const { newPhone, code } = JSON.parse(raw) as PhoneChangePayload;
this.eventBus.publish(
new PhoneChangeRequestedEvent(command.userId, newPhone, code),
);
}
this.logger.log(
`Resent ${command.context} OTP for user ${command.userId}`,
this.constructor.name,
);
return { sent: true };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to resend OTP: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể gửi lại mã OTP');
}
}
private buildRedisKey(command: ResendOtpCommand): string {
const prefix =
command.context === 'EMAIL_CHANGE' ? EMAIL_CHANGE_OTP_PREFIX : PHONE_CHANGE_OTP_PREFIX;
return `${prefix}:${command.userId}`;
}
}

View File

@@ -0,0 +1,7 @@
export class ResetPasswordCommand {
constructor(
public readonly emailOrPhone: string,
public readonly code: string,
public readonly newPassword: string,
) {}
}

View File

@@ -0,0 +1,83 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import {
DomainException,
LoggerService,
NotFoundException,
RedisService,
ValidationException,
} from '@modules/shared';
import { type IRefreshTokenRepository, REFRESH_TOKEN_REPOSITORY } from '../../../domain/repositories/refresh-token.repository';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { HashedPassword } from '../../../domain/value-objects/hashed-password.vo';
import { PASSWORD_RESET_OTP_PREFIX } from '../forgot-password/forgot-password.handler';
import { ResetPasswordCommand } from './reset-password.command';
export interface ResetPasswordResultDto {
id: string;
updatedAt: Date;
}
@CommandHandler(ResetPasswordCommand)
export class ResetPasswordHandler implements ICommandHandler<ResetPasswordCommand> {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
@Inject(REFRESH_TOKEN_REPOSITORY) private readonly refreshTokenRepo: IRefreshTokenRepository,
private readonly redis: RedisService,
private readonly logger: LoggerService,
) {}
async execute(command: ResetPasswordCommand): Promise<ResetPasswordResultDto> {
try {
const hashedResult = await HashedPassword.fromPlain(command.newPassword);
if (hashedResult.isErr) {
throw new ValidationException(hashedResult.unwrapErr());
}
const passwordVo = hashedResult.unwrap();
const identifier = command.emailOrPhone.trim();
const user = identifier.includes('@')
? await this.userRepo.findByEmail(identifier)
: await this.userRepo.findByPhone(identifier);
if (!user) {
throw new NotFoundException('Người dùng', identifier);
}
const redisKey = `${PASSWORD_RESET_OTP_PREFIX}:${user.id}`;
const raw = await this.redis.get(redisKey);
if (!raw) {
throw new ValidationException(
'Mã xác thực đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu lại.',
);
}
const { code } = JSON.parse(raw) as { code: string };
if (code !== command.code) {
throw new ValidationException('Mã xác thực không đúng');
}
user.changePassword(passwordVo);
await this.userRepo.update(user);
await this.redis.del(redisKey);
await this.refreshTokenRepo.revokeAllForUser(user.id);
this.logger.log(
`Password reset completed for user ${user.id}`,
this.constructor.name,
);
return { id: user.id, updatedAt: user.updatedAt };
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to reset password: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException('Không thể đặt lại mật khẩu');
}
}
}

View File

@@ -11,12 +11,15 @@ import { CancelUserDeletionHandler } from './application/commands/cancel-user-de
import { DisableMfaHandler } from './application/commands/disable-mfa/disable-mfa.handler';
import { ExportUserDataHandler } from './application/commands/export-user-data/export-user-data.handler';
import { ForceDeleteUserHandler } from './application/commands/force-delete-user/force-delete-user.handler';
import { ForgotPasswordHandler } from './application/commands/forgot-password/forgot-password.handler';
import { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
import { LoginUserHandler } from './application/commands/login-user/login-user.handler';
import { ResetPasswordHandler } from './application/commands/reset-password/reset-password.handler';
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
@@ -57,6 +60,9 @@ const CommandHandlers = [
UpdateProfileHandler,
VerifyEmailChangeHandler,
VerifyPhoneChangeHandler,
ForgotPasswordHandler,
ResetPasswordHandler,
ResendOtpHandler,
RequestUserDeletionHandler,
CancelUserDeletionHandler,
ForceDeleteUserHandler,

View File

@@ -150,4 +150,9 @@ export class UserEntity extends AggregateRoot<string> {
this._phone = phone;
this.updatedAt = new Date();
}
changePassword(passwordHash: HashedPassword): void {
this._passwordHash = passwordHash;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,12 @@
import { type DomainEvent } from '@modules/shared';
export class PasswordResetRequestedEvent implements DomainEvent {
readonly eventName = 'user.password_reset_requested';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly email: string,
public readonly otpCode: string,
) {}
}

View File

@@ -17,11 +17,17 @@ import {
EndpointRateLimitGuard,
UnauthorizedException,
} from '@modules/shared';
import { ForgotPasswordCommand } from '../../application/commands/forgot-password/forgot-password.command';
import { type ForgotPasswordResultDto } from '../../application/commands/forgot-password/forgot-password.handler';
import { GenerateKycUploadUrlsCommand } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
import { LoginUserCommand } from '../../application/commands/login-user/login-user.command';
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
import { ResendOtpCommand } from '../../application/commands/resend-otp/resend-otp.command';
import { type ResendOtpResultDto } from '../../application/commands/resend-otp/resend-otp.handler';
import { ResetPasswordCommand } from '../../application/commands/reset-password/reset-password.command';
import { type ResetPasswordResultDto } from '../../application/commands/reset-password/reset-password.handler';
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
@@ -38,10 +44,13 @@ import { TokenService, type JwtPayload, type TokenPair } from '../../infrastruct
import { type LocalStrategyResult } from '../../infrastructure/strategies/local.strategy';
import { CurrentUser } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { ForgotPasswordDto } from '../dto/forgot-password.dto';
import { GenerateKycUploadUrlsDto } from '../dto/generate-kyc-upload-urls.dto';
import { LoginDto } from '../dto/login.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { RegisterDto } from '../dto/register.dto';
import { ResendOtpDto } from '../dto/resend-otp.dto';
import { ResetPasswordDto } from '../dto/reset-password.dto';
import { SubmitKycDto } from '../dto/submit-kyc.dto';
import { UpdateProfileDto } from '../dto/update-profile.dto';
import { VerifyEmailChangeDto } from '../dto/verify-email-change.dto';
@@ -188,6 +197,39 @@ export class AuthController {
return { message: 'Đã đăng xuất' };
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 3, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('forgot-password')
@ApiOperation({
summary: 'Request password reset OTP',
description:
'Sends a password reset code to the user identified by email or phone. Always returns 200 with { sent: true } to prevent account enumeration.',
})
@ApiResponse({ status: 201, description: 'Reset code sent (or silently ignored for unknown identifier)' })
@ApiResponse({ status: 400, description: 'Validation error' })
async forgotPassword(
@Body() dto: ForgotPasswordDto,
): Promise<ForgotPasswordResultDto> {
return this.commandBus.execute(new ForgotPasswordCommand(dto.emailOrPhone));
}
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' })
@UseGuards(EndpointRateLimitGuard)
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using OTP code' })
@ApiResponse({ status: 201, description: 'Password reset successfully' })
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 404, description: 'User not found' })
async resetPassword(
@Body() dto: ResetPasswordDto,
): Promise<ResetPasswordResultDto> {
return this.commandBus.execute(
new ResetPasswordCommand(dto.emailOrPhone, dto.code, dto.newPassword),
);
}
@Post('exchange-token')
@ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' })
@ApiResponse({ status: 201, description: 'Auth cookies set' })
@@ -272,6 +314,24 @@ export class AuthController {
return { message: 'Email đã được cập nhật thành công', data: result };
}
@UseGuards(JwtAuthGuard)
@Post('resend-otp')
@ApiBearerAuth('JWT')
@ApiOperation({
summary: 'Resend a pending profile-change OTP',
description:
'Re-emits the existing OTP for an in-flight email or phone change without generating a new code, so the original TTL is preserved. Returns 400 if no OTP is pending. For password-reset re-sends, call POST /auth/forgot-password again.',
})
@ApiResponse({ status: 201, description: 'OTP re-sent via the originating channel' })
@ApiResponse({ status: 400, description: 'No pending OTP request, or invalid context' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async resendOtp(
@CurrentUser() user: JwtPayload,
@Body() dto: ResendOtpDto,
): Promise<ResendOtpResultDto> {
return this.commandBus.execute(new ResendOtpCommand(user.sub, dto.context));
}
@UseGuards(JwtAuthGuard)
@Get('profile/agent')
@ApiBearerAuth('JWT')

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class ForgotPasswordDto {
@ApiProperty({
description: 'Email hoặc số điện thoại (định dạng +84...)',
example: 'nguoi-dung@goodgo.vn',
})
@IsString()
@IsNotEmpty()
emailOrPhone!: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
import { type ResendOtpContext } from '../../application/commands/resend-otp/resend-otp.command';
export const RESEND_OTP_CONTEXTS = ['EMAIL_CHANGE', 'PHONE_CHANGE'] as const;
export class ResendOtpDto {
@ApiProperty({
description:
'Loại OTP cần gửi lại. Hỗ trợ đổi email (EMAIL_CHANGE) hoặc đổi số điện thoại (PHONE_CHANGE). Để gửi lại mã đặt lại mật khẩu, gọi lại endpoint /auth/forgot-password.',
enum: RESEND_OTP_CONTEXTS,
example: 'EMAIL_CHANGE',
})
@IsEnum(RESEND_OTP_CONTEXTS, {
message: 'Context phải là EMAIL_CHANGE hoặc PHONE_CHANGE',
})
context!: ResendOtpContext;
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, MinLength } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty({
description: 'Email hoặc số điện thoại đã yêu cầu đặt lại mật khẩu',
example: 'nguoi-dung@goodgo.vn',
})
@IsString()
@IsNotEmpty()
emailOrPhone!: string;
@ApiProperty({
description: 'Mã OTP 6 chữ số gửi qua email',
example: '123456',
})
@IsString()
@Length(6, 6)
code!: string;
@ApiProperty({
description: 'Mật khẩu mới (ít nhất 8 ký tự)',
minLength: 8,
})
@IsString()
@MinLength(8)
newPassword!: string;
}