1. Pre-launch review (no code) Listed what’s left before going live: testing, backups, monitoring, legal/GDPR, and a dry run on the VPS. Listed the features a barbershop system usually has and which of them you’re missing. Researched WhatsApp pricing, which led to the free route below. 2. Security cleanup This came out of that review. Deleted: /dev/scheduling-test (a test page that shipped), /api/debug/db-connect-test (a leftover debug route), and two files of unauthenticated booking and appointment actions (11 actions). One of them listed appointments with customer details to anyone. Audited: all 127 server actions. The only unguarded ones left are the intended public ones: availability lookups, cookie-based booking drafts, and the token-gated one-time installer. Tidied: removed the four now-dead booking schemas from schemas.ts. 3. Client import from a spreadsheet Entry points: an upload icon next to “+” on the Clients page, and a link on the Add Client page. It’s owner-only, checked again on the server. Flow: /staff/clients/import. Upload a CSV and check the guessed column mapping. Review the preview, then import in chunks with progress. A report lists problem rows by spreadsheet row number, with a download. CSV template: a visible “Download CSV template” button (headers only: First name, Last name, Email, Phone). Behavior: Each client gets a sign-in-ready account, the same as adding one by hand. Nobody is emailed. Existing emails are left untouched. Earlier guest bookings under the same email attach automatically. “Surname, Firstname” names are read the right way round. People sharing a phone number still import. Limits: an email is required for every row, CSV only (no .xlsx), up to 5,000 rows per file, and notes aren’t imported. 4. WhatsApp reminders (free, manual route) Button: a WhatsApp button next to Call and Email on four pages: the owner and barber appointment pages, “Current appointment”, and the client page. It opens WhatsApp with a ready-written reminder, including a “change it” link. Phone numbers: read as the business’s own country when typed without a country code. This uses the new libphonenumber-js dependency. A number WhatsApp can’t use means no button. Reminders list: a new /staff/reminders page lists tomorrow’s confirmed bookings in time order. Each row has a one-tap Send on WhatsApp. Tapping marks it “Sent 14:46”, with an Undo. The mark shows on the appointment pages too. Barbers see their own list; owners see the whole shop with barber names. Calendar link: “WhatsApp reminders · 3 to send tomorrow” on both calendar pages. Database: one additive migration, Appointment.whatsappReminderSentAt. Rescheduling clears it, since a reminder for the old time says nothing about the new one. Audit log: who marked or undid each reminder is recorded. Tests and verification Before today Now Unit tests 297 331 Integration tests 273 285 New unit tests: the CSV parser, import row logic, and WhatsApp link and phone handling. New integration tests: the import service and the reminders service. Checks: typecheck and lint are clean (the one old branding warning remains). Browser: I exercised each feature in a real browser against a throwaway MySQL 8, including the permission split between Leo and Marcus.
This commit is contained in:
39 files changed
+2100
-580
No files matched your search
Generated
+7
@@ -25,6 +25,7 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"image-size": "^2.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"libphonenumber-js": "^1.13.15",
|
||||
"lucide-react": "^1.28.0",
|
||||
"luxon": "^3.7.2",
|
||||
"mariadb": "^3.5.3",
|
||||
@@ -8186,6 +8187,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.15",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz",
|
||||
"integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"image-size": "^2.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"libphonenumber-js": "^1.13.15",
|
||||
"lucide-react": "^1.28.0",
|
||||
"luxon": "^3.7.2",
|
||||
"mariadb": "^3.5.3",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Appointment` ADD COLUMN `whatsappReminderSentAt` DATETIME(3) NULL;
|
||||
@@ -1136,6 +1136,15 @@ model Appointment {
|
||||
/// notifying on reassignment exactly as it did before.
|
||||
staffSelectedByCustomer Boolean @default(true)
|
||||
|
||||
/// When staff last tapped "send on WhatsApp" for this booking's reminder —
|
||||
/// the manual, free route (see lib/messaging/whatsapp.ts), so this records
|
||||
/// that the message was OPENED for sending, not that it was delivered
|
||||
/// (the app can't know). Exists so two people sharing a reminders list
|
||||
/// don't text the same customer twice. Cleared whenever the booking's
|
||||
/// time or barber changes (rescheduleAppointment): a reminder for the old
|
||||
/// slot says nothing about the new one.
|
||||
whatsappReminderSentAt DateTime?
|
||||
|
||||
status AppointmentStatus @default(CONFIRMED)
|
||||
|
||||
/// Stored as UTC instants; the business's `timezone` (see Business model)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
@@ -131,6 +132,19 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<WhatsAppButton
|
||||
businessId={context.businessId}
|
||||
businessName={context.businessName}
|
||||
timezone={tz}
|
||||
customer={appointment.customer}
|
||||
appointment={{
|
||||
id: appointment.id,
|
||||
startAt: appointment.startAt,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s) => s.service.name).join(" + "),
|
||||
whatsappReminderSentAt: appointment.whatsappReminderSentAt,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
|
||||
@@ -23,6 +23,7 @@ import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||
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 { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||
@@ -233,6 +234,10 @@ export default async function OwnerCalendarPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={context.businessId} timezone={businessTimezone} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -120,6 +121,19 @@ export default async function StaffAppointmentDetailsPage({
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<WhatsAppButton
|
||||
businessId={staff.businessId}
|
||||
businessName={staff.business.name}
|
||||
timezone={tz}
|
||||
customer={appointment.customer}
|
||||
appointment={{
|
||||
id: appointment.id,
|
||||
startAt: appointment.startAt,
|
||||
staffName: appointment.staff.user.name ?? "your barber",
|
||||
serviceNames: appointment.services.map((s) => s.service.name).join(" + "),
|
||||
whatsappReminderSentAt: appointment.whatsappReminderSentAt,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { CalendarX2, Mail, Phone } from "lucide-react";
|
||||
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -103,6 +104,11 @@ export default async function StaffCustomerDetailsPage({
|
||||
}));
|
||||
|
||||
const tz = context.businessTimezone;
|
||||
// The soonest upcoming booking — what a WhatsApp reminder from this page is about.
|
||||
const now = new Date();
|
||||
const nextBooking = upcoming
|
||||
.filter((a) => a.startAt > now)
|
||||
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())[0];
|
||||
|
||||
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
|
||||
if (appointments.length === 0) {
|
||||
@@ -197,6 +203,25 @@ export default async function StaffCustomerDetailsPage({
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<WhatsAppButton
|
||||
businessId={context.businessId}
|
||||
businessName={context.businessName}
|
||||
timezone={tz}
|
||||
customer={customer}
|
||||
appointment={
|
||||
nextBooking
|
||||
? {
|
||||
id: nextBooking.id,
|
||||
startAt: nextBooking.startAt,
|
||||
staffName: nextBooking.staff.user.name ?? "your barber",
|
||||
serviceNames: nextBooking.services.map((s) => s.service.name).join(" + "),
|
||||
whatsappReminderSentAt: nextBooking.whatsappReminderSentAt,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="min-w-[104px] flex-1"
|
||||
linkClassName="h-12 w-full justify-center rounded-md px-4 py-0"
|
||||
/>
|
||||
<Button asChild size="md" className="min-w-[104px] flex-1">
|
||||
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { AddClientForm } from "@/features/staff/components/add-client-form";
|
||||
@@ -10,12 +13,21 @@ export const dynamic = "force-dynamic";
|
||||
export default async function AddClientPage() {
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/home");
|
||||
const canImport = await hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Add Client" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<AddClientForm />
|
||||
{canImport && (
|
||||
<p className="text-muted mt-4 text-center text-xs">
|
||||
Moving over from another system?{" "}
|
||||
<Link href="/staff/clients/import" className="text-accent-foreground font-bold underline">
|
||||
Import clients from a spreadsheet
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { PageContainer } from "@/components/layout/page-container";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { ClientImportForm } from "@/features/staff/components/client-import-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Import Clients" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Owner-only — same permission the import action itself enforces; this just spares everyone else a dead-end page. */
|
||||
export default async function ImportClientsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/clients/import");
|
||||
if (!(await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE))) redirect("/staff/clients");
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Import Clients" onBack sticky className="bg-stone" />
|
||||
<PageContainer size="narrow">
|
||||
<ClientImportForm />
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Plus, Search, Users } from "lucide-react";
|
||||
import { Plus, Search, Upload, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
@@ -27,7 +29,10 @@ export default async function StaffClientsPage({
|
||||
|
||||
const { q = "", sort } = await searchParams;
|
||||
|
||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
|
||||
const [unreadNotificationCount, canImport] = await Promise.all([
|
||||
getUnreadNotificationCount(prisma, context.userId),
|
||||
hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE),
|
||||
]);
|
||||
|
||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||
// a real business boundary even though this is a single-business v1 app
|
||||
@@ -94,13 +99,24 @@ export default async function StaffClientsPage({
|
||||
className="bg-stone"
|
||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||
action={
|
||||
<Link
|
||||
href="/staff/clients/add"
|
||||
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
|
||||
aria-label="Add client"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
{canImport && (
|
||||
<Link
|
||||
href="/staff/clients/import"
|
||||
className="border-line flex h-10 w-10 items-center justify-center rounded-full border bg-white"
|
||||
aria-label="Import clients from a spreadsheet"
|
||||
>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/staff/clients/add"
|
||||
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
|
||||
aria-label="Add client"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, NotebookText, Phone, Scissors } from "lucide-react";
|
||||
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -128,6 +129,19 @@ export default async function StaffCurrentPage() {
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
<WhatsAppButton
|
||||
businessId={staff.businessId}
|
||||
businessName={staff.business.name}
|
||||
timezone={tz}
|
||||
customer={current.customer}
|
||||
appointment={{
|
||||
id: current.id,
|
||||
startAt: current.startAt,
|
||||
staffName: current.staff.user.name ?? "your barber",
|
||||
serviceNames: current.services.map((s) => s.service.name).join(" + "),
|
||||
whatsappReminderSentAt: current.whatsappReminderSentAt,
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
href={`/staff/clients/${current.customerId}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { listAppointments, selectCurrentAppointment } from "@/services/schedulin
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||
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";
|
||||
@@ -178,6 +179,10 @@ export default async function StaffCalendarPage({
|
||||
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 px-6">
|
||||
<RemindersLink businessId={staff.businessId} timezone={staff.business.timezone} staffId={staff.id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||||
<div className="lg:flex-1">
|
||||
{wholeDayOff ? (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { PageContainer } from "@/components/layout/page-container";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
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 { 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";
|
||||
|
||||
export const metadata: Metadata = { title: "WhatsApp Reminders" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function WhatsAppRemindersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ date?: string }>;
|
||||
}) {
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/home");
|
||||
|
||||
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);
|
||||
|
||||
// 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 [appointments, country] = await Promise.all([
|
||||
canSeeEveryone || context.staffId
|
||||
? listReminderCandidates(prisma, {
|
||||
businessId: context.businessId,
|
||||
date,
|
||||
businessTimezone: timezone,
|
||||
staffId: canSeeEveryone ? undefined : (context.staffId ?? undefined),
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getBusinessCountry(context.businessId),
|
||||
]);
|
||||
|
||||
const rows = appointments.map((appointment) => {
|
||||
const serviceNames = appointment.services.map((s) => s.service.name).join(" + ");
|
||||
const staffName = appointment.staff.user.name ?? "your barber";
|
||||
return {
|
||||
id: appointment.id,
|
||||
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,
|
||||
firstName: appointment.customer.firstName,
|
||||
serviceNames,
|
||||
staffName,
|
||||
sentAt: appointment.whatsappReminderSentAt,
|
||||
href: reminderWhatsAppUrl({
|
||||
country,
|
||||
businessName: context.businessName,
|
||||
timezone,
|
||||
customer: appointment.customer,
|
||||
appointment: { id: appointment.id, startAt: appointment.startAt, staffName, serviceNames },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const sentCount = rows.filter((r) => r.sentAt).length;
|
||||
const noPhoneCount = rows.filter((r) => !r.href).length;
|
||||
const toSend = rows.length - sentCount - noPhoneCount;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="WhatsApp Reminders" onBack sticky className="bg-stone" />
|
||||
<DateStrip selectedDate={date} businessTimezone={timezone} basePath="/staff/reminders" />
|
||||
|
||||
<PageContainer size="narrow" className="space-y-4">
|
||||
{rows.length > 0 && (
|
||||
<p className="text-muted text-sm">
|
||||
<strong className="text-ink">{toSend}</strong> to send · {sentCount} sent
|
||||
{noPhoneCount > 0 && ` · ${noPhoneCount} with no usable phone number`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={MessageCircle}
|
||||
title="No confirmed appointments this day"
|
||||
description="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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/staff/clients/${row.customerId}`} className="block truncate text-sm font-bold">
|
||||
{row.customerName}
|
||||
</Link>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{row.serviceNames}
|
||||
{canSeeEveryone && ` · ${row.staffName}`}
|
||||
</p>
|
||||
</div>
|
||||
{row.href ? (
|
||||
<WhatsAppSendLink
|
||||
href={row.href}
|
||||
appointmentId={row.id}
|
||||
initialSentAt={row.sentAt?.toISOString() ?? null}
|
||||
timezone={timezone}
|
||||
customerFirstName={row.firstName}
|
||||
variant="row"
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={`/staff/clients/${row.customerId}`}
|
||||
className="text-muted shrink-0 text-right text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
No phone number
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-muted text-xs">
|
||||
Tap <strong>Send on WhatsApp</strong>, and WhatsApp opens on this phone with the reminder already written — you
|
||||
press send there. It's marked as sent here so nobody messages the same customer twice. The app can't
|
||||
see inside WhatsApp, so a booking is marked when you open the message, not when it's delivered.
|
||||
</p>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import mariadb from "mariadb";
|
||||
import { NextResponse } from "next/server";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
|
||||
/**
|
||||
* TEMPORARY — diagnose the "pool timeout" seen on the Vercel+Hostinger
|
||||
* deployment. Prisma's connection pool wraps every failure as the same
|
||||
* generic "pool timeout" message, hiding the actual socket-level error
|
||||
* (ETIMEDOUT = firewall silently dropping packets, ECONNREFUSED = port
|
||||
* closed, ER_ACCESS_DENIED_ERROR = wrong credentials, etc.) that would
|
||||
* otherwise tell us exactly what's blocking the connection. This makes
|
||||
* one raw `mariadb.createConnection()` attempt, bypassing the pool
|
||||
* entirely, and reports that real error back. Delete this route once the
|
||||
* connectivity issue is resolved — an authenticated diagnostic endpoint
|
||||
* has no reason to stay in a shipped app.
|
||||
*
|
||||
* Auth: reuses CRON_SECRET (already configured for
|
||||
* /api/cron/process-notifications) rather than adding a new secret only
|
||||
* this throwaway route would ever use.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
|
||||
}
|
||||
|
||||
const provided = request.headers.get("authorization");
|
||||
if (!provided || !secretsMatch(provided, `Bearer ${secret}`)) {
|
||||
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||
}
|
||||
|
||||
const host = process.env.DATABASE_HOST;
|
||||
const port = process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306;
|
||||
const database = process.env.DATABASE_NAME;
|
||||
const user = process.env.DATABASE_USER;
|
||||
const password = process.env.DATABASE_PASSWORD;
|
||||
const ssl = process.env.DATABASE_SSL === "true";
|
||||
|
||||
if (!host || !database || !user || password === undefined) {
|
||||
return NextResponse.json({ error: "DATABASE_* env vars are not fully configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const conn = await mariadb.createConnection({
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
connectTimeout: 8000,
|
||||
ssl: ssl ? { rejectUnauthorized: true } : undefined,
|
||||
});
|
||||
await conn.query("SELECT 1");
|
||||
await conn.end();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
durationMs: Date.now() - startedAt,
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as { code?: string; errno?: number; message?: string };
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
errorCode: err.code ?? null,
|
||||
errorNo: err.errno ?? null,
|
||||
errorMessage: err.message ?? String(error),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { SchedulingTestClient } from "./scheduling-test-client";
|
||||
|
||||
export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
|
||||
// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Internal-only harness for manually exercising the booking engine
|
||||
* (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
|
||||
* the seeded demo business/staff/customer from prisma/seed.ts. See
|
||||
* docs/phase-3-summary.md.
|
||||
*/
|
||||
export default async function SchedulingTestPage() {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { slug: "blake-heritage-barbershop" },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
});
|
||||
if (!business) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [services, staff, customer] = await Promise.all([
|
||||
prisma.service.findMany({
|
||||
where: { businessId: business.id, isActive: true },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
}),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
prisma.customerProfile.findFirst({
|
||||
where: { email: "alex.chen@example.com" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="text-danger p-8 text-sm">
|
||||
No seeded customer found — run <code>npm run prisma:seed</code> first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SchedulingTestClient
|
||||
business={business}
|
||||
services={services.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
}))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import {
|
||||
getAvailableDatesAction,
|
||||
getAvailableTimeSlotsAction,
|
||||
} from "@/features/booking/actions/availability";
|
||||
import {
|
||||
cancelBookingAction,
|
||||
createBookingAction,
|
||||
rescheduleBookingAction,
|
||||
} from "@/features/booking/actions/booking";
|
||||
import {
|
||||
checkInAction,
|
||||
completeAppointmentAction,
|
||||
listAppointmentsAction,
|
||||
markNoShowAction,
|
||||
startAppointmentAction,
|
||||
} from "@/features/booking/actions/appointment";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MonthCalendar } from "@/components/ui/month-calendar";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
}
|
||||
interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface TimeSlot {
|
||||
startMinutes: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar-date components, not `.toISOString().slice(0, 10)` — that
|
||||
* converts to UTC first, which silently shifts the date back a day for
|
||||
* anyone in a timezone ahead of UTC (e.g. the business's own Europe/London
|
||||
* during BST). `date` here is always local midnight from MonthCalendar's
|
||||
* own `new Date(year, month, day)`, so reading the local parts back out is
|
||||
* the correct inverse.
|
||||
*/
|
||||
function toYMD(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
const EMPTY_SET: Set<string> = new Set();
|
||||
const EMPTY_SLOTS: TimeSlot[] = [];
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
export function SchedulingTestClient(props: {
|
||||
business: { id: string; name: string; timezone: string };
|
||||
services: Service[];
|
||||
staff: Staff[];
|
||||
customerId: string;
|
||||
}) {
|
||||
const { business, services, staff, customerId } = props;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>(services[0] ? [services[0].id] : []);
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string>("any");
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const refreshAppointments = () => {
|
||||
listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshAppointments();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Available dates for the visible month. When nothing is selected there's
|
||||
// nothing to fetch — `effectiveAvailableDates` below derives the empty
|
||||
// case at render time rather than this effect resetting state
|
||||
// synchronously (which cascades an extra render for no reason).
|
||||
useEffect(() => {
|
||||
if (selectedServiceIds.length === 0) return;
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
||||
getAvailableDatesAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
fromDate: toYMD(from),
|
||||
toDate: toYMD(to),
|
||||
}).then((res) => setAvailableDates(new Set(res.dates)));
|
||||
}, [business.id, month, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
|
||||
|
||||
// Time slots for the selected date — same derive-at-render-time pattern.
|
||||
useEffect(() => {
|
||||
if (!selectedDate || selectedServiceIds.length === 0) return;
|
||||
getAvailableTimeSlotsAction({
|
||||
businessId: business.id,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
date: toYMD(selectedDate),
|
||||
}).then((res) => setSlots(res.slots));
|
||||
}, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
|
||||
const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const book = (slot: TimeSlot) => {
|
||||
startTransition(async () => {
|
||||
const result = await createBookingAction({
|
||||
businessId: business.id,
|
||||
customerId,
|
||||
serviceIds: selectedServiceIds,
|
||||
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
||||
startAt: slot.startAt,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage(`Booked: ${result.appointment.id}`);
|
||||
setNotes("");
|
||||
setSelectedDate(undefined);
|
||||
refreshAppointments();
|
||||
} else {
|
||||
setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = (appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
|
||||
setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const reschedule = (appointmentId: string) => {
|
||||
const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
|
||||
if (!input) return;
|
||||
startTransition(async () => {
|
||||
const result = await rescheduleBookingAction({ appointmentId, startAt: input });
|
||||
setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await fn(appointmentId);
|
||||
setMessage(result.success ? "Status updated" : "Transition rejected");
|
||||
refreshAppointments();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 px-6 py-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Scheduling Engine — Dev Test</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{message && (
|
||||
<div className="rounded-xl border border-line bg-white p-3 text-sm" role="status">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">1. Services</h2>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={`${s.name} (${s.durationMinutes}m, £${s.price})`}
|
||||
checked={selectedServiceIds.includes(s.id)}
|
||||
onChange={() => toggleService(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="pt-2 text-xs font-bold uppercase tracking-widest text-muted">2. Barber</h2>
|
||||
<Select value={selectedStaffId} onChange={(e) => setSelectedStaffId(e.target.value)}>
|
||||
<option value="any">Anyone available</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-muted">3. Date</h2>
|
||||
<MonthCalendar
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
isDateDisabled={(date) => !effectiveAvailableDates.has(toYMD(date))}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted">Selectable dates are ones the engine reports as having openings.</p>
|
||||
</Card>
|
||||
|
||||
{selectedDate && (
|
||||
<Card className="space-y-4 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">4. Time</h2>
|
||||
{effectiveSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted">No available slots this day for this selection.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{effectiveSlots.map((slot) => (
|
||||
<button
|
||||
key={slot.startAt}
|
||||
onClick={() => book(slot)}
|
||||
disabled={isPending}
|
||||
className="rounded-lg border border-line px-4 py-2 text-sm font-medium hover:border-ink disabled:opacity-50"
|
||||
>
|
||||
{new Date(slot.startAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
||||
<span className="ml-1 text-[10px] text-muted">({slot.staffOptions.length} avail.)</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notes (optional)"
|
||||
className="w-full rounded-lg border border-line px-3 py-2 text-sm"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="space-y-3 p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">Appointments</h2>
|
||||
{appointments.length === 0 && <p className="text-sm text-muted">None yet.</p>}
|
||||
{appointments.map((a) => (
|
||||
<div key={a.id} className="rounded-xl border border-line p-4 text-sm">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[a.status] ?? "confirmed"} />
|
||||
<span className="text-xs text-muted">{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => checkInAction(id), a.id)} disabled={isPending}>
|
||||
Check in
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => startAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Start
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => completeAppointmentAction(id), a.id)} disabled={isPending}>
|
||||
Complete
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => markNoShowAction(id), a.id)} disabled={isPending}>
|
||||
No-show
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => reschedule(a.id)} disabled={isPending}>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => cancel(a.id)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as AppointmentService from "@/services/scheduling/appointment.service";
|
||||
|
||||
export async function getAppointmentAction(appointmentId: string) {
|
||||
return AppointmentService.getAppointmentById(prisma, appointmentId);
|
||||
}
|
||||
|
||||
export async function listAppointmentsAction(params: Parameters<typeof AppointmentService.listAppointments>[1]) {
|
||||
return AppointmentService.listAppointments(prisma, params);
|
||||
}
|
||||
|
||||
// Next's Server Actions compiler statically analyses "use server" files for
|
||||
// exported `async function` declarations — a `const` bound to a factory's
|
||||
// return value (however trivial) isn't recognized as a callable action, so
|
||||
// each one below is a real function declaration even though the bodies
|
||||
// are identical apart from which AppointmentService transition they call.
|
||||
|
||||
export async function checkInAction(appointmentId: string) {
|
||||
const result = await AppointmentService.checkIn(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.start(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.complete(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function markNoShowAction(appointmentId: string) {
|
||||
const result = await AppointmentService.markNoShow(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(appointmentId: string) {
|
||||
const result = await AppointmentService.confirm(prisma, appointmentId);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import * as BookingService from "@/services/scheduling/booking.service";
|
||||
import { fail } from "@/services/scheduling/types";
|
||||
import { cancelBookingSchema, createBookingSchema, rescheduleBookingSchema, updateBookingNotesSchema } from "../schemas";
|
||||
|
||||
export async function createBookingAction(input: unknown) {
|
||||
const parsed = createBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.createBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function rescheduleBookingAction(input: unknown) {
|
||||
const parsed = rescheduleBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.rescheduleBooking(prisma, {
|
||||
...parsed.data,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
});
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function cancelBookingAction(input: unknown) {
|
||||
const parsed = cancelBookingSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
|
||||
const result = await BookingService.cancelBooking(prisma, parsed.data);
|
||||
if (result.success) revalidatePath("/dev/scheduling-test");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** "Update booking" — notes only. Time/staff changes go through reschedule (a distinct, re-validated workflow), not this. */
|
||||
export async function updateBookingNotesAction(input: unknown) {
|
||||
const parsed = updateBookingNotesSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
|
||||
}
|
||||
|
||||
const appointment = await prisma.appointment.update({
|
||||
where: { id: parsed.data.appointmentId },
|
||||
data: { notes: parsed.data.notes },
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
revalidatePath("/dev/scheduling-test");
|
||||
return { success: true as const, appointment };
|
||||
}
|
||||
@@ -38,32 +38,6 @@ export const getAvailableTimeSlotsSchema = z.object({
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const createBookingSchema = z.object({
|
||||
businessId: z.string(),
|
||||
customerId: z.string(),
|
||||
serviceIds: z.array(z.string()).min(1),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const rescheduleBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
staffId: z.string().optional(),
|
||||
startAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
export const cancelBookingSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
cancelledByUserId: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateBookingNotesSchema = z.object({
|
||||
appointmentId: z.string(),
|
||||
notes: z.string().max(2000),
|
||||
});
|
||||
|
||||
// ── Customer booking-flow draft steps ────────────────────────────────────
|
||||
|
||||
export const saveServicesStepSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { IMPORT_CHUNK_SIZE, validateImportRow } from "@/services/customer/client-import-rows";
|
||||
import { importClients } from "@/services/customer/customer-import.service";
|
||||
import type { ImportClientInput } from "@/services/customer/customer-import.service";
|
||||
|
||||
const chunkSchema = z.object({
|
||||
rows: z
|
||||
.array(
|
||||
z.object({
|
||||
rowNumber: z.number().int().positive(),
|
||||
firstName: z.string().max(200),
|
||||
lastName: z.string().max(200),
|
||||
email: z.string().max(400),
|
||||
phone: z.string().max(100),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(IMPORT_CHUNK_SIZE * 2),
|
||||
});
|
||||
|
||||
export interface ImportClientsChunkResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
created?: number;
|
||||
alreadyClients?: number;
|
||||
/** Rows that couldn't be imported, with why — row numbers are the spreadsheet's own. */
|
||||
problems?: { rowNumber: number; email: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One slice of a spreadsheet import. The browser has already shown a
|
||||
* preview, but nothing it computed is trusted: every row is validated again
|
||||
* here with the same shared rules, and the whole action is owner-only — a
|
||||
* bulk import creates accounts for hundreds of people, which is a different
|
||||
* order of thing from a barber adding a walk-in on the floor.
|
||||
*/
|
||||
export async function importClientsChunkAction(input: unknown): Promise<ImportClientsChunkResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const parsed = chunkSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "That chunk of the file wasn't valid. Please try the import again." };
|
||||
|
||||
const problems: NonNullable<ImportClientsChunkResult["problems"]> = [];
|
||||
const valid: ImportClientInput[] = [];
|
||||
for (const row of parsed.data.rows) {
|
||||
const result = validateImportRow(row);
|
||||
if (result.ok) valid.push({ rowNumber: row.rowNumber, client: result.client });
|
||||
else problems.push({ rowNumber: row.rowNumber, email: row.email, reason: result.reason });
|
||||
}
|
||||
|
||||
const outcome = await importClients(prisma, valid);
|
||||
problems.push(...outcome.failed);
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: context.userId,
|
||||
action: "CLIENTS_IMPORTED",
|
||||
entityType: "Business",
|
||||
entityId: context.businessId,
|
||||
metadata: { created: outcome.created, alreadyClients: outcome.alreadyClients, problems: problems.length },
|
||||
});
|
||||
|
||||
revalidatePath("/staff/clients");
|
||||
return { success: true, created: outcome.created, alreadyClients: outcome.alreadyClients, problems };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
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 { prisma } from "@/lib/db/prisma";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { setWhatsAppReminderSent } from "@/services/scheduling/whatsapp-reminders.service";
|
||||
|
||||
const inputSchema = z.object({ appointmentId: z.string().min(1), sent: z.boolean() });
|
||||
|
||||
export interface MarkWhatsAppReminderResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
/** ISO timestamp when marked sent, null when cleared. */
|
||||
sentAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a booking's WhatsApp reminder as sent (or undoes it). A barber can do
|
||||
* this for their own bookings; someone who can see everyone's bookings (an
|
||||
* owner) can do it for any booking in the business — the same split the
|
||||
* reminders list itself uses, re-checked here because the browser's idea of
|
||||
* which rows it may touch is not a permission check.
|
||||
*/
|
||||
export async function markWhatsAppReminderAction(input: unknown): Promise<MarkWhatsAppReminderResult> {
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "That request wasn't valid." };
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { success: false, error: "You must be signed in." };
|
||||
|
||||
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
|
||||
if (!appointment || appointment.businessId !== context.businessId) {
|
||||
return { success: false, error: "Appointment not found." };
|
||||
}
|
||||
|
||||
const canSeeEveryone = await hasPermission(context.userId, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canSeeEveryone && appointment.staffId !== context.staffId) {
|
||||
return { success: false, error: "You can only do this for your own appointments." };
|
||||
}
|
||||
|
||||
const sentAt = await setWhatsAppReminderSent(prisma, parsed.data);
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: context.userId,
|
||||
action: parsed.data.sent ? "WHATSAPP_REMINDER_MARKED_SENT" : "WHATSAPP_REMINDER_MARK_CLEARED",
|
||||
entityType: "Appointment",
|
||||
entityId: appointment.id,
|
||||
});
|
||||
|
||||
revalidatePath("/staff/reminders");
|
||||
return { success: true, sentAt: sentAt ? sentAt.toISOString() : null };
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { parseCsv } from "@/lib/csv/parse-csv";
|
||||
import {
|
||||
IMPORT_CHUNK_SIZE,
|
||||
MAX_IMPORT_ROWS,
|
||||
buildImportRows,
|
||||
guessMapping,
|
||||
mappingProblem,
|
||||
validateImportRow,
|
||||
} from "@/services/customer/client-import-rows";
|
||||
import type { ColumnMapping, ImportField } from "@/services/customer/client-import-rows";
|
||||
import { importClientsChunkAction } from "../actions/client-import";
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
// Headers only — an example row is one forgotten delete away from importing a
|
||||
// fake client. The page explains the formats; the headers are all it needs.
|
||||
const TEMPLATE_CSV = "First name,Last name,Email,Phone\n";
|
||||
|
||||
const FIELD_LABEL: Record<ImportField, string> = {
|
||||
firstName: "First name",
|
||||
lastName: "Last name",
|
||||
fullName: "Full name (split into first and last)",
|
||||
email: "Email",
|
||||
phone: "Phone (optional)",
|
||||
};
|
||||
const FIELD_ORDER: ImportField[] = ["email", "firstName", "lastName", "fullName", "phone"];
|
||||
|
||||
interface Problem {
|
||||
rowNumber: number;
|
||||
email: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type Stage =
|
||||
| { name: "choose" }
|
||||
| { name: "map"; fileName: string; headers: string[]; records: string[][] }
|
||||
| { name: "importing"; done: number; total: number }
|
||||
| { name: "done"; created: number; alreadyClients: number; problems: Problem[]; stoppedEarly?: string };
|
||||
|
||||
function downloadCsv(filename: string, csv: string) {
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvCell(value: string | number): string {
|
||||
const text = String(value);
|
||||
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreadsheet → clients. Parsing and the live preview happen in the browser
|
||||
* (nothing leaves the page until "Import"), but each slice sent to the server
|
||||
* is re-validated there with the same shared rules — see
|
||||
* client-import-rows.ts and actions/client-import.ts.
|
||||
*/
|
||||
export function ClientImportForm() {
|
||||
const [stage, setStage] = useState<Stage>({ name: "choose" });
|
||||
const [mapping, setMapping] = useState<ColumnMapping>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const analysis = useMemo(() => {
|
||||
if (stage.name !== "map") return null;
|
||||
const rows = buildImportRows(stage.records.slice(1), mapping);
|
||||
const seen = new Set<string>();
|
||||
const valid: { rowNumber: number; firstName: string; lastName: string; email: string; phone: string }[] = [];
|
||||
const problems: Problem[] = [];
|
||||
let repeats = 0;
|
||||
for (const row of rows) {
|
||||
const result = validateImportRow(row);
|
||||
if (!result.ok) {
|
||||
problems.push({ rowNumber: row.rowNumber, email: row.email, reason: result.reason });
|
||||
} else if (seen.has(result.client.email)) {
|
||||
repeats++;
|
||||
} else {
|
||||
seen.add(result.client.email);
|
||||
valid.push(row);
|
||||
}
|
||||
}
|
||||
return { total: rows.length, valid, problems, repeats };
|
||||
}, [stage, mapping]);
|
||||
|
||||
const chooseFile = async (file: File | undefined) => {
|
||||
setError(null);
|
||||
if (!file) return;
|
||||
if (/\.xlsx?$/i.test(file.name)) {
|
||||
setError("That's an Excel file. In Excel choose File → Save As → CSV (Comma delimited), then upload that file.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
setError("That file is over 5 MB — too large to be a client list. Check it's the right file.");
|
||||
return;
|
||||
}
|
||||
const records = parseCsv(await file.text());
|
||||
const headers = records[0] ?? [];
|
||||
if (records.length < 2 || headers.every((h) => h === "")) {
|
||||
setError("The file needs a header row (like First name, Last name, Email) and at least one client below it.");
|
||||
return;
|
||||
}
|
||||
if (records.length - 1 > MAX_IMPORT_ROWS) {
|
||||
setError(`That file has more than ${MAX_IMPORT_ROWS.toLocaleString()} rows. Split it into smaller files and import them one at a time.`);
|
||||
return;
|
||||
}
|
||||
setMapping(guessMapping(headers));
|
||||
setStage({ name: "map", fileName: file.name, headers, records });
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
if (!analysis || stage.name !== "map") return;
|
||||
const { valid, problems: localProblems } = analysis;
|
||||
const problems: Problem[] = [...localProblems];
|
||||
let created = 0;
|
||||
let alreadyClients = 0;
|
||||
let stoppedEarly: string | undefined;
|
||||
|
||||
for (let start = 0; start < valid.length; start += IMPORT_CHUNK_SIZE) {
|
||||
setStage({ name: "importing", done: start, total: valid.length });
|
||||
const result = await importClientsChunkAction({ rows: valid.slice(start, start + IMPORT_CHUNK_SIZE) });
|
||||
if (!result.success) {
|
||||
stoppedEarly = result.error ?? "The import was interrupted.";
|
||||
break;
|
||||
}
|
||||
created += result.created ?? 0;
|
||||
alreadyClients += result.alreadyClients ?? 0;
|
||||
problems.push(...(result.problems ?? []));
|
||||
}
|
||||
|
||||
problems.sort((a, b) => a.rowNumber - b.rowNumber);
|
||||
setStage({ name: "done", created, alreadyClients, problems, stoppedEarly });
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStage({ name: "choose" });
|
||||
setMapping({});
|
||||
setError(null);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
};
|
||||
|
||||
if (stage.name === "importing") {
|
||||
const percent = stage.total === 0 ? 100 : Math.round((stage.done / stage.total) * 100);
|
||||
return (
|
||||
<div className="border-line space-y-4 rounded-md border bg-white p-6">
|
||||
<p className="text-sm font-bold">Importing clients…</p>
|
||||
<div className="bg-stone h-2 overflow-hidden rounded-full" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="bg-accent h-full transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
{stage.done.toLocaleString()} of {stage.total.toLocaleString()} — keep this page open until it finishes.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.name === "done") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-line space-y-3 rounded-md border bg-white p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{stage.stoppedEarly ? (
|
||||
<AlertCircle className="text-danger h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-accent h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
<p className="text-sm font-bold">{stage.stoppedEarly ? "Import stopped" : "Import complete"}</p>
|
||||
</div>
|
||||
{stage.stoppedEarly && <p className="text-danger text-sm">{stage.stoppedEarly}</p>}
|
||||
<dl className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<dd className="text-xl font-bold">{stage.created.toLocaleString()}</dd>
|
||||
<dt className="text-muted text-[10px] font-bold tracking-widest uppercase">Added</dt>
|
||||
</div>
|
||||
<div>
|
||||
<dd className="text-xl font-bold">{stage.alreadyClients.toLocaleString()}</dd>
|
||||
<dt className="text-muted text-[10px] font-bold tracking-widest uppercase">Already clients</dt>
|
||||
</div>
|
||||
<div>
|
||||
<dd className="text-xl font-bold">{stage.problems.length.toLocaleString()}</dd>
|
||||
<dt className="text-muted text-[10px] font-bold tracking-widest uppercase">Need a look</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-muted text-xs">
|
||||
Nobody was emailed. Clients can sign in any time with just their email address, and any bookings they made
|
||||
as a guest under it are attached to their account automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stage.problems.length > 0 && (
|
||||
<div className="border-line space-y-3 rounded-md border bg-white p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-bold">Rows that weren't imported</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"clients-not-imported.csv",
|
||||
["Row,Email,Reason", ...stage.problems.map((p) => [p.rowNumber, p.email, p.reason].map(csvCell).join(","))].join("\n"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="divide-line max-h-72 divide-y overflow-y-auto text-sm">
|
||||
{stage.problems.slice(0, 200).map((p) => (
|
||||
<li key={`${p.rowNumber}-${p.reason}`} className="flex gap-3 py-2">
|
||||
<span className="text-muted w-14 shrink-0">Row {p.rowNumber}</span>
|
||||
<span className="min-w-0 flex-1">{p.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{stage.problems.length > 200 && (
|
||||
<p className="text-muted text-xs">Showing the first 200 — download the file for the full list.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={reset}>
|
||||
Import another file
|
||||
</Button>
|
||||
<Button asChild className="flex-1">
|
||||
<Link href="/staff/clients">View clients</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.name === "map" && analysis) {
|
||||
const blocker = mappingProblem(mapping);
|
||||
const importable = analysis.valid.length;
|
||||
const columnOptions = stage.headers.map((header, index) => (
|
||||
<option key={index} value={index}>
|
||||
{header || `Column ${index + 1}`}
|
||||
</option>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-line space-y-4 rounded-md border bg-white p-6">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileSpreadsheet className="text-muted h-4 w-4" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate font-bold">{stage.fileName}</span>
|
||||
<button type="button" onClick={reset} className="text-accent-foreground text-xs font-bold underline">
|
||||
Change file
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted text-sm">Match each of these to a column in your file. We've guessed — check them.</p>
|
||||
<div className="space-y-3">
|
||||
{FIELD_ORDER.map((field) => (
|
||||
<Select
|
||||
key={field}
|
||||
label={FIELD_LABEL[field]}
|
||||
value={mapping[field] === undefined ? "" : String(mapping[field])}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setMapping((prev) => {
|
||||
const next = { ...prev };
|
||||
if (value === "") delete next[field];
|
||||
else next[field] = Number(value);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">— not in my file —</option>
|
||||
{columnOptions}
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-3 rounded-md border bg-white p-6">
|
||||
{blocker ? (
|
||||
<p className="text-danger text-sm font-medium">{blocker}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
<strong>{importable.toLocaleString()}</strong> of {analysis.total.toLocaleString()} rows are ready to import.
|
||||
</p>
|
||||
{(analysis.problems.length > 0 || analysis.repeats > 0) && (
|
||||
<p className="text-muted text-xs">
|
||||
{analysis.problems.length > 0 && `${analysis.problems.length.toLocaleString()} can't be imported (listed afterwards). `}
|
||||
{analysis.repeats > 0 && `${analysis.repeats.toLocaleString()} repeat an email that's already earlier in the file and will be skipped.`}
|
||||
</p>
|
||||
)}
|
||||
{importable > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-muted text-[10px] tracking-widest uppercase">
|
||||
<tr>
|
||||
<th className="py-1 pr-3 font-bold">Name</th>
|
||||
<th className="py-1 pr-3 font-bold">Email</th>
|
||||
<th className="py-1 font-bold">Phone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-line divide-y">
|
||||
{analysis.valid.slice(0, 5).map((row) => (
|
||||
<tr key={row.rowNumber}>
|
||||
<td className="py-1.5 pr-3">{`${row.firstName} ${row.lastName}`.trim()}</td>
|
||||
<td className="py-1.5 pr-3">{row.email}</td>
|
||||
<td className="py-1.5">{row.phone}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full" disabled={!!blocker || importable === 0} onClick={runImport}>
|
||||
{importable > 0 ? `Import ${importable.toLocaleString()} clients` : "Nothing to import"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-line space-y-4 rounded-md border bg-white p-6">
|
||||
<p className="text-sm font-bold">Bring your existing clients across</p>
|
||||
<p className="text-muted text-sm">
|
||||
Upload a CSV file with one client per row. You need an email address for each client — that's how they sign
|
||||
in and how their bookings are matched to them. Name and phone come along too.
|
||||
</p>
|
||||
<ul className="text-muted list-disc space-y-1 pl-5 text-xs">
|
||||
<li>
|
||||
The template has the columns we look for: First name, Last name, Email, Phone. Every row needs an email and a
|
||||
name; the rest is optional. Your own column names work too — you'll match them up on the next step.
|
||||
</li>
|
||||
<li>From Excel: File → Save As → CSV. From Google Sheets: File → Download → CSV.</li>
|
||||
<li>Nobody is emailed when they're imported, and anyone already in your list is left exactly as they are.</li>
|
||||
</ul>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv("clients-template.csv", TEMPLATE_CSV)}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Download CSV template
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".csv,.tsv,.txt,text/csv"
|
||||
onChange={(event) => chooseFile(event.target.files?.[0])}
|
||||
className="file:bg-ink block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-xs file:font-bold file:tracking-widest file:text-white file:uppercase"
|
||||
aria-label="Choose a CSV file"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChevronRight, MessageCircle } from "lucide-react";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 [appointments, country] = await Promise.all([
|
||||
listReminderCandidates(prisma, {
|
||||
businessId,
|
||||
date: addDays(todayIn(timezone), 1),
|
||||
businessTimezone: timezone,
|
||||
staffId,
|
||||
}),
|
||||
getBusinessCountry(businessId),
|
||||
]);
|
||||
const toSend = appointments.filter(
|
||||
(a) => !a.whatsappReminderSentAt && toWhatsAppNumber(a.customer.phone, country),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/staff/reminders"
|
||||
className="border-line hover:bg-stone flex items-center gap-3 rounded-full border bg-white px-4 py-2.5"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<ChevronRight className="text-muted h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { getBusinessCountry } from "@/lib/business/get-business-country";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { reminderWhatsAppUrl } from "../lib/whatsapp-reminder-link";
|
||||
import { WhatsAppSendLink } from "./whatsapp-send-link";
|
||||
|
||||
/**
|
||||
* "Message on WhatsApp" — opens WhatsApp with a message already typed; the
|
||||
* person presses send themselves, from their own phone. Nothing is sent by
|
||||
* the app (see lib/messaging/whatsapp.ts). Hidden when the customer has no
|
||||
* phone number WhatsApp could use, rather than showing a dead button.
|
||||
*
|
||||
* With an appointment it writes a reminder for that booking (including a link
|
||||
* the customer can use to change it) and records when it was sent, shared
|
||||
* with the daily reminders list; without one, just a greeting.
|
||||
*/
|
||||
export async function WhatsAppButton({
|
||||
businessId,
|
||||
businessName,
|
||||
timezone,
|
||||
customer,
|
||||
appointment,
|
||||
className,
|
||||
linkClassName,
|
||||
}: {
|
||||
businessId: string;
|
||||
businessName: string;
|
||||
timezone: string;
|
||||
customer: { firstName: string; phone: string | null; userId: string | null };
|
||||
appointment?: {
|
||||
id: string;
|
||||
startAt: Date;
|
||||
staffName: string;
|
||||
serviceNames: string;
|
||||
whatsappReminderSentAt: Date | null;
|
||||
};
|
||||
/** Wrapper sizing (e.g. flex-1 in a row of buttons). */
|
||||
className?: string;
|
||||
/** The link's own shape (height, radius, width). */
|
||||
linkClassName?: string;
|
||||
}) {
|
||||
const href = reminderWhatsAppUrl({
|
||||
country: await getBusinessCountry(businessId),
|
||||
businessName,
|
||||
timezone,
|
||||
customer,
|
||||
appointment,
|
||||
});
|
||||
if (!href) return null;
|
||||
|
||||
if (appointment) {
|
||||
return (
|
||||
<WhatsAppSendLink
|
||||
href={href}
|
||||
appointmentId={appointment.id}
|
||||
initialSentAt={appointment.whatsappReminderSentAt?.toISOString() ?? null}
|
||||
timezone={timezone}
|
||||
customerFirstName={customer.firstName}
|
||||
className={className}
|
||||
linkClassName={linkClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${customer.firstName} on WhatsApp`}
|
||||
className={cn(
|
||||
"border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold",
|
||||
className,
|
||||
linkClassName,
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="text-accent h-3.5 w-3.5" aria-hidden="true" /> WhatsApp
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Check, MessageCircle } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { markWhatsAppReminderAction } from "../actions/whatsapp-reminders";
|
||||
|
||||
/**
|
||||
* A WhatsApp link that also remembers it was used. Tapping opens WhatsApp
|
||||
* (a plain link in a new tab — the browser does that immediately, nothing
|
||||
* here can block or delay it) and, alongside, records "reminder sent" so the
|
||||
* next person looking at this booking sees it's been dealt with.
|
||||
*
|
||||
* "Sent" means "opened WhatsApp for this booking", not "message delivered":
|
||||
* the app can't see inside WhatsApp. That's why a sent booking still shows a
|
||||
* link (to resend or fix a typo) and an Undo.
|
||||
*/
|
||||
export function WhatsAppSendLink({
|
||||
href,
|
||||
appointmentId,
|
||||
initialSentAt,
|
||||
timezone,
|
||||
customerFirstName,
|
||||
variant = "button",
|
||||
className,
|
||||
linkClassName,
|
||||
}: {
|
||||
href: string;
|
||||
appointmentId: string;
|
||||
/** ISO timestamp, or null if no reminder has been sent yet. */
|
||||
initialSentAt: string | null;
|
||||
timezone: string;
|
||||
customerFirstName: string;
|
||||
/** "button" matches Call/Email on the detail pages; "row" is the larger target used in the reminders list. */
|
||||
variant?: "button" | "row";
|
||||
/** Applied to the wrapper (sizing within a flex row, etc.). */
|
||||
className?: string;
|
||||
/** Applied to the link itself (height, radius, width). */
|
||||
linkClassName?: string;
|
||||
}) {
|
||||
const [sentAt, setSentAt] = useState<string | null>(initialSentAt);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const record = (sent: boolean) => {
|
||||
const previous = sentAt;
|
||||
setError(null);
|
||||
setSentAt(sent ? new Date().toISOString() : null); // optimistic
|
||||
startTransition(async () => {
|
||||
const result = await markWhatsAppReminderAction({ appointmentId, sent });
|
||||
if (!result.success) {
|
||||
setSentAt(previous);
|
||||
setError(result.error ?? "Couldn't save that — please try again.");
|
||||
} else {
|
||||
setSentAt(result.sentAt ?? null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sentLabel = sentAt
|
||||
? new Date(sentAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone })
|
||||
: null;
|
||||
|
||||
const base =
|
||||
variant === "row"
|
||||
? "flex h-11 items-center justify-center gap-2 rounded-md border px-4 text-xs font-bold whitespace-nowrap"
|
||||
: "flex items-center gap-2 rounded-lg border px-4 py-2 text-xs font-bold";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start gap-1", variant === "row" && "items-end", className)}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
// Only the first tap records a send; tapping again to resend leaves the original time.
|
||||
if (!sentAt) record(true);
|
||||
}}
|
||||
aria-label={
|
||||
sentAt
|
||||
? `Open WhatsApp to message ${customerFirstName} again (reminder marked sent at ${sentLabel})`
|
||||
: `Send ${customerFirstName} a WhatsApp reminder`
|
||||
}
|
||||
className={cn(
|
||||
base,
|
||||
sentAt ? "border-line text-muted bg-white" : "border-accent bg-accent text-white",
|
||||
variant === "button" && !sentAt && "border-line bg-white text-ink",
|
||||
linkClassName,
|
||||
)}
|
||||
>
|
||||
{sentAt ? (
|
||||
<>
|
||||
<Check className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Sent {sentLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageCircle
|
||||
className={cn("h-3.5 w-3.5", variant === "button" ? "text-accent" : "text-white")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{variant === "row" ? "Send on WhatsApp" : "WhatsApp"}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
{sentAt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => record(false)}
|
||||
className="text-muted text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-[10px] font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
buildGreetingMessage,
|
||||
buildReminderMessage,
|
||||
toWhatsAppNumber,
|
||||
whatsAppUrl,
|
||||
} from "@/lib/messaging/whatsapp";
|
||||
import { absoluteUrl } from "@/services/notifications/templates/shared";
|
||||
import { customerAppointmentLink } from "@/services/scheduling/guest-access.service";
|
||||
|
||||
/**
|
||||
* The one place a WhatsApp reminder link for a booking is assembled — shared
|
||||
* by the per-appointment button and the daily reminders list so the two can
|
||||
* never word the message differently. Returns null when the customer has no
|
||||
* phone number WhatsApp could use.
|
||||
*/
|
||||
export function reminderWhatsAppUrl(params: {
|
||||
/** The business's own country (ISO alpha-2) — see getBusinessCountry. */
|
||||
country: string;
|
||||
businessName: string;
|
||||
timezone: string;
|
||||
customer: { firstName: string; phone: string | null; userId: string | null };
|
||||
appointment?: { id: string; startAt: Date; staffName: string; serviceNames: string };
|
||||
}): string | null {
|
||||
const { customer, appointment, businessName, timezone } = params;
|
||||
const digits = toWhatsAppNumber(customer.phone, params.country);
|
||||
if (!digits) return null;
|
||||
|
||||
if (!appointment) {
|
||||
return whatsAppUrl(digits, buildGreetingMessage({ customerFirstName: customer.firstName, businessName }));
|
||||
}
|
||||
|
||||
return whatsAppUrl(
|
||||
digits,
|
||||
buildReminderMessage({
|
||||
customerFirstName: customer.firstName,
|
||||
businessName,
|
||||
services: appointment.serviceNames,
|
||||
staffName: appointment.staffName,
|
||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
manageUrl: absoluteUrl(customerAppointmentLink({ id: appointment.id, customer: { userId: customer.userId } })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The business's own country (ISO 3166-1 alpha-2, e.g. "GB") — what a phone
|
||||
* number typed without a country code is assumed to belong to. Falls back to
|
||||
* "GB" for a business with no location yet, matching the schema default.
|
||||
*/
|
||||
export const getBusinessCountry = cache(async (businessId: string): Promise<string> => {
|
||||
const location = await prisma.businessLocation.findFirst({
|
||||
where: { businessId },
|
||||
orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }],
|
||||
select: { country: true },
|
||||
});
|
||||
return location?.country ?? "GB";
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectDelimiter, isBlankRecord, parseCsv } from "../parse-csv";
|
||||
|
||||
describe("parseCsv", () => {
|
||||
it("reads a plain comma file with a header row", () => {
|
||||
expect(parseCsv("First name,Email\nJamie,jamie@example.com\nSam,sam@example.com\n")).toEqual([
|
||||
["First name", "Email"],
|
||||
["Jamie", "jamie@example.com"],
|
||||
["Sam", "sam@example.com"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips a UTF-8 byte-order mark (Excel adds one)", () => {
|
||||
expect(parseCsv("Name,Email\nJamie,j@example.com")[0]).toEqual(["Name", "Email"]);
|
||||
});
|
||||
|
||||
it("handles Windows line endings and a missing final newline", () => {
|
||||
expect(parseCsv("a,b\r\n1,2\r\n3,4")).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps commas, doubled quotes and line breaks that sit inside quotes", () => {
|
||||
const records = parseCsv('Name,Notes\n"Fraser, Jamie","Said ""hello""\nand left"\n');
|
||||
expect(records[1]).toEqual(["Fraser, Jamie", 'Said "hello"\nand left']);
|
||||
});
|
||||
|
||||
it("detects semicolons (European Excel) and tabs", () => {
|
||||
expect(detectDelimiter("Name;Email;Phone")).toBe(";");
|
||||
expect(detectDelimiter("Name\tEmail\tPhone")).toBe("\t");
|
||||
expect(parseCsv("Name;Email\nJamie;j@example.com")[1]).toEqual(["Jamie", "j@example.com"]);
|
||||
});
|
||||
|
||||
it("ignores delimiters inside a quoted header when detecting", () => {
|
||||
expect(detectDelimiter('"Last, First";Email;Phone')).toBe(";");
|
||||
});
|
||||
|
||||
it("trims cells and keeps blank records so row numbers stay true", () => {
|
||||
const records = parseCsv("a,b\n x , y \n,\n3,4\n");
|
||||
expect(records[1]).toEqual(["x", "y"]);
|
||||
expect(isBlankRecord(records[2]!)).toBe(true);
|
||||
expect(records[3]).toEqual(["3", "4"]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty file", () => {
|
||||
expect(parseCsv("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* A small, dependency-free CSV reader — enough for the files Excel, Google
|
||||
* Sheets, Numbers and other booking systems export, which is all the client
|
||||
* import needs. Deliberately not a spreadsheet library: reading .xlsx means a
|
||||
* large parser fed untrusted files, so the import asks for a CSV instead
|
||||
* (every spreadsheet app can "Save as / Download as CSV").
|
||||
*
|
||||
* Handles: a UTF-8 byte-order mark, comma / semicolon / tab delimiters
|
||||
* (European Excel writes semicolons), quoted fields containing delimiters,
|
||||
* doubled quotes and line breaks, and \r\n or \n line endings.
|
||||
*/
|
||||
|
||||
export type CsvDelimiter = "," | ";" | "\t";
|
||||
|
||||
const CANDIDATES: CsvDelimiter[] = [",", ";", "\t"];
|
||||
|
||||
/** Picks the delimiter that appears most in the header line (outside quotes). */
|
||||
export function detectDelimiter(text: string): CsvDelimiter {
|
||||
const firstLine = readFirstLine(text);
|
||||
let best: CsvDelimiter = ",";
|
||||
let bestCount = 0;
|
||||
for (const candidate of CANDIDATES) {
|
||||
let count = 0;
|
||||
let inQuotes = false;
|
||||
for (const char of firstLine) {
|
||||
if (char === '"') inQuotes = !inQuotes;
|
||||
else if (char === candidate && !inQuotes) count++;
|
||||
}
|
||||
if (count > bestCount) {
|
||||
best = candidate;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function readFirstLine(text: string): string {
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
if (char === '"') inQuotes = !inQuotes;
|
||||
else if ((char === "\n" || char === "\r") && !inQuotes) return text.slice(0, i);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every record as an array of trimmed cell strings. Fully blank
|
||||
* records are kept (as arrays of empty strings) so a record's index always
|
||||
* matches the row number a spreadsheet app would show — callers skip blanks
|
||||
* themselves with `isBlankRecord`.
|
||||
*/
|
||||
export function parseCsv(input: string, delimiter?: CsvDelimiter): string[][] {
|
||||
const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
const sep = delimiter ?? detectDelimiter(text);
|
||||
|
||||
const records: string[][] = [];
|
||||
let record: string[] = [];
|
||||
let cell = "";
|
||||
let inQuotes = false;
|
||||
|
||||
const endCell = () => {
|
||||
record.push(cell.trim());
|
||||
cell = "";
|
||||
};
|
||||
const endRecord = () => {
|
||||
endCell();
|
||||
records.push(record);
|
||||
record = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i]!;
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' && cell === "") inQuotes = true;
|
||||
else if (char === sep) endCell();
|
||||
else if (char === "\r") {
|
||||
if (text[i + 1] === "\n") i++;
|
||||
endRecord();
|
||||
} else if (char === "\n") endRecord();
|
||||
else cell += char;
|
||||
}
|
||||
|
||||
// A file that doesn't end with a newline still has a last record.
|
||||
if (cell !== "" || record.length > 0) endRecord();
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export function isBlankRecord(record: string[]): boolean {
|
||||
return record.every((cell) => cell === "");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGreetingMessage, buildReminderMessage, toWhatsAppNumber, whatsAppUrl } from "../whatsapp";
|
||||
|
||||
describe("toWhatsAppNumber", () => {
|
||||
it("turns UK local, spaced and international formats into the same digits", () => {
|
||||
expect(toWhatsAppNumber("07911 123456", "GB")).toBe("447911123456");
|
||||
expect(toWhatsAppNumber("+44 7911 123456", "GB")).toBe("447911123456");
|
||||
expect(toWhatsAppNumber("(07911) 123-456", "GB")).toBe("447911123456");
|
||||
expect(toWhatsAppNumber("0044 7911 123456", "GB")).toBe("447911123456");
|
||||
});
|
||||
|
||||
it("reads a local number as belonging to the business's own country", () => {
|
||||
expect(toWhatsAppNumber("0412 345 678", "AU")).toBe("61412345678");
|
||||
});
|
||||
|
||||
it("keeps an explicit international number even when the business is elsewhere", () => {
|
||||
expect(toWhatsAppNumber("+1 415 555 2671", "GB")).toBe("14155552671");
|
||||
});
|
||||
|
||||
it("accepts a plausible number even in a block the library doesn't list as allocated", () => {
|
||||
// Its tables lag newly issued ranges; hiding the button for a real client would be worse than a dead chat.
|
||||
expect(toWhatsAppNumber("+44 7911 654321", "GB")).toBe("447911654321");
|
||||
});
|
||||
|
||||
it("returns null for blanks and numbers that can't be real", () => {
|
||||
expect(toWhatsAppNumber("", "GB")).toBeNull();
|
||||
expect(toWhatsAppNumber(null, "GB")).toBeNull();
|
||||
expect(toWhatsAppNumber(" ", "GB")).toBeNull();
|
||||
expect(toWhatsAppNumber("12345", "GB")).toBeNull();
|
||||
expect(toWhatsAppNumber("0791", "GB")).toBeNull();
|
||||
expect(toWhatsAppNumber("not a number", "GB")).toBeNull();
|
||||
});
|
||||
|
||||
it("copes with an unusable country code instead of throwing", () => {
|
||||
expect(toWhatsAppNumber("+44 7911 123456", "")).toBe("447911123456");
|
||||
expect(toWhatsAppNumber("07911 123456", "")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whatsAppUrl", () => {
|
||||
it("builds a wa.me link and encodes the message", () => {
|
||||
expect(whatsAppUrl("447700900123")).toBe("https://wa.me/447700900123");
|
||||
const url = whatsAppUrl("447700900123", "Hi Jamie & co!\nSee you");
|
||||
expect(url).toBe("https://wa.me/447700900123?text=Hi%20Jamie%20%26%20co!%0ASee%20you");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messages", () => {
|
||||
const base = {
|
||||
customerFirstName: "Jamie",
|
||||
businessName: "Blake Heritage Barbershop",
|
||||
services: "Skinfade + Beard trim",
|
||||
staffName: "Leo",
|
||||
dateLabel: "Thu 15 Oct",
|
||||
timeLabel: "2:30 PM",
|
||||
};
|
||||
|
||||
it("writes a reminder with the details a customer needs", () => {
|
||||
const message = buildReminderMessage({ ...base, manageUrl: "https://shop.example/appointments/a1" });
|
||||
expect(message).toContain("Hi Jamie");
|
||||
expect(message).toContain("Blake Heritage Barbershop");
|
||||
expect(message).toContain("Skinfade + Beard trim with Leo on Thu 15 Oct at 2:30 PM");
|
||||
expect(message).toContain("https://shop.example/appointments/a1");
|
||||
});
|
||||
|
||||
it("leaves the manage line out when there's no link", () => {
|
||||
expect(buildReminderMessage(base)).not.toContain("Need to change it?");
|
||||
});
|
||||
|
||||
it("has a plain greeting for a client with nothing booked", () => {
|
||||
expect(buildGreetingMessage({ customerFirstName: "Jamie", businessName: "Blake Heritage Barbershop" })).toBe(
|
||||
"Hi Jamie, it's Blake Heritage Barbershop.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
||||
import type { CountryCode } from "libphonenumber-js";
|
||||
|
||||
/**
|
||||
* "Send on WhatsApp" links — the free, manual route. Nothing is sent by the
|
||||
* app: a barber taps a button, WhatsApp opens on their own phone with the
|
||||
* message already typed, and they press send. No WhatsApp Business account,
|
||||
* no API, no per-message cost, and nothing to get approved.
|
||||
*
|
||||
* (The automated route is the paid WhatsApp Business Platform — a
|
||||
* business-started reminder is a chargeable "utility" template there. See
|
||||
* the project notes before building it.)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Turns a phone number as typed ("07700 900123", "+44 7700 900123", "(0)7700…")
|
||||
* into the digits-only international form wa.me wants ("447700900123").
|
||||
* `defaultCountry` is the business's own country — a local-format number is
|
||||
* read as belonging there. Returns null when it can't be a phone number at
|
||||
* all (blank, too short, letters), so callers can hide the button rather
|
||||
* than open a dead link.
|
||||
*/
|
||||
export function toWhatsAppNumber(rawPhone: string | null | undefined, defaultCountry: string): string | null {
|
||||
const trimmed = rawPhone?.trim();
|
||||
if (!trimmed) return null;
|
||||
const country = /^[A-Za-z]{2}$/.test(defaultCountry) ? (defaultCountry.toUpperCase() as CountryCode) : undefined;
|
||||
const parsed = parsePhoneNumberFromString(trimmed, country);
|
||||
// "Possible" (right length and shape for the country), deliberately not the
|
||||
// stricter "valid" (in an allocated number block): the library's tables lag
|
||||
// newly issued ranges, and wrongly hiding the button for a real customer is
|
||||
// worse than opening WhatsApp for a number it then says isn't registered.
|
||||
if (!parsed?.isPossible()) return null;
|
||||
return parsed.number.replace(/^\+/, "");
|
||||
}
|
||||
|
||||
export function whatsAppUrl(digits: string, message?: string): string {
|
||||
const base = `https://wa.me/${digits}`;
|
||||
return message ? `${base}?text=${encodeURIComponent(message)}` : base;
|
||||
}
|
||||
|
||||
export interface ReminderMessageInput {
|
||||
customerFirstName: string;
|
||||
businessName: string;
|
||||
services: string;
|
||||
staffName: string;
|
||||
/** Already formatted in the business's time zone, e.g. "Thu 15 Oct". */
|
||||
dateLabel: string;
|
||||
/** e.g. "2:30 PM". */
|
||||
timeLabel: string;
|
||||
/** Absolute link where they can view or change the booking — optional. */
|
||||
manageUrl?: string;
|
||||
}
|
||||
|
||||
export function buildReminderMessage(input: ReminderMessageInput): string {
|
||||
const lines = [
|
||||
`Hi ${input.customerFirstName}, a reminder of your appointment at ${input.businessName}: ${input.services} with ${input.staffName} on ${input.dateLabel} at ${input.timeLabel}.`,
|
||||
];
|
||||
if (input.manageUrl) lines.push(`Need to change it? ${input.manageUrl}`);
|
||||
lines.push("See you then!");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildGreetingMessage(input: { customerFirstName: string; businessName: string }): string {
|
||||
return `Hi ${input.customerFirstName}, it's ${input.businessName}.`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildImportRows,
|
||||
guessMapping,
|
||||
mappingProblem,
|
||||
splitFullName,
|
||||
validateImportRow,
|
||||
} from "../client-import-rows";
|
||||
|
||||
describe("guessMapping", () => {
|
||||
it("recognises common header names, ignoring case and punctuation", () => {
|
||||
expect(guessMapping(["First_Name", "SURNAME", "E-mail", "Mobile Number"])).toEqual({
|
||||
firstName: 0,
|
||||
lastName: 1,
|
||||
email: 2,
|
||||
phone: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a single Name column as a full name", () => {
|
||||
expect(guessMapping(["Name", "Email", "Phone"])).toEqual({ fullName: 0, email: 1, phone: 2 });
|
||||
});
|
||||
|
||||
it("drops the full-name column when first and last are separate", () => {
|
||||
const mapping = guessMapping(["Client", "First name", "Last name", "Email"]);
|
||||
expect(mapping.fullName).toBeUndefined();
|
||||
expect(mapping.firstName).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves unknown columns unmapped for the user to choose", () => {
|
||||
expect(guessMapping(["Foo", "Bar"])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mappingProblem", () => {
|
||||
it("needs an email and some kind of name", () => {
|
||||
expect(mappingProblem({})).toMatch(/email/i);
|
||||
expect(mappingProblem({ email: 0 })).toMatch(/name/i);
|
||||
expect(mappingProblem({ email: 0, fullName: 1 })).toBeNull();
|
||||
expect(mappingProblem({ email: 0, firstName: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitFullName", () => {
|
||||
it("splits on the first space", () => {
|
||||
expect(splitFullName("Jamie van der Berg")).toEqual({ firstName: "Jamie", lastName: "van der Berg" });
|
||||
expect(splitFullName(" Cher ")).toEqual({ firstName: "Cher", lastName: "" });
|
||||
});
|
||||
|
||||
it("reads \"Surname, Firstname\" the right way round", () => {
|
||||
expect(splitFullName("Brown, Ben")).toEqual({ firstName: "Ben", lastName: "Brown" });
|
||||
expect(splitFullName("van der Berg,Jamie")).toEqual({ firstName: "Jamie", lastName: "van der Berg" });
|
||||
// A trailing comma with nothing after it isn't a Surname, Firstname pair.
|
||||
expect(splitFullName("Brown,")).toEqual({ firstName: "Brown,", lastName: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildImportRows", () => {
|
||||
const mapping = { fullName: 0, email: 1, phone: 2 };
|
||||
|
||||
it("numbers rows the way a spreadsheet does (header is row 1) and skips blank lines", () => {
|
||||
const rows = buildImportRows(
|
||||
[
|
||||
["Jamie Fraser", "jamie@example.com", "07700 900123"],
|
||||
["", "", ""],
|
||||
["Sam Osei", "sam@example.com", ""],
|
||||
],
|
||||
mapping,
|
||||
);
|
||||
expect(rows.map((r) => r.rowNumber)).toEqual([2, 4]);
|
||||
expect(rows[0]).toMatchObject({ firstName: "Jamie", lastName: "Fraser", phone: "07700 900123" });
|
||||
});
|
||||
|
||||
it("prefers separate first and last columns over a full name", () => {
|
||||
const rows = buildImportRows([["Jamie", "Fraser", "j@example.com"]], { firstName: 0, lastName: 1, email: 2 });
|
||||
expect(rows[0]).toMatchObject({ firstName: "Jamie", lastName: "Fraser" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImportRow", () => {
|
||||
const good = { firstName: "Jamie", lastName: "Fraser", email: "Jamie@Example.com ", phone: " 07700 900123 " };
|
||||
|
||||
it("normalises a good row", () => {
|
||||
expect(validateImportRow(good)).toEqual({
|
||||
ok: true,
|
||||
client: { firstName: "Jamie", lastName: "Fraser", email: "jamie@example.com", phone: "07700 900123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a missing last name and a missing phone", () => {
|
||||
const result = validateImportRow({ ...good, lastName: "", phone: "" });
|
||||
expect(result).toMatchObject({ ok: true, client: { lastName: "", phone: null } });
|
||||
});
|
||||
|
||||
it("rejects a missing or malformed email", () => {
|
||||
expect(validateImportRow({ ...good, email: "" })).toMatchObject({ ok: false, reason: expect.stringMatching(/no email/i) });
|
||||
expect(validateImportRow({ ...good, email: "jamie@" })).toMatchObject({ ok: false });
|
||||
expect(validateImportRow({ ...good, email: "not an email" })).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("rejects a row with no name, and over-long values", () => {
|
||||
expect(validateImportRow({ ...good, firstName: " " })).toMatchObject({ ok: false, reason: "No name" });
|
||||
expect(validateImportRow({ ...good, firstName: "x".repeat(101) })).toMatchObject({ ok: false });
|
||||
expect(validateImportRow({ ...good, phone: "1".repeat(31) })).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
import { importClients } from "../customer-import.service";
|
||||
|
||||
let counter = 0;
|
||||
const uniqueEmail = () => `import-${Date.now()}-${++counter}@test.local`;
|
||||
const uniquePhone = () => `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const row = (rowNumber: number, email: string, extra: { phone?: string | null; firstName?: string } = {}) => ({
|
||||
rowNumber,
|
||||
client: { firstName: extra.firstName ?? "Imported", lastName: "Client", email, phone: extra.phone ?? null },
|
||||
});
|
||||
|
||||
describe("importClients (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
let leftoverProfileIds: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
leftoverProfileIds = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const created = await prisma.user.findMany({
|
||||
where: { email: { startsWith: "import-" } },
|
||||
select: { id: true },
|
||||
});
|
||||
business.createdUserIds.push(...created.map((u) => u.id));
|
||||
await cleanupTestBusiness(business);
|
||||
await prisma.customerProfile.deleteMany({ where: { id: { in: leftoverProfileIds } } });
|
||||
});
|
||||
|
||||
it("creates a sign-in-ready client account for each row, without sending anything", async () => {
|
||||
const email = uniqueEmail();
|
||||
const phone = uniquePhone();
|
||||
const outcome = await importClients(prisma, [row(2, email, { phone })]);
|
||||
|
||||
expect(outcome).toEqual({ created: 1, alreadyClients: 0, failed: [] });
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { email }, include: { customerProfile: true } });
|
||||
// Must be verified or requestOtp's onlyIfUserExists would refuse to ever send them a code.
|
||||
expect(user.emailVerified).not.toBeNull();
|
||||
expect(user.customerProfile).toMatchObject({ firstName: "Imported", lastName: "Client", email, phone, isGuest: false });
|
||||
expect(await prisma.notification.count({ where: { recipientCustomerId: user.customerProfile!.id } })).toBe(0);
|
||||
expect(await prisma.emailOtp.count({ where: { email } })).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves an existing account exactly as it is", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const existing = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
|
||||
const outcome = await importClients(prisma, [row(2, existing.email!, { firstName: "Overwritten" })]);
|
||||
|
||||
expect(outcome).toMatchObject({ created: 0, alreadyClients: 1 });
|
||||
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { userId } });
|
||||
expect(profile.firstName).toBe("Test");
|
||||
});
|
||||
|
||||
it("matches existing emails regardless of case", async () => {
|
||||
const { userId } = await createTestCustomer(business);
|
||||
const existing = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const outcome = await importClients(prisma, [row(2, existing.email!.toLowerCase())]);
|
||||
expect(outcome.alreadyClients).toBe(1);
|
||||
});
|
||||
|
||||
it("counts a repeated email within the same file once", async () => {
|
||||
const email = uniqueEmail();
|
||||
const outcome = await importClients(prisma, [row(2, email), row(3, email)]);
|
||||
|
||||
expect(outcome).toMatchObject({ created: 1, alreadyClients: 1, failed: [] });
|
||||
expect(await prisma.user.count({ where: { email } })).toBe(1);
|
||||
});
|
||||
|
||||
it("imports people who share one phone number — only the first account keeps it", async () => {
|
||||
const phone = uniquePhone();
|
||||
const a = uniqueEmail();
|
||||
const b = uniqueEmail();
|
||||
const outcome = await importClients(prisma, [row(2, a, { phone }), row(3, b, { phone })]);
|
||||
|
||||
expect(outcome).toMatchObject({ created: 2, failed: [] });
|
||||
const [userA, userB] = await Promise.all([
|
||||
prisma.user.findUniqueOrThrow({ where: { email: a }, include: { customerProfile: true } }),
|
||||
prisma.user.findUniqueOrThrow({ where: { email: b }, include: { customerProfile: true } }),
|
||||
]);
|
||||
expect(userA.phone).toBe(phone);
|
||||
expect(userB.phone).toBeNull(); // would otherwise violate User.phone's unique constraint
|
||||
expect(userB.customerProfile!.phone).toBe(phone); // but the client record still has it
|
||||
});
|
||||
|
||||
it("imports a phone that an existing account already uses, without failing the row", async () => {
|
||||
const phone = uniquePhone();
|
||||
await prisma.user.create({ data: { email: uniqueEmail(), phone, emailVerified: new Date() } });
|
||||
|
||||
const email = uniqueEmail();
|
||||
const outcome = await importClients(prisma, [row(2, email, { phone })]);
|
||||
|
||||
expect(outcome).toMatchObject({ created: 1, failed: [] });
|
||||
const profile = await prisma.customerProfile.findFirstOrThrow({ where: { email } });
|
||||
expect(profile.phone).toBe(phone);
|
||||
});
|
||||
|
||||
it("attaches an earlier guest booking under the same email to the imported account", async () => {
|
||||
const email = uniqueEmail();
|
||||
const guest = await prisma.customerProfile.create({
|
||||
data: { firstName: "Guest", lastName: "Visitor", email, phone: uniquePhone(), isGuest: true },
|
||||
});
|
||||
leftoverProfileIds.push(guest.id);
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId: guest.id, staffId });
|
||||
|
||||
await importClients(prisma, [row(2, email)]);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { email }, include: { customerProfile: true } });
|
||||
const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } });
|
||||
expect(appointment.customerId).toBe(user.customerProfile!.id);
|
||||
expect(await prisma.customerProfile.findUnique({ where: { id: guest.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("handles an empty batch", async () => {
|
||||
expect(await importClients(prisma, [])).toEqual({ created: 0, alreadyClients: 0, failed: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { isBlankRecord } from "@/lib/csv/parse-csv";
|
||||
|
||||
/**
|
||||
* The pure half of the client import — column guessing, turning a record
|
||||
* into a client, and validating it. No database and no browser APIs, so the
|
||||
* import page runs it to show a live preview AND the server runs the very
|
||||
* same code again on what it receives (the browser is never trusted; this
|
||||
* is shared so the two can't drift apart).
|
||||
*/
|
||||
|
||||
export const MAX_IMPORT_ROWS = 5000;
|
||||
/** Rows sent to the server per request — keeps each call quick and the progress bar honest. */
|
||||
export const IMPORT_CHUNK_SIZE = 100;
|
||||
|
||||
export type ImportField = "firstName" | "lastName" | "fullName" | "email" | "phone";
|
||||
|
||||
/** Which spreadsheet column (by index) feeds each field. */
|
||||
export type ColumnMapping = Partial<Record<ImportField, number>>;
|
||||
|
||||
export interface ImportRowInput {
|
||||
/** The row number as a spreadsheet shows it (the header is row 1). */
|
||||
rowNumber: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface NormalizedClient {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export type RowValidation = { ok: true; client: NormalizedClient } | { ok: false; reason: string };
|
||||
|
||||
const HEADER_ALIASES: Record<ImportField, string[]> = {
|
||||
firstName: ["first name", "firstname", "first", "given name", "forename", "fname"],
|
||||
lastName: ["last name", "lastname", "last", "surname", "family name", "lname"],
|
||||
fullName: ["name", "full name", "fullname", "client", "client name", "customer", "customer name"],
|
||||
email: ["email", "e-mail", "email address", "e-mail address", "mail"],
|
||||
phone: ["phone", "phone number", "mobile", "mobile number", "mobile phone", "telephone", "tel", "cell", "contact number"],
|
||||
};
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.toLowerCase().replace(/[_\-.]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Best guess at which column is which, from the header row. The user can change any of it. */
|
||||
export function guessMapping(headers: string[]): ColumnMapping {
|
||||
const normalized = headers.map(normalizeHeader);
|
||||
const mapping: ColumnMapping = {};
|
||||
const taken = new Set<number>();
|
||||
|
||||
// Most specific fields first, so "Name" never swallows "First name".
|
||||
const order: ImportField[] = ["email", "firstName", "lastName", "phone", "fullName"];
|
||||
for (const field of order) {
|
||||
const aliases = HEADER_ALIASES[field].map(normalizeHeader);
|
||||
const index = normalized.findIndex((header, i) => !taken.has(i) && aliases.includes(header));
|
||||
if (index !== -1) {
|
||||
mapping[field] = index;
|
||||
taken.add(index);
|
||||
}
|
||||
}
|
||||
// Separate first/last columns make a "full name" column redundant.
|
||||
if (mapping.firstName !== undefined && mapping.fullName !== undefined) delete mapping.fullName;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/** Whether the mapping has what an import needs: an email and some kind of name. */
|
||||
export function mappingProblem(mapping: ColumnMapping): string | null {
|
||||
if (mapping.email === undefined) return "Choose which column holds the email address.";
|
||||
if (mapping.firstName === undefined && mapping.fullName === undefined) {
|
||||
return "Choose which column holds the first name (or a full name).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a single name column into first / last. "Surname, Firstname" (a
|
||||
* common export format) is recognised by its comma; otherwise it splits on
|
||||
* the first space, so "Jamie van der Berg" becomes Jamie / van der Berg.
|
||||
*/
|
||||
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
|
||||
const trimmed = fullName.trim().replace(/\s+/g, " ");
|
||||
const comma = trimmed.indexOf(",");
|
||||
if (comma !== -1) {
|
||||
const lastName = trimmed.slice(0, comma).trim();
|
||||
const firstName = trimmed.slice(comma + 1).trim();
|
||||
if (firstName) return { firstName, lastName };
|
||||
}
|
||||
const space = trimmed.indexOf(" ");
|
||||
if (space === -1) return { firstName: trimmed, lastName: "" };
|
||||
return { firstName: trimmed.slice(0, space), lastName: trimmed.slice(space + 1) };
|
||||
}
|
||||
|
||||
/** Turns parsed records (header excluded) into rows, skipping blank lines but keeping true row numbers. */
|
||||
export function buildImportRows(records: string[][], mapping: ColumnMapping): ImportRowInput[] {
|
||||
const cell = (record: string[], field: ImportField) => {
|
||||
const index = mapping[field];
|
||||
return index === undefined ? "" : (record[index] ?? "").trim();
|
||||
};
|
||||
|
||||
const rows: ImportRowInput[] = [];
|
||||
records.forEach((record, i) => {
|
||||
if (isBlankRecord(record)) return;
|
||||
|
||||
let firstName = cell(record, "firstName");
|
||||
let lastName = cell(record, "lastName");
|
||||
if (!firstName) {
|
||||
const split = splitFullName(cell(record, "fullName"));
|
||||
firstName = split.firstName;
|
||||
if (!lastName) lastName = split.lastName;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
rowNumber: i + 2, // +1 for the header row, +1 because spreadsheets count from 1
|
||||
firstName,
|
||||
lastName,
|
||||
email: cell(record, "email"),
|
||||
phone: cell(record, "phone"),
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Not a full RFC 5322 check on purpose — "something@something.tld" with no
|
||||
// spaces catches typos and stray text without rejecting unusual-but-real addresses.
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
export function validateImportRow(row: Pick<ImportRowInput, "firstName" | "lastName" | "email" | "phone">): RowValidation {
|
||||
const email = row.email.trim().toLowerCase();
|
||||
if (!email) return { ok: false, reason: "No email address" };
|
||||
if (email.length > 254 || !EMAIL_PATTERN.test(email)) return { ok: false, reason: `"${row.email.trim()}" isn't a valid email address` };
|
||||
|
||||
const firstName = row.firstName.trim();
|
||||
if (!firstName) return { ok: false, reason: "No name" };
|
||||
const lastName = row.lastName.trim();
|
||||
if (firstName.length > 100 || lastName.length > 100) return { ok: false, reason: "Name is too long" };
|
||||
|
||||
const phone = row.phone.trim();
|
||||
if (phone.length > 30) return { ok: false, reason: "Phone number is too long" };
|
||||
|
||||
return { ok: true, client: { firstName, lastName, email, phone: phone || null } };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { claimGuestProfilesSafely } from "./guest-merge.service";
|
||||
import type { NormalizedClient } from "./client-import-rows";
|
||||
|
||||
/**
|
||||
* Bulk-creates clients from an already-validated spreadsheet — see
|
||||
* client-import-rows.ts for the validation shared with the import page.
|
||||
*
|
||||
* Each row becomes exactly what staff adding one client by hand produces
|
||||
* (createCustomerByStaff in customer.service.ts): a real account with the
|
||||
* email marked verified, so the client can sign in with just an emailed code
|
||||
* and nothing is sent to them now. The email is taken on trust from whoever
|
||||
* runs the import — same as typing it into the add-client form.
|
||||
*
|
||||
* Never overwrites anyone: an email that already has an account is left
|
||||
* exactly as it is and counted as "already a client". If a guest booking
|
||||
* exists under the same email, it's folded into the new account (see
|
||||
* guest-merge.service.ts), same as every other way an account gets created.
|
||||
*/
|
||||
|
||||
export interface ImportClientInput {
|
||||
rowNumber: number;
|
||||
client: NormalizedClient;
|
||||
}
|
||||
|
||||
export interface ImportClientsOutcome {
|
||||
created: number;
|
||||
/** Rows whose email already belongs to an account — left untouched. */
|
||||
alreadyClients: number;
|
||||
failed: { rowNumber: number; email: string; reason: string }[];
|
||||
}
|
||||
|
||||
function uniqueTarget(error: unknown): string | null {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2002") return null;
|
||||
const target = error.meta?.target;
|
||||
return Array.isArray(target) ? target.join(",") : typeof target === "string" ? target : "";
|
||||
}
|
||||
|
||||
export async function importClients(prisma: PrismaClient, rows: ImportClientInput[]): Promise<ImportClientsOutcome> {
|
||||
const outcome: ImportClientsOutcome = { created: 0, alreadyClients: 0, failed: [] };
|
||||
if (rows.length === 0) return outcome;
|
||||
|
||||
const [existingByEmail, existingByPhone] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { email: { in: rows.map((r) => r.client.email) } },
|
||||
select: { email: true },
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: { phone: { in: rows.flatMap((r) => (r.client.phone ? [r.client.phone] : [])) } },
|
||||
select: { phone: true },
|
||||
}),
|
||||
]);
|
||||
const emailsTaken = new Set(existingByEmail.map((u) => u.email?.toLowerCase()));
|
||||
// User.phone is unique; a household often shares one number. The first
|
||||
// client keeps it on their account, later ones keep it only on their
|
||||
// client record — never a failed row over a phone number.
|
||||
const phonesTaken = new Set(existingByPhone.map((u) => u.phone));
|
||||
|
||||
for (const { rowNumber, client } of rows) {
|
||||
if (emailsTaken.has(client.email)) {
|
||||
outcome.alreadyClients++;
|
||||
continue;
|
||||
}
|
||||
emailsTaken.add(client.email); // a repeat later in the same file is "already a client" too
|
||||
|
||||
const accountPhone = client.phone && !phonesTaken.has(client.phone) ? client.phone : null;
|
||||
if (accountPhone) phonesTaken.add(accountPhone);
|
||||
|
||||
const create = (phone: string | null) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
email: client.email,
|
||||
phone,
|
||||
name: `${client.firstName} ${client.lastName}`.trim(),
|
||||
emailVerified: new Date(),
|
||||
customerProfile: {
|
||||
create: {
|
||||
firstName: client.firstName,
|
||||
lastName: client.lastName,
|
||||
email: client.email,
|
||||
phone: client.phone,
|
||||
isGuest: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
try {
|
||||
let user: { id: string };
|
||||
try {
|
||||
user = await create(accountPhone);
|
||||
} catch (error) {
|
||||
// Lost a race on the phone number to a concurrent request — keep the client, drop it from the account.
|
||||
if (accountPhone && uniqueTarget(error)?.includes("phone")) user = await create(null);
|
||||
else throw error;
|
||||
}
|
||||
outcome.created++;
|
||||
await claimGuestProfilesSafely(prisma, user.id);
|
||||
} catch (error) {
|
||||
if (uniqueTarget(error)?.includes("email")) {
|
||||
outcome.alreadyClients++; // created by a concurrent request between our check and our insert
|
||||
} else {
|
||||
console.error(`[ClientImport] row ${rowNumber} failed`, error);
|
||||
outcome.failed.push({ rowNumber, email: client.email, reason: "Couldn't be saved — try this row again" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 {
|
||||
cleanupTestBusiness,
|
||||
createTestAppointment,
|
||||
createTestBusiness,
|
||||
createTestCustomer,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "./fixtures";
|
||||
import type { TestBusiness } from "./fixtures";
|
||||
import { createBooking, rescheduleBooking } from "../booking.service";
|
||||
import { listReminderCandidates, setWhatsAppReminderSent } from "../whatsapp-reminders.service";
|
||||
|
||||
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
|
||||
let date = addDays(todayIn(timezone), minDaysAhead);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (weekdayOf(date) === target) return date;
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
describe("WhatsApp reminders (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
describe("listReminderCandidates", () => {
|
||||
it("returns confirmed bookings on that business-local day, earliest first", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const day = addDays(todayIn(business.timezone), 3);
|
||||
|
||||
const later = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: wallClockToInstant(day, 15 * 60, business.timezone),
|
||||
});
|
||||
const earlier = await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId,
|
||||
startAt: wallClockToInstant(day, 10 * 60, business.timezone),
|
||||
});
|
||||
|
||||
const result = await listReminderCandidates(prisma, {
|
||||
businessId: business.businessId,
|
||||
date: day,
|
||||
businessTimezone: business.timezone,
|
||||
});
|
||||
|
||||
expect(result.map((a) => a.id)).toEqual([earlier.appointmentId, later.appointmentId]);
|
||||
});
|
||||
|
||||
it("leaves out anything that isn't confirmed, on another day, or another barber's when narrowed", async () => {
|
||||
const mine = await createTestStaff(business);
|
||||
const theirs = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const day = addDays(todayIn(business.timezone), 3);
|
||||
const at = (hour: number) => wallClockToInstant(day, hour * 60, business.timezone);
|
||||
|
||||
const wanted = await createTestAppointment(business, { customerId, staffId: mine.staffId, startAt: at(10) });
|
||||
const pending = await createTestAppointment(business, { customerId, staffId: mine.staffId, startAt: at(11) });
|
||||
const cancelled = await createTestAppointment(business, { customerId, staffId: mine.staffId, startAt: at(12) });
|
||||
await createTestAppointment(business, { customerId, staffId: theirs.staffId, startAt: at(13) });
|
||||
await createTestAppointment(business, {
|
||||
customerId,
|
||||
staffId: mine.staffId,
|
||||
startAt: wallClockToInstant(addDays(day, 1), 10 * 60, business.timezone),
|
||||
});
|
||||
await prisma.appointment.update({ where: { id: pending.appointmentId }, data: { status: AppointmentStatus.PENDING } });
|
||||
await prisma.appointment.update({ where: { id: cancelled.appointmentId }, data: { status: AppointmentStatus.CANCELLED } });
|
||||
|
||||
const result = await listReminderCandidates(prisma, {
|
||||
businessId: business.businessId,
|
||||
date: day,
|
||||
businessTimezone: business.timezone,
|
||||
staffId: mine.staffId,
|
||||
});
|
||||
|
||||
expect(result.map((a) => a.id)).toEqual([wanted.appointmentId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWhatsAppReminderSent", () => {
|
||||
it("records when a reminder was sent, and can undo it", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const { appointmentId } = await createTestAppointment(business, { customerId, staffId });
|
||||
|
||||
const now = new Date("2026-10-15T09:30:00.000Z");
|
||||
expect(await setWhatsAppReminderSent(prisma, { appointmentId, sent: true, now })).toEqual(now);
|
||||
expect((await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } })).whatsappReminderSentAt).toEqual(now);
|
||||
|
||||
expect(await setWhatsAppReminderSent(prisma, { appointmentId, sent: false })).toBeNull();
|
||||
expect((await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } })).whatsappReminderSentAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescheduling", () => {
|
||||
it("clears the sent mark — a reminder for the old time says nothing about the new one", async () => {
|
||||
const monday = nextWeekday(business.timezone, Weekday.MONDAY);
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
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");
|
||||
|
||||
await setWhatsAppReminderSent(prisma, { appointmentId: created.appointment.id, sent: true });
|
||||
|
||||
const moved = await rescheduleBooking(prisma, {
|
||||
appointmentId: created.appointment.id,
|
||||
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
|
||||
});
|
||||
expect(moved.success).toBe(true);
|
||||
|
||||
const after = await prisma.appointment.findUniqueOrThrow({ where: { id: created.appointment.id } });
|
||||
expect(after.whatsappReminderSentAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,14 @@ export async function rescheduleAppointment(
|
||||
|
||||
return tx.appointment.update({
|
||||
where: { id: params.appointmentId },
|
||||
data: { staffId: params.staffId, startAt: params.startAt, endAt: params.endAt },
|
||||
data: {
|
||||
staffId: params.staffId,
|
||||
startAt: params.startAt,
|
||||
endAt: params.endAt,
|
||||
// A WhatsApp reminder already sent was about the OLD slot — the
|
||||
// customer needs telling about this one (see the field's doc comment).
|
||||
whatsappReminderSentAt: null,
|
||||
},
|
||||
include: { services: true, staff: { include: { user: true } }, customer: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import { listAppointments } from "./appointment.service";
|
||||
|
||||
/**
|
||||
* The manual WhatsApp reminder list (see lib/messaging/whatsapp.ts for why
|
||||
* it's manual). "Sent" here means a person opened WhatsApp with the message
|
||||
* ready — the app can't see whether they pressed send — and exists purely so
|
||||
* two people sharing the list don't text the same customer twice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The bookings worth a reminder on a given business-local day: CONFIRMED
|
||||
* only. A booking still awaiting approval isn't certain yet, and one already
|
||||
* checked in, started, finished or cancelled needs no reminder.
|
||||
*/
|
||||
export async function listReminderCandidates(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; date: CalendarDate; businessTimezone: string; staffId?: string },
|
||||
) {
|
||||
return listAppointments(prisma, {
|
||||
businessId: params.businessId,
|
||||
staffId: params.staffId,
|
||||
fromDate: params.date,
|
||||
toDate: params.date,
|
||||
businessTimezone: params.businessTimezone,
|
||||
statuses: ["CONFIRMED"],
|
||||
});
|
||||
}
|
||||
|
||||
/** Records (or clears) that this booking's reminder has been sent. Returns the new value. */
|
||||
export async function setWhatsAppReminderSent(
|
||||
prisma: PrismaClient,
|
||||
params: { appointmentId: string; sent: boolean; now?: Date },
|
||||
): Promise<Date | null> {
|
||||
const value = params.sent ? (params.now ?? new Date()) : null;
|
||||
await prisma.appointment.update({
|
||||
where: { id: params.appointmentId },
|
||||
data: { whatsappReminderSentAt: value },
|
||||
});
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user