Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
This commit is contained in:
116 files changed
+3089
-766
No files matched your search
@@ -16,6 +16,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
|
||||
@@ -64,6 +66,10 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noShow = (await getNoShowStandings(prisma, { businessId: context.businessId, customerIds: [appointment.customerId] })).get(
|
||||
appointment.customerId,
|
||||
);
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
@@ -112,6 +118,7 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
|
||||
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
@@ -117,6 +118,11 @@ export default async function OwnerCalendarPage({
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const noShowStandings = await getNoShowStandings(prisma, {
|
||||
businessId: context.businessId,
|
||||
customerIds: appointments.map((a) => a.customerId),
|
||||
});
|
||||
|
||||
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
|
||||
|
||||
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
|
||||
@@ -162,6 +168,7 @@ export default async function OwnerCalendarPage({
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
|
||||
}));
|
||||
|
||||
return { workingWindow, blocks, wholeDayOff, staffAppointments };
|
||||
@@ -235,7 +242,7 @@ export default async function OwnerCalendarPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={context.businessId} timezone={businessTimezone} />
|
||||
<RemindersLink businessId={context.businessId} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -283,6 +290,7 @@ export default async function OwnerCalendarPage({
|
||||
)}
|
||||
hasNotes={!!a.notes}
|
||||
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
|
||||
noShowFlagged={noShowStandings.get(a.customerId)?.flagged ?? false}
|
||||
actions={
|
||||
<Link
|
||||
href={`/owner/appointments/${a.id}`}
|
||||
|
||||
@@ -41,6 +41,8 @@ export default async function OwnerBookingRulesPage() {
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
noShowFlagThreshold: rule.noShowFlagThreshold,
|
||||
noShowBlockThreshold: rule.noShowBlockThreshold,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function OwnerBusinessDetailsPage() {
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
|
||||
legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR",
|
||||
legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR",
|
||||
whatsappReminderLeadHours: business.settings?.whatsappReminderLeadHours ?? 24,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
|
||||
@@ -41,8 +41,6 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
rewardLabel: programme.rewardLabel ?? undefined,
|
||||
maxRewardValue: programme.maxRewardValue?.toString(),
|
||||
freeServiceId: programme.freeServiceId ?? undefined,
|
||||
autoApplyReward: programme.autoApplyReward,
|
||||
requiresStaffApproval: programme.requiresStaffApproval,
|
||||
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
|
||||
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
|
||||
earnOnCancelled: programme.earnOnCancelled,
|
||||
@@ -63,8 +61,6 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
stampsRequired: 10,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE" as const,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
|
||||
@@ -51,6 +53,10 @@ export default async function StaffAppointmentDetailsPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
const noShow = (await getNoShowStandings(prisma, { businessId: staff.businessId, customerIds: [appointment.customerId] })).get(
|
||||
appointment.customerId,
|
||||
);
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
@@ -101,6 +107,7 @@ export default async function StaffAppointmentDetailsPage({
|
||||
>
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
{noShow && <NoShowBadge className="ml-2 align-middle" count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />}
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
|
||||
@@ -18,6 +18,9 @@ import { getActingBusinessContext } from "@/lib/business/get-acting-business-con
|
||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { ResetNoShowsButton } from "@/features/staff/components/reset-no-shows-button";
|
||||
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
|
||||
import { CustomerNotes } from "@/features/staff/components/customer-notes";
|
||||
@@ -89,6 +92,13 @@ export default async function StaffCustomerDetailsPage({
|
||||
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
|
||||
]);
|
||||
|
||||
// Derived from the history already loaded above — no extra query beyond the two rule numbers.
|
||||
const noShowStanding = evaluateStanding(
|
||||
countNoShows([...past, ...cancelled], customer.noShowResetAt),
|
||||
await getNoShowThresholds(prisma, context.businessId),
|
||||
);
|
||||
const canResetNoShows = await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS);
|
||||
|
||||
const loyaltyTransactions = loyalty?.accountId
|
||||
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
|
||||
: [];
|
||||
@@ -174,8 +184,23 @@ export default async function StaffCustomerDetailsPage({
|
||||
<p className="text-muted text-xs">
|
||||
{customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
<NoShowBadge
|
||||
className="mt-1"
|
||||
count={noShowStanding.count}
|
||||
flagged={noShowStanding.flagged}
|
||||
blocked={noShowStanding.blocked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{noShowStanding.count > 0 && (
|
||||
<div className="border-line space-y-2 border-t pt-4 text-sm">
|
||||
<p className="text-muted">
|
||||
{noShowStanding.count} no-show{noShowStanding.count === 1 ? "" : "s"} since their last visit.
|
||||
{noShowStanding.blocked && " Online booking is blocked for them — you can still book them in."}
|
||||
</p>
|
||||
{canResetNoShows && <ResetNoShowsButton customerId={customer.id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4 text-sm">
|
||||
<p className="text-muted">{customer.email}</p>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
||||
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -49,6 +51,7 @@ export default async function StaffClientsPage({
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
noShowResetAt: true,
|
||||
appointments: {
|
||||
where: { businessId: context.businessId },
|
||||
orderBy: { startAt: "desc" },
|
||||
@@ -57,6 +60,8 @@ export default async function StaffClientsPage({
|
||||
},
|
||||
});
|
||||
|
||||
const noShowThresholds = await getNoShowThresholds(prisma, context.businessId);
|
||||
|
||||
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
||||
businessId: context.businessId,
|
||||
customerIds: customers.map((c) => c.id),
|
||||
@@ -166,6 +171,10 @@ export default async function StaffClientsPage({
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
const noShow = evaluateStanding(
|
||||
countNoShows(customer.appointments, customer.noShowResetAt),
|
||||
noShowThresholds,
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -178,8 +187,11 @@ export default async function StaffClientsPage({
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
<p className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="truncate">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</span>
|
||||
<NoShowBadge count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RemindersLink } from "@/features/staff/components/reminders-link";
|
||||
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
||||
import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
|
||||
import { getNoShowStandings } from "@/services/customer/no-show.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
@@ -87,6 +88,11 @@ export default async function StaffCalendarPage({
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const noShowStandings = await getNoShowStandings(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerIds: appointments.map((a) => a.customerId),
|
||||
});
|
||||
|
||||
const workingWindow =
|
||||
workingHours &&
|
||||
!workingHours.isOff &&
|
||||
@@ -123,6 +129,7 @@ export default async function StaffCalendarPage({
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
noShowFlagged: noShowStandings.get(a.customerId)?.flagged ?? false,
|
||||
}));
|
||||
|
||||
const currentTimeMinutes =
|
||||
@@ -180,7 +187,7 @@ export default async function StaffCalendarPage({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={staff.businessId} timezone={staff.business.timezone} staffId={staff.id} />
|
||||
<RemindersLink businessId={staff.businessId} staffId={staff.id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||||
@@ -213,6 +220,7 @@ export default async function StaffCalendarPage({
|
||||
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
|
||||
hasNotes={a.hasNotes}
|
||||
hasAvailableReward={a.hasAvailableReward}
|
||||
noShowFlagged={a.noShowFlagged}
|
||||
actions={
|
||||
<Link
|
||||
href={`/staff/appointments/${a.id}`}
|
||||
|
||||
@@ -10,8 +10,12 @@ import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getBusinessCountry } from "@/lib/business/get-business-country";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { addDays, todayIn } from "@/lib/time/business-time";
|
||||
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { addDays, calendarDateOf, todayIn } from "@/lib/time/business-time";
|
||||
import {
|
||||
getWhatsAppLeadHours,
|
||||
listDueReminderCandidates,
|
||||
listReminderCandidates,
|
||||
} from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { WhatsAppSendLink } from "@/features/staff/components/whatsapp-send-link";
|
||||
import { reminderWhatsAppUrl } from "@/features/staff/lib/whatsapp-reminder-link";
|
||||
@@ -29,29 +33,46 @@ export default async function WhatsAppRemindersPage({
|
||||
|
||||
const timezone = context.businessTimezone;
|
||||
const { date: dateParam } = await searchParams;
|
||||
// Tomorrow by default — the day this exists to prepare for.
|
||||
const date = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : addDays(todayIn(timezone), 1);
|
||||
// Default view is "due now": what should be sent right away, soonest first. A date in the URL
|
||||
// switches to browsing one calendar day ahead.
|
||||
const browsingDate = dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) ? dateParam : null;
|
||||
const dueNow = browsingDate === null;
|
||||
const date = browsingDate ?? addDays(todayIn(timezone), 1);
|
||||
const leadHours = await getWhatsAppLeadHours(prisma, context.businessId);
|
||||
|
||||
// A barber works their own list; an owner (anyone who can see all
|
||||
// appointments) works the whole shop's, with the barber named on each row.
|
||||
const canSeeEveryone = await hasPermission(context.userId, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
const staffFilter = canSeeEveryone ? undefined : (context.staffId ?? undefined);
|
||||
const [appointments, country] = await Promise.all([
|
||||
canSeeEveryone || context.staffId
|
||||
? listReminderCandidates(prisma, {
|
||||
businessId: context.businessId,
|
||||
date,
|
||||
businessTimezone: timezone,
|
||||
staffId: canSeeEveryone ? undefined : (context.staffId ?? undefined),
|
||||
})
|
||||
? dueNow
|
||||
? listDueReminderCandidates(prisma, { businessId: context.businessId, leadHours, staffId: staffFilter })
|
||||
: listReminderCandidates(prisma, {
|
||||
businessId: context.businessId,
|
||||
date,
|
||||
businessTimezone: timezone,
|
||||
staffId: staffFilter,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getBusinessCountry(context.businessId),
|
||||
]);
|
||||
|
||||
const today = todayIn(timezone);
|
||||
const tomorrow = addDays(today, 1);
|
||||
const rows = appointments.map((appointment) => {
|
||||
const startDate = calendarDateOf(appointment.startAt, timezone);
|
||||
const dayLabel =
|
||||
startDate === today
|
||||
? "Today"
|
||||
: startDate === tomorrow
|
||||
? "Tomorrow"
|
||||
: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: timezone });
|
||||
const serviceNames = appointment.services.map((s) => s.service.name).join(" + ");
|
||||
const staffName = appointment.staff.user.name ?? "your barber";
|
||||
return {
|
||||
id: appointment.id,
|
||||
dayLabel,
|
||||
time: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }),
|
||||
customerName: `${appointment.customer.firstName} ${appointment.customer.lastName}`.trim(),
|
||||
customerId: appointment.customerId,
|
||||
@@ -76,12 +97,33 @@ export default async function WhatsAppRemindersPage({
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="WhatsApp Reminders" onBack sticky className="bg-stone" />
|
||||
<DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />
|
||||
{!dueNow && <DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />}
|
||||
|
||||
<PageContainer size="narrow" className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{dueNow ? (
|
||||
<p className="text-muted text-sm">
|
||||
Due now: starting within the next <strong className="text-ink">{leadHours} hours</strong>
|
||||
</p>
|
||||
) : (
|
||||
<Link href="/staff/reminders" className="text-sm font-bold underline">
|
||||
← Due now
|
||||
</Link>
|
||||
)}
|
||||
{dueNow && (
|
||||
<Link
|
||||
href={`/staff/reminders?date=${addDays(todayIn(timezone), 1)}`}
|
||||
className="text-muted shrink-0 text-xs font-bold underline"
|
||||
>
|
||||
Browse by day
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<p className="text-muted text-sm">
|
||||
<strong className="text-ink">{toSend}</strong> to send · {sentCount} sent
|
||||
<strong className="text-ink">{toSend}</strong> to send
|
||||
{!dueNow && ` · ${sentCount} sent`}
|
||||
{noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`}
|
||||
</p>
|
||||
)}
|
||||
@@ -89,14 +131,30 @@ export default async function WhatsAppRemindersPage({
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MessageCircle}
|
||||
title="No confirmed appointments this day"
|
||||
description="Confirmed bookings show up here, ready to remind."
|
||||
title={dueNow ? "You're all caught up" : "No confirmed appointments this day"}
|
||||
description={
|
||||
dueNow
|
||||
? `Nothing is due to remind right now. Reminders appear here ${leadHours} hours before each appointment.`
|
||||
: "Confirmed bookings show up here, ready to remind."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="border-line flex items-center gap-4 rounded-md border bg-white p-4">
|
||||
<p className="w-14 shrink-0 text-base font-bold tabular-nums">{row.time}</p>
|
||||
{rows.map((row, index) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={`flex items-center gap-4 rounded-md border bg-white p-4 ${
|
||||
dueNow && index === 0 ? "border-accent ring-accent/30 ring-2" : "border-line"
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 shrink-0">
|
||||
<p className="text-base font-bold tabular-nums">{row.time}</p>
|
||||
{dueNow && (
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
{index === 0 ? `Next · ${row.dayLabel}` : row.dayLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/staff/clients/${row.customerId}`} className="block truncate text-sm font-bold">
|
||||
{row.customerName}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default async function StaffCalendarSettingsPage() {
|
||||
connected: true,
|
||||
accountEmail: connection.accountEmail,
|
||||
selectedCalendarName: connection.selectedCalendarName,
|
||||
appCreatedCalendar: connection.appCreatedCalendar,
|
||||
lastSyncAt: connection.lastSyncAt?.toISOString(),
|
||||
lastSyncError: connection.lastSyncError,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const STAFF_GROUPS: NotificationPreferenceGroup[] = [
|
||||
{ type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED, label: "Appointment rescheduled" },
|
||||
{ type: NotificationType.STAFF_APPOINTMENT_CANCELLED, label: "Appointment cancelled" },
|
||||
{ type: NotificationType.STAFF_APPOINTMENT_REMINDER, label: "Upcoming appointment reminders" },
|
||||
{ type: NotificationType.STAFF_WHATSAPP_REMINDERS_DUE, label: "WhatsApp reminders ready to send" },
|
||||
{ type: NotificationType.STAFF_SCHEDULE_CHANGED, label: "Your schedule changed" },
|
||||
{ type: NotificationType.STAFF_INVITATION_RECEIVED, label: "Team invitations" },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||
import { INSTALLATION_SINGLETON_ID, isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
|
||||
import { nudgeStaffWhatsAppReminders } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
|
||||
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
|
||||
|
||||
@@ -48,6 +49,11 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ skipped: "not_installed" });
|
||||
}
|
||||
|
||||
// Proof of life for /api/health: "the cron stopped" must be distinguishable from "nothing to send".
|
||||
await prisma.applicationInstallation
|
||||
.updateMany({ where: { id: INSTALLATION_SINGLETON_ID }, data: { lastCronRunAt: new Date() } })
|
||||
.catch(() => {});
|
||||
|
||||
const result = await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: getEmailProvider(),
|
||||
pushProvider: getPushProvider(),
|
||||
@@ -55,5 +61,12 @@ export async function POST(request: Request) {
|
||||
// Piggybacks the loyalty reward expiry sweep on the same scheduled trigger
|
||||
// rather than standing up a second cron endpoint for one small job.
|
||||
await expireOverdueRewards(prisma);
|
||||
// Same trigger again: tell barbers when WhatsApp reminders are waiting to be sent by hand.
|
||||
// Never lets a failure here fail the run — the notification queue above already did its job.
|
||||
try {
|
||||
await nudgeStaffWhatsAppReminders(prisma);
|
||||
} catch (error) {
|
||||
console.error("[cron] WhatsApp reminder nudge failed", error instanceof Error ? error.message : error);
|
||||
}
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getInstallationState } from "@/lib/installation/installation-state";
|
||||
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
|
||||
import { assessQueueHealth, getQueueHealth } from "@/services/notifications/queue-health";
|
||||
|
||||
/**
|
||||
* Docker/orchestrator health check — deliberately never requires
|
||||
@@ -17,6 +19,11 @@ import { getInstallationState } from "@/lib/installation/installation-state";
|
||||
* installation token, rather than Manager needing its own authenticated
|
||||
* status endpoint. Deliberately still just a boolean + a timestamp: never
|
||||
* the installation token itself, never anything else about the tenant.
|
||||
*
|
||||
* Also reports whether notifications are actually going out (`queue`,
|
||||
* `cron`, `degradedReasons`): the Manager emails the operator when this
|
||||
* turns "degraded". It still answers HTTP 200 when degraded — the Docker
|
||||
* healthcheck must not restart a container over a stopped cron.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -27,10 +34,34 @@ export async function GET() {
|
||||
|
||||
const { installed, installedAt } = await getInstallationState().catch(() => ({ installed: false, installedAt: null }));
|
||||
|
||||
// Never lets the queue report break the probe itself — a failure here just omits the extra fields.
|
||||
let queueReport: Record<string, unknown> = {};
|
||||
let status: "healthy" | "degraded" = "healthy";
|
||||
try {
|
||||
const [queue, installation] = await Promise.all([
|
||||
getQueueHealth(prisma),
|
||||
prisma.applicationInstallation.findUnique({
|
||||
where: { id: INSTALLATION_SINGLETON_ID },
|
||||
select: { lastCronRunAt: true },
|
||||
}),
|
||||
]);
|
||||
const lastCronRunAt = installation?.lastCronRunAt ?? null;
|
||||
const assessment = assessQueueHealth({ installed, installedAt, lastCronRunAt, queue });
|
||||
status = assessment.status;
|
||||
queueReport = {
|
||||
queue,
|
||||
cron: { lastCronRunAt: lastCronRunAt ? lastCronRunAt.toISOString() : null },
|
||||
degradedReasons: assessment.degradedReasons,
|
||||
};
|
||||
} catch {
|
||||
// omitted
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "healthy",
|
||||
status,
|
||||
database: "reachable",
|
||||
installed,
|
||||
installedAt: installedAt ? installedAt.toISOString() : null,
|
||||
...queueReport,
|
||||
});
|
||||
}
|
||||
@@ -40,7 +40,10 @@ export async function GET(request: Request) {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
|
||||
}
|
||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, business: { select: { name: true, timezone: true } } },
|
||||
});
|
||||
if (!staffProfile) {
|
||||
return settingsRedirect("error", "not_staff");
|
||||
}
|
||||
@@ -57,11 +60,23 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const provider = new GoogleCalendarProvider();
|
||||
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
|
||||
const calendars = await provider.listWritableCalendars(tokens.accessToken);
|
||||
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
|
||||
if (!primaryCalendar) {
|
||||
return settingsRedirect("error", "no_writable_calendar");
|
||||
}
|
||||
// The narrow scope can't list a person's calendars (that is the point of it), so events go to a
|
||||
// calendar this app creates. Reconnecting reuses the one it made before, if it's still there,
|
||||
// rather than adding another "bookings" calendar every time.
|
||||
const existing = await prisma.staffCalendarConnection.findUnique({
|
||||
where: { staffId: staffProfile.id },
|
||||
select: { appCreatedCalendar: true, selectedCalendarId: true, selectedCalendarName: true },
|
||||
});
|
||||
const reusable =
|
||||
existing?.appCreatedCalendar &&
|
||||
(await provider.calendarExists({ accessToken: tokens.accessToken, calendarId: existing.selectedCalendarId }));
|
||||
const primaryCalendar = reusable
|
||||
? { id: existing.selectedCalendarId, name: existing.selectedCalendarName }
|
||||
: await provider.createCalendar({
|
||||
accessToken: tokens.accessToken,
|
||||
name: `${staffProfile.business.name} bookings`,
|
||||
timeZone: staffProfile.business.timezone,
|
||||
});
|
||||
|
||||
const connection = await prisma.staffCalendarConnection.upsert({
|
||||
where: { staffId: staffProfile.id },
|
||||
@@ -74,6 +89,7 @@ export async function GET(request: Request) {
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
selectedCalendarId: primaryCalendar.id,
|
||||
selectedCalendarName: primaryCalendar.name,
|
||||
appCreatedCalendar: true,
|
||||
},
|
||||
update: {
|
||||
externalAccountId: tokens.externalAccountId,
|
||||
@@ -83,6 +99,7 @@ export async function GET(request: Request) {
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
selectedCalendarId: primaryCalendar.id,
|
||||
selectedCalendarName: primaryCalendar.name,
|
||||
appCreatedCalendar: true,
|
||||
revokedAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { Gift, NotebookText } from "lucide-react";
|
||||
import { Gift, NotebookText, UserX } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
@@ -20,6 +20,8 @@ export interface AppointmentCardProps {
|
||||
hasNotes?: boolean;
|
||||
/** Small loyalty-reward-available indicator — same. */
|
||||
hasAvailableReward?: boolean;
|
||||
/** Customer has reached the no-show flag threshold — staff cards only. */
|
||||
noShowFlagged?: boolean;
|
||||
/** Reschedule/Cancel/Book again buttons — left to the caller so this
|
||||
* component stays agnostic of which actions apply to which status. */
|
||||
actions?: ReactNode;
|
||||
@@ -35,6 +37,7 @@ export function AppointmentCard({
|
||||
formattedTime,
|
||||
hasNotes,
|
||||
hasAvailableReward,
|
||||
noShowFlagged,
|
||||
actions,
|
||||
}: AppointmentCardProps) {
|
||||
return (
|
||||
@@ -72,6 +75,9 @@ export function AppointmentCard({
|
||||
aria-label="Reward available"
|
||||
/>
|
||||
)}
|
||||
{noShowFlagged && (
|
||||
<UserX className="text-warning-foreground h-3.5 w-3.5 shrink-0" aria-label="Has missed appointments" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
{personLabel} {person.name}
|
||||
|
||||
@@ -86,6 +86,7 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
|
||||
appointmentId: parsed.data.appointmentId,
|
||||
staffId: parsed.data.staffId,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
actorUserId: session?.user?.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { auth } from "@/auth";
|
||||
import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getNoShowStanding } from "@/services/customer/no-show.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||
import { getFirstFriendlyMessage } from "../error-messages";
|
||||
import { getFirstFriendlyMessage, getFriendlyMessage } from "../error-messages";
|
||||
import {
|
||||
preselectServiceSchema,
|
||||
saveBarberTimeStepSchema,
|
||||
@@ -170,6 +171,14 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
|
||||
fieldErrors: guestParsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
// Same rule createBooking enforces at the final commit — told here so the customer isn't
|
||||
// sent through the review step only to be refused at the end.
|
||||
const guestStanding = await getNoShowStanding(prisma, {
|
||||
businessId: parsed.data.businessId,
|
||||
email: guestParsed.data.email,
|
||||
phone: guestParsed.data.phone,
|
||||
});
|
||||
if (guestStanding.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: guestParsed.data,
|
||||
@@ -177,6 +186,11 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
|
||||
policyAcknowledged: true,
|
||||
});
|
||||
} else {
|
||||
const profile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||
if (profile) {
|
||||
const standing = await getNoShowStanding(prisma, { businessId: parsed.data.businessId, customerId: profile.id });
|
||||
if (standing.blocked) return { success: false, error: getFriendlyMessage("CUSTOMER_BOOKING_BLOCKED") };
|
||||
}
|
||||
await writeDraft({
|
||||
...draft,
|
||||
guest: undefined,
|
||||
|
||||
@@ -13,6 +13,8 @@ const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
|
||||
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
|
||||
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
|
||||
BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.",
|
||||
CUSTOMER_BOOKING_BLOCKED:
|
||||
"We can't take online bookings for you right now — please get in touch with us to arrange an appointment.",
|
||||
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
|
||||
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
|
||||
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
|
||||
|
||||
@@ -72,6 +72,10 @@ export async function listAvailableCalendarsAction(): Promise<ListCalendarsResul
|
||||
if (!connection || connection.revokedAt) {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
// A connection made with the narrow scope can only reach the calendar this app created.
|
||||
if (connection.appCreatedCalendar) {
|
||||
return { success: false, error: "Events are added to the calendar NextChair created in your Google account." };
|
||||
}
|
||||
|
||||
const provider = new GoogleCalendarProvider();
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface ConnectionState {
|
||||
* button, because syncing has silently stopped and nothing else says so.
|
||||
*/
|
||||
needsReconnect?: boolean;
|
||||
/**
|
||||
* Connected with the narrow scope: events go to a calendar this app created, and there is
|
||||
* nothing to choose (the scope can't list the person's calendars).
|
||||
*/
|
||||
appCreatedCalendar?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
|
||||
@@ -86,8 +91,9 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted mb-4 text-xs">
|
||||
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
||||
from sign-in — you can use a different one if you like.
|
||||
Sync your confirmed appointments to Google Calendar. We create a calendar for them in your Google account
|
||||
and never see or change your other calendars or events. This connects a Google account separately from
|
||||
sign-in — you can use a different one if you like.
|
||||
</p>
|
||||
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||
{configured ? (
|
||||
@@ -108,7 +114,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||
<p>Connected as {state.accountEmail}</p>
|
||||
<p>Syncing to “{state.selectedCalendarName}”</p>
|
||||
<p>
|
||||
{state.appCreatedCalendar ? "Appointments are added to the calendar " : "Syncing to "}“
|
||||
{state.selectedCalendarName}”
|
||||
{state.appCreatedCalendar && " in your Google account — show or hide it from Google Calendar's sidebar."}
|
||||
</p>
|
||||
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
|
||||
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
|
||||
</div>
|
||||
@@ -135,9 +145,11 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
<Button size="sm" isLoading={isPending} onClick={sync}>
|
||||
Sync Now
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||
Change Calendar
|
||||
</Button>
|
||||
{!state.appCreatedCalendar && (
|
||||
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||
Change Calendar
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
|
||||
@@ -170,6 +170,7 @@ export async function ownerRescheduleAppointmentAction(params: {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
actorUserId: owner.userId,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this appointment." };
|
||||
@@ -183,9 +184,13 @@ export async function ownerRescheduleAppointmentAction(params: {
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
await notifyBookingBarberChanged(prisma, result.appointment, {
|
||||
customerSelectedStaff: appointment.staffSelectedByCustomer,
|
||||
});
|
||||
// When the time moved too, the "rescheduled" notice already names the new barber — one
|
||||
// message, not two. The dedicated notice is only for a pure reassignment.
|
||||
if (appointment.startAt.getTime() === new Date(params.startAt).getTime()) {
|
||||
await notifyBookingBarberChanged(prisma, result.appointment, {
|
||||
customerSelectedStaff: appointment.staffSelectedByCustomer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseSplashScreens } from "@/lib/business/branding";
|
||||
import type { SplashScreenEntry } from "@/lib/business/branding";
|
||||
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
import { createMediaAsset } from "@/services/media/media.service";
|
||||
import { MAX_WHATSAPP_LEAD_HOURS, validateWhatsAppLeadHours } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import {
|
||||
setOpeningHours,
|
||||
updateAppIconSettings,
|
||||
@@ -104,6 +105,10 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
|
||||
const { staff, error } = await requireBusinessManager();
|
||||
if (!staff) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
if (settings.whatsappReminderLeadHours !== undefined && !validateWhatsAppLeadHours(settings.whatsappReminderLeadHours)) {
|
||||
return { success: false, error: `WhatsApp reminders can be due between 1 and ${MAX_WHATSAPP_LEAD_HOURS} hours ahead.` };
|
||||
}
|
||||
|
||||
await updateBusinessSettings(prisma, { businessId: staff.businessId, settings });
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
|
||||
@@ -126,6 +126,33 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">No-shows</h3>
|
||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||
<p className="text-muted text-xs leading-relaxed">
|
||||
Count the appointments a customer missed since they last attended (a completed appointment, or
|
||||
when you reset them). Leave a box empty to turn that part off. Staff can always book a blocked
|
||||
customer in directly.
|
||||
</p>
|
||||
<Input
|
||||
label="Show a warning symbol at (no-shows)"
|
||||
hint="e.g. 2 — the symbol appears on their calendar entries, appointments and client page."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.noShowFlagThreshold ?? ""}
|
||||
onChange={(e) => set("noShowFlagThreshold", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
<Input
|
||||
label="Block online booking at (no-shows)"
|
||||
hint="e.g. 4 — they're asked to contact you instead. Must be the same as or higher than the warning number."
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.noShowBlockThreshold ?? ""}
|
||||
onChange={(e) => set("noShowBlockThreshold", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface BusinessDetailsInitial {
|
||||
cancellationPolicy: string;
|
||||
legalNameDisplay: LegalNameDisplay;
|
||||
legalContactEmail: LegalContactEmail;
|
||||
whatsappReminderLeadHours: number;
|
||||
}
|
||||
|
||||
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
|
||||
@@ -92,6 +93,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
cancellationPolicy: form.cancellationPolicy || undefined,
|
||||
legalNameDisplay: form.legalNameDisplay,
|
||||
legalContactEmail: form.legalContactEmail,
|
||||
whatsappReminderLeadHours: form.whatsappReminderLeadHours,
|
||||
});
|
||||
if (!settingsResult.success) {
|
||||
setError(settingsResult.error ?? "Couldn't save business policies.");
|
||||
@@ -213,6 +215,22 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
value={form.cancellationPolicy}
|
||||
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
||||
/>
|
||||
<div className="border-line space-y-3 border-t pt-4">
|
||||
<Select
|
||||
label="WhatsApp reminders are due"
|
||||
value={String(form.whatsappReminderLeadHours)}
|
||||
onChange={(e) => set("whatsappReminderLeadHours", Number(e.target.value))}
|
||||
>
|
||||
{[12, 24, 36, 48, 72].map((hours) => (
|
||||
<option key={hours} value={hours}>
|
||||
{hours} hours before an appointment
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-muted ml-1 text-xs leading-relaxed">
|
||||
Staff see these on the Reminders list and get a push when some are ready to send.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-line space-y-4 border-t pt-4">
|
||||
<p className="text-muted ml-1 text-xs leading-relaxed">
|
||||
Your site's{" "}
|
||||
|
||||
@@ -190,18 +190,6 @@ export function LoyaltyConfigForm({
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||
<Switch
|
||||
label="Apply reward automatically"
|
||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||
checked={config.autoApplyReward}
|
||||
onCheckedChange={(v) => set("autoApplyReward", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
checked={config.requiresStaffApproval}
|
||||
onCheckedChange={(v) => set("requiresStaffApproval", v)}
|
||||
/>
|
||||
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
|
||||
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -155,13 +155,14 @@ export async function rescheduleStaffAppointmentAction(params: {
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
actorUserId: staff.user.id,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { resetNoShows } from "@/services/customer/no-show.service";
|
||||
|
||||
export interface NoShowActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Forgive" a customer's no-shows: they stop counting towards the flag and block
|
||||
* thresholds from now on (history is untouched). Needs MANAGE_ALL_APPOINTMENTS —
|
||||
* it lifts a block the owner configured, so it isn't open to every barber.
|
||||
*/
|
||||
export async function resetNoShowsAction(customerId: string): Promise<NoShowActionResult> {
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!(await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS))) {
|
||||
return { success: false, error: "You don't have permission to reset no-shows." };
|
||||
}
|
||||
|
||||
const inDirectory =
|
||||
(await prisma.customerProfile.count({
|
||||
where: { AND: [{ id: customerId }, clientDirectoryWhere(context.businessId)] },
|
||||
})) > 0;
|
||||
if (!inDirectory) return { success: false, error: "Client not found." };
|
||||
|
||||
await resetNoShows(prisma, { customerId });
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: context.userId,
|
||||
action: "CUSTOMER_NO_SHOWS_RESET",
|
||||
entityType: "CustomerProfile",
|
||||
entityId: customerId,
|
||||
});
|
||||
revalidatePath(`/staff/clients/${customerId}`);
|
||||
revalidatePath("/staff/clients");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export interface CalendarAppointment {
|
||||
status: AppointmentStatus;
|
||||
hasNotes: boolean;
|
||||
hasAvailableReward: boolean;
|
||||
/** Customer has reached the no-show flag threshold (see no-show.service.ts). */
|
||||
noShowFlagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarBlock {
|
||||
@@ -413,6 +415,9 @@ function EventContent({ event }: { event: CalendarEvent }) {
|
||||
{appointment.hasAvailableReward && (
|
||||
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
||||
)}
|
||||
{appointment.noShowFlagged && (
|
||||
<UserX className="text-warning-foreground h-2.5 w-2.5 shrink-0" aria-label="Has missed appointments" />
|
||||
)}
|
||||
</div>
|
||||
{height >= 44 && (
|
||||
<p className="truncate text-[11px] opacity-80">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { UserX } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
/**
|
||||
* The symbol shown against a customer who has reached the no-show flag (amber) or
|
||||
* block (red) threshold — see no-show.service.ts. Renders nothing for a customer
|
||||
* who is neither, so callers can drop it in unconditionally.
|
||||
*/
|
||||
export function NoShowBadge({
|
||||
count,
|
||||
flagged,
|
||||
blocked,
|
||||
className,
|
||||
}: {
|
||||
count: number;
|
||||
flagged: boolean;
|
||||
blocked: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!flagged && !blocked) return null;
|
||||
const label = blocked
|
||||
? `Online booking blocked after ${count} no-show${count === 1 ? "" : "s"}`
|
||||
: `${count} no-show${count === 1 ? "" : "s"} since their last visit`;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wide whitespace-nowrap",
|
||||
blocked ? "bg-danger/10 text-danger" : "bg-warning/15 text-warning-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<UserX className="h-3 w-3" aria-hidden="true" />
|
||||
{blocked ? `Blocked · ${count}` : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,36 +3,28 @@ import Link from "next/link";
|
||||
import { getBusinessCountry } from "@/lib/business/get-business-country";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { toWhatsAppNumber } from "@/lib/messaging/whatsapp";
|
||||
import { addDays, todayIn } from "@/lib/time/business-time";
|
||||
import { listReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
import { getWhatsAppLeadHours, listDueReminderCandidates } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
|
||||
/**
|
||||
* Entry point to the reminders list, shown on the calendar pages with the
|
||||
* number still to send for tomorrow — so the job announces itself instead of
|
||||
* relying on someone remembering to look.
|
||||
* number due to send right now (starting within the business's reminder
|
||||
* lead time) — so the job announces itself instead of relying on someone
|
||||
* remembering to look.
|
||||
*/
|
||||
export async function RemindersLink({
|
||||
businessId,
|
||||
timezone,
|
||||
staffId,
|
||||
}: {
|
||||
businessId: string;
|
||||
timezone: string;
|
||||
/** Narrow to one barber's bookings; omit for the whole shop. */
|
||||
staffId?: string;
|
||||
}) {
|
||||
const leadHours = await getWhatsAppLeadHours(prisma, businessId);
|
||||
const [appointments, country] = await Promise.all([
|
||||
listReminderCandidates(prisma, {
|
||||
businessId,
|
||||
date: addDays(todayIn(timezone), 1),
|
||||
businessTimezone: timezone,
|
||||
staffId,
|
||||
}),
|
||||
listDueReminderCandidates(prisma, { businessId, leadHours, staffId }),
|
||||
getBusinessCountry(businessId),
|
||||
]);
|
||||
const toSend = appointments.filter(
|
||||
(a) => !a.whatsappReminderSentAt && toWhatsAppNumber(a.customer.phone, country),
|
||||
).length;
|
||||
const toSend = appointments.filter((a) => toWhatsAppNumber(a.customer.phone, country)).length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -42,7 +34,7 @@ export async function RemindersLink({
|
||||
<MessageCircle className="text-accent h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 text-xs font-bold">
|
||||
WhatsApp reminders
|
||||
{toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send tomorrow</span> : null}
|
||||
{toSend > 0 ? <span className="text-accent-foreground"> · {toSend} to send now</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="text-muted h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resetNoShowsAction } from "../actions/no-show";
|
||||
|
||||
/** Two taps on purpose: it lifts a block the owner set, so it shouldn't happen by accident. */
|
||||
export function ResetNoShowsButton({ customerId }: { customerId: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const reset = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await resetNoShowsAction(customerId);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't reset no-shows.");
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (!confirming) {
|
||||
return (
|
||||
<Button variant="secondary" size="sm" onClick={() => setConfirming(true)}>
|
||||
Reset no-shows
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted text-xs">
|
||||
Their no-shows stop counting from now. Their history is kept.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={reset} isLoading={isPending}>
|
||||
Yes, reset
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Instrumentation } from "next";
|
||||
|
||||
/**
|
||||
* Server-side error reporting: every unhandled error in a route, page or
|
||||
* server action reaches this hook with the digest the visitor sees on the
|
||||
* error page. See src/lib/monitoring/report-error.ts for what is logged and
|
||||
* emailed to the operator (OPERATOR_ALERT_EMAIL) and what never is.
|
||||
*/
|
||||
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
try {
|
||||
const { reportServerError } = await import("@/lib/monitoring/report-error");
|
||||
await reportServerError(
|
||||
error,
|
||||
{ path: request.path, method: request.method },
|
||||
{ routePath: context.routePath },
|
||||
);
|
||||
} catch {
|
||||
// Reporting must never be the thing that breaks a request.
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { randomInt } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { resolveSenderName } from "@/services/notifications/sender-identity";
|
||||
// Type-only — the barrel this comes from has a `server-only` guard that
|
||||
// throws under Vitest's plain Node runtime (see reminder-worker.ts's own
|
||||
// established pattern: never call getEmailProvider() from inside a
|
||||
@@ -152,12 +153,12 @@ export async function requestOtp(db: Db, options: RequestOtpOptions): Promise<Re
|
||||
});
|
||||
}
|
||||
|
||||
const rendered = renderOtpEmail({ senderName: options.senderName ?? "NextChair", code, expiresInMinutes: 10 });
|
||||
const rendered = renderOtpEmail({ senderName: resolveSenderName(options.senderName), code, expiresInMinutes: 10 });
|
||||
// Never logged — the code must not reach application logs, only this
|
||||
// one email (see the brief's "do not include the OTP in logs").
|
||||
await options.emailProvider.sendEmail({
|
||||
to: { email },
|
||||
sender: { name: options.senderName ?? "NextChair" },
|
||||
sender: { name: resolveSenderName(options.senderName) },
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
text: rendered.text,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAlertLimiter } from "../alert-limiter";
|
||||
|
||||
describe("createAlertLimiter", () => {
|
||||
const hour = 3_600_000;
|
||||
|
||||
it("sends the first alert and holds the same one back for the cooldown", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("boom", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("boom", 30 * 60_000)).toBe(false);
|
||||
expect(limiter.shouldSend("boom", hour)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different errors independently", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("a", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("b", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps how many alerts one day can produce, then starts over the next day", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: 0, maxPerDay: 3 });
|
||||
expect([1, 2, 3, 4, 5].map((n) => limiter.shouldSend(`e${n}`, n))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(limiter.shouldSend("e6", 25 * hour)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/services/notifications/providers", () => ({ getEmailProvider: vi.fn() }));
|
||||
|
||||
import { buildErrorAlert, isIgnorableError } from "../report-error";
|
||||
|
||||
describe("isIgnorableError", () => {
|
||||
it("ignores redirects, not-found and dropped connections, but not real failures", () => {
|
||||
expect(isIgnorableError(new Error("NEXT_REDIRECT"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("NEXT_NOT_FOUND"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("socket hang up"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("Cannot read properties of undefined"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildErrorAlert", () => {
|
||||
const request = { path: "/owner/calendar?token=secret-value&date=2026-10-20", method: "GET" };
|
||||
|
||||
it("names the business and route and never includes the query string", () => {
|
||||
const alert = buildErrorAlert({
|
||||
business: "Blake Heritage",
|
||||
error: new Error("db exploded"),
|
||||
request,
|
||||
});
|
||||
expect(alert.subject).toBe("[Blake Heritage] Server error on /owner/calendar");
|
||||
expect(alert.text).toContain("db exploded");
|
||||
expect(alert.text).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
it("dedupes on the digest when there is one, otherwise on route and message", () => {
|
||||
const withDigest = Object.assign(new Error("x"), { digest: "abc123" });
|
||||
expect(buildErrorAlert({ business: "B", error: withDigest, request }).key).toBe("abc123");
|
||||
expect(buildErrorAlert({ business: "B", error: new Error("x"), request }).key).toBe(
|
||||
"/owner/calendar:x",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Keeps operator alert emails useful: the same problem is reported once per
|
||||
* cooldown (not once per request while it's broken), and a runaway failure
|
||||
* can't send more than a fixed number of emails a day. In-memory and
|
||||
* per-process, like the app's other rate limiter — a restart resets it, which
|
||||
* at worst re-sends one alert.
|
||||
*/
|
||||
|
||||
export interface AlertLimiterOptions {
|
||||
cooldownMs: number;
|
||||
maxPerDay: number;
|
||||
}
|
||||
|
||||
export function createAlertLimiter(options: AlertLimiterOptions) {
|
||||
const lastSent = new Map<string, number>();
|
||||
let dayStart = 0;
|
||||
let sentToday = 0;
|
||||
|
||||
return {
|
||||
/** True when an alert with this key should go out now — and records it as sent. */
|
||||
shouldSend(key: string, now: number = Date.now()): boolean {
|
||||
if (now - dayStart >= 24 * 3_600_000) {
|
||||
dayStart = now;
|
||||
sentToday = 0;
|
||||
}
|
||||
if (sentToday >= options.maxPerDay) return false;
|
||||
const previous = lastSent.get(key);
|
||||
if (previous !== undefined && now - previous < options.cooldownMs) return false;
|
||||
|
||||
lastSent.set(key, now);
|
||||
sentToday += 1;
|
||||
// Bound memory: drop entries that have long since stopped mattering.
|
||||
if (lastSent.size > 500) {
|
||||
for (const [k, t] of lastSent) if (now - t >= options.cooldownMs) lastSent.delete(k);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
import { createAlertLimiter } from "./alert-limiter";
|
||||
import { getEmailProvider } from "@/services/notifications/providers";
|
||||
|
||||
/**
|
||||
* Server-side error reporting for Next's `onRequestError` hook (see
|
||||
* src/instrumentation.ts). Every error is logged with its digest — the id
|
||||
* the browser's error page can show — and, when OPERATOR_ALERT_EMAIL is set
|
||||
* for this deployment, emailed to the operator, de-duplicated so one broken
|
||||
* page produces one email an hour, not one per visitor.
|
||||
*
|
||||
* The email carries only: which business, the route, the method, the error
|
||||
* message and the first lines of its stack. Never request headers, cookies,
|
||||
* bodies or the query string, which can hold tokens and personal data.
|
||||
*/
|
||||
|
||||
const limiter = createAlertLimiter({ cooldownMs: 60 * 60_000, maxPerDay: 20 });
|
||||
|
||||
export interface ReportedRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
/** Errors that are control flow or a visitor going away, not something to wake anyone for. */
|
||||
export function isIgnorableError(error: unknown): boolean {
|
||||
const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||
return /NEXT_REDIRECT|NEXT_NOT_FOUND|NEXT_HTTP_ERROR_FALLBACK|aborted|ECONNRESET|socket hang up/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildErrorAlert(params: {
|
||||
business: string;
|
||||
error: unknown;
|
||||
request: ReportedRequest;
|
||||
routePath?: string;
|
||||
}): { subject: string; text: string; key: string } {
|
||||
const error = params.error instanceof Error ? params.error : new Error(String(params.error));
|
||||
const digestValue = (error as { digest?: unknown }).digest;
|
||||
const digest = typeof digestValue === "string" ? digestValue : undefined;
|
||||
const route = params.routePath ?? params.request.path.split("?")[0] ?? "";
|
||||
const stack = (error.stack ?? "").split("\n").slice(0, 12).join("\n");
|
||||
|
||||
return {
|
||||
subject: `[${params.business}] Server error on ${route}`,
|
||||
key: digest ?? `${route}:${error.message}`.slice(0, 200),
|
||||
text: [
|
||||
`A server error happened on ${params.business}.`,
|
||||
"",
|
||||
`Route: ${route}`,
|
||||
`Method: ${params.request.method}`,
|
||||
`Error: ${error.message}`,
|
||||
...(digest ? [`Digest: ${digest} (also shown to the visitor on the error page)`] : []),
|
||||
"",
|
||||
"Stack (first lines):",
|
||||
stack,
|
||||
"",
|
||||
"You'll get at most one email per hour for the same error.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportServerError(
|
||||
error: unknown,
|
||||
request: ReportedRequest,
|
||||
context: { routePath?: string },
|
||||
): Promise<void> {
|
||||
if (isIgnorableError(error)) return;
|
||||
|
||||
const business = process.env.BUSINESS_NAME?.trim() || process.env.DOMAIN?.trim() || "NextChair";
|
||||
const alert = buildErrorAlert({ business, error, request, routePath: context.routePath });
|
||||
console.error(
|
||||
`[server-error] ${alert.subject} — ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
const to = process.env.OPERATOR_ALERT_EMAIL?.trim();
|
||||
if (!to || !limiter.shouldSend(alert.key)) return;
|
||||
|
||||
try {
|
||||
await getEmailProvider().sendEmail({
|
||||
to: { email: to },
|
||||
sender: { name: `${business} alerts` },
|
||||
subject: alert.subject,
|
||||
html: `<pre style="font:13px/1.5 monospace;white-space:pre-wrap;">${alert.text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)}</pre>`,
|
||||
text: alert.text,
|
||||
});
|
||||
} catch {
|
||||
// An alert that can't be sent must never turn into a second error.
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ export interface BusinessSettingsInput {
|
||||
legalNameDisplay?: LegalNameDisplay;
|
||||
/** Whose inbox those pages give for legal/privacy requests. */
|
||||
legalContactEmail?: LegalContactEmail;
|
||||
/** Hours ahead the staff WhatsApp reminders queue treats an appointment as due — see whatsapp-reminders.service.ts. */
|
||||
whatsappReminderLeadHours?: number;
|
||||
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
|
||||
reviewsMinRating?: number | null;
|
||||
reviewsExcludeRatingOnly?: boolean;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CalendarProviderError } from "../types";
|
||||
describe("mapGoogleError", () => {
|
||||
it("treats the token endpoint's invalid_grant as a dead connection, not a bad event", () => {
|
||||
// Google sends HTTP 400 for this — the case that used to read "rejected this event".
|
||||
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
|
||||
const error = mapGoogleError(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Token has been expired or revoked.",
|
||||
});
|
||||
|
||||
expect(error.code).toBe("TOKEN_REVOKED");
|
||||
expect(error.retryable).toBe(false);
|
||||
@@ -25,7 +28,12 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("still reports a genuinely bad event as one, now with Google's reason", () => {
|
||||
const error = mapGoogleError(400, { error: { message: "The specified time range is empty.", errors: [{ reason: "timeRangeEmpty" }] } });
|
||||
const error = mapGoogleError(400, {
|
||||
error: {
|
||||
message: "The specified time range is empty.",
|
||||
errors: [{ reason: "timeRangeEmpty" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.code).toBe("INVALID_EVENT");
|
||||
expect(error.retryable).toBe(false);
|
||||
@@ -38,12 +46,21 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("keeps the other mappings", () => {
|
||||
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe("TOKEN_EXPIRED");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code).toBe("CALENDAR_NOT_WRITABLE");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe("RATE_LIMITED");
|
||||
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe(
|
||||
"TOKEN_EXPIRED",
|
||||
);
|
||||
expect(
|
||||
mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code,
|
||||
).toBe("CALENDAR_NOT_WRITABLE");
|
||||
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe(
|
||||
"RATE_LIMITED",
|
||||
);
|
||||
expect(mapGoogleError(429, undefined).retryable).toBe(true);
|
||||
expect(mapGoogleError(404, undefined).code).toBe("CALENDAR_NOT_FOUND");
|
||||
expect(mapGoogleError(503, undefined)).toMatchObject({ code: "PROVIDER_OUTAGE", retryable: true });
|
||||
expect(mapGoogleError(503, undefined)).toMatchObject({
|
||||
code: "PROVIDER_OUTAGE",
|
||||
retryable: true,
|
||||
});
|
||||
expect(mapGoogleError(418, undefined).code).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
@@ -52,13 +69,19 @@ describe("mapGoogleError", () => {
|
||||
});
|
||||
|
||||
it("never puts the response body or any token in the user-facing message", () => {
|
||||
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "secret-detail-xyz" });
|
||||
const error = mapGoogleError(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "secret-detail-xyz",
|
||||
});
|
||||
expect(error.message).not.toContain("secret-detail-xyz");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoogleCalendarProvider against a failing Google", () => {
|
||||
const original = { id: process.env.GOOGLE_CALENDAR_CLIENT_ID, secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET };
|
||||
const original = {
|
||||
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
|
||||
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
|
||||
@@ -72,22 +95,126 @@ describe("GoogleCalendarProvider against a failing Google", () => {
|
||||
});
|
||||
|
||||
const respondWith = (status: number, body: unknown) =>
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
|
||||
);
|
||||
|
||||
it("an expired refresh token surfaces as TOKEN_REVOKED when refreshing", async () => {
|
||||
respondWith(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
|
||||
respondWith(400, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Token has been expired or revoked.",
|
||||
});
|
||||
|
||||
const failure = await new GoogleCalendarProvider().refreshAccessToken("old-refresh-token").catch((e: unknown) => e);
|
||||
const failure = await new GoogleCalendarProvider()
|
||||
.refreshAccessToken("old-refresh-token")
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(failure).toBeInstanceOf(CalendarProviderError);
|
||||
expect(failure).toMatchObject({ code: "TOKEN_REVOKED", retryable: false });
|
||||
});
|
||||
|
||||
it("deleting an event Google already removed (410) is not an error", async () => {
|
||||
respondWith(410, { error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] } });
|
||||
respondWith(410, {
|
||||
error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
new GoogleCalendarProvider().deleteEvent({ accessToken: "t", calendarId: "primary", providerEventId: "gone" }),
|
||||
new GoogleCalendarProvider().deleteEvent({
|
||||
accessToken: "t",
|
||||
calendarId: "primary",
|
||||
providerEventId: "gone",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoogleCalendarProvider scopes and the app-created calendar", () => {
|
||||
const original = {
|
||||
id: process.env.GOOGLE_CALENDAR_CLIENT_ID,
|
||||
secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = "test-client-secret";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_ID = original.id;
|
||||
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = original.secret;
|
||||
});
|
||||
|
||||
it("asks Google for the one narrow calendar scope — never the broad ones that trigger the unverified-app warning", () => {
|
||||
const url = new URL(
|
||||
new GoogleCalendarProvider().buildAuthorizationUrl({
|
||||
redirectUri: "https://x.test/cb",
|
||||
state: "s",
|
||||
}),
|
||||
);
|
||||
const scopes = (url.searchParams.get("scope") ?? "").split(" ");
|
||||
|
||||
expect(scopes).toContain("https://www.googleapis.com/auth/calendar.app.created");
|
||||
expect(scopes.filter((s) => s.includes("/auth/calendar"))).toEqual([
|
||||
"https://www.googleapis.com/auth/calendar.app.created",
|
||||
]);
|
||||
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.events");
|
||||
expect(scopes).not.toContain("https://www.googleapis.com/auth/calendar.readonly");
|
||||
});
|
||||
|
||||
it("creates a secondary calendar with the business's name and time zone", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "cal-123", summary: "Blake Heritage bookings" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const calendar = await new GoogleCalendarProvider().createCalendar({
|
||||
accessToken: "token",
|
||||
name: "Blake Heritage bookings",
|
||||
timeZone: "Europe/London",
|
||||
});
|
||||
|
||||
expect(calendar).toEqual({
|
||||
id: "cal-123",
|
||||
name: "Blake Heritage bookings",
|
||||
writable: true,
|
||||
primary: false,
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://www.googleapis.com/calendar/v3/calendars");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
summary: "Blake Heritage bookings",
|
||||
timeZone: "Europe/London",
|
||||
});
|
||||
});
|
||||
|
||||
it("calendarExists says yes for a calendar that's there, no for one that was deleted, and rethrows anything else", async () => {
|
||||
const provider = new GoogleCalendarProvider();
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 200 })));
|
||||
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(true);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { errors: [{ reason: "notFound" }] } }), {
|
||||
status: 404,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await provider.calendarExists({ accessToken: "t", calendarId: "cal-123" })).toBe(false);
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 503 })));
|
||||
await expect(
|
||||
provider.calendarExists({ accessToken: "t", calendarId: "cal-123" }),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_OUTAGE" });
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,12 @@ export interface CalendarProvider {
|
||||
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
|
||||
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
|
||||
|
||||
/** Creates a secondary calendar owned by this app in the person's account. */
|
||||
createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo>;
|
||||
|
||||
/** True while a calendar this app created earlier still exists. */
|
||||
calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean>;
|
||||
|
||||
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
|
||||
|
||||
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
|
||||
|
||||
@@ -8,17 +8,22 @@ import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteC
|
||||
* 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.
|
||||
* email). One calendar scope only: `calendar.app.created` — create a
|
||||
* secondary calendar and read/write events on calendars THIS APP created,
|
||||
* and nothing else. It cannot see or touch a person's existing calendars or
|
||||
* events, which is what keeps the consent screen narrow (the broader
|
||||
* `calendar.events` / `calendar.readonly` pair is what made Google show its
|
||||
* "hasn't verified this app" warning). The trade-off is by design: events go
|
||||
* to a dedicated "<Business> bookings" calendar this app creates in the
|
||||
* person's Google account, not into an existing one — see createCalendar.
|
||||
*
|
||||
* 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(" ");
|
||||
export const CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.app.created", "email"];
|
||||
const SCOPES = CALENDAR_SCOPES.join(" ");
|
||||
|
||||
export interface GoogleErrorBody {
|
||||
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||
@@ -216,6 +221,30 @@ export class GoogleCalendarProvider implements CalendarProvider {
|
||||
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
|
||||
}
|
||||
|
||||
/** Creates the secondary calendar this app writes events to. Needs only `calendar.app.created`. */
|
||||
async createCalendar(params: { accessToken: string; name: string; timeZone: string }): Promise<RemoteCalendarInfo> {
|
||||
const response = await googleFetch("https://www.googleapis.com/calendar/v3/calendars", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ summary: params.name, timeZone: params.timeZone }),
|
||||
});
|
||||
const created: { id: string; summary?: string } = await response.json();
|
||||
return { id: created.id, name: created.summary ?? params.name, writable: true, primary: false };
|
||||
}
|
||||
|
||||
/** Whether a calendar this app created earlier still exists — a reconnect reuses it instead of creating a second one. */
|
||||
async calendarExists(params: { accessToken: string; calendarId: string }): Promise<boolean> {
|
||||
try {
|
||||
await googleFetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}`, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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`,
|
||||
|
||||
@@ -37,6 +37,16 @@ export class MockCalendarProvider implements CalendarProvider {
|
||||
return this.calendars;
|
||||
}
|
||||
|
||||
async createCalendar(params: { name: string }): Promise<RemoteCalendarInfo> {
|
||||
const calendar = { id: `mock-calendar-${this.calendars.length + 1}`, name: params.name, writable: true, primary: false };
|
||||
this.calendars.push(calendar);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async calendarExists(params: { calendarId: string }): Promise<boolean> {
|
||||
return this.calendars.some((c) => c.id === params.calendarId);
|
||||
}
|
||||
|
||||
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||
const id = `mock-event-${this.nextEventId++}`;
|
||||
this.events.set(id, params.event);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppointmentStatus, Weekday } from "@/generated/prisma/enums";
|
||||
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestGuestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { getNoShowStanding, getNoShowStandings, resetNoShows } from "../no-show.service";
|
||||
|
||||
function nextMonday(timezone: string): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), 7);
|
||||
while (weekdayOf(date) !== Weekday.MONDAY) date = addDays(date, 1);
|
||||
return date;
|
||||
}
|
||||
|
||||
describe("no-show flag and block (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let staffId: string;
|
||||
let staffUserId: string;
|
||||
let monday: CalendarDate;
|
||||
const guestProfileIds: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
({ staffId, userId: staffUserId } = await createTestStaff(business));
|
||||
monday = nextMonday(business.timezone);
|
||||
guestProfileIds.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
await prisma.customerProfile
|
||||
.deleteMany({ where: { id: { in: guestProfileIds } } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const setThresholds = (flag: number | null, block: number | null) =>
|
||||
prisma.bookingRule.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { noShowFlagThreshold: flag, noShowBlockThreshold: block },
|
||||
});
|
||||
|
||||
/** Past appointments with a given outcome, oldest first, one per day. */
|
||||
async function addHistory(customerId: string, statuses: AppointmentStatus[]) {
|
||||
for (const [i, status] of statuses.entries()) {
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: new Date(Date.now() - (statuses.length - i + 1) * 24 * 60 * 60_000),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status } });
|
||||
}
|
||||
}
|
||||
|
||||
const bookOnline = (customerId: string, hour = 10) =>
|
||||
createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, hour * 60, business.timezone),
|
||||
});
|
||||
|
||||
it("does nothing while the feature is off, however many no-shows", async () => {
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW", "NO_SHOW"]);
|
||||
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(false);
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("flags at the flag number and blocks online booking at the block number", async () => {
|
||||
await setThresholds(2, 3);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 1,
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 2,
|
||||
flagged: true,
|
||||
blocked: false,
|
||||
});
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
const blocked = await bookOnline(customerId, 14);
|
||||
expect(blocked.success).toBe(false);
|
||||
if (!blocked.success)
|
||||
expect(blocked.validation.issues.map((i) => i.code)).toContain("CUSTOMER_BOOKING_BLOCKED");
|
||||
});
|
||||
|
||||
it("lets staff and owners still book a blocked customer in", async () => {
|
||||
await setThresholds(null, 1);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW"]);
|
||||
|
||||
expect((await bookOnline(customerId)).success).toBe(false);
|
||||
const byStaff = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
createdByUserId: staffUserId,
|
||||
});
|
||||
expect(byStaff.success).toBe(true);
|
||||
});
|
||||
|
||||
it("resets when they attend: a completed appointment after the no-shows clears the count", async () => {
|
||||
await setThresholds(1, 2);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(true);
|
||||
|
||||
await addHistory(customerId, ["COMPLETED"]);
|
||||
expect(
|
||||
await getNoShowStanding(prisma, { businessId: business.businessId, customerId }),
|
||||
).toMatchObject({
|
||||
count: 0,
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
expect((await bookOnline(customerId)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("an owner reset forgives past no-shows but not later ones", async () => {
|
||||
await setThresholds(null, 2);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await addHistory(customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).blocked,
|
||||
).toBe(true);
|
||||
|
||||
await resetNoShows(prisma, { customerId, now: new Date(Date.now() - 60_000) });
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
|
||||
).toBe(0);
|
||||
|
||||
// A no-show that happened after the reset counts again.
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: new Date(Date.now() - 30_000),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status: "NO_SHOW" } });
|
||||
expect(
|
||||
(await getNoShowStanding(prisma, { businessId: business.businessId, customerId })).count,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("a blocked guest can't dodge the block with a fresh profile that reuses their email or phone", async () => {
|
||||
await setThresholds(null, 2);
|
||||
const original = await createTestGuestCustomer();
|
||||
guestProfileIds.push(original.customerId);
|
||||
await addHistory(original.customerId, ["NO_SHOW", "NO_SHOW"]);
|
||||
|
||||
const sameEmail = await createTestGuestCustomer({ email: original.email.toUpperCase() });
|
||||
const samePhone = await createTestGuestCustomer({ phone: original.phone });
|
||||
guestProfileIds.push(sameEmail.customerId, samePhone.customerId);
|
||||
const stranger = await createTestGuestCustomer();
|
||||
guestProfileIds.push(stranger.customerId);
|
||||
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: sameEmail.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: samePhone.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
email: original.email,
|
||||
phone: null,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await getNoShowStanding(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId: stranger.customerId,
|
||||
})
|
||||
).blocked,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("standings for a whole day's customers only include people who have missed appointments", async () => {
|
||||
await setThresholds(1, null);
|
||||
const a = await createTestCustomer(business);
|
||||
const b = await createTestCustomer(business);
|
||||
await addHistory(a.customerId, ["NO_SHOW"]);
|
||||
await addHistory(b.customerId, ["COMPLETED"]);
|
||||
|
||||
const standings = await getNoShowStandings(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerIds: [a.customerId, b.customerId],
|
||||
});
|
||||
expect([...standings.keys()]).toEqual([a.customerId]);
|
||||
expect(standings.get(a.customerId)).toMatchObject({ flagged: true, blocked: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countNoShows, evaluateStanding, normalizeEmail } from "../no-show.service";
|
||||
|
||||
const at = (iso: string) => new Date(iso);
|
||||
const noShow = (iso: string) => ({ status: "NO_SHOW", startAt: at(iso) });
|
||||
const completed = (iso: string) => ({ status: "COMPLETED", startAt: at(iso) });
|
||||
|
||||
describe("countNoShows", () => {
|
||||
it("counts every no-show when the customer has never attended", () => {
|
||||
expect(
|
||||
countNoShows([noShow("2026-01-01T10:00:00Z"), noShow("2026-02-01T10:00:00Z")], null),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("resets after an attended appointment", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
noShow("2026-02-01T10:00:00Z"),
|
||||
completed("2026-03-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only the no-shows after the latest attended appointment", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
completed("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-03-01T10:00:00Z"),
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores order of the input", () => {
|
||||
const history = [
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
completed("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, null)).toBe(1);
|
||||
});
|
||||
|
||||
it("an owner reset forgives everything before it, but not later no-shows", () => {
|
||||
const history = [
|
||||
noShow("2026-01-01T10:00:00Z"),
|
||||
noShow("2026-02-01T10:00:00Z"),
|
||||
noShow("2026-04-01T10:00:00Z"),
|
||||
];
|
||||
expect(countNoShows(history, at("2026-03-01T00:00:00Z"))).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores appointments that are neither no-shows nor attended", () => {
|
||||
expect(countNoShows([{ status: "CANCELLED", startAt: at("2026-01-01T10:00:00Z") }], null)).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateStanding", () => {
|
||||
it("is all clear when the feature is off", () => {
|
||||
expect(evaluateStanding(9, { flagThreshold: null, blockThreshold: null })).toMatchObject({
|
||||
flagged: false,
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags at the flag threshold and blocks at the block threshold", () => {
|
||||
const thresholds = { flagThreshold: 2, blockThreshold: 4 };
|
||||
expect(evaluateStanding(1, thresholds)).toMatchObject({ flagged: false, blocked: false });
|
||||
expect(evaluateStanding(2, thresholds)).toMatchObject({ flagged: true, blocked: false });
|
||||
expect(evaluateStanding(3, thresholds)).toMatchObject({ flagged: true, blocked: false });
|
||||
expect(evaluateStanding(4, thresholds)).toMatchObject({ flagged: true, blocked: true });
|
||||
});
|
||||
|
||||
it("supports a block with no flag, and a flag with no block", () => {
|
||||
expect(evaluateStanding(3, { flagThreshold: null, blockThreshold: 3 })).toMatchObject({
|
||||
flagged: true,
|
||||
blocked: true,
|
||||
});
|
||||
expect(evaluateStanding(5, { flagThreshold: 2, blockThreshold: null })).toMatchObject({
|
||||
flagged: true,
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEmail", () => {
|
||||
it("lowercases and trims so the same address always matches", () => {
|
||||
expect(normalizeEmail(" Alex@Example.COM ")).toBe("alex@example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* No-show handling: a symbol against a customer at N no-shows, and a block
|
||||
* on online booking at M. The count is DERIVED from appointment history,
|
||||
* never stored — a no-show is an Appointment with status NO_SHOW, and the
|
||||
* count is how many of those happened after the customer's most recent
|
||||
* COMPLETED (attended) appointment, or after an owner's reset
|
||||
* (CustomerProfile.noShowResetAt), whichever is later. That is what makes
|
||||
* "this resets if they show" free: there is no counter to forget to clear,
|
||||
* and it survives guest-profile merges.
|
||||
*
|
||||
* Caveat inherent to the design: "attended" means staff marked the
|
||||
* appointment COMPLETED, so a no-show streak only resets when they do.
|
||||
*/
|
||||
|
||||
export interface NoShowThresholds {
|
||||
flagThreshold: number | null;
|
||||
blockThreshold: number | null;
|
||||
}
|
||||
|
||||
export interface NoShowStanding extends NoShowThresholds {
|
||||
count: number;
|
||||
flagged: boolean;
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
status: string;
|
||||
startAt: Date;
|
||||
}
|
||||
|
||||
/** No-shows since the latest of (last completed appointment, reset). Pure — callers pass what they already loaded. */
|
||||
export function countNoShows(history: HistoryEntry[], resetAt: Date | null): number {
|
||||
let cutoff = resetAt ? resetAt.getTime() : Number.NEGATIVE_INFINITY;
|
||||
for (const entry of history) {
|
||||
if (entry.status === AppointmentStatus.COMPLETED)
|
||||
cutoff = Math.max(cutoff, entry.startAt.getTime());
|
||||
}
|
||||
return history.filter(
|
||||
(e) => e.status === AppointmentStatus.NO_SHOW && e.startAt.getTime() > cutoff,
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Thresholds are null (off) or a positive integer; at/above counts, so a threshold of 2 flags on the second no-show. */
|
||||
export function evaluateStanding(count: number, thresholds: NoShowThresholds): NoShowStanding {
|
||||
const { flagThreshold, blockThreshold } = thresholds;
|
||||
const blocked = blockThreshold !== null && count >= blockThreshold;
|
||||
// A blocked customer is always also flagged, even if the owner set the block threshold below the flag one.
|
||||
const flagged = blocked || (flagThreshold !== null && count >= flagThreshold);
|
||||
return { count, flagThreshold, blockThreshold, flagged, blocked };
|
||||
}
|
||||
|
||||
export async function getNoShowThresholds(
|
||||
prisma: PrismaClient,
|
||||
businessId: string,
|
||||
): Promise<NoShowThresholds> {
|
||||
const rule = await prisma.bookingRule.findUnique({
|
||||
where: { businessId },
|
||||
select: { noShowFlagThreshold: true, noShowBlockThreshold: true },
|
||||
});
|
||||
return {
|
||||
flagThreshold: rule?.noShowFlagThreshold ?? null,
|
||||
blockThreshold: rule?.noShowBlockThreshold ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNoShowHandlingOn(thresholds: NoShowThresholds): boolean {
|
||||
return thresholds.flagThreshold !== null || thresholds.blockThreshold !== null;
|
||||
}
|
||||
|
||||
/** Guest profiles are keyed by email + phone, so these are the two things a person keeps constant. */
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every profile that is evidently the same person as the one asked about:
|
||||
* the profile itself, plus any sharing its email or its phone. Without
|
||||
* this a blocked guest could book again by changing one of the two.
|
||||
*/
|
||||
async function relatedProfiles(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId?: string; email?: string; phone?: string | null },
|
||||
) {
|
||||
const known = params.customerId
|
||||
? await prisma.customerProfile.findUnique({
|
||||
where: { id: params.customerId },
|
||||
select: { id: true, email: true, phone: true },
|
||||
})
|
||||
: null;
|
||||
const emails = new Set<string>();
|
||||
const phones = new Set<string>();
|
||||
if (known?.email) emails.add(normalizeEmail(known.email));
|
||||
if (known?.phone) phones.add(known.phone);
|
||||
if (params.email) emails.add(normalizeEmail(params.email));
|
||||
if (params.phone) phones.add(params.phone);
|
||||
|
||||
const or = [
|
||||
...(params.customerId ? [{ id: params.customerId }] : []),
|
||||
...[...emails].map((email) => ({ email })),
|
||||
...[...phones].map((phone) => ({ phone })),
|
||||
];
|
||||
if (or.length === 0) return [];
|
||||
return prisma.customerProfile.findMany({
|
||||
where: { OR: or },
|
||||
select: { id: true, noShowResetAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing used to ENFORCE the block: counts across every profile that
|
||||
* shares this customer's email or phone. Returns an "all clear" standing
|
||||
* without touching appointment data when the feature is off.
|
||||
*/
|
||||
export async function getNoShowStanding(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerId?: string; email?: string; phone?: string | null },
|
||||
): Promise<NoShowStanding> {
|
||||
const thresholds = await getNoShowThresholds(prisma, params.businessId);
|
||||
if (!isNoShowHandlingOn(thresholds)) return evaluateStanding(0, thresholds);
|
||||
|
||||
const profiles = await relatedProfiles(prisma, params);
|
||||
if (profiles.length === 0) return evaluateStanding(0, thresholds);
|
||||
|
||||
const history = await prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
customerId: { in: profiles.map((p) => p.id) },
|
||||
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
|
||||
},
|
||||
select: { status: true, startAt: true },
|
||||
});
|
||||
const resetAt = profiles.reduce<Date | null>(
|
||||
(latest, p) =>
|
||||
p.noShowResetAt && (!latest || p.noShowResetAt > latest) ? p.noShowResetAt : latest,
|
||||
null,
|
||||
);
|
||||
return evaluateStanding(countNoShows(history, resetAt), thresholds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standings for a batch of profiles at once (the clients list, a day's
|
||||
* calendar). Per profile, not cross-profile: display only, and one query
|
||||
* for the whole batch matters more there than catching a renamed guest.
|
||||
*/
|
||||
export async function getNoShowStandings(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; customerIds: string[] },
|
||||
): Promise<Map<string, NoShowStanding>> {
|
||||
const standings = new Map<string, NoShowStanding>();
|
||||
const thresholds = await getNoShowThresholds(prisma, params.businessId);
|
||||
if (!isNoShowHandlingOn(thresholds) || params.customerIds.length === 0) return standings;
|
||||
|
||||
const ids = [...new Set(params.customerIds)];
|
||||
const [profiles, history] = await Promise.all([
|
||||
prisma.customerProfile.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, noShowResetAt: true },
|
||||
}),
|
||||
prisma.appointment.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
customerId: { in: ids },
|
||||
status: { in: [AppointmentStatus.NO_SHOW, AppointmentStatus.COMPLETED] },
|
||||
},
|
||||
select: { customerId: true, status: true, startAt: true },
|
||||
}),
|
||||
]);
|
||||
const resetById = new Map(profiles.map((p) => [p.id, p.noShowResetAt]));
|
||||
const historyById = new Map<string, HistoryEntry[]>();
|
||||
for (const entry of history) {
|
||||
const list = historyById.get(entry.customerId) ?? [];
|
||||
list.push(entry);
|
||||
historyById.set(entry.customerId, list);
|
||||
}
|
||||
for (const id of ids) {
|
||||
const standing = evaluateStanding(
|
||||
countNoShows(historyById.get(id) ?? [], resetById.get(id) ?? null),
|
||||
thresholds,
|
||||
);
|
||||
if (standing.count > 0) standings.set(id, standing);
|
||||
}
|
||||
return standings;
|
||||
}
|
||||
|
||||
/** Owner "forgive": every profile that is evidently this person starts again from zero. */
|
||||
export async function resetNoShows(
|
||||
prisma: PrismaClient,
|
||||
params: { customerId: string; now?: Date },
|
||||
): Promise<number> {
|
||||
const profiles = await relatedProfiles(prisma, { customerId: params.customerId });
|
||||
const { count } = await prisma.customerProfile.updateMany({
|
||||
where: { id: { in: profiles.map((p) => p.id) } },
|
||||
data: { noShowResetAt: params.now ?? new Date() },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -9,8 +9,6 @@ const base: ProgrammeConfigInput = {
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "50",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "25",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -58,8 +56,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "40",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -87,8 +83,6 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "FREE_SERVICE",
|
||||
freeServiceId: inactiveService.id,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
@@ -342,21 +336,18 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("sends exactly one more warning when the reward is down to its last day — two in total", async () => {
|
||||
it("never sends a second warning when the reward is down to its last day — one heads-up is enough", async () => {
|
||||
const { reward } = await setUpAccountWithReward(new Date(Date.now() + 5 * 24 * 60 * 60 * 1000));
|
||||
|
||||
await expireOverdueRewards(prisma); // 5 days out → the "7d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left → the "1d" warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no third
|
||||
await expireOverdueRewards(prisma); // 5 days out → the one warning
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.5 * 24 * 60 * 60 * 1000)); // 12h left — no second
|
||||
await expireOverdueRewards(prisma, new Date(Date.now() + 4.8 * 24 * 60 * 60 * 1000)); // still the last day — no second
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { type: "LOYALTY_REWARD_EXPIRING", loyaltyRewardId: reward.id },
|
||||
select: { idempotencyKey: true },
|
||||
});
|
||||
expect(notifications.map((n) => n.idempotencyKey).sort()).toEqual([
|
||||
`notify:loyalty-reward-expiring:${reward.id}:1d`,
|
||||
`notify:loyalty-reward-expiring:${reward.id}:7d`,
|
||||
]);
|
||||
expect(notifications.map((n) => n.idempotencyKey)).toEqual([`notify:loyalty-reward-expiring:${reward.id}:7d`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -549,8 +549,6 @@ export interface ProgrammeConfigInput {
|
||||
rewardLabel?: string;
|
||||
maxRewardValue?: string;
|
||||
freeServiceId?: string;
|
||||
autoApplyReward: boolean;
|
||||
requiresStaffApproval: boolean;
|
||||
stampsExpireAfterDays?: number;
|
||||
rewardExpiresAfterDays?: number;
|
||||
earnOnCancelled: boolean;
|
||||
@@ -630,8 +628,6 @@ export async function updateProgrammeConfig(
|
||||
rewardLabel: params.input.rewardLabel?.trim() || null,
|
||||
maxRewardValue: params.input.maxRewardValue || null,
|
||||
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
|
||||
autoApplyReward: params.input.autoApplyReward,
|
||||
requiresStaffApproval: params.input.requiresStaffApproval,
|
||||
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
|
||||
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
|
||||
earnOnCancelled: params.input.earnOnCancelled,
|
||||
@@ -864,9 +860,9 @@ const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
|
||||
* expiresAt has passed to EXPIRED (redeemReward also defends against this
|
||||
* directly, but the sweep is what makes "Expired" actually show up in
|
||||
* history/UI instead of staying stuck at AVAILABLE forever), then warn
|
||||
* about rewards still AVAILABLE and expiring within the next week — twice
|
||||
* per reward at most (7 days out, then the last day; see
|
||||
* notifyLoyaltyRewardExpiring), not on every sweep.
|
||||
* about rewards still AVAILABLE and expiring within the next week — once
|
||||
* per reward (see notifyLoyaltyRewardExpiring), not on every sweep. A second
|
||||
* "last day" email was cut: one heads-up is enough, and it arrives a week out.
|
||||
*/
|
||||
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
|
||||
await prisma.loyaltyReward.updateMany({
|
||||
@@ -890,7 +886,7 @@ export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new
|
||||
rewardId: reward.id,
|
||||
rewardLabel: reward.label,
|
||||
expiresAt: reward.expiresAt,
|
||||
bucket: reward.expiresAt.getTime() - now.getTime() <= 86_400_000 ? "1d" : "7d",
|
||||
bucket: "7d",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -231,15 +231,9 @@ describe("NotificationService (integration)", () => {
|
||||
expect(staffNotification.deliveries.map((d) => d.channel)).toContain(NotificationChannel.IN_APP);
|
||||
});
|
||||
|
||||
it("STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, OWNER_NEW_BOOKING, and OWNER_NO_SHOW default to off with no preference row", async () => {
|
||||
it("OWNER_NEW_BOOKING and OWNER_NO_SHOW default to off on every channel with no preference row", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const disabledByDefault = [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
];
|
||||
for (const type of disabledByDefault) {
|
||||
for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type,
|
||||
@@ -251,6 +245,19 @@ describe("NotificationService (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("STAFF_NEW_APPOINTMENT and STAFF_APPOINTMENT_RESCHEDULED never email by default, and a stored EMAIL preference opts in", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
for (const type of [NotificationType.STAFF_NEW_APPOINTMENT, NotificationType.STAFF_APPOINTMENT_RESCHEDULED]) {
|
||||
const params = { businessId: business.businessId, type, recipientUserId: userId, bodyText: "x" };
|
||||
const { notification: defaults } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:default:${type}` });
|
||||
expect(defaults.deliveries.map((d) => d.channel)).not.toContain(NotificationChannel.EMAIL);
|
||||
|
||||
await setNotificationPreference(prisma, { userId, type, channel: NotificationChannel.EMAIL, isEnabled: true });
|
||||
const { notification: optedIn } = await notify(prisma, { ...params, idempotencyKey: `test:${userId}:optin:${type}` });
|
||||
expect(optedIn.deliveries.map((d) => d.channel)).toContain(NotificationChannel.EMAIL);
|
||||
}
|
||||
});
|
||||
|
||||
it("STAFF_APPOINTMENT_REMINDER defaults to IN_APP on and EMAIL off, and a stored EMAIL preference opts in", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const params = {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { notify } from "../notification.service";
|
||||
import { getQueueHealth } from "../queue-health";
|
||||
import { MockEmailProvider } from "../providers/mock-email-provider";
|
||||
import { processDueNotificationDeliveries } from "../reminder-worker";
|
||||
|
||||
describe("notification queue health (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let n = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
/** One EMAIL delivery for a registered customer, backdated by `minutesOld`. */
|
||||
async function queueEmail(minutesOld: number) {
|
||||
const { customerId, userId } = await createTestCustomer(business);
|
||||
const { notification } = await notify(prisma, {
|
||||
businessId: business.businessId,
|
||||
type: NotificationType.BOOKING_CONFIRMED,
|
||||
recipientUserId: userId,
|
||||
recipientCustomerId: customerId,
|
||||
bodyText: "hi",
|
||||
idempotencyKey: `qh:${Date.now()}:${n++}`,
|
||||
});
|
||||
const delivery = notification.deliveries.find((d) => d.channel === "EMAIL")!;
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: { createdAt: new Date(Date.now() - minutesOld * 60_000) },
|
||||
});
|
||||
return delivery.id;
|
||||
}
|
||||
|
||||
// The test database is shared by every business a run has created, so assert on differences.
|
||||
it("counts only deliveries that have been waiting more than ten minutes", async () => {
|
||||
const before = await getQueueHealth(prisma);
|
||||
await queueEmail(2); // fresh: not stale
|
||||
await queueEmail(30); // stale
|
||||
const after = await getQueueHealth(prisma);
|
||||
|
||||
expect(after.stalePendingCount - before.stalePendingCount).toBe(1);
|
||||
expect(after.oldestPendingAgeSeconds).toBeGreaterThanOrEqual(29 * 60);
|
||||
});
|
||||
|
||||
it("puts a delivery stuck in PROCESSING back in the queue, and sends it", async () => {
|
||||
const id = await queueEmail(60);
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id },
|
||||
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30 * 60_000) },
|
||||
});
|
||||
expect((await getQueueHealth(prisma)).stuckProcessingCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const provider = new MockEmailProvider();
|
||||
await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: provider,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
|
||||
const delivery = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } });
|
||||
expect(delivery.status).toBe("SENT");
|
||||
expect(provider.sentEmails).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves a delivery that is mid-send right now alone", async () => {
|
||||
const id = await queueEmail(1);
|
||||
await prisma.notificationDelivery.update({
|
||||
where: { id },
|
||||
data: { status: "PROCESSING", lastAttemptedAt: new Date(Date.now() - 30_000) },
|
||||
});
|
||||
|
||||
await processDueNotificationDeliveries(prisma, {
|
||||
emailProvider: new MockEmailProvider(),
|
||||
businessId: business.businessId,
|
||||
});
|
||||
|
||||
expect((await prisma.notificationDelivery.findUniqueOrThrow({ where: { id } })).status).toBe(
|
||||
"PROCESSING",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assessQueueHealth } from "../queue-health";
|
||||
import type { QueueHealth } from "../queue-health";
|
||||
import { isSystemicFailureReason } from "../failure-reasons";
|
||||
|
||||
const now = new Date("2026-10-20T12:00:00Z");
|
||||
const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000);
|
||||
const calm: QueueHealth = {
|
||||
stalePendingCount: 0,
|
||||
oldestPendingAgeSeconds: null,
|
||||
stuckProcessingCount: 0,
|
||||
failedLast24h: 0,
|
||||
sentLast24h: 10,
|
||||
};
|
||||
|
||||
const assess = (overrides: Partial<Parameters<typeof assessQueueHealth>[0]> = {}) =>
|
||||
assessQueueHealth({
|
||||
installed: true,
|
||||
installedAt: minutesAgo(60 * 24 * 30),
|
||||
lastCronRunAt: minutesAgo(1),
|
||||
queue: calm,
|
||||
now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("assessQueueHealth", () => {
|
||||
it("is healthy when the cron ran a minute ago and nothing is backed up", () => {
|
||||
expect(assess()).toEqual({ status: "healthy", degradedReasons: [] });
|
||||
});
|
||||
|
||||
it("is degraded when the cron hasn't run for ten minutes, and says how long", () => {
|
||||
const result = assess({ lastCronRunAt: minutesAgo(45) });
|
||||
expect(result.status).toBe("degraded");
|
||||
expect(result.degradedReasons[0]).toContain("45 minutes");
|
||||
});
|
||||
|
||||
it("is degraded when the cron has never run on an established install", () => {
|
||||
const result = assess({ lastCronRunAt: null });
|
||||
expect(result.status).toBe("degraded");
|
||||
expect(result.degradedReasons[0]).toContain("never run");
|
||||
});
|
||||
|
||||
it("gives a brand-new install ten minutes before 'the cron never ran' counts", () => {
|
||||
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(3) }).status).toBe("healthy");
|
||||
expect(assess({ lastCronRunAt: null, installedAt: minutesAgo(30) }).status).toBe("degraded");
|
||||
});
|
||||
|
||||
it("says nothing about the cron before the app is installed", () => {
|
||||
expect(assess({ installed: false, installedAt: null, lastCronRunAt: null }).status).toBe(
|
||||
"healthy",
|
||||
);
|
||||
});
|
||||
|
||||
it("is degraded when notifications are waiting or stuck", () => {
|
||||
expect(
|
||||
assess({ queue: { ...calm, stalePendingCount: 4, oldestPendingAgeSeconds: 900 } })
|
||||
.degradedReasons[0],
|
||||
).toContain("4 notification");
|
||||
expect(assess({ queue: { ...calm, stuckProcessingCount: 1 } }).status).toBe("degraded");
|
||||
});
|
||||
|
||||
it("treats a few failures among successes as normal, but all-failing as a setup problem", () => {
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 20 } }).status).toBe(
|
||||
"healthy",
|
||||
);
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 5, sentLast24h: 0 } }).status).toBe(
|
||||
"degraded",
|
||||
);
|
||||
expect(assess({ queue: { ...calm, failedLast24h: 2, sentLast24h: 0 } }).status).toBe("healthy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSystemicFailureReason", () => {
|
||||
it("recognises setup problems: bad SMTP login, auth failures, missing keys", () => {
|
||||
for (const reason of [
|
||||
"Invalid login: 535 5.7.8 Authentication failed",
|
||||
"Web Push is not configured — VAPID_PUBLIC_KEY must be set",
|
||||
"401 Unauthorized",
|
||||
"Invalid API key",
|
||||
]) {
|
||||
expect(isSystemicFailureReason(reason)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat one unreachable recipient as a setup problem", () => {
|
||||
for (const reason of [
|
||||
"550 5.1.1 Mailbox not found",
|
||||
"No email address on file for this recipient.",
|
||||
"Connection timed out",
|
||||
]) {
|
||||
expect(isSystemicFailureReason(reason)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -53,13 +53,33 @@ describe("defaultPreferenceIsEnabled", () => {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
|
||||
});
|
||||
|
||||
it("types that default off do so on every channel", () => {
|
||||
it("routine staff events are push-only by default: email off, push on", () => {
|
||||
for (const type of [
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.STAFF_WHATSAPP_REMINDERS_DUE,
|
||||
]) {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(false);
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.PUSH)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("important staff and owner events keep email on by default", () => {
|
||||
for (const type of [
|
||||
NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
NotificationType.STAFF_SCHEDULE_CHANGED,
|
||||
NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
NotificationType.OWNER_BOOKING_CANCELLED,
|
||||
NotificationType.OWNER_STAFF_INVITATION_ACCEPTED,
|
||||
NotificationType.OWNER_REPEATED_DELIVERY_FAILURE,
|
||||
]) {
|
||||
expect(defaultPreferenceIsEnabled(type, NotificationChannel.EMAIL)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("owner-facing types that would be noise for a multi-barber shop default off on every channel", () => {
|
||||
for (const type of [NotificationType.OWNER_NEW_BOOKING, NotificationType.OWNER_NO_SHOW]) {
|
||||
for (const channel of channels) expect(defaultPreferenceIsEnabled(type, channel)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Failures that say the SETUP is broken (a wrong SMTP login, bad push keys, a
|
||||
* missing config) rather than one recipient being unreachable (a mistyped
|
||||
* address). The first of these is worth telling the owner about at once; a
|
||||
* recipient-specific one has to repeat before it is.
|
||||
*/
|
||||
export function isSystemicFailureReason(reason: string): boolean {
|
||||
return /\b(auth(entication)?|invalid login|credential|unauthori[sz]ed|forbidden|api[- ]?key|vapid|not configured|535|534|530)\b/i.test(
|
||||
reason,
|
||||
);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export class BrevoEmailProvider implements EmailProvider {
|
||||
// display name comes from the caller.
|
||||
from: { address: this.fromAddress, name: params.sender.name },
|
||||
to: { address: params.to.email, name: params.to.name },
|
||||
replyTo: this.replyTo,
|
||||
replyTo: params.replyTo || this.replyTo,
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
text: params.text,
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface SendEmailParams {
|
||||
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
|
||||
sender: EmailSender;
|
||||
subject: string;
|
||||
/**
|
||||
* Where a customer's reply should go — the business's own email, so answering
|
||||
* "can I move my appointment?" reaches the shop rather than a shared inbox.
|
||||
* Falls back to the provider's own configured reply-to (EMAIL_REPLY_TO), if any.
|
||||
*/
|
||||
replyTo?: string;
|
||||
html: string;
|
||||
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
||||
text: string;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationChannel, NotificationDeliveryStatus } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* What GET /api/health reports about the notification queue, and the rules
|
||||
* that turn it into "healthy" or "degraded". The Manager polls this per
|
||||
* business and emails the operator when it turns degraded (see
|
||||
* manager/src/lib/health.ts, which reads exactly these field names).
|
||||
*
|
||||
* Why a "degraded" state at all: email, push and WhatsApp nudges are all
|
||||
* delivered by a background tick (/api/cron/process-notifications). If that
|
||||
* tick silently stops, nothing errors — messages just never go out — so the
|
||||
* app has to say so itself.
|
||||
*/
|
||||
|
||||
export const STALE_AFTER_MINUTES = 10;
|
||||
|
||||
export interface QueueHealth {
|
||||
/** Email/push deliveries that were due more than STALE_AFTER_MINUTES ago and still haven't been sent. */
|
||||
stalePendingCount: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
/** Deliveries claimed by a worker that then never finished (it crashed mid-send). */
|
||||
stuckProcessingCount: number;
|
||||
failedLast24h: number;
|
||||
sentLast24h: number;
|
||||
}
|
||||
|
||||
const QUEUED_CHANNELS = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||
|
||||
export async function getQueueHealth(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<QueueHealth> {
|
||||
const cutoff = new Date(now.getTime() - STALE_AFTER_MINUTES * 60_000);
|
||||
const dayAgo = new Date(now.getTime() - 24 * 3_600_000);
|
||||
|
||||
const [pending, scheduled, stuck, failed, sent] = await Promise.all([
|
||||
// Created and never picked up. PENDING rows have no scheduledFor unless they're waiting out a retry backoff.
|
||||
prisma.notificationDelivery.aggregate({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.PENDING,
|
||||
OR: [{ scheduledFor: null, createdAt: { lt: cutoff } }, { scheduledFor: { lt: cutoff } }],
|
||||
},
|
||||
_count: true,
|
||||
_min: { createdAt: true, scheduledFor: true },
|
||||
}),
|
||||
// Reminders whose time has come.
|
||||
prisma.notificationDelivery.aggregate({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.SCHEDULED,
|
||||
scheduledFor: { lt: cutoff },
|
||||
},
|
||||
_count: true,
|
||||
_min: { scheduledFor: true },
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.PROCESSING,
|
||||
lastAttemptedAt: { lt: cutoff },
|
||||
},
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.FAILED,
|
||||
lastAttemptedAt: { gte: dayAgo },
|
||||
},
|
||||
}),
|
||||
prisma.notificationDelivery.count({
|
||||
where: {
|
||||
channel: { in: QUEUED_CHANNELS },
|
||||
status: NotificationDeliveryStatus.SENT,
|
||||
sentAt: { gte: dayAgo },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const dueTimes = [
|
||||
pending._min.scheduledFor ?? pending._min.createdAt,
|
||||
scheduled._min.scheduledFor,
|
||||
].filter((d): d is Date => d instanceof Date);
|
||||
const oldest = dueTimes.length > 0 ? Math.min(...dueTimes.map((d) => d.getTime())) : null;
|
||||
|
||||
return {
|
||||
stalePendingCount: pending._count + scheduled._count,
|
||||
oldestPendingAgeSeconds:
|
||||
oldest === null ? null : Math.max(0, Math.round((now.getTime() - oldest) / 1000)),
|
||||
stuckProcessingCount: stuck,
|
||||
failedLast24h: failed,
|
||||
sentLast24h: sent,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthAssessment {
|
||||
status: "healthy" | "degraded";
|
||||
degradedReasons: string[];
|
||||
}
|
||||
|
||||
/** Pure. `installedAt`/`lastCronRunAt` null = never. A brand-new install gets a grace period before "the cron never ran" counts. */
|
||||
export function assessQueueHealth(input: {
|
||||
installed: boolean;
|
||||
installedAt: Date | null;
|
||||
lastCronRunAt: Date | null;
|
||||
queue: QueueHealth;
|
||||
now?: Date;
|
||||
}): HealthAssessment {
|
||||
const now = input.now ?? new Date();
|
||||
const reasons: string[] = [];
|
||||
const minutesAgo = (d: Date) => Math.round((now.getTime() - d.getTime()) / 60_000);
|
||||
|
||||
if (input.installed) {
|
||||
const reference = input.lastCronRunAt ?? input.installedAt;
|
||||
if (reference && minutesAgo(reference) >= STALE_AFTER_MINUTES) {
|
||||
reasons.push(
|
||||
input.lastCronRunAt
|
||||
? `The notification processor hasn't run for ${minutesAgo(input.lastCronRunAt)} minutes — emails and push notifications are not being sent.`
|
||||
: "The notification processor has never run — emails and push notifications are not being sent.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.queue.stalePendingCount > 0) {
|
||||
reasons.push(
|
||||
`${input.queue.stalePendingCount} notification(s) have been waiting more than ${STALE_AFTER_MINUTES} minutes to send.`,
|
||||
);
|
||||
}
|
||||
if (input.queue.stuckProcessingCount > 0) {
|
||||
reasons.push(`${input.queue.stuckProcessingCount} notification(s) got stuck while sending.`);
|
||||
}
|
||||
// A handful of failures is normal (a mistyped address). Every one failing is a configuration problem: bad SMTP login, bad push keys.
|
||||
if (input.queue.failedLast24h >= 3 && input.queue.sentLast24h === 0) {
|
||||
reasons.push(
|
||||
`All ${input.queue.failedLast24h} notification deliveries in the last 24 hours failed — check the email and push settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: reasons.length > 0 ? "degraded" : "healthy", degradedReasons: reasons };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationChannel, NotificationDeliveryStatus, NotificationType } from "@/generated/prisma/enums";
|
||||
import { isSystemicFailureReason } from "./failure-reasons";
|
||||
import { notifySafely, resolveActivePushSubscriptions, resolveEmailAddress } from "./notification.service";
|
||||
import type { EmailProvider } from "./providers/email-provider";
|
||||
import type { PushProvider } from "./providers/push-provider";
|
||||
@@ -39,6 +40,9 @@ const BACKOFF_MINUTES = [1, 5, 30] as const;
|
||||
/** Failed deliveries for one business within a single processing run at or above this count raises OWNER_REPEATED_DELIVERY_FAILURE — see NotificationType in schema.prisma. */
|
||||
const REPEATED_FAILURE_THRESHOLD = 3;
|
||||
|
||||
/** A delivery claimed this long ago that never finished belongs to a worker that died mid-send. */
|
||||
const STUCK_PROCESSING_AFTER_MINUTES = 10;
|
||||
|
||||
export interface ProcessNotificationsResult {
|
||||
claimed: number;
|
||||
sent: number;
|
||||
@@ -130,6 +134,17 @@ export async function processDueNotificationDeliveries(
|
||||
const now = params.now ?? new Date();
|
||||
const batchSize = params.batchSize ?? 25;
|
||||
|
||||
// A worker that crashed mid-send leaves its delivery PROCESSING forever, and nothing else
|
||||
// ever picks it up. Put those back in the queue (a send takes seconds; 10 minutes is a corpse).
|
||||
await prisma.notificationDelivery.updateMany({
|
||||
where: {
|
||||
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
|
||||
status: NotificationDeliveryStatus.PROCESSING,
|
||||
lastAttemptedAt: { lt: new Date(now.getTime() - STUCK_PROCESSING_AFTER_MINUTES * 60_000) },
|
||||
},
|
||||
data: { status: NotificationDeliveryStatus.PENDING },
|
||||
});
|
||||
|
||||
const due = await prisma.notificationDelivery.findMany({
|
||||
where: {
|
||||
channel: { in: [NotificationChannel.EMAIL, NotificationChannel.PUSH] },
|
||||
@@ -152,6 +167,8 @@ export async function processDueNotificationDeliveries(
|
||||
inAppReleased: 0,
|
||||
};
|
||||
const failuresByBusiness = new Map<string, number>();
|
||||
const systemicFailureBusinesses = new Set<string>();
|
||||
|
||||
|
||||
const inApp = await releaseDueInAppReminders(prisma, now);
|
||||
result.inAppReleased = inApp.released;
|
||||
@@ -230,11 +247,12 @@ export async function processDueNotificationDeliveries(
|
||||
delivery.notification.businessId,
|
||||
(failuresByBusiness.get(delivery.notification.businessId) ?? 0) + 1,
|
||||
);
|
||||
if (isSystemicFailureReason(outcome.reason)) systemicFailureBusinesses.add(delivery.notification.businessId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [businessId, count] of failuresByBusiness) {
|
||||
if (count >= REPEATED_FAILURE_THRESHOLD) {
|
||||
if (count >= REPEATED_FAILURE_THRESHOLD || systemicFailureBusinesses.has(businessId)) {
|
||||
await raiseRepeatedFailureNotice(prisma, businessId, now);
|
||||
}
|
||||
}
|
||||
@@ -266,6 +284,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
||||
const sendResult = await emailProvider.sendEmail({
|
||||
to: { email: recipientEmail },
|
||||
sender: email.sender,
|
||||
replyTo: email.replyTo,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
text: email.text,
|
||||
@@ -353,7 +372,7 @@ async function raiseRepeatedFailureNotice(prisma: PrismaClient, businessId: stri
|
||||
recipientUserId: ownerUserId,
|
||||
subject: "Some notifications failed to send",
|
||||
bodyText: "Several notifications failed to send today. Check your notification settings, Brevo configuration, and push setup.",
|
||||
deepLinkPath: "/owner/notifications",
|
||||
deepLinkPath: "/staff/settings/notifications",
|
||||
idempotencyKey: `notify:owner-repeated-delivery-failure:${businessId}:${dayStamp}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { recipientKindOf } from "./types";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
@@ -21,6 +22,44 @@ 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;
|
||||
/** The business's own email, so a customer's reply reaches the shop. Undefined when the business hasn't set one. */
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
/** Button text per notification type for emails that use the generic template. */
|
||||
const GENERIC_ACTION_LABEL: Partial<Record<NotificationType, string>> = {
|
||||
BOOKING_APPROVED: "View Appointment",
|
||||
BOOKING_BARBER_CHANGED: "View Appointment",
|
||||
LOYALTY_REWARD_EXPIRING: "View Loyalty Card",
|
||||
STAFF_NEW_APPOINTMENT: "View Appointment",
|
||||
STAFF_APPOINTMENT_RESCHEDULED: "View Appointment",
|
||||
STAFF_APPOINTMENT_CANCELLED: "View Appointment",
|
||||
STAFF_APPOINTMENT_REMINDER: "View Appointment",
|
||||
STAFF_SCHEDULE_CHANGED: "View Schedule",
|
||||
STAFF_INVITATION_RECEIVED: "Accept Invitation",
|
||||
STAFF_WHATSAPP_REMINDERS_DUE: "Open Reminders",
|
||||
OWNER_NEW_BOOKING: "View Booking",
|
||||
OWNER_BOOKING_CANCELLED: "View Booking",
|
||||
OWNER_NO_SHOW: "View Booking",
|
||||
OWNER_STAFF_INVITATION_ACCEPTED: "View Team",
|
||||
OWNER_REPEATED_DELIVERY_FAILURE: "Check Notification Settings",
|
||||
};
|
||||
|
||||
/** Where this recipient manages their notification choices — guests have no account, so no link. */
|
||||
function preferencesPathFor(notification: { type: NotificationType; recipientUserId: string | null }): string | undefined {
|
||||
if (!notification.recipientUserId) return undefined;
|
||||
return recipientKindOf(notification.type) === "CUSTOMER" ? "/settings/notifications" : "/staff/settings/notifications";
|
||||
}
|
||||
|
||||
/** The .ics download for an appointment, carrying the same guest token as the manage link so it works without signing in. */
|
||||
function calendarPathFor(appointmentId: string, manageAppointmentPath: string): string {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = new URL(manageAppointmentPath, "http://placeholder.invalid").searchParams.get("token");
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
return `/api/appointments/${appointmentId}/calendar.ics${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +100,8 @@ export async function renderEmailForNotification(
|
||||
if (!business) return null;
|
||||
|
||||
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||
const replyTo = business.email?.trim() || undefined;
|
||||
const format = { timezone: business.timezone, currency: business.currency, locale: business.locale };
|
||||
|
||||
const businessInfo = {
|
||||
name: business.name,
|
||||
@@ -70,11 +111,12 @@ export async function renderEmailForNotification(
|
||||
: undefined,
|
||||
phone: business.phone ?? undefined,
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? undefined,
|
||||
themeColor: business.themeColor ?? undefined,
|
||||
};
|
||||
|
||||
const recipientName = await resolveRecipientName(prisma, notification);
|
||||
const rendered = await renderBody();
|
||||
return { ...rendered, sender };
|
||||
return { ...rendered, sender, replyTo };
|
||||
|
||||
async function renderBody(): Promise<RenderedEmail> {
|
||||
switch (notification.type) {
|
||||
@@ -83,11 +125,19 @@ export async function renderEmailForNotification(
|
||||
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",
|
||||
});
|
||||
const manageAppointmentPath = notification.deepLinkPath ?? "/appointments";
|
||||
const data = await buildAppointmentEmailData(
|
||||
prisma,
|
||||
notification,
|
||||
{
|
||||
business: businessInfo,
|
||||
recipientName,
|
||||
manageAppointmentPath,
|
||||
calendarPath: notification.appointmentId ? calendarPathFor(notification.appointmentId, manageAppointmentPath) : undefined,
|
||||
preferencesPath: preferencesPathFor(notification),
|
||||
},
|
||||
format,
|
||||
);
|
||||
if (!data) return renderFallback();
|
||||
if (notification.type === NotificationType.BOOKING_CONFIRMED)
|
||||
return renderBookingConfirmedEmail(data);
|
||||
@@ -123,6 +173,8 @@ export async function renderEmailForNotification(
|
||||
subject: notification.subject ?? businessInfo.name,
|
||||
bodyText: notification.bodyText,
|
||||
deepLinkPath: notification.deepLinkPath ?? undefined,
|
||||
actionLabel: GENERIC_ACTION_LABEL[notification.type],
|
||||
preferencesPath: preferencesPathFor(notification),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -152,7 +204,11 @@ async function resolveRecipientName(
|
||||
async function buildAppointmentEmailData(
|
||||
prisma: PrismaClient,
|
||||
notification: { appointmentId: string | null },
|
||||
base: Pick<AppointmentEmailData, "business" | "recipientName" | "manageAppointmentPath">,
|
||||
base: Pick<
|
||||
AppointmentEmailData,
|
||||
"business" | "recipientName" | "manageAppointmentPath" | "calendarPath" | "preferencesPath"
|
||||
>,
|
||||
format: { timezone: string; currency: string; locale: string },
|
||||
): Promise<AppointmentEmailData | null> {
|
||||
if (!notification.appointmentId) return null;
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
@@ -174,17 +230,21 @@ async function buildAppointmentEmailData(
|
||||
...base,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
||||
// Always in the business's own timezone: the server runs in UTC, so formatting without
|
||||
// one showed a 10:00 summer appointment as 09:00 in its own confirmation email.
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: format.timezone,
|
||||
}),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: format.timezone,
|
||||
}),
|
||||
durationLabel: `${totalMinutes} min`,
|
||||
totalLabel: `£${totalPrice.toFixed(2)}`,
|
||||
totalLabel: formatMoney(totalPrice, format),
|
||||
appointmentRef: appointment.id.slice(-8),
|
||||
};
|
||||
}
|
||||
@@ -214,3 +274,12 @@ async function buildLoyaltyRewardEmailData(
|
||||
loyaltyCardPath: "/home",
|
||||
};
|
||||
}
|
||||
|
||||
/** The business's own currency and locale — a hard-coded "£" was wrong for any other currency. */
|
||||
export function formatMoney(amount: number, format: { currency: string; locale: string }): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(format.locale, { style: "currency", currency: format.currency }).format(amount);
|
||||
} catch {
|
||||
return `${format.currency} ${amount.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeHtml } from "../layout";
|
||||
import { escapeHtml, normalizeAccentColor, readableTextOn, renderEmailLayout } from "../layout";
|
||||
import {
|
||||
renderBookingCancelledEmail,
|
||||
renderBookingConfirmedEmail,
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
} from "../booking-emails";
|
||||
import type { AppointmentEmailData } from "../booking-emails";
|
||||
import { renderGenericNotificationEmail } from "../generic-email";
|
||||
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "../loyalty-emails";
|
||||
import {
|
||||
renderLoyaltyRewardEarnedEmail,
|
||||
renderLoyaltyRewardRedeemedEmail,
|
||||
} from "../loyalty-emails";
|
||||
import type { LoyaltyRewardEmailData } from "../loyalty-emails";
|
||||
import { renderStaffInvitationEmail, renderStaffScheduleChangedEmail } from "../staff-emails";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("escapes all five HTML-significant characters", () => {
|
||||
@@ -21,7 +23,11 @@ describe("escapeHtml", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const business = { name: "Blake Heritage Barbershop", address: "1 High St, London", phone: "020 1234 5678" };
|
||||
const business = {
|
||||
name: "Blake Heritage Barbershop",
|
||||
address: "1 High St, London",
|
||||
phone: "020 1234 5678",
|
||||
};
|
||||
|
||||
const appointmentData: AppointmentEmailData = {
|
||||
business,
|
||||
@@ -66,7 +72,9 @@ describe("booking email templates", () => {
|
||||
});
|
||||
|
||||
it("renderBookingCancelledEmail escapes a user-supplied cancellation reason", () => {
|
||||
const email = renderBookingCancelledEmail(appointmentData, { reason: `<img src=x onerror="alert(1)">` });
|
||||
const email = renderBookingCancelledEmail(appointmentData, {
|
||||
reason: `<img src=x onerror="alert(1)">`,
|
||||
});
|
||||
expect(email.html).not.toContain("<img src=x");
|
||||
expect(email.html).toContain("<img");
|
||||
});
|
||||
@@ -98,32 +106,6 @@ describe("loyalty email templates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("staff email templates", () => {
|
||||
it("renderStaffInvitationEmail includes the accept link and role", () => {
|
||||
const email = renderStaffInvitationEmail({
|
||||
business,
|
||||
staffName: "Jamie Wallace",
|
||||
roleName: "Barber",
|
||||
invitedByName: "Marcus Reed",
|
||||
acceptInvitePath: "/invite/accept?token=abc",
|
||||
});
|
||||
expect(email.html).toContain("Barber");
|
||||
expect(email.html).toContain("/invite/accept?token=abc");
|
||||
});
|
||||
|
||||
it("renderStaffScheduleChangedEmail names who made the change", () => {
|
||||
const email = renderStaffScheduleChangedEmail({
|
||||
business,
|
||||
staffName: "Leo Vance",
|
||||
changedByName: "Marcus Reed",
|
||||
whatChanged: "your working hours",
|
||||
viewSchedulePath: "/staff/settings/working-hours",
|
||||
});
|
||||
expect(email.html).toContain("Marcus Reed");
|
||||
expect(email.html).toContain("your working hours");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic fallback template", () => {
|
||||
it("renders the notification's own subject/bodyText when no bespoke template exists", () => {
|
||||
const email = renderGenericNotificationEmail({
|
||||
@@ -148,3 +130,97 @@ describe("generic fallback template", () => {
|
||||
expect(email.html).not.toContain("View Details");
|
||||
});
|
||||
});
|
||||
|
||||
describe("email layout", () => {
|
||||
const layout = (overrides: Partial<Parameters<typeof renderEmailLayout>[0]> = {}) =>
|
||||
renderEmailLayout({
|
||||
businessName: "Blake Heritage",
|
||||
headline: "Hello",
|
||||
bodyHtml: "<p>Body</p>",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("is a mobile-friendly, dark-mode-aware document", () => {
|
||||
const html = layout();
|
||||
expect(html).toContain('<html lang="en">');
|
||||
expect(html).toContain('name="viewport"');
|
||||
expect(html).toContain('name="color-scheme" content="light dark"');
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("<title>Hello</title>");
|
||||
});
|
||||
|
||||
it("shows the preheader as hidden inbox preview text, escaped", () => {
|
||||
const html = layout({ preheader: "Mon 3 Aug <at> 16:30" });
|
||||
expect(html).toContain("display:none");
|
||||
expect(html).toContain("Mon 3 Aug <at> 16:30");
|
||||
});
|
||||
|
||||
it("renders details in a card and escapes their values", () => {
|
||||
const html = layout({ details: [{ label: "Barber", value: "Marcus <Reed>" }] });
|
||||
expect(html).toContain("Barber");
|
||||
expect(html).toContain("Marcus <Reed>");
|
||||
expect(html).not.toContain("Marcus <Reed>");
|
||||
});
|
||||
|
||||
it("uses the business colour for the button with readable text on it", () => {
|
||||
const dark = layout({
|
||||
actionLabel: "Go",
|
||||
actionUrl: "https://x.test/a",
|
||||
accentColor: "#1f3440",
|
||||
});
|
||||
expect(dark).toContain("background:#1f3440");
|
||||
expect(dark).toContain("color:#ffffff");
|
||||
const light = layout({
|
||||
actionLabel: "Go",
|
||||
actionUrl: "https://x.test/a",
|
||||
accentColor: "#e9c98f",
|
||||
});
|
||||
expect(light).toContain("background:#e9c98f");
|
||||
expect(light).toContain("color:#111111");
|
||||
});
|
||||
|
||||
it("never lets a non-hex colour into an inline style", () => {
|
||||
expect(normalizeAccentColor("red;background:url(x)")).toBe("#171717");
|
||||
expect(normalizeAccentColor("#ABCDEF")).toBe("#abcdef");
|
||||
expect(normalizeAccentColor(undefined)).toBe("#171717");
|
||||
expect(readableTextOn("#ffffff")).toBe("#111111");
|
||||
expect(readableTextOn("#000000")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("lists secondary and footer links in both the HTML and the plain-text version", () => {
|
||||
const email = renderBookingConfirmedEmail({
|
||||
...appointmentData,
|
||||
calendarPath: "/api/appointments/abc123/calendar.ics?token=raw",
|
||||
preferencesPath: "/settings/notifications",
|
||||
});
|
||||
expect(email.html).toContain("Add to calendar");
|
||||
expect(email.html).toContain("View on map");
|
||||
expect(email.html).toContain("Manage notification preferences");
|
||||
expect(email.text).toContain("Add to calendar:");
|
||||
expect(email.text).toContain("google.com/maps");
|
||||
expect(email.text).toContain("/settings/notifications");
|
||||
});
|
||||
|
||||
it("leaves out the calendar and map links on a cancelled booking, and the preferences link for a guest", () => {
|
||||
const email = renderBookingCancelledEmail({
|
||||
...appointmentData,
|
||||
calendarPath: "/api/appointments/abc123/calendar.ics",
|
||||
});
|
||||
expect(email.html).not.toContain("Add to calendar");
|
||||
expect(email.html).not.toContain("View on map");
|
||||
expect(email.html).not.toContain("Manage notification preferences");
|
||||
});
|
||||
|
||||
it("uses a fitting button label for the generic template when one is given", () => {
|
||||
const email = renderGenericNotificationEmail({
|
||||
business,
|
||||
recipientName: "Jamie",
|
||||
subject: "You've been invited",
|
||||
bodyText: "Marcus invited you.",
|
||||
deepLinkPath: "/invite/accept?token=abc",
|
||||
actionLabel: "Accept Invitation",
|
||||
});
|
||||
expect(email.html).toContain("Accept Invitation");
|
||||
expect(email.text).toContain("Accept Invitation:");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
|
||||
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import type { EmailDetail, EmailLink } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText, mapUrl } from "./shared";
|
||||
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
|
||||
|
||||
export interface AppointmentEmailData {
|
||||
@@ -12,31 +13,53 @@ export interface AppointmentEmailData {
|
||||
durationLabel: string;
|
||||
totalLabel: string;
|
||||
appointmentRef: string;
|
||||
/** Relative path — e.g. "/appointments/xyz?token=..." for a guest, "/appointments" for a registered customer. */
|
||||
/** Relative path — e.g. "/appointments/xyz?token=[REDACTED] for a guest, "/appointments" for a registered customer. */
|
||||
manageAppointmentPath: string;
|
||||
/** Relative path of the .ics download for this appointment (same guest token as the manage link) — omit to leave the "Add to calendar" link out. */
|
||||
calendarPath?: string;
|
||||
/** Relative path of this recipient's notification settings — omit for guests, who have no account to manage. */
|
||||
preferencesPath?: string;
|
||||
}
|
||||
|
||||
function renderAppointmentEmail(
|
||||
data: AppointmentEmailData,
|
||||
copy: { subject: string; headline: string; intro: string; extraNote?: string; actionLabel?: string },
|
||||
copy: {
|
||||
subject: string;
|
||||
headline: string;
|
||||
intro: string;
|
||||
extraNote?: string;
|
||||
actionLabel?: string;
|
||||
/** Whether the appointment is still going ahead — a cancelled one gets no calendar link. */
|
||||
live?: boolean;
|
||||
},
|
||||
): RenderedEmail {
|
||||
const actionUrl = absoluteUrl(data.manageAppointmentPath);
|
||||
const actionLabel = copy.actionLabel ?? "View Appointment";
|
||||
|
||||
const detailsHtml = [
|
||||
detailRow("Service", data.serviceNames.join(", ")),
|
||||
detailRow("Barber", data.staffName),
|
||||
detailRow("Date", data.dateLabel),
|
||||
detailRow("Time", data.timeLabel),
|
||||
detailRow("Duration", data.durationLabel),
|
||||
detailRow("Total", data.totalLabel),
|
||||
detailRow("Reference", data.appointmentRef),
|
||||
].join("");
|
||||
const details: EmailDetail[] = [
|
||||
{ label: "Service", value: data.serviceNames.join(", ") },
|
||||
{ label: "Barber", value: data.staffName },
|
||||
{ label: "Date", value: data.dateLabel },
|
||||
{ label: "Time", value: data.timeLabel },
|
||||
{ label: "Duration", value: data.durationLabel },
|
||||
{ label: "Total", value: data.totalLabel },
|
||||
{ label: "Reference", value: data.appointmentRef },
|
||||
];
|
||||
|
||||
const secondaryLinks: EmailLink[] = [];
|
||||
if (data.calendarPath && copy.live !== false)
|
||||
secondaryLinks.push({ label: "Add to calendar", url: absoluteUrl(data.calendarPath) });
|
||||
if (data.business.address && copy.live !== false)
|
||||
secondaryLinks.push({ label: "View on map", url: mapUrl(data.business.address) });
|
||||
|
||||
const footerLinks: EmailLink[] = data.preferencesPath
|
||||
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
|
||||
: [];
|
||||
|
||||
const preheader = `${data.dateLabel} at ${data.timeLabel} with ${data.staffName} · ${data.serviceNames.join(", ")}`;
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.recipientName},`),
|
||||
paragraph(copy.intro),
|
||||
detailsHtml,
|
||||
copy.extraNote ? paragraph(copy.extraNote) : "",
|
||||
].join("");
|
||||
|
||||
@@ -45,9 +68,14 @@ function renderAppointmentEmail(
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: copy.headline,
|
||||
bodyHtml,
|
||||
details,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
secondaryLinks,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
footerLinks,
|
||||
});
|
||||
|
||||
const text = renderPlainTextLayout({
|
||||
@@ -57,19 +85,14 @@ function renderAppointmentEmail(
|
||||
`Hi ${data.recipientName},`,
|
||||
"",
|
||||
copy.intro,
|
||||
"",
|
||||
`Service: ${data.serviceNames.join(", ")}`,
|
||||
`Barber: ${data.staffName}`,
|
||||
`Date: ${data.dateLabel}`,
|
||||
`Time: ${data.timeLabel}`,
|
||||
`Duration: ${data.durationLabel}`,
|
||||
`Total: ${data.totalLabel}`,
|
||||
`Reference: ${data.appointmentRef}`,
|
||||
...(copy.extraNote ? ["", copy.extraNote] : []),
|
||||
],
|
||||
details,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
secondaryLinks,
|
||||
footerNote: footerNoteText(data.business),
|
||||
footerLinks,
|
||||
});
|
||||
|
||||
return { subject: copy.subject, html, text };
|
||||
@@ -113,6 +136,7 @@ export function renderBookingCancelledEmail(
|
||||
? `Reason: ${options.reason}`
|
||||
: undefined,
|
||||
actionLabel: "Book Another Appointment",
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface GenericNotificationEmailData {
|
||||
/** The Notification's own canonical bodyText (see Notification.bodyText in schema.prisma) — this template is the fallback for any type without a bespoke renderer, so it has no more structured data than that. */
|
||||
bodyText: string;
|
||||
deepLinkPath?: string;
|
||||
/** Button text — defaults to "View Details"; render-email.ts picks a fitting one per notification type. */
|
||||
actionLabel?: string;
|
||||
/** Relative path of the recipient's notification settings, for the footer link. */
|
||||
preferencesPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +27,10 @@ export interface GenericNotificationEmailData {
|
||||
export function renderGenericNotificationEmail(data: GenericNotificationEmailData): RenderedEmail {
|
||||
const actionUrl = data.deepLinkPath ? absoluteUrl(data.deepLinkPath) : undefined;
|
||||
const bodyHtml = [paragraph(`Hi ${data.recipientName},`), paragraph(data.bodyText)].join("");
|
||||
const actionLabel = actionUrl ? (data.actionLabel ?? "View Details") : undefined;
|
||||
const footerLinks = data.preferencesPath
|
||||
? [{ label: "Manage notification preferences", url: absoluteUrl(data.preferencesPath) }]
|
||||
: [];
|
||||
|
||||
return {
|
||||
subject: data.subject,
|
||||
@@ -31,17 +39,21 @@ export function renderGenericNotificationEmail(data: GenericNotificationEmailDat
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: data.subject,
|
||||
bodyHtml,
|
||||
actionLabel: actionUrl ? "View Details" : undefined,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader: data.bodyText,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
footerLinks,
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
businessName: data.business.name,
|
||||
headline: data.subject,
|
||||
bodyLines: [`Hi ${data.recipientName},`, "", data.bodyText],
|
||||
actionLabel: actionUrl ? "View Details" : undefined,
|
||||
actionLabel,
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
footerLinks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,17 @@ export function escapeHtml(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export interface EmailDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EmailLink {
|
||||
label: string;
|
||||
/** Absolute URL. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EmailLayoutInput {
|
||||
businessName: string;
|
||||
/** Absolute URL — email clients can't resolve a relative path the way a browser can. Omitted entirely (not a broken `<img>`) if not supplied. */
|
||||
@@ -24,10 +35,83 @@ export interface EmailLayoutInput {
|
||||
headline: string;
|
||||
/** Pre-escaped/pre-built HTML fragment for the main content — see the per-type renderers, which build this from escaped pieces. */
|
||||
bodyHtml: string;
|
||||
/** Label/value rows shown in a shaded card under the body (date, time, barber…). Plain text — escaped here. */
|
||||
details?: EmailDetail[];
|
||||
actionLabel?: string;
|
||||
/** Absolute URL — see NEXT_PUBLIC_APP_URL / renderAbsoluteLink in index.ts. */
|
||||
actionUrl?: string;
|
||||
/** Lower-emphasis text links under the button ("Add to calendar", "View on map"). */
|
||||
secondaryLinks?: EmailLink[];
|
||||
/** The business's brand colour (#rrggbb) for the button; anything else falls back to near-black. */
|
||||
accentColor?: string;
|
||||
/** The grey line inbox lists show after the subject. Plain text — escaped here. */
|
||||
preheader?: string;
|
||||
footerNote?: string;
|
||||
/** Small links in the footer, e.g. "Manage notification preferences". */
|
||||
footerLinks?: EmailLink[];
|
||||
}
|
||||
|
||||
const DEFAULT_ACCENT = "#171717";
|
||||
|
||||
/** `#rrggbb` only — the value ends up in an inline style, so nothing else is allowed through. */
|
||||
export function normalizeAccentColor(input: string | undefined | null): string {
|
||||
return input && /^#[0-9a-fA-F]{6}$/.test(input.trim())
|
||||
? input.trim().toLowerCase()
|
||||
: DEFAULT_ACCENT;
|
||||
}
|
||||
|
||||
/** Black or white text, whichever reads better on `hex` (WCAG relative luminance). */
|
||||
export function readableTextOn(hex: string): "#ffffff" | "#111111" {
|
||||
const channel = (offset: number) => {
|
||||
const value = parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||
return luminance > 0.4 ? "#111111" : "#ffffff";
|
||||
}
|
||||
|
||||
/** Dark mode on top of inline light styles: clients that honour prefers-color-scheme swap the palette, the rest just show the light one. The accent button is left alone so it keeps the brand colour in both. */
|
||||
const STYLE_BLOCK = `<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nc-bg { background:#111111 !important; }
|
||||
.nc-card { background:#1c1c1e !important; }
|
||||
.nc-heading { color:#ffffff !important; }
|
||||
.nc-text { color:#e5e5e5 !important; }
|
||||
.nc-muted { color:#a3a3a3 !important; }
|
||||
.nc-details { background:#2a2a2d !important; }
|
||||
.nc-rule { border-color:#3a3a3c !important; }
|
||||
.nc-link { color:#e5e5e5 !important; }
|
||||
.nc-btn { border-color:#8a8a8f !important; }
|
||||
}
|
||||
@media only screen and (max-width: 520px) {
|
||||
.nc-card { padding:24px 20px !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
function detailsCard(details: EmailDetail[] | undefined): string {
|
||||
if (!details || details.length === 0) return "";
|
||||
const rows = details
|
||||
.map(
|
||||
(d) => `<tr>
|
||||
<td class="nc-muted" style="padding:6px 16px 6px 0;font-size:12px;color:#737373;vertical-align:top;white-space:nowrap;">${escapeHtml(d.label)}</td>
|
||||
<td class="nc-heading" style="padding:6px 0;font-size:14px;font-weight:600;color:#171717;">${escapeHtml(d.value)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<tr><td style="padding:8px 0 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-details" style="background:#f5f5f4;border-radius:12px;">
|
||||
<tr><td style="padding:12px 18px;"><table role="presentation" cellpadding="0" cellspacing="0" width="100%">${rows}</table></td></tr>
|
||||
</table>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function linkList(links: EmailLink[] | undefined, separator: string): string {
|
||||
return (links ?? [])
|
||||
.map(
|
||||
(l) =>
|
||||
`<a class="nc-link" href="${escapeHtml(l.url)}" style="color:#404040;text-decoration:underline;">${escapeHtml(l.label)}</a>`,
|
||||
)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,32 +122,59 @@ export interface EmailLayoutInput {
|
||||
* blocked, which is the default in most mail clients).
|
||||
*/
|
||||
export function renderEmailLayout(input: EmailLayoutInput): string {
|
||||
const accent = normalizeAccentColor(input.accentColor);
|
||||
const logo = input.businessLogoUrl
|
||||
? `<img src="${escapeHtml(input.businessLogoUrl)}" alt="${escapeHtml(input.businessName)}" height="32" style="height:32px;vertical-align:middle;margin-right:8px;" />`
|
||||
: "";
|
||||
|
||||
const preheader = input.preheader
|
||||
? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f5f5f4;">${escapeHtml(input.preheader)}${" ‌".repeat(40)}</div>`
|
||||
: "";
|
||||
|
||||
const action =
|
||||
input.actionLabel && input.actionUrl
|
||||
? `<tr><td style="padding:24px 0 0;">
|
||||
<a href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:#171717;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
|
||||
? `<tr><td style="padding:24px 0 ${input.secondaryLinks && input.secondaryLinks.length > 0 ? 0 : 24}px;">
|
||||
<a class="nc-btn" href="${escapeHtml(input.actionUrl)}" style="display:inline-block;background:${accent};border:1px solid ${accent};color:${readableTextOn(accent)};text-decoration:none;padding:13px 26px;border-radius:9999px;font-weight:bold;font-size:14px;">${escapeHtml(input.actionLabel)}</a>
|
||||
</td></tr>`
|
||||
: "";
|
||||
|
||||
const footer = input.footerNote
|
||||
? `<tr><td style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${input.footerNote}</td></tr>`
|
||||
const secondary =
|
||||
input.secondaryLinks && input.secondaryLinks.length > 0
|
||||
? `<tr><td class="nc-muted" style="padding:14px 0 24px;font-size:13px;color:#737373;">${linkList(input.secondaryLinks, " · ")}</td></tr>`
|
||||
: "";
|
||||
|
||||
const footerParts = [
|
||||
input.footerNote ? `<div>${input.footerNote}</div>` : "",
|
||||
input.footerLinks && input.footerLinks.length > 0
|
||||
? `<div style="margin-top:8px;">${linkList(input.footerLinks, " · ")}</div>`
|
||||
: "",
|
||||
].join("");
|
||||
const footer = footerParts
|
||||
? `<tr><td class="nc-muted nc-rule" style="padding-top:24px;border-top:1px solid #e5e5e5;color:#737373;font-size:12px;line-height:1.5;">${footerParts}</td></tr>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:24px 0;">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title>${escapeHtml(input.headline)}</title>
|
||||
${STYLE_BLOCK}
|
||||
</head>
|
||||
<body class="nc-bg" style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
${preheader}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="nc-bg" style="background:#f5f5f4;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;padding:32px;max-width:480px;width:100%;">
|
||||
<tr><td style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
|
||||
<tr><td style="font-size:20px;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
|
||||
<tr><td style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
|
||||
<td align="center" style="padding:0 12px;">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0" class="nc-card" style="background:#ffffff;border-radius:16px;padding:32px;max-width:520px;width:100%;">
|
||||
<tr><td class="nc-heading" style="padding-bottom:16px;font-size:16px;font-weight:bold;color:#171717;">${logo}${escapeHtml(input.businessName)}</td></tr>
|
||||
<tr><td class="nc-heading" style="font-size:22px;line-height:1.3;font-weight:bold;color:#171717;padding-bottom:12px;">${escapeHtml(input.headline)}</td></tr>
|
||||
<tr><td class="nc-text" style="font-size:14px;line-height:1.6;color:#404040;">${input.bodyHtml}</td></tr>
|
||||
${detailsCard(input.details)}
|
||||
${action}
|
||||
${secondary}
|
||||
${footer}
|
||||
</table>
|
||||
</td>
|
||||
@@ -77,19 +188,27 @@ export interface PlainTextLayoutInput {
|
||||
businessName: string;
|
||||
headline: string;
|
||||
bodyLines: string[];
|
||||
details?: EmailDetail[];
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
secondaryLinks?: EmailLink[];
|
||||
footerNote?: string;
|
||||
footerLinks?: EmailLink[];
|
||||
}
|
||||
|
||||
export function renderPlainTextLayout(input: PlainTextLayoutInput): string {
|
||||
const lines = [input.businessName, "", input.headline, "", ...input.bodyLines];
|
||||
if (input.details && input.details.length > 0) {
|
||||
lines.push("", ...input.details.map((d) => `${d.label}: ${d.value}`));
|
||||
}
|
||||
if (input.actionLabel && input.actionUrl) {
|
||||
lines.push("", `${input.actionLabel}: ${input.actionUrl}`);
|
||||
}
|
||||
for (const link of input.secondaryLinks ?? []) lines.push(`${link.label}: ${link.url}`);
|
||||
if (input.footerNote) {
|
||||
lines.push("", input.footerNote);
|
||||
}
|
||||
for (const link of input.footerLinks ?? []) lines.push(`${link.label}: ${link.url}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -98,7 +217,7 @@ export function paragraph(text: string): string {
|
||||
return `<p style="margin:0 0 12px;">${escapeHtml(text)}</p>`;
|
||||
}
|
||||
|
||||
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. */
|
||||
/** A label/value row — "Date: Mon 3 Aug", "Barber: Marcus Reed", etc. Prefer the `details` card on renderEmailLayout; kept for templates that interleave rows with prose. */
|
||||
export function detailRow(label: string, value: string): string {
|
||||
return `<p style="margin:0 0 4px;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { detailRow, paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import type { EmailDetail } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
|
||||
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
|
||||
|
||||
@@ -18,10 +19,12 @@ export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): Re
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.recipientName},`),
|
||||
paragraph(`You've earned a reward: ${data.rewardLabel}!`),
|
||||
detailRow("Reward", data.rewardDescription),
|
||||
data.expiresLabel ? detailRow("Expires", data.expiresLabel) : "",
|
||||
paragraph("Show this to your barber on your next visit to redeem it."),
|
||||
].join("");
|
||||
const details: EmailDetail[] = [
|
||||
{ label: "Reward", value: data.rewardDescription },
|
||||
...(data.expiresLabel ? [{ label: "Expires", value: data.expiresLabel }] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
subject: `You've earned a reward — ${data.business.name}`,
|
||||
@@ -30,8 +33,11 @@ export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): Re
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: "You've earned a reward!",
|
||||
bodyHtml,
|
||||
details,
|
||||
actionLabel: "View Loyalty Card",
|
||||
actionUrl,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader: `${data.rewardLabel} — show this on your next visit`,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
@@ -41,11 +47,10 @@ export function renderLoyaltyRewardEarnedEmail(data: LoyaltyRewardEmailData): Re
|
||||
`Hi ${data.recipientName},`,
|
||||
"",
|
||||
`You've earned a reward: ${data.rewardLabel}!`,
|
||||
`Reward: ${data.rewardDescription}`,
|
||||
...(data.expiresLabel ? [`Expires: ${data.expiresLabel}`] : []),
|
||||
"",
|
||||
"Show this to your barber on your next visit to redeem it.",
|
||||
],
|
||||
details,
|
||||
actionLabel: "View Loyalty Card",
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
@@ -58,9 +63,9 @@ export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData):
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.recipientName},`),
|
||||
paragraph(`Your reward has been applied: ${data.rewardLabel}.`),
|
||||
detailRow("Reward", data.rewardDescription),
|
||||
paragraph("Thanks for being a loyal customer!"),
|
||||
].join("");
|
||||
const details: EmailDetail[] = [{ label: "Reward", value: data.rewardDescription }];
|
||||
|
||||
return {
|
||||
subject: `Reward applied — ${data.business.name}`,
|
||||
@@ -69,8 +74,11 @@ export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData):
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: "Your reward has been applied",
|
||||
bodyHtml,
|
||||
details,
|
||||
actionLabel: "View Loyalty Card",
|
||||
actionUrl,
|
||||
accentColor: data.business.themeColor,
|
||||
preheader: `${data.rewardLabel} has been applied`,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
@@ -80,10 +88,10 @@ export function renderLoyaltyRewardRedeemedEmail(data: LoyaltyRewardEmailData):
|
||||
`Hi ${data.recipientName},`,
|
||||
"",
|
||||
`Your reward has been applied: ${data.rewardLabel}.`,
|
||||
`Reward: ${data.rewardDescription}`,
|
||||
"",
|
||||
"Thanks for being a loyal customer!",
|
||||
],
|
||||
details,
|
||||
actionLabel: "View Loyalty Card",
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
|
||||
@@ -23,7 +23,7 @@ export function renderOtpEmail(data: OtpEmailData): RenderedEmail {
|
||||
].join("");
|
||||
|
||||
return {
|
||||
subject: "Your NextChair verification code",
|
||||
subject: "Your verification code",
|
||||
html: renderEmailLayout({
|
||||
businessName: data.senderName,
|
||||
headline: "Your verification code",
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface BusinessEmailInfo {
|
||||
address?: string;
|
||||
phone?: string;
|
||||
cancellationPolicy?: string;
|
||||
/** The business's brand colour (#rrggbb) — becomes the email button colour. */
|
||||
themeColor?: string;
|
||||
}
|
||||
|
||||
/** Absolute URL — email clients can't resolve a relative path, unlike a browser looking at the current origin. */
|
||||
@@ -14,12 +16,19 @@ export function absoluteUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
/** A Google Maps search for the business address — opens the maps app on a phone. */
|
||||
export function mapUrl(address: string): string {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||
}
|
||||
|
||||
export function footerNoteHtml(business: BusinessEmailInfo): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (business.address) parts.push(business.address);
|
||||
if (business.phone) parts.push(business.phone);
|
||||
if (business.cancellationPolicy) parts.push(business.cancellationPolicy);
|
||||
return parts.length > 0 ? parts.map((p) => `<span>${escapeHtml(p)}</span>`).join("<br/>") : undefined;
|
||||
return parts.length > 0
|
||||
? parts.map((p) => `<span>${escapeHtml(p)}</span>`).join("<br/>")
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function footerNoteText(business: BusinessEmailInfo): string | undefined {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { paragraph, renderEmailLayout, renderPlainTextLayout } from "./layout";
|
||||
import { absoluteUrl, footerNoteHtml, footerNoteText } from "./shared";
|
||||
import type { BusinessEmailInfo, RenderedEmail } from "./shared";
|
||||
|
||||
export interface StaffInvitationEmailData {
|
||||
business: BusinessEmailInfo;
|
||||
staffName: string;
|
||||
roleName: string;
|
||||
invitedByName: string;
|
||||
/** Relative path — /invite/accept?token=... */
|
||||
acceptInvitePath: string;
|
||||
}
|
||||
|
||||
export function renderStaffInvitationEmail(data: StaffInvitationEmailData): RenderedEmail {
|
||||
const actionUrl = absoluteUrl(data.acceptInvitePath);
|
||||
const article = data.roleName === "Administrator" ? "an" : "a";
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.staffName},`),
|
||||
paragraph(
|
||||
`${data.invitedByName} has added you as ${article} ${data.roleName} at ${data.business.name}. Verify your email to get started — no password needed.`,
|
||||
),
|
||||
].join("");
|
||||
|
||||
return {
|
||||
subject: `You've been invited to join ${data.business.name}`,
|
||||
html: renderEmailLayout({
|
||||
businessName: data.business.name,
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: "You've been invited",
|
||||
bodyHtml,
|
||||
actionLabel: "Verify Your Email",
|
||||
actionUrl,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
businessName: data.business.name,
|
||||
headline: "You've been invited",
|
||||
bodyLines: [
|
||||
`Hi ${data.staffName},`,
|
||||
"",
|
||||
`${data.invitedByName} has added you as ${article} ${data.roleName} at ${data.business.name}. Verify your email to get started — no password needed.`,
|
||||
],
|
||||
actionLabel: "Verify Your Email",
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface StaffScheduleChangedEmailData {
|
||||
business: BusinessEmailInfo;
|
||||
staffName: string;
|
||||
changedByName: string;
|
||||
/** e.g. "your working hours", "your breaks", "your time off" */
|
||||
whatChanged: string;
|
||||
/** Relative path — e.g. /staff/settings/working-hours */
|
||||
viewSchedulePath: string;
|
||||
}
|
||||
|
||||
export function renderStaffScheduleChangedEmail(data: StaffScheduleChangedEmailData): RenderedEmail {
|
||||
const actionUrl = absoluteUrl(data.viewSchedulePath);
|
||||
const bodyHtml = [
|
||||
paragraph(`Hi ${data.staffName},`),
|
||||
paragraph(`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`),
|
||||
].join("");
|
||||
|
||||
return {
|
||||
subject: `Your schedule was updated — ${data.business.name}`,
|
||||
html: renderEmailLayout({
|
||||
businessName: data.business.name,
|
||||
businessLogoUrl: data.business.logoUrl,
|
||||
headline: "Your schedule was updated",
|
||||
bodyHtml,
|
||||
actionLabel: "View Schedule",
|
||||
actionUrl,
|
||||
footerNote: footerNoteHtml(data.business),
|
||||
}),
|
||||
text: renderPlainTextLayout({
|
||||
businessName: data.business.name,
|
||||
headline: "Your schedule was updated",
|
||||
bodyLines: [
|
||||
`Hi ${data.staffName},`,
|
||||
"",
|
||||
`${data.changedByName} has updated ${data.whatChanged} at ${data.business.name}. Take a look to see what changed.`,
|
||||
],
|
||||
actionLabel: "View Schedule",
|
||||
actionUrl,
|
||||
footerNote: footerNoteText(data.business),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const RECIPIENT_KIND: Record<NotificationType, NotificationRecipientKind> = {
|
||||
STAFF_APPOINTMENT_REMINDER: "STAFF",
|
||||
STAFF_SCHEDULE_CHANGED: "STAFF",
|
||||
STAFF_INVITATION_RECEIVED: "STAFF",
|
||||
STAFF_WHATSAPP_REMINDERS_DUE: "STAFF",
|
||||
OWNER_NEW_BOOKING: "OWNER",
|
||||
OWNER_BOOKING_CANCELLED: "OWNER",
|
||||
OWNER_NO_SHOW: "OWNER",
|
||||
@@ -72,6 +73,7 @@ const PREFERENCE_CONTROLLED_TYPES = new Set<NotificationType>([
|
||||
NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
NotificationType.STAFF_SCHEDULE_CHANGED,
|
||||
NotificationType.STAFF_INVITATION_RECEIVED,
|
||||
NotificationType.STAFF_WHATSAPP_REMINDERS_DUE,
|
||||
// Owner-facing
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_BOOKING_CANCELLED,
|
||||
@@ -85,34 +87,32 @@ export function isPreferenceControlled(type: NotificationType): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which preference-controlled types default to OFF when no
|
||||
* NotificationPreference row exists yet — every other preference-
|
||||
* controlled type keeps defaulting to on (today's original blanket
|
||||
* behaviour). A stored row always wins regardless of what's here (see
|
||||
* Which preference-controlled types default to OFF on EVERY channel when no
|
||||
* NotificationPreference row exists yet. Only the two owner-facing events
|
||||
* that would otherwise be noise for a business with several barbers (every
|
||||
* booking, every no-show) — an owner who wants them turns them on. A stored
|
||||
* row always wins regardless of what's here (see
|
||||
* NotificationService.isChannelEnabled's `preference?.isEnabled ?? ...`),
|
||||
* so changing this set never touches anyone's explicit choice — only
|
||||
* what a brand-new user/business sees before they've ever visited
|
||||
* Settings. The owner of a business is commonly also its only barber, so
|
||||
* without softer defaults here a solo owner's very first booking would
|
||||
* arrive as two emails (STAFF_NEW_APPOINTMENT + OWNER_NEW_BOOKING) they
|
||||
* never asked for — see also the dedup logic in notification.service.ts,
|
||||
* which independently prevents a *duplicate* even when both default on.
|
||||
* so changing this set never touches anyone's explicit choice.
|
||||
*/
|
||||
const DEFAULT_DISABLED_TYPES = new Set<NotificationType>([
|
||||
NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
NotificationType.OWNER_NEW_BOOKING,
|
||||
NotificationType.OWNER_NO_SHOW,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Types that are on by default overall but have specific channels that
|
||||
* default to OFF — e.g. a barber's 2-hour heads-up is worth a push/in-app
|
||||
* nudge out of the box, but a second email for something that's about to
|
||||
* happen anyway is opt-in. Same "stored row always wins" rule as above.
|
||||
* default to OFF. The rule for staff and owners: email is for events that
|
||||
* need action or a record (a customer cancelling, a schedule change, an
|
||||
* invitation); routine "FYI" events — a new booking, a reschedule, an
|
||||
* upcoming appointment, reminders waiting to be sent — arrive as push
|
||||
* only. Same "stored row always wins" rule as above.
|
||||
*/
|
||||
const DEFAULT_DISABLED_CHANNELS: Partial<Record<NotificationType, ReadonlySet<NotificationChannel>>> = {
|
||||
[NotificationType.STAFF_NEW_APPOINTMENT]: new Set([NotificationChannel.EMAIL]),
|
||||
[NotificationType.STAFF_APPOINTMENT_RESCHEDULED]: new Set([NotificationChannel.EMAIL]),
|
||||
[NotificationType.STAFF_APPOINTMENT_REMINDER]: new Set([NotificationChannel.EMAIL]),
|
||||
[NotificationType.STAFF_WHATSAPP_REMINDERS_DUE]: new Set([NotificationChannel.EMAIL]),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -222,8 +222,6 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
||||
rewardType: loyaltyProgramme.rewardType,
|
||||
rewardValue: loyaltyProgramme.rewardValue?.toString(),
|
||||
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
|
||||
autoApplyReward: loyaltyProgramme.autoApplyReward,
|
||||
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
|
||||
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
||||
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
||||
cardFont: loyaltyProgramme.cardFont,
|
||||
|
||||
@@ -6,7 +6,15 @@ import { setNotificationPreference } from "@/services/notifications/notification
|
||||
import { getAppointmentById } from "../appointment.service";
|
||||
import { notifyBookingBarberChanged, notifyBookingCompleted } from "../booking-notifications";
|
||||
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
|
||||
import { cleanupTestBusiness, createTestAppointment, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestGuestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
@@ -43,7 +51,9 @@ describe("booking notifications (integration)", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
if (guestProfileIds.length > 0) {
|
||||
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
|
||||
await prisma.customerProfile
|
||||
.deleteMany({ where: { id: { in: guestProfileIds } } })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,8 +125,18 @@ describe("booking notifications (integration)", () => {
|
||||
// STAFF_NEW_APPOINTMENT/OWNER_NEW_BOOKING both default to off (see
|
||||
// types.ts) — enable both explicitly so this test isolates "different
|
||||
// people don't dedup against each other" from the unrelated default.
|
||||
await setNotificationPreference(prisma, { userId: staffUserId, type: NotificationType.STAFF_NEW_APPOINTMENT, channel: NotificationChannel.IN_APP, isEnabled: true });
|
||||
await setNotificationPreference(prisma, { userId: otherOwner.userId, type: NotificationType.OWNER_NEW_BOOKING, channel: NotificationChannel.IN_APP, isEnabled: true });
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: staffUserId,
|
||||
type: NotificationType.STAFF_NEW_APPOINTMENT,
|
||||
channel: NotificationChannel.IN_APP,
|
||||
isEnabled: true,
|
||||
});
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: otherOwner.userId,
|
||||
type: NotificationType.OWNER_NEW_BOOKING,
|
||||
channel: NotificationChannel.IN_APP,
|
||||
isEnabled: true,
|
||||
});
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
@@ -168,7 +188,10 @@ describe("booking notifications (integration)", () => {
|
||||
});
|
||||
|
||||
it("requireApproval creates a BOOKING_PENDING_APPROVAL notification instead of BOOKING_CONFIRMED", async () => {
|
||||
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
|
||||
await prisma.bookingRule.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { requireApproval: true },
|
||||
});
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
@@ -219,7 +242,11 @@ describe("booking notifications (integration)", () => {
|
||||
expect(rescheduledNotifications).toHaveLength(2);
|
||||
|
||||
const staffNotifications = await prisma.notification.findMany({
|
||||
where: { appointmentId: created.appointment.id, type: "STAFF_APPOINTMENT_RESCHEDULED", recipientUserId: staffUserId },
|
||||
where: {
|
||||
appointmentId: created.appointment.id,
|
||||
type: "STAFF_APPOINTMENT_RESCHEDULED",
|
||||
recipientUserId: staffUserId,
|
||||
},
|
||||
});
|
||||
expect(staffNotifications).toHaveLength(2);
|
||||
});
|
||||
@@ -242,7 +269,12 @@ describe("booking notifications (integration)", () => {
|
||||
expect(cancelled.success).toBe(true);
|
||||
|
||||
const cancelNotifications = await prisma.notification.findMany({
|
||||
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
|
||||
where: {
|
||||
appointmentId: created.appointment.id,
|
||||
type: {
|
||||
in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"],
|
||||
},
|
||||
},
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(cancelNotifications).toHaveLength(3);
|
||||
@@ -250,7 +282,9 @@ describe("booking notifications (integration)", () => {
|
||||
// Same staff member holds both roles — OWNER_BOOKING_CANCELLED is
|
||||
// deduped against STAFF_APPOINTMENT_CANCELLED (zero deliveries), same
|
||||
// as the new-booking case above.
|
||||
const ownerCancelNotification = cancelNotifications.find((n) => n.type === "OWNER_BOOKING_CANCELLED");
|
||||
const ownerCancelNotification = cancelNotifications.find(
|
||||
(n) => n.type === "OWNER_BOOKING_CANCELLED",
|
||||
);
|
||||
expect(ownerCancelNotification?.deliveries).toHaveLength(0);
|
||||
|
||||
// Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
|
||||
@@ -283,7 +317,11 @@ describe("booking notifications (integration)", () => {
|
||||
it("emails the customer when they originally picked this staff member specifically", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: true });
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
staffSelectedByCustomer: true,
|
||||
});
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
|
||||
await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: true });
|
||||
@@ -298,7 +336,11 @@ describe("booking notifications (integration)", () => {
|
||||
it("does not email the customer when they originally booked 'any available barber'", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: false });
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
staffSelectedByCustomer: false,
|
||||
});
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
|
||||
await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: false });
|
||||
@@ -313,4 +355,81 @@ describe("booking notifications (integration)", () => {
|
||||
expect(channels).toContain("IN_APP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("never notifying someone about their own action", () => {
|
||||
async function bookWithStaff() {
|
||||
const { staffId, userId: staffUserId } = await createTestStaff(business);
|
||||
const { customerId, userId: customerUserId } = await createTestCustomer(business);
|
||||
await grantOwnerPermission(staffUserId);
|
||||
const created = await createBooking(prisma, {
|
||||
businessId: business.businessId,
|
||||
customerId,
|
||||
staffId,
|
||||
serviceIds: [business.haircutServiceId],
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
if (!created.success) throw new Error("setup failed");
|
||||
return { appointmentId: created.appointment.id, staffUserId, customerUserId };
|
||||
}
|
||||
|
||||
const count = (appointmentId: string, type: string) =>
|
||||
prisma.notification.count({ where: { appointmentId, type: type as never } });
|
||||
|
||||
it("a barber who cancels an appointment isn't told, but the customer still is", async () => {
|
||||
const { appointmentId, staffUserId } = await bookWithStaff();
|
||||
|
||||
await cancelBooking(prisma, { appointmentId, cancelledByUserId: staffUserId });
|
||||
|
||||
expect(await count(appointmentId, "BOOKING_CANCELLED")).toBe(1);
|
||||
expect(await count(appointmentId, "STAFF_APPOINTMENT_CANCELLED")).toBe(0);
|
||||
expect(await count(appointmentId, "OWNER_BOOKING_CANCELLED")).toBe(0);
|
||||
});
|
||||
|
||||
it("a customer who cancels their own booking still notifies the barber", async () => {
|
||||
const { appointmentId, customerUserId } = await bookWithStaff();
|
||||
|
||||
await cancelBooking(prisma, { appointmentId, cancelledByUserId: customerUserId });
|
||||
|
||||
expect(await count(appointmentId, "BOOKING_CANCELLED")).toBe(1);
|
||||
expect(await count(appointmentId, "STAFF_APPOINTMENT_CANCELLED")).toBe(1);
|
||||
});
|
||||
|
||||
it("a barber who reschedules isn't told, but the customer still is", async () => {
|
||||
const { appointmentId, staffUserId } = await bookWithStaff();
|
||||
|
||||
await rescheduleBooking(prisma, {
|
||||
appointmentId,
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
actorUserId: staffUserId,
|
||||
});
|
||||
|
||||
expect(await count(appointmentId, "BOOKING_RESCHEDULED")).toBe(1);
|
||||
expect(await count(appointmentId, "STAFF_APPOINTMENT_RESCHEDULED")).toBe(0);
|
||||
});
|
||||
|
||||
it("a customer who reschedules still notifies the barber", async () => {
|
||||
const { appointmentId, customerUserId } = await bookWithStaff();
|
||||
|
||||
await rescheduleBooking(prisma, {
|
||||
appointmentId,
|
||||
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
|
||||
actorUserId: customerUserId,
|
||||
});
|
||||
|
||||
expect(await count(appointmentId, "STAFF_APPOINTMENT_RESCHEDULED")).toBe(1);
|
||||
});
|
||||
|
||||
it("a reassignment that keeps the same time sends no 'rescheduled' notice (the barber-changed one covers it)", async () => {
|
||||
const { appointmentId } = await bookWithStaff();
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId,
|
||||
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(await count(appointmentId, "BOOKING_RESCHEDULED")).toBe(0);
|
||||
expect(await count(appointmentId, "STAFF_APPOINTMENT_RESCHEDULED")).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,3 +69,25 @@ describe("validateBookingRuleInput", () => {
|
||||
expect(validateBookingRuleInput({ ...base, maxAppointmentsPerStaffPerDay: 5 }).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBookingRuleInput — no-show numbers", () => {
|
||||
it("accepts both numbers off, either on, or both on", () => {
|
||||
expect(validateBookingRuleInput({ ...base, noShowFlagThreshold: null, noShowBlockThreshold: null }).valid).toBe(true);
|
||||
expect(validateBookingRuleInput({ ...base, noShowFlagThreshold: 2 }).valid).toBe(true);
|
||||
expect(validateBookingRuleInput({ ...base, noShowBlockThreshold: 3 }).valid).toBe(true);
|
||||
expect(validateBookingRuleInput({ ...base, noShowFlagThreshold: 2, noShowBlockThreshold: 2 }).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects zero, negative and fractional numbers", () => {
|
||||
for (const bad of [0, -1, 1.5]) {
|
||||
expect(validateBookingRuleInput({ ...base, noShowFlagThreshold: bad }).valid).toBe(false);
|
||||
expect(validateBookingRuleInput({ ...base, noShowBlockThreshold: bad }).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a block number lower than the flag number", () => {
|
||||
const result = validateBookingRuleInput({ ...base, noShowFlagThreshold: 4, noShowBlockThreshold: 2 });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]?.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
} from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
import { createBooking, rescheduleBooking } from "../booking.service";
|
||||
import { listReminderCandidates, setWhatsAppReminderSent } from "../whatsapp-reminders.service";
|
||||
import {
|
||||
listDueReminderCandidates,
|
||||
listReminderCandidates,
|
||||
nudgeStaffWhatsAppReminders,
|
||||
setWhatsAppReminderSent,
|
||||
} from "../whatsapp-reminders.service";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
@@ -131,4 +136,107 @@ describe("WhatsApp reminders (integration)", () => {
|
||||
expect(after.whatsappReminderSentAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("due now", () => {
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
async function appointmentIn(hours: number, overrides: { customerId: string; staffId: string; status?: AppointmentStatus }) {
|
||||
const { appointmentId } = await createTestAppointment(business, {
|
||||
customerId: overrides.customerId,
|
||||
staffId: overrides.staffId,
|
||||
startAt: new Date(Date.now() + hours * HOUR),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: appointmentId }, data: { status: overrides.status ?? "CONFIRMED" } });
|
||||
return appointmentId;
|
||||
}
|
||||
|
||||
it("lists confirmed, unsent bookings starting within the lead time, soonest first", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const soon = await appointmentIn(5, { customerId, staffId });
|
||||
const sooner = await appointmentIn(2, { customerId, staffId });
|
||||
await appointmentIn(30, { customerId, staffId }); // beyond a 24h lead time
|
||||
await appointmentIn(-2, { customerId, staffId }); // already started
|
||||
await appointmentIn(3, { customerId, staffId, status: "PENDING" });
|
||||
await appointmentIn(3, { customerId, staffId, status: "CANCELLED" });
|
||||
const alreadySent = await appointmentIn(4, { customerId, staffId });
|
||||
await setWhatsAppReminderSent(prisma, { appointmentId: alreadySent, sent: true });
|
||||
|
||||
const due = await listDueReminderCandidates(prisma, { businessId: business.businessId, leadHours: 24 });
|
||||
|
||||
expect(due.map((a) => a.id)).toEqual([sooner, soon]);
|
||||
});
|
||||
|
||||
it("respects a longer lead time and a single barber's list", async () => {
|
||||
const first = await createTestStaff(business);
|
||||
const second = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const mine = await appointmentIn(30, { customerId, staffId: first.staffId });
|
||||
await appointmentIn(30, { customerId, staffId: second.staffId });
|
||||
|
||||
expect(await listDueReminderCandidates(prisma, { businessId: business.businessId, leadHours: 24 })).toHaveLength(0);
|
||||
const due = await listDueReminderCandidates(prisma, {
|
||||
businessId: business.businessId,
|
||||
leadHours: 48,
|
||||
staffId: first.staffId,
|
||||
});
|
||||
expect(due.map((a) => a.id)).toEqual([mine]);
|
||||
});
|
||||
|
||||
describe("the nudge to barbers", () => {
|
||||
async function withPhone(customerId: string, phone: string | null) {
|
||||
await prisma.customerProfile.update({ where: { id: customerId }, data: { phone } });
|
||||
}
|
||||
|
||||
it("tells a barber once a day how many reminders are waiting, and not again on later runs", async () => {
|
||||
const { staffId, userId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await withPhone(customerId, "+44 7700 900123");
|
||||
await appointmentIn(3, { customerId, staffId });
|
||||
await appointmentIn(6, { customerId, staffId });
|
||||
|
||||
expect(await nudgeStaffWhatsAppReminders(prisma)).toBe(1);
|
||||
expect(await nudgeStaffWhatsAppReminders(prisma)).toBe(1); // asked again, but the same notification
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { type: "STAFF_WHATSAPP_REMINDERS_DUE", recipientUserId: userId },
|
||||
});
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]!.bodyText).toContain("2 WhatsApp reminders");
|
||||
expect(notifications[0]!.deepLinkPath).toBe("/staff/reminders");
|
||||
});
|
||||
|
||||
it("is push-only: no email by default", async () => {
|
||||
const { staffId, userId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
await withPhone(customerId, "+44 7700 900123");
|
||||
await appointmentIn(3, { customerId, staffId });
|
||||
|
||||
await nudgeStaffWhatsAppReminders(prisma);
|
||||
|
||||
const notification = await prisma.notification.findFirstOrThrow({
|
||||
where: { type: "STAFF_WHATSAPP_REMINDERS_DUE", recipientUserId: userId },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(notification.deliveries.map((d) => d.channel)).not.toContain("EMAIL");
|
||||
});
|
||||
|
||||
it("says nothing when nothing is sendable: no phone number, already sent, or nothing due", async () => {
|
||||
const { staffId, userId } = await createTestStaff(business);
|
||||
const noPhone = await createTestCustomer(business);
|
||||
await withPhone(noPhone.customerId, null);
|
||||
await appointmentIn(3, { customerId: noPhone.customerId, staffId });
|
||||
|
||||
const sent = await createTestCustomer(business);
|
||||
await withPhone(sent.customerId, "+44 7700 900124");
|
||||
const sentAppointment = await appointmentIn(4, { customerId: sent.customerId, staffId });
|
||||
await setWhatsAppReminderSent(prisma, { appointmentId: sentAppointment, sent: true });
|
||||
|
||||
expect(await nudgeStaffWhatsAppReminders(prisma)).toBe(0);
|
||||
expect(
|
||||
await prisma.notification.count({ where: { type: "STAFF_WHATSAPP_REMINDERS_DUE", recipientUserId: userId } }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,11 @@ export interface ListAppointmentsParams {
|
||||
order?: "asc" | "desc";
|
||||
/** Cap the number of rows returned (for paged lists). */
|
||||
take?: number;
|
||||
/** Only appointments starting in [startsFrom, startsBefore) — an exact window in time, unlike fromDate/toDate's whole business days. */
|
||||
startsFrom?: Date;
|
||||
startsBefore?: Date;
|
||||
/** Only appointments whose WhatsApp reminder hasn't been marked sent. */
|
||||
whatsappReminderUnsent?: boolean;
|
||||
}
|
||||
|
||||
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
|
||||
@@ -82,6 +87,10 @@ export async function listAppointments(prisma: PrismaClient, params: ListAppoint
|
||||
...(params.customerId ? { customerId: params.customerId } : {}),
|
||||
...(params.statuses ? { status: { in: params.statuses } } : {}),
|
||||
...dateFilter,
|
||||
...(params.startsFrom || params.startsBefore
|
||||
? { startAt: { ...(params.startsFrom ? { gte: params.startsFrom } : {}), ...(params.startsBefore ? { lt: params.startsBefore } : {}) } }
|
||||
: {}),
|
||||
...(params.whatsappReminderUnsent ? { whatsappReminderSentAt: null } : {}),
|
||||
},
|
||||
include: APPOINTMENT_INCLUDE,
|
||||
orderBy: [{ startAt: params.order ?? "asc" }, { id: "asc" }],
|
||||
|
||||
@@ -84,7 +84,11 @@ export async function notifyBookingCreated(
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyBookingRescheduled(prisma: PrismaClient, appointment: Appointment): Promise<void> {
|
||||
export async function notifyBookingRescheduled(
|
||||
prisma: PrismaClient,
|
||||
appointment: Appointment,
|
||||
options: { actorUserId?: string } = {},
|
||||
): Promise<void> {
|
||||
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
|
||||
const dateLabel = formatDateLabel(appointment.startAt, timezone);
|
||||
const timeLabel = formatTimeLabel(appointment.startAt, timezone);
|
||||
@@ -105,22 +109,25 @@ export async function notifyBookingRescheduled(prisma: PrismaClient, appointment
|
||||
idempotencyKey: `notify:booking-rescheduled:${appointment.id}:${versionSuffix}`,
|
||||
});
|
||||
|
||||
await notifySafely(prisma, {
|
||||
businessId: appointment.businessId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
recipientUserId: appointment.staff.userId,
|
||||
appointmentId: appointment.id,
|
||||
subject: "Appointment rescheduled",
|
||||
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`,
|
||||
deepLinkPath: `/staff/appointments/${appointment.id}`,
|
||||
idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`,
|
||||
});
|
||||
// Never tell someone about a change they just made themselves.
|
||||
if (appointment.staff.userId !== options.actorUserId) {
|
||||
await notifySafely(prisma, {
|
||||
businessId: appointment.businessId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_RESCHEDULED,
|
||||
recipientUserId: appointment.staff.userId,
|
||||
appointmentId: appointment.id,
|
||||
subject: "Appointment rescheduled",
|
||||
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) has moved to ${dateLabel} at ${timeLabel}.`,
|
||||
deepLinkPath: `/staff/appointments/${appointment.id}`,
|
||||
idempotencyKey: `notify:staff-appointment-rescheduled:${appointment.id}:${versionSuffix}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyBookingCancelled(
|
||||
prisma: PrismaClient,
|
||||
appointment: Appointment,
|
||||
options: { wasLateCancellation?: boolean; reason?: string } = {},
|
||||
options: { wasLateCancellation?: boolean; reason?: string; actorUserId?: string } = {},
|
||||
): Promise<void> {
|
||||
const timezone = await getBusinessTimezone(prisma, appointment.businessId);
|
||||
const dateLabel = formatDateLabel(appointment.startAt, timezone);
|
||||
@@ -140,18 +147,22 @@ export async function notifyBookingCancelled(
|
||||
idempotencyKey: `notify:booking-cancelled:${appointment.id}`,
|
||||
});
|
||||
|
||||
await notifySafely(prisma, {
|
||||
businessId: appointment.businessId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
recipientUserId: appointment.staff.userId,
|
||||
appointmentId: appointment.id,
|
||||
subject: "Appointment cancelled",
|
||||
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`,
|
||||
deepLinkPath: `/staff/appointments/${appointment.id}`,
|
||||
idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`,
|
||||
});
|
||||
// Never tell someone about a cancellation they just made themselves (a barber or owner
|
||||
// cancelling from the app). The customer is always told — their email is the receipt.
|
||||
if (appointment.staff.userId !== options.actorUserId) {
|
||||
await notifySafely(prisma, {
|
||||
businessId: appointment.businessId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_CANCELLED,
|
||||
recipientUserId: appointment.staff.userId,
|
||||
appointmentId: appointment.id,
|
||||
subject: "Appointment cancelled",
|
||||
bodyText: `${appointment.customer.firstName} ${appointment.customer.lastName}'s appointment (${services}) on ${dateLabel} at ${timeLabel} was cancelled.`,
|
||||
deepLinkPath: `/staff/appointments/${appointment.id}`,
|
||||
idempotencyKey: `notify:staff-appointment-cancelled:${appointment.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerUserIds = await getOwnerUserIds(prisma, appointment.businessId);
|
||||
const ownerUserIds = (await getOwnerUserIds(prisma, appointment.businessId)).filter((id) => id !== options.actorUserId);
|
||||
for (const ownerUserId of ownerUserIds) {
|
||||
await notifySafely(prisma, {
|
||||
businessId: appointment.businessId,
|
||||
|
||||
@@ -48,6 +48,21 @@ export function validateBookingRuleInput(input: BookingRuleInput): ValidationRes
|
||||
) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Max appointments per staff member per day must be at least 1, or left unlimited." });
|
||||
}
|
||||
for (const [value, label] of [
|
||||
[input.noShowFlagThreshold, "flag"],
|
||||
[input.noShowBlockThreshold, "block"],
|
||||
] as const) {
|
||||
if (value !== null && value !== undefined && (!Number.isInteger(value) || value < 1)) {
|
||||
return fail({ code: "INVALID_INPUT", message: `The no-show ${label} number must be 1 or more, or left empty to turn it off.` });
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.noShowFlagThreshold != null &&
|
||||
input.noShowBlockThreshold != null &&
|
||||
input.noShowBlockThreshold < input.noShowFlagThreshold
|
||||
) {
|
||||
return fail({ code: "INVALID_INPUT", message: "The no-show block number can't be lower than the flag number." });
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { AppointmentStatus } from "@/generated/prisma/enums";
|
||||
import { calendarDateOf, minutesFromMidnight } from "@/lib/time/business-time";
|
||||
import { getNoShowStanding } from "@/services/customer/no-show.service";
|
||||
import { getAppointmentById } from "./appointment.service";
|
||||
import {
|
||||
getAvailableTimeSlots,
|
||||
@@ -84,6 +85,22 @@ export async function createBooking(
|
||||
});
|
||||
if (!baseContext || !baseValidation.valid) return { success: false, validation: baseValidation };
|
||||
|
||||
// Online self-service bookings only: staff and owners always set createdByUserId, and can still
|
||||
// book a blocked customer (by phone, in person) — that is the whole point of the block being
|
||||
// about *online* booking. See no-show.service.ts.
|
||||
if (!input.createdByUserId) {
|
||||
const standing = await getNoShowStanding(prisma, { businessId: input.businessId, customerId: input.customerId });
|
||||
if (standing.blocked) {
|
||||
return {
|
||||
success: false,
|
||||
validation: fail({
|
||||
code: "CUSTOMER_BOOKING_BLOCKED",
|
||||
message: "Online booking is switched off for this customer after repeated no-shows.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let staffId = input.staffId;
|
||||
if (!staffId) {
|
||||
if (baseContext.eligibleStaffIds.length === 0) {
|
||||
@@ -180,6 +197,8 @@ export interface RescheduleBookingInput {
|
||||
/** Omit to keep the current staff member, or supply a different one. */
|
||||
staffId?: string;
|
||||
startAt: Date;
|
||||
/** Who made the change — they aren't notified about their own action. Omit for a guest. */
|
||||
actorUserId?: string;
|
||||
}
|
||||
|
||||
const NON_RESCHEDULABLE_STATUSES: AppointmentStatus[] = [
|
||||
@@ -232,7 +251,12 @@ export async function rescheduleBooking(
|
||||
effectiveBuffer: context.effectiveBuffer,
|
||||
});
|
||||
const appointment = await getAppointmentById(prisma, input.appointmentId);
|
||||
await notifyBookingRescheduled(prisma, appointment!);
|
||||
// A reassignment keeps the same slot: the customer hears about it through the dedicated
|
||||
// "your barber has changed" notice (see ownerRescheduleAppointmentAction), not a
|
||||
// "rescheduled to the same time" one.
|
||||
if (existing.startAt.getTime() !== input.startAt.getTime()) {
|
||||
await notifyBookingRescheduled(prisma, appointment!, { actorUserId: input.actorUserId });
|
||||
}
|
||||
await cancelScheduledReminders(prisma, input.appointmentId);
|
||||
await scheduleAppointmentReminders(prisma, appointment!);
|
||||
return { success: true, appointment: appointment! };
|
||||
@@ -325,6 +349,7 @@ export async function cancelBooking(
|
||||
});
|
||||
|
||||
await notifyBookingCancelled(prisma, appointment, {
|
||||
actorUserId: input.cancelledByUserId,
|
||||
wasLateCancellation,
|
||||
reason: input.reason,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface BookingRuleConfig {
|
||||
customerCanChooseStaff: boolean;
|
||||
allowGuestBooking: boolean;
|
||||
requireApproval: boolean;
|
||||
/** null/undefined = off. See no-show.service.ts. */
|
||||
noShowFlagThreshold?: number | null;
|
||||
noShowBlockThreshold?: number | null;
|
||||
}
|
||||
|
||||
export function checkMinNotice(candidateStart: Date, now: Date, minNoticeMinutes: number): ValidationResult {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const BOOKING_VALIDATION_ERROR_CODES = [
|
||||
"STAFF_CANNOT_PERFORM_SERVICE",
|
||||
"BUSINESS_CLOSED",
|
||||
"BUSINESS_HOLIDAY",
|
||||
"CUSTOMER_BOOKING_BLOCKED",
|
||||
"STAFF_NOT_WORKING",
|
||||
"STAFF_ON_BREAK",
|
||||
"STAFF_ON_LEAVE",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
import { toWhatsAppNumber } from "@/lib/messaging/whatsapp";
|
||||
import { calendarDateOf } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { notifySafely } from "@/services/notifications/notification.service";
|
||||
import { listAppointments } from "./appointment.service";
|
||||
|
||||
/**
|
||||
@@ -40,3 +44,101 @@ export async function setWhatsAppReminderSent(
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── "Due now" ─────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_WHATSAPP_LEAD_HOURS = 24;
|
||||
export const MAX_WHATSAPP_LEAD_HOURS = 168;
|
||||
|
||||
/** How many hours ahead a reminder becomes due — the owner's choice (BusinessSettings), 24 until they set one. */
|
||||
export async function getWhatsAppLeadHours(prisma: PrismaClient, businessId: string): Promise<number> {
|
||||
const settings = await prisma.businessSettings.findUnique({
|
||||
where: { businessId },
|
||||
select: { whatsappReminderLeadHours: true },
|
||||
});
|
||||
return settings?.whatsappReminderLeadHours ?? DEFAULT_WHATSAPP_LEAD_HOURS;
|
||||
}
|
||||
|
||||
export function validateWhatsAppLeadHours(hours: number): boolean {
|
||||
return Number.isInteger(hours) && hours >= 1 && hours <= MAX_WHATSAPP_LEAD_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminders due to be sent by hand RIGHT NOW: CONFIRMED bookings that start
|
||||
* within the next `leadHours`, haven't started yet, and haven't been marked
|
||||
* sent — soonest first, so the top of the list is always the most urgent.
|
||||
* (listReminderCandidates is the per-calendar-day view for browsing ahead.)
|
||||
*/
|
||||
export async function listDueReminderCandidates(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; leadHours: number; now?: Date; staffId?: string },
|
||||
) {
|
||||
const now = params.now ?? new Date();
|
||||
return listAppointments(prisma, {
|
||||
businessId: params.businessId,
|
||||
staffId: params.staffId,
|
||||
statuses: ["CONFIRMED"],
|
||||
startsFrom: now,
|
||||
startsBefore: new Date(now.getTime() + params.leadHours * 3_600_000),
|
||||
whatsappReminderUnsent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells each barber, once a day, that they have WhatsApp reminders waiting to
|
||||
* be sent by hand — a push (email is off by default for this type) so the job
|
||||
* announces itself instead of relying on someone remembering to open the list.
|
||||
* Called from the notification cron; only barbers with at least one reminder
|
||||
* that is due, unsent and has a usable phone number are nudged, and the
|
||||
* idempotency key (barber + business-local date) makes every later cron tick
|
||||
* that day a no-op. Returns how many barbers were nudged this call.
|
||||
*/
|
||||
export async function nudgeStaffWhatsAppReminders(prisma: PrismaClient, params: { now?: Date } = {}): Promise<number> {
|
||||
const now = params.now ?? new Date();
|
||||
const businesses = await prisma.business.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
timezone: true,
|
||||
settings: { select: { whatsappReminderLeadHours: true } },
|
||||
locations: { orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }], take: 1, select: { country: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let nudged = 0;
|
||||
for (const business of businesses) {
|
||||
const due = await listDueReminderCandidates(prisma, {
|
||||
businessId: business.id,
|
||||
leadHours: business.settings?.whatsappReminderLeadHours ?? DEFAULT_WHATSAPP_LEAD_HOURS,
|
||||
now,
|
||||
});
|
||||
const country = business.locations[0]?.country ?? "GB";
|
||||
const sendable = due.filter((a) => toWhatsAppNumber(a.customer.phone, country));
|
||||
|
||||
const byStaff = new Map<string, typeof sendable>();
|
||||
for (const appointment of sendable) {
|
||||
const list = byStaff.get(appointment.staffId) ?? [];
|
||||
list.push(appointment);
|
||||
byStaff.set(appointment.staffId, list);
|
||||
}
|
||||
|
||||
const localDate = calendarDateOf(now, business.timezone);
|
||||
for (const [staffId, appointments] of byStaff) {
|
||||
const first = appointments[0]!;
|
||||
const time = first.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone });
|
||||
await notifySafely(prisma, {
|
||||
businessId: business.id,
|
||||
type: NotificationType.STAFF_WHATSAPP_REMINDERS_DUE,
|
||||
recipientUserId: first.staff.userId,
|
||||
subject: "WhatsApp reminders to send",
|
||||
bodyText:
|
||||
appointments.length === 1
|
||||
? `1 WhatsApp reminder to send — ${first.customer.firstName} ${first.customer.lastName} at ${time}.`
|
||||
: `${appointments.length} WhatsApp reminders to send — the next is ${first.customer.firstName} ${first.customer.lastName} at ${time}.`,
|
||||
deepLinkPath: "/staff/reminders",
|
||||
idempotencyKey: `notify:whatsapp-reminders-due:${staffId}:${localDate}`,
|
||||
});
|
||||
nudged += 1;
|
||||
}
|
||||
}
|
||||
return nudged;
|
||||
}
|
||||
Reference in new issue
Block a user