File

apps/recallassess/recallassess-api/src/api/client/contact-support/contact-support.service.ts

Description

CLContactSupportService

Owns both contact-enquiry intake paths:

Both paths converge on the same internal pipeline (persistAndNotify()), so the downstream behaviour — DB row creation, visitor acknowledgement, admin notification — is identical regardless of how the enquiry arrived. The only differences are the source flag, the optional subject, and the auto-reply loop guard for inbound emails.

All emails go through BNestEmailSenderService.sendTemplatedEmail() so they pick up the standard skeleton (header/footer/branding) and audit-log into email_log.

Email-send failures are logged but never block the API response — the enquiry is already saved, so the visitor's submission isn't lost if SES has a hiccup.

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No

Methods

Private deriveName
deriveName(rawName: string | undefined, email: string)

Pick the best display name we can. Prefer the explicit one from the From: header. If it's missing or blank, derive a passable fallback from the email's local-part

Example :
Parameters :
Name Type Optional
rawName string | undefined No
email string No
Returns : string
Private errorMessage
errorMessage(err: unknown)
Parameters :
Name Type Optional
err unknown No
Returns : string
Private formatAdminDateTime
formatAdminDateTime(d: Date)

Format a date+time for the ADMIN notification email in the company's timezone, with the timezone clearly labelled. Admins typically read the notification hours later, so they need both date AND time, but they need to know which timezone they're reading.

Hardcoded to Asia/Hong_Kong because that's where the support team is based. If the team relocates, change this to read from a mail.timezone system setting.

Example: "Fri, 1 May 2026, 4:50 PM (HKT)"

Note: the visitor's confirmation email intentionally has NO timestamp — they receive it within seconds of submitting, and any date string risks cross-timezone confusion (NYC visitor on Thursday night sees Friday because the server is on UTC). The reference ID is what matters there.

Parameters :
Name Type Optional
d Date No
Returns : string
Private Async persistAndNotify
persistAndNotify(args: literal type)

Save the enquiry, then send (a) the visitor acknowledgement (unless skipped) and (b) the admin notification. Returns the enquiry ID.

Parameters :
Name Type Optional
args literal type No
Returns : Promise<number>
Private Async resolveAdminEnquiryUrl
resolveAdminEnquiryUrl()

Build the URL the admin team uses to open the enquiry list in the admin PWA. Reads mail.admin_url if available so staging/prod resolve correctly without a deploy.

Returns : Promise<string>
Private Async resolveAdminRecipient
resolveAdminRecipient()

Resolve the support inbox address from system settings. The skeleton + sendTemplatedEmail inject mail.contact_email into rendered templates automatically, but for the recipient itself we read the same setting directly here so the admin notification goes to the right place.

Returns : Promise<string>
Async submitEnquiry
submitEnquiry(dto: CreateContactEnquiryDto)

Form-submission entry point — POST /api/client/contact-support/enquiry. Tags the row source = WEBSITE_FORM and always sends both emails.

Parameters :
Name Type Optional
dto CreateContactEnquiryDto No
Returns : Promise<literal type>
Async submitFromInboundEmail
submitFromInboundEmail(dto: InboundEmailWebhookDto)

Inbound-email webhook entry point — POST /api/client/contact-support/inbound-email. Tags the row source = INBOUND_EMAIL. Skips the visitor acknowledgement when the incoming message was itself an autoresponder, so we never bounce-loop with a vacation responder or another mail server's auto-reply.

Parameters :
Name Type Optional
dto InboundEmailWebhookDto No
Returns : Promise<literal type>

Properties

Private Static Readonly ADMIN_RECIPIENT_FALLBACK
Type : string
Default value : "enquiry@recallsolutions.ai"

Hardcoded fallback only if the system setting is absent. In normal operation the mail.contact_email setting exists and this constant is never used.

Private Readonly logger
Type : unknown
Default value : new Logger(CLContactSupportService.name)
Private Static Readonly TEMPLATE_KEY_ACK
Type : string
Default value : "contact.support.enquiry.acknowledgement"

Template key for the visitor auto-reply. Defined in 019-contact-support-email-templates.sql.

