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
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:
@@ -0,0 +1,3 @@
|
||||
export class ForgotPasswordCommand {
|
||||
constructor(public readonly emailOrPhone: string) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ResendOtpContext = 'EMAIL_CHANGE' | 'PHONE_CHANGE';
|
||||
|
||||
export class ResendOtpCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly context: ResendOtpContext,
|
||||
) {}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ResetPasswordCommand {
|
||||
constructor(
|
||||
public readonly emailOrPhone: string,
|
||||
public readonly code: string,
|
||||
public readonly newPassword: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user