apps/recallassess/recallassess-api/src/api/shared/email/services/journey-email-attachments.service.ts
Journey Email Attachment Loader
Loads per-course PDF attachments for the four 100-Day Journey emails. Each Course can have N Media records associated with each of the four journey emails:
When a journey email is sent (originally OR resent), the system needs to download those Media records from S3 and attach them to the outgoing message. This service provides a single, reusable entry point for that.
Mirrors the logic in RecallAssessEmailLogHooksService.getResendAttachmentBuffers — that file is the resend-flow hook; this is the original-send equivalent.
Typical usage (from anywhere that sends a journey email):
const attachments = await journeyAttachmentService.loadForTemplateAndCourse( templateKey, courseId, ); await emailSender.sendEmail({ to, subject, content, attachments });
Returns [] if not a journey template, S3 isn't configured, or no Media records exist.
Properties |
|
Methods |
constructor(prisma: BNestPrismaService)
|
||||||
|
Parameters :
|
| Async loadForCourseSequence |
loadForCourseSequence(courseId: number, sequenceNumber: number)
|
|
Lower-level: given a course id and a sequence (1-4), download the documents from S3 and return them as attachment buffers.
Returns :
Promise<EmailAttachment[]>
|
| Async loadForTemplateAndCourse | ||||||||||||
loadForTemplateAndCourse(templateKey: string, courseId: number | null | undefined, metadata?: unknown)
|
||||||||||||
|
Top-level entry point — given a template key and course id, return the list of attachment buffers to include with the outgoing email. Empty array if there's nothing to attach (not a journey email, no docs uploaded, S3 not configured).
Parameters :
Returns :
Promise<EmailAttachment[]>
|
| resolveSequenceNumber |
resolveSequenceNumber(templateKey: string, metadata?: unknown)
|
|
Resolve the sequence number for a journey email from either the template key
or the email_log metadata's
Returns :
number | null
|
| sequenceNumberFromTemplateKey | ||||||
sequenceNumberFromTemplateKey(templateKey: string)
|
||||||
|
Resolve the journey sequence number (1-4) from a template key. Returns null if the template is not a journey template.
Parameters :
Returns :
number | null
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(JourneyEmailAttachmentsService.name)
|
| Private Readonly s3BucketName |
Type : string | null
|
| Private Readonly s3Client |
Type : S3Client
|
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { BNestPrismaService } from "@bish-nest/core/services";
import type { EmailAttachment } from "@bish-nest/core";
import { Injectable, Logger } from "@nestjs/common";
/**
* Journey Email Attachment Loader
*
* Loads per-course PDF attachments for the four 100-Day Journey emails. Each Course
* can have N Media records associated with each of the four journey emails:
* - Course.hundredDjEmail1Documents (Media[])
* - Course.hundredDjEmail2Documents (Media[])
* - Course.hundredDjEmail3Documents (Media[])
* - Course.hundredDjEmail4Documents (Media[])
*
* When a journey email is sent (originally OR resent), the system needs to download
* those Media records from S3 and attach them to the outgoing message. This service
* provides a single, reusable entry point for that.
*
* Mirrors the logic in RecallAssessEmailLogHooksService.getResendAttachmentBuffers
* — that file is the resend-flow hook; this is the original-send equivalent.
*
* Typical usage (from anywhere that sends a journey email):
*
* const attachments = await journeyAttachmentService.loadForTemplateAndCourse(
* templateKey,
* courseId,
* );
* await emailSender.sendEmail({ to, subject, content, attachments });
*
* Returns [] if not a journey template, S3 isn't configured, or no Media records exist.
*/
@Injectable()
export class JourneyEmailAttachmentsService {
private readonly logger = new Logger(JourneyEmailAttachmentsService.name);
private readonly s3Client: S3Client;
private readonly s3BucketName: string | null;
constructor(private readonly prisma: BNestPrismaService) {
const s3Region = process.env["AWS_REGION"] || "ap-southeast-1";
this.s3Client = new S3Client({ region: s3Region });
this.s3BucketName = process.env["AWS_S3_MEDIA_BUCKET"] || null;
}
/**
* Resolve the journey sequence number (1-4) from a template key. Returns null
* if the template is not a journey template.
*/
sequenceNumberFromTemplateKey(templateKey: string): number | null {
const m =
templateKey.match(/course\.hundred\.day\.journey\.email(\d)/) ||
templateKey.match(/hundred\.day\.journey\.email(\d)/);
return m ? parseInt(m[1], 10) : null;
}
/**
* Resolve the sequence number for a journey email from either the template key
* or the email_log metadata's `hundred_dj_email_number` field.
*/
resolveSequenceNumber(templateKey: string, metadata?: unknown): number | null {
const fromKey = this.sequenceNumberFromTemplateKey(templateKey);
if (fromKey) return fromKey;
if (metadata && typeof metadata === "object") {
const md = metadata as Record<string, unknown>;
const v = md["hundred_dj_email_number"];
if (typeof v === "number" && v >= 1 && v <= 4) return v;
if (typeof v === "string" && /^[1-4]$/.test(v)) return parseInt(v, 10);
}
return null;
}
/**
* Top-level entry point — given a template key and course id, return the list of
* attachment buffers to include with the outgoing email. Empty array if there's
* nothing to attach (not a journey email, no docs uploaded, S3 not configured).
*/
async loadForTemplateAndCourse(
templateKey: string,
courseId: number | null | undefined,
metadata?: unknown,
): Promise<EmailAttachment[]> {
const sequenceNumber = this.resolveSequenceNumber(templateKey, metadata);
if (sequenceNumber === null) {
return [];
}
if (courseId == null) {
this.logger.warn(
`Journey email (${templateKey}) requested attachments but no courseId was provided`,
);
return [];
}
return this.loadForCourseSequence(courseId, sequenceNumber);
}
/**
* Lower-level: given a course id and a sequence (1-4), download the documents
* from S3 and return them as attachment buffers.
*/
async loadForCourseSequence(
courseId: number,
sequenceNumber: number,
): Promise<EmailAttachment[]> {
if (sequenceNumber < 1 || sequenceNumber > 4) {
this.logger.warn(`Invalid journey sequence slot: ${sequenceNumber}`);
return [];
}
if (!this.s3BucketName) {
this.logger.warn(
"S3 bucket not configured (AWS_S3_MEDIA_BUCKET) — cannot load journey-email attachments",
);
return [];
}
// Look up Media records for this course's hundred_dj_emailN slot.
//
// NOTE on schema history: there used to be separate columns
// `course_hundred_dj_email{1..4}_id` on the media table, but the
// 20260328200000_consolidate_hundred_dj_media_on_course_id migration
// dropped them and consolidated to a single `course_id` column. Each
// Media row's slot is now identified by the `media_name` enum:
// COURSE__HUNDRED_DJ_EMAIL1 / EMAIL2 / EMAIL3 / EMAIL4
//
// So the query is `course_id = ? AND media_name = ?` — NOT the legacy
// `course_hundred_dj_emailN_id` columns (which no longer exist).
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];
const queries: Record<number, () => Promise<any[]>> = {
1: () => client.$queryRaw`SELECT * FROM media WHERE course_id = ${courseId} AND media_name::text = ${mediaName}`,
2: () => client.$queryRaw`SELECT * FROM media WHERE course_id = ${courseId} AND media_name::text = ${mediaName}`,
3: () => client.$queryRaw`SELECT * FROM media WHERE course_id = ${courseId} AND media_name::text = ${mediaName}`,
4: () => client.$queryRaw`SELECT * FROM media WHERE course_id = ${courseId} AND media_name::text = ${mediaName}`,
};
let mediaRecords: any[] = [];
try {
mediaRecords = await queries[sequenceNumber]();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(
`Failed to query journey media (course ${courseId}, seq ${sequenceNumber}): ${msg}`,
);
return [];
}
if (!mediaRecords?.length) {
this.logger.debug(
`No journey-email-${sequenceNumber} documents uploaded for course ${courseId} — sending without attachments`,
);
return [];
}
const attachments: EmailAttachment[] = [];
const mediaPrefix = process.env["AWS_S3_MEDIA_PREFIX"] || "private";
const prefix = mediaPrefix.endsWith("/") ? mediaPrefix : `${mediaPrefix}/`;
for (const media of mediaRecords) {
try {
const s3Key = String(media.media_path).startsWith(prefix)
? String(media.media_path)
: `${prefix}${media.media_path}`;
// Resolve a friendly filename — prefer original_filename, fall back to
// the part after the storage hash dash, and finally to a synthesized name.
let filename: string | undefined =
typeof media.original_filename === "string" ? media.original_filename : undefined;
if (!filename) {
const lastSegment = String(media.media_path).split("/").pop() || "";
const dashIdx = lastSegment.indexOf("-");
if (dashIdx > 0) {
filename = lastSegment.substring(dashIdx + 1);
}
}
if (!filename) {
filename = `document-${media.id}${media.file_extension ? `.${media.file_extension}` : ""}`;
}
const contentType: string =
(typeof media.mime_type === "string" && media.mime_type) || "application/octet-stream";
const cmd = new GetObjectCommand({
Bucket: this.s3BucketName,
Key: s3Key,
});
const response = await this.s3Client.send(cmd);
if (!response.Body) {
this.logger.warn(`No body returned for S3 object ${s3Key} — skipping`);
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(
`Journey attachment loaded: ${filename} (${buffer.length} bytes) ← s3://${this.s3BucketName}/${s3Key}`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(
`Failed to download attachment (media ${media.id}, course ${courseId}, seq ${sequenceNumber}): ${msg}`,
);
// Continue — better to send the email without one missing PDF than fail entirely
}
}
this.logger.log(
`Journey email seq ${sequenceNumber} for course ${courseId}: loaded ${attachments.length} attachment(s)`,
);
return attachments;
}
}