File

apps/recallassess/recallassess-api/src/api/shared/email/services/journey-email-attachments.service.ts

Description

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.

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService)
Parameters :
Name Type Optional
prisma BNestPrismaService No

Methods

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.

Parameters :
Name Type Optional
courseId number No
sequenceNumber number No
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 :
Name Type Optional
templateKey string No
courseId number | null | undefined No
metadata unknown Yes
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 hundred_dj_email_number field.

Parameters :
Name Type Optional
templateKey string No
metadata unknown Yes
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 :
Name Type Optional
templateKey string No
Returns : number | null

Properties

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

results matching ""

    No results matching ""