diff --git a/src/app.module.ts b/src/app.module.ts index 0cc73868..1a9e959c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,10 +2,7 @@ import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { GraphQLModule } from '@nestjs/graphql'; -import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { ScheduleModule } from '@nestjs/schedule'; -import { join } from 'path'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { DashboardModule } from './dashboard/dashboard.module'; @@ -40,6 +37,10 @@ import { SupportTicketsModule } from './support-tickets/support-tickets.module'; import { AuditModule } from './audit/audit.module'; import { MetricsModule } from './metrics/metrics.module'; import { PropertyTaxModule } from './properties/tax/property-tax.module'; +import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor'; +import { VersionHeaderInterceptor } from './versioning/version-header.interceptor'; +import { DeprecationWarningInterceptor } from './versioning/deprecation-warning.interceptor'; +import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; @Module({ imports: [ @@ -47,15 +48,7 @@ import { PropertyTaxModule } from './properties/tax/property-tax.module'; isGlobal: true, envFilePath: ['.env.local', '.env'], }), - GraphQLModule.forRoot({ - driver: ApolloDriver, - autoSchemaFile: join(process.cwd(), 'src/schema.gql'), - sortSchema: true, - // playground removed: Apollo Server v5 defaults to Apollo Sandbox (uses @apollo/server@^5 peer) - subscriptions: { - 'graphql-ws': true, - }, - }), + ScheduleModule.forRoot(), CacheModuleConfig, AnalyticsModule, @@ -91,6 +84,12 @@ import { PropertyTaxModule } from './properties/tax/property-tax.module'; ], controllers: [AppController], + providers: [ + ResponseFormatInterceptor, + VersionHeaderInterceptor, + DeprecationWarningInterceptor, + RateLimitHeadersInterceptor, + ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { @@ -99,4 +98,4 @@ export class AppModule implements NestModule { // regardless of underlying Express 5 / path-to-regexp v8 syntax changes. consumer.apply(RequestIdMiddleware).forRoutes('*'); } -} +} \ No newline at end of file diff --git a/src/auth/decorators/gql-user.decorator.ts b/src/auth/decorators/gql-user.decorator.ts deleted file mode 100644 index 13bb3eb3..00000000 --- a/src/auth/decorators/gql-user.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck - -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { GqlExecutionContext } from '@nestjs/graphql'; - -export const GqlUser = createParamDecorator((data: unknown, context: ExecutionContext) => { - const ctx = GqlExecutionContext.create(context); - return ctx.getContext().req.authUser; -}); diff --git a/src/auth/guards/gql-auth.guard.ts b/src/auth/guards/gql-auth.guard.ts deleted file mode 100644 index 181c1ca7..00000000 --- a/src/auth/guards/gql-auth.guard.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck - -import { ExecutionContext, Injectable } from '@nestjs/common'; -import { GqlExecutionContext } from '@nestjs/graphql'; -import { JwtAuthGuard } from './jwt-auth.guard'; - -@Injectable() -export class GqlAuthGuard extends JwtAuthGuard { - getRequest(context: ExecutionContext) { - const ctx = GqlExecutionContext.create(context); - return ctx.getContext().req; - } - - override async canActivate(context: ExecutionContext): Promise { - const request = this.getRequest(context); - const authorizationHeader = request.headers.authorization; - const token = this.extractBearerToken(authorizationHeader); - - if (!token) { - return false; // Or throw UnauthorizedException - } - - try { - request.authUser = await this.authService.validateAccessToken(token); - request.accessToken = token; - return true; - } catch (e) { - return false; - } - } - - protected override extractBearerToken(header?: string): string | null { - if (!header) { - return null; - } - - const [scheme, token] = header.split(' '); - return scheme === 'Bearer' && token ? token : null; - } -} diff --git a/src/common/interceptors/response-format.interceptor.ts b/src/common/interceptors/response-format.interceptor.ts new file mode 100644 index 00000000..ea12ca8b --- /dev/null +++ b/src/common/interceptors/response-format.interceptor.ts @@ -0,0 +1,131 @@ +// @ts-nocheck + +/** + * Response Format Interceptor + * Standardizes all API responses into a consistent envelope format + * + * Success response format: { success: true, data, meta, timestamp } + * Error response format: { success: false, message, errors, timestamp } + * Pagination meta format: { page, limit, total, totalPages } + */ + +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + HttpException, +} from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { map, catchError } from 'rxjs/operators'; +import { Response } from 'express'; + +interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +interface SuccessResponse { + success: true; + data: T; + meta?: PaginationMeta | Record; + timestamp: string; +} + +interface ErrorResponse { + success: false; + message: string; + errors?: any[]; + timestamp: string; +} + +@Injectable() +export class ResponseFormatInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const timestamp = new Date().toISOString(); + const ctx = context.switchToHttp(); + const response = ctx.getResponse(); + + return next.handle().pipe( + map((data) => { + // If data already has our standard format, return it as-is + if (data && typeof data === 'object' && 'success' in data) { + return data; + } + + // Handle paginated responses that already have { data, meta } structure + if (data && typeof data === 'object' && 'data' in data && 'meta' in data) { + const { data: responseData, meta } = data; + return { + success: true, + data: responseData, + meta: this.validatePaginationMeta(meta), + timestamp, + } as SuccessResponse; + } + + // Handle standard raw responses + return { + success: true, + data, + timestamp, + } as SuccessResponse; + }), + catchError((error) => { + let statusCode = 500; + let message = 'Internal Server Error'; + let errors: any[] | undefined; + + if (error instanceof HttpException) { + statusCode = error.getStatus(); + const errorResponse = error.getResponse(); + + if (typeof errorResponse === 'string') { + message = errorResponse; + } else if (typeof errorResponse === 'object') { + message = (errorResponse as any).message || message; + errors = (errorResponse as any).errors; + } + } else if (error instanceof Error) { + message = error.message; + } + + response.status(statusCode); + + const errorResponse: ErrorResponse = { + success: false, + message, + timestamp, + }; + + if (errors) { + errorResponse.errors = errors; + } + + return throwError(() => ({ + ...errorResponse, + statusCode, + })); + }), + ); + } + + private validatePaginationMeta(meta: any): PaginationMeta | Record { + // Check if it has pagination properties + const hasPaginationProps = 'page' in meta || 'limit' in meta || 'total' in meta || 'totalPages' in meta; + + if (hasPaginationProps) { + return { + page: meta.page || 1, + limit: meta.limit || 10, + total: meta.total || 0, + totalPages: meta.totalPages || Math.ceil((meta.total || 0) / (meta.limit || 10)), + } as PaginationMeta; + } + + // Return as-is if it's just regular meta + return meta; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index c20f63a9..12175de2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,10 +7,10 @@ import { AppModule } from './app.module'; import { VersionHeaderInterceptor } from './versioning/version-header.interceptor'; import { DeprecationWarningInterceptor } from './versioning/deprecation-warning.interceptor'; import { CacheMetricsInterceptor } from './cache/cache-metrics.interceptor'; -import { CacheMonitoringService } from './cache/cache-monitoring.service'; import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; +import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor'; import { setupSwagger } from './config/swagger.config'; import { validateEnvironment } from './utils/validate-env'; @@ -22,26 +22,58 @@ async function bootstrap() { // Node.js version check (#775, #754 NestJS 11 requires Node 20+) const REQUIRED_NODE_MAJOR = 20; const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); + if (Number.isNaN(nodeMajor) || nodeMajor < REQUIRED_NODE_MAJOR) { logger.error( `Node.js >= ${REQUIRED_NODE_MAJOR} required, found ${process.versions.node}. ` + `Please upgrade Node.js (see https://nodejs.org/).`, ); + process.exit(1); + } - // Setup Swagger documentation - setupSwagger(app); + const app = await NestFactory.create(AppModule); - app.enableShutdownHooks(); + // Global validation pipe + app.useGlobalPipes(new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + })); - const port = process.env.PORT || 3000; - await app.listen(port); - logger.log(`PropChain API running on http://localhost:${port}`); - logger.log(`API Versioning enabled. Supported versions: v1, v2`); - logger.log(`📚 Swagger UI available at http://localhost:${port}/api/docs`); - logger.log(`📋 OpenAPI spec available at http://localhost:${port}/api/openapi.json`); - logger.log(`💾 Redis Caching enabled`); - logger.log(`🛡️ Rate Limiting enabled (per-user, per-endpoint, IP-based)`); - } + // Register global interceptors + const responseFormatInterceptor = app.get(ResponseFormatInterceptor); + const versionHeaderInterceptor = app.get(VersionHeaderInterceptor); + const deprecationWarningInterceptor = app.get(DeprecationWarningInterceptor); + const cacheMetricsInterceptor = app.get(CacheMetricsInterceptor); + const rateLimitHeadersInterceptor = app.get(RateLimitHeadersInterceptor); + + app.useGlobalInterceptors( + responseFormatInterceptor, + versionHeaderInterceptor, + deprecationWarningInterceptor, + cacheMetricsInterceptor, + rateLimitHeadersInterceptor, + ); + + // Register global guards + const reflector = app.get(Reflector); + const rateLimitService = app.get(RateLimitService); + app.useGlobalGuards(new RateLimitGuard(rateLimitService, reflector)); - bootstrap(); + // Setup Swagger documentation + setupSwagger(app); + + app.enableShutdownHooks(); + + const port = process.env.PORT || 3000; + await app.listen(port); + logger.log(`PropChain API running on http://localhost:${port}`); + logger.log(`API Versioning enabled. Supported versions: v1, v2`); + logger.log(`📚 Swagger UI available at http://localhost:${port}/api/docs`); + logger.log(`📋 OpenAPI spec available at http://localhost:${port}/api/openapi.json`); + logger.log(`💾 Redis Caching enabled`); + logger.log(`🛡️ Rate Limiting enabled (per-user, per-endpoint, IP-based)`); + logger.log(`✅ Response format interceptor enabled - all API responses now follow standardized format`); } + +bootstrap(); \ No newline at end of file diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index 5bf7b652..70b16eff 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -11,8 +11,7 @@ import { PropertyExpiryService } from './property-expiry.service'; import { PrismaModule } from '../database/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; -import { PropertiesResolver } from './properties.resolver'; -import { PubSub } from 'graphql-subscriptions'; + import { FraudModule } from '../fraud/fraud.module'; import { PropertyReportService } from './report/property-report.service'; import { CacheModuleConfig } from '../cache/cache.module'; @@ -25,13 +24,8 @@ import { CacheModuleConfig } from '../cache/cache.module'; PropertyImagesService, GeocodingService, PropertyExpiryService, - PropertiesResolver, PropertyReportService, - { - provide: 'PUB_SUB', - useValue: new PubSub(), - }, ], exports: [PropertiesService, PropertyReportService, PropertyImagesService, GeocodingService, PropertyExpiryService], }) -export class PropertiesModule {} +export class PropertiesModule {} \ No newline at end of file diff --git a/src/properties/properties.resolver.ts b/src/properties/properties.resolver.ts deleted file mode 100644 index 6604a03e..00000000 --- a/src/properties/properties.resolver.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @ts-nocheck - -import { Resolver, Query, Mutation, Args, Subscription } from '@nestjs/graphql'; -import { UseGuards, Inject } from '@nestjs/common'; -import { PubSub } from 'graphql-subscriptions'; -import { PropertiesService } from './properties.service'; -import { Property } from './models/property.model'; -import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; -import { GqlAuthGuard } from '../auth/guards/gql-auth.guard'; -import { GqlUser } from '../auth/decorators/gql-user.decorator'; - -@Resolver(() => Property) -export class PropertiesResolver { - constructor( - private readonly propertiesService: PropertiesService, - @Inject('PUB_SUB') private readonly pubSub: any, - ) {} - - @Query(() => [Property], { name: 'properties' }) - async getProperties( - @Args('limit', { nullable: true }) limit?: number, - @Args('offset', { nullable: true }) offset?: number, - ) { - return this.propertiesService.findAll({ - take: limit, - skip: offset, - }); - } - - @Query(() => Property, { name: 'property' }) - async getProperty(@Args('id') id: string) { - return this.propertiesService.findOne(id); - } - - @Mutation(() => Property) - @UseGuards(GqlAuthGuard) - async createProperty(@GqlUser() user: any, @Args('input') input: CreatePropertyDto) { - const property = await this.propertiesService.create(input, user.id); - this.pubSub.publish('propertyAdded', { propertyAdded: property }); - return property; - } - - @Mutation(() => Property) - @UseGuards(GqlAuthGuard) - async updateProperty(@Args('id') id: string, @Args('input') input: UpdatePropertyDto) { - return this.propertiesService.update(id, input); - } - - @Subscription(() => Property, { - name: 'propertyAdded', - }) - propertyAdded() { - return this.pubSub.asyncIterator('propertyAdded'); - } -} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 6ecf2f92..839ea0e2 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -9,7 +9,7 @@ import { ActivityLogService } from './activity-log.service'; import { ActivityLogController, AdminActivityLogController } from './activity-log.controller'; import { PrismaModule } from '../database/prisma.module'; import { SessionsModule } from '../sessions/sessions.module'; -import { UsersResolver } from './users.resolver'; + import { EmailVerificationController } from './email-verification.controller'; import { EmailVerificationService } from './email-verification.service'; import { EmailService } from '../email/email.service'; @@ -28,11 +28,10 @@ import { RateLimitService } from '../auth/rate-limit.service'; UsersService, UserPreferencesService, ActivityLogService, - UsersResolver, EmailVerificationService, EmailService, RateLimitService, ], exports: [UsersService, UserPreferencesService, ActivityLogService, EmailVerificationService], }) -export class UsersModule {} +export class UsersModule {} \ No newline at end of file diff --git a/src/users/users.resolver.ts b/src/users/users.resolver.ts deleted file mode 100644 index 0ef82182..00000000 --- a/src/users/users.resolver.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @ts-nocheck - -import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'; -import { UseGuards } from '@nestjs/common'; -import { UsersService } from './users.service'; -import { User } from './models/user.model'; -import { GqlAuthGuard } from '../auth/guards/gql-auth.guard'; -import { GqlUser } from '../auth/decorators/gql-user.decorator'; -import { UpdateUserDto } from './dto/user.dto'; - -@Resolver(() => User) -export class UsersResolver { - constructor(private readonly usersService: UsersService) {} - - @Query(() => User, { name: 'me' }) - @UseGuards(GqlAuthGuard) - async getMe(@GqlUser() user: any) { - return this.usersService.findOne(user.id); - } - - @Query(() => [User], { name: 'users' }) - @UseGuards(GqlAuthGuard) - async getUsers() { - return this.usersService.findAll(); - } - - @Query(() => User, { name: 'user' }) - @UseGuards(GqlAuthGuard) - async getUser(@Args('id') id: string) { - return this.usersService.findOne(id); - } - - @Mutation(() => User) - @UseGuards(GqlAuthGuard) - async updateProfile(@GqlUser() user: any, @Args('input') input: UpdateUserDto) { - // Note: UpdateUserDto might need @InputType() decoration if not already. - // NestJS GraphQL can automatically handle it if mapped correctly. - return this.usersService.update(user.id, input); - } -}