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