File

apps/recallassess/recallassess-api/src/api/shared/email/services/update-pending-email-logs.service.ts

Description

Updates PENDING email logs that use a given template so their subject and content match the latest template body with mail-merge applied (per-log participant/course/company). Use after editing an email template so scheduled emails send the updated content.

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, dynamicMailFieldsService: DynamicMailFieldsService, unsubscribeTokenService: UnsubscribeTokenService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
dynamicMailFieldsService DynamicMailFieldsService No
unsubscribeTokenService UnsubscribeTokenService No

Methods

Async updatePendingEmailLogsForTemplate
updatePendingEmailLogsForTemplate(templateId: number)

Find all PENDING email logs for the given template, merge each with its context, and update subject/content to the latest template + mail-merge. Only logs with status PENDING and matching email_template_id are updated.

Parameters :
Name Type Optional
templateId number No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(UpdatePendingEmailLogsService.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 { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EmailStatus } from "@prisma/client";
import { getEmailSkeleton } from "../../../../templates/email/email-skeleton.template";
import { UnsubscribeTokenService } from "./unsubscribe-token.service";

/**
 * Updates PENDING email logs that use a given template so their subject and content
 * match the latest template body with mail-merge applied (per-log participant/course/company).
 * Use after editing an email template so scheduled emails send the updated content.
 */
@Injectable()
export class UpdatePendingEmailLogsService {
  private readonly logger = new Logger(UpdatePendingEmailLogsService.name);

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly dynamicMailFieldsService: DynamicMailFieldsService,
    private readonly unsubscribeTokenService: UnsubscribeTokenService,
  ) {}

  /**
   * Find all PENDING email logs for the given template, merge each with its context,
   * and update subject/content to the latest template + mail-merge.
   * Only logs with status PENDING and matching email_template_id are updated.
   */
  async updatePendingEmailLogsForTemplate(templateId: number): Promise<{ updated: number; message: string }> {
    const template = await this.prisma.client.emailTemplate.findUnique({
      where: { id: templateId },
    });

    if (!template) {
      throw new NotFoundException(`Email template with ID ${templateId} not found`);
    }

    const pendingLogs = await this.prisma.client.emailLog.findMany({
      where: {
        status: EmailStatus.PENDING,
        email_template_id: templateId,
      },
      include: {
        participant: true,
        course: true,
        company: true,
      },
    });

    if (pendingLogs.length === 0) {
      return {
        updated: 0,
        message: `No PENDING email logs found for template "${template.template_key}".`,
      };
    }

    const frontendUrl = requireEnv("FRONTEND_URL");

    let updated = 0;

    for (const log of pendingLogs) {
      const variables: Record<string, string> = {};

      if (log.participant) {
        variables["user.name"] = log.participant.first_name;
        variables["user.email"] = log.participant.email;
      }

      if (log.course) {
        variables["course.name"] = log.course.title;
        variables["course.id"] = log.course.id.toString();
      }

      // Use learning_group_participant_id (enrollment id) for my-courses routes when available.
      // Frontend routes: /portal/my-courses/:id/{pre-bat,post-bat,knowledge-review}
      if (log.learning_group_participant_id) {
        const enrollmentId = log.learning_group_participant_id;
        variables["system.myCourseUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}`;
        variables["system.knowledgeReviewUrl"] =
          `${frontendUrl}/portal/my-courses/${enrollmentId}/knowledge-review`;
        variables["system.preBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/pre-bat`;
        variables["system.postBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
        variables["system.assessmentUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
      } else {
        // Fallback if there's no enrollment context — at least don't 404 to a non-existent route.
        variables["system.assessmentUrl"] = `${frontendUrl}/portal/my-courses`;
      }

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

      // Plural — courses list page.
      variables["system.myCoursesUrl"] = `${frontendUrl}/portal/my-courses`;
      variables["system.managePreferencesUrl"] = `${frontendUrl}/portal/settings/notifications`;
      // Hosted email icon PNGs.
      const assetsUrl = process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl;
      variables["system.iconsUrl"] = `${assetsUrl}/assets/images/email-icons`;

      if (log.participant_id) {
        try {
          const token = await this.unsubscribeTokenService.getOrGenerateToken(log.participant_id);
          variables["system.unsubscribeUrl"] = `${frontendUrl}/unsubscribe?token=${token}`;
        } catch {
          variables["system.unsubscribeUrl"] = frontendUrl;
        }
      }

      // 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 and would leak `{{else}}` literals
      // (and both branches) into pending email logs whenever an admin used a
      // conditional template. See render-email-template.util.ts for the rules.
      const mergedSubject = renderEmailTemplate(template.subject, variables);
      const mergedContent = renderEmailTemplate(template.message_body, variables);

      // For pending logs, persist content that matches what will be sent:
      // - If the template body is already a full HTML document (has <html> or <!doctype>),
      //   store the merged HTML as-is.
      // - Otherwise, wrap with the shared email skeleton so header/footer are included.
      const trimmed = mergedContent.trim().toLowerCase();
      let finalContent =
        trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
          ? mergedContent
          : getEmailSkeleton(mergedContent, template.custom_styles || undefined);

      // Re-render so any placeholders inside the skeleton header/footer
      // ({{mail.*}}, {{system.*}}, conditionals) are resolved.
      finalContent = renderEmailTemplate(finalContent, variables);

      await this.prisma.client.emailLog.update({
        where: { id: log.id },
        data: { subject: mergedSubject, content: finalContent },
      });

      updated++;
      this.logger.debug(
        `Updated PENDING email log ${log.id} with latest template content (template: ${template.template_key})`,
      );
    }

    this.logger.log(
      `Updated ${updated} PENDING email log(s) for template "${template.template_key}" (id: ${templateId})`,
    );

    return {
      updated,
      message: `Updated ${updated} PENDING email log(s) with latest template content and mail-merge.`,
    };
  }
}

results matching ""

    No results matching ""