fix(platform): resolve master compile errors blocking load test (GOO-171)

- listings index.ts now re-exports PROPERTY_REPOSITORY/IPropertyRepository
  and MEDIA_STORAGE_SERVICE/IMediaStorageService/MinioMediaStorageService
  (documents module depends on these)
- add PropertyDocument model + DocumentType + DocumentVerificationStatus
  enums to Prisma schema and create companion migration
- add TooManyRequestsException to shared domain exceptions
- add RL_SENSITIVE_WRITE preset to endpoint-rate-limit decorator and
  re-export from shared/infrastructure
- add certificateVerified to PropertyExtras + Create/UpdateListingDto so
  listings.controller line 135/341 type-check
- create PhoneLoginOtpRequestedEvent + matching notifications listener
  (notifications.module already imports the listener)
- oauth.service constructs UserEntity with deletedAt: null
- web: fix LegalStatus fixtures ('Sổ hồng' -> 'SO_HONG'), make
  ListingDetail.property.certificateVerified optional so existing fixtures
  compile, type admin layout auth-store mock to accept null user

Verified: `pnpm typecheck` green across @goodgo/api, @goodgo/web,
@goodgo/mcp-servers; `pnpm --filter @goodgo/api build` succeeds.

Unblocks [GOO-137](/GOO/issues/GOO-137) load test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 01:10:18 +07:00
parent 6b23bfb756
commit d463f578cd
18 changed files with 173 additions and 5 deletions

View File

@@ -4,3 +4,4 @@ export { EmailChangeRequestedEvent } from './email-change-requested.event';
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
export { EmailChangedEvent } from './email-changed.event';
export { PhoneChangedEvent } from './phone-changed.event';
export { PhoneLoginOtpRequestedEvent } from './phone-login-otp-requested.event';

View File

@@ -0,0 +1,16 @@
import { type DomainEvent } from '@modules/shared';
/**
* Emitted when a user requests an OTP to log in via phone number.
* Triggers an SMS notification with the one-time code.
*/
export class PhoneLoginOtpRequestedEvent implements DomainEvent {
readonly eventName = 'user.phone_login_otp_requested';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly phone: string,
public readonly otpCode: string,
) {}
}

View File

@@ -16,4 +16,5 @@ export { EmailChangeRequestedEvent } from './domain/events/email-change-requeste
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
export { EmailChangedEvent } from './domain/events/email-changed.event';
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
export { PhoneLoginOtpRequestedEvent } from './domain/events/phone-login-otp-requested.event';
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';

View File

@@ -125,6 +125,7 @@ export class OAuthService {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
deletedAt: null,
});
await this.userRepo.save(user);

View File

