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:
21 files changed
+1180
-118
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “last updated” 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>“{LEGAL.productName}”, “we”, “us”</strong> — {LEGAL.legalName}, {LEGAL.address}.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Platform”</strong> — the {LEGAL.productName} website, web app and installable app, through which
|
||||
appointments are booked and managed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Business”</strong> — a salon, barbershop or other service provider that uses the Platform to take
|
||||
bookings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Staff”</strong> — people who work for a Business and use the Platform to manage schedules and
|
||||
appointments.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Customer”, “you”</strong> — anyone who books an appointment through the Platform, with or
|
||||
without an account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>“Services”</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 “as is” 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 “last updated” 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
@@ -52,19 +53,12 @@ export default async function StaffCustomerDetailsPage({
|
||||
});
|
||||
if (!customer) notFound();
|
||||
|
||||
// "Belongs to this business" mirrors the Clients list query exactly
|
||||
// (see (staff)/staff/clients/page.tsx) — either a real appointment here,
|
||||
// or a registered client with no appointment anywhere yet (added via
|
||||
// /staff/clients/add, or self-registered but never booked). Without the
|
||||
// second branch, a client visible in the list because of that same
|
||||
// broadening would 404 the moment staff actually clicked into them —
|
||||
// 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();
|
||||
// The same rule the Clients list uses (clientDirectoryWhere), asked about this
|
||||
// one person — so anyone the list shows opens, and anyone it hides (the
|
||||
// Super Administrator, a barber with no bookings) 404s here too.
|
||||
const inDirectory =
|
||||
(await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
|
||||
if (!inDirectory) notFound();
|
||||
|
||||
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
||||
listAppointments(prisma, {
|
||||
@@ -220,7 +214,7 @@ export default async function StaffCustomerDetailsPage({
|
||||
: undefined
|
||||
}
|
||||
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">
|
||||
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||
@@ -34,31 +35,14 @@ export default async function StaffClientsPage({
|
||||
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
|
||||
// (see docs/phase-5-summary.md). A registered client with NO appointment
|
||||
// anywhere yet (freshly added via /staff/clients/add, or self-registered
|
||||
// but never booked) is also included — otherwise a client staff just
|
||||
// added would be invisible here until their first booking, which reads
|
||||
// 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).
|
||||
// Who counts as a client is defined once, in clientDirectoryWhere (shared with
|
||||
// the detail page so the two can't disagree). `select`-scoped rather than a
|
||||
// full row + appointment `include`: the card only ever reads status/startAt
|
||||
// off each appointment, never the service lines/notes/discount a full
|
||||
// include would pull down for every appointment of every client on every
|
||||
// page view (see the Phase 7 performance pass).
|
||||
const customers = await prisma.customerProfile.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ appointments: { some: { businessId: context.businessId } } },
|
||||
{ isGuest: false, appointments: { none: {} } },
|
||||
],
|
||||
},
|
||||
where: clientDirectoryWhere(context.businessId),
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
|
||||
@@ -42,7 +42,13 @@ export default async function StaffCalendarSettingsPage() {
|
||||
lastSyncAt: connection.lastSyncAt?.toISOString(),
|
||||
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>
|
||||
|
||||
@@ -119,13 +119,13 @@ export function RegisterForm() {
|
||||
|
||||
<p className="text-muted px-1 text-[10px] leading-relaxed">
|
||||
By creating an account, you agree to our{" "}
|
||||
<a href="#" className="underline">
|
||||
<Link href="/terms" className="underline">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="underline">
|
||||
<Link href="/privacy" className="underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function disconnectCalendarAction(): Promise<CalendarActionResult>
|
||||
// appointment changes have nothing left to sync against.
|
||||
await prisma.staffCalendarConnection.update({
|
||||
where: { staffId },
|
||||
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null },
|
||||
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null, lastSyncError: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -50,6 +50,13 @@ export async function syncNowAction(): Promise<CalendarActionResult> {
|
||||
return { success: false, error: "Connect Google Calendar first." };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface ConnectionState {
|
||||
selectedCalendarName?: string;
|
||||
lastSyncAt?: string;
|
||||
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 }) {
|
||||
@@ -68,6 +74,17 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
return (
|
||||
<div className="border-line rounded-md border bg-white p-5">
|
||||
<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'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">
|
||||
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.
|
||||
@@ -75,7 +92,9 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
||||
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||
{configured ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
appointment,
|
||||
className,
|
||||
linkClassName,
|
||||
buttonClassName,
|
||||
}: {
|
||||
businessId: string;
|
||||
businessName: string;
|
||||
@@ -36,8 +36,8 @@ export async function WhatsAppButton({
|
||||
};
|
||||
/** Wrapper sizing (e.g. flex-1 in a row of buttons). */
|
||||
className?: string;
|
||||
/** The link's own shape (height, radius, width). */
|
||||
linkClassName?: string;
|
||||
/** The button's own shape (height, radius, width). */
|
||||
buttonClassName?: string;
|
||||
}) {
|
||||
const href = reminderWhatsAppUrl({
|
||||
country: await getBusinessCountry(businessId),
|
||||
@@ -57,7 +57,7 @@ export async function WhatsAppButton({
|
||||
timezone={timezone}
|
||||
customerFirstName={customer.firstName}
|
||||
className={className}
|
||||
linkClassName={linkClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +69,9 @@ export async function WhatsAppButton({
|
||||
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",
|
||||
"border-line flex items-center justify-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold",
|
||||
className,
|
||||
linkClassName,
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
<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":
|
||||
* 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.
|
||||
*
|
||||
* 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({
|
||||
href,
|
||||
@@ -23,7 +28,7 @@ export function WhatsAppSendLink({
|
||||
customerFirstName,
|
||||
variant = "button",
|
||||
className,
|
||||
linkClassName,
|
||||
buttonClassName,
|
||||
}: {
|
||||
href: string;
|
||||
appointmentId: string;
|
||||
@@ -35,8 +40,8 @@ export function WhatsAppSendLink({
|
||||
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;
|
||||
/** Applied to the pill itself (height, radius, width). */
|
||||
buttonClassName?: string;
|
||||
}) {
|
||||
const [sentAt, setSentAt] = useState<string | null>(initialSentAt);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -61,56 +66,66 @@ export function WhatsAppSendLink({
|
||||
? 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";
|
||||
const isRow = variant === "row";
|
||||
const radius = isRow ? "rounded-md" : "rounded-lg";
|
||||
|
||||
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`
|
||||
}
|
||||
<div className={cn("flex flex-col items-start gap-1", className)}>
|
||||
<div
|
||||
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,
|
||||
"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,
|
||||
)}
|
||||
>
|
||||
{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"
|
||||
<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(
|
||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap",
|
||||
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
|
||||
sentAt && "text-muted",
|
||||
)}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
{sentAt ? (
|
||||
<>
|
||||
<Check className="text-accent h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{isRow ? `Sent ${sentLabel}` : `WhatsApp · sent ${sentLabel}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageCircle
|
||||
className={cn("h-3.5 w-3.5 shrink-0", isRow ? "text-white" : "text-accent")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isRow ? "Send on WhatsApp" : "WhatsApp"}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
{sentAt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => record(false)}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-[10px] font-medium">
|
||||
{error}
|
||||
|
||||
@@ -23,6 +23,8 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"];
|
||||
const INSTALL_EXEMPT_PREFIXES = [
|
||||
"/install",
|
||||
"/offline",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/manifest.webmanifest",
|
||||
"/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 { encryptToken } from "@/lib/security/token-encryption";
|
||||
import { MockCalendarProvider } from "../mock-calendar.provider";
|
||||
import { CalendarProviderError } from "../types";
|
||||
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
|
||||
|
||||
/**
|
||||
@@ -184,4 +185,83 @@ describe("calendar-sync.service (integration)", () => {
|
||||
expect(event).toBeNull();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,7 @@ async function syncOneConnection(
|
||||
};
|
||||
provider: CalendarProvider;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<"ok" | "revoked"> {
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
where: { id: params.appointmentId },
|
||||
include: {
|
||||
@@ -105,7 +105,7 @@ async function syncOneConnection(
|
||||
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
|
||||
},
|
||||
});
|
||||
if (!appointment) return;
|
||||
if (!appointment) return "ok";
|
||||
|
||||
const provider = params.provider;
|
||||
const existing = await prisma.appointmentCalendarEvent.findUnique({
|
||||
@@ -113,7 +113,7 @@ async function syncOneConnection(
|
||||
});
|
||||
|
||||
try {
|
||||
if (appointment.status === AppointmentStatus.PENDING) return;
|
||||
if (appointment.status === AppointmentStatus.PENDING) return "ok";
|
||||
|
||||
const accessToken = await getValidAccessToken(prisma, params.connection, provider);
|
||||
|
||||
@@ -125,7 +125,7 @@ async function syncOneConnection(
|
||||
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
|
||||
});
|
||||
}
|
||||
return;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
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 } });
|
||||
return "ok";
|
||||
} catch (error) {
|
||||
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) {
|
||||
await prisma.appointmentCalendarEvent
|
||||
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
|
||||
.catch(() => {});
|
||||
}
|
||||
return revoked ? "revoked" : "ok";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,8 +273,11 @@ export async function runInitialSync(
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let synced = 0;
|
||||
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(" ");
|
||||
|
||||
interface GoogleErrorBody {
|
||||
export interface GoogleErrorBody {
|
||||
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
|
||||
const reason =
|
||||
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
|
||||
/**
|
||||
* Turns a failed Google call into one of our own errors. Google reports
|
||||
* 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") {
|
||||
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") {
|
||||
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) {
|
||||
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) {
|
||||
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
|
||||
// 410 is Google's "this event was already deleted" — same outcome as not found.
|
||||
if (status === 404 || status === 410) {
|
||||
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false, detail);
|
||||
}
|
||||
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) {
|
||||
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> {
|
||||
|
||||
@@ -51,11 +51,19 @@ export type CalendarProviderErrorCode =
|
||||
export class CalendarProviderError extends Error {
|
||||
code: CalendarProviderErrorCode;
|
||||
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);
|
||||
this.name = "CalendarProviderError";
|
||||
this.code = code;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user