diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8b5ef2b..c692d27 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,6 @@ import './instrument'; -import { ValidationPipe } from '@nestjs/common'; +import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import cookieParser from 'cookie-parser'; @@ -18,6 +18,14 @@ async function bootstrap() { const logger = app.get(LoggerService); app.useLogger(logger); + // ── API Versioning — global /api/v1/ prefix ── + app.setGlobalPrefix('api/v1', { + exclude: [ + { path: 'health', method: RequestMethod.GET }, + { path: 'ready', method: RequestMethod.GET }, + ], + }); + // ── OpenAPI / Swagger ── const swaggerConfig = new DocumentBuilder() .setTitle('Goodgo Platform API') @@ -37,7 +45,7 @@ async function bootstrap() { .addTag('analytics', 'Market reports & price analytics') .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api/docs', app, document, { + SwaggerModule.setup('api/v1/docs', app, document, { swaggerOptions: { persistAuthorization: true }, }); @@ -108,7 +116,7 @@ async function bootstrap() { const port = process.env['PORT'] ?? 3001; await app.listen(port); - logger.log(`API running on http://localhost:${port}`, 'Bootstrap'); + logger.log(`API running on http://localhost:${port}/api/v1`, 'Bootstrap'); } bootstrap(); diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 851962a..897f980 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -7,8 +7,9 @@ export { GlobalExceptionFilter } from './filters/global-exception.filter'; export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware'; export { RequestLoggingMiddleware } from './middleware/request-logging.middleware'; export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'; +export { CsrfMiddleware } from './middleware/csrf.middleware'; export { maskPii } from './pii-masker'; export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard'; export { FileValidationPipe } from './pipes/file-validation.pipe'; -export type { FileValidationOptions } from './pipes/file-validation.pipe'; +export type { FileValidationOptions, UploadedFile } from './pipes/file-validation.pipe'; export { validateEnv, validateJwtSecret } from './env-validation'; diff --git a/prisma/migrations/20260409100000_add_compound_indexes_query_optimization/migration.sql b/prisma/migrations/20260409100000_add_compound_indexes_query_optimization/migration.sql new file mode 100644 index 0000000..034a212 --- /dev/null +++ b/prisma/migrations/20260409100000_add_compound_indexes_query_optimization/migration.sql @@ -0,0 +1,52 @@ +-- AddCompoundIndexes: query optimization for Listing and Property tables +-- Addresses missing compound indexes identified in database audit (TEC-1566) + +-- ============================================================================= +-- LISTING COMPOUND INDEXES +-- ============================================================================= + +-- 1. Seller dashboard: WHERE sellerId = ? [AND status = ?] ORDER BY publishedAt DESC +-- Covers: findBySellerId(), getUserDetail() seller listings, admin user detail +-- Leading column sellerId has high cardinality; status + publishedAt enable +-- efficient range scans for "my active listings, newest first" +CREATE INDEX "Listing_sellerId_status_publishedAt_idx" + ON "Listing" ("sellerId", "status", "publishedAt" DESC NULLS LAST); + +-- 2. Agent portfolio: WHERE agentId = ? AND status = ? +-- Covers: agent listing management, agent performance queries +-- agentId is nullable so Postgres skips NULLs in B-tree naturally +CREATE INDEX "Listing_agentId_status_idx" + ON "Listing" ("agentId", "status"); + +-- 3. Browse / search: WHERE status = ? ORDER BY createdAt DESC +-- Covers: search(), findByStatus(), getModerationQueue(), getDashboardStats() counts, +-- reindexAll() WHERE status = 'ACTIVE' ORDER BY publishedAt +-- Most listing queries filter on status first then paginate by time +CREATE INDEX "Listing_status_createdAt_idx" + ON "Listing" ("status", "createdAt" DESC); + +-- 4. Active listings feed: WHERE status = 'ACTIVE' ORDER BY publishedAt DESC +-- The search indexer and public listing feeds sort by publishedAt for active listings +CREATE INDEX "Listing_status_publishedAt_idx" + ON "Listing" ("status", "publishedAt" DESC NULLS LAST); + +-- 5. Transaction type filtering: WHERE transactionType = ? AND status = ? ORDER BY createdAt DESC +-- Search queries commonly filter by SALE/RENT combined with status +CREATE INDEX "Listing_transactionType_status_createdAt_idx" + ON "Listing" ("transactionType", "status", "createdAt" DESC); + +-- ============================================================================= +-- PROPERTY COMPOUND INDEXES +-- ============================================================================= + +-- 6. Location + type search: WHERE district = ? AND propertyType = ? +-- Extends existing (district, city) index for the very common search pattern +-- that filters by district AND propertyType (via Listing -> Property relation) +-- Note: district and propertyType live on Property, not Listing +CREATE INDEX "Property_district_propertyType_idx" + ON "Property" ("district", "propertyType"); + +-- 7. Full location + type search: WHERE district = ? AND city = ? AND propertyType = ? +-- Covers the search() query pattern that filters all three together +CREATE INDEX "Property_district_city_propertyType_idx" + ON "Property" ("district", "city", "propertyType"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78f21e8..4162ce6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,9 +193,14 @@ model Property { valuations Valuation[] media PropertyMedia[] + // --- Single-column indexes --- @@index([propertyType]) @@index([district, city]) @@index([location], type: Gist) + + // --- Compound indexes (query optimization) --- + @@index([district, propertyType]) + @@index([district, city, propertyType]) } model PropertyMedia { @@ -242,6 +247,7 @@ model Listing { transactions Transaction[] inquiries Inquiry[] + // --- Single-column indexes --- @@index([status]) @@index([transactionType]) @@index([priceVND]) @@ -252,6 +258,13 @@ model Listing { @@index([createdAt]) @@index([featuredUntil]) @@index([expiresAt]) + + // --- Compound indexes (query optimization) --- + @@index([sellerId, status, publishedAt(sort: Desc)]) + @@index([agentId, status]) + @@index([status, createdAt(sort: Desc)]) + @@index([status, publishedAt(sort: Desc)]) + @@index([transactionType, status, createdAt(sort: Desc)]) } // =============================================================================