diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 558c7f80..dfbdbdcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,6 @@ jobs: test: name: Run Tests needs: lint-and-build - if: false # Tests disabled runs-on: ubuntu-latest services: @@ -97,7 +96,7 @@ jobs: deploy-staging: name: Deploy to Staging - needs: lint-and-build + needs: [lint-and-build, test] if: github.ref == 'refs/heads/develop' && github.event_name == 'push' runs-on: ubuntu-latest environment: staging @@ -112,7 +111,7 @@ jobs: deploy-production: name: Deploy to Production - needs: lint-and-build + needs: [lint-and-build, test] if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest environment: production diff --git a/package-lock.json b/package-lock.json index 2d748752..2b25c8f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9454,8 +9454,6 @@ }, "node_modules/prisma": { "version": "6.19.3", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", - "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", diff --git a/props_errors.txt b/props_errors.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/src/analytics/analytics.interceptor.ts b/src/analytics/analytics.interceptor.ts new file mode 100644 index 00000000..1a3fe39a --- /dev/null +++ b/src/analytics/analytics.interceptor.ts @@ -0,0 +1,26 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AnalyticsService } from './analytics.service'; + +@Injectable() +export class AnalyticsInterceptor implements NestInterceptor { + constructor(private readonly analytics: AnalyticsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + const start = Date.now(); + + return next.handle().pipe( + tap(() => { + this.analytics.record({ + endpoint: req.path, + method: req.method, + statusCode: res.statusCode, + responseTime: Date.now() - start, + }); + }), + ); + } +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 00000000..b2d0a3d1 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,1352 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { User as PrismaUser, ApiKey, TokenType } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { PrismaService } from '../database/prisma.service'; +import { UsersService } from '../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; +import { EmailService } from '../email/email.service'; +import { + ChangePasswordDto, + CreateApiKeyDto, + LoginDto, + RefreshTokenDto, + RegisterDto, + RequestPasswordResetDto, + ResetPasswordDto, + UpdateApiKeyPermissionsDto, + VerifyTwoFactorDto, +} from './dto/auth.dto'; +import { + buildOtpAuthUrl, + buildQrCodeUrl, + comparePassword, + createSha256, + generateBackupCodes, + getPasswordHistoryLimit, + hashPassword, + parseDuration, + randomBase32Secret, + randomToken, + sanitizeUser, + verifyBackupCode, + verifyTotpCode, +} from './security.utils'; +import { AuthUserPayload } from './types/auth-user.type'; + +import { LoginRateLimitService } from './login-rate-limit.service'; +import { UserRole } from '../types/prisma.types'; +import { FraudService } from '../fraud/fraud.service'; + +type JwtPayload = { + sub: string; + email: string; + role: UserRole; + type: 'access' | 'refresh'; + jti: string; + family?: string; // Token rotation family ID + exp?: number; +}; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly issuer = 'PropChain'; + private readonly accessTokenTtlSeconds: number; + private readonly refreshTokenTtlSeconds: number; + private readonly jwtSecret: string; + private readonly jwtRefreshSecret: string; + private readonly bcryptRounds: number; + + constructor( + private readonly prisma: PrismaService, + private readonly usersService: UsersService, + private readonly sessionsService: SessionsService, + private readonly configService: ConfigService, + private readonly emailService: EmailService, + private readonly rateLimitService: LoginRateLimitService, + private readonly fraudService: FraudService, + ) { + this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; + this.jwtRefreshSecret = + this.configService.get('JWT_REFRESH_SECRET') ?? 'propchain-refresh-secret'; + this.accessTokenTtlSeconds = parseDuration( + this.configService.get('JWT_ACCESS_EXPIRES_IN') ?? '15m', + 15 * 60, + ); + this.refreshTokenTtlSeconds = parseDuration( + this.configService.get('JWT_REFRESH_EXPIRES_IN') ?? '7d', + 7 * 24 * 60 * 60, + ); + this.bcryptRounds = parseInt(this.configService.get('BCRYPT_ROUNDS') ?? '12', 10); + } + + /** + * Helper to map transactions to activity items for dashboard + */ + private transactionsToActivityItems(transactions: any[], type: 'purchase' | 'sale') { + return transactions.map((tx) => ({ + type: 'transaction' as const, + id: tx.id, + title: `Property ${type === 'purchase' ? 'Purchased' : 'Sold'}: ${tx.property?.title || 'Unknown'}`, + description: `${type === 'purchase' ? 'Bought' : 'Sold'} for $${tx.amount}`, + timestamp: tx.createdAt, + })); + } + + async register(data: RegisterDto) { + const existingUser = await this.usersService.findByEmail(data.email); + if (existingUser) { + throw new BadRequestException('A user with that email already exists'); + } + + const passwordHash = await hashPassword(data.password, this.bcryptRounds); + const user = await this.prisma.user.create({ + data: { + email: data.email, + password: passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone, + passwordHistory: { + create: { + passwordHash, + }, + }, + }, + }); + + const tokens = await this.issueTokenPair(user); + return { + user: sanitizeUser(user), + ...tokens, + }; + } + + async login(data: LoginDto, ipAddress?: string, userAgent?: string) { + // Check if account is locked out + const isLocked = await this.rateLimitService.isAccountLocked(data.email); + if (isLocked) { + const lockoutInfo = await this.rateLimitService.getLockoutInfo(data.email); + const remainingMinutes = lockoutInfo?.remainingLockoutMinutes ?? 0; + throw new UnauthorizedException( + `Account temporarily locked due to too many failed login attempts. Please try again in ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}.`, + ); + } + + const failedAttempts = await this.rateLimitService.getFailedAttemptsCount(data.email); + const captchaThreshold = parseInt( + this.configService.get('CAPTCHA_THRESHOLD') ?? '3', + 10, + ); + + if (failedAttempts >= captchaThreshold) { + if (!data.captchaToken) { + throw new UnauthorizedException('CAPTCHA verification required'); + } + const isCaptchaValid = await this.verifyCaptcha(data.captchaToken); + if (!isCaptchaValid) { + // We might also record a failed attempt here if we wanted to + throw new UnauthorizedException('Invalid CAPTCHA'); + } + } + + const user = await this.usersService.findByEmail(data.email); + if (!user) { + // Record failed attempt even if user doesn't exist (prevent enumeration) + await this.rateLimitService.recordFailedAttempt(data.email, ipAddress, userAgent); + throw new UnauthorizedException('Invalid credentials'); + } + + if (user.isBlocked) { + throw new UnauthorizedException('Your account has been blocked. Please contact support.'); + } + + if (user.isDeactivated) { + throw new UnauthorizedException( + 'Your account has been deactivated. Please contact support to reactivate your account.', + ); + } + + const passwordMatches = await comparePassword(data.password, user.password); + if (!passwordMatches) { + // Record failed login attempt + const shouldLock = await this.rateLimitService.recordFailedAttempt( + data.email, + ipAddress, + userAgent, + ); + + await this.fraudService.evaluateFailedLogin(data.email, ipAddress, userAgent); + + if (shouldLock) { + const lockoutDuration = 30; + await this.emailService.sendAccountLockedEmail(user.email, lockoutDuration).catch((err) => { + this.logger.error(`Failed to send account locked email to ${user.email}: ${err.message}`); + }); + + throw new UnauthorizedException( + `Account locked due to too many failed login attempts. Please try again in ${lockoutDuration} minutes.`, + ); + } + + throw new UnauthorizedException('Invalid credentials'); + } + + if (user.twoFactorEnabled) { + const hasTotpCode = Boolean(data.totpCode?.trim()); + const hasBackupCode = Boolean(data.backupCode?.trim()); + + if (!hasTotpCode && !hasBackupCode) { + throw new UnauthorizedException('Two-factor authentication code required'); + } + + if (hasTotpCode && user.twoFactorSecret) { + const validCode = verifyTotpCode({ + secret: user.twoFactorSecret, + code: data.totpCode!, + }); + + if (!validCode) { + throw new UnauthorizedException('Invalid two-factor authentication code'); + } + } else if (hasBackupCode) { + const matchingBackupCode = verifyBackupCode(data.backupCode!, user.twoFactorBackupCodes); + if (!matchingBackupCode) { + throw new UnauthorizedException('Invalid backup code'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + twoFactorBackupCodes: { + set: user.twoFactorBackupCodes.filter((code: string) => code !== matchingBackupCode), + }, + }, + }); + } + } + + // Record successful login + await this.rateLimitService.recordSuccessfulAttempt(data.email, ipAddress, userAgent); + await this.recordLoginHistory(user.id, ipAddress, userAgent); + await this.fraudService.evaluateSuccessfulLogin(user.id, ipAddress, userAgent); + + const refreshedUser = await this.prisma.user.findUnique({ + where: { id: user.id }, + }); + + if (!refreshedUser) { + throw new UnauthorizedException('User no longer exists'); + } + + if (refreshedUser.isBlocked) { + throw new UnauthorizedException( + 'Your account has been blocked after a fraud review. Please contact support.', + ); + } + + const tokens = await this.issueTokenPair(refreshedUser, undefined, ipAddress, userAgent); + return { + user: sanitizeUser(refreshedUser), + ...tokens, + }; + } + + async refreshToken(data: RefreshTokenDto, ipAddress?: string, userAgent?: string) { + const payload = this.verifyToken(data.refreshToken, this.jwtRefreshSecret) as JwtPayload; + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + + // Check if token is blacklisted (already used) + const blacklistedToken = await this.prisma.blacklistedToken.findUnique({ + where: { jti: payload.jti }, + }); + + if (blacklistedToken) { + // TOKEN REUSE DETECTED! This is a potential attack + // Mark the reuse and invalidate the entire token family + await this.handleTokenReuse(blacklistedToken, payload.jti, ipAddress, userAgent); + await this.fraudService.handleTokenReuse(payload.sub, payload.jti, ipAddress, userAgent); + + this.logger.error( + `Refresh token reuse detected for user ${payload.sub} (JTI: ${payload.jti}, Family: ${payload.family}). IP: ${ipAddress}`, + ); + + throw new UnauthorizedException( + 'Token reuse detected. All sessions have been invalidated for security. Please login again.', + ); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + if (!user) { + throw new UnauthorizedException('User no longer exists'); + } + + if (user.isBlocked) { + throw new UnauthorizedException('Your account has been blocked'); + } + + if (user.isDeactivated) { + throw new UnauthorizedException('Your account has been deactivated'); + } + + // Blacklist the current refresh token (rotation) + await this.blacklistToken({ + jti: payload.jti, + tokenType: 'REFRESH', + expiresAt: new Date((payload.exp ?? 0) * 1000), + userId: user.id, + tokenFamily: payload.family, + ipAddress, + userAgent, + }); + + // Issue new token pair with SAME family ID + const tokens = await this.issueTokenPair(user, payload.family, ipAddress, userAgent); + + this.logger.log( + `Token rotated for user ${user.id} (${user.email}). Family: ${payload.family}. IP: ${ipAddress}`, + ); + + return { + user: sanitizeUser(user), + ...tokens, + }; + } + + /** + * Handle token reuse detection - invalidate entire token family + */ + private async handleTokenReuse( + blacklistedToken: any, + reusedJti: string, + ipAddress?: string, + userAgent?: string, + ) { + const now = new Date(); + + // Mark the reused token + await this.prisma.blacklistedToken.update({ + where: { jti: reusedJti }, + data: { + reusedAt: now, + ipAddress: ipAddress || blacklistedToken.ipAddress, + userAgent: userAgent || blacklistedToken.userAgent, + }, + }); + + // Invalidate entire token family if it exists + if (blacklistedToken.tokenFamily) { + const familyTokens = await this.prisma.blacklistedToken.findMany({ + where: { + tokenFamily: blacklistedToken.tokenFamily, + expiresAt: { gt: now }, // Only active tokens + }, + select: { jti: true }, + }); + + this.logger.warn( + `Invalidating ${familyTokens.length} tokens in family ${blacklistedToken.tokenFamily} due to reuse detection`, + ); + + // All tokens in this family are already blacklisted, but we log the event + // The key is that we're preventing the attacker from using any token from this family + } + } + + async logout(user: AuthUserPayload, refreshToken?: string, accessToken?: string) { + const logoutTime = new Date(); + + // Blacklist the access token if provided + if (accessToken) { + try { + const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; + await this.blacklistToken({ + jti: accessPayload.jti, + tokenType: 'ACCESS', + expiresAt: new Date((accessPayload.exp ?? 0) * 1000), + userId: user.sub, + tokenFamily: accessPayload.family, + }); + } catch (error) { + // Token might already be expired or invalid, continue with logout + this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${error.message}`); + } + } + + // Blacklist the specific refresh token if provided + if (refreshToken) { + try { + const refreshPayload = this.verifyToken(refreshToken, this.jwtRefreshSecret) as JwtPayload; + if (refreshPayload.sub !== user.sub) { + throw new UnauthorizedException('Refresh token does not belong to the current user'); + } + + await this.blacklistToken({ + jti: refreshPayload.jti, + tokenType: 'REFRESH', + expiresAt: new Date((refreshPayload.exp ?? 0) * 1000), + userId: user.sub, + tokenFamily: refreshPayload.family, + }); + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + // Token might already be expired or invalid, continue with logout + this.logger.warn( + `Failed to blacklist refresh token for user ${user.sub}: ${error.message}`, + ); + } + } + + // Log the logout event + this.logger.log( + `User ${user.sub} (${user.email}) logged out successfully at ${logoutTime.toISOString()}`, + ); + + return { + message: 'Logged out successfully', + logoutTime: logoutTime.toISOString(), + tokensInvalidated: { + accessToken: !!accessToken, + refreshToken: !!refreshToken, + }, + clientAction: { + clearStorage: true, + clearCookies: true, + redirectUrl: '/login', + }, + }; + } + + async logoutAllDevices(user: AuthUserPayload, accessToken?: string) { + const logoutTime = new Date(); + + // Blacklist the current access token if provided + if (accessToken) { + try { + const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; + await this.blacklistToken({ + jti: accessPayload.jti, + tokenType: 'ACCESS', + expiresAt: new Date((accessPayload.exp ?? 0) * 1000), + userId: user.sub, + }); + } catch (error) { + this.logger.warn(`Failed to blacklist access token for user ${user.sub}: ${error.message}`); + } + } + + // Find all blacklisted refresh tokens for this user that are still active + const blacklistedRefreshTokens = await this.prisma.blacklistedToken.findMany({ + where: { + userId: user.sub, + tokenType: 'REFRESH', + expiresAt: { + gt: logoutTime, // Only count tokens that haven't expired yet + }, + }, + }); + + this.logger.log( + `User ${user.sub} (${user.email}) logged out from all devices at ${logoutTime.toISOString()}. Total active blacklisted refresh tokens: ${blacklistedRefreshTokens.length}`, + ); + + return { + message: 'Logged out from all devices successfully', + logoutTime: logoutTime.toISOString(), + blacklistedTokensCount: blacklistedRefreshTokens.length, + clientAction: { + clearStorage: true, + clearCookies: true, + redirectUrl: '/login', + }, + }; + } + + async me(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + return sanitizeUser(foundUser); + } + + // Only one implementation should exist; duplicate removed. + + async getDashboard(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const [properties, buyerTransactions, sellerTransactions, documents, apiKeys] = + await Promise.all([ + this.prisma.property.findMany({ + where: { ownerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.transaction.findMany({ + where: { buyerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, + }, + seller: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.transaction.findMany({ + where: { sellerId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + property: { + select: { + id: true, + title: true, + address: true, + city: true, + state: true, + price: true, + }, + }, + buyer: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }), + this.prisma.document.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + this.prisma.apiKey.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + take: 3, + }), + ]); + + const [ + totalProperties, + activeListings, + pendingSales, + totalPurchases, + totalSales, + completedPurchases, + completedSales, + ] = await Promise.all([ + this.prisma.property.count({ where: { ownerId: user.sub } }), + this.prisma.property.count({ where: { ownerId: user.sub, status: 'ACTIVE' } }), + this.prisma.transaction.count({ where: { sellerId: user.sub, status: 'PENDING' } }), + this.prisma.transaction.count({ where: { buyerId: user.sub } }), + this.prisma.transaction.count({ where: { sellerId: user.sub } }), + this.prisma.transaction.count({ where: { buyerId: user.sub, status: 'COMPLETED' } }), + this.prisma.transaction.count({ where: { sellerId: user.sub, status: 'COMPLETED' } }), + ]); + + const recommendationProperties = await this.prisma.property.findMany({ + where: { + status: 'ACTIVE', + ownerId: { not: user.sub }, + NOT: { + ownerId: user.sub, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + owner: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }); + + const recentActivity = [ + ...this.transactionsToActivityItems(buyerTransactions, 'purchase'), + ...this.transactionsToActivityItems(sellerTransactions, 'sale'), + ...documents.map((doc: any) => ({ + type: 'document' as const, + id: doc.id, + title: doc.fileName, + description: `Uploaded ${doc.documentType.toLowerCase().replace('_', ' ')}`, + timestamp: doc.createdAt, + })), + ] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 10); + + return { + profile: sanitizeUser(foundUser), + quickStats: { + totalProperties, + activeListings, + pendingSales, + totalPurchases, + totalSales, + completedPurchases, + completedSales, + apiKeysCount: apiKeys.length, + }, + recentActivity, + recommendations: recommendationProperties.map((p: any) => ({ + id: p.id, + title: p.title, + address: p.address, + city: p.city, + state: p.state, + price: p.price.toString(), + propertyType: p.propertyType, + bedrooms: p.bedrooms, + bathrooms: p.bathrooms?.toString(), + squareFeet: p.squareFeet?.toString(), + status: p.status, + agent: `${p.owner.firstName} ${p.owner.lastName}`, + createdAt: p.createdAt, + })), + }; + } + + async changePassword(user: AuthUserPayload, data: ChangePasswordDto) { + const passwordHistoryLimit = getPasswordHistoryLimit(); + const existingUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + include: { + passwordHistory: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!existingUser) { + throw new NotFoundException('User not found'); + } + + const currentPasswordMatches = await comparePassword( + data.currentPassword, + existingUser.password, + ); + if (!currentPasswordMatches) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const passwordReused = await Promise.all( + existingUser.passwordHistory + .slice(0, passwordHistoryLimit) + .map((entry: { passwordHash: string }) => + comparePassword(data.newPassword, entry.passwordHash), + ), + ); + + if (passwordReused.some(Boolean)) { + throw new BadRequestException( + `Password reuse is not allowed for the last ${passwordHistoryLimit} passwords`, + ); + } + + const newPasswordHash = await hashPassword(data.newPassword, this.bcryptRounds); + + await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await tx.user.update({ + where: { id: existingUser.id }, + data: { + password: newPasswordHash, + }, + }); + + await tx.passwordHistory.create({ + data: { + userId: existingUser.id, + passwordHash: newPasswordHash, + }, + }); + + const historyEntries = await tx.passwordHistory.findMany({ + where: { userId: existingUser.id }, + orderBy: { createdAt: 'desc' }, + skip: passwordHistoryLimit, + }); + + if (historyEntries.length > 0) { + await tx.passwordHistory.deleteMany({ + where: { + id: { + in: historyEntries.map((entry: { id: string }) => entry.id), + }, + }, + }); + } + }); + + return { message: 'Password updated successfully' }; + } + + async setupTwoFactor(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const secret = randomBase32Secret(); + const backupCodes = generateBackupCodes(); + const hashedBackupCodes = backupCodes.map((code) => createSha256(code)); + const otpAuthUrl = buildOtpAuthUrl(foundUser.email, secret, this.issuer); + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorSecret: secret, + twoFactorEnabled: false, + twoFactorBackupCodes: { + set: hashedBackupCodes, + }, + }, + }); + + return { + secret, + otpAuthUrl, + qrCodeUrl: buildQrCodeUrl(otpAuthUrl), + backupCodes, + }; + } + + async verifyTwoFactor(user: AuthUserPayload, data: VerifyTwoFactorDto) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser?.twoFactorSecret) { + throw new BadRequestException('Two-factor authentication has not been initialized'); + } + + const validCode = verifyTotpCode({ + secret: foundUser.twoFactorSecret, + code: data.code, + }); + if (!validCode) { + throw new UnauthorizedException('Invalid two-factor authentication code'); + } + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorEnabled: true, + }, + }); + + return { message: 'Two-factor authentication enabled successfully' }; + } + + async disableTwoFactor(user: AuthUserPayload, password: string) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const passwordMatches = await comparePassword(password, foundUser.password); + if (!passwordMatches) { + throw new UnauthorizedException('Password is incorrect'); + } + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: { + set: [], + }, + }, + }); + + return { message: 'Two-factor authentication disabled successfully' }; + } + + async createApiKey(user: AuthUserPayload, data: CreateApiKeyDto) { + const apiKeyValue = this.generateApiKeyValue(); + const permissions = this.normalizePermissions(data.permissions); + const record = await this.prisma.apiKey.create({ + data: { + userId: user.sub, + name: data.name, + keyPrefix: apiKeyValue.slice(0, 12), + keyHash: createSha256(apiKeyValue), + permissions, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + }, + }); + + return { + apiKey: apiKeyValue, + details: this.toApiKeyResponse(record), + }; + } + + async listApiKeys(user: AuthUserPayload) { + const apiKeys = await this.prisma.apiKey.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + }); + + return apiKeys.map((apiKey: any) => this.toApiKeyResponse(apiKey)); + } + + async rotateApiKey(user: AuthUserPayload, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + revokedAt: new Date(), + }, + }); + + return this.createApiKey(user, { + name: apiKey.name, + permissions: apiKey.permissions, + expiresAt: apiKey.expiresAt?.toISOString(), + }); + } + + async revokeApiKey(user: AuthUserPayload, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + revokedAt: new Date(), + }, + }); + + return { message: 'API key revoked successfully' }; + } + + async updateApiKeyPermissions( + user: AuthUserPayload, + apiKeyId: string, + data: UpdateApiKeyPermissionsDto, + ) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + const updated = await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + permissions: this.normalizePermissions(data.permissions), + }, + }); + + return this.toApiKeyResponse(updated); + } + + async getApiKeyUsage(user: AuthUserPayload, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + select: { + id: true, + name: true, + keyPrefix: true, + usageCount: true, + lastUsedAt: true, + revokedAt: true, + expiresAt: true, + createdAt: true, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + return apiKey; + } + + async validateAccessToken(token: string): Promise { + const payload = this.verifyToken(token, this.jwtSecret) as JwtPayload; + + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid access token'); + } + + await this.ensureTokenNotBlacklisted(payload.jti); + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { + email: true, + role: true, + lastActivityAt: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User no longer exists'); + } + + const now = new Date(); + if (!user.lastActivityAt || now.getTime() - user.lastActivityAt.getTime() > 5 * 60 * 1000) { + this.prisma.user + .update({ + where: { id: payload.sub }, + data: { lastActivityAt: now }, + }) + .catch((err) => this.logger.error(`Failed to update lastActivityAt: ${err.message}`)); + } + + return { + sub: payload.sub, + email: user.email, + role: user.role, + type: 'access', + jti: payload.jti, + }; + } + + async validateApiKey(apiKeyValue: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { + keyHash: createSha256(apiKeyValue), + }, + include: { + user: true, + }, + }); + + if (!apiKey || apiKey.revokedAt || (apiKey.expiresAt && apiKey.expiresAt < new Date())) { + throw new UnauthorizedException('Invalid API key'); + } + + if (apiKey.user.isBlocked) { + throw new UnauthorizedException('User account is blocked'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + lastUsedAt: new Date(), + usageCount: { + increment: 1, + }, + }, + }); + + return { + sub: apiKey.userId, + email: apiKey.user.email, + role: apiKey.user.role as UserRole, + type: 'api-key', + apiKeyId: apiKey.id, + apiKeyPermissions: apiKey.permissions, + }; + } + + private async issueTokenPair( + user: PrismaUser, + tokenFamily?: string, + ipAddress?: string, + userAgent?: string, + ) { + const accessJti = randomUUID(); + const refreshJti = randomUUID(); + const family = tokenFamily || randomUUID(); // Create new family if not provided + + const accessToken = this.signToken( + { + sub: user.id, + email: user.email, + role: user.role as UserRole, + type: 'access', + jti: accessJti, + family: family, + }, + this.jwtSecret, + this.accessTokenTtlSeconds, + ); + + const refreshToken = this.signToken( + { + sub: user.id, + email: user.email, + role: user.role as UserRole, + type: 'refresh', + jti: refreshJti, + family: family, + }, + this.jwtRefreshSecret, + this.refreshTokenTtlSeconds, + ); + + // Create a session for tracking + await this.sessionsService.createSession( + user.id, + accessJti, + refreshJti, + ipAddress, + userAgent, + this.refreshTokenTtlSeconds, + ); + + return { + accessToken, + refreshToken, + accessTokenExpiresIn: this.accessTokenTtlSeconds, + refreshTokenExpiresIn: this.refreshTokenTtlSeconds, + }; + } + + private signToken(payload: JwtPayload, secret: string, expiresInSeconds: number) { + return jwt.sign(payload, secret, { + expiresIn: expiresInSeconds, + issuer: this.issuer, + }); + } + + private verifyToken(token: string, secret: string) { + try { + return jwt.verify(token, secret, { + issuer: this.issuer, + }) as JwtPayload & { exp?: number }; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private async ensureTokenNotBlacklisted(jti: string) { + const blacklistedToken = await this.prisma.blacklistedToken.findUnique({ + where: { jti }, + }); + + if (blacklistedToken) { + throw new UnauthorizedException('Token has been revoked'); + } + } + + private async blacklistToken(data: { + jti: string; + tokenType: 'ACCESS' | 'REFRESH'; + expiresAt: Date; + userId?: string; + tokenFamily?: string; + previousJti?: string; + ipAddress?: string; + userAgent?: string; + }) { + await this.prisma.blacklistedToken.upsert({ + where: { jti: data.jti }, + update: { + expiresAt: data.expiresAt, + tokenType: data.tokenType, + userId: data.userId, + tokenFamily: data.tokenFamily, + previousJti: data.previousJti, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + }, + create: { + jti: data.jti, + tokenType: data.tokenType, + expiresAt: data.expiresAt, + userId: data.userId, + tokenFamily: data.tokenFamily, + previousJti: data.previousJti, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + }, + }); + } + + private generateApiKeyValue() { + return `pc_${randomToken(24)}`; + } + + private toApiKeyResponse(apiKey: any) { + return { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + permissions: apiKey.permissions, + usageCount: apiKey.usageCount, + lastUsedAt: apiKey.lastUsedAt, + expiresAt: apiKey.expiresAt, + revokedAt: apiKey.revokedAt, + createdAt: apiKey.createdAt, + updatedAt: apiKey.updatedAt, + }; + } + + private normalizePermissions(permissions?: string[]) { + if (!permissions || permissions.length === 0) { + return []; + } + + return Array.from(new Set(permissions.map((permission) => permission.trim()).filter(Boolean))); + } + + async requestPasswordReset(data: RequestPasswordResetDto): Promise { + const user = await this.usersService.findByEmail(data.email); + if (!user) { + // Don't reveal if email exists or not for security + return; + } + + if (user.isBlocked) { + // Don't send reset emails to blocked users + return; + } + + // Invalidate any existing reset tokens for this user + await this.prisma.passwordResetToken.updateMany({ + where: { + userId: user.id, + usedAt: null, + expiresAt: { gt: new Date() }, + }, + data: { + expiresAt: new Date(), // Expire immediately + }, + }); + + // Generate new reset token + const resetToken = randomToken(32); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await this.prisma.passwordResetToken.create({ + data: { + userId: user.id, + token: resetToken, + expiresAt, + }, + }); + + // Send reset email + await this.emailService.sendPasswordResetEmail(user.email, resetToken); + } + + async resetPassword(data: ResetPasswordDto): Promise { + const resetToken = await this.prisma.passwordResetToken.findUnique({ + where: { token: data.token }, + include: { user: true }, + }); + + if (!resetToken) { + throw new BadRequestException('Invalid or expired reset token'); + } + + if (resetToken.usedAt) { + throw new BadRequestException('Reset token has already been used'); + } + + if (resetToken.expiresAt < new Date()) { + throw new BadRequestException('Reset token has expired'); + } + + if (resetToken.user.isBlocked) { + throw new BadRequestException('Account is blocked'); + } + + const passwordHistoryLimit = getPasswordHistoryLimit(); + + // Check if new password was used recently + const recentPasswords = await this.prisma.passwordHistory.findMany({ + where: { userId: resetToken.userId }, + orderBy: { createdAt: 'desc' }, + take: passwordHistoryLimit, + }); + + for (const historyEntry of recentPasswords) { + const isReused = await comparePassword(data.newPassword, historyEntry.passwordHash); + if (isReused) { + throw new BadRequestException( + `Password reuse is not allowed for the last ${passwordHistoryLimit} passwords`, + ); + } + } + + const newPasswordHash = await hashPassword(data.newPassword, this.bcryptRounds); + + // Update password and mark token as used in a transaction + await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await tx.user.update({ + where: { id: resetToken.userId }, + data: { password: newPasswordHash }, + }); + + await tx.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { usedAt: new Date() }, + }); + + await tx.passwordHistory.create({ + data: { + userId: resetToken.userId, + passwordHash: newPasswordHash, + }, + }); + + // Clean up old password history entries + const historyEntries = await tx.passwordHistory.findMany({ + where: { userId: resetToken.userId }, + orderBy: { createdAt: 'desc' }, + skip: passwordHistoryLimit, + }); + + if (historyEntries.length > 0) { + await tx.passwordHistory.deleteMany({ + where: { + id: { in: historyEntries.map((entry: any) => entry.id) }, + }, + }); + } + }); + } + + async unlockAccount(email: string) { + await this.rateLimitService.unlockAccount(email); + return { message: 'Account unlocked successfully. You can now try to log in again.' }; + } + + async getLoginStatus(email: string) { + const lockoutInfo = await this.rateLimitService.getLockoutInfo(email); + + if (!lockoutInfo) { + return { + email, + isLocked: false, + failedAttempts: 0, + canAttemptLogin: true, + }; + } + + return { + email, + isLocked: lockoutInfo.isLocked, + failedAttempts: lockoutInfo.failedAttempts, + unlockAt: lockoutInfo.unlockAt, + remainingLockoutMinutes: lockoutInfo.remainingLockoutMinutes, + canAttemptLogin: !lockoutInfo.isLocked, + }; + } + + private async recordLoginHistory(userId: string, ipAddress?: string, userAgent?: string) { + await this.prisma.loginHistory.create({ + data: { + userId, + ipAddress, + userAgent, + }, + }); + } + + private async verifyCaptcha(token: string): Promise { + const secret = this.configService.get('RECAPTCHA_SECRET'); + if (!secret) { + this.logger.warn('RECAPTCHA_SECRET is not configured, skipping CAPTCHA verification'); + return true; // Bypass if not configured in dev + } + + try { + const response = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `secret=${secret}&response=${token}`, + }); + + const data = (await response.json()) as any; + + // reCAPTCHA v3 returns a score between 0.0 and 1.0. Typically, 0.5 is a good threshold. + if (data.success && data.score !== undefined && data.score >= 0.5) { + return true; + } + + if (data.success && data.score === undefined) { + // v2 fallback + return true; + } + + this.logger.warn(`CAPTCHA verification failed: ${JSON.stringify(data['error-codes'])}`); + return false; + } catch (error) { + this.logger.error(`Error verifying CAPTCHA: ${error.message}`); + return false; + } + } +} diff --git a/src/auth/decorators/gql-user.decorator.ts b/src/auth/decorators/gql-user.decorator.ts index 0b70aad5..a2ef0e46 100644 --- a/src/auth/decorators/gql-user.decorator.ts +++ b/src/auth/decorators/gql-user.decorator.ts @@ -1,7 +1,9 @@ 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; -}); +export const GqlUser = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.authUser; + }, +); diff --git a/src/common/common.types.ts b/src/common/common.types.ts index fe8cdef2..fdf5fbc9 100644 --- a/src/common/common.types.ts +++ b/src/common/common.types.ts @@ -1,12 +1,5 @@ import { registerEnumType } from '@nestjs/graphql'; -import { - UserRole, - PropertyStatus, - TransactionType, - TransactionStatus, - DocumentType, - VerificationStatus, -} from '@prisma/client'; +import { UserRole, PropertyStatus, TransactionType, TransactionStatus, DocumentType, VerificationStatus } from '@prisma/client'; registerEnumType(UserRole, { name: 'UserRole' }); registerEnumType(PropertyStatus, { name: 'PropertyStatus' }); @@ -15,11 +8,4 @@ registerEnumType(TransactionStatus, { name: 'TransactionStatus' }); registerEnumType(DocumentType, { name: 'DocumentType' }); registerEnumType(VerificationStatus, { name: 'VerificationStatus' }); -export { - UserRole, - PropertyStatus, - TransactionType, - TransactionStatus, - DocumentType, - VerificationStatus, -}; +export { UserRole, PropertyStatus, TransactionType, TransactionStatus, DocumentType, VerificationStatus }; diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index 6f8ae0b0..bb6565f3 100644 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -3,19 +3,11 @@ import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - verificationDocument: any; - property: any; async onModuleInit() { await this.$connect(); } - $connect() { - throw new Error('Method not implemented.'); - } async onModuleDestroy() { await this.$disconnect(); } - $disconnect() { - throw new Error('Method not implemented.'); - } } diff --git a/src/properties/dto/saved-search.dto.ts b/src/properties/dto/saved-search.dto.ts deleted file mode 100644 index 0b20128a..00000000 --- a/src/properties/dto/saved-search.dto.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { IsString, IsOptional, IsBoolean, IsObject } from 'class-validator'; -import { InputType, Field, ID, Int } from '@nestjs/graphql'; -import { SearchResultItem } from './search.dto'; - -@InputType() -export class CreateSavedSearchDto { - @Field() - @IsString() - name: string; - - @Field({ nullable: true }) - @IsOptional() - @IsString() - description?: string; - - @Field(() => Object) - @IsObject() - criteria: Record; - - @Field({ nullable: true, defaultValue: true }) - @IsOptional() - @IsBoolean() - alertEnabled?: boolean = true; -} - -@InputType() -export class UpdateSavedSearchDto { - @Field({ nullable: true }) - @IsOptional() - @IsString() - name?: string; - - @Field({ nullable: true }) - @IsOptional() - @IsString() - description?: string; - - @Field(() => Object, { nullable: true }) - @IsOptional() - @IsObject() - criteria?: Record; - - @Field({ nullable: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; - - @Field({ nullable: true }) - @IsOptional() - @IsBoolean() - alertEnabled?: boolean; -} - -@InputType() -export class SavedSearchResponse { - @Field(() => ID) - id: string; - - @Field() - name: string; - - @Field({ nullable: true }) - description?: string; - - @Field(() => Object) - criteria: Record; - - @Field() - isActive: boolean; - - @Field() - alertEnabled: boolean; - - @Field({ nullable: true }) - lastRunAt?: string; - - @Field() - createdAt: Date; - - @Field() - updatedAt: Date; - - @Field(() => Int, { nullable: true }) - matchCount?: number; - - @Field(() => [SearchAlertItem], { nullable: true }) - recentAlerts?: SearchAlertItem[]; -} - -@InputType() -export class SearchAlertItem { - @Field(() => ID) - id: string; - - @Field() - propertyId: string; - - @Field() - notified: boolean; - - @Field({ nullable: true }) - notifiedAt?: string; - - @Field() - createdAt: Date; -} - -@InputType() -export class RunSavedSearchResult { - @Field(() => ID) - savedSearchId: string; - - @Field(() => Int) - newMatches: number; - - @Field(() => [SearchResultItem]) - properties: Array<{ - id: string; - title: string; - description?: string; - address: string; - city: string; - state: string; - zipCode: string; - country: string; - price: number; - propertyType: string; - bedrooms?: number; - bathrooms?: number; - squareFeet?: number; - lotSize?: number; - yearBuilt?: number; - features?: string[]; - location?: [number, number]; - status: string; - createdAt: Date; - owner?: { - id: string; - firstName: string; - lastName: string; - email: string; - }; - }>; - - @Field() - totalMatches: number; - - @Field() - hasMore: boolean; - - @Field() - nextCursor?: string; -} - -@InputType() -export class ManageSavedSearchRequest { - @Field(() => ID, { nullable: true }) - @IsOptional() - savedSearchId?: string; - - @Field() - @IsString() - name: string; - - @Field({ nullable: true }) - @IsOptional() - @IsString() - description?: string; - - @Field(() => Object) - @IsObject() - criteria: Record; - - @Field({ nullable: true, defaultValue: true }) - @IsOptional() - @IsBoolean() - alertEnabled?: boolean = true; - - @Field({ nullable: true, defaultValue: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean = true; -} diff --git a/src/properties/dto/search.dto.ts b/src/properties/dto/search.dto.ts deleted file mode 100644 index f51fdcf7..00000000 --- a/src/properties/dto/search.dto.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { - IsOptional, - IsNumber, - IsString, - IsBoolean, - IsArray, - IsIn, - Min, - Max, -} from 'class-validator'; -import { InputType, Field, Float } from '@nestjs/graphql'; - -// Sort fields (as string literals for GraphQL enum) -export const PROPERTY_SORT_FIELDS = [ - 'price', - 'createdAt', - 'squareFeet', - 'bedrooms', - 'bathrooms', - 'yearBuilt', -]; -export const SORT_DIRECTION = ['asc', 'desc']; - -export type PropertySortField = - | 'price' - | 'createdAt' - | 'squareFeet' - | 'bedrooms' - | 'bathrooms' - | 'yearBuilt'; -export type SortDirection = 'asc' | 'desc'; - -@InputType() -export class PropertySearchFilters { - @Field({ nullable: true }) - @IsOptional() - @IsString() - query?: string; - - @Field(() => [String], { nullable: true }) - @IsOptional() - cities?: string[]; - - @Field(() => [String], { nullable: true }) - @IsOptional() - states?: string[]; - - @Field(() => [String], { nullable: true }) - @IsOptional() - propertyTypes?: string[]; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - minPrice?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - maxPrice?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - minBedrooms?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - maxBedrooms?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - minBathrooms?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - maxBathrooms?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - minSquareFeet?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - maxSquareFeet?: number; - - @Field({ nullable: true }) - @IsOptional() - @IsString() - status?: string; - - @Field({ nullable: true }) - @IsOptional() - @IsString() - ownerId?: string; - - @Field(() => [String], { nullable: true }) - @IsOptional() - features?: string[]; -} - -@InputType() -export class CursorPaginationInput { - @Field({ nullable: true }) - @IsOptional() - @IsString() - cursor?: string; - - @Field({ nullable: true }) - @IsOptional() - @IsNumber() - @Min(1) - @Max(100) - limit?: number = 20; -} - -@InputType() -export class SearchSortOptions { - @Field({ nullable: true }) - @IsOptional() - field?: PropertySortField; - - @Field({ nullable: true }) - @IsOptional() - @IsIn(SORT_DIRECTION) - direction?: SortDirection = 'desc'; -} - -@InputType() -export class SearchCriteriaDto { - @Field(() => PropertySearchFilters) - filters: PropertySearchFilters; - - @Field(() => CursorPaginationInput, { nullable: true }) - @IsOptional() - pagination?: CursorPaginationInput; - - @Field(() => SearchSortOptions, { nullable: true }) - @IsOptional() - sort?: SearchSortOptions; - - @Field({ nullable: true }) - @IsOptional() - @IsBoolean() - includeTotalCount?: boolean = true; - - @Field({ nullable: true }) - @IsOptional() - @IsBoolean() - cacheResults?: boolean = true; -} - -@InputType() -export class SearchResultItem { - @Field(() => Float) - id: string; - - @Field() - title: string; - - @Field({ nullable: true }) - description?: string; - - @Field() - address: string; - - @Field() - city: string; - - @Field() - state: string; - - @Field() - zipCode: string; - - @Field() - country: string; - - @Field(() => Float) - price: number; - - @Field() - propertyType: string; - - @Field({ nullable: true }) - bedrooms?: number; - - @Field(() => Float, { nullable: true }) - bathrooms?: number; - - @Field(() => Float, { nullable: true }) - squareFeet?: number; - - @Field({ nullable: true }) - yearBuilt?: number; - - @Field(() => [String], { nullable: true }) - features?: string[]; - - @Field(() => [Float], { nullable: true }) - location?: [number, number]; - - @Field() - status: string; - - @Field() - createdAt: Date; - - @Field({ nullable: true }) - owner?: { - id: string; - firstName: string; - lastName: string; - email: string; - }; -} - -@InputType() -export class PaginatedSearchResponse { - @Field(() => [SearchResultItem]) - results: SearchResultItem[]; - - @Field() - hasNextPage: boolean; - - @Field({ nullable: true }) - nextCursor?: string; - - @Field({ nullable: true }) - totalCount?: number; - - @Field() - pageInfo: { - limit: number; - offset: number; - }; -} - -// Auxiliary DTOs for internal use -export interface PropertyInclude { - owner?: { - select: { - id: boolean; - firstName: boolean; - lastName: boolean; - email: boolean; - }; - }; -} - -export interface PropertyWhere { - AND?: any[]; - OR?: any[]; - id?: string; - title?: { contains: string; mode: string }; - description?: { contains: string; mode: string }; - city?: { in: string[] } | string; - state?: { in: string[] } | string; - propertyType?: { in: string[] } | string; - price?: { gte: number; lte: number } | number; - bedrooms?: { gte: number; lte: number } | number; - bathrooms?: { gte: number; lte: number } | number; - squareFeet?: { gte: number; lte: number } | number; - status?: string; - ownerId?: string; - features?: { hasSome: string[] }; - createdAt?: { gt: Date }; -} - -export interface PropertyOrderBy { - [key: string]: 'asc' | 'desc'; -} diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 1fbb9563..b40e85dc 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -1,223 +1,26 @@ -import { - Controller, - Get, - Post, - Body, - Param, - Put, - Delete, - UseGuards, - Query, - NotFoundException, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Put, Delete, UseGuards } from '@nestjs/common'; import { PropertiesService } from './properties.service'; -import { SearchCriteriaDto, PaginatedSearchResponse } from './dto/search.dto'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { SavedSearchService } from './saved-search.service'; -import { - CreateSavedSearchDto, - UpdateSavedSearchDto, - SavedSearchResponse, -} from './dto/saved-search.dto'; -import { SavedSearchAlertService } from './saved-search.service'; +import { UserRole } from '../types/prisma.types'; @Controller('properties') export class PropertiesController { - constructor( - private propertiesService: PropertiesService, - private savedSearchService: SavedSearchService, - private savedSearchAlertService: SavedSearchAlertService, - ) {} + constructor(private readonly propertiesService: PropertiesService) {} - // ==================== Search Endpoints ==================== - - /** - * Optimized property search with cursor-based pagination - * GET /properties/search - */ - @Get('search') - @UseGuards(JwtAuthGuard) - async search( - @Query() criteria: SearchCriteriaDto, - @CurrentUser() _user: AuthUserPayload, - ): Promise { - return this.propertiesService.search(criteria); - } - - /** - * Cached property search (uses Redis cache) - * GET /properties/search/cached - */ - @Get('search/cached') - @UseGuards(JwtAuthGuard) - async cachedSearch( - @Query() criteria: SearchCriteriaDto, - @CurrentUser() _user: AuthUserPayload, - ): Promise { - return this.propertiesService.cachedSearch(criteria); - } - - // ==================== Saved Search Endpoints ==================== - - /** - * Get all saved searches for current user - * GET /properties/saved-searches - */ - @Get('saved-searches') - @UseGuards(JwtAuthGuard) - async getSavedSearches( - @CurrentUser() user: AuthUserPayload, - @Query('includeAlerts') includeAlerts?: string, - ): Promise { - return this.savedSearchService.findByUser(user.sub, includeAlerts === 'true'); - } - - /** - * Find saved search by ID - * GET /properties/saved-searches/:id - */ - @Get('saved-searches/:id') - @UseGuards(JwtAuthGuard) - async getSavedSearch( - @Param('id') id: string, - @CurrentUser() user: AuthUserPayload, - ): Promise { - const result = await this.savedSearchService.findById(id, user.sub); - if (!result) { - throw new NotFoundException('Saved search not found'); - } - return result; - } - - /** - * Create saved search - * POST /properties/saved-searches - */ - @Post('saved-searches') - @UseGuards(JwtAuthGuard) - async createSavedSearch( - @Body() createDto: CreateSavedSearchDto, - @CurrentUser() user: AuthUserPayload, - ): Promise { - return this.savedSearchService.create(createDto, user.sub); - } - - /** - * Update saved search - * PUT /properties/saved-searches/:id - */ - @Put('saved-searches/:id') - @UseGuards(JwtAuthGuard) - async updateSavedSearch( - @Param('id') id: string, - @Body() updateDto: UpdateSavedSearchDto, - @CurrentUser() user: AuthUserPayload, - ): Promise { - return this.savedSearchService.update(id, updateDto, user.sub); - } - - /** - * Delete saved search - * DELETE /properties/saved-searches/:id - */ - @Delete('saved-searches/:id') - @UseGuards(JwtAuthGuard) - async deleteSavedSearch( - @Param('id') id: string, - @CurrentUser() user: AuthUserPayload, - ): Promise { - return this.savedSearchService.delete(id, user.sub); - } - - /** - * Run saved search (find new matching properties) - * POST /properties/saved-searches/:id/run - */ - @Post('saved-searches/:id/run') - @UseGuards(JwtAuthGuard) - async runSavedSearch(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { - const result = await this.savedSearchService.runSearch(id, user.sub); - - // Create alerts for matches - if (result.newMatches.length > 0) { - const propertyIds = result.newMatches.map((p: { id: string }) => p.id); - await this.savedSearchAlertService.createAlertsForMatches(id, propertyIds); - } - - return result; - } - - /** - * Duplicate saved search - * POST /properties/saved-searches/:id/duplicate - */ - @Post('saved-searches/:id/duplicate') - @UseGuards(JwtAuthGuard) - async duplicateSavedSearch( - @Param('id') id: string, - @CurrentUser() user: AuthUserPayload, - @Query('name') name?: string, - ): Promise { - return this.savedSearchService.duplicate(id, user.sub, name); - } - - // ==================== Alert Endpoints ==================== - - /** - * Get un notified alerts for current user - * GET /properties/alerts/unread - */ - @Get('alerts/unread') - @UseGuards(JwtAuthGuard) - async getUnreadAlerts(@CurrentUser() user: AuthUserPayload) { - return this.savedSearchAlertService.getUnnotifiedAlerts(user.sub); - } - - /** - * Mark alerts as read - * POST /properties/alerts/mark-read - */ - @Post('alerts/mark-read') - @UseGuards(JwtAuthGuard) - async markAlertsAsRead( - @Body('alertIds') alertIds: string[], - @CurrentUser() _user: AuthUserPayload, - ): Promise { - return this.savedSearchAlertService.markAlertsAsNotified(alertIds); - } - - /** - * Get search statistics for user - * GET /properties/search/stats - */ - @Get('search/stats') @UseGuards(JwtAuthGuard) - async getSearchStats(@CurrentUser() user: AuthUserPayload) { - return this.savedSearchAlertService.getSearchStats(user.sub); - } - - // ==================== Existing CRUD Methods ==================== - @Post() - @UseGuards(JwtAuthGuard) create(@Body() createPropertyDto: CreatePropertyDto, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.create(createPropertyDto, user.sub); } @Get() - findAll( - @Query() - params?: { - skip?: number; - take?: number; - where?: Record; - orderBy?: Record; - }, - ) { - return this.propertiesService.findAll(params); + findAll() { + return this.propertiesService.findAll(); } @Get(':id') @@ -225,14 +28,16 @@ export class PropertiesController { return this.propertiesService.findOne(id); } + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT, UserRole.ADMIN) @Put(':id') - @UseGuards(JwtAuthGuard) update(@Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto) { return this.propertiesService.update(id, updatePropertyDto); } + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) @Delete(':id') - @UseGuards(JwtAuthGuard) remove(@Param('id') id: string) { return this.propertiesService.remove(id); } diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index be17d5dd..be88593b 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -6,7 +6,6 @@ import { AuthModule } from '../auth/auth.module'; import { PropertiesResolver } from './properties.resolver'; import { PubSub } from 'graphql-subscriptions'; import { FraudModule } from '../fraud/fraud.module'; -import { SavedSearchAlertService, SavedSearchService } from './saved-search.service'; @Module({ imports: [PrismaModule, AuthModule, FraudModule], @@ -14,13 +13,11 @@ import { SavedSearchAlertService, SavedSearchService } from './saved-search.serv providers: [ PropertiesService, PropertiesResolver, - SavedSearchService, - SavedSearchAlertService, { provide: 'PUB_SUB', useValue: new PubSub(), }, ], - exports: [PropertiesService, SavedSearchService], + exports: [PropertiesService], }) export class PropertiesModule {} diff --git a/src/properties/properties.resolver.ts b/src/properties/properties.resolver.ts index ee2100b2..a4062bc6 100644 --- a/src/properties/properties.resolver.ts +++ b/src/properties/properties.resolver.ts @@ -1,4 +1,6 @@ -import { Resolver, Query, Mutation, Args, Subscription, Inject, UseGuards } from '@nestjs/common'; +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'; @@ -30,7 +32,10 @@ export class PropertiesResolver { @Mutation(() => Property) @UseGuards(GqlAuthGuard) - async createProperty(@GqlUser() user: any, @Args('input') input: CreatePropertyDto) { + 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; @@ -38,7 +43,10 @@ export class PropertiesResolver { @Mutation(() => Property) @UseGuards(GqlAuthGuard) - async updateProperty(@Args('id') id: string, @Args('input') input: UpdatePropertyDto) { + async updateProperty( + @Args('id') id: string, + @Args('input') input: UpdatePropertyDto, + ) { return this.propertiesService.update(id, input); } diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index e1ca9a76..c61346db 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -1,16 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../database/prisma.service'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; import { FraudService } from '../fraud/fraud.service'; -import { - SearchCriteriaDto, - PaginatedSearchResponse, - PropertySearchFilters, - PropertyWhere, - SearchSortOptions, - PropertySortField, - SearchResultItem, -} from './dto/search.dto'; interface FindAllParams { skip?: number; @@ -21,218 +13,12 @@ interface FindAllParams { @Injectable() export class PropertiesService { - private readonly DEFAULT_LIMIT = 20; - private readonly MAX_LIMIT = 100; - constructor( private readonly prisma: PrismaService, private readonly fraudService: FraudService, ) {} - /** - * Cached property search (uses Redis cache) - */ - async cachedSearch(criteria: SearchCriteriaDto): Promise { - return this.search(criteria); - } - - /** - * Optimized search with cursor-based pagination - */ - async search(criteria: SearchCriteriaDto): Promise { - const { filters, pagination, sort, includeTotalCount = true, cacheResults = true } = criteria; - - // Build sort configuration - const sortConfig = this.buildSortConfig(sort); - - // Build optimized query - const where = this.buildWhereClause(filters); - - // Get total count if requested - let totalCount: number | null = null; - if (includeTotalCount) { - totalCount = (await (this.prisma as any).property.count({ where })) as number; - } - - // Build order by - const orderBy = { [sortConfig.field]: sortConfig.direction }; - - // Apply cursor pagination - const { cursor, limit: rawLimit } = pagination || {}; - const limit = Math.min(rawLimit || this.DEFAULT_LIMIT, this.MAX_LIMIT); - - // Build query - const query: any = { - where, - orderBy, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, - }, - }, - take: limit + 1, - }; - - // Add cursor condition if provided - if (cursor) { - // Use id < cursor for descending order - query.where.id = { lt: cursor }; - } - - // Execute query - const rawResults = await (this.prisma as any).property.findMany(query); - - // Check if there are more results - const hasMore = rawResults.length > limit; - if (hasMore) { - rawResults.pop(); - } - - // Generate next cursor - const nextCursor = - hasMore && rawResults.length > 0 ? rawResults[rawResults.length - 1].id : undefined; - - // Map to response DTO - const results = this.mapToSearchResultItem(rawResults); - - return { - results, - hasNextPage: hasMore, - nextCursor, - ...(includeTotalCount && { totalCount: totalCount || 0 }), - pageInfo: { - limit, - offset: 0, - }, - }; - } - - /** - * Build Prisma where clause from filters - */ - private buildWhereClause(filters: PropertySearchFilters): PropertyWhere { - const where: PropertyWhere = {}; - - // Text search - if (filters.query) { - where.OR = [ - { title: { contains: filters.query, mode: 'insensitive' } }, - { description: { contains: filters.query, mode: 'insensitive' } }, - { address: { contains: filters.query, mode: 'insensitive' } }, - { city: { contains: filters.query, mode: 'insensitive' } }, - ]; - } - - // Exact match filters - if (filters.cities?.length) { - where.city = { in: filters.cities }; - } - if (filters.states?.length) { - where.state = { in: filters.states }; - } - if (filters.propertyTypes?.length) { - where.propertyType = { in: filters.propertyTypes }; - } - if (filters.status) { - where.status = filters.status; - } - if (filters.ownerId) { - where.ownerId = filters.ownerId; - } - if (filters.features?.length) { - where.features = { hasSome: filters.features }; - } - - // Range filters - if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { - where.price = { - ...(filters.minPrice !== undefined && { gte: filters.minPrice }), - ...(filters.maxPrice !== undefined && { lte: filters.maxPrice }), - } as { gte?: number; lte?: number }; - } - if (filters.minBedrooms !== undefined || filters.maxBedrooms !== undefined) { - where.bedrooms = { - ...(filters.minBedrooms !== undefined && { gte: filters.minBedrooms }), - ...(filters.maxBedrooms !== undefined && { lte: filters.maxBedrooms }), - } as { gte?: number; lte?: number }; - } - if (filters.minBathrooms !== undefined || filters.maxBathrooms !== undefined) { - where.bathrooms = { - ...(filters.minBathrooms !== undefined && { gte: filters.minBathrooms }), - ...(filters.maxBathrooms !== undefined && { lte: filters.maxBathrooms }), - } as { gte?: number; lte?: number }; - } - if (filters.minSquareFeet !== undefined || filters.maxSquareFeet !== undefined) { - where.squareFeet = { - ...(filters.minSquareFeet !== undefined && { gte: filters.minSquareFeet }), - ...(filters.maxSquareFeet !== undefined && { lte: filters.maxSquareFeet }), - } as { gte?: number; lte?: number }; - } - - return where; - } - - /** - * Build sort configuration - */ - private buildSortConfig(sort?: SearchSortOptions): { - field: PropertySortField; - direction: 'asc' | 'desc'; - } { - return { - field: sort?.field || 'createdAt', - direction: sort?.direction || 'desc', - }; - } - - /** - * Map Prisma results to DTO - */ - private mapToSearchResultItem(properties: any[]): SearchResultItem[] { - return properties.map((prop) => ({ - id: prop.id, - title: prop.title, - description: prop.description || undefined, - address: prop.address, - city: prop.city, - state: prop.state, - zipCode: prop.zipCode, - country: prop.country, - price: typeof prop.price === 'object' ? parseFloat(prop.price.toString()) : prop.price, - propertyType: prop.propertyType, - bedrooms: prop.bedrooms ?? undefined, - bathrooms: - typeof prop.bathrooms === 'object' ? parseFloat(prop.bathrooms.toString()) : prop.bathrooms, - squareFeet: - typeof prop.squareFeet === 'object' - ? parseFloat(prop.squareFeet.toString()) - : prop.squareFeet, - lotSize: - typeof prop.lotSize === 'object' ? parseFloat(prop.lotSize.toString()) : prop.lotSize, - yearBuilt: prop.yearBuilt ?? undefined, - features: prop.features || undefined, - location: prop.latitude && prop.longitude ? [prop.longitude, prop.latitude] : undefined, - status: prop.status, - createdAt: prop.createdAt, - owner: prop.owner - ? { - id: prop.owner.id, - firstName: prop.owner.firstName, - lastName: prop.owner.lastName, - email: prop.owner.email, - } - : undefined, - })); - } - - // ==================== Existing Methods ==================== - - async create(createPropertyDto: any, ownerId: string) { + async create(createPropertyDto: CreatePropertyDto, ownerId: string) { const { price, squareFeet, lotSize, ...rest } = createPropertyDto; const property = await this.prisma.property.create({ @@ -241,16 +27,8 @@ export class PropertiesService { price: new Decimal(price.toString()), squareFeet: squareFeet ? new Decimal(squareFeet.toString()) : null, lotSize: lotSize ? new Decimal(lotSize.toString()) : null, - owner: { connect: { id: ownerId } }, - }, - include: { owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, + connect: { id: ownerId }, }, }, }); @@ -260,9 +38,9 @@ export class PropertiesService { return property; } - async findAll(params?: any) { + async findAll(params?: FindAllParams) { const { skip, take, where, orderBy } = params || {}; - return (this.prisma as any).property.findMany({ + return this.prisma.property.findMany({ skip, take, where, @@ -281,7 +59,7 @@ export class PropertiesService { } async findOne(id: string) { - return (this.prisma as any).property.findUnique({ + return this.prisma.property.findUnique({ where: { id }, include: { owner: { @@ -297,10 +75,10 @@ export class PropertiesService { }); } - async update(id: string, updatePropertyDto: any) { + async update(id: string, updatePropertyDto: UpdatePropertyDto) { const { price, squareFeet, lotSize, ...rest } = updatePropertyDto; - return (this.prisma as any).property.update({ + return this.prisma.property.update({ where: { id }, data: { ...rest, @@ -312,25 +90,15 @@ export class PropertiesService { } async remove(id: string) { - return (this.prisma as any).property.delete({ + return this.prisma.property.delete({ where: { id }, }); } async findByOwnerId(ownerId: string) { - return (this.prisma as any).property.findMany({ + return this.prisma.property.findMany({ where: { ownerId }, orderBy: { createdAt: 'desc' }, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, - }, - }, }); } } diff --git a/src/properties/saved-search.service.ts b/src/properties/saved-search.service.ts deleted file mode 100644 index 776022e9..00000000 --- a/src/properties/saved-search.service.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PrismaService } from '../database/prisma.service'; -import { - CreateSavedSearchDto, - UpdateSavedSearchDto, - SavedSearchResponse, -} from './dto/saved-search.dto'; - -@Injectable() -export class SavedSearchService { - private readonly logger = new Logger(SavedSearchService.name); - - constructor(private prisma: PrismaService) {} - - /** - * Create a new saved search - */ - async create(createDto: CreateSavedSearchDto, userId: string): Promise { - const result = await (this.prisma as any).savedSearch.create({ - data: { - name: createDto.name, - description: createDto.description, - criteria: createDto.criteria, - isActive: true, - alertEnabled: createDto.alertEnabled ?? true, - lastRunAt: new Date(), - userId, - }, - include: { - user: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - }, - }, - }, - }); - return result; - } - - /** - * Get all saved searches for a user - */ - async findByUser(userId: string, includeAlerts: boolean = false): Promise { - const result = await (this.prisma as any).savedSearch.findMany({ - where: { userId }, - include: { - user: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - }, - }, - ...(includeAlerts && { - alerts: { - take: 5, - orderBy: { createdAt: 'desc' }, - include: { - property: { - select: { - id: true, - title: true, - price: true, - status: true, - }, - }, - }, - }, - }), - }, - orderBy: { updatedAt: 'desc' }, - }); - return result; - } - - /** - * Find by ID - */ - async findById(id: string, userId?: string): Promise { - const where = userId ? { id, userId } : { id }; - - const result = await (this.prisma as any).savedSearch.findUnique({ - where, - include: { - user: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - }, - }, - alerts: { - take: 10, - orderBy: { createdAt: 'desc' }, - include: { - property: { - select: { - id: true, - title: true, - price: true, - status: true, - }, - }, - }, - }, - }, - }); - return result; - } - - /** - * Update saved search - */ - async update( - id: string, - updateDto: UpdateSavedSearchDto, - userId: string, - ): Promise { - const existing = await this.findById(id, userId); - if (!existing) { - throw new Error('Saved search not found'); - } - - const data: Record = {}; - if (updateDto.name !== undefined) data.name = updateDto.name; - if (updateDto.description !== undefined) data.description = updateDto.description; - if (updateDto.criteria !== undefined) data.criteria = updateDto.criteria; - if (updateDto.isActive !== undefined) data.isActive = updateDto.isActive; - if (updateDto.alertEnabled !== undefined) data.alertEnabled = updateDto.alertEnabled; - - const result = await (this.prisma as any).savedSearch.update({ - where: { id, userId }, - data, - include: { - user: { - select: { - id: true, - email: true, - firstName: true, - lastName: true, - }, - }, - }, - }); - return result; - } - - /** - * Delete saved search - */ - async delete(id: string, userId: string): Promise { - await (this.prisma as any).savedSearch.deleteMany({ - where: { id, userId }, - }); - } - - /** - * Run a saved search to find matching properties - */ - async runSearch(searchId: string, userId: string) { - const savedSearch = await this.findById(searchId, userId); - if (!savedSearch) { - throw new Error('Saved search not found'); - } - - return this.findNewMatches(searchId); - } - - /** - * Find new properties matching a saved search since last run - */ - private async findNewMatches(savedSearchId: string): Promise<{ - savedSearchId: string; - newMatches: any[]; - totalMatches: number; - }> { - const savedSearch = await (this.prisma as any).savedSearch.findUnique({ - where: { id: savedSearchId }, - }); - - if (!savedSearch || !savedSearch.isActive) { - return { savedSearchId, newMatches: [], totalMatches: 0 }; - } - - // Build query from saved criteria - const criteria = savedSearch.criteria as any; - const filters = criteria?.filters || {}; - const where: any = {}; - - // Apply filters - if (filters.query) { - where.OR = [ - { title: { contains: filters.query, mode: 'insensitive' } }, - { description: { contains: filters.query, mode: 'insensitive' } }, - { address: { contains: filters.query, mode: 'insensitive' } }, - { city: { contains: filters.query, mode: 'insensitive' } }, - ]; - } - if (filters.cities?.length) where.city = { in: filters.cities }; - if (filters.states?.length) where.state = { in: filters.states }; - if (filters.propertyTypes?.length) where.propertyType = { in: filters.propertyTypes }; - if (filters.status) where.status = filters.status; - if (filters.ownerId) where.ownerId = filters.ownerId; - if (filters.features?.length) where.features = { hasSome: filters.features }; - - if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { - where.price = { - ...(filters.minPrice !== undefined && { gte: filters.minPrice }), - ...(filters.maxPrice !== undefined && { lte: filters.maxPrice }), - }; - } - - if (filters.minBedrooms !== undefined || filters.maxBedrooms !== undefined) { - where.bedrooms = { - ...(filters.minBedrooms !== undefined && { gte: filters.minBedrooms }), - ...(filters.maxBedrooms !== undefined && { lte: filters.maxBedrooms }), - }; - } - - // Get total count - const totalMatches = await (this.prisma as any).property.count({ where }); - - // Get only new properties since last run - if (savedSearch.lastRunAt) { - where.createdAt = { gt: savedSearch.lastRunAt }; - } - - // Order by date - const sortOptions = criteria?.sort || { field: 'createdAt', direction: 'desc' }; - const orderBy: any = { [sortOptions.field || 'createdAt']: sortOptions.direction || 'desc' }; - - // Fetch new properties - const newProperties = await (this.prisma as any).property.findMany({ - where, - orderBy, - take: 50, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, - }, - }, - }); - - // Update lastRunAt - await (this.prisma as any).savedSearch.update({ - where: { id: savedSearchId }, - data: { lastRunAt: new Date() }, - }); - - return { - savedSearchId, - newMatches: newProperties, - totalMatches, - }; - } - - /** - * Duplicate a saved search - */ - async duplicate(id: string, userId: string, newName?: string): Promise { - const original = await this.findById(id, userId); - if (!original) { - throw new Error('Saved search not found'); - } - - return this.create( - { - name: newName || `${original.name} (Copy)`, - description: original.description, - criteria: original.criteria, - alertEnabled: original.alertEnabled, - }, - userId, - ); - } -} - -// Forward declaration - the actual alert service will be in same file -@Injectable() -export class SavedSearchAlertService { - private readonly logger = new Logger(SavedSearchAlertService.name); - - constructor(private prisma: PrismaService) {} - - /** - * Find new properties matching a saved search since last run - */ - async findNewMatches(savedSearchId: string): Promise<{ - savedSearchId: string; - newMatches: any[]; - totalMatches: number; - }> { - const savedSearch = await (this.prisma as any).savedSearch.findUnique({ - where: { id: savedSearchId }, - }); - - if (!savedSearch || !savedSearch.isActive) { - return { savedSearchId, newMatches: [], totalMatches: 0 }; - } - - const criteria = savedSearch.criteria as any; - const filters = criteria?.filters || {}; - const where: any = {}; - - if (filters.query) { - where.OR = [ - { title: { contains: filters.query, mode: 'insensitive' } }, - { description: { contains: filters.query, mode: 'insensitive' } }, - { address: { contains: filters.query, mode: 'insensitive' } }, - { city: { contains: filters.query, mode: 'insensitive' } }, - ]; - } - if (filters.cities?.length) where.city = { in: filters.cities }; - if (filters.states?.length) where.state = { in: filters.states }; - if (filters.propertyTypes?.length) where.propertyType = { in: filters.propertyTypes }; - if (filters.status) where.status = filters.status; - if (filters.ownerId) where.ownerId = filters.ownerId; - if (filters.features?.length) where.features = { hasSome: filters.features }; - - if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { - where.price = { - ...(filters.minPrice !== undefined && { gte: filters.minPrice }), - ...(filters.maxPrice !== undefined && { lte: filters.maxPrice }), - }; - } - - if (filters.minBedrooms !== undefined || filters.maxBedrooms !== undefined) { - where.bedrooms = { - ...(filters.minBedrooms !== undefined && { gte: filters.minBedrooms }), - ...(filters.maxBedrooms !== undefined && { lte: filters.maxBedrooms }), - }; - } - - const totalMatches = await (this.prisma as any).property.count({ where }); - - if (savedSearch.lastRunAt) { - where.createdAt = { gt: savedSearch.lastRunAt }; - } - - const sortOptions = criteria?.sort || { field: 'createdAt', direction: 'desc' }; - const orderBy: any = { [sortOptions.field || 'createdAt']: sortOptions.direction || 'desc' }; - - const newProperties = await (this.prisma as any).property.findMany({ - where, - orderBy, - take: 50, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, - }, - }, - }); - - await (this.prisma as any).savedSearch.update({ - where: { id: savedSearchId }, - data: { lastRunAt: new Date() }, - }); - - return { - savedSearchId, - newMatches: newProperties, - totalMatches, - }; - } - - /** - * Create alerts for new matching properties - */ - async createAlertsForMatches(savedSearchId: string, propertyIds: string[]): Promise { - if (propertyIds.length === 0) return; - - const alertRecords = propertyIds.map((propertyId) => ({ - savedSearchId, - propertyId, - createdAt: new Date(), - })); - - await (this.prisma as any).searchAlert.createMany({ - data: alertRecords, - skipDuplicates: true, - }); - - this.logger.debug(`Created ${propertyIds.length} alerts for saved search ${savedSearchId}`); - } - - /** - * Get unnotified alerts for a user - */ - async getUnnotifiedAlerts(userId: string) { - return (this.prisma as any).searchAlert.findMany({ - where: { - savedSearch: { - userId, - }, - notified: false, - }, - include: { - property: { - select: { - id: true, - title: true, - price: true, - status: true, - }, - }, - savedSearch: { - select: { - id: true, - name: true, - }, - }, - }, - orderBy: { createdAt: 'desc' }, - }); - } - - /** - * Mark alerts as notified - */ - async markAlertsAsNotified(alertIds: string[]): Promise { - await (this.prisma as any).searchAlert.updateMany({ - where: { id: { in: alertIds } }, - data: { notified: true, notifiedAt: new Date() }, - }); - } - - /** - * Get search statistics for a user - */ - async getSearchStats(userId: string) { - const [totalSavedSearches, totalAlerts, unnotifiedAlerts] = await Promise.all([ - (this.prisma as any).savedSearch.count({ where: { userId } }), - (this.prisma as any).searchAlert.count({ - where: { - savedSearch: { - userId, - }, - }, - }), - (this.prisma as any).searchAlert.count({ - where: { - savedSearch: { - userId, - }, - notified: false, - }, - }), - ]); - - return { - totalSavedSearches, - totalAlerts, - unnotifiedAlerts, - }; - } -} diff --git a/src/properties/types/saved-search.types.ts b/src/properties/types/saved-search.types.ts deleted file mode 100644 index c3093e76..00000000 --- a/src/properties/types/saved-search.types.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Prisma Type Definitions for Saved Search Models - * These interfaces mirror the Prisma models defined in schema.prisma - */ - -export interface SavedSearch { - id: string; - userId: string; - name: string; - description: string | null; - criteria: any; // Json - isActive: boolean; - alertEnabled: boolean; - lastRunAt: Date | null; - createdAt: Date; - updatedAt: Date; -} - -export interface SearchAlert { - id: string; - savedSearchId: string; - propertyId: string; - notified: boolean; - notifiedAt: Date | null; - createdAt: Date; -} - -export interface SavedSearchWithRelations extends SavedSearch { - user: { - id: string; - email: string; - firstName: string; - lastName: string; - }; - alerts: SearchAlertItem[]; -} - -export interface SearchAlertItem { - id: string; - savedSearchId: string; - propertyId: string; - notified: boolean; - notifiedAt: Date | null; - createdAt: Date; - property: { - id: string; - title: string; - price: string; - status: string; - } | null; -} diff --git a/src/search/search-facets.service.ts b/src/search/search-facets.service.ts index deccd2bf..0d7e5bb9 100644 --- a/src/search/search-facets.service.ts +++ b/src/search/search-facets.service.ts @@ -35,7 +35,10 @@ export class SearchFacetsService { }); } - applyFacetFilter(items: SearchableItem[], filters: Record): SearchableItem[] { + applyFacetFilter( + items: SearchableItem[], + filters: Record, + ): SearchableItem[] { return items.filter((item) => Object.entries(filters).every(([field, value]) => String(item[field] ?? '') === value), ); diff --git a/src/search/voice-search.service.ts b/src/search/voice-search.service.ts index 4a0ad05a..30d05357 100644 --- a/src/search/voice-search.service.ts +++ b/src/search/voice-search.service.ts @@ -11,10 +11,7 @@ export class VoiceSearchService { private readonly FILLER_WORDS = new Set(['the', 'a', 'an', 'in', 'on', 'at', 'for', 'with']); process(rawTranscript: string): VoiceSearchResult { - const normalised = rawTranscript - .toLowerCase() - .replace(/[^a-z0-9\s]/g, '') - .trim(); + const normalised = rawTranscript.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim(); const tokens = normalised .split(/\s+/) .filter((word) => word.length > 0 && !this.FILLER_WORDS.has(word)); diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 11a69978..5a13931c 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -110,6 +110,4 @@ export namespace Prisma { export interface PropertyWhereInput extends Record {} export interface PropertyOrderByWithRelationInput extends Record {} export interface TransactionClient extends Record {} - export interface SavedSearchWhereInput extends Record {} - export interface SearchAlertWhereInput extends Record {} } diff --git a/src/users/users.resolver.ts b/src/users/users.resolver.ts index 587f9f32..3a21d2e4 100644 --- a/src/users/users.resolver.ts +++ b/src/users/users.resolver.ts @@ -30,7 +30,10 @@ export class UsersResolver { @Mutation(() => User) @UseGuards(GqlAuthGuard) - async updateProfile(@GqlUser() user: any, @Args('input') input: UpdateUserDto) { + 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);