This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

@@ -0,0 +1,187 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { encryptToken } from "@/lib/security/token-encryption";
import { MockCalendarProvider } from "../mock-calendar.provider";
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
/**
* Exercises the real sync policy (create/update/cancel/duplicate
* prevention/reassignment) against a real database, with a
* `MockCalendarProvider` standing in for Google — see that file's own
* header for why (the brief: "Do not call real Google Calendar APIs in
* automated tests").
*/
describe("calendar-sync.service (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let provider: MockCalendarProvider;
beforeAll(() => {
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
});
beforeEach(async () => {
business = await createTestBusiness();
const staff = await createTestStaff(business);
staffId = staff.staffId;
const customer = await createTestCustomer(business);
customerId = customer.customerId;
provider = new MockCalendarProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function connectCalendar(): Promise<string> {
const connection = await prisma.staffCalendarConnection.create({
data: {
staffId,
externalAccountId: "mock-account",
accountEmail: "barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token"),
encryptedRefreshToken: encryptToken("mock-refresh-token"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
return connection.id;
}
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId: overrides.staffId ?? staffId,
customerId,
status,
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
}
it("creates a remote event for a CONFIRMED appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
expect(provider.events.size).toBe(1);
const [providerEvent] = [...provider.events.values()];
expect(providerEvent?.summary).toContain("Test Customer");
});
it("does not sync a PENDING appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.PENDING);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
await syncAppointmentSafely(prisma, appointment.id, provider);
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
expect(events).toHaveLength(1);
expect(provider.events.size).toBe(1);
});
it("deletes the remote event when the appointment is cancelled", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(0);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
});
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
await connectCalendar();
const otherStaff = await createTestStaff(business);
const otherConnection = await prisma.staffCalendarConnection.create({
data: {
staffId: otherStaff.staffId,
externalAccountId: "mock-account-2",
accountEmail: "other-barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token-2"),
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
await syncAppointmentSafely(prisma, appointment.id, provider);
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connection: { staffId } },
});
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
});
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
});
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
await connectCalendar();
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
const pending = await createAppointment(AppointmentStatus.PENDING);
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
const result = await runInitialSync(prisma, staffId, provider);
expect(result.synced).toBe(1);
expect(provider.events.size).toBe(1);
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
expect(syncedEvent).toBeTruthy();
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
expect(pendingEvent).toBeNull();
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
expect(cancelledEvent).toBeNull();
});
it("does nothing when no calendar connection exists for the staff member", async () => {
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
});
@@ -0,0 +1,32 @@
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* Provider-neutral operations `CalendarSyncService` calls — Google is the
* only implementation today (`google-calendar.provider.ts`), but nothing
* in the sync service or appointment Server Actions calls Google's API
* directly (see the brief's explicit "Do not call Google APIs directly
* from appointment Server Actions"), so a second provider is additive.
*/
export interface CalendarProvider {
name: string;
/** OAuth authorization URL the browser is redirected to — `state` is an opaque, server-generated CSRF/replay token the callback route re-verifies. */
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string;
/** Exchanges the callback's `code` for tokens, and resolves which external account this actually is. */
exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens>;
refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken>;
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void>;
/** Best-effort server-side revocation at disconnect — see the brief's "Revoke credentials where possible." */
revoke(refreshToken: string): Promise<void>;
}
@@ -0,0 +1,257 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
import type { CalendarProvider } from "./calendar-provider";
import { GoogleCalendarProvider } from "./google-calendar.provider";
import { CalendarProviderError } from "./types";
import type { CalendarEventInput } from "./types";
/**
* Appointment → Google Calendar sync policy — the one place that decides
* *whether* and *how* an appointment's status maps to a calendar event.
* Provider-neutral: everything here goes through `CalendarProvider`, never
* a Google-specific call directly (see calendar-provider.ts). Every
* public function here is safe to call from a Server Action after an
* appointment mutation commits — sync failures are caught and recorded,
* never thrown, so a Google outage can never roll back or block a real
* booking change (see the brief's explicit requirement).
*/
function getProvider(): CalendarProvider {
// Google is the only provider today — see CalendarProvider's own header
// for why a second one is additive, not a rewrite, when needed.
return new GoogleCalendarProvider();
}
async function getValidAccessToken(
prisma: PrismaClient,
connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null },
provider: CalendarProvider,
): Promise<string> {
const stillValid =
connection.encryptedAccessToken && connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() - Date.now() > 60_000;
if (stillValid) return decryptToken(connection.encryptedAccessToken!);
const refreshToken = decryptToken(connection.encryptedRefreshToken);
const refreshed = await provider.refreshAccessToken(refreshToken);
await prisma.staffCalendarConnection.update({
where: { id: connection.id },
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
});
return refreshed.accessToken;
}
function buildEventContent(appointment: {
id: string;
startAt: Date;
endAt: Date;
status: AppointmentStatus;
customer: { firstName: string; lastName: string };
services: { service: { name: string } }[];
business: { name: string; locations: { addressLine1: string; city: string; postalCode: string }[] };
}): CalendarEventInput {
const serviceNames = appointment.services.map((s) => s.service.name).join(", ");
const location = appointment.business.locations[0];
const locationLine = location ? `${location.addressLine1}, ${location.city} ${location.postalCode}` : undefined;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
const descriptionLines = [
`Customer: ${appointment.customer.firstName} ${appointment.customer.lastName}`,
`Service: ${serviceNames}`,
`Business: ${appointment.business.name}`,
locationLine ? `Address: ${locationLine}` : undefined,
`Reference: ${appointment.id}`,
appUrl ? `View: ${appUrl}/staff/appointments/${appointment.id}` : undefined,
appointment.status === AppointmentStatus.NO_SHOW ? "Status: customer did not show up." : undefined,
].filter((line): line is string => !!line);
return {
summary: `${serviceNames} — ${appointment.customer.firstName} ${appointment.customer.lastName}`,
description: descriptionLines.join("\n"),
location: locationLine,
startAt: appointment.startAt,
endAt: appointment.endAt,
};
}
/**
* Policy: PENDING appointments are never synced (an unapproved booking
* shouldn't occupy the barber's personal calendar); CANCELLED deletes the
* remote event outright (a cancelled slot has no reason to still show
* busy); everything else (CONFIRMED/CHECKED_IN/IN_PROGRESS/COMPLETED/
* NO_SHOW) creates or updates the event, with NO_SHOW additionally noting
* the outcome in the description rather than removing the event (the
* barber's calendar should still show they were scheduled to work).
*/
async function syncOneConnection(
prisma: PrismaClient,
params: {
appointmentId: string;
connection: {
id: string;
encryptedAccessToken: string | null;
encryptedRefreshToken: string;
tokenExpiresAt: Date | null;
selectedCalendarId: string;
};
provider: CalendarProvider;
},
): Promise<void> {
const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId },
include: {
customer: { select: { firstName: true, lastName: true } },
services: { include: { service: { select: { name: true } } } },
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
},
});
if (!appointment) return;
const provider = params.provider;
const existing = await prisma.appointmentCalendarEvent.findUnique({
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
});
try {
if (appointment.status === AppointmentStatus.PENDING) return;
const accessToken = await getValidAccessToken(prisma, params.connection, provider);
if (appointment.status === AppointmentStatus.CANCELLED) {
if (existing) {
await provider.deleteEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId });
await prisma.appointmentCalendarEvent.update({
where: { id: existing.id },
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
});
}
return;
}
const event = buildEventContent(appointment);
if (existing && existing.status !== CalendarSyncStatus.CANCELLED) {
await provider.updateEvent({
accessToken,
calendarId: params.connection.selectedCalendarId,
providerEventId: existing.providerEventId,
event,
});
await prisma.appointmentCalendarEvent.update({
where: { id: existing.id },
data: { status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, sourceVersion: { increment: 1 } },
});
} else {
const created = await provider.createEvent({ accessToken, calendarId: params.connection.selectedCalendarId, event });
await prisma.appointmentCalendarEvent.upsert({
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
create: {
appointmentId: params.appointmentId,
connectionId: params.connection.id,
providerEventId: created.providerEventId,
providerCalendarId: params.connection.selectedCalendarId,
status: CalendarSyncStatus.SYNCED,
},
update: {
providerEventId: created.providerEventId,
providerCalendarId: params.connection.selectedCalendarId,
status: CalendarSyncStatus.SYNCED,
lastSyncedAt: new Date(),
lastSyncError: null,
},
});
}
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } });
} catch (error) {
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
if (existing) {
await prisma.appointmentCalendarEvent
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
.catch(() => {});
}
}
}
/**
* Call after any appointment mutation that should be reflected in
* calendars — confirmation, approval, reschedule, service change,
* cancellation, no-show. Never throws; a Google failure is recorded on
* the connection/event rows for the staff settings page to surface, not
* propagated back to the caller (see this file's own header). If the
* appointment was reassigned to a different staff member, the previous
* staff member's event (if any) is cancelled first so responsibility
* moves cleanly rather than leaving a stale duplicate.
*/
export async function syncAppointmentSafely(
prisma: PrismaClient,
appointmentId: string,
provider: CalendarProvider = getProvider(),
): Promise<void> {
const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { staffId: true } });
if (!appointment) return;
const staleEvents = await prisma.appointmentCalendarEvent.findMany({
where: { appointmentId, status: { not: CalendarSyncStatus.CANCELLED }, connection: { staffId: { not: appointment.staffId } } },
include: { connection: true },
});
for (const stale of staleEvents) {
await syncOneConnection(prisma, {
appointmentId,
provider,
connection: {
id: stale.connection.id,
encryptedAccessToken: stale.connection.encryptedAccessToken,
encryptedRefreshToken: stale.connection.encryptedRefreshToken,
tokenExpiresAt: stale.connection.tokenExpiresAt,
selectedCalendarId: stale.connection.selectedCalendarId,
},
}).catch(() => {});
}
// A reassigned-away staff member's event is deleted via the CANCELLED
// path above regardless of the appointment's own current status — it's
// no longer this connection's appointment to reflect.
if (staleEvents.length > 0) {
await prisma.appointmentCalendarEvent.updateMany({
where: { id: { in: staleEvents.map((e) => e.id) } },
data: { status: CalendarSyncStatus.CANCELLED },
});
}
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId: appointment.staffId } });
if (!connection || connection.revokedAt) return;
await syncOneConnection(prisma, { appointmentId, connection, provider });
}
/**
* Sync every future appointment for a newly connected staff member — see
* the brief's "Initial sync... default to the next 90 days." `provider`
* is injectable (defaults to the real Google provider) purely so tests
* can pass a `MockCalendarProvider` — see
* src/services/calendar/mock-calendar.provider.ts and the brief's "Do
* not call real Google Calendar APIs in automated tests."
*/
export async function runInitialSync(
prisma: PrismaClient,
staffId: string,
provider: CalendarProvider = getProvider(),
): Promise<{ synced: number }> {
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (!connection) return { synced: 0 };
const appointments = await prisma.appointment.findMany({
where: {
staffId,
startAt: { gte: new Date(), lte: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
status: { notIn: [AppointmentStatus.PENDING, AppointmentStatus.CANCELLED] },
},
select: { id: true },
});
for (const appointment of appointments) {
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
}
return { synced: appointments.length };
}
@@ -0,0 +1,225 @@
import type { CalendarProvider } from "./calendar-provider";
import { CalendarProviderError } from "./types";
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* Google Calendar over its plain REST API (OAuth 2.0 + Calendar API v3)
* via `fetch` — no `googleapis` SDK dependency, matching this app's
* existing pattern of building directly against a documented REST
* contract rather than adding a heavy client library (see
* brevo-email-provider.ts's own header for the same reasoning applied to
* email). Minimum scopes: `calendar.events` (create/update/delete events)
* and `calendar.readonly` (list calendars to choose a destination) —
* deliberately not the full `calendar` scope, which also grants calendar
* *settings* management this app never needs.
*
* Unverified against a live Google account in this environment — no
* OAuth credentials were available to test against. See
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
*/
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
interface GoogleErrorBody {
error?: string | { message?: string; errors?: { reason?: string }[] };
error_description?: string;
}
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
const reason =
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
if (status === 401 || reason === "authError") {
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true);
}
if (status === 403 && reason === "insufficientPermissions") {
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false);
}
if (status === 403 || status === 429) {
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true);
}
if (status === 404) {
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
}
if (status === 400) {
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false);
}
if (status >= 500) {
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true);
}
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true);
}
async function googleFetch(url: string, init: RequestInit): Promise<Response> {
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new CalendarProviderError("TIMEOUT", "Could not reach Google Calendar.", true);
}
if (!response.ok) {
let body: GoogleErrorBody | undefined;
try {
body = await response.json();
} catch {
// Non-JSON error body — mapGoogleError below still has the status code.
}
throw mapGoogleError(response.status, body);
}
return response;
}
interface GoogleConfig {
clientId: string;
clientSecret: string;
}
function loadConfig(): GoogleConfig {
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET must be set to use Google Calendar sync.");
}
return { clientId, clientSecret };
}
function toGoogleEventBody(event: CalendarEventInput) {
return {
summary: event.summary,
description: event.description,
location: event.location,
start: { dateTime: event.startAt.toISOString() },
end: { dateTime: event.endAt.toISOString() },
// Never public — see the brief's "Events must not be public."
visibility: "private",
};
}
export class GoogleCalendarProvider implements CalendarProvider {
name = "google";
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string {
const { clientId } = loadConfig();
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", params.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", SCOPES);
url.searchParams.set("access_type", "offline");
// Forces Google to reissue a refresh token even for a user who
// previously connected — otherwise a reconnect after disconnect can
// silently get no refresh token at all.
url.searchParams.set("prompt", "consent");
url.searchParams.set("state", params.state);
return url.toString();
}
async exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens> {
const { clientId, clientSecret } = loadConfig();
const response = await googleFetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: params.code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: params.redirectUri,
grant_type: "authorization_code",
}),
});
const tokens = await response.json();
if (!tokens.refresh_token) {
throw new CalendarProviderError(
"MISSING_REFRESH_TOKEN",
"Google didn't return a refresh token — please try connecting again.",
false,
);
}
const userInfoResponse = await googleFetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = await userInfoResponse.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
externalAccountId: userInfo.id,
accountEmail: userInfo.email,
};
}
async refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken> {
const { clientId, clientSecret } = loadConfig();
const response = await googleFetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
}),
});
const tokens = await response.json();
return { accessToken: tokens.access_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000) };
}
async listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]> {
const response = await googleFetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json();
const items: { id: string; summary: string; primary?: boolean; accessRole: string }[] = data.items ?? [];
return items
.filter((item) => item.accessRole === "owner" || item.accessRole === "writer")
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
}
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const response = await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
{
method: "POST",
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(toGoogleEventBody(params.event)),
},
);
const created = await response.json();
return { providerEventId: created.id };
}
async updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void> {
await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(toGoogleEventBody(params.event)),
},
);
}
async deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void> {
try {
await googleFetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${params.accessToken}` } },
);
} catch (error) {
// Already deleted on the Google side (e.g. the owner removed it
// manually) — not a failure from this app's point of view.
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return;
throw error;
}
}
async revoke(refreshToken: string): Promise<void> {
try {
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST" });
} catch {
// Best-effort — see the brief's "Revoke credentials where possible." A failure here doesn't block local disconnect.
}
}
}
@@ -0,0 +1,57 @@
import type { CalendarProvider } from "./calendar-provider";
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
/**
* In-memory `CalendarProvider` for tests — never makes a real network
* call, per the brief's "Do not call real Google Calendar APIs in
* automated tests." Records every create/update/delete so a test can
* assert on sync behavior (idempotency, retry, error mapping) without a
* live Google account.
*/
export class MockCalendarProvider implements CalendarProvider {
name = "mock";
events = new Map<string, CalendarEventInput>();
private nextEventId = 1;
public calendars: RemoteCalendarInfo[] = [{ id: "primary", name: "Primary Calendar", writable: true, primary: true }];
buildAuthorizationUrl(): string {
return "https://mock.local/authorize";
}
async exchangeCodeForTokens(): Promise<ExchangedTokens> {
return {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
expiresAt: new Date(Date.now() + 3600_000),
externalAccountId: "mock-account-id",
accountEmail: "mock@example.com",
};
}
async refreshAccessToken(): Promise<RefreshedAccessToken> {
return { accessToken: "mock-access-token-refreshed", expiresAt: new Date(Date.now() + 3600_000) };
}
async listWritableCalendars(): Promise<RemoteCalendarInfo[]> {
return this.calendars;
}
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
const id = `mock-event-${this.nextEventId++}`;
this.events.set(id, params.event);
return { providerEventId: id };
}
async updateEvent(params: { providerEventId: string; event: CalendarEventInput }): Promise<void> {
this.events.set(params.providerEventId, params.event);
}
async deleteEvent(params: { providerEventId: string }): Promise<void> {
this.events.delete(params.providerEventId);
}
async revoke(): Promise<void> {
// No-op — nothing to revoke in-memory.
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Provider-neutral shapes for one-way (business → calendar) appointment
* sync — see calendar-provider.ts for the interface these compose, and
* docs/phase-8-summary.md for the overall architecture.
*/
export interface CalendarEventInput {
summary: string;
description: string;
location?: string;
startAt: Date;
endAt: Date;
}
export interface RemoteCalendarInfo {
id: string;
name: string;
/** Only calendars the connected account can write to are ever offered — see the brief's "Do not allow read-only calendars." */
writable: boolean;
primary: boolean;
}
export interface ExchangedTokens {
accessToken: string;
refreshToken: string;
/** Absent when the provider didn't return one (e.g. a token refresh call, which doesn't reissue a refresh token). */
expiresAt: Date;
externalAccountId: string;
accountEmail: string;
}
export interface RefreshedAccessToken {
accessToken: string;
expiresAt: Date;
}
export type CalendarProviderErrorCode =
| "CONSENT_DENIED"
| "TOKEN_EXPIRED"
| "TOKEN_REVOKED"
| "MISSING_REFRESH_TOKEN"
| "CALENDAR_NOT_FOUND"
| "CALENDAR_NOT_WRITABLE"
| "RATE_LIMITED"
| "PROVIDER_OUTAGE"
| "INVALID_EVENT"
| "TIMEOUT"
| "UNKNOWN";
/** Never exposes a raw provider response — see the brief's "Do not expose raw provider responses." */
export class CalendarProviderError extends Error {
code: CalendarProviderErrorCode;
retryable: boolean;
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) {
super(message);
this.name = "CalendarProviderError";
this.code = code;
this.retryable = retryable;
}
}
@@ -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. */
+10 -1
View File
@@ -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,
});
+114 -47
View File
@@ -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,