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
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user