Private Static Readonly TEMPLATE_KEY_NOTIFY
Type : string
Default value : "contact.support.enquiry.notification"

Template key for the internal admin notification.

import { BNestEmailSenderService } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { Injectable, Logger } from "@nestjs/common";
import { CreateContactEnquiryDto } from "./dto/create-contact-enquiry.dto";
import { InboundEmailWebhookDto } from "./dto/inbound-email-webhook.dto";

/**
 * CLContactSupportService
 *
 * Owns both contact-enquiry intake paths:
 *   - submitEnquiry()           visitor used the website form
 *   - submitFromInboundEmail()  visitor emailed enquiry@recallsolutions.ai directly
 *
 * Both paths converge on the same internal pipeline (`persistAndNotify()`), so the
 * downstream behaviour — DB row creation, visitor acknowledgement, admin
 * notification — is identical regardless of how the enquiry arrived. The only
 * differences are the source flag, the optional subject, and the auto-reply
 * loop guard for inbound emails.
 *
 * All emails go through BNestEmailSenderService.sendTemplatedEmail() so they pick
 * up the standard skeleton (header/footer/branding) and audit-log into email_log.
 *
 * Email-send failures are logged but never block the API response — the enquiry
 * is already saved, so the visitor's submission isn't lost if SES has a hiccup.
 */
@Injectable()
export class CLContactSupportService {
  private readonly logger = new Logger(CLContactSupportService.name);

  /** Template key for the visitor auto-reply. Defined in 019-contact-support-email-templates.sql. */
  private static readonly TEMPLATE_KEY_ACK = "contact.support.enquiry.acknowledgement";

  /** Template key for the internal admin notification. */
  private static readonly TEMPLATE_KEY_NOTIFY = "contact.support.enquiry.notification";

