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';