Fixes
CI / ci (push) Canceled after 0s

fixed super admin showing in client list

added a reconnect button to google calendar syncying

fixed ui issue with whatsapp undo link
This commit is contained in:
Stefan Lazic committed 2026-10-17 09:35:52 +01:00
1 parent 2ed9c5ecca
commit 05ea83e059
21 files changed
+1149 -87

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { LEGAL } from "@/features/legal/legal-config";
/**
* Shell for the public legal pages (/terms, /privacy). Deliberately static
* and independent of any business's branding — these documents are the
* platform operator's, and must stay readable by guests, signed-in users
* and (via the proxy's install-exempt list) before installation completes.
*/
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-stone flex min-h-screen flex-col">
<header className="border-line border-b bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-6 py-4">
<Link href="/" className="text-ink flex items-center gap-2 text-sm font-bold">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to {LEGAL.productName}
</Link>
<nav aria-label="Legal" className="text-muted flex gap-5 text-sm">
<Link href="/terms" className="hover:text-ink">
Terms
</Link>
<Link href="/privacy" className="hover:text-ink">
Privacy
</Link>
</nav>
</div>
</header>
<main id="main-content" className="mx-auto w-full max-w-3xl flex-1 px-6 py-10 md:py-14">
{children}
</main>
<footer className="border-line text-muted border-t px-6 py-6 text-center text-xs">
© {new Date().getFullYear()} {LEGAL.legalName}
</footer>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document";
import { LEGAL } from "@/features/legal/legal-config";
export const metadata: Metadata = { title: "Privacy Policy" };
const sections: LegalSectionDef[] = [
{
id: "who-we-are",
title: "Who is responsible for your data",
body: (
<>
<p>
{LEGAL.productName} is a booking platform used by many independent Businesses. Two parties handle your data, in
different roles:
</p>
<ul>
<li>
<strong>The Business you book with</strong> decides why and how your booking details are used for your appointments —
it is an independent <em>controller</em> of that data. It sees your name, contact details and booking history, and may
keep notes about you.
</li>
<li>
<strong>{LEGAL.legalName}</strong> ({LEGAL.address}) runs the Platform. For your account, sign-in and the running and
security of the Platform, we are a controller. For the booking data a Business keeps in the Platform, we act as its{" "}
<em>processor</em>, on its instructions.
</li>
</ul>
<p>
Contact us about privacy at <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a>.
</p>
</>
),
},
{
id: "data-we-collect",
title: "What we collect",
body: (
<>
<p>
<strong>You give us:</strong>
</p>
<ul>
<li>Name, email address and, if you provide it, phone number.</li>
<li>Your bookings: services, staff member, date and time, and any message you add.</li>
<li>Preferences, such as a preferred barber, and notification settings.</li>
<li>A profile picture, if you upload one.</li>
</ul>
<p>
<strong>Created while you use the Platform:</strong>
</p>
<ul>
<li>Appointment history and status (confirmed, cancelled, completed, no-show).</li>
<li>Loyalty points and rewards, if the Business runs a loyalty programme.</li>
<li>Notes a Business keeps about its clients to deliver the service (for example, preferred style).</li>
<li>Sign-in codes (short-lived and stored in hashed form) and session information that keeps you signed in.</li>
<li>A push-notification address for your device, if you switch on push notifications.</li>
</ul>
<p>
<strong>For Staff and owners:</strong> work schedule, services offered and, if connected, the Google Calendar account used
to sync appointments.
</p>
<p>
<strong>Clients added by a Business:</strong> a Business may add or import its existing clients (name, contact details,
history) into the Platform. In that case the Business is responsible for having the right to do so and for telling those
people.
</p>
</>
),
},
{
id: "why",
title: "How and why we use it",
body: (
<ul>
<li>
<strong>To provide the booking service</strong> — create and manage appointments, show availability and sign you in.
<em> Basis:</em> performing our contract with you.
</li>
<li>
<strong>To send booking messages</strong> — confirmations, reminders, changes and cancellations by email, push, or a
WhatsApp message from the Business. <em>Basis:</em> performing the contract, and legitimate interest in reducing missed
appointments.
</li>
<li>
<strong>To keep the Platform safe</strong> — prevent abuse, fraud and unauthorised access, and fix errors.{" "}
<em>Basis:</em> legitimate interest.
</li>
<li>
<strong>To meet legal obligations</strong> — such as accounting, or responding to lawful requests. <em>Basis:</em> legal
obligation.
</li>
<li>
<strong>Optional features</strong> — such as push notifications or Google Calendar sync. <em>Basis:</em> your consent,
which you can withdraw at any time.
</li>
</ul>
),
},
{
id: "sharing",
title: "Who we share it with",
body: (
<>
<ul>
<li>
<strong>The Business you book with</strong>, and its Staff, who need your details to serve you.
</li>
<li>
<strong>Service providers</strong> that run the Platform for us, such as hosting, email delivery and push-notification
services, bound to use the data only for that purpose.
</li>
<li>
<strong>Google</strong>, if a Staff member or owner connects Google Calendar — appointment details are written to their
calendar.
</li>
<li>
<strong>WhatsApp</strong>, if a Business messages you there — your phone number and the message are handled by WhatsApp
under its own terms.
</li>
<li>
<strong>Authorities</strong>, where the law requires it.
</li>
</ul>
<p>We do not sell your personal data and we do not use it for third-party advertising.</p>
</>
),
},
{
id: "cookies",
title: "Cookies and similar technologies",
body: (
<>
<p>We use only what the Platform needs to work:</p>
<ul>
<li>
<strong>Session cookie</strong> — keeps you signed in. Without it you could not use your account.
</li>
<li>
<strong>Local storage and the app cache</strong> — remember small settings and let the installable app load offline.
</li>
</ul>
<p>
These are strictly necessary and do not need consent. We do not use advertising or tracking cookies. If we ever add
analytics, we will update this policy and ask for your consent where the law requires it.
</p>
</>
),
},
{
id: "retention",
title: "How long we keep it",
body: (
<>
<p>
We keep your account and booking data while your account is active and the Business needs it to serve you. Sign-in codes
expire within minutes and are cleared soon after.
</p>
<p>
When you ask us to delete your account we remove or anonymise your data, except what we must keep by law (for example,
records needed for accounting or to resolve a dispute). A Business may keep its own records of past appointments
according to its legal duties.
</p>
</>
),
},
{
id: "security",
title: "Security",
body: (
<p>
We protect your data with encrypted connections, access controls limited to what each role needs, and hashed sign-in codes.
No system is perfectly secure; if a breach affecting you occurs we will notify you and the relevant authority as the law
requires.
</p>
),
},
{
id: "transfers",
title: "International transfers",
body: (
<p>
Some of our providers (for example Google or WhatsApp, when you use those features) may process data outside your
country. Where they do, we rely on appropriate safeguards such as standard contractual clauses, as required by applicable
data protection law.
</p>
),
},
{
id: "rights",
title: "Your rights",
body: (
<>
<p>Depending on where you live (including under the GDPR and equivalent laws), you can:</p>
<ul>
<li>access the personal data we hold about you;</li>
<li>correct data that is wrong or incomplete;</li>
<li>ask us to delete your data;</li>
<li>restrict or object to certain uses, including those based on legitimate interest;</li>
<li>receive your data in a portable format;</li>
<li>withdraw consent at any time, where we rely on it.</li>
</ul>
<p>
Email <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> to exercise any of these. For data a Business
controls, we may pass your request to that Business. We will reply within one month. You also have the right to complain
to your local data protection authority.
</p>
</>
),
},
{
id: "children",
title: "Children",
body: (
<p>
The Platform is not directed at children under 16, and we do not knowingly collect their data. If you believe a child has
given us personal data, contact us and we will delete it.
</p>
),
},
{
id: "changes",
title: "Changes to this policy",
body: (
<p>
We will post any update here with a new &ldquo;last updated&rdquo; date and tell you about significant changes through the
Platform or by email. See also our <Link href="/terms">Terms of Service</Link>.
</p>
),
},
];
export default function PrivacyPage() {
return (
<LegalDocument
title="Privacy Policy"
intro={
<p>
This policy explains what personal data {LEGAL.productName} and the Businesses that use it collect when you book an
appointment, why, and the choices you have. We have tried to keep it plain and specific.
</p>
}
sections={sections}
/>
);
}
+277
View File
@@ -0,0 +1,277 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalDocument, type LegalSectionDef } from "@/features/legal/components/legal-document";
import { LEGAL } from "@/features/legal/legal-config";
export const metadata: Metadata = { title: "Terms of Service" };
const sections: LegalSectionDef[] = [
{
id: "definitions",
title: "Definitions",
body: (
<ul>
<li>
<strong>&ldquo;{LEGAL.productName}&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;</strong> — {LEGAL.legalName}, {LEGAL.address}.
</li>
<li>
<strong>&ldquo;Platform&rdquo;</strong> — the {LEGAL.productName} website, web app and installable app, through which
appointments are booked and managed.
</li>
<li>
<strong>&ldquo;Business&rdquo;</strong> — a salon, barbershop or other service provider that uses the Platform to take
bookings.
</li>
<li>
<strong>&ldquo;Staff&rdquo;</strong> — people who work for a Business and use the Platform to manage schedules and
appointments.
</li>
<li>
<strong>&ldquo;Customer&rdquo;, &ldquo;you&rdquo;</strong> — anyone who books an appointment through the Platform, with or
without an account.
</li>
<li>
<strong>&ldquo;Services&rdquo;</strong> — the haircuts, treatments and other services a Business offers.
</li>
</ul>
),
},
{
id: "about",
title: "About these Terms",
body: (
<>
<p>
These Terms govern your use of the Platform. By creating an account, booking an appointment or otherwise using the
Platform, you agree to them. If you do not agree, please do not use the Platform.
</p>
<p>
You must be at least 16 years old to create an account or book on your own behalf. If you are younger, a parent or
guardian must book for you.
</p>
</>
),
},
{
id: "our-role",
title: "What NextChair does — and doesn’t do",
body: (
<>
<p>
{LEGAL.productName} provides the software that connects Customers with Businesses: viewing services and availability,
booking and managing appointments, receiving reminders and, where a Business enables it, collecting loyalty points.
</p>
<p>
<strong>We are not the provider of the Services.</strong> Each appointment is an agreement between you and the Business.
The Business is responsible for the Services, their quality, their pricing, and any payment taken in person. We are an
intermediary and are not a party to that agreement.
</p>
</>
),
},
{
id: "accounts",
title: "Your account",
body: (
<>
<p>
You can book as a guest where a Business allows it, or create an account. We sign you in with a one-time code sent to
your email address, so there is no password to remember — but you must keep access to that email address secure.
</p>
<ul>
<li>Give accurate contact details so the Business can reach you about your appointment.</li>
<li>You are responsible for activity under your account. Tell us promptly if you think it has been accessed without permission.</li>
<li>One person, one account. Do not share your account or create accounts for others without their knowledge.</li>
</ul>
</>
),
},
{
id: "bookings",
title: "Bookings, changes and cancellations",
body: (
<>
<p>
A booking is confirmed when the Platform shows it as confirmed or you receive a confirmation. Some Businesses approve
bookings manually; in that case the booking is pending until they accept it.
</p>
<ul>
<li>
Each Business sets its own cancellation window, no-show policy and any deposits or fees. These are shown during booking
and apply to your appointment.
</li>
<li>
If you cannot attend, cancel or reschedule as early as you can, through the Platform or by contacting the Business.
</li>
<li>
A Business may need to change or cancel an appointment (for example due to illness). They will contact you and offer a
new time or a cancellation where possible.
</li>
</ul>
</>
),
},
{
id: "payments",
title: "Prices and payments",
body: (
<p>
Prices are set by each Business and shown at booking. Unless the Platform explicitly says otherwise, payment is made to the
Business directly, at the appointment. We do not process payments for Services and are not responsible for refunds or
payment disputes with a Business.
</p>
),
},
{
id: "reminders",
title: "Reminders and notifications",
body: (
<>
<p>
To reduce missed appointments, the Platform and the Business may send you booking confirmations and reminders by email,
push notification, or a message the Business sends you on WhatsApp using the phone number you provided.
</p>
<p>
These are service messages about your booking. You can turn off push notifications in your device or account settings.
Messages sent by a Business through WhatsApp are governed by WhatsApp’s own terms.
</p>
</>
),
},
{
id: "acceptable-use",
title: "Acceptable use",
body: (
<>
<p>You agree not to:</p>
<ul>
<li>make false, spam or repeated bookings you do not intend to keep;</li>
<li>harass, threaten or abuse Staff, Businesses or other Customers;</li>
<li>attempt to access accounts, data or systems that are not yours;</li>
<li>interfere with the Platform, probe it for vulnerabilities without our permission, or use bots or scrapers on it;</li>
<li>use the Platform for anything unlawful.</li>
</ul>
<p>We may suspend or close accounts that break these rules.</p>
</>
),
},
{
id: "reviews",
title: "Reviews and content",
body: (
<p>
Where the Platform lets you leave feedback or upload content such as a profile picture, you keep ownership of it but give
the Business and us a non-exclusive, worldwide licence to display it on the Platform for as long as it is there. It must be
honest, lawful and not infringe anyone’s rights. We may remove content that breaks these Terms.
</p>
),
},
{
id: "ip",
title: "Our intellectual property",
body: (
<p>
The Platform, including its software, design, name and logo, belongs to us or our licensors. You may use it for its
intended purpose, but you may not copy, modify, resell or reverse-engineer it, except as the law allows.
</p>
),
},
{
id: "liability",
title: "Availability and liability",
body: (
<>
<p>
We work to keep the Platform running and accurate, but it is provided &ldquo;as is&rdquo; and we cannot promise it will
always be available or error-free. Maintenance and outages happen.
</p>
<p>
To the extent the law allows, we are not liable for the Services a Business provides, for a Business’s actions or
omissions, or for indirect or consequential losses. Nothing in these Terms limits liability that cannot be limited by law,
including for death or personal injury caused by negligence, or for fraud. Your statutory consumer rights are not
affected.
</p>
</>
),
},
{
id: "privacy",
title: "Your personal data",
body: (
<p>
How we and Businesses handle your personal data is explained in our <Link href="/privacy">Privacy Policy</Link>.
</p>
),
},
{
id: "complaints",
title: "Complaints",
body: (
<>
<p>
<strong>About a Service:</strong> raise it with the Business first — they are responsible for the Service.
</p>
<p>
<strong>About the Platform:</strong> email <a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> with what
happened and, if relevant, the date and Business. We aim to reply within 14 days.
</p>
</>
),
},
{
id: "changes",
title: "Changes and termination",
body: (
<>
<p>
We may update these Terms, for example when we add features or the law changes. We will post the new version here with a
new &ldquo;last updated&rdquo; date and, for significant changes, tell you through the Platform or by email. If you keep
using the Platform after the change, you accept the updated Terms; if you do not agree, you can stop using it and delete
your account.
</p>
<p>
You can stop using the Platform at any time. We may suspend or end your access if you break these Terms or if we are
required to by law.
</p>
</>
),
},
{
id: "law",
title: "Governing law",
body: (
<p>
These Terms are governed by the law of {LEGAL.governingLaw}. This does not remove any mandatory consumer protections you
have in the country where you live.
</p>
),
},
{
id: "contact",
title: "Contact us",
body: (
<p>
{LEGAL.legalName}
<br />
{LEGAL.address}
<br />
<a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a>
</p>
),
},
];
export default function TermsPage() {
return (
<LegalDocument
title="Terms of Service"
intro={
<p>
Welcome to {LEGAL.productName}. These Terms explain the rules for using our booking platform, whether you book
appointments as a Customer or work for a Business. Please read them — they are written to be readable.
</p>
}
sections={sections}
/>
);
}
+8 -14
View File
@@ -11,6 +11,7 @@ import { EmptyState } from "@/components/feedback/empty-state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TopNav } from "@/components/navigation/top-nav"; import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { clientDirectoryWhere } from "@/services/customer/client-directory";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
@@ -52,19 +53,12 @@ export default async function StaffCustomerDetailsPage({
}); });
if (!customer) notFound(); if (!customer) notFound();
// "Belongs to this business" mirrors the Clients list query exactly // The same rule the Clients list uses (clientDirectoryWhere), asked about this
// (see (staff)/staff/clients/page.tsx) — either a real appointment here, // one person — so anyone the list shows opens, and anyone it hides (the
// or a registered client with no appointment anywhere yet (added via // Super Administrator, a barber with no bookings) 404s here too.
// /staff/clients/add, or self-registered but never booked). Without the const inDirectory =
// second branch, a client visible in the list because of that same (await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
// broadening would 404 the moment staff actually clicked into them — if (!inDirectory) notFound();
// confirmed live, this exact gap.
const [appointmentsHereCount, totalAppointmentCount] = await Promise.all([
prisma.appointment.count({ where: { customerId: id, businessId: context.businessId } }),
prisma.appointment.count({ where: { customerId: id } }),
]);
const belongsToBusiness = appointmentsHereCount > 0 || (!customer.isGuest && totalAppointmentCount === 0);
if (!belongsToBusiness) notFound();
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([ const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
listAppointments(prisma, { listAppointments(prisma, {
@@ -220,7 +214,7 @@ export default async function StaffCustomerDetailsPage({
: undefined : undefined
} }
className="min-w-[104px] flex-1" className="min-w-[104px] flex-1"
linkClassName="h-12 w-full justify-center rounded-md px-4 py-0" buttonClassName="h-12 w-full rounded-md"
/> />
<Button asChild size="md" className="min-w-[104px] flex-1"> <Button asChild size="md" className="min-w-[104px] flex-1">
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link> <Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
+8 -24
View File
@@ -9,6 +9,7 @@ import { hasPermission } from "@/lib/auth/permissions";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { clientDirectoryWhere } from "@/services/customer/client-directory";
import { matchesCustomerSearch } from "@/services/customer/customer.service"; import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
@@ -34,31 +35,14 @@ export default async function StaffClientsPage({
hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE), hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE),
]); ]);
// Scoped via the appointment relation, not "every CustomerProfile row" — // Who counts as a client is defined once, in clientDirectoryWhere (shared with
// a real business boundary even though this is a single-business v1 app // the detail page so the two can't disagree). `select`-scoped rather than a
// (see docs/phase-5-summary.md). A registered client with NO appointment // full row + appointment `include`: the card only ever reads status/startAt
// anywhere yet (freshly added via /staff/clients/add, or self-registered // off each appointment, never the service lines/notes/discount a full
// but never booked) is also included — otherwise a client staff just // include would pull down for every appointment of every client on every
// added would be invisible here until their first booking, which reads // page view (see the Phase 7 performance pass).
// as "did that even work?". A registered client with appointments only
// at some other business (not reachable in this single-business-per-
// deployment app, but kept as a real predicate rather than an assumption)
// is correctly excluded either way. Guests are never included by this
// second branch — a guest profile is only ever created alongside an
// appointment (see resolveGuestCustomer), so one with zero appointments
// shouldn't exist, but isGuest: false is there as a real guard, not a
// guess. `select`-scoped rather than the full row + full appointment
// `include` this used to fetch — the card only ever reads status/startAt
// off each appointment, never the service lines/notes/discount that a
// full include would also pull down for every appointment of every
// client on every page view (see the Phase 7 performance pass).
const customers = await prisma.customerProfile.findMany({ const customers = await prisma.customerProfile.findMany({
where: { where: clientDirectoryWhere(context.businessId),
OR: [
{ appointments: { some: { businessId: context.businessId } } },
{ isGuest: false, appointments: { none: {} } },
],
},
select: { select: {
id: true, id: true,
firstName: true, firstName: true,
@@ -42,7 +42,13 @@ export default async function StaffCalendarSettingsPage() {
lastSyncAt: connection.lastSyncAt?.toISOString(), lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError, lastSyncError: connection.lastSyncError,
} }
: { connected: false } : {
connected: false,
// Revoked WITH a recorded reason = it broke by itself (a deliberate
// disconnect clears the error), so say so and offer Reconnect.
needsReconnect: !!connection?.revokedAt && !!connection.lastSyncError,
lastSyncError: connection?.lastSyncError,
}
} }
/> />
</main> </main>
@@ -119,13 +119,13 @@ export function RegisterForm() {
<p className="text-muted px-1 text-[10px] leading-relaxed"> <p className="text-muted px-1 text-[10px] leading-relaxed">
By creating an account, you agree to our{" "} By creating an account, you agree to our{" "}
<a href="#" className="underline"> <Link href="/terms" className="underline">
Terms of Service Terms of Service
</a>{" "} </Link>{" "}
and{" "} and{" "}
<a href="#" className="underline"> <Link href="/privacy" className="underline">
Privacy Policy Privacy Policy
</a> </Link>
. .
</p> </p>
+8 -1
View File
@@ -38,7 +38,7 @@ export async function disconnectCalendarAction(): Promise<CalendarActionResult>
// appointment changes have nothing left to sync against. // appointment changes have nothing left to sync against.
await prisma.staffCalendarConnection.update({ await prisma.staffCalendarConnection.update({
where: { staffId }, where: { staffId },
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null }, data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null, lastSyncError: null },
}); });
return { success: true }; return { success: true };
} }
@@ -50,6 +50,13 @@ export async function syncNowAction(): Promise<CalendarActionResult> {
return { success: false, error: "Connect Google Calendar first." }; return { success: false, error: "Connect Google Calendar first." };
} }
await runInitialSync(prisma, staffId); await runInitialSync(prisma, staffId);
// runInitialSync never throws (failures are recorded on the connection), so
// look at what it recorded rather than reporting "complete" regardless.
const after = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
if (after?.revokedAt || after?.lastSyncError) {
return { success: false, error: after.lastSyncError ?? "Google Calendar needs to be reconnected." };
}
return { success: true }; return { success: true };
} }
@@ -16,6 +16,12 @@ export interface ConnectionState {
selectedCalendarName?: string; selectedCalendarName?: string;
lastSyncAt?: string; lastSyncAt?: string;
lastSyncError?: string | null; lastSyncError?: string | null;
/**
* The connection died on its own (Google refused our refresh token) rather
* than being disconnected on purpose — shown as a warning with a Reconnect
* button, because syncing has silently stopped and nothing else says so.
*/
needsReconnect?: boolean;
} }
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) { export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
@@ -68,6 +74,17 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
return ( return (
<div className="border-line rounded-md border bg-white p-5"> <div className="border-line rounded-md border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p> <p className="mb-1 text-sm font-bold">Google Calendar</p>
{state.needsReconnect && (
<div role="alert" className="border-danger/30 bg-danger/5 mb-4 rounded-lg border p-3 text-xs">
<p className="text-danger font-bold">Syncing has stopped</p>
<p className="text-ink mt-1">
{state.lastSyncError ?? "Your Google Calendar connection has expired."}{" "}
Appointments you book or change
won&apos;t appear in Google Calendar until you reconnect — anything upcoming is caught up automatically
when you do.
</p>
</div>
)}
<p className="text-muted mb-4 text-xs"> <p className="text-muted mb-4 text-xs">
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
from sign-in — you can use a different one if you like. from sign-in — you can use a different one if you like.
@@ -75,7 +92,9 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>} {message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
{configured ? ( {configured ? (
<Button asChild size="sm"> <Button asChild size="sm">
<a href="/api/staff/calendar/google/authorize">Connect Google Calendar</a> <a href="/api/staff/calendar/google/authorize">
{state.needsReconnect ? "Reconnect Google Calendar" : "Connect Google Calendar"}
</a>
</Button> </Button>
) : ( ) : (
<p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p> <p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p>
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
import { LEGAL } from "../legal-config";
export interface LegalSectionDef {
id: string;
title: string;
body: ReactNode;
}
/**
* Shared shell for the Terms of Service and Privacy Policy: title, last
* updated date, a jump-to table of contents, then numbered sections. Pure
* presentation — the wording lives in each page.
*/
export function LegalDocument({
title,
intro,
sections,
}: {
title: string;
intro: ReactNode;
sections: LegalSectionDef[];
}) {
return (
<article>
<h1 className="text-ink text-3xl font-bold tracking-tight md:text-4xl">{title}</h1>
<p className="text-muted mt-2 text-sm">Last updated {LEGAL.lastUpdated}</p>
<div className="text-ink mt-6 space-y-3 leading-relaxed">{intro}</div>
<nav aria-label="Contents" className="border-line mt-8 rounded-tile border bg-white p-5">
<p className="text-ink mb-3 text-sm font-bold">Contents</p>
<ol className="text-muted grid gap-x-8 gap-y-1.5 text-sm sm:grid-cols-2">
{sections.map((section, i) => (
<li key={section.id}>
<a href={`#${section.id}`} className="hover:text-accent-foreground hover:underline">
{i + 1}. {section.title}
</a>
</li>
))}
</ol>
</nav>
<div className="mt-10 space-y-10">
{sections.map((section, i) => (
<section key={section.id} id={section.id} className="scroll-mt-24">
<h2 className="text-ink mb-3 text-xl font-bold tracking-tight">
{i + 1}. {section.title}
</h2>
<div className="text-ink space-y-3 leading-relaxed [&_a]:text-accent-foreground [&_a]:underline [&_li]:pl-1 [&_ul]:list-disc [&_ul]:space-y-1.5 [&_ul]:pl-6">
{section.body}
</div>
</section>
))}
</div>
</article>
);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Single source of truth for the facts the Terms of Service and Privacy
* Policy pages state about the operator. Every value marked TODO is a
* placeholder — fill it in (and have the documents reviewed by a lawyer)
* before launch.
*/
export const LEGAL = {
productName: "NextChair",
/** TODO: registered legal entity name. */
legalName: "NextChair [LEGAL ENTITY NAME]",
/** TODO: registered address. */
address: "[REGISTERED ADDRESS]",
/** TODO: monitored inbox for legal and privacy requests. */
contactEmail: "legal@nextchair.example",
/** TODO: jurisdiction whose law governs the Terms and whose courts hear disputes. */
governingLaw: "[COUNTRY]",
/** Bump whenever either document changes materially. */
lastUpdated: "17 October 2026",
} as const;
@@ -21,7 +21,7 @@ export async function WhatsAppButton({
customer, customer,
appointment, appointment,
className, className,
linkClassName, buttonClassName,
}: { }: {
businessId: string; businessId: string;
businessName: string; businessName: string;
@@ -36,8 +36,8 @@ export async function WhatsAppButton({
}; };
/** Wrapper sizing (e.g. flex-1 in a row of buttons). */ /** Wrapper sizing (e.g. flex-1 in a row of buttons). */
className?: string; className?: string;
/** The link's own shape (height, radius, width). */ /** The button's own shape (height, radius, width). */
linkClassName?: string; buttonClassName?: string;
}) { }) {
const href = reminderWhatsAppUrl({ const href = reminderWhatsAppUrl({
country: await getBusinessCountry(businessId), country: await getBusinessCountry(businessId),
@@ -57,7 +57,7 @@ export async function WhatsAppButton({
timezone={timezone} timezone={timezone}
customerFirstName={customer.firstName} customerFirstName={customer.firstName}
className={className} className={className}
linkClassName={linkClassName} buttonClassName={buttonClassName}
/> />
); );
} }
@@ -69,9 +69,9 @@ export async function WhatsAppButton({
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={`Message ${customer.firstName} on WhatsApp`} aria-label={`Message ${customer.firstName} on WhatsApp`}
className={cn( className={cn(
"border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold", "border-line flex items-center justify-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold",
className, className,
linkClassName, buttonClassName,
)} )}
> >
<MessageCircle className="text-accent h-3.5 w-3.5" aria-hidden="true" /> WhatsApp <MessageCircle className="text-accent h-3.5 w-3.5" aria-hidden="true" /> WhatsApp
@@ -14,6 +14,11 @@ import { markWhatsAppReminderAction } from "../actions/whatsapp-reminders";
* "Sent" means "opened WhatsApp for this booking", not "message delivered": * "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 * 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. * link (to resend or fix a typo) and an Undo.
*
* It is ONE pill in every state, Undo included, so it stays the same height
* and on the same line as the buttons around it (Call, Email, ...). An
* earlier version put Undo underneath and swapped the label for a bare
* "Sent 09:18", which threw the whole row out of line.
*/ */
export function WhatsAppSendLink({ export function WhatsAppSendLink({
href, href,
@@ -23,7 +28,7 @@ export function WhatsAppSendLink({
customerFirstName, customerFirstName,
variant = "button", variant = "button",
className, className,
linkClassName, buttonClassName,
}: { }: {
href: string; href: string;
appointmentId: string; appointmentId: string;
@@ -35,8 +40,8 @@ export function WhatsAppSendLink({
variant?: "button" | "row"; variant?: "button" | "row";
/** Applied to the wrapper (sizing within a flex row, etc.). */ /** Applied to the wrapper (sizing within a flex row, etc.). */
className?: string; className?: string;
/** Applied to the link itself (height, radius, width). */ /** Applied to the pill itself (height, radius, width). */
linkClassName?: string; buttonClassName?: string;
}) { }) {
const [sentAt, setSentAt] = useState<string | null>(initialSentAt); const [sentAt, setSentAt] = useState<string | null>(initialSentAt);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,13 +66,21 @@ export function WhatsAppSendLink({
? new Date(sentAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }) ? new Date(sentAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone })
: null; : null;
const base = const isRow = variant === "row";
variant === "row" const radius = isRow ? "rounded-md" : "rounded-lg";
? "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 ( return (
<div className={cn("flex flex-col items-start gap-1", variant === "row" && "items-end", className)}> <div className={cn("flex flex-col items-start gap-1", className)}>
<div
className={cn(
"flex items-stretch overflow-hidden border text-xs font-bold",
radius,
isRow && "h-11",
// Unsent in the list is the call to action: filled green. Everywhere else it's a plain button like its neighbours.
!sentAt && isRow ? "border-accent bg-accent text-white" : "border-line text-ink bg-white",
buttonClassName,
)}
>
<a <a
href={href} href={href}
target="_blank" target="_blank"
@@ -82,23 +95,23 @@ export function WhatsAppSendLink({
: `Send ${customerFirstName} a WhatsApp reminder` : `Send ${customerFirstName} a WhatsApp reminder`
} }
className={cn( className={cn(
base, "flex flex-1 items-center justify-center gap-2 whitespace-nowrap",
sentAt ? "border-line text-muted bg-white" : "border-accent bg-accent text-white", isRow ? "px-4" : sentAt ? "px-3 py-2" : "px-4 py-2", // a touch tighter once sent, so the longer label still fits beside its neighbours
variant === "button" && !sentAt && "border-line bg-white text-ink", sentAt && "text-muted",
linkClassName,
)} )}
> >
{sentAt ? ( {sentAt ? (
<> <>
<Check className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Sent {sentLabel} <Check className="text-accent h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{isRow ? `Sent ${sentLabel}` : `WhatsApp · sent ${sentLabel}`}
</> </>
) : ( ) : (
<> <>
<MessageCircle <MessageCircle
className={cn("h-3.5 w-3.5", variant === "button" ? "text-accent" : "text-white")} className={cn("h-3.5 w-3.5 shrink-0", isRow ? "text-white" : "text-accent")}
aria-hidden="true" aria-hidden="true"
/> />
{variant === "row" ? "Send on WhatsApp" : "WhatsApp"} {isRow ? "Send on WhatsApp" : "WhatsApp"}
</> </>
)} )}
</a> </a>
@@ -106,11 +119,13 @@ export function WhatsAppSendLink({
<button <button
type="button" type="button"
onClick={() => record(false)} onClick={() => record(false)}
className="text-muted text-[10px] font-bold tracking-widest uppercase underline" aria-label={`Undo: mark the WhatsApp reminder to ${customerFirstName} as not sent`}
className="border-line text-muted hover:text-ink hover:bg-stone border-l px-2.5 font-bold transition-colors"
> >
Undo Undo
</button> </button>
)} )}
</div>
{error && ( {error && (
<p role="alert" className="text-danger text-[10px] font-medium"> <p role="alert" className="text-danger text-[10px] font-medium">
{error} {error}
+2
View File
@@ -23,6 +23,8 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"];
const INSTALL_EXEMPT_PREFIXES = [ const INSTALL_EXEMPT_PREFIXES = [
"/install", "/install",
"/offline", "/offline",
"/terms",
"/privacy",
"/manifest.webmanifest", "/manifest.webmanifest",
"/icon.png", "/icon.png",
"/apple-icon.png", "/apple-icon.png",
@@ -10,6 +10,7 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums"; import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { encryptToken } from "@/lib/security/token-encryption"; import { encryptToken } from "@/lib/security/token-encryption";
import { MockCalendarProvider } from "../mock-calendar.provider"; import { MockCalendarProvider } from "../mock-calendar.provider";
import { CalendarProviderError } from "../types";
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service"; import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
/** /**
@@ -184,4 +185,83 @@ describe("calendar-sync.service (integration)", () => {
expect(event).toBeNull(); expect(event).toBeNull();
expect(provider.events.size).toBe(0); expect(provider.events.size).toBe(0);
}); });
describe("when Google refuses the connection", () => {
// A provider whose token refresh fails the way Google's does once a
// refresh token has expired or been revoked (HTTP 400 invalid_grant).
class RevokedProvider extends MockCalendarProvider {
refreshCalls = 0;
override async refreshAccessToken(): Promise<never> {
this.refreshCalls++;
throw new CalendarProviderError(
"TOKEN_REVOKED",
"Your Google Calendar connection has expired or was revoked. Reconnect it to resume syncing.",
false,
"400 · invalid_grant",
);
}
}
async function connectWithExpiredAccessToken() {
const id = await connectCalendar();
await prisma.staffCalendarConnection.update({
where: { id },
data: { tokenExpiresAt: new Date(Date.now() - 60_000) }, // forces a refresh, as happens an hour after every connect
});
return id;
}
it("marks the connection revoked and records the reason, so the settings page can offer Reconnect", async () => {
const connectionId = await connectWithExpiredAccessToken();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, new RevokedProvider());
const connection = await prisma.staffCalendarConnection.findUniqueOrThrow({ where: { id: connectionId } });
expect(connection.revokedAt).not.toBeNull();
expect(connection.encryptedAccessToken).toBeNull();
expect(connection.lastSyncError).toMatch(/expired or was revoked/i);
expect(connection.lastSyncError).not.toMatch(/rejected this event/i);
});
it("stops after the first refusal instead of retrying every upcoming appointment", async () => {
await connectWithExpiredAccessToken();
await createAppointment(AppointmentStatus.CONFIRMED);
await createAppointment(AppointmentStatus.CONFIRMED);
await createAppointment(AppointmentStatus.CONFIRMED);
const revoked = new RevokedProvider();
await runInitialSync(prisma, staffId, revoked);
expect(revoked.refreshCalls).toBe(1);
});
it("skips later appointment changes entirely once the connection is revoked", async () => {
await connectWithExpiredAccessToken();
const first = await createAppointment(AppointmentStatus.CONFIRMED);
const second = await createAppointment(AppointmentStatus.CONFIRMED);
const revoked = new RevokedProvider();
await syncAppointmentSafely(prisma, first.id, revoked);
await syncAppointmentSafely(prisma, second.id, revoked);
expect(revoked.refreshCalls).toBe(1);
});
it("leaves the connection connected after an ordinary event failure — only the error is recorded", async () => {
const connectionId = await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
class RejectingProvider extends MockCalendarProvider {
override async createEvent(): Promise<never> {
throw new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event (invalid).", false, "400 · invalid");
}
}
await syncAppointmentSafely(prisma, appointment.id, new RejectingProvider());
const connection = await prisma.staffCalendarConnection.findUniqueOrThrow({ where: { id: connectionId } });
expect(connection.revokedAt).toBeNull();
expect(connection.lastSyncError).toBe("Google Calendar rejected this event (invalid).");
});
});
}); });
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GoogleCalendarProvider, mapGoogleError } from "../google-calendar.provider";
import { CalendarProviderError } from "../types";
describe("mapGoogleError", () => {
it("treats the token endpoint's invalid_grant as a dead connection, not a bad event", () => {
// Google sends HTTP 400 for this — the case that used to read "rejected this event".
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
expect(error.code).toBe("TOKEN_REVOKED");
expect(error.retryable).toBe(false);
expect(error.message).toMatch(/expired or was revoked/i);
expect(error.message).toMatch(/reconnect/i);
expect(error.detail).toContain("invalid_grant");
expect(error.detail).toContain("Token has been expired or revoked.");
});
it("points at the server's Google credentials for invalid_client / unauthorized_client", () => {
for (const code of ["invalid_client", "unauthorized_client"]) {
const error = mapGoogleError(401, { error: code });
expect(error.code).toBe("UNKNOWN");
expect(error.retryable).toBe(false);
expect(error.message).toMatch(/administrator/i);
}
});
it("still reports a genuinely bad event as one, now with Google's reason", () => {
const error = mapGoogleError(400, { error: { message: "The specified time range is empty.", errors: [{ reason: "timeRangeEmpty" }] } });
expect(error.code).toBe("INVALID_EVENT");
expect(error.retryable).toBe(false);
expect(error.message).toBe("Google Calendar rejected this event (timeRangeEmpty).");
expect(error.detail).toContain("timeRangeEmpty");
});
it("omits the reason brackets when Google gave none", () => {
expect(mapGoogleError(400, undefined).message).toBe("Google Calendar rejected this event.");
});
it("keeps the other mappings", () => {
expect(mapGoogleError(401, { error: { errors: [{ reason: "authError" }] } }).code).toBe("TOKEN_EXPIRED");
expect(mapGoogleError(403, { error: { errors: [{ reason: "insufficientPermissions" }] } }).code).toBe("CALENDAR_NOT_WRITABLE");
expect(mapGoogleError(403, { error: { errors: [{ reason: "rateLimitExceeded" }] } }).code).toBe("RATE_LIMITED");
expect(mapGoogleError(429, undefined).retryable).toBe(true);
expect(mapGoogleError(404, undefined).code).toBe("CALENDAR_NOT_FOUND");
expect(mapGoogleError(503, undefined)).toMatchObject({ code: "PROVIDER_OUTAGE", retryable: true });
expect(mapGoogleError(418, undefined).code).toBe("UNKNOWN");
});
it("treats 410 Gone (an already-deleted event) like not found", () => {
expect(mapGoogleError(410, undefined).code).toBe("CALENDAR_NOT_FOUND");
});
it("never puts the response body or any token in the user-facing message", () => {
const error = mapGoogleError(400, { error: "invalid_grant", error_description: "secret-detail-xyz" });
expect(error.message).not.toContain("secret-detail-xyz");
});
});
describe("GoogleCalendarProvider against a failing Google", () => {
const original = { id: process.env.GOOGLE_CALENDAR_CLIENT_ID, secret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET };
beforeEach(() => {
process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id";
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = "test-client-secret";
});
afterEach(() => {
vi.unstubAllGlobals();
process.env.GOOGLE_CALENDAR_CLIENT_ID = original.id;
process.env.GOOGLE_CALENDAR_CLIENT_SECRET = original.secret;
});
const respondWith = (status: number, body: unknown) =>
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
it("an expired refresh token surfaces as TOKEN_REVOKED when refreshing", async () => {
respondWith(400, { error: "invalid_grant", error_description: "Token has been expired or revoked." });
const failure = await new GoogleCalendarProvider().refreshAccessToken("old-refresh-token").catch((e: unknown) => e);
expect(failure).toBeInstanceOf(CalendarProviderError);
expect(failure).toMatchObject({ code: "TOKEN_REVOKED", retryable: false });
});
it("deleting an event Google already removed (410) is not an error", async () => {
respondWith(410, { error: { message: "Resource has been deleted", errors: [{ reason: "deleted" }] } });
await expect(
new GoogleCalendarProvider().deleteEvent({ accessToken: "t", calendarId: "primary", providerEventId: "gone" }),
).resolves.toBeUndefined();
});
});
+33 -7
View File
@@ -96,7 +96,7 @@ async function syncOneConnection(
}; };
provider: CalendarProvider; provider: CalendarProvider;
}, },
): Promise<void> { ): Promise<"ok" | "revoked"> {
const appointment = await prisma.appointment.findUnique({ const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId }, where: { id: params.appointmentId },
include: { include: {
@@ -105,7 +105,7 @@ async function syncOneConnection(
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } }, business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
}, },
}); });
if (!appointment) return; if (!appointment) return "ok";
const provider = params.provider; const provider = params.provider;
const existing = await prisma.appointmentCalendarEvent.findUnique({ const existing = await prisma.appointmentCalendarEvent.findUnique({
@@ -113,7 +113,7 @@ async function syncOneConnection(
}); });
try { try {
if (appointment.status === AppointmentStatus.PENDING) return; if (appointment.status === AppointmentStatus.PENDING) return "ok";
const accessToken = await getValidAccessToken(prisma, params.connection, provider); const accessToken = await getValidAccessToken(prisma, params.connection, provider);
@@ -125,7 +125,7 @@ async function syncOneConnection(
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null }, data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
}); });
} }
return; return "ok";
} }
const event = buildEventContent(appointment); const event = buildEventContent(appointment);
@@ -163,14 +163,37 @@ async function syncOneConnection(
} }
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } }); await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } });
return "ok";
} catch (error) { } catch (error) {
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly."; const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
// The user only ever sees `message`; the server log gets what's needed to
// diagnose it (Google's status and reason — never tokens or the payload).
console.error(
`[CalendarSync] appointment ${params.appointmentId} failed:`,
error instanceof CalendarProviderError ? `${error.code}${error.detail ? ` (${error.detail})` : ""}` : error,
);
const revoked = error instanceof CalendarProviderError && error.code === "TOKEN_REVOKED";
await prisma.staffCalendarConnection
.update({
where: { id: params.connection.id },
// A dead refresh token can never work again, so stop pretending to be
// connected: mark it revoked (the settings page then offers
// "Reconnect", and every later sync short-circuits instead of
// hammering Google with a token it has already refused) and keep the
// reason so the page can explain why.
data: revoked
? { lastSyncError: message, revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null }
: { lastSyncError: message },
})
.catch(() => {});
if (existing) { if (existing) {
await prisma.appointmentCalendarEvent await prisma.appointmentCalendarEvent
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } }) .update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
.catch(() => {}); .catch(() => {});
} }
return revoked ? "revoked" : "ok";
} }
} }
@@ -250,8 +273,11 @@ export async function runInitialSync(
select: { id: true }, select: { id: true },
}); });
let synced = 0;
for (const appointment of appointments) { for (const appointment of appointments) {
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider }); const outcome = await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
if (outcome === "revoked") break; // every remaining one would fail the same way
synced++;
} }
return { synced: appointments.length }; return { synced };
} }
@@ -20,34 +20,73 @@ import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteC
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" "); const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
interface GoogleErrorBody { export interface GoogleErrorBody {
error?: string | { message?: string; errors?: { reason?: string }[] }; error?: string | { message?: string; errors?: { reason?: string }[] };
error_description?: string; error_description?: string;
} }
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError { /**
const reason = * Turns a failed Google call into one of our own errors. Google reports
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined; * failures in two different shapes: the Calendar API sends
* `{ error: { message, errors: [{ reason }] } }`, but the OAuth token
* endpoint sends `{ error: "invalid_grant", error_description }` — and BOTH
* use HTTP 400 for very different problems. Treating every 400 as "bad
* event" (as this once did) made an expired or revoked connection look like
* Google disliked an appointment, which sent people hunting in the wrong
* place. The token-endpoint codes are therefore checked first.
*/
export function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
const oauthError = typeof body?.error === "string" ? body.error : undefined;
const reason = typeof body?.error === "object" ? body.error.errors?.[0]?.reason : undefined;
const googleMessage = typeof body?.error === "object" ? body.error.message : body?.error_description;
const detail = [status, oauthError ?? reason, googleMessage].filter(Boolean).join(" · ");
// The refresh token no longer works: the user revoked access, changed their
// password, or — the common one — the app's Google consent screen is still
// in "Testing", where Google expires refresh tokens after 7 days. Retrying
// can never help; only reconnecting can.
if (oauthError === "invalid_grant") {
return new CalendarProviderError(
"TOKEN_REVOKED",
"Your Google Calendar connection has expired or was revoked. Reconnect it to resume syncing.",
false,
detail,
);
}
if (oauthError === "invalid_client" || oauthError === "unauthorized_client") {
return new CalendarProviderError(
"UNKNOWN",
"Google Calendar sign-in isn't set up correctly on this server. Ask your administrator to check the Google credentials.",
false,
detail,
);
}
if (status === 401 || reason === "authError") { if (status === 401 || reason === "authError") {
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true); return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true, detail);
} }
if (status === 403 && reason === "insufficientPermissions") { if (status === 403 && reason === "insufficientPermissions") {
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false); return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false, detail);
} }
if (status === 403 || status === 429) { if (status === 403 || status === 429) {
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true); return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true, detail);
} }
if (status === 404) { // 410 is Google's "this event was already deleted" — same outcome as not found.
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false); if (status === 404 || status === 410) {
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false, detail);
} }
if (status === 400) { if (status === 400) {
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false); return new CalendarProviderError(
"INVALID_EVENT",
`Google Calendar rejected this event${reason ? ` (${reason})` : ""}.`,
false,
detail,
);
} }
if (status >= 500) { if (status >= 500) {
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true); return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true, detail);
} }
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true); return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true, detail);
} }
async function googleFetch(url: string, init: RequestInit): Promise<Response> { async function googleFetch(url: string, init: RequestInit): Promise<Response> {
+9 -1
View File
@@ -51,11 +51,19 @@ export type CalendarProviderErrorCode =
export class CalendarProviderError extends Error { export class CalendarProviderError extends Error {
code: CalendarProviderErrorCode; code: CalendarProviderErrorCode;
retryable: boolean; retryable: boolean;
/**
* Short diagnostic for the SERVER LOG only — HTTP status, the provider's
* error code and its one-line message (e.g. "400 · invalid_grant · Token
* has been expired or revoked"). Never contains tokens or the request
* body, and is never shown to a user: `message` is what the UI displays.
*/
detail?: string;
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) { constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean, detail?: string) {
super(message); super(message);
this.name = "CalendarProviderError"; this.name = "CalendarProviderError";
this.code = code; this.code = code;
this.retryable = retryable; this.retryable = retryable;
this.detail = detail;
} }
} }
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestAppointment,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { clientDirectoryWhere } from "../client-directory";
describe("clientDirectoryWhere (integration)", () => {
let business: TestBusiness;
let leftoverProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
leftoverProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
await prisma.customerProfile.deleteMany({ where: { id: { in: leftoverProfileIds } } });
});
const inDirectory = async (profileId: string) =>
(await prisma.customerProfile.count({ where: { AND: [{ id: profileId }, clientDirectoryWhere(business.businessId)] } })) > 0;
async function userWithProfile(opts: { isSuperAdmin?: boolean } = {}) {
const email = `dir-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`;
const user = await prisma.user.create({
data: {
email,
name: "Some Person",
emailVerified: new Date(),
isSuperAdmin: opts.isSuperAdmin ?? false,
customerProfile: { create: { firstName: "Some", lastName: "Person", email, isGuest: false } },
},
include: { customerProfile: true },
});
business.createdUserIds.push(user.id);
return { userId: user.id, profileId: user.customerProfile!.id };
}
it("includes a registered client who has no bookings yet (added by staff, or imported)", async () => {
const { profileId } = await userWithProfile();
expect(await inDirectory(profileId)).toBe(true);
});
it("includes a guest who has booked — a guest has no linked user, which a negated relation filter would silently drop", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestGuestCustomer();
leftoverProfileIds.push(customerId);
await createTestAppointment(business, { customerId, staffId });
expect(await inDirectory(customerId)).toBe(true);
});
it("never includes the Super Administrator — even with a stray client record", async () => {
const { profileId } = await userWithProfile({ isSuperAdmin: true });
expect(await inDirectory(profileId)).toBe(false);
});
it("never includes the Super Administrator even if they somehow have a booking", async () => {
const { staffId } = await createTestStaff(business);
const { profileId } = await userWithProfile({ isSuperAdmin: true });
await createTestAppointment(business, { customerId: profileId, staffId });
expect(await inDirectory(profileId)).toBe(false);
});
it("leaves out a staff member whose only client record came from using a customer page", async () => {
const { userId } = await createTestStaff(business);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const profile = await prisma.customerProfile.create({
data: { userId, firstName: "Barber", lastName: "Person", email: user.email ?? "", isGuest: false },
});
expect(await inDirectory(profile.id)).toBe(false);
});
it("includes a staff member who has genuinely booked an appointment — they're a client of the shop", async () => {
const { staffId: colleagueStaffId } = await createTestStaff(business);
const { userId } = await createTestStaff(business);
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const profile = await prisma.customerProfile.create({
data: { userId, firstName: "Barber", lastName: "Person", email: user.email ?? "", isGuest: false },
});
await createTestAppointment(business, { customerId: profile.id, staffId: colleagueStaffId });
expect(await inDirectory(profile.id)).toBe(true);
});
it("includes an ordinary customer with a booking, and leaves out a bookingless guest", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await createTestAppointment(business, { customerId, staffId });
const { customerId: orphanGuest } = await createTestGuestCustomer();
leftoverProfileIds.push(orphanGuest);
expect(await inDirectory(customerId)).toBe(true);
// Guests are only ever created alongside a booking; one with none isn't a client.
expect(await inDirectory(orphanGuest)).toBe(false);
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { Prisma } from "@/generated/prisma/client";
/**
* Who counts as one of a business's CLIENTS — the single definition shared by
* the Clients list and a client's detail page. They used to each carry their
* own copy of this rule and drifted apart, so a client listed in one 404'd in
* the other; keep it here and nowhere else.
*
* A client is someone with at least one booking at this business, OR a
* registered person with no bookings anywhere yet (added by staff or
* imported — otherwise they'd be invisible until their first booking, which
* reads as "did that even work?"). Two kinds of account are never clients:
*
* - The platform Super Administrator, in any circumstance. They can pick up
* a stray client record just by opening the customer-side profile page
* (which creates one), and they must never appear in the shop's list.
* - A staff member with no bookings — who has only a client record because
* they used a customer-side page. One who has genuinely booked a haircut
* with a colleague IS a client, via their booking.
*
* Every user-relation test is written as `is: null` OR a positive match, never
* `NOT { user: … }`: guests have no linked user at all, and a negated
* relation filter would silently drop them (SQL's NULL-in-NOT trap).
*/
export function clientDirectoryWhere(businessId: string): Prisma.CustomerProfileWhereInput {
const notSuperAdmin: Prisma.CustomerProfileWhereInput = {
OR: [{ user: { is: null } }, { user: { isSuperAdmin: false } }],
};
const notStaff: Prisma.CustomerProfileWhereInput = {
OR: [{ user: { is: null } }, { user: { staffProfile: { is: null } } }],
};
return {
AND: [
notSuperAdmin,
{
OR: [
{ appointments: { some: { businessId } } },
{ AND: [{ isGuest: false, appointments: { none: {} } }, notStaff] },
],
},
],
};
}