Files
goodgo-platform/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts
Ho Ngoc Hai 3be106074d
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
feat: add P0/P1/P2 features + Swagger enrichment for MVP completeness
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>
2026-04-19 00:19:37 +07:00

106 lines
4.0 KiB
TypeScript

import { Body, Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import {
type JwtPayload,
CurrentUser,
JwtAuthGuard,
RolesGuard,
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')
export class AgentsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get agent dashboard stats' })
@ApiResponse({ status: 200, description: 'Agent dashboard data' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — only agents' })
@ApiResponse({ status: 404, description: 'Agent profile not found' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Get('me/dashboard')
async getDashboard(
@CurrentUser() user: JwtPayload,
): Promise<AgentDashboardData> {
return this.queryBus.execute(new GetAgentDashboardQuery(user.sub));
}
@ApiOperation({ summary: 'Get public agent profile' })
@ApiParam({ name: 'agentId', description: 'Agent ID' })
@ApiResponse({ status: 200, description: 'Agent public profile data' })
@ApiResponse({ status: 404, description: 'Agent not found' })
@Get(':agentId/profile')
async getPublicProfile(
@Param('agentId') agentId: string,
): Promise<AgentPublicProfileData> {
const profile = await this.queryBus.execute(
new GetAgentPublicProfileQuery(agentId),
);
if (!profile) {
throw new NotFoundException('Không tìm thấy môi giới');
}
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' })
@ApiResponse({ status: 201, description: 'Quality score recalculated' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — only admins' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post(':agentId/recalculate-score')
async recalculateScore(
@Param('agentId') agentId: string,
): Promise<{ message: string }> {
await this.commandBus.execute(
new RecalculateQualityScoreCommand(agentId),
);
return { message: 'Quality score recalculated' };
}
}