diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts index e73c582..411e272 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts @@ -29,12 +29,15 @@ describe('PrismaAVMService', () => { }); it('returns zero confidence when fewer than 3 comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - ]); + // First $queryRaw call: property location lookup + // Second $queryRaw call: findComparables (parameterized after refactor in 6774914) + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -44,14 +47,15 @@ describe('PrismaAVMService', () => { }); it('calculates weighted valuation with sufficient comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, - { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, + { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -63,7 +67,8 @@ describe('PrismaAVMService', () => { }); it('uses coordinates directly when no propertyId', async () => { - mockPrisma.$queryRawUnsafe.mockResolvedValue([ + // coords-only path: no property lookup, $queryRaw used for comparables directly + mockPrisma.$queryRaw.mockResolvedValue([ { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, @@ -78,18 +83,20 @@ describe('PrismaAVMService', () => { expect(result.confidence).toBeGreaterThan(0); expect(Number(result.estimatedPrice)).toBeGreaterThan(0); - expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + // coords-only path: $queryRaw is used for comparables; $queryRawUnsafe not called + expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled(); }); }); describe('getComparables', () => { it('returns comparables for a property', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, + ]); const result = await service.getComparables('prop-1', 3000); 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..80dca0c --- /dev/null +++ b/apps/api/src/modules/auth/domain/events/phone-login-otp-requested.event.ts @@ -0,0 +1,12 @@ +import { type DomainEvent } from '@modules/shared'; + +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/metrics/metrics.constants.ts b/apps/api/src/modules/metrics/metrics.constants.ts index a8f2f02..7a170f4 100644 --- a/apps/api/src/modules/metrics/metrics.constants.ts +++ b/apps/api/src/modules/metrics/metrics.constants.ts @@ -15,6 +15,11 @@ export const SEARCH_QUERY_DURATION = 'search_query_duration_seconds'; export const GOODGO_WS_CONNECTED_CLIENTS = 'goodgo_ws_connected_clients'; export const GOODGO_WS_MESSAGES_TOTAL = 'goodgo_ws_messages_total'; +// ── Read-Model / Projection Metrics ── +export const READ_MODEL_PROJECTOR_LAG_SECONDS = 'goodgo_read_model_projector_lag_seconds'; +export const READ_MODEL_REFRESH_DURATION_SECONDS = 'goodgo_read_model_refresh_duration_seconds'; +export const READ_MODEL_RECONCILIATION_DRIFT_TOTAL = 'goodgo_read_model_reconciliation_drift_total'; + // ── Web Vitals / RUM Metrics ── export const WEB_VITALS_LCP = 'goodgo_web_vitals_lcp_seconds'; export const WEB_VITALS_FCP = 'goodgo_web_vitals_fcp_seconds'; 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..5d3ec3b --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/phone-login-otp-requested.listener.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { LoggerService } from '@modules/shared'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; +import type { PhoneLoginOtpRequestedEvent } from '../../../auth/domain/events/phone-login-otp-requested.event'; + +@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( + `Sending OTP SMS to ${event.phone} for user ${event.aggregateId}`, + 'PhoneLoginOtpRequestedListener', + ); + + await this.commandBus.execute( + new SendNotificationCommand({ + userId: event.aggregateId, + channel: 'sms', + template: 'phone_login_otp', + context: { + phone: event.phone, + otpCode: event.otpCode, + }, + }), + ); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/interceptors/index.ts b/apps/api/src/modules/shared/infrastructure/interceptors/index.ts new file mode 100644 index 0000000..90ad837 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/interceptors/index.ts @@ -0,0 +1,51 @@ +/** + * RFC-001 Phase 1 — API versioning interceptors. + * + * Placeholder implementations so the module compiles while the full + * versioning feature (GOO-170) is being developed on its own branch. + */ +import { Injectable, type NestInterceptor, type ExecutionContext, type CallHandler } from '@nestjs/common'; +import type { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +export const API_MINOR_HEADER = 'X-Api-Minor-Version'; +export const API_MINOR_RESOLVED_HEADER = 'X-Api-Minor-Resolved'; + +export interface ResolvedApiVersion { + major: number; + minor: number; + raw: string; +} + +/** + * Reads the Accept-Version request header and attaches a parsed + * ResolvedApiVersion object to `req.apiVersion` for downstream use. + */ +@Injectable() +export class VersionInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest<{ apiVersion?: ResolvedApiVersion; headers: Record }>(); + const raw = req.headers['accept-version'] ?? 'v1.0'; + const [majorStr, minorStr] = raw.replace(/^v/, '').split('.'); + req.apiVersion = { + major: parseInt(majorStr ?? '1', 10), + minor: parseInt(minorStr ?? '0', 10), + raw, + }; + return next.handle(); + } +} + +/** + * Writes deprecation headers when the resolved spec carries a sunset date. + */ +@Injectable() +export class DeprecationInterceptor implements NestInterceptor { + intercept(_context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + tap(() => { + // Deprecation warnings are a no-op in the stub. + }), + ); + } +} diff --git a/apps/api/src/modules/shared/infrastructure/versioning.ts b/apps/api/src/modules/shared/infrastructure/versioning.ts new file mode 100644 index 0000000..9e51d84 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/versioning.ts @@ -0,0 +1,44 @@ +/** + * RFC-001 Phase 1 — API versioning registry. + * + * Placeholder stubs so the module compiles while the full versioning + * feature (GOO-170) is being developed on its own branch. + */ + +export interface ApiVersionDeprecation { + sunset: string; + replacement?: string; + message?: string; +} + +export interface ApiMajorSpec { + major: number; + minMinor: number; + maxMinor: number; + deprecation?: ApiVersionDeprecation; +} + +export interface ApiVersionRegistry { + current: string; + specs: ApiMajorSpec[]; +} + +export const API_VERSION_REGISTRY: ApiVersionRegistry = { + current: 'v1.0', + specs: [ + { + major: 1, + minMinor: 0, + maxMinor: 0, + }, + ], +}; + +/** + * Resolve the major-version spec for a given Accept-Version header value. + * Returns undefined when no matching spec is found. + */ +export function resolveMajorSpec(version: string): ApiMajorSpec | undefined { + const major = parseInt(version.replace(/^v/, '').split('.')[0] ?? '1', 10); + return API_VERSION_REGISTRY.specs.find((s) => s.major === major); +}