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:
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||
import { UpgradeToAgentHandler } from './application/commands/upgrade-to-agent/upgrade-to-agent.handler';
|
||||
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
||||
import { GetAgentPublicProfileHandler } from './application/queries/get-agent-public-profile/get-agent-public-profile.handler';
|
||||
@@ -8,7 +9,7 @@ import { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
|
||||
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||
const CommandHandlers = [RecalculateQualityScoreHandler, UpgradeToAgentHandler];
|
||||
|
||||
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class UpgradeToAgentCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly licenseNumber?: string,
|
||||
public readonly agency?: string,
|
||||
public readonly bio?: string,
|
||||
public readonly serviceAreas?: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ConflictException,
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
PrismaService,
|
||||
} from '@modules/shared';
|
||||
import { UpgradeToAgentCommand } from './upgrade-to-agent.command';
|
||||
|
||||
export interface UpgradeToAgentResult {
|
||||
agentId: string;
|
||||
userId: string;
|
||||
isVerified: false;
|
||||
}
|
||||
|
||||
@CommandHandler(UpgradeToAgentCommand)
|
||||
export class UpgradeToAgentHandler
|
||||
implements ICommandHandler<UpgradeToAgentCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpgradeToAgentCommand): Promise<UpgradeToAgentResult> {
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: command.userId },
|
||||
select: { id: true, role: true, agent: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User', command.userId);
|
||||
}
|
||||
|
||||
if (user.role === 'AGENT') {
|
||||
throw new ConflictException('Tài khoản đã là đại lý');
|
||||
}
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
throw new ConflictException('Admin không cần nâng cấp đại lý');
|
||||
}
|
||||
|
||||
if (user.agent) {
|
||||
throw new ConflictException('Hồ sơ đại lý đã tồn tại cho tài khoản này');
|
||||
}
|
||||
|
||||
const agentId = await this.prisma.$transaction(async (tx) => {
|
||||
const agent = await tx.agent.create({
|
||||
data: {
|
||||
userId: command.userId,
|
||||
licenseNumber: command.licenseNumber,
|
||||
agency: command.agency,
|
||||
bio: command.bio,
|
||||
serviceAreas: command.serviceAreas ?? [],
|
||||
isVerified: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: command.userId },
|
||||
data: { role: 'AGENT' },
|
||||
});
|
||||
|
||||
return agent.id;
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`User ${command.userId} upgraded to AGENT (agentId=${agentId})`,
|
||||
'UpgradeToAgentHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
userId: command.userId,
|
||||
isVerified: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to upgrade user ${command.userId} to agent: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể nâng cấp tài khoản lên đại lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
Roles,
|
||||
} from '@modules/auth';
|
||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
||||
import { UpgradeToAgentCommand } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.command';
|
||||
import { type UpgradeToAgentResult } from '../../application/commands/upgrade-to-agent/upgrade-to-agent.handler';
|
||||
import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query';
|
||||
import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query';
|
||||
import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository';
|
||||
import { UpgradeToAgentDto } from '../dto/upgrade-to-agent.dto';
|
||||
|
||||
@ApiTags('agents')
|
||||
@Controller('agents')
|
||||
@@ -59,6 +62,29 @@ export class AgentsController {
|
||||
return profile;
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Nâng cấp tài khoản lên đại lý' })
|
||||
@ApiResponse({ status: 201, description: 'Tài khoản đã được nâng cấp lên đại lý (chưa xác minh)' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy người dùng' })
|
||||
@ApiResponse({ status: 409, description: 'Tài khoản đã là đại lý hoặc không được phép nâng cấp' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('me/upgrade')
|
||||
async upgradeToAgent(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpgradeToAgentDto,
|
||||
): Promise<UpgradeToAgentResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpgradeToAgentCommand(
|
||||
user.sub,
|
||||
dto.licenseNumber,
|
||||
dto.agency,
|
||||
dto.bio,
|
||||
dto.serviceAreas,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpgradeToAgentDto {
|
||||
@ApiProperty({ required: false, description: 'Số giấy phép hành nghề' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
licenseNumber?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Công ty / Sàn giao dịch' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
agency?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Giới thiệu bản thân' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
bio?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
type: [String],
|
||||
description: 'Khu vực hoạt động (slug quận/huyện)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
serviceAreas?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user