  /**
   * Hardcoded fallback only if the system setting is absent. In normal operation the
   * `mail.contact_email` setting exists and this constant is never used.
   */
  private static readonly ADMIN_RECIPIENT_FALLBACK = "enquiry@recallsolutions.ai";

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly emailSender: BNestEmailSenderService,
  ) {}

  // -------------------------------------------------------------------------
  // Public entry points
  // -------------------------------------------------------------------------

  /**
   * Form-submission entry point — POST /api/client/contact-support/enquiry.
   * Tags the row source = WEBSITE_FORM and always sends both emails.
   */
  async submitEnquiry(dto: CreateContactEnquiryDto): Promise<{ success: boolean; message: string }> {
    await this.persistAndNotify({
      name: dto.name,
      email: dto.email,
      message: dto.message,
      subject: null,
      source: "WEBSITE_FORM",
      skipVisitorAck: false,
    });

    return {
      success: true,
      message: "Your enquiry has been submitted successfully.",
    };
  }

  /**
   * Inbound-email webhook entry point — POST /api/client/contact-support/inbound-email.
   * Tags the row source = INBOUND_EMAIL. Skips the visitor acknowledgement when the
   * incoming message was itself an autoresponder, so we never bounce-loop with a
   * vacation responder or another mail server's auto-reply.
   */
  async submitFromInboundEmail(
    dto: InboundEmailWebhookDto,
  ): Promise<{ success: boolean; message: string; enquiryId?: number; skipped?: boolean }> {
    const skipVisitorAck = dto.isAutoSubmitted === true;
    if (skipVisitorAck) {
      this.logger.warn(
        `Inbound email from ${dto.fromEmail} marked Auto-Submitted. Saving enquiry but skipping visitor acknowledgement to avoid auto-reply loops.`,
      );
    }

    const enquiryId = await this.persistAndNotify({
      name: this.deriveName(dto.fromName, dto.fromEmail),
      email: dto.fromEmail,
      message: dto.message,
      subject: dto.subject ?? null,
      source: "INBOUND_EMAIL",
      receivedAtOverride: dto.receivedAt ? new Date(dto.receivedAt) : undefined,
      skipVisitorAck,
    });

    return {
      success: true,
      message: skipVisitorAck
        ? "Inbound enquiry stored; visitor acknowledgement skipped (auto-submitted)."
        : "Inbound enquiry stored and acknowledgement sent.",
      enquiryId,
      skipped: skipVisitorAck,
    };
  }

  // -------------------------------------------------------------------------
  // Internal pipeline shared by both entry points
  // -------------------------------------------------------------------------

  /**
   * Save the enquiry, then send (a) the visitor acknowledgement (unless skipped)
   * and (b) the admin notification. Returns the enquiry ID.
   */
  private async persistAndNotify(args: {
    name: string;
    email: string;
    message: string;
    subject: string | null;
    source: "WEBSITE_FORM" | "INBOUND_EMAIL";
    receivedAtOverride?: Date;
    skipVisitorAck: boolean;
  }): Promise<number> {
    // We let Prisma stamp created_at automatically. If the email's Date: header
    // gave us a more accurate "received" time we honour it for display in the
    // template, but we never override the DB column itself — created_at is the
    // server's audit timestamp and shouldn't be set by an external party.
    const enquiry = await (this.prisma.client as unknown as {
      contactEnquiry: {
        create: (args: unknown) => Promise<{ id: number; created_at: Date }>;
      };
    }).contactEnquiry.create({
      data: {
        name: args.name,
        email: args.email,
        message: args.message,
        subject: args.subject,
        source: args.source,
      },
    });

    const receivedAt = args.receivedAtOverride ?? enquiry.created_at;
    const receivedAtIso = receivedAt.toISOString();

    // Visitor email shows NO timestamp — they receive it within seconds of
    // submitting, and any date/time we render risks cross-timezone confusion
    // (a NYC visitor on Thursday night sees a Friday date because the server
    // is on UTC). The reference ID is the meaningful identifier.
    //
    // Admin email shows full date + time in the company's timezone (Hong Kong),
    // clearly labelled (HKT). Admins read these hours after submission and
    // need the timestamp for triage; the label removes ambiguity.
    const receivedAtForAdmin = this.formatAdminDateTime(receivedAt);

    const adminRecipient = await this.resolveAdminRecipient();
    const adminEnquiryUrl = await this.resolveAdminEnquiryUrl();

    // 1. Visitor acknowledgement
    if (!args.skipVisitorAck) {
      try {
        await this.emailSender.sendTemplatedEmail({
          to: args.email,
          templateKey: CLContactSupportService.TEMPLATE_KEY_ACK,
          variables: {
            "user.name": args.name,
            "enquiry.message": args.message,
            "enquiry.id": String(enquiry.id),
          },
          metadata: {
            triggeredBy: "contact_support_enquiry_acknowledgement",
            enquiryId: enquiry.id,
            enquiryEmail: args.email,
            enquirySource: args.source,
            enquiryReceivedAt: receivedAtIso,
          },
        });

        await (this.prisma.client as unknown as {
          contactEnquiry: { update: (args: unknown) => Promise<unknown> };
        }).contactEnquiry.update({
          where: { id: enquiry.id },
          data: { customer_email_sent_at: new Date() },
        });
      } catch (err) {
        this.logger.error(
          `Failed to send visitor acknowledgement for enquiry #${enquiry.id}: ${this.errorMessage(err)}`,
        );
      }
    }

    // 2. Admin notification — independent of the visitor send.
    try {
      await this.emailSender.sendTemplatedEmail({
        to: adminRecipient,
        templateKey: CLContactSupportService.TEMPLATE_KEY_NOTIFY,
        variables: {
          "user.name": args.name,
          "user.email": args.email,
          "enquiry.message": args.message,
          "enquiry.id": String(enquiry.id),
          "enquiry.received_at": receivedAtForAdmin,
          "system.adminEnquiryUrl": adminEnquiryUrl,
        },
        metadata: {
          triggeredBy: "contact_support_enquiry_notification",
          enquiryId: enquiry.id,
          enquiryEmail: args.email,
          enquirySource: args.source,
          enquiryReceivedAt: receivedAtIso,
        },
      });
    } catch (err) {
      this.logger.error(
        `Failed to send admin notification for enquiry #${enquiry.id}: ${this.errorMessage(err)}`,
      );
    }

    return enquiry.id;
  }

  // -------------------------------------------------------------------------
  // Helpers
  // -------------------------------------------------------------------------

  /**
   * Pick the best display name we can. Prefer the explicit one from the From: header.
   * If it's missing or blank, derive a passable fallback from the email's local-part
   * (the bit before the @): "sarah.t@example.com" -> "Sarah T". Last resort: "(no name)".
   */
  private deriveName(rawName: string | undefined, email: string): string {
    const explicit = (rawName || "").trim();
    if (explicit.length > 0) {
      return explicit.slice(0, 120);
    }

    const local = email.split("@")[0] || "";
    const cleaned = local.replace(/[._\-+]/g, " ").trim();
    if (!cleaned) {
      return "(no name)";
    }

    return cleaned
      .split(/\s+/)
      .map((part) => (part.length > 0 ? part[0].toUpperCase() + part.slice(1) : part))
      .join(" ")
      .slice(0, 120);
  }

  /**
   * Resolve the support inbox address from system settings. The skeleton + sendTemplatedEmail
   * inject `mail.contact_email` into rendered templates automatically, but for the *recipient*
   * itself we read the same setting directly here so the admin notification goes to the right place.
   */
  private async resolveAdminRecipient(): Promise<string> {
    try {
      const prismaAny = this.prisma.client as unknown as {
        systemSetting: { findFirst: (args: unknown) => Promise<{ value?: string } | null> };
      };
      const setting = await prismaAny.systemSetting.findFirst({
        where: { key: "mail.contact_email" },
      });
      const value = (setting?.value || "").trim();
      if (value) {
        return value;
      }
    } catch (err) {
      this.logger.warn(
        `Could not read mail.contact_email setting; falling back to default. ${this.errorMessage(err)}`,
      );
    }
    return CLContactSupportService.ADMIN_RECIPIENT_FALLBACK;
  }

  /**
   * Build the URL the admin team uses to open the enquiry list in the admin PWA.
   * Reads `mail.admin_url` if available so staging/prod resolve correctly without a deploy.
   */
  private async resolveAdminEnquiryUrl(): Promise<string> {
    try {
      const prismaAny = this.prisma.client as unknown as {
        systemSetting: { findFirst: (args: unknown) => Promise<{ value?: string } | null> };
      };
      const setting = await prismaAny.systemSetting.findFirst({
        where: { key: "mail.admin_url" },
      });
      const adminBase = (setting?.value || "").trim();
      if (adminBase) {
        return `${adminBase.replace(/\/$/, "")}/module/contact-enquiry/list`;
      }
    } catch {
      // Setting is optional. Fall through to a sensible default.
    }
    return "/module/contact-enquiry/list";
  }

  /**
   * Format a date+time for the ADMIN notification email in the company's
   * timezone, with the timezone clearly labelled. Admins typically read the
   * notification hours later, so they need both date AND time, but they need
   * to know which timezone they're reading.
   *
   * Hardcoded to Asia/Hong_Kong because that's where the support team is
   * based. If the team relocates, change this to read from a `mail.timezone`
   * system setting.
   *
   * Example: "Fri, 1 May 2026, 4:50 PM (HKT)"
   *
   * Note: the visitor's confirmation email intentionally has NO timestamp —
   * they receive it within seconds of submitting, and any date string risks
   * cross-timezone confusion (NYC visitor on Thursday night sees Friday
   * because the server is on UTC). The reference ID is what matters there.
   */
  private formatAdminDateTime(d: Date): string {
    const formatted = new Intl.DateTimeFormat("en-GB", {
      weekday: "short",
      day: "numeric",
      month: "short",
      year: "numeric",
      hour: "numeric",
      minute: "2-digit",
      hour12: true,
      timeZone: "Asia/Hong_Kong",
    }).format(d);
    return `${formatted} (HKT)`;
  }

  private errorMessage(err: unknown): string {
    if (err instanceof Error) return err.message;
    if (typeof err === "string") return err;
    try {
      return JSON.stringify(err);
    } catch {
      return "unknown error";
    }
  }
}

results matching ""

    No results matching ""