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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -125,6 +125,7 @@ export class OAuthService {
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
await this.userRepo.save(user);
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PropertyExtras {
|
||||
petFriendly?: boolean;
|
||||
suitableFor?: string[];
|
||||
whyThisLocation?: string;
|
||||
certificateVerified?: boolean;
|
||||
}
|
||||
|
||||
export class CreateListingCommand {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user