File

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

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, bnestEmailLogService: BNestEmailLogService, dynamicMailFieldsService: DynamicMailFieldsService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
bnestEmailLogService BNestEmailLogService No
dynamicMailFieldsService DynamicMailFieldsService No

Methods

Private Async createPostBatInviteEmailLogAsOriginal
createPostBatInviteEmailLogAsOriginal(lgp: literal type, scheduledDate: Date)
Parameters :
Name Type Optional
lgp literal type No
scheduledDate Date No
Returns : Promise<number | null>
Private Async getExistingPostBatInviteEmailLogId
getExistingPostBatInviteEmailLogId(participantId: number, learningGroupParticipantId: number)
Parameters :
Name Type Optional
participantId number No
learningGroupParticipantId number No
Returns : Promise<number | null>
Async sendPostBatInviteForLearningGroupParticipant
sendPostBatInviteForLearningGroupParticipant(_learningGroupParticipantId: number)
Parameters :
Name Type Optional
_learningGroupParticipantId number No
Returns : Promise<Date | null>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(EmailReminderService.name)
import { DynamicMailFieldsService } from "@api/shared/dynamic-mail-fields/dynamic-mail-fields.service";
import { renderEmailTemplate } from "@api/shared/email/render-email-template.util";
import { requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { BNestEmailLogService } from "@bish-nest/email-log";
import { Injectable, Logger } from "@nestjs/common";
import { EMAIL_REMINDER_CONFIG } from "../../../../config/email-reminder.config";
import { getEmailSkeleton } from "../../../../templates/email/email-skeleton.template";

@Injectable()
export class EmailReminderService {
  private readonly logger = new Logger(EmailReminderService.name);

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly bnestEmailLogService: BNestEmailLogService,
    private readonly dynamicMailFieldsService: DynamicMailFieldsService,
  ) {}

  async sendPostBatInviteForLearningGroupParticipant(_learningGroupParticipantId: number): Promise<Date | null> {
    const lgp = await this.prisma.client.learningGroupParticipant.findUnique({
      where: { id: _learningGroupParticipantId },
      include: {
        participant: { include: { company: true } },
        course: { select: { id: true, title: true } },
      },
    });

    if (!lgp) return null;

    // Find existing email log (any status) to use as "original" for copy+send
    const originalLogId = await this.getExistingPostBatInviteEmailLogId(lgp.participant_id, lgp.id);

    let logIdToResend: number;

    if (originalLogId) {
      // Resend = create copy of original and send; original record is not updated
      logIdToResend = originalLogId;
      this.logger.debug(
        `Post invite send: resending from existing log ${originalLogId} (copy + send, no update to original)`,
      );
    } else {
      // No original: create one with merged content (scheduled far future so scheduler never sends it), then resend it
      const newLogId = await this.createPostBatInviteEmailLogAsOriginal(
        lgp as any,
        new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000),
      );
      if (!newLogId) {
        this.logger.warn(
          `Failed to create POST BAT invite email log for participant ${lgp.participant_id}, enrollment ${lgp.id}`,
        );
        return null;
      }
      logIdToResend = newLogId;
      this.logger.debug(`Post invite send: created original log ${newLogId}, resending (copy + send)`);
    }

    await this.bnestEmailLogService.resendEmail(logIdToResend);

    const effectiveDate =
      lgp.post_bat_email_date && lgp.post_bat_email_date <= new Date() ? lgp.post_bat_email_date : new Date();

    await this.prisma.client.learningGroupParticipant.update({
      where: { id: _learningGroupParticipantId },
      data: { post_bat_email_date: effectiveDate },
    });

    return effectiveDate;
  }

  private async getExistingPostBatInviteEmailLogId(
    participantId: number,
    learningGroupParticipantId: number,
  ): Promise<number | null> {
    const template = await this.prisma.client.emailTemplate.findFirst({
      where: {
        template_key: EMAIL_REMINDER_CONFIG.assessments.postBat.available.templateKey,
      },
    });
    if (!template) return null;

    const log = await this.prisma.client.emailLog.findFirst({
      where: {
        participant_id: participantId,
        learning_group_participant_id: learningGroupParticipantId,
        email_template_id: template.id,
      },
      orderBy: { created_at: "desc" },
      select: { id: true },
    });

    return log?.id ?? null;
  }

  private async createPostBatInviteEmailLogAsOriginal(
    lgp: {
      participant: {
        id: number;
        email: string;
        first_name: string;
        last_name: string;
        company_id: number | null;
        company?: { name: string | null } | null;
      };
      course: { id: number; title: string } | null;
      learning_group_id: number;
      id: number;
    },
    scheduledDate: Date,
  ): Promise<number | null> {
    const participant = lgp.participant;
    const course = lgp.course;

    if (!course) return null;

    const template = await this.prisma.client.emailTemplate.findFirst({
      where: {
        template_key: EMAIL_REMINDER_CONFIG.assessments.postBat.available.templateKey,
        is_active: true,
      },
    });

    if (!template) return null;

    const variables: Record<string, string> = {};
    variables["user.name"] = participant.first_name;
    variables["user.email"] = participant.email;
    variables["course.name"] = course.title;
    variables["course.id"] = course.id.toString();

    const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
      companyNameFallback: participant.company?.name ?? undefined,
    });
    Object.assign(variables, dynamicMailVars);

    const frontendUrl = requireEnv("FRONTEND_URL");

    // Enrollment-specific URLs. Without lgp.id, links would land on the dashboard with
    // no course context. The frontend routes are /portal/my-courses/:id/{pre-bat,post-bat}.
    const enrollmentId = lgp.id;
    variables["system.myCourseUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}`;
    variables["system.myCoursesUrl"] = `${frontendUrl}/portal/my-courses`;
    variables["system.preBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/pre-bat`;
    variables["system.postBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
    // system.assessmentUrl is legacy — older reminder templates use it. Now points to the
    // correct enrollment-specific post-bat URL instead of the broken /portal/assessment/post-bat.
    variables["system.assessmentUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
    variables["system.managePreferencesUrl"] = `${frontendUrl}/portal/settings/notifications`;
    // Hosted email icon PNGs — same pattern as the logo, served from the PWA assets folder.
    const assetsUrl = process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl;
    variables["system.iconsUrl"] = `${assetsUrl}/assets/images/email-icons`;

    // Use the shared renderer so {{#if}}{{else}}{{/if}} and {{#unless}}{{/unless}}
    // blocks are evaluated correctly. The previous raw `replace(/\{\{key\}\}/, …)`
    // loop only handled simple placeholders, which would leave conditional
    // tokens visible in the rendered reminder email if an admin used them.
    const mergedSubject = renderEmailTemplate(template.subject, variables);
    const mergedContent = renderEmailTemplate(template.message_body, variables);

    // If template body is not a full HTML document, wrap with skeleton (adds header/footer),
    // then re-render so skeleton header/footer placeholders ({{mail.*}}, {{system.*}}, etc.) resolve.
    const trimmed = mergedContent.trim().toLowerCase();
    let finalContent =
      trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
        ? mergedContent
        : getEmailSkeleton(mergedContent, template.custom_styles || undefined);

    finalContent = renderEmailTemplate(finalContent, variables);

    const newLog = await this.prisma.client.emailLog.create({
      data: {
        email_template_id: template.id,
        recipient_email: participant.email,
        subject: mergedSubject,
        content: finalContent,
        status: "PENDING",
        scheduled_date: scheduledDate,
        participant_id: participant.id,
        company_id: participant.company_id,
        course_id: course.id,
        learning_group_id: lgp.learning_group_id,
        learning_group_participant_id: lgp.id,
        metadata: {
          reminder_type: "post_bat_invite",
          scheduled_from: "post_invite_send_now",
        } as any,
      } as any,
    });

    return newLog.id;
  }
}

results matching ""

    No results matching ""