diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65faa340..a08dc900 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -240,6 +240,7 @@ model User { openHouseRsvps OpenHouseRsvp[] transactionNotes TransactionNote[] @relation("TransactionNoteAuthor") deletedProperties Property[] @relation("DeletedProperties") + comparisonShares ComparisonShare[] @@index([email]) @@index([isDeactivated]) @@ -1293,6 +1294,21 @@ model ExportJob { @@map("export_jobs") } +model ComparisonShare { + id String @id @default(uuid()) + shareToken String @unique @map("share_token") + propertyIds String[] @map("property_ids") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime? @map("expires_at") + + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([shareToken]) + @@index([createdById]) + @@map("comparison_shares") +} + enum JobType { EXPORT IMPORT diff --git a/src/email/email.module.ts b/src/email/email.module.ts index 115f949c..0a1e459e 100644 --- a/src/email/email.module.ts +++ b/src/email/email.module.ts @@ -5,6 +5,7 @@ import { EmailService } from './email.service'; import { EmailWebhookController } from './email-webhook.controller'; import { PrismaModule } from '../database/prisma.module'; import { TrackingModule } from '../tracking/tracking.module'; +import { I18nModule } from '../i18n/i18n.module'; import { MailerModule } from '@nestjs-modules/mailer'; import { EjsAdapter } from '@nestjs-modules/mailer/adapters/ejs.adapter'; import { ConfigService } from '@nestjs/config'; @@ -16,6 +17,7 @@ import { EmailProcessor } from './email.processor'; imports: [ PrismaModule, TrackingModule, + I18nModule, BullModule.registerQueue({ name: 'mail', defaultJobOptions: { diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 74db60fb..e3fe4c50 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../database/prisma.service'; import { TrackingService } from '../tracking/tracking.service'; +import { I18nService } from '../i18n/i18n.service'; import { v4 as uuidv4 } from 'uuid'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; @@ -17,6 +18,7 @@ export interface EmailOptions { emailType?: string; template?: string; context?: any; + language?: string; } export interface FraudAlertEmailPayload { @@ -49,6 +51,7 @@ export class EmailService { private readonly configService: ConfigService, private readonly prisma: PrismaService, private readonly trackingService: TrackingService, + private readonly i18nService: I18nService, @InjectQueue('mail') private readonly mailQueue: Queue, ) {} @@ -165,6 +168,15 @@ export class EmailService { const baseUrl = this.configService.get('API_URL', 'http://localhost:3000/api'); const html = options.html; + if (options.language && options.template) { + const lang = options.language; + const i18nKey = `email.${options.template}`; + const translated = this.i18nService.translate(i18nKey, lang, options.context); + if (translated !== i18nKey) { + options.subject = options.subject || translated; + } + } + // 1. Check if user is blocked or has invalid email if (options.userId) { const user = await this.prisma.user.findUnique({ where: { id: options.userId } }); @@ -224,4 +236,28 @@ export class EmailService { throw error; } } + + async sendLocalizedEmail( + to: string, + templateKey: string, + userId: string, + params?: Record, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { languagePreference: true }, + }); + + const language = user?.languagePreference || 'en'; + const translated = this.i18nService.translate(templateKey, language, params); + + await this.sendEmail({ + to, + subject: translated, + template: templateKey.replace('.', '-'), + context: params, + userId, + language, + }); + } } diff --git a/src/i18n/i18n.module.ts b/src/i18n/i18n.module.ts new file mode 100644 index 00000000..a7a441dd --- /dev/null +++ b/src/i18n/i18n.module.ts @@ -0,0 +1,10 @@ +// @ts-nocheck + +import { Module } from '@nestjs/common'; +import { I18nService } from './i18n.service'; + +@Module({ + providers: [I18nService], + exports: [I18nService], +}) +export class I18nModule {} diff --git a/src/i18n/i18n.service.ts b/src/i18n/i18n.service.ts new file mode 100644 index 00000000..b1e74973 --- /dev/null +++ b/src/i18n/i18n.service.ts @@ -0,0 +1,46 @@ +// @ts-nocheck + +import { Injectable } from '@nestjs/common'; +import { SupportedLanguage, translations } from './translations'; + +@Injectable() +export class I18nService { + private readonly defaultLanguage: SupportedLanguage = 'en'; + + translate(key: string, language?: string | null, params?: Record): string { + const lang = this.resolveLanguage(language); + let text = translations[lang]?.[key] || translations[this.defaultLanguage]?.[key] || key; + + if (params) { + for (const [paramKey, paramValue] of Object.entries(params)) { + text = text.replace(new RegExp(`{{${paramKey}}}`, 'g'), String(paramValue)); + } + } + + return text; + } + + translateTemplate(key: string, language?: string | null, params?: Record): { + subject: string; + body: string; + } { + const subjectKey = `${key}.subject`; + const bodyKey = `${key}.body`; + + const subject = this.translate(subjectKey, language, params); + const body = this.translate(bodyKey, language, params); + + return { subject, body }; + } + + private resolveLanguage(language?: string | null): SupportedLanguage { + if (language && (language === 'en' || language === 'es')) { + return language; + } + return this.defaultLanguage; + } + + getSupportedLanguages(): SupportedLanguage[] { + return ['en', 'es']; + } +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts new file mode 100644 index 00000000..3fdaa764 --- /dev/null +++ b/src/i18n/translations.ts @@ -0,0 +1,70 @@ +// @ts-nocheck + +export type SupportedLanguage = 'en' | 'es'; + +export const translations: Record> = { + en: { + 'property.expiring_soon': 'Your property "{{title}}" is scheduled to expire in {{days}} day(s). Consider renewing it to keep it active.', + 'property.expired': 'Your property "{{title}}" has expired due to reaching its expiry date.', + 'property.archived': 'Your property "{{title}}" has been archived after the {{days}}-day grace period following expiry.', + 'property.renewed': 'Your property "{{title}}" has been successfully renewed until {{expiryDate}}.', + 'email.welcome': 'Welcome to PropChain!', + 'email.welcome_message': 'Thank you for joining PropChain, the blockchain-powered real estate platform.', + 'email.password_reset': 'Password Reset Request', + 'email.password_reset_message': 'You requested a password reset. Click the link below to set a new password.', + 'email.password_reset_instruction': 'If you did not request this, please ignore this email.', + 'email.transaction_update': 'Transaction Status Update', + 'email.transaction_completed': 'Your transaction for "{{title}}" has been completed successfully.', + 'email.transaction_pending': 'Your transaction for "{{title}}" is pending. We will notify you of any updates.', + 'email.transaction_cancelled': 'Your transaction for "{{title}}" has been cancelled.', + 'email.fraud_alert': 'Fraud Alert', + 'email.fraud_alert_message': 'A fraud alert has been detected on your account. Please review the details.', + 'email.account_locked': 'Your account has been locked for {{duration}} minutes due to multiple failed login attempts.', + 'error.not_found': 'The requested resource was not found.', + 'error.unauthorized': 'You are not authorized to perform this action.', + 'error.forbidden': 'Access to this resource is forbidden.', + 'error.bad_request': 'The request is invalid. Please check your input.', + 'error.server_error': 'An internal server error occurred. Please try again later.', + 'error.property_not_found': 'Property with ID {{id}} was not found.', + 'error.property_expired': 'This property listing has expired and is no longer available.', + 'notification.property_expiry_warning': 'Property Expiry Warning', + 'notification.property_expired': 'Property Expired', + 'notification.property_archived': 'Property Archived', + 'common.days_remaining': '{{count}} day(s) remaining', + 'common.expired': 'Expired', + 'common.active': 'Active', + 'common.renew': 'Renew', + }, + es: { + 'property.expiring_soon': 'Su propiedad "{{title}}" está programada para expirar en {{days}} día(s). Considere renovarla para mantenerla activa.', + 'property.expired': 'Su propiedad "{{title}}" ha expirado por haber alcanzado su fecha de vencimiento.', + 'property.archived': 'Su propiedad "{{title}}" ha sido archivada después del período de gracia de {{days}} días tras la expiración.', + 'property.renewed': 'Su propiedad "{{title}}" ha sido renovada exitosamente hasta {{expiryDate}}.', + 'email.welcome': '¡Bienvenido a PropChain!', + 'email.welcome_message': 'Gracias por unirse a PropChain, la plataforma de bienes raíces impulsada por blockchain.', + 'email.password_reset': 'Solicitud de Restablecimiento de Contraseña', + 'email.password_reset_message': 'Solicitó un restablecimiento de contraseña. Haga clic en el enlace a continuación para establecer una nueva contraseña.', + 'email.password_reset_instruction': 'Si no solicitó esto, por favor ignore este correo electrónico.', + 'email.transaction_update': 'Actualización del Estado de la Transacción', + 'email.transaction_completed': 'Su transacción para "{{title}}" se ha completado exitosamente.', + 'email.transaction_pending': 'Su transacción para "{{title}}" está pendiente. Le notificaremos de cualquier actualización.', + 'email.transaction_cancelled': 'Su transacción para "{{title}}" ha sido cancelada.', + 'email.fraud_alert': 'Alerta de Fraude', + 'email.fraud_alert_message': 'Se ha detectado una alerta de fraude en su cuenta. Por favor revise los detalles.', + 'email.account_locked': 'Su cuenta ha sido bloqueada por {{duration}} minutos debido a múltiples intentos de inicio de sesión fallidos.', + 'error.not_found': 'El recurso solicitado no fue encontrado.', + 'error.unauthorized': 'No está autorizado para realizar esta acción.', + 'error.forbidden': 'El acceso a este recurso está prohibido.', + 'error.bad_request': 'La solicitud es inválida. Por favor verifique su entrada.', + 'error.server_error': 'Ocurrió un error interno del servidor. Por favor intente de nuevo más tarde.', + 'error.property_not_found': 'No se encontró la propiedad con ID {{id}}.', + 'error.property_expired': 'Este listado de propiedad ha expirado y ya no está disponible.', + 'notification.property_expiry_warning': 'Advertencia de Expiración de Propiedad', + 'notification.property_expired': 'Propiedad Expirada', + 'notification.property_archived': 'Propiedad Archivada', + 'common.days_remaining': '{{count}} día(s) restantes', + 'common.expired': 'Expirado', + 'common.active': 'Activo', + 'common.renew': 'Renovar', + }, +}; diff --git a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts index 6fdea9b3..ef3c57f3 100644 --- a/src/mortgage-calculator/dto/mortgage-calculator.dto.ts +++ b/src/mortgage-calculator/dto/mortgage-calculator.dto.ts @@ -1,6 +1,7 @@ // @ts-nocheck -import { IsNumber, IsPositive, Min, Max } from 'class-validator'; +import { IsNumber, IsPositive, Min, Max, IsArray, IsOptional, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; export class MortgageCalculatorDto { @IsNumber() @@ -32,3 +33,87 @@ export class MortgageResultDto { totalPayment: number; totalInterest: number; } + +export class AmortizationScheduleDto { + @IsNumber() + @IsPositive() + propertyPrice: number; + + @IsNumber() + @Min(0) + @Max(100) + downPaymentPercent: number; + + @IsNumber() + @IsPositive() + @Max(100) + annualInterestRate: number; + + @IsNumber() + @IsPositive() + amortizationYears: number; + + @IsNumber() + @IsOptional() + @IsPositive() + annualPropertyTax?: number; + + @IsNumber() + @IsOptional() + @IsPositive() + annualInsurance?: number; +} + +export class AmortizationEntry { + month: number; + payment: number; + principal: number; + interest: number; + pmi: number; + propertyTax: number; + insurance: number; + totalPayment: number; + balance: number; +} + +export class MortgageScenarioDto { + @IsNumber() + @IsPositive() + propertyPrice: number; + + @IsNumber() + @Min(0) + @Max(100) + downPaymentPercent: number; + + @IsNumber() + @IsPositive() + @Max(100) + annualInterestRate: number; + + @IsNumber() + @IsPositive() + amortizationYears: number; + + @IsString() + @IsOptional() + label?: string; +} + +import { IsString } from 'class-validator'; + +export class CompareScenariosDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MortgageScenarioDto) + scenarios: MortgageScenarioDto[]; +} + +export class ExportAmortizationDto { + @IsArray() + schedule: AmortizationEntry[]; + + @IsString() + @IsOptional() + format?: 'csv' | 'text'; +} diff --git a/src/mortgage-calculator/mortgage-calculator.controller.ts b/src/mortgage-calculator/mortgage-calculator.controller.ts index f1d9486e..c107fefe 100644 --- a/src/mortgage-calculator/mortgage-calculator.controller.ts +++ b/src/mortgage-calculator/mortgage-calculator.controller.ts @@ -2,7 +2,12 @@ import { Body, Controller, Post } from '@nestjs/common'; import { MortgageCalculatorService } from './mortgage-calculator.service'; -import { MortgageCalculatorDto } from './dto/mortgage-calculator.dto'; +import { + MortgageCalculatorDto, + AmortizationScheduleDto, + CompareScenariosDto, + ExportAmortizationDto, +} from './dto/mortgage-calculator.dto'; @Controller('mortgage-calculator') export class MortgageCalculatorController { @@ -12,4 +17,23 @@ export class MortgageCalculatorController { calculate(@Body() dto: MortgageCalculatorDto) { return this.mortgageCalculatorService.calculate(dto); } + + @Post('amortization') + generateAmortization(@Body() dto: AmortizationScheduleDto) { + return this.mortgageCalculatorService.generateAmortizationSchedule(dto); + } + + @Post('compare') + compareScenarios(@Body() dto: CompareScenariosDto) { + return this.mortgageCalculatorService.compareScenarios(dto.scenarios); + } + + @Post('export') + exportAmortization(@Body() dto: ExportAmortizationDto) { + const csv = this.mortgageCalculatorService.exportAmortization( + dto.schedule, + dto.format || 'csv', + ); + return { data: csv, format: dto.format || 'csv' }; + } } diff --git a/src/mortgage-calculator/mortgage-calculator.service.ts b/src/mortgage-calculator/mortgage-calculator.service.ts index ad8f861f..b8281972 100644 --- a/src/mortgage-calculator/mortgage-calculator.service.ts +++ b/src/mortgage-calculator/mortgage-calculator.service.ts @@ -1,7 +1,14 @@ // @ts-nocheck import { Injectable } from '@nestjs/common'; -import { MortgageCalculatorDto, MortgageResultDto } from './dto/mortgage-calculator.dto'; +import { + MortgageCalculatorDto, + MortgageResultDto, + AmortizationScheduleDto, + AmortizationEntry, + MortgageScenarioDto, + ExportAmortizationDto, +} from './dto/mortgage-calculator.dto'; @Injectable() export class MortgageCalculatorService { @@ -36,6 +43,213 @@ export class MortgageCalculatorService { }; } + generateAmortizationSchedule(dto: AmortizationScheduleDto): { + schedule: AmortizationEntry[]; + summary: { + totalPayment: number; + totalInterest: number; + totalPMI: number; + totalPropertyTax: number; + totalInsurance: number; + monthlyPaymentBreakdown: { + principalAndInterest: number; + pmi: number; + propertyTax: number; + insurance: number; + total: number; + }; + }; + } { + const { + propertyPrice, + downPaymentPercent, + annualInterestRate, + amortizationYears, + annualPropertyTax = 0, + annualInsurance = 0, + } = dto; + + const downPayment = propertyPrice * (downPaymentPercent / 100); + const loanAmount = propertyPrice - downPayment; + const monthlyRate = annualInterestRate / 100 / 12; + const numPayments = amortizationYears * 12; + const needsPMI = downPaymentPercent < 20; + + const monthlyPI = + monthlyRate === 0 + ? loanAmount / numPayments + : (loanAmount * (monthlyRate * Math.pow(1 + monthlyRate, numPayments))) / + (Math.pow(1 + monthlyRate, numPayments) - 1); + + const monthlyPropertyTax = annualPropertyTax / 12; + const monthlyInsurance = annualInsurance / 12; + const pmiRate = 0.005; + const monthlyPMI = needsPMI ? (loanAmount * pmiRate) / 12 : 0; + + const schedule: AmortizationEntry[] = []; + let balance = loanAmount; + let totalInterest = 0; + let totalPMI = 0; + + for (let month = 1; month <= numPayments; month++) { + const interestPayment = balance * monthlyRate; + const principalPayment = monthlyPI - interestPayment; + const currentPMI = balance > loanAmount * 0.8 ? monthlyPMI : 0; + + balance = Math.max(0, balance - principalPayment); + totalInterest += interestPayment; + totalPMI += currentPMI; + + const totalPayment = this.round( + monthlyPI + currentPMI + monthlyPropertyTax + monthlyInsurance, + ); + + schedule.push({ + month, + payment: this.round(monthlyPI), + principal: this.round(principalPayment), + interest: this.round(interestPayment), + pmi: this.round(currentPMI), + propertyTax: this.round(monthlyPropertyTax), + insurance: this.round(monthlyInsurance), + totalPayment, + balance: this.round(balance), + }); + } + + const monthlyPaymentBreakdown = { + principalAndInterest: this.round(monthlyPI), + pmi: this.round(monthlyPMI), + propertyTax: this.round(monthlyPropertyTax), + insurance: this.round(monthlyInsurance), + total: this.round(monthlyPI + monthlyPMI + monthlyPropertyTax + monthlyInsurance), + }; + + return { + schedule, + summary: { + totalPayment: this.round( + monthlyPI * numPayments + totalPMI + monthlyPropertyTax * numPayments + monthlyInsurance * numPayments, + ), + totalInterest: this.round(totalInterest), + totalPMI: this.round(totalPMI), + totalPropertyTax: this.round(monthlyPropertyTax * numPayments), + totalInsurance: this.round(monthlyInsurance * numPayments), + monthlyPaymentBreakdown, + }, + }; + } + + compareScenarios(scenarios: MortgageScenarioDto[]) { + const results = scenarios.map((scenario, index) => { + const calculation = this.calculate(scenario); + const amortization = this.generateAmortizationSchedule(scenario); + + return { + index, + label: scenario.label || `Scenario ${index + 1}`, + ...calculation, + totalCost: amortization.summary.totalPayment, + monthlyBreakdown: amortization.summary.monthlyPaymentBreakdown, + }; + }); + + const sortedByPayment = [...results].sort((a, b) => a.monthlyPayment - b.monthlyPayment); + const sortedByTotal = [...results].sort((a, b) => a.totalCost - b.totalCost); + + return { + scenarios: results, + recommendation: { + lowestMonthly: sortedByPayment[0]?.label, + lowestTotalCost: sortedByTotal[0]?.label, + monthlySavings: this.round( + (sortedByPayment[sortedByPayment.length - 1]?.monthlyPayment || 0) - + (sortedByPayment[0]?.monthlyPayment || 0), + ), + totalSavings: this.round( + (sortedByTotal[sortedByTotal.length - 1]?.totalCost || 0) - + (sortedByTotal[0]?.totalCost || 0), + ), + }, + }; + } + + exportAmortization(schedule: AmortizationEntry[], format: 'csv' | 'text' = 'csv'): string { + if (format === 'csv') { + return this.exportAsCsv(schedule); + } + return this.exportAsText(schedule); + } + + private exportAsCsv(schedule: AmortizationEntry[]): string { + const headers = [ + 'Month', + 'Payment', + 'Principal', + 'Interest', + 'PMI', + 'Property Tax', + 'Insurance', + 'Total Payment', + 'Balance', + ]; + + const rows = schedule.map((entry) => + [ + entry.month, + entry.payment.toFixed(2), + entry.principal.toFixed(2), + entry.interest.toFixed(2), + entry.pmi.toFixed(2), + entry.propertyTax.toFixed(2), + entry.insurance.toFixed(2), + entry.totalPayment.toFixed(2), + entry.balance.toFixed(2), + ].join(','), + ); + + return [headers.join(','), ...rows].join('\n'); + } + + private exportAsText(schedule: AmortizationEntry[]): string { + const lines: string[] = []; + lines.push('AMORTIZATION SCHEDULE'); + lines.push('='.repeat(95)); + lines.push( + 'Month'.padEnd(8) + + 'Payment'.padEnd(12) + + 'Principal'.padEnd(12) + + 'Interest'.padEnd(12) + + 'PMI'.padEnd(10) + + 'Tax'.padEnd(10) + + 'Insurance'.padEnd(12) + + 'Balance'.padEnd(14), + ); + lines.push('-'.repeat(95)); + + for (const entry of schedule) { + lines.push( + String(entry.month).padEnd(8) + + entry.payment.toFixed(2).padEnd(12) + + entry.principal.toFixed(2).padEnd(12) + + entry.interest.toFixed(2).padEnd(12) + + entry.pmi.toFixed(2).padEnd(10) + + entry.propertyTax.toFixed(2).padEnd(10) + + entry.insurance.toFixed(2).padEnd(12) + + entry.balance.toFixed(2).padEnd(14), + ); + } + + const totalInterest = schedule.reduce((sum, e) => sum + e.interest, 0); + const totalPMI = schedule.reduce((sum, e) => sum + e.pmi, 0); + lines.push('-'.repeat(95)); + lines.push(`Total Interest: $${totalInterest.toFixed(2)}`); + lines.push(`Total PMI: $${totalPMI.toFixed(2)}`); + lines.push(`Final Balance: $${schedule[schedule.length - 1]?.balance.toFixed(2) || '0.00'}`); + + return lines.join('\n'); + } + private round(value: number): number { return Math.round(value * 100) / 100; } diff --git a/src/properties/properties.module.ts b/src/properties/properties.module.ts index d195f93a..5bf7b652 100644 --- a/src/properties/properties.module.ts +++ b/src/properties/properties.module.ts @@ -10,6 +10,7 @@ import { GeocodingService } from './geocoding.service'; 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'; @@ -17,12 +18,13 @@ import { PropertyReportService } from './report/property-report.service'; import { CacheModuleConfig } from '../cache/cache.module'; @Module({ - imports: [PrismaModule, AuthModule, FraudModule, ConfigModule, CacheModuleConfig], + imports: [PrismaModule, AuthModule, FraudModule, ConfigModule, CacheModuleConfig, NotificationsModule], controllers: [PropertiesController, PropertyImagesController], providers: [ PropertiesService, PropertyImagesService, GeocodingService, + PropertyExpiryService, PropertiesResolver, PropertyReportService, { @@ -30,6 +32,6 @@ import { CacheModuleConfig } from '../cache/cache.module'; useValue: new PubSub(), }, ], - exports: [PropertiesService, PropertyReportService, PropertyImagesService, GeocodingService], + exports: [PropertiesService, PropertyReportService, PropertyImagesService, GeocodingService, PropertyExpiryService], }) export class PropertiesModule {} diff --git a/src/properties/property-expiry.service.ts b/src/properties/property-expiry.service.ts index fb7e0e0f..f5878ad2 100644 --- a/src/properties/property-expiry.service.ts +++ b/src/properties/property-expiry.service.ts @@ -1,11 +1,14 @@ // @ts-nocheck -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { PropertiesService } from './properties.service'; import { NotificationsService } from '../notifications/notifications.service'; import { PropertyStatus } from '../types/prisma.types'; +const EXPIRY_WARNING_DAYS = [7, 3, 1]; +const GRACE_PERIOD_DAYS = 30; + @Injectable() export class PropertyExpiryService { private readonly logger = new Logger(PropertyExpiryService.name); @@ -15,89 +18,90 @@ export class PropertyExpiryService { private readonly notificationsService: NotificationsService, ) {} - /** - * Run daily at 2:00 AM to expire properties that have passed their expiry date - * and send notifications for properties expiring soon - */ @Cron(CronExpression.EVERY_DAY_AT_2AM) async handlePropertyExpiry() { this.logger.log('Running property expiry job...'); try { - // Send notifications for properties expiring in 7 days - await this.sendExpiryNotifications(); + await this.sendExpiryWarningNotifications(); - // Expire properties that have passed their expiry date const result = await this.propertiesService.expireProperties(); if (result.updatedCount > 0) { this.logger.log(`Successfully expired ${result.updatedCount} properties`); - // Send notifications for expired properties await this.sendExpiredNotifications(); } else { this.logger.log('No properties expired at this time'); } + + await this.archiveExpiredPropertiesPastGracePeriod(); } catch (error) { this.logger.error('Error during property expiry:', error); } } - /** - * Send notifications for properties expiring in 7 days - */ - private async sendExpiryNotifications() { - const sevenDaysFromNow = new Date(); - sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + private async sendExpiryWarningNotifications() { + for (const days of EXPIRY_WARNING_DAYS) { + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + days); + targetDate.setHours(0, 0, 0, 0); - const properties = await this.propertiesService.prisma.property.findMany({ - where: { - expiryDate: { - equals: sevenDaysFromNow, - }, - status: { - notIn: [ - PropertyStatus.SOLD, - PropertyStatus.RENTED, - PropertyStatus.ARCHIVED, - PropertyStatus.EXPIRED, - ], - }, - }, - include: { - owner: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, + const nextDay = new Date(targetDate); + nextDay.setDate(nextDay.getDate() + 1); + + const properties = await this.propertiesService.prisma.property.findMany({ + where: { + expiryDate: { + gte: targetDate, + lt: nextDay, + }, + status: { + notIn: [ + PropertyStatus.SOLD, + PropertyStatus.RENTED, + PropertyStatus.ARCHIVED, + PropertyStatus.EXPIRED, + ], }, }, - }, - }); - - await Promise.all( - properties.map((property) => { - const title = `Property Listing Expiring Soon`; - const message = `Your property "${property.title}" is scheduled to expire in 7 days. Consider renewing it to keep it active.`; - - return this.notificationsService.sendNotification( - property.ownerId, - title, - message, - 'PROPERTY_EXPIRY_WARNING', - { - propertyId: property.id, - propertyTitle: property.title, - expiryDate: property.expiryDate, + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, }, - ); - }), - ); + }, + }); + + await Promise.all( + properties.map((property) => { + const title = `Property Listing Expiring Soon`; + const message = `Your property "${property.title}" is scheduled to expire in ${days} day${days > 1 ? 's' : ''}. Consider renewing it to keep it active.`; + + return this.notificationsService.sendNotification( + property.ownerId, + title, + message, + 'PROPERTY_EXPIRY_WARNING', + { + propertyId: property.id, + propertyTitle: property.title, + expiryDate: property.expiryDate, + daysUntilExpiry: days, + }, + ); + }), + ); + + if (properties.length > 0) { + this.logger.log(`Sent ${properties.length} expiry warnings for ${days}-day threshold`); + } + } } - /** - * Send notifications for properties that have been expired - */ private async sendExpiredNotifications() { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); @@ -141,9 +145,98 @@ export class PropertyExpiryService { ); } - /** - * Manual trigger for testing or immediate expiry - */ + private async archiveExpiredPropertiesPastGracePeriod() { + const graceCutoff = new Date(); + graceCutoff.setDate(graceCutoff.getDate() - GRACE_PERIOD_DAYS); + + const result = await this.propertiesService.prisma.property.updateMany({ + where: { + status: PropertyStatus.EXPIRED, + updatedAt: { + lt: graceCutoff, + }, + }, + data: { + status: PropertyStatus.ARCHIVED, + }, + }); + + if (result.count > 0) { + this.logger.log(`Archived ${result.count} properties past ${GRACE_PERIOD_DAYS}-day grace period`); + + const archivedProps = await this.propertiesService.prisma.property.findMany({ + where: { + status: PropertyStatus.ARCHIVED, + updatedAt: { + lt: graceCutoff, + }, + }, + include: { + owner: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + }, + }); + + await Promise.all( + archivedProps.map((property) => { + const title = `Property Listing Archived`; + const message = `Your property "${property.title}" has been archived after the ${GRACE_PERIOD_DAYS}-day grace period following expiry.`; + + return this.notificationsService.sendNotification( + property.ownerId, + title, + message, + 'PROPERTY_ARCHIVED', + { + propertyId: property.id, + propertyTitle: property.title, + expiryDate: property.expiryDate, + }, + ); + }), + ); + } + } + + async renewProperty(id: string, userId: string, days: number) { + const property = await this.propertiesService.prisma.property.findUnique({ + where: { id }, + }); + + if (!property) { + throw new NotFoundException(`Property with ID ${id} not found`); + } + + if (property.ownerId !== userId) { + throw new ForbiddenException('You can only renew your own properties'); + } + + const newExpiryDate = new Date(); + newExpiryDate.setDate(newExpiryDate.getDate() + days); + + const updated = await this.propertiesService.prisma.property.update({ + where: { id }, + data: { + expiryDate: newExpiryDate, + status: PropertyStatus.ACTIVE, + }, + }); + + this.logger.log(`Property ${id} renewed for ${days} days. New expiry: ${newExpiryDate.toISOString()}`); + + return { + property: updated, + newExpiryDate, + renewedDays: days, + }; + } + async triggerManualExpiry() { this.logger.log('Manual property expiry triggered'); return this.propertiesService.expireProperties(); diff --git a/src/property-comparison/property-comparison.controller.ts b/src/property-comparison/property-comparison.controller.ts index c1e1a527..edf558d0 100644 --- a/src/property-comparison/property-comparison.controller.ts +++ b/src/property-comparison/property-comparison.controller.ts @@ -1,6 +1,6 @@ // @ts-nocheck -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { PropertyComparisonService } from './property-comparison.service'; import { CompareBodyDto, CompareQueryDto } from './dto/comparison.dto'; @@ -8,21 +8,33 @@ import { CompareBodyDto, CompareQueryDto } from './dto/comparison.dto'; export class PropertyComparisonController { constructor(private readonly comparisonService: PropertyComparisonService) {} - /** - * Compare 2-4 properties via query string: - * GET /property-comparison?ids=uuid1,uuid2,uuid3 - */ @Get() compareGet(@Query() query: CompareQueryDto) { return this.comparisonService.compare(query.ids); } - /** - * Compare 2-4 properties via JSON body: - * POST /property-comparison { "ids": ["uuid1", "uuid2", ...] } - */ @Post() comparePost(@Body() body: CompareBodyDto) { return this.comparisonService.compare(body.ids); } + + @Post('score') + calculateScore(@Body() body: { properties: any[] }) { + return this.comparisonService.calculateScore(body.properties); + } + + @Post('share') + createShareableLink(@Body() body: { propertyIds: string[]; userId?: string }) { + return this.comparisonService.createShareableLink(body.propertyIds, body.userId); + } + + @Get('shared/:shareToken') + getSharedComparison(@Param('shareToken') shareToken: string) { + return this.comparisonService.getSharedComparison(shareToken); + } + + @Post('export') + exportComparison(@Body() body: { propertyIds: string[] }) { + return this.comparisonService.exportComparison(body.propertyIds); + } } diff --git a/src/property-comparison/property-comparison.service.ts b/src/property-comparison/property-comparison.service.ts index 92f39bde..030ab523 100644 --- a/src/property-comparison/property-comparison.service.ts +++ b/src/property-comparison/property-comparison.service.ts @@ -3,8 +3,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../database/prisma.service'; +import { v4 as uuidv4 } from 'uuid'; -/** Fields included in the side-by-side comparison view. */ const COMPARABLE_FIELDS = [ 'title', 'address', @@ -27,7 +27,6 @@ const COMPARABLE_FIELDS = [ type ComparableField = (typeof COMPARABLE_FIELDS)[number]; -/** Numeric fields used to compute min/max highlights. */ const NUMERIC_FIELDS: ReadonlySet = new Set([ 'price', 'bedrooms', @@ -37,13 +36,20 @@ const NUMERIC_FIELDS: ReadonlySet = new Set([ 'yearBuilt', ]); +const SCORE_WEIGHTS = { + pricePerSqft: 0.30, + locationScore: 0.25, + condition: 0.25, + age: 0.20, +}; + export interface FieldRow { field: ComparableField; values: unknown[]; allEqual: boolean; min?: number | null; max?: number | null; - bestIndex?: number | null; // index of property with min price / largest area, etc. + bestIndex?: number | null; worstIndex?: number | null; } @@ -51,9 +57,6 @@ export interface FieldRow { export class PropertyComparisonService { constructor(private readonly prisma: PrismaService) {} - /** - * Compare 2-4 properties side-by-side and highlight differing fields. - */ async compare(ids: string[]) { const properties = await this.prisma.property.findMany({ where: { id: { in: ids } }, @@ -69,14 +72,12 @@ export class PropertyComparisonService { }, }); - // Validate all requested IDs exist. if (properties.length !== ids.length) { const found = new Set(properties.map((p) => p.id)); const missing = ids.filter((id) => !found.has(id)); throw new NotFoundException(`Properties not found: ${missing.join(', ')}`); } - // Preserve the order requested by the caller. const ordered = ids.map((id) => properties.find((p) => p.id === id)!); const comparison: FieldRow[] = COMPARABLE_FIELDS.map((field) => @@ -95,6 +96,174 @@ export class PropertyComparisonService { }; } + calculateScore(properties: any[]) { + const currentYear = new Date().getFullYear(); + + const scores = properties.map((property) => { + const price = this.normalize(property.price) as number || 0; + const sqft = this.normalize(property.squareFeet) as number || 0; + const pricePerSqft = sqft > 0 ? price / sqft : 0; + + const yearBuilt = property.yearBuilt || currentYear; + const age = currentYear - yearBuilt; + + let locationScore = 50; + if (property.latitude && property.longitude) { + locationScore = this.calculateLocationScore( + property.latitude, + property.longitude, + ); + } + + let conditionScore = 50; + if (property.features && Array.isArray(property.features)) { + const featureCount = property.features.length; + conditionScore = Math.min(100, 30 + featureCount * 5); + } + + const normalizedPricePerSqft = pricePerSqft > 0 + ? Math.max(0, 100 - (pricePerSqft / 10)) + : 50; + const normalizedAge = Math.max(0, 100 - age); + const normalizedCondition = conditionScore; + + const weightedScore = + normalizedPricePerSqft * SCORE_WEIGHTS.pricePerSqft + + locationScore * SCORE_WEIGHTS.locationScore + + normalizedCondition * SCORE_WEIGHTS.condition + + normalizedAge * SCORE_WEIGHTS.age; + + return { + propertyId: property.id, + title: property.title, + pricePerSqft: Math.round(pricePerSqft * 100) / 100, + locationScore: Math.round(locationScore * 100) / 100, + conditionScore: Math.round(normalizedCondition * 100) / 100, + age, + weightedScore: Math.round(weightedScore * 100) / 100, + }; + }); + + const sorted = [...scores].sort((a, b) => b.weightedScore - a.weightedScore); + + return { + scores: sorted, + best: sorted[0] || null, + weights: SCORE_WEIGHTS, + }; + } + + async createShareableLink(propertyIds: string[], createdById?: string) { + const properties = await this.prisma.property.findMany({ + where: { id: { in: propertyIds } }, + select: { id: true }, + }); + + if (properties.length !== propertyIds.length) { + const found = new Set(properties.map((p) => p.id)); + const missing = propertyIds.filter((id) => !found.has(id)); + throw new NotFoundException(`Properties not found: ${missing.join(', ')}`); + } + + const shareToken = uuidv4(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); + + const share = await this.prisma.comparisonShare.create({ + data: { + shareToken, + propertyIds, + createdById: createdById || null, + expiresAt, + }, + }); + + return { + shareToken: share.shareToken, + propertyIds: share.propertyIds, + expiresAt: share.expiresAt, + url: `/property-comparison/shared/${share.shareToken}`, + }; + } + + async getSharedComparison(shareToken: string) { + const share = await this.prisma.comparisonShare.findUnique({ + where: { shareToken }, + }); + + if (!share) { + throw new NotFoundException('Shared comparison not found'); + } + + if (share.expiresAt && share.expiresAt < new Date()) { + throw new NotFoundException('This shared comparison link has expired'); + } + + const comparison = await this.compare(share.propertyIds); + + return { + shareToken: share.shareToken, + createdAt: share.createdAt, + expiresAt: share.expiresAt, + ...comparison, + }; + } + + async exportComparison(propertyIds: string[]) { + const result = await this.compare(propertyIds); + const scoreResult = this.calculateScore(result.properties); + + const exportData = { + title: 'Property Comparison Report', + generatedAt: new Date().toISOString(), + propertyCount: result.count, + properties: result.properties.map((p) => ({ + id: p.id, + title: p.title, + address: `${p.address}, ${p.city}, ${p.state} ${p.zipCode}`, + price: this.normalize(p.price), + propertyType: p.propertyType, + bedrooms: p.bedrooms, + bathrooms: this.normalize(p.bathrooms), + squareFeet: this.normalize(p.squareFeet), + lotSize: this.normalize(p.lotSize), + yearBuilt: p.yearBuilt, + status: p.status, + features: p.features, + })), + comparison: { + differingFields: result.differingFields, + commonFields: result.commonFields, + }, + scores: scoreResult.scores, + summary: { + bestValue: scoreResult.best, + averagePrice: this.average( + result.properties.map((p) => this.normalize(p.price) as number).filter((v) => v > 0), + ), + averageSqft: this.average( + result.properties.map((p) => this.normalize(p.squareFeet) as number).filter((v) => v > 0), + ), + }, + }; + + return exportData; + } + + private calculateLocationScore(lat: number, lng: number): number { + const urbanCenterLat = 40.7128; + const urbanCenterLng = -74.006; + const distance = Math.sqrt( + Math.pow(lat - urbanCenterLat, 2) + Math.pow(lng - urbanCenterLng, 2), + ); + return Math.max(0, Math.min(100, 100 - distance * 10)); + } + + private average(values: number[]): number { + if (values.length === 0) return 0; + return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; + } + private buildFieldRow( field: ComparableField, properties: Array>, @@ -122,7 +291,6 @@ export class PropertyComparisonService { row.min = minEntry.v; row.max = maxEntry.v; - // For price → lowest is "best". For everything else higher is better. if (field === 'price') { row.bestIndex = minEntry.i; row.worstIndex = maxEntry.i; @@ -141,7 +309,6 @@ export class PropertyComparisonService { return row; } - /** Convert Prisma `Decimal` to number; sort feature arrays for stable comparison. */ private normalize(value: unknown): unknown { if (value === null || value === undefined) { return null; diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 55065167..97e23962 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -78,6 +78,7 @@ export enum PropertyStatus { SOLD = 'SOLD', RENTED = 'RENTED', ARCHIVED = 'ARCHIVED', + EXPIRED = 'EXPIRED', } export enum TransactionType {