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 { PhoneChangeRequestedEvent } from './phone-change-requested.event';
|
||||||
export { EmailChangedEvent } from './email-changed.event';
|
export { EmailChangedEvent } from './email-changed.event';
|
||||||
export { PhoneChangedEvent } from './phone-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 { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
|
||||||
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
export { EmailChangedEvent } from './domain/events/email-changed.event';
|
||||||
export { PhoneChangedEvent } from './domain/events/phone-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';
|
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export class OAuthService {
|
|||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
totpEnabledAt: null,
|
totpEnabledAt: null,
|
||||||
|
deletedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userRepo.save(user);
|
await this.userRepo.save(user);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface PropertyExtras {
|
|||||||
petFriendly?: boolean;
|
petFriendly?: boolean;
|
||||||
suitableFor?: string[];
|
suitableFor?: string[];
|
||||||
whyThisLocation?: string;
|
whyThisLocation?: string;
|
||||||
|
certificateVerified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateListingCommand {
|
export class CreateListingCommand {
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ export { ListingOwnershipTransferredEvent } from './domain/events/listing-owners
|
|||||||
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event';
|
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event';
|
||||||
export { ListingExpiringEvent } from './domain/events/listing-expiring.event';
|
export { ListingExpiringEvent } from './domain/events/listing-expiring.event';
|
||||||
export { Price } from './domain/value-objects/price.vo';
|
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()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(2000)
|
||||||
whyThisLocation?: string;
|
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)
|
@MaxLength(2000)
|
||||||
whyThisLocation?: string;
|
whyThisLocation?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: true, description: 'Whether ownership certificate has been verified' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
certificateVerified?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
nullable: true,
|
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);
|
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) =>
|
export const EndpointRateLimit = (options: EndpointRateLimitOptions) =>
|
||||||
SetMetadata<string, EndpointRateLimitOptions>(ENDPOINT_RATE_LIMIT_KEY, options);
|
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 {
|
export {
|
||||||
EndpointRateLimit,
|
EndpointRateLimit,
|
||||||
ENDPOINT_RATE_LIMIT_KEY,
|
ENDPOINT_RATE_LIMIT_KEY,
|
||||||
|
RL_SENSITIVE_WRITE,
|
||||||
type EndpointRateLimitOptions,
|
type EndpointRateLimitOptions,
|
||||||
} from './decorators/endpoint-rate-limit.decorator';
|
} from './decorators/endpoint-rate-limit.decorator';
|
||||||
export { EndpointRateLimitGuard } from './guards/endpoint-rate-limit.guard';
|
export { EndpointRateLimitGuard } from './guards/endpoint-rate-limit.guard';
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ vi.mock('@/components/ui/language-switcher', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLogout = vi.fn();
|
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' },
|
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
@@ -52,7 +59,7 @@ const mockAuthStore = vi.fn(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/auth-store', () => ({
|
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', () => ({
|
vi.mock('@/lib/utils', () => ({
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function makeListing(id: string, overrides: Partial<ListingDetail> = {}): Listin
|
|||||||
floors: null,
|
floors: null,
|
||||||
direction: 'SOUTH',
|
direction: 'SOUTH',
|
||||||
yearBuilt: 2020,
|
yearBuilt: 2020,
|
||||||
legalStatus: 'Sổ hồng',
|
legalStatus: 'SO_HONG',
|
||||||
amenities: ['Gym', 'Pool'],
|
amenities: ['Gym', 'Pool'],
|
||||||
projectName: 'Vinhomes',
|
projectName: 'Vinhomes',
|
||||||
latitude: null,
|
latitude: null,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function makeListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
|
|||||||
totalFloors: null,
|
totalFloors: null,
|
||||||
direction: 'SOUTH',
|
direction: 'SOUTH',
|
||||||
yearBuilt: 2020,
|
yearBuilt: 2020,
|
||||||
legalStatus: 'Sổ hồng',
|
legalStatus: 'SO_HONG',
|
||||||
amenities: ['Hồ bơi', 'Gym'],
|
amenities: ['Hồ bơi', 'Gym'],
|
||||||
nearbyPOIs: null,
|
nearbyPOIs: null,
|
||||||
metroDistanceM: null,
|
metroDistanceM: null,
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export interface ListingDetail {
|
|||||||
direction: Direction | null;
|
direction: Direction | null;
|
||||||
yearBuilt: number | null;
|
yearBuilt: number | null;
|
||||||
legalStatus: LegalStatus | null;
|
legalStatus: LegalStatus | null;
|
||||||
certificateVerified: boolean;
|
certificateVerified?: boolean;
|
||||||
amenities: string[] | null;
|
amenities: string[] | null;
|
||||||
nearbyPOIs: unknown;
|
nearbyPOIs: unknown;
|
||||||
metroDistanceM: number | null;
|
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[]
|
listings Listing[]
|
||||||
valuations Valuation[]
|
valuations Valuation[]
|
||||||
media PropertyMedia[]
|
media PropertyMedia[]
|
||||||
|
documents PropertyDocument[]
|
||||||
|
|
||||||
// --- Single-column indexes ---
|
// --- Single-column indexes ---
|
||||||
@@index([propertyType])
|
@@index([propertyType])
|
||||||
@@ -398,6 +399,42 @@ model PropertyMedia {
|
|||||||
@@index([propertyId])
|
@@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 {
|
model Listing {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
propertyId String
|
propertyId String
|
||||||
|
|||||||
Reference in New Issue
Block a user