From 2ed9c5eccafa6ffcb815a92680aee0e73b99fdd8 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Thu, 15 Oct 2026 21:25:09 +0100 Subject: [PATCH] security cleanup, client import, WhatsApp reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 7 + package.json | 1 + .../migration.sql | 2 + prisma/schema.prisma | 9 + .../(owner)/owner/appointments/[id]/page.tsx | 14 + src/app/(owner)/owner/calendar/page.tsx | 5 + .../(staff)/staff/appointments/[id]/page.tsx | 14 + src/app/(staff)/staff/clients/[id]/page.tsx | 25 ++ src/app/(staff)/staff/clients/add/page.tsx | 12 + src/app/(staff)/staff/clients/import/page.tsx | 27 ++ src/app/(staff)/staff/clients/page.tsx | 34 +- src/app/(staff)/staff/current/page.tsx | 14 + src/app/(staff)/staff/page.tsx | 5 + src/app/(staff)/staff/reminders/page.tsx | 139 +++++++ src/app/api/debug/db-connect-test/route.ts | 79 ---- src/app/dev/scheduling-test/page.tsx | 60 --- .../scheduling-test-client.tsx | 300 -------------- src/features/booking/actions/appointment.ts | 49 --- src/features/booking/actions/booking.ts | 56 --- src/features/booking/schemas.ts | 26 -- src/features/staff/actions/client-import.ts | 82 ++++ .../staff/actions/whatsapp-reminders.ts | 56 +++ .../staff/components/client-import-form.tsx | 371 ++++++++++++++++++ .../staff/components/reminders-link.tsx | 50 +++ .../staff/components/whatsapp-button.tsx | 80 ++++ .../staff/components/whatsapp-send-link.tsx | 121 ++++++ .../staff/lib/whatsapp-reminder-link.ts | 53 +++ src/lib/business/get-business-country.ts | 16 + src/lib/csv/__tests__/parse-csv.test.ts | 50 +++ src/lib/csv/parse-csv.ts | 106 +++++ src/lib/messaging/__tests__/whatsapp.test.ts | 75 ++++ src/lib/messaging/whatsapp.ts | 65 +++ .../__tests__/client-import-rows.test.ts | 106 +++++ ...ustomer-import.service.integration.test.ts | 128 ++++++ src/services/customer/client-import-rows.ts | 146 +++++++ .../customer/customer-import.service.ts | 112 ++++++ .../whatsapp-reminders.integration.test.ts | 134 +++++++ src/services/scheduling/scheduling.service.ts | 9 +- .../scheduling/whatsapp-reminders.service.ts | 42 ++ 39 files changed, 2100 insertions(+), 580 deletions(-) create mode 100644 prisma/migrations/20261015120000_add_whatsapp_reminder_sent_at/migration.sql create mode 100644 src/app/(staff)/staff/clients/import/page.tsx create mode 100644 src/app/(staff)/staff/reminders/page.tsx delete mode 100644 src/app/api/debug/db-connect-test/route.ts delete mode 100644 src/app/dev/scheduling-test/page.tsx delete mode 100644 src/app/dev/scheduling-test/scheduling-test-client.tsx delete mode 100644 src/features/booking/actions/appointment.ts delete mode 100644 src/features/booking/actions/booking.ts create mode 100644 src/features/staff/actions/client-import.ts create mode 100644 src/features/staff/actions/whatsapp-reminders.ts create mode 100644 src/features/staff/components/client-import-form.tsx create mode 100644 src/features/staff/components/reminders-link.tsx create mode 100644 src/features/staff/components/whatsapp-button.tsx create mode 100644 src/features/staff/components/whatsapp-send-link.tsx create mode 100644 src/features/staff/lib/whatsapp-reminder-link.ts create mode 100644 src/lib/business/get-business-country.ts create mode 100644 src/lib/csv/__tests__/parse-csv.test.ts create mode 100644 src/lib/csv/parse-csv.ts create mode 100644 src/lib/messaging/__tests__/whatsapp.test.ts create mode 100644 src/lib/messaging/whatsapp.ts create mode 100644 src/services/customer/__tests__/client-import-rows.test.ts create mode 100644 src/services/customer/__tests__/customer-import.service.integration.test.ts create mode 100644 src/services/customer/client-import-rows.ts create mode 100644 src/services/customer/customer-import.service.ts create mode 100644 src/services/scheduling/__tests__/whatsapp-reminders.integration.test.ts create mode 100644 src/services/scheduling/whatsapp-reminders.service.ts diff --git a/package-lock.json b/package-lock.json index f9d1d7e..dbc0086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3c2d63e..11a607d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20261015120000_add_whatsapp_reminder_sent_at/migration.sql b/prisma/migrations/20261015120000_add_whatsapp_reminder_sent_at/migration.sql new file mode 100644 index 0000000..2d5040b --- /dev/null +++ b/prisma/migrations/20261015120000_add_whatsapp_reminder_sent_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Appointment` ADD COLUMN `whatsappReminderSentAt` DATETIME(3) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d30522b..9a7f30a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/(owner)/owner/appointments/[id]/page.tsx b/src/app/(owner)/owner/appointments/[id]/page.tsx index 88af4d5..5bacf8f 100644 --- a/src/app/(owner)/owner/appointments/[id]/page.tsx +++ b/src/app/(owner)/owner/appointments/[id]/page.tsx @@ -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({ >