apps/recallassess/recallassess-api/src/api/client/contact-support/contact-support.service.ts
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.
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService)
|
|||||||||
|
Parameters :
|
| 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 :
Returns :
string
|
| Private errorMessage | ||||||
errorMessage(err: unknown)
|
||||||
|
Parameters :
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 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 :
Returns :
string
|
| Private Async resolveAdminEnquiryUrl |
resolveAdminEnquiryUrl()
|
|
Build the URL the admin team uses to open the enquiry list in the admin PWA.
Reads
Returns :
Promise<string>
|
| Private Async resolveAdminRecipient |
resolveAdminRecipient()
|
|
Resolve the support inbox address from system settings. The skeleton + sendTemplatedEmail
inject
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 :
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 :
Returns :
Promise<literal type>
|
| 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
|
| 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";
}
}
}