File

apps/recallassess/recallassess-api/src/common/adapters/recallassess-email-log-hooks.service.ts

Description

RecallAssess email-log hooks: subscription banner, journey-sequence resend attachments (DB + S3).

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, systemLogService: SystemLogService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
systemLogService SystemLogService No

Methods

Async buildResendEmailLogPrepared
buildResendEmailLogPrepared(input: BuildResendEmailLogPreparedInput)
Parameters :
Name Type Optional
input BuildResendEmailLogPreparedInput No
Returns : Promise<ResendEmailLogPrepared>
Async getEmailLogBannerContext
getEmailLogBannerContext(companyId: number)
Parameters :
Name Type Optional
companyId number No
Returns : Promise<EmailLogBannerContext | null>
Async getResendAttachmentBuffers
getResendAttachmentBuffers(courseId: number, sequenceNumber: number)
Parameters :
Name Type Optional
courseId number No
sequenceNumber number No
Returns : Promise<ResendEmailAttachment[]>
Private isJourneySequenceTemplate
isJourneySequenceTemplate(templateKey: string)

RecallAssess journey email template keys (100-day journey sequence).

Parameters :
Name Type Optional
templateKey string No
Returns : boolean
Async onEmailLogRescheduled
onEmailLogRescheduled(input: OnEmailLogRescheduledInput)

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.

Parameters :
Name Type Optional
input OnEmailLogRescheduledInput No
Returns : Promise<void>
parseResendAttachmentContext
parseResendAttachmentContext(templateKey: string, metadata: unknown)
Parameters :
Name Type Optional
templateKey string No
metadata unknown No
Returns : ResendAttachmentContext
Private sequenceNumberFromTemplateKey
sequenceNumberFromTemplateKey(templateKey: string)
Parameters :
Name Type Optional
templateKey string No
Returns : number | null

Properties

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

results matching ""

    No results matching ""