Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { MockEmailProvider } from "../providers/mock-email-provider";
|
||||
import { notify } from "../notification.service";
|
||||
import { processDueNotificationDeliveries } from "../reminder-worker";
|
||||
import { renderEmailForNotification } from "../render-email";
|
||||
|
||||
/**
|
||||
* The follow-up-to-Phase-8 behaviour: every outgoing email's visible
|
||||
* sender NAME is the installed business's own name, resolved fresh per
|
||||
* send — never a global lookup, never cached across requests/business
|
||||
* renames. See docs/phase-8-email-sender-summary.md.
|
||||
*/
|
||||
describe("business-name-as-sender (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let provider: MockEmailProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
provider = new MockEmailProvider();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("resolves the installed business's name as the sender for a booking-confirmation-style email", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "You've earned a stamp.",
|
||||
idempotencyKey: "test:sender:booking",
|
||||
});
|
||||
|
||||
const rendered = await renderEmailForNotification(prisma, notification);
|
||||
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||
expect(rendered?.sender.name).not.toBe("NextChair");
|
||||
});
|
||||
|
||||
it("resolves the business name for a staff-invitation notification (falls through to the generic template, same sender resolution)", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
recipientUserId: userId,
|
||||
subject: "You've been invited",
|
||||
bodyText: "You've been invited to join the team.",
|
||||
idempotencyKey: "test:sender:staff-invite",
|
||||
});
|
||||
|
||||
const rendered = await renderEmailForNotification(prisma, notification);
|
||||
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||
});
|
||||
|
||||
it("passes the resolved business name all the way through the background processor to the email provider", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "You've earned a stamp.",
|
||||
idempotencyKey: "test:sender:worker",
|
||||
});
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(1);
|
||||
expect(provider.sentEmails).toHaveLength(1);
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
});
|
||||
|
||||
it("uses the updated business name for the very next email after a rename — no restart, no stale cache", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "First stamp.",
|
||||
idempotencyKey: "test:sender:rename-before",
|
||||
});
|
||||
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
|
||||
await prisma.business.update({ where: { id: business.businessId }, data: { name: "Renamed Barbershop" } });
|
||||
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "Second stamp.",
|
||||
idempotencyKey: "test:sender:rename-after",
|
||||
});
|
||||
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
|
||||
expect(provider.sentEmails).toHaveLength(2);
|
||||
expect(provider.sentEmails[1]?.sender.name).toBe("Renamed Barbershop");
|
||||
});
|
||||
|
||||
it("never lets one business's notification resolve another business's name (no accidental cross-business branding)", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
await prisma.business.update({ where: { id: otherBusiness.businessId }, data: { name: "Other Business Name" } });
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "Stamp for business A.",
|
||||
idempotencyKey: "test:sender:isolation",
|
||||
});
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||
expect(result.sent).toBe(1);
|
||||
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||
expect(provider.sentEmails[0]?.sender.name).not.toBe("Other Business Name");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveSenderName } from "../sender-identity";
|
||||
|
||||
describe("resolveSenderName", () => {
|
||||
const originalEmailFromName = process.env.EMAIL_FROM_NAME;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EMAIL_FROM_NAME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEmailFromName === undefined) delete process.env.EMAIL_FROM_NAME;
|
||||
else process.env.EMAIL_FROM_NAME = originalEmailFromName;
|
||||
});
|
||||
|
||||
it("uses the business name when present", () => {
|
||||
expect(resolveSenderName("Blake Heritage Barbershop")).toBe("Blake Heritage Barbershop");
|
||||
});
|
||||
|
||||
it("prefers the business name even when EMAIL_FROM_NAME is also set", () => {
|
||||
process.env.EMAIL_FROM_NAME = "Some Configured Name";
|
||||
expect(resolveSenderName("Riverside Barbershop")).toBe("Riverside Barbershop");
|
||||
});
|
||||
|
||||
it("falls back to EMAIL_FROM_NAME only when no business name is available", () => {
|
||||
process.env.EMAIL_FROM_NAME = "Emergency Fallback Name";
|
||||
expect(resolveSenderName(null)).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName(undefined)).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName("")).toBe("Emergency Fallback Name");
|
||||
expect(resolveSenderName(" ")).toBe("Emergency Fallback Name");
|
||||
});
|
||||
|
||||
it("falls back to the neutral name when neither a business name nor EMAIL_FROM_NAME is available", () => {
|
||||
expect(resolveSenderName(null)).toBe("Booking Notifications");
|
||||
expect(resolveSenderName(undefined)).toBe("Booking Notifications");
|
||||
});
|
||||
|
||||
it("never defaults to the internal product name at any fallback tier", () => {
|
||||
// Tier 1 (business name present), tier 3 (nothing configured at all) —
|
||||
// neither branch has any "NextChair" literal anywhere in this
|
||||
// function's source, so this is really a regression guard: if someone
|
||||
// later "helpfully" reintroduces a NextChair default, this fails.
|
||||
expect(resolveSenderName("Blake Heritage Barbershop")).not.toBe("NextChair");
|
||||
expect(resolveSenderName(null)).not.toBe("NextChair");
|
||||
expect(resolveSenderName(undefined)).not.toBe("NextChair");
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,19 @@ describe("BrevoEmailProvider", () => {
|
||||
username: "test-user",
|
||||
password: "test-pass",
|
||||
fromAddress: "noreply@example.com",
|
||||
fromName: "Test Business",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
});
|
||||
|
||||
const params = { to: { email: "customer@example.com" }, subject: "Hi", html: "<p>Hi</p>", text: "Hi" };
|
||||
const params = {
|
||||
to: { email: "customer@example.com" },
|
||||
sender: { name: "Blake Heritage Barbershop" },
|
||||
subject: "Hi",
|
||||
html: "<p>Hi</p>",
|
||||
text: "Hi",
|
||||
};
|
||||
|
||||
it("reports success with the provider message id on a clean send", async () => {
|
||||
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||
@@ -72,4 +77,12 @@ describe("BrevoEmailProvider", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("test-pass");
|
||||
expect(JSON.stringify(result)).not.toContain("test-user");
|
||||
});
|
||||
|
||||
it("sends with the per-message sender name and the provider's own configured address, ignoring any sender.email", async () => {
|
||||
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||
await provider.sendEmail({ ...params, sender: { name: "Riverside Barbershop", email: "someone-else@other-domain.com" } });
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,16 @@ import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-pr
|
||||
* BrevoApiEmailProvider) can be added without touching
|
||||
* NotificationService or the background processor — that's the whole
|
||||
* point of the provider abstraction.
|
||||
*
|
||||
* Sender identity (Phase 8 follow-up, see docs/phase-8-email-sender-summary.md):
|
||||
* this config carries only the sender *address* (`fromAddress`) — a
|
||||
* static provider setting, since it must be a domain/address actually
|
||||
* verified with the SMTP relay. The visible sender *name* is no longer
|
||||
* part of this config at all; it arrives per-message via
|
||||
* `SendEmailParams.sender.name`, resolved fresh for every send by
|
||||
* render-email.ts from the notification's own business. This file
|
||||
* deliberately has no Prisma import and no business lookup of any kind —
|
||||
* see that same doc for why the resolution lives one layer up.
|
||||
*/
|
||||
export interface BrevoEmailProviderConfig {
|
||||
host: string;
|
||||
@@ -45,7 +55,6 @@ export interface BrevoEmailProviderConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
}
|
||||
|
||||
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
|
||||
@@ -64,7 +73,6 @@ export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig
|
||||
username,
|
||||
password,
|
||||
fromAddress,
|
||||
fromName: process.env.EMAIL_FROM_NAME ?? "NextChair",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,11 +98,9 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
readonly name = "brevo";
|
||||
private readonly transporter: nodemailer.Transporter;
|
||||
private readonly fromAddress: string;
|
||||
private readonly fromName: string;
|
||||
|
||||
constructor(config: BrevoEmailProviderConfig) {
|
||||
this.fromAddress = config.fromAddress;
|
||||
this.fromName = config.fromName;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
@@ -109,7 +115,11 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: { address: this.fromAddress, name: this.fromName },
|
||||
// The address is always this provider's own env-configured,
|
||||
// verified-with-the-relay value — `params.sender.email` is
|
||||
// deliberately ignored (see EmailSender's own header). Only the
|
||||
// display name comes from the caller.
|
||||
from: { address: this.fromAddress, name: params.sender.name },
|
||||
to: { address: params.to.email, name: params.to.name },
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
|
||||
@@ -16,6 +16,7 @@ export class ConsoleEmailProvider implements EmailProvider {
|
||||
console.log(
|
||||
[
|
||||
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
|
||||
`From: ${params.sender.name}`,
|
||||
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
|
||||
`Subject: ${params.subject}`,
|
||||
"",
|
||||
|
||||
@@ -12,8 +12,25 @@ export interface EmailAddress {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible sender identity for one message — resolved per-send by the
|
||||
* notification/rendering layer (see render-email.ts's `resolveSenderName`),
|
||||
* never baked into static provider config. `email` is optional and, for
|
||||
* BrevoEmailProvider specifically, ignored: the sender *address* stays
|
||||
* exclusively environment-configured (`EMAIL_FROM_ADDRESS`) because it
|
||||
* must match a domain/address actually verified with the SMTP relay — see
|
||||
* that provider's own header. `name` is present here so a future provider
|
||||
* that *can* vary its address per-message isn't blocked by this shape.
|
||||
*/
|
||||
export interface EmailSender {
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailParams {
|
||||
to: EmailAddress;
|
||||
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
|
||||
sender: EmailSender;
|
||||
subject: string;
|
||||
html: string;
|
||||
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { EmailProvider } from "./providers/email-provider";
|
||||
import type { PushProvider } from "./providers/push-provider";
|
||||
import { getOwnerUserIds } from "./recipients";
|
||||
import { renderEmailForNotification } from "./render-email";
|
||||
import { getBranding } from "@/lib/business/branding";
|
||||
|
||||
/**
|
||||
* The background notification processor — the one place EMAIL and PUSH
|
||||
@@ -161,6 +162,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
|
||||
const sendResult = await emailProvider.sendEmail({
|
||||
to: { email: recipientEmail },
|
||||
sender: email.sender,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
text: email.text,
|
||||
@@ -202,10 +204,17 @@ async function processPushDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
let lastFailureReason = "";
|
||||
let anyRetryable = false;
|
||||
|
||||
// Every notify() call sets a subject (see notification.service.ts),
|
||||
// so this fallback is defensive rather than a normal path — but a
|
||||
// push notification's title is genuinely user-visible (shown by the
|
||||
// OS), so it still needs the business name, not the internal product
|
||||
// name, when reached.
|
||||
const fallbackTitle = delivery.notification.subject ?? (await getBranding()).name;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const sendResult = await pushProvider.sendPush({
|
||||
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
|
||||
title: delivery.notification.subject ?? "NextChair",
|
||||
title: fallbackTitle,
|
||||
body: delivery.notification.bodyText,
|
||||
deepLinkPath: delivery.notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { renderBookingCancelledEmail, renderBookingConfirmedEmail, renderBookingPendingApprovalEmail, renderBookingReminderEmail, renderBookingRescheduledEmail } from "./templates/booking-emails";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
renderBookingPendingApprovalEmail,
|
||||
renderBookingReminderEmail,
|
||||
renderBookingRescheduledEmail,
|
||||
} from "./templates/booking-emails";
|
||||
import type { AppointmentEmailData } from "./templates/booking-emails";
|
||||
import { renderGenericNotificationEmail } from "./templates/generic-email";
|
||||
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "./templates/loyalty-emails";
|
||||
import {
|
||||
renderLoyaltyRewardEarnedEmail,
|
||||
renderLoyaltyRewardRedeemedEmail,
|
||||
} from "./templates/loyalty-emails";
|
||||
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
|
||||
import type { RenderedEmail } from "./templates/shared";
|
||||
import { resolveSenderName } from "./sender-identity";
|
||||
import type { EmailSender } from "./providers/email-provider";
|
||||
|
||||
export interface RenderedEmailWithSender extends RenderedEmail {
|
||||
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
|
||||
sender: EmailSender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a stored Notification row, builds the actual EMAIL to send —
|
||||
@@ -17,17 +33,35 @@ import type { RenderedEmail } from "./templates/shared";
|
||||
* (e.g. a since-updated business name) rather than a stale snapshot. This
|
||||
* is the file that dispatches by NotificationType; the templates/ folder
|
||||
* itself is pure and knows nothing about Prisma.
|
||||
*
|
||||
* The sender *name* (see RenderedEmailWithSender) is resolved here too,
|
||||
* from this exact same `business` row — scoped by `notification.businessId`
|
||||
* via `findUnique`, never a global `findFirst()`, so one business can
|
||||
* never accidentally supply another's branding to a misdirected send.
|
||||
*/
|
||||
export async function renderEmailForNotification(
|
||||
prisma: PrismaClient,
|
||||
notification: { id: string; type: NotificationType; businessId: string; appointmentId: string | null; loyaltyRewardId: string | null; subject: string | null; bodyText: string; deepLinkPath: string | null; recipientUserId: string | null; recipientCustomerId: string | null },
|
||||
): Promise<RenderedEmail | null> {
|
||||
notification: {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
businessId: string;
|
||||
appointmentId: string | null;
|
||||
loyaltyRewardId: string | null;
|
||||
subject: string | null;
|
||||
bodyText: string;
|
||||
deepLinkPath: string | null;
|
||||
recipientUserId: string | null;
|
||||
recipientCustomerId: string | null;
|
||||
},
|
||||
): Promise<RenderedEmailWithSender | null> {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: notification.businessId },
|
||||
include: { locations: true, settings: true },
|
||||
});
|
||||
if (!business) return null;
|
||||
|
||||
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||
|
||||
const businessInfo = {
|
||||
name: business.name,
|
||||
logoUrl: business.logoUrl ?? undefined,
|
||||
@@ -39,47 +73,58 @@ export async function renderEmailForNotification(
|
||||
};
|
||||
|
||||
const recipientName = await resolveRecipientName(prisma, notification);
|
||||
const rendered = await renderBody();
|
||||
return { ...rendered, sender };
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.BOOKING_CONFIRMED:
|
||||
case NotificationType.BOOKING_PENDING_APPROVAL:
|
||||
case NotificationType.BOOKING_RESCHEDULED:
|
||||
case NotificationType.BOOKING_CANCELLED:
|
||||
case NotificationType.BOOKING_REMINDER: {
|
||||
const data = await buildAppointmentEmailData(prisma, notification, {
|
||||
async function renderBody(): Promise<RenderedEmail> {
|
||||
switch (notification.type) {
|
||||
case NotificationType.BOOKING_CONFIRMED:
|
||||
case NotificationType.BOOKING_PENDING_APPROVAL:
|
||||
case NotificationType.BOOKING_RESCHEDULED:
|
||||
case NotificationType.BOOKING_CANCELLED:
|
||||
case NotificationType.BOOKING_REMINDER: {
|
||||
const data = await buildAppointmentEmailData(prisma, notification, {
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED)
|
||||
return renderBookingConfirmedEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL)
|
||||
return renderBookingPendingApprovalEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_RESCHEDULED)
|
||||
return renderBookingRescheduledEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_CANCELLED)
|
||||
return renderBookingCancelledEmail(data);
|
||||
return renderBookingReminderEmail(data);
|
||||
}
|
||||
|
||||
case NotificationType.LOYALTY_REWARD_EARNED:
|
||||
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
||||
const data = await buildLoyaltyRewardEmailData(prisma, notification, {
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
||||
? renderLoyaltyRewardEarnedEmail(data)
|
||||
: renderLoyaltyRewardRedeemedEmail(data);
|
||||
}
|
||||
|
||||
default:
|
||||
return renderFallback();
|
||||
}
|
||||
|
||||
function renderFallback(): RenderedEmail {
|
||||
return renderGenericNotificationEmail({
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED) return renderBookingConfirmedEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL) return renderBookingPendingApprovalEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_RESCHEDULED) return renderBookingRescheduledEmail(data);
|
||||
if (notification.type === NotificationType.BOOKING_CANCELLED) return renderBookingCancelledEmail(data);
|
||||
return renderBookingReminderEmail(data);
|
||||
}
|
||||
|
||||
case NotificationType.LOYALTY_REWARD_EARNED:
|
||||
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
||||
const data = await buildLoyaltyRewardEmailData(prisma, notification, { business: businessInfo, recipientName });
|
||||
if (!data) return renderFallback();
|
||||
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
||||
? renderLoyaltyRewardEarnedEmail(data)
|
||||
: renderLoyaltyRewardRedeemedEmail(data);
|
||||
}
|
||||
|
||||
default:
|
||||
return renderFallback();
|
||||
}
|
||||
|
||||
function renderFallback(): RenderedEmail {
|
||||
return renderGenericNotificationEmail({
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +140,10 @@ async function resolveRecipientName(
|
||||
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
|
||||
}
|
||||
if (notification.recipientUserId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: notification.recipientUserId }, select: { name: true } });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: notification.recipientUserId },
|
||||
select: { name: true },
|
||||
});
|
||||
if (user?.name) return user.name;
|
||||
}
|
||||
return "there";
|
||||
@@ -113,15 +161,28 @@ async function buildAppointmentEmailData(
|
||||
});
|
||||
if (!appointment) return null;
|
||||
|
||||
const totalPrice = appointment.services.reduce((sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking), 0);
|
||||
const totalMinutes = appointment.services.reduce((sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes, 0);
|
||||
const totalPrice = appointment.services.reduce(
|
||||
(sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking),
|
||||
0,
|
||||
);
|
||||
const totalMinutes = appointment.services.reduce(
|
||||
(sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
durationLabel: `${totalMinutes} min`,
|
||||
totalLabel: `£${totalPrice.toFixed(2)}`,
|
||||
appointmentRef: appointment.id.slice(-8),
|
||||
@@ -134,7 +195,9 @@ async function buildLoyaltyRewardEmailData(
|
||||
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
|
||||
): Promise<LoyaltyRewardEmailData | null> {
|
||||
if (!notification.loyaltyRewardId) return null;
|
||||
const reward = await prisma.loyaltyReward.findUnique({ where: { id: notification.loyaltyRewardId } });
|
||||
const reward = await prisma.loyaltyReward.findUnique({
|
||||
where: { id: notification.loyaltyRewardId },
|
||||
});
|
||||
if (!reward) return null;
|
||||
|
||||
return {
|
||||
@@ -142,7 +205,11 @@ async function buildLoyaltyRewardEmailData(
|
||||
rewardLabel: reward.label,
|
||||
rewardDescription: reward.label,
|
||||
expiresLabel: reward.expiresAt
|
||||
? reward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
||||
? reward.expiresAt.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: undefined,
|
||||
loyaltyCardPath: "/home",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Resolves the visible email sender *name* — pure and DB-free (the caller,
|
||||
* render-email.ts, has already loaded the business); kept as its own tiny
|
||||
* module so the priority order is independently unit-testable. Never
|
||||
* returns the internal product name. See
|
||||
* docs/phase-8-email-sender-summary.md for the full priority-order
|
||||
* rationale.
|
||||
*/
|
||||
|
||||
const NEUTRAL_FALLBACK_SENDER_NAME = "Booking Notifications";
|
||||
|
||||
/**
|
||||
* Priority: 1) the installed business's own name, 2) EMAIL_FROM_NAME as an
|
||||
* emergency-only fallback (never required to be kept in sync with the
|
||||
* business name — see .env.example), 3) a neutral, non-product name. A
|
||||
* normally-installed application always resolves tier 1 — every
|
||||
* Notification is business-scoped (see prisma/schema.prisma,
|
||||
* Notification.businessId is required) — so tiers 2/3 exist purely as
|
||||
* defensive fallbacks for a business row somehow having a blank name, or
|
||||
* for a future notification type with no business association at all.
|
||||
*/
|
||||
export function resolveSenderName(businessName: string | null | undefined): string {
|
||||
const trimmedBusinessName = businessName?.trim();
|
||||
if (trimmedBusinessName) return trimmedBusinessName;
|
||||
|
||||
const envFallback = process.env.EMAIL_FROM_NAME?.trim();
|
||||
if (envFallback) return envFallback;
|
||||
|
||||
return NEUTRAL_FALLBACK_SENDER_NAME;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.staffName},`),
|
||||
paragraph(
|
||||
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
||||
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||
),
|
||||
].join("");
|
||||
|
||||
@@ -37,7 +37,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
||||
bodyLines: [
|
||||
`Hi ${data.staffName},`,
|
||||
"",
|
||||
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
||||
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||
],
|
||||
actionLabel: "Set Your Password",
|
||||
actionUrl,
|
||||
|
||||
Reference in new issue
Block a user