diff --git a/src/admin/admin-audit.interceptor.ts b/src/admin/admin-audit.interceptor.ts index ddc1517b..cf014dc3 100644 --- a/src/admin/admin-audit.interceptor.ts +++ b/src/admin/admin-audit.interceptor.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable, tap } from 'rxjs'; import { PrismaService } from '../database/prisma.service'; diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index fdefde02..d30127d9 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -18,7 +18,7 @@ import { HttpStatus, } from '@nestjs/common'; import { Response } from 'express'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -48,6 +48,7 @@ import { ArchiveService } from '../archive/archive.service'; import { CleanupService } from '../database/cleanup.service'; @ApiTags('Admin') +@ApiBearerAuth('access-token') @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 6ee8b54e..1ef62d1b 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -109,9 +109,7 @@ export class AdminService { async listUsers(query: AdminUsersQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const skip = (query as any).cursor - ? undefined - : (page - 1) * limit; + const skip = (query as any).cursor ? undefined : (page - 1) * limit; const where: any = { role: query.role, @@ -148,9 +146,10 @@ export class AdminService { this.prisma.user.count({ where }), ]); - const nextCursor = items.length === limit - ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') - : null; + const nextCursor = + items.length === limit + ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') + : null; return { total, page, limit, items, nextCursor, previousCursor: (query as any).cursor || null }; } @@ -241,9 +240,7 @@ export class AdminService { async getModerationQueue(query: ModerationQueueQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const skip = (query as any).cursor - ? undefined - : (page - 1) * limit; + const skip = (query as any).cursor ? undefined : (page - 1) * limit; const where: any = { status: query.status ?? PropertyStatus.PENDING, @@ -273,9 +270,10 @@ export class AdminService { this.prisma.property.count({ where }), ]); - const nextCursor = items.length === limit - ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') - : null; + const nextCursor = + items.length === limit + ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') + : null; return { total, page, limit, items, nextCursor, previousCursor: (query as any).cursor || null }; } @@ -350,9 +348,7 @@ export class AdminService { async monitorTransactions(query: TransactionMonitoringQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; - const skip = (query as any).cursor - ? undefined - : (page - 1) * limit; + const skip = (query as any).cursor ? undefined : (page - 1) * limit; const where: any = { status: query.status, @@ -385,9 +381,10 @@ export class AdminService { this.prisma.transaction.count({ where }), ]); - const nextCursor = items.length === limit - ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') - : null; + const nextCursor = + items.length === limit + ? Buffer.from((items[items.length - 1] as any).createdAt.toISOString()).toString('base64') + : null; return { total, page, limit, items, nextCursor, previousCursor: (query as any).cursor || null }; } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 5018e115..e6febf3e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { ApiKeyAnalyticsService } from './api-key-analytics.service'; import { @@ -28,6 +29,7 @@ import { UserRole } from '../types/prisma.types'; import { Request } from 'express'; import { VerifyEmailDto } from '../users/dto/email-change.dto'; +@ApiTags('Authentication') @Controller('auth') export class AuthController { constructor( @@ -67,6 +69,7 @@ export class AuthController { return this.authService.refreshToken(refreshTokenDto, ipAddress, userAgent); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('logout') logout( @@ -77,18 +80,21 @@ export class AuthController { return this.authService.logout(user, logoutDto.refreshToken, request.accessToken); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('logout-all') logoutAllDevices(@CurrentUser() user: AuthUserPayload, @Req() request: { accessToken?: string }) { return this.authService.logoutAllDevices(user, request.accessToken); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get('me') me(@CurrentUser() user: AuthUserPayload) { return this.authService.me(user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('change-password') changePassword( @@ -98,12 +104,14 @@ export class AuthController { return this.authService.changePassword(user, changePasswordDto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('2fa/setup') setupTwoFactor(@CurrentUser() user: AuthUserPayload) { return this.authService.setupTwoFactor(user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('2fa/verify') verifyTwoFactor( @@ -113,6 +121,7 @@ export class AuthController { return this.authService.verifyTwoFactor(user, verifyTwoFactorDto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('2fa/disable') disableTwoFactor( @@ -133,30 +142,35 @@ export class AuthController { }; } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('api-keys') createApiKey(@CurrentUser() user: AuthUserPayload, @Body() createApiKeyDto: CreateApiKeyDto) { return this.authService.createApiKey(user, createApiKeyDto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get('api-keys') listApiKeys(@CurrentUser() user: AuthUserPayload) { return this.authService.listApiKeys(user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('api-keys/:id/rotate') rotateApiKey(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { return this.authService.rotateApiKey(user, id); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post('api-keys/:id/revoke') revokeApiKey(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { return this.authService.revokeApiKey(user, id); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Patch('api-keys/:id/permissions') updateApiKeyPermissions( @@ -167,6 +181,7 @@ export class AuthController { return this.authService.updateApiKeyPermissions(user, id, updateApiKeyPermissionsDto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get('api-keys/:id/usage') getApiKeyUsage(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { @@ -183,6 +198,7 @@ export class AuthController { return this.authService.resetPassword(resetPasswordDto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('unlock-account') diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ca0391d3..a572a5b4 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -546,7 +546,9 @@ export class AuthService { }); } catch (error: unknown) { // Token might already be expired or invalid, continue with logout - this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${(error as Error).message}`); + this.logger.warn( + `Failed to blacklist access token for user ${user.sub}: ${(error as Error).message}`, + ); } } @@ -610,7 +612,9 @@ export class AuthService { userId: user.sub, }); } catch (error: unknown) { - this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${(error as Error).message}`); + this.logger.warn( + `Failed to blacklist access token for user ${user.sub}: ${(error as Error).message}`, + ); } } @@ -1621,12 +1625,16 @@ export class AuthService { const bypass = this.configService.get('CAPTCHA_BYPASS') === 'true'; if (bypass) { - this.logger.warn('CAPTCHA bypass is enabled via CAPTCHA_BYPASS=true. This should only be used in development.'); + this.logger.warn( + 'CAPTCHA bypass is enabled via CAPTCHA_BYPASS=true. This should only be used in development.', + ); return true; } if (!secret) { - throw new Error('RECAPTCHA_SECRET is not configured. Set CAPTCHA_BYPASS=true for development environments.'); + throw new Error( + 'RECAPTCHA_SECRET is not configured. Set CAPTCHA_BYPASS=true for development environments.', + ); } try { diff --git a/src/blockchain/blockchain.service.ts b/src/blockchain/blockchain.service.ts index d3161cd1..ab341a72 100644 --- a/src/blockchain/blockchain.service.ts +++ b/src/blockchain/blockchain.service.ts @@ -530,10 +530,7 @@ export class BlockchainService { throw error; } const err = error as Error; - this.logger.error( - `Failed to record transaction on blockchain: ${err.message}`, - err.stack, - ); + this.logger.error(`Failed to record transaction on blockchain: ${err.message}`, err.stack); throw new InternalServerErrorException('Failed to record transaction on blockchain'); } } diff --git a/src/blockchain/service/blockchain-recording.processor.ts b/src/blockchain/service/blockchain-recording.processor.ts index 7d57dd28..084a6c39 100644 --- a/src/blockchain/service/blockchain-recording.processor.ts +++ b/src/blockchain/service/blockchain-recording.processor.ts @@ -21,7 +21,9 @@ export class BlockchainRecordingProcessor { // Use recordTransactionOnBlockchain with minimal required data // The full recording should be initiated by the controller with complete data // For queue processing, we just log and mark as processed - this.logger.log(`Blockchain recording job ${job.id} processed for transaction ${job.data.transactionId}`); + this.logger.log( + `Blockchain recording job ${job.id} processed for transaction ${job.data.transactionId}`, + ); return { processed: true, transactionId: job.data.transactionId }; } } diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts index 8797b6ee..f11adc7c 100644 --- a/src/dashboard/dashboard.controller.ts +++ b/src/dashboard/dashboard.controller.ts @@ -1,12 +1,15 @@ // @ts-nocheck import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; import { DashboardDto } from './dto/dashboard.dto'; +@ApiTags('Dashboard') +@ApiBearerAuth('access-token') @Controller('dashboard') @UseGuards(JwtAuthGuard) export class DashboardController { diff --git a/src/documents/documents.controller.ts b/src/documents/documents.controller.ts index 6a26c797..4db78167 100644 --- a/src/documents/documents.controller.ts +++ b/src/documents/documents.controller.ts @@ -13,7 +13,7 @@ import { UseGuards, HttpStatus, } from '@nestjs/common'; -import { ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { ApiOperation, ApiBody, ApiResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { DocumentsService } from './documents.service'; import { @@ -30,6 +30,8 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; import { UserRole } from '../types/prisma.types'; +@ApiTags('Documents') +@ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Controller('documents') export class DocumentsController { diff --git a/src/favorites/favorites.controller.ts b/src/favorites/favorites.controller.ts index e8e8712b..5bb0b1f1 100644 --- a/src/favorites/favorites.controller.ts +++ b/src/favorites/favorites.controller.ts @@ -8,12 +8,14 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { FavoritesService } from './favorites.service'; import { ListFavoritesQueryDto } from './dto/favorite.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; +@ApiTags('Favorites') @Controller('favorites') export class FavoritesController { constructor(private readonly favoritesService: FavoritesService) {} @@ -21,6 +23,7 @@ export class FavoritesController { /** * Add a property to the current user's favorites. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post(':propertyId') add( @@ -33,6 +36,7 @@ export class FavoritesController { /** * Remove a property from the current user's favorites. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Delete(':propertyId') remove( @@ -45,6 +49,7 @@ export class FavoritesController { /** * List the current user's favorites (paginated). */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get() list(@CurrentUser() user: AuthUserPayload, @Query() query: ListFavoritesQueryDto) { @@ -57,6 +62,7 @@ export class FavoritesController { /** * Total count of favorites for the current user. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get('count') async myCount(@CurrentUser() user: AuthUserPayload) { @@ -67,6 +73,7 @@ export class FavoritesController { /** * Whether a property is currently favorited by the user. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get(':propertyId/status') async status( diff --git a/src/main.ts b/src/main.ts index d07fd9fe..fb145010 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe, BadRequestException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; @@ -13,6 +11,7 @@ import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-head import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor'; import { setupSwagger } from './config/swagger.config'; import { validateEnvironment } from './utils/validate-env'; +import { TraceInterceptor } from './tracing/trace.interceptor'; // Issue #964 – exception filters are registered globally via APP_FILTER // providers in AppModule. We deliberately do NOT call useGlobalFilters here // to avoid registering the same filter twice. @@ -36,6 +35,40 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); + // CORS configuration + const corsOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim()) + : ['http://localhost:3000']; + + const isProduction = process.env.NODE_ENV === 'production'; + + if (isProduction && corsOrigins.includes('*')) { + logger.warn('Wildcard CORS origins are not allowed in production. Using default origins.'); + corsOrigins.length = 0; + corsOrigins.push('http://localhost:3000'); + } + + app.enableCors({ + origin: corsOrigins, + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + credentials: true, + allowedHeaders: ['Content-Type', 'Authorization', 'API-Version', 'api-key'], + }); + + // Security headers middleware + app.use((req: any, res: any, next: any) => { + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", + ); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + next(); + }); + // Issue #964 – Localize validation error messages via the I18nService. const { I18nService } = await import('./i18n/i18n.service'); const i18n = app.get(I18nService); @@ -71,6 +104,7 @@ async function bootstrap() { deprecationWarningInterceptor, cacheMetricsInterceptor, rateLimitHeadersInterceptor, + new TraceInterceptor(), ); // Issue #964 – Exception filters are registered globally via APP_FILTER @@ -81,7 +115,7 @@ async function bootstrap() { // Register global guards const reflector = app.get(Reflector); const rateLimitService = app.get(RateLimitService); - app.useGlobalGuards(new RateLimitGuard(rateLimitService, reflector)); + app.useGlobalGuards(new RateLimitGuard(reflector, rateLimitService)); // Setup Swagger documentation setupSwagger(app); diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 5b4aeddd..dab17b37 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -36,8 +36,7 @@ import { import { CreateAmenityDto, UpdateAmenityDto } from './dto/amenity.dto'; import { PropertyReportService } from './report/property-report.service'; import { Response } from 'express'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; @ApiTags('Properties') @Controller('properties') @@ -47,6 +46,7 @@ export class PropertiesController { private readonly propertyReportService: PropertyReportService, ) {} + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post() create(@Body() createPropertyDto: CreatePropertyDto, @CurrentUser() user: AuthUserPayload) { @@ -77,6 +77,7 @@ export class PropertiesController { return this.propertiesService.findOne(id); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT, UserRole.ADMIN) @Put(':id') @@ -88,6 +89,7 @@ export class PropertiesController { return this.propertiesService.update(id, updatePropertyDto, user.sub); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Delete(':id') @@ -95,6 +97,7 @@ export class PropertiesController { return this.propertiesService.remove(id, user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/restore') @@ -102,6 +105,7 @@ export class PropertiesController { return this.propertiesService.restore(id, user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get(':id/report') async generateReport(@Param('id') id: string, @Res() res: Response): Promise { @@ -140,6 +144,7 @@ export class PropertiesController { * * Allowed for the property's owner, AGENT, or ADMIN. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Patch(':id/status') transitionStatus( @@ -155,6 +160,7 @@ export class PropertiesController { ); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/approve') @@ -162,6 +168,7 @@ export class PropertiesController { return this.propertiesService.approveProperty(id, user.sub); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/reject') @@ -169,6 +176,7 @@ export class PropertiesController { return this.propertiesService.rejectProperty(id, user.sub); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/status') @@ -180,6 +188,7 @@ export class PropertiesController { return this.propertiesService.bulkUpdatePropertyStatus(body.propertyIds, body.status); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/delete') @@ -191,6 +200,7 @@ export class PropertiesController { return this.propertiesService.bulkDeleteProperties(body.propertyIds); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/export') @@ -202,6 +212,7 @@ export class PropertiesController { return this.propertiesService.bulkExportProperties(body.propertyIds, body.filter); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post(':id/agents') async assignAgent( @@ -212,6 +223,7 @@ export class PropertiesController { return this.propertiesService.assignAgent(propertyId, dto, user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Put(':id/agents/:agentId') async updateAgentAssignment( @@ -223,6 +235,7 @@ export class PropertiesController { return this.propertiesService.updateAgentAssignment(propertyId, agentId, dto, user); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Delete(':id/agents/:agentId') async removeAgentAssignment( @@ -240,6 +253,7 @@ export class PropertiesController { // ---- Amenity endpoints (#551) ---- + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post(':id/amenities') async addAmenity(@Param('id') propertyId: string, @Body() dto: CreateAmenityDto) { @@ -251,6 +265,7 @@ export class PropertiesController { return this.propertiesService.getAmenities(propertyId); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Patch(':id/amenities/:amenityId') async updateAmenity( @@ -261,6 +276,7 @@ export class PropertiesController { return this.propertiesService.updateAmenity(propertyId, amenityId, dto); } + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Delete(':id/amenities/:amenityId') async removeAmenity(@Param('id') propertyId: string, @Param('amenityId') amenityId: string) { diff --git a/src/property-views/property-views.controller.ts b/src/property-views/property-views.controller.ts index 670171bf..443f9c31 100644 --- a/src/property-views/property-views.controller.ts +++ b/src/property-views/property-views.controller.ts @@ -13,6 +13,7 @@ import { UseGuards, } from '@nestjs/common'; import { Request } from 'express'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { PropertyViewsService } from './property-views.service'; import { PopularPropertiesQueryDto, @@ -24,10 +25,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -interface RequestWithAuth extends Request { - authUser?: AuthUserPayload; -} - +@ApiTags('Properties') @Controller('property-views') export class PropertyViewsController { constructor(private readonly propertyViewsService: PropertyViewsService) {} @@ -81,6 +79,7 @@ export class PropertyViewsController { /** * Paginated view history for a property. Requires auth. */ + @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get(':propertyId/history') history( diff --git a/src/property-views/property-views.service.ts b/src/property-views/property-views.service.ts index 20a562ce..c3427660 100644 --- a/src/property-views/property-views.service.ts +++ b/src/property-views/property-views.service.ts @@ -1,6 +1,4 @@ -// @ts-nocheck - -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; export interface RecordViewInput { @@ -11,6 +9,12 @@ export interface RecordViewInput { sessionId?: string | null; } +const BOT_USER_AGENT_PATTERNS = + /bot|crawler|spider|scraper|curl|wget|python-requests|headless|phantom|selenium|puppeteer/i; + +const DEDUP_IP_WINDOW_MINUTES = 30; +const DEDUP_USER_WINDOW_HOURS = 24; + export interface ViewHistoryParams { skip?: number; take?: number; @@ -24,10 +28,57 @@ export interface PopularQueryParams { @Injectable() export class PropertyViewsService { + private readonly logger = new Logger(PropertyViewsService.name); + constructor(private readonly prisma: PrismaService) {} + /** + * Detect common bot user-agent patterns. + */ + private isBotUserAgent(userAgent?: string | null): boolean { + if (!userAgent) return false; + return BOT_USER_AGENT_PATTERNS.test(userAgent); + } + + /** + * Check if a duplicate view was recorded recently from the same IP + * within the deduplication window. + */ + private async isDuplicateByIp(propertyId: string, ipAddress: string | null): Promise { + if (!ipAddress) return false; + const cutoff = new Date(Date.now() - DEDUP_IP_WINDOW_MINUTES * 60 * 1000); + const recent = await this.prisma.propertyView.findFirst({ + where: { + propertyId, + ipAddress, + viewedAt: { gte: cutoff }, + }, + select: { id: true }, + }); + return !!recent; + } + + /** + * Check if a duplicate view was recorded recently by the same user + * within the deduplication window. + */ + private async isDuplicateByUser(propertyId: string, userId: string | null): Promise { + if (!userId) return false; + const cutoff = new Date(Date.now() - DEDUP_USER_WINDOW_HOURS * 60 * 60 * 1000); + const recent = await this.prisma.propertyView.findFirst({ + where: { + propertyId, + userId, + viewedAt: { gte: cutoff }, + }, + select: { id: true }, + }); + return !!recent; + } + /** * Record a view event and atomically increment the property's view counter. + * Applies deduplication: bot requests, repeat IP (30 min), repeat user (24 hr). */ async recordView(propertyId: string, input: RecordViewInput) { const property = await this.prisma.property.findUnique({ @@ -39,6 +90,24 @@ export class PropertyViewsService { throw new NotFoundException(`Property ${propertyId} not found`); } + // Dedup: filter bot traffic + if (this.isBotUserAgent(input.userAgent)) { + this.logger.debug(`View suppressed: bot user-agent detected for property ${propertyId}`); + return { view: null, deduplicated: true, reason: 'bot' }; + } + + // Dedup: same IP within 30 minutes + if (await this.isDuplicateByIp(propertyId, input.ipAddress ?? null)) { + this.logger.debug(`View suppressed: duplicate IP for property ${propertyId}`); + return { view: null, deduplicated: true, reason: 'duplicate_ip' }; + } + + // Dedup: same user within 24 hours + if (await this.isDuplicateByUser(propertyId, input.userId ?? null)) { + this.logger.debug(`View suppressed: duplicate user for property ${propertyId}`); + return { view: null, deduplicated: true, reason: 'duplicate_user' }; + } + const [view, updated] = await this.prisma.$transaction([ this.prisma.propertyView.create({ data: { @@ -57,7 +126,7 @@ export class PropertyViewsService { }), ]); - return { view, viewCount: updated.viewCount }; + return { view, viewCount: updated.viewCount, deduplicated: false }; } /** diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index d6945454..e24ced7b 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -4,7 +4,7 @@ import { Controller, Get, Post, Query, Body, UseGuards, Request } from '@nestjs/ import { SearchService, SearchQuery } from './search.service'; import { SearchAutocompleteService } from './search-autocomplete.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth } from '@nestjs/swagger'; interface AuthenticatedRequest extends Request { user: { @@ -14,7 +14,8 @@ interface AuthenticatedRequest extends Request { }; } -@ApiTags('search') +@ApiTags('Search') +@ApiBearerAuth('access-token') @Controller('search') @UseGuards(JwtAuthGuard) export class SearchController { diff --git a/src/sessions/sessions.controller.ts b/src/sessions/sessions.controller.ts index 6a199d66..5a5d1368 100644 --- a/src/sessions/sessions.controller.ts +++ b/src/sessions/sessions.controller.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { Controller, Delete, Get, Param, Patch, Body, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { SessionsService } from './sessions.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -12,6 +13,8 @@ import { UpdateSessionDto, } from './dto/session.dto'; +@ApiTags('Sessions') +@ApiBearerAuth('access-token') @Controller('sessions') @UseGuards(JwtAuthGuard) export class SessionsController { diff --git a/src/tracing/trace.interceptor.ts b/src/tracing/trace.interceptor.ts new file mode 100644 index 00000000..901d6a80 --- /dev/null +++ b/src/tracing/trace.interceptor.ts @@ -0,0 +1,17 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; + +@Injectable() +export class TraceInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const traceId = request.headers['x-trace-id'] || `trace-${Date.now()}`; + request.traceId = traceId; + return next.handle().pipe( + tap(() => { + const response = context.switchToHttp().getResponse(); + response.setHeader('X-Trace-Id', traceId); + }), + ); + } +} diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 83f026d3..a090414d 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -112,9 +112,12 @@ export class TransactionsService { this.prisma.transaction.count({ where }), ]); - const nextCursor = transactions.length === limit - ? Buffer.from((transactions[transactions.length - 1] as any).createdAt.toISOString()).toString('base64') - : null; + const nextCursor = + transactions.length === limit + ? Buffer.from( + (transactions[transactions.length - 1] as any).createdAt.toISOString(), + ).toString('base64') + : null; return { total, @@ -125,7 +128,10 @@ export class TransactionsService { previousCursor: cursor || null, }; } catch (error) { - this.logger.error(`Failed to list transactions: ${(error as Error).message}`, (error as Error).stack); + this.logger.error( + `Failed to list transactions: ${(error as Error).message}`, + (error as Error).stack, + ); throw error; } } diff --git a/src/users/role-escalation.service.spec.ts b/src/users/role-escalation.service.spec.ts index 1980c0ed..755a010e 100644 --- a/src/users/role-escalation.service.spec.ts +++ b/src/users/role-escalation.service.spec.ts @@ -1,8 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../database/prisma.service'; import { RoleEscalationService } from './role-escalation.service'; describe('RoleEscalationService', () => { + let service: RoleEscalationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoleEscalationService, + { + provide: PrismaService, + useValue: { + activityLog: { + create: jest.fn(), + }, + }, + }, + ], + }).compile(); + + service = module.get(RoleEscalationService); + }); + it('should be defined', () => { - const service = new RoleEscalationService(); expect(service).toBeDefined(); }); }); diff --git a/src/users/role-escalation.service.ts b/src/users/role-escalation.service.ts index d123932b..43144509 100644 --- a/src/users/role-escalation.service.ts +++ b/src/users/role-escalation.service.ts @@ -1,11 +1,31 @@ -// @ts-nocheck - -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; import { RequestRoleEscalationDto } from './dto/request-role-escalation.dto'; @Injectable() export class RoleEscalationService { - async requestEscalation(_userId: string, _dto: RequestRoleEscalationDto): Promise { - // TODO: implement role escalation workflow + private readonly logger = new Logger(RoleEscalationService.name); + + constructor(private readonly prisma: PrismaService) {} + + async requestEscalation(userId: string, dto: RequestRoleEscalationDto): Promise { + // Audit log the escalation request (#886) + await this.prisma.activityLog + .create({ + data: { + userId, + action: 'ROLE_ESCALATION_REQUESTED', + entityType: 'USER', + entityId: userId, + description: `User requested role escalation to ${dto.requestedRole}`, + metadata: { + requestedRole: dto.requestedRole, + justification: dto.justification, + }, + }, + }) + .catch((err) => { + this.logger.error(`Failed to audit-log role escalation request: ${err}`); + }); } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 7093eaf0..ac8beabd 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -27,6 +27,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; import { UserRole } from '../types/prisma.types'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { ActivityLogService } from './activity-log.service'; import { @@ -47,6 +48,8 @@ const UNAUTHORIZED_ACTION_MESSAGE = 'You are not authorized to perform this acti const REACTIVATE_LIMIT = 5; const REACTIVATE_WINDOW_MS = 60 * 60 * 1000; +@ApiTags('Users') +@ApiBearerAuth('access-token') @Controller('users') export class UsersController { private readonly downloadRateLimitMap = new Map();