apps/recallassess/recallassess-api/src/api/shared/email/services/enhanced-email.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService, unsubscribeTokenService: UnsubscribeTokenService)
|
||||||||||||
|
Parameters :
|
| Private calculateAverageQuotient | ||||||
calculateAverageQuotient(modules: Array<unknown>)
|
||||||
|
Calculate average quotient from module scores
Parameters :
Returns :
number
|
| Private Async getBatPersonalization | ||||||
getBatPersonalization(participantId: number)
|
||||||
|
Get BAT-based personalization variables
Parameters :
Returns :
Promise<Record<string, string>>
|
| Private getLevelPriority | ||||||
getLevelPriority(level: string)
|
||||||
|
Get level priority for comparison
Parameters :
Returns :
number
|
| Async sendPersonalizedEmail | ||||||
sendPersonalizedEmail(options: literal type)
|
||||||
|
Send personalized email based on BAT scores
Parameters :
Returns :
any
|
| Async trackEmailClick |
trackEmailClick(emailLogId: number, linkUrl: string, metadata?: literal type)
|
|
Track email click
Returns :
any
|
| Async trackEmailOpen | |||||||||
trackEmailOpen(emailLogId: number, metadata?: literal type)
|
|||||||||
|
Track email open
Parameters :
Returns :
any
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(EnhancedEmailService.name)
|
import { resolveFrontendUrl } from "@api/shared/email/resolve-frontend-url.util";
import { BNestEmailSenderService, optionalEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, Logger } from "@nestjs/common";
import { AssessmentType } from "@prisma/client";
import { UnsubscribeTokenService } from "./unsubscribe-token.service";
@Injectable()
export class EnhancedEmailService {
private readonly logger = new Logger(EnhancedEmailService.name);
constructor(
private prisma: BNestPrismaService,
private emailSender: BNestEmailSenderService,
private unsubscribeTokenService: UnsubscribeTokenService,
) {}
/**
* Send personalized email based on BAT scores
*/
async sendPersonalizedEmail(options: {
participantId: number;
templateKey: string;
variables?: Record<string, string>;
personalizeByBat?: boolean;
}) {
const { participantId, templateKey, variables = {}, personalizeByBat = true } = options;
// Get participant data
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
include: {
company: {
select: {
name: true,
id: true,
},
},
},
});
if (!participant) {
throw new Error(`Participant not found: ${participantId}`);
}
// Build base variables
const emailVariables: Record<string, string> = {
"user.name": `${participant.first_name} ${participant.last_name}`,
"user.email": participant.email,
"user.first_name": participant.first_name,
"user.last_name": participant.last_name,
"company.name": participant.company?.name || "",
...variables,
};
// Personalize based on BAT scores if requested
if (personalizeByBat) {
const batPersonalization = await this.getBatPersonalization(participantId);
Object.assign(emailVariables, batPersonalization);
}
// Get or generate unsubscribe token and URL
const unsubscribeToken = await this.unsubscribeTokenService.getOrGenerateToken(participantId);
const frontendUrl = resolveFrontendUrl();
const unsubscribeUrl = `${frontendUrl}/unsubscribe?token=${unsubscribeToken}`;
const unsub = optionalEnv("UNSUBSCRIBE_EMAIL", "");
const fromSes = optionalEnv("AWS_SES_FROM_EMAIL", "");
const unsubscribeEmail = unsub !== "" ? unsub : fromSes !== "" ? fromSes : "unsubscribe@recallsolutions.ai";
// Add unsubscribe URL to variables
emailVariables["system.unsubscribeUrl"] = unsubscribeUrl;
emailVariables["system.managePreferencesUrl"] = `${frontendUrl}/portal/settings/notifications`;
// Send email
await this.emailSender.sendTemplatedEmail({
to: participant.email,
templateKey,
variables: emailVariables,
metadata: {
participant_id: participantId,
company_id: participant.company_id,
personalized: personalizeByBat,
},
unsubscribeUrl,
unsubscribeEmail,
});
this.logger.log(`Sent personalized email to participant ${participantId}: ${templateKey}`);
}
/**
* Get BAT-based personalization variables
*/
private async getBatPersonalization(participantId: number): Promise<Record<string, string>> {
// Get latest PRE BAT assessment
const preBat = await this.prisma.client.assessmentParticipant.findFirst({
where: {
participant_id: participantId,
assessment_type: AssessmentType.PRE_BAT,
},
include: {
assessmentResults: {
include: {
courseModule: {
select: {
title: true,
course_module_code: true,
},
},
},
},
},
orderBy: {
assessment_completion_date: "desc",
},
});
if (!preBat || !preBat.assessmentResults.length) {
return {
"user.bat_level": "Not Assessed",
"user.strongest_area": "N/A",
"user.improvement_area": "N/A",
};
}
// Analyze BAT results
const moduleScores: Record<string, { level: string; count: number }> = {};
preBat.assessmentResults.forEach((result) => {
const moduleCode = result.courseModule?.course_module_code || "unknown";
const level = result.grade_level || "INTERMEDIATE";
if (!moduleScores[moduleCode]) {
moduleScores[moduleCode] = { level: "", count: 0 };
}
moduleScores[moduleCode].count++;
// Use highest level for module
if (this.getLevelPriority(level) > this.getLevelPriority(moduleScores[moduleCode].level)) {
moduleScores[moduleCode].level = level;
}
});
// Find strongest and weakest areas
const modules = Object.entries(moduleScores);
const strongest = modules.reduce((a, b) =>
this.getLevelPriority(a[1].level) > this.getLevelPriority(b[1].level) ? a : b,
);
const weakest = modules.reduce((a, b) =>
this.getLevelPriority(a[1].level) < this.getLevelPriority(b[1].level) ? a : b,
);
// Calculate overall level
const avgQuotient = preBat.individual_quotient
? Number(preBat.individual_quotient)
: this.calculateAverageQuotient(modules);
let overallLevel = "INTERMEDIATE";
if (avgQuotient >= 3.5) {
overallLevel = "EXPERT";
} else if (avgQuotient >= 2.5) {
overallLevel = "ADVANCED";
} else if (avgQuotient >= 1.5) {
overallLevel = "INTERMEDIATE";
} else {
overallLevel = "FOUNDATION";
}
return {
"user.bat_level": overallLevel,
"user.bat_score": avgQuotient.toFixed(1),
"user.strongest_area": strongest[0] || "N/A",
"user.improvement_area": weakest[0] || "N/A",
"user.bat_completion_date": preBat.assessment_completion_date?.toISOString().split("T")[0] || "N/A",
};
}
/**
* Get level priority for comparison
*/
private getLevelPriority(level: string): number {
const priorities: Record<string, number> = {
EXPERT: 4,
ADVANCED: 3,
INTERMEDIATE: 2,
FOUNDATION: 1,
};
return priorities[level] || 2;
}
/**
* Calculate average quotient from module scores
*/
private calculateAverageQuotient(modules: Array<[string, { level: string; count: number }]>): number {
if (modules.length === 0) return 2.0;
const totalPriority = modules.reduce((sum, [, data]) => sum + this.getLevelPriority(data.level), 0);
return totalPriority / modules.length;
}
/**
* Track email open
*/
async trackEmailOpen(emailLogId: number, metadata?: { userAgent?: string; ipAddress?: string }) {
// Check if analytics entry already exists
const existing = await this.prisma.client.emailLog.findUnique({
where: { id: emailLogId },
});
if (!existing) {
this.logger.warn(`Email log not found: ${emailLogId}`);
return;
}
// Track email open in email_analytics table
try {
const emailLog = await this.prisma.client.emailLog.findUnique({
where: { id: emailLogId },
select: { participant_id: true },
});
// Detect device type from user agent
const userAgent = metadata?.userAgent || "";
let deviceType: string | null = null;
if (userAgent) {
if (/mobile|android|iphone|ipad/i.test(userAgent)) {
deviceType = "mobile";
} else if (/tablet|ipad/i.test(userAgent)) {
deviceType = "tablet";
} else {
deviceType = "desktop";
}
}
// Detect email client from user agent
let emailClient: string | null = null;
if (userAgent) {
if (/gmail/i.test(userAgent)) emailClient = "Gmail";
else if (/outlook|microsoft/i.test(userAgent)) emailClient = "Outlook";
else if (/yahoo/i.test(userAgent)) emailClient = "Yahoo";
else if (/apple.*mail/i.test(userAgent)) emailClient = "Apple Mail";
}
// Create analytics record
await (this.prisma.client as any).emailAnalytics.create({
data: {
email_log_id: emailLogId,
participant_id: emailLog?.participant_id || null,
event_type: "open",
user_agent: userAgent || null,
ip_address: metadata?.ipAddress || null,
device_type: deviceType,
email_client: emailClient,
metadata: metadata || {},
},
});
// Update email log with first open timestamp and increment count
const currentLog = await this.prisma.client.emailLog.findUnique({
where: { id: emailLogId },
});
const currentOpenedCount = (currentLog as any)?.opened_count || 0;
const currentOpenedAt = (currentLog as any)?.opened_at;
const now = new Date();
const isFirstOpen = !currentOpenedAt;
// Calculate IP addresses array
const existingOpenedIps = (currentLog as any)?.opened_ip_addresses || [];
const ipAddress = metadata?.ipAddress;
const updatedOpenedIps = [...existingOpenedIps];
if (ipAddress && !existingOpenedIps.includes(ipAddress)) {
updatedOpenedIps.push(ipAddress);
}
// Calculate time to open in minutes
let timeToOpenMinutes: number | null = null;
if (isFirstOpen && (currentLog as any)?.sent_date) {
const sentDate = new Date((currentLog as any).sent_date);
const diffMinutes = Math.floor((now.getTime() - sentDate.getTime()) / (1000 * 60));
timeToOpenMinutes = diffMinutes > 0 ? diffMinutes : null;
}
await this.prisma.client.emailLog.update({
where: { id: emailLogId },
data: {
opened_at: currentOpenedAt || now,
opened_count: currentOpenedCount + 1,
last_opened_at: now,
opened_ip_addresses: updatedOpenedIps.length > 0 ? updatedOpenedIps : null,
first_opened_ip: isFirstOpen ? ipAddress || null : (currentLog as any)?.first_opened_ip || null,
device_type: isFirstOpen ? deviceType : (currentLog as any)?.device_type || deviceType,
email_client: isFirstOpen ? emailClient : (currentLog as any)?.email_client || emailClient,
user_agent: isFirstOpen ? userAgent : (currentLog as any)?.user_agent || userAgent,
time_to_open_minutes: timeToOpenMinutes || (currentLog as any)?.time_to_open_minutes || null,
} as any,
});
this.logger.debug(`Tracked email open for email_log ${emailLogId}`);
} catch (error) {
this.logger.error(`Failed to track email open:`, error);
// Don't throw - tracking failure shouldn't break the app
}
}
/**
* Track email click
*/
async trackEmailClick(
emailLogId: number,
linkUrl: string,
metadata?: { userAgent?: string; ipAddress?: string },
) {
// Track email click in email_analytics table
try {
const emailLog = await this.prisma.client.emailLog.findUnique({
where: { id: emailLogId },
select: { participant_id: true },
});
// Detect device type from user agent
const userAgent = metadata?.userAgent || "";
let deviceType: string | null = null;
if (userAgent) {
if (/mobile|android|iphone|ipad/i.test(userAgent)) {
deviceType = "mobile";
} else if (/tablet|ipad/i.test(userAgent)) {
deviceType = "tablet";
} else {
deviceType = "desktop";
}
}
// Create analytics record
await (this.prisma.client as any).emailAnalytics.create({
data: {
email_log_id: emailLogId,
participant_id: emailLog?.participant_id || null,
event_type: "click",
link_url: linkUrl,
user_agent: userAgent || null,
ip_address: metadata?.ipAddress || null,
device_type: deviceType,
metadata: metadata || {},
},
});
// Update email log with first click timestamp and increment count
const currentLog = await this.prisma.client.emailLog.findUnique({
where: { id: emailLogId },
});
const currentClickedCount = (currentLog as any)?.clicked_count || 0;
const currentClickedAt = (currentLog as any)?.clicked_at;
const now = new Date();
const isFirstClick = !currentClickedAt;
// Calculate IP addresses array
const existingClickedIps = (currentLog as any)?.clicked_ip_addresses || [];
const ipAddress = metadata?.ipAddress;
const updatedClickedIps = [...existingClickedIps];
if (ipAddress && !existingClickedIps.includes(ipAddress)) {
updatedClickedIps.push(ipAddress);
}
// Calculate time to click in minutes
let timeToClickMinutes: number | null = null;
if (isFirstClick && (currentLog as any)?.sent_date) {
const sentDate = new Date((currentLog as any).sent_date);
const diffMinutes = Math.floor((now.getTime() - sentDate.getTime()) / (1000 * 60));
timeToClickMinutes = diffMinutes > 0 ? diffMinutes : null;
}
await this.prisma.client.emailLog.update({
where: { id: emailLogId },
data: {
clicked_at: currentClickedAt || now,
clicked_count: currentClickedCount + 1,
last_clicked_at: now,
clicked_ip_addresses: updatedClickedIps.length > 0 ? updatedClickedIps : null,
first_clicked_ip: isFirstClick ? ipAddress || null : (currentLog as any)?.first_clicked_ip || null,
time_to_click_minutes: timeToClickMinutes || (currentLog as any)?.time_to_click_minutes || null,
} as any,
});
this.logger.debug(`Tracked email click for email_log ${emailLogId}: ${linkUrl}`);
} catch (error) {
this.logger.error(`Failed to track email click:`, error);
// Don't throw - tracking failure shouldn't break the app
}
}
}