Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ model User {
transactionNotes TransactionNote[] @relation("TransactionNoteAuthor")
deletedProperties Property[] @relation("DeletedProperties")
priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor")
comparisonShares ComparisonShare[]

@@index([email])
@@index([isDeactivated])
Expand Down Expand Up @@ -1317,6 +1318,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
Expand Down
2 changes: 2 additions & 0 deletions src/email/email.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,6 +17,7 @@ import { EmailProcessor } from './email.processor';
imports: [
PrismaModule,
TrackingModule,
I18nModule,
BullModule.registerQueue({
name: 'mail',
defaultJobOptions: {
Expand Down
1 change: 1 addition & 0 deletions src/email/email.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('EmailService.handleBounce', () => {
prisma,
{ createEmailEngagement: jest.fn() } as any,
{ add: jest.fn() } as any,
{ translate: jest.fn().mockReturnValue('test') } as any,
);

await service.handleBounce('test@example.com', 'HARD', 'Mailbox disabled', {
Expand Down
36 changes: 36 additions & 0 deletions src/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,6 +18,7 @@ export interface EmailOptions {
emailType?: string;
template?: string;
context?: any;
language?: string;
}

export interface FraudAlertEmailPayload {
Expand Down Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -165,6 +168,15 @@ export class EmailService {
const baseUrl = this.configService.get<string>('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 } });
Expand Down Expand Up @@ -224,4 +236,28 @@ export class EmailService {
throw error;
}
}

async sendLocalizedEmail(
to: string,
templateKey: string,
userId: string,
params?: Record<string, string | number>,
): Promise<void> {
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,
});
}
}
10 changes: 10 additions & 0 deletions src/i18n/i18n.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @ts-nocheck

import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';

@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}
46 changes: 46 additions & 0 deletions src/i18n/i18n.service.ts
Original file line number Diff line number Diff line change
@@ -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, string | number>): 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<string, string | number>): {
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'];
}
}
70 changes: 70 additions & 0 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @ts-nocheck

export type SupportedLanguage = 'en' | 'es';

export const translations: Record<SupportedLanguage, Record<string, string>> = {
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',
},
};
87 changes: 86 additions & 1 deletion src/mortgage-calculator/dto/mortgage-calculator.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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';
}
26 changes: 25 additions & 1 deletion src/mortgage-calculator/mortgage-calculator.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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' };
}
}
Loading
Loading