apps/recallassess/recallassess-api/src/common/adapters/recallassess-email-log-hooks.service.ts
RecallAssess email-log hooks: subscription banner, journey-sequence resend attachments (DB + S3).
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, systemLogService: SystemLogService)
|
|||||||||
|
Parameters :
|
| Async getEmailLogBannerContext | ||||||
getEmailLogBannerContext(companyId: number)
|
||||||
|
Parameters :
Returns :
Promise<EmailLogBannerContext | null>
|
| Async getResendAttachmentBuffers |
getResendAttachmentBuffers(courseId: number, sequenceNumber: number)
|
|
Returns :
Promise<ResendEmailAttachment[]>
|
| Private isJourneySequenceTemplate | ||||||
isJourneySequenceTemplate(templateKey: string)
|
||||||
|
RecallAssess journey email template keys (100-day journey sequence).
Parameters :
Returns :
boolean
|
| Async onEmailLogRescheduled | ||||||
onEmailLogRescheduled(input: OnEmailLogRescheduledInput)
|
||||||
|
Audit hook for Errors here are swallowed by the lib service so a failed audit write does not surface a 500 to the QA/admin caller — the underlying reschedule already succeeded by the time this fires.
Parameters :
Returns :
Promise<void>
|
| parseResendAttachmentContext |
parseResendAttachmentContext(templateKey: string, metadata: unknown)
|
|
Returns :
ResendAttachmentContext
|
| Private sequenceNumberFromTemplateKey | ||||||
sequenceNumberFromTemplateKey(templateKey: string)
|
||||||
|
Parameters :
Returns :
number | null
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(RecallAssessEmailLogHooksService.name)
|
| Private Readonly s3BucketName |
Type : string | null
|
| Private Readonly s3Client |
Type : S3Client
|
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { optionalEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import {
BNestEmailLogHooks,
type BuildResendEmailLogPreparedInput,
type EmailLogBannerContext,
type OnEmailLogRescheduledInput,
type ResendAttachmentContext,
type ResendEmailAttachment,
type ResendEmailLogPrepared,
} from "@bish-nest/email-log";
import { Injectable, Logger } from "@nestjs/common";
import { SystemLogEntityType } from "@prisma/client";
import { SystemLogService } from "@api/shared/services/system-log.service";
/**
* RecallAssess email-log hooks: subscription banner, journey-sequence resend attachments (DB + S3).
*/
@Injectable()
export class RecallAssessEmailLogHooksService implements BNestEmailLogHooks {
private readonly logger = new Logger(RecallAssessEmailLogHooksService.name);
private readonly s3Client: S3Client;
private readonly s3BucketName: string | null;
constructor(
private readonly prisma: BNestPrismaService,
private readonly systemLogService: SystemLogService,
) {
const s3Region = optionalEnv("AWS_REGION", "ap-southeast-1");
this.s3Client = new S3Client({ region: s3Region });
const bucket = optionalEnv("AWS_S3_MEDIA_BUCKET", "");
this.s3BucketName = bucket !== "" ? bucket : null;
}
/**
* Audit hook for `PATCH /:id/reschedule` and `POST /:id/send-in-1-minute`.
* Writes a system_log row with entity_type=EMAIL_LOG, operation=UPDATE,
* and old/new scheduled_date so any reschedule is traceable to the
* admin who performed it. The acting user_id is captured automatically
* by SystemLogService from the request context.
*
* Errors here are swallowed by the lib service so a failed audit
* write does not surface a 500 to the QA/admin caller — the
* underlying reschedule already succeeded by the time this fires.
*/
async onEmailLogRescheduled(input: OnEmailLogRescheduledInput): Promise<void> {
const oldIso = input.oldScheduledDate ? input.oldScheduledDate.toISOString() : null;
const newIso = input.newScheduledDate.toISOString();
await this.systemLogService.logUpdate(
SystemLogEntityType.EMAIL_LOG,
input.emailLogId,
{ scheduled_date: oldIso },
{ scheduled_date: newIso },
{ scheduled_date: { old: oldIso, new: newIso } },
{
// Stash the action variant in request_body so an admin viewing
// the system_log row can tell whether it was an explicit
// reschedule or a "Send in 1 min" shortcut.
request_body: {
action: input.triggeredBySendInOneMinute ? "send_in_1_minute" : "reschedule",
},
},
);
}
async getEmailLogBannerContext(companyId: number): Promise<EmailLogBannerContext | null> {
const prismaAny = this.prisma.client as any;
if (!prismaAny?.subscription?.findFirst || !prismaAny?.systemSetting?.findFirst) {
return null;
}
const activeSubscription = await prismaAny.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: "ACTIVE",
},
select: { id: true },
});
const contactSetting = await prismaAny.systemSetting.findFirst({
where: { key: "mail.contact_email" },
select: { value: true },
});
const contactEmail =
(typeof contactSetting?.value === "string" ? contactSetting.value : "enquiries@recallsolutions.ai").trim() ||
"enquiries@recallsolutions.ai";
return {
shouldShowBanner: !activeSubscription,
contactEmail,
};
}
parseResendAttachmentContext(templateKey: string, metadata: unknown): ResendAttachmentContext {
this.logger.debug(
`[EmailLogHooksxxxx] parseResendAttachmentContext called: templateKey="${templateKey}" hasMetadata=${metadata !== null && metadata !== undefined}`,
);
if (!this.isJourneySequenceTemplate(templateKey)) {
return { wantsAttachments: false, sequenceNumber: null };
}
let sequenceNumber = this.sequenceNumberFromTemplateKey(templateKey);
if (!sequenceNumber && metadata && typeof metadata === "object") {
const md = metadata as Record<string, unknown>;
const metaNum = md["hundred_dj_email_number"];
if (typeof metaNum === "number" && metaNum >= 1 && metaNum <= 4) {
sequenceNumber = metaNum;
} else if (typeof metaNum === "string" && /^[1-4]$/.test(metaNum)) {
sequenceNumber = parseInt(metaNum, 10);
}
}
return { wantsAttachments: true, sequenceNumber: sequenceNumber ?? null };
}
/** RecallAssess journey email template keys (100-day journey sequence). */
private isJourneySequenceTemplate(templateKey: string): boolean {
return (
templateKey === "course.hundred.day.journey.email1" ||
templateKey === "course.hundred.day.journey.email2" ||
templateKey === "course.hundred.day.journey.email3" ||
templateKey === "course.hundred.day.journey.email4" ||
templateKey === "hundred.day.journey.email1" ||
templateKey === "hundred.day.journey.email2" ||
templateKey === "hundred.day.journey.email3" ||
templateKey === "hundred.day.journey.email4"
);
}
private sequenceNumberFromTemplateKey(templateKey: string): number | null {
const match =
templateKey.match(/course\.hundred\.day\.journey\.email(\d)/) ||
templateKey.match(/hundred\.day\.journey\.email(\d)/);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
async getResendAttachmentBuffers(courseId: number, sequenceNumber: number): Promise<ResendEmailAttachment[]> {
if (!this.s3BucketName) {
this.logger.warn("S3 bucket not configured - cannot load journey-sequence resend attachments");
return [];
}
// Schema note: the legacy `course_hundred_dj_emailN_id` columns on `media` were
// dropped by 20260328200000_consolidate_hundred_dj_media_on_course_id. Each
// journey-email Media row is now identified by `course_id` + `media_name` enum.
const client = this.prisma.client as any;
const mediaNameBySequence: Record<number, string> = {
1: "COURSE__HUNDRED_DJ_EMAIL1",
2: "COURSE__HUNDRED_DJ_EMAIL2",
3: "COURSE__HUNDRED_DJ_EMAIL3",
4: "COURSE__HUNDRED_DJ_EMAIL4",
};
const mediaName = mediaNameBySequence[sequenceNumber];
if (!mediaName) {
this.logger.warn(`Invalid journey sequence slot: ${sequenceNumber}`);
return [];
}
const mediaRecords: any[] = await client.$queryRaw`
SELECT * FROM media WHERE course_id = ${courseId} AND media_name::text = ${mediaName}
`;
if (!mediaRecords?.length) {
this.logger.debug(`No documents found for sequence ${sequenceNumber} (course ${courseId})`);
return [];
}
const attachments: ResendEmailAttachment[] = [];
const mediaPrefix = optionalEnv("AWS_S3_MEDIA_PREFIX", "private");
const prefix = mediaPrefix.endsWith("/") ? mediaPrefix : `${mediaPrefix}/`;
for (const media of mediaRecords) {
try {
const s3Key = media.media_path.startsWith(prefix) ? media.media_path : `${prefix}${media.media_path}`;
let filename = media.original_filename as string | undefined;
if (!filename) {
const pathParts = String(media.media_path).split("/");
const lastPart = pathParts[pathParts.length - 1];
if (lastPart && lastPart.includes("-")) {
const dashIndex = lastPart.indexOf("-");
if (dashIndex > 0) {
filename = lastPart.substring(dashIndex + 1);
}
}
}
if (!filename) {
filename = `document-${media.id}${media.file_extension ? `.${media.file_extension}` : ""}`;
}
const contentType = media.mime_type || "application/octet-stream";
const getObjectCommand = new GetObjectCommand({
Bucket: this.s3BucketName,
Key: s3Key,
});
const response = await this.s3Client.send(getObjectCommand);
if (!response.Body) {
this.logger.warn(`No body returned for S3 object: ${s3Key}`);
continue;
}
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
attachments.push({
filename,
content: buffer,
contentType,
});
this.logger.debug(`Downloaded attachment: ${filename} (${buffer.length} bytes) from S3: ${s3Key}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to download attachment for media ${media.id}: ${errorMessage}`, {
mediaId: media.id,
mediaPath: media.media_path,
originalFilename: media.original_filename,
courseId,
sequenceNumber,
});
}
}
return attachments;
}
async buildResendEmailLogPrepared(input: BuildResendEmailLogPreparedInput): Promise<ResendEmailLogPrepared> {
const emailLogAny = input.sourceEmailLog as Record<string, unknown>;
const participant = input.participant;
const prismaCreate: Record<string, unknown> = {
recipient_email: input.recipientEmail,
subject: emailLogAny["subject"],
content: emailLogAny["content"],
status: "PENDING",
retry_count: 0,
max_retries: 3,
email_template_id: emailLogAny["email_template_id"],
participant_id: participant ? participant.id : emailLogAny["participant_id"],
company_id: participant ? participant.company_id : emailLogAny["company_id"],
course_id: emailLogAny["course_id"],
learning_group_id: emailLogAny["learning_group_id"],
learning_group_participant_id: emailLogAny["learning_group_participant_id"],
assessment_id: emailLogAny["assessment_id"],
knowledge_review_id: emailLogAny["knowledge_review_id"],
subscription_id: emailLogAny["subscription_id"],
invoice_id: emailLogAny["invoice_id"],
scheduled_date: emailLogAny["scheduled_date"],
metadata: {
resend_from_log_id: input.resendFromLogId,
original_recipient: input.baseRecipientEmail,
override_applied: input.recipientEmail !== input.baseRecipientEmail,
resend_timestamp: new Date().toISOString(),
...((emailLogAny["metadata"] as Record<string, unknown>) || {}),
},
};
const senderMetadata: Record<string, unknown> = {
resend_from_log_id: input.resendFromLogId,
original_recipient: input.baseRecipientEmail,
override_applied: input.recipientEmail !== input.baseRecipientEmail,
participant_id: participant ? participant.id : emailLogAny["participant_id"],
company_id: participant ? participant.company_id : emailLogAny["company_id"],
course_id: emailLogAny["course_id"],
learning_group_id: emailLogAny["learning_group_id"],
learning_group_participant_id: emailLogAny["learning_group_participant_id"],
assessment_id: emailLogAny["assessment_id"],
knowledge_review_id: emailLogAny["knowledge_review_id"],
subscription_id: emailLogAny["subscription_id"],
invoice_id: emailLogAny["invoice_id"],
};
return { prismaCreate, senderMetadata };
}
}