apps/recallassess/recallassess-api/src/api/shared/email/services/update-pending-email-logs.service.ts
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.
Properties |
|
Methods |
constructor(prisma: BNestPrismaService, dynamicMailFieldsService: DynamicMailFieldsService, unsubscribeTokenService: UnsubscribeTokenService)
|
||||||||||||
|
Parameters :
|
| 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 :
Returns :
Promise<literal type>
|
| 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.`,
};
}
}