File

apps/recallassess/recallassess-api/src/api/shared/email/services/enhanced-email.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService, unsubscribeTokenService: UnsubscribeTokenService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No
unsubscribeTokenService UnsubscribeTokenService No

Methods

Private calculateAverageQuotient
calculateAverageQuotient(modules: Array<unknown>)

Calculate average quotient from module scores

Parameters :
Name Type Optional
modules Array<unknown> No
Returns : number
Private Async getBatPersonalization
getBatPersonalization(participantId: number)

Get BAT-based personalization variables

Parameters :
Name Type Optional
participantId number No
Returns : Promise<Record<string, string>>
Private getLevelPriority
getLevelPriority(level: string)

Get level priority for comparison

Parameters :
Name Type Optional
level string No
Returns : number
Async sendPersonalizedEmail
sendPersonalizedEmail(options: literal type)

Send personalized email based on BAT scores

Parameters :
Name Type Optional
options literal type No
Returns : any
Async trackEmailClick
trackEmailClick(emailLogId: number, linkUrl: string, metadata?: literal type)

Track email click

Parameters :
Name Type Optional
emailLogId number No
linkUrl string No
metadata literal type Yes
Returns : any
Async trackEmailOpen
trackEmailOpen(emailLogId: number, metadata?: literal type)

Track email open

Parameters :
Name Type Optional
emailLogId number No
metadata literal type Yes
Returns : any

Properties

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
    }
  }
}

results matching ""

    No results matching ""