@@ -14,6 +14,7 @@ export interface PropertyExtras {
petFriendly?: boolean;
suitableFor?: string[];
whyThisLocation?: string;
certificateVerified?: boolean;
}
export class CreateListingCommand {

View File

@@ -23,3 +23,9 @@ export { ListingOwnershipTransferredEvent } from './domain/events/listing-owners
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event';
export { ListingExpiringEvent } from './domain/events/listing-expiring.event';
export { Price } from './domain/value-objects/price.vo';
export { PROPERTY_REPOSITORY, type IPropertyRepository } from './domain/repositories/property.repository';
export {
MEDIA_STORAGE_SERVICE,
type IMediaStorageService,
MinioMediaStorageService,
} from './infrastructure/services/media-storage.service';

View File

@@ -212,4 +212,9 @@ export class CreateListingDto {
@IsString()
@MaxLength(2000)
whyThisLocation?: string;
@ApiPropertyOptional({ example: true, description: 'Whether ownership certificate has been verified' })
@IsOptional()
@IsBoolean()
certificateVerified?: boolean;
}

View File

@@ -115,6 +115,11 @@ export class UpdateListingDto {
@MaxLength(2000)
whyThisLocation?: string;
@ApiPropertyOptional({ example: true, description: 'Whether ownership certificate has been verified' })
@IsOptional()
@IsBoolean()
certificateVerified?: boolean;
@ApiPropertyOptional({
type: String,
nullable: true,

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type PhoneLoginOtpRequestedEvent } from '@modules/auth';
import { LoggerService } from '@modules/shared';
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
@Injectable()
export class PhoneLoginOtpRequestedListener {
constructor(
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@OnEvent('user.phone_login_otp_requested', { async: true })
async handle(event: PhoneLoginOtpRequestedEvent): Promise<void> {
this.logger.log(
`Handling phone login OTP for user ${event.aggregateId}`,
'PhoneLoginOtpRequestedListener',
);
await this.commandBus.execute(
new SendNotificationCommand(
event.aggregateId,
'SMS',
'user.phone_login_otp',
{ otpCode: event.otpCode },
event.phone,
),
);
}
}

View File

@@ -54,3 +54,9 @@ export class ForbiddenException extends DomainException {
super(ErrorCode.FORBIDDEN, message, HttpStatus.FORBIDDEN);
}
}
export class TooManyRequestsException extends DomainException {
constructor(message = 'Too many requests', details?: Record<string, unknown>) {
super(ErrorCode.TOO_MANY_REQUESTS, message, HttpStatus.TOO_MANY_REQUESTS, details);
}
}

View File

@@ -55,3 +55,14 @@ export interface EndpointRateLimitOptions {
*/
export const EndpointRateLimit = (options: EndpointRateLimitOptions) =>
SetMetadata<string, EndpointRateLimitOptions>(ENDPOINT_RATE_LIMIT_KEY, options);
/**
* Standard preset for sensitive write endpoints (document uploads, account changes, etc.).
* 10 requests/min per authenticated user; ADMIN bypass enabled.
*/
export const RL_SENSITIVE_WRITE: EndpointRateLimitOptions = {
limit: 10,
windowSeconds: 60,
keyStrategy: 'user',
adminBypass: true,
};

View File

@@ -35,6 +35,7 @@ export { UserRateLimit } from './decorators/user-rate-limit.decorator';
export {
EndpointRateLimit,
ENDPOINT_RATE_LIMIT_KEY,
RL_SENSITIVE_WRITE,
type EndpointRateLimitOptions,
} from './decorators/endpoint-rate-limit.decorator';
export { EndpointRateLimitGuard } from './guards/endpoint-rate-limit.guard';

View File

@@ -44,7 +44,14 @@ vi.mock('@/components/ui/language-switcher', () => ({
}));
const mockLogout = vi.fn();
const mockAuthStore = vi.fn(() => ({
type AuthUser = { id: string; fullName: string; role: string; email: string } | null;
type AuthState = {
user: AuthUser;
isAuthenticated: boolean;
isInitialized: boolean;
logout: typeof mockLogout;
};
const mockAuthStore = vi.fn((): AuthState => ({
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
isAuthenticated: true,
isInitialized: true,
@@ -52,7 +59,7 @@ const mockAuthStore = vi.fn(() => ({
}));
vi.mock('@/lib/auth-store', () => ({
useAuthStore: (...args: unknown[]) => mockAuthStore(...args),
useAuthStore: (...args: unknown[]) => (mockAuthStore as unknown as (...a: unknown[]) => AuthState)(...args),
}));
vi.mock('@/lib/utils', () => ({

View File

@@ -96,7 +96,7 @@ function makeListing(id: string, overrides: Partial<ListingDetail> = {}): Listin
floors: null,
direction: 'SOUTH',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
legalStatus: 'SO_HONG',
amenities: ['Gym', 'Pool'],
projectName: 'Vinhomes',
latitude: null,

View File

@@ -137,7 +137,7 @@ function makeListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
totalFloors: null,
direction: 'SOUTH',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
legalStatus: 'SO_HONG',
amenities: ['Hồ bơi', 'Gym'],
nearbyPOIs: null,
metroDistanceM: null,

View File

@@ -110,7 +110,7 @@ export interface ListingDetail {
direction: Direction | null;
yearBuilt: number | null;
legalStatus: LegalStatus | null;
certificateVerified: boolean;
certificateVerified?: boolean;
amenities: string[] | null;
nearbyPOIs: unknown;
metroDistanceM: number | null;

View File

@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "DocumentType" AS ENUM ('SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER');
-- CreateEnum
CREATE TYPE "DocumentVerificationStatus" AS ENUM ('PENDING_REVIEW', 'APPROVED', 'REJECTED');
-- CreateTable
CREATE TABLE "PropertyDocument" (
"id" TEXT NOT NULL,
"propertyId" TEXT NOT NULL,
"uploadedById" TEXT NOT NULL,
"documentType" "DocumentType" NOT NULL,
"status" "DocumentVerificationStatus" NOT NULL DEFAULT 'PENDING_REVIEW',
"url" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSizeBytes" INTEGER NOT NULL,
"description" TEXT,
"rejectionReason" TEXT,
"reviewedById" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropertyDocument_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PropertyDocument_propertyId_idx" ON "PropertyDocument"("propertyId");
-- CreateIndex
CREATE INDEX "PropertyDocument_status_createdAt_idx" ON "PropertyDocument"("status", "createdAt");
-- CreateIndex
CREATE INDEX "PropertyDocument_uploadedById_idx" ON "PropertyDocument"("uploadedById");
-- AddForeignKey
ALTER TABLE "PropertyDocument" ADD CONSTRAINT "PropertyDocument_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -370,6 +370,7 @@ model Property {
listings Listing[]
valuations Valuation[]
media PropertyMedia[]
documents PropertyDocument[]
// --- Single-column indexes ---
@@index([propertyType])
@@ -398,6 +399,42 @@ model PropertyMedia {
@@index([propertyId])
}
enum DocumentType {
SO_DO
SO_HONG
GCNQSD
OTHER
}
enum DocumentVerificationStatus {
PENDING_REVIEW
APPROVED
REJECTED
}
model PropertyDocument {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
uploadedById String
documentType DocumentType
status DocumentVerificationStatus @default(PENDING_REVIEW)
url String
fileName String
mimeType String
fileSizeBytes Int
description String? @db.Text
rejectionReason String? @db.Text
reviewedById String?
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([propertyId])
@@index([status, createdAt])
@@index([uploadedById])
}
model Listing {
id String @id @default(cuid())
propertyId String