diff --git a/apps/api/src/modules/auth/domain/events/index.ts b/apps/api/src/modules/auth/domain/events/index.ts index 61b25ce..357a85a 100644 --- a/apps/api/src/modules/auth/domain/events/index.ts +++ b/apps/api/src/modules/auth/domain/events/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/domain/events/phone-login-otp-requested.event.ts b/apps/api/src/modules/auth/domain/events/phone-login-otp-requested.event.ts new file mode 100644 index 0000000..5b2266f --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/phone-login-otp-requested.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index ff194e0..ee8aaa4 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -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'; diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index d19e482..59c4699 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -125,6 +125,7 @@ export class OAuthService { totpEnabled: false, totpBackupCodes: [], totpEnabledAt: null, + deletedAt: null, }); await this.userRepo.save(user); diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts index bed9a6c..4ae6651 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts @@ -14,6 +14,7 @@ export interface PropertyExtras { petFriendly?: boolean; suitableFor?: string[]; whyThisLocation?: string; + certificateVerified?: boolean; } export class CreateListingCommand { diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index 1b5095e..459103c 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -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'; diff --git a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts index b6db631..ff14009 100644 --- a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts @@ -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; } diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts index 900c1ff..51fdbed 100644 --- a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts @@ -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, diff --git a/apps/api/src/modules/notifications/application/listeners/phone-login-otp-requested.listener.ts b/apps/api/src/modules/notifications/application/listeners/phone-login-otp-requested.listener.ts new file mode 100644 index 0000000..ba03ffa --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/phone-login-otp-requested.listener.ts @@ -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 { + 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, + ), + ); + } +} diff --git a/apps/api/src/modules/shared/domain/domain-exception.ts b/apps/api/src/modules/shared/domain/domain-exception.ts index 7ca5e5b..85106bb 100644 --- a/apps/api/src/modules/shared/domain/domain-exception.ts +++ b/apps/api/src/modules/shared/domain/domain-exception.ts @@ -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) { + super(ErrorCode.TOO_MANY_REQUESTS, message, HttpStatus.TOO_MANY_REQUESTS, details); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/decorators/endpoint-rate-limit.decorator.ts b/apps/api/src/modules/shared/infrastructure/decorators/endpoint-rate-limit.decorator.ts index b9731d4..dfbba20 100644 --- a/apps/api/src/modules/shared/infrastructure/decorators/endpoint-rate-limit.decorator.ts +++ b/apps/api/src/modules/shared/infrastructure/decorators/endpoint-rate-limit.decorator.ts @@ -55,3 +55,14 @@ export interface EndpointRateLimitOptions { */ export const EndpointRateLimit = (options: EndpointRateLimitOptions) => SetMetadata(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, +}; diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 9d3fb95..c2408f6 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -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'; diff --git a/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx b/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx index 878d4d4..f28b8ec 100644 --- a/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx +++ b/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx @@ -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', () => ({ diff --git a/apps/web/components/comparison/__tests__/comparison-table.spec.tsx b/apps/web/components/comparison/__tests__/comparison-table.spec.tsx index 460570c..3299523 100644 --- a/apps/web/components/comparison/__tests__/comparison-table.spec.tsx +++ b/apps/web/components/comparison/__tests__/comparison-table.spec.tsx @@ -96,7 +96,7 @@ function makeListing(id: string, overrides: Partial = {}): Listin floors: null, direction: 'SOUTH', yearBuilt: 2020, - legalStatus: 'Sổ hồng', + legalStatus: 'SO_HONG', amenities: ['Gym', 'Pool'], projectName: 'Vinhomes', latitude: null, diff --git a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx index 76d3f63..ae14d2d 100644 --- a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx @@ -137,7 +137,7 @@ function makeListing(overrides: Partial = {}): ListingDetail { totalFloors: null, direction: 'SOUTH', yearBuilt: 2020, - legalStatus: 'Sổ hồng', + legalStatus: 'SO_HONG', amenities: ['Hồ bơi', 'Gym'], nearbyPOIs: null, metroDistanceM: null, diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index e6fef2c..28f658a 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -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; diff --git a/prisma/migrations/20260424000000_add_property_document/migration.sql b/prisma/migrations/20260424000000_add_property_document/migration.sql new file mode 100644 index 0000000..8192a16 --- /dev/null +++ b/prisma/migrations/20260424000000_add_property_document/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64a1c0b..9f26319 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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