+
{error && (
{error}
diff --git a/src/proxy.ts b/src/proxy.ts
index ad21066..4505b33 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -23,6 +23,8 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"];
const INSTALL_EXEMPT_PREFIXES = [
"/install",
"/offline",
+ "/terms",
+ "/privacy",
"/manifest.webmanifest",
"/icon.png",
"/apple-icon.png",
diff --git a/src/services/calendar/__tests__/calendar-sync.service.integration.test.ts b/src/services/calendar/__tests__/calendar-sync.service.integration.test.ts
index e2cc022..6a954a5 100644
--- a/src/services/calendar/__tests__/calendar-sync.service.integration.test.ts
+++ b/src/services/calendar/__tests__/calendar-sync.service.integration.test.ts
@@ -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 {
+ 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 {
+ 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).");
+ });
+ });
});
diff --git a/src/services/calendar/__tests__/google-calendar.provider.test.ts b/src/services/calendar/__tests__/google-calendar.provider.test.ts
new file mode 100644
index 0000000..d3fe6c8
--- /dev/null
+++ b/src/services/calendar/__tests__/google-calendar.provider.test.ts
@@ -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();
+ });
+});
diff --git a/src/services/calendar/calendar-sync.service.ts b/src/services/calendar/calendar-sync.service.ts
index 44b04e0..9fb7f66 100644
--- a/src/services/calendar/calendar-sync.service.ts
+++ b/src/services/calendar/calendar-sync.service.ts
@@ -96,7 +96,7 @@ async function syncOneConnection(
};
provider: CalendarProvider;
},
-): Promise {
+): 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 };
}
diff --git a/src/services/calendar/google-calendar.provider.ts b/src/services/calendar/google-calendar.provider.ts
index 32d4905..483dba1 100644
--- a/src/services/calendar/google-calendar.provider.ts
+++ b/src/services/calendar/google-calendar.provider.ts
@@ -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 {
diff --git a/src/services/calendar/types.ts b/src/services/calendar/types.ts
index 834837f..3b9ccae 100644
--- a/src/services/calendar/types.ts
+++ b/src/services/calendar/types.ts
@@ -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;
}
}
diff --git a/src/services/customer/__tests__/client-directory.integration.test.ts b/src/services/customer/__tests__/client-directory.integration.test.ts
new file mode 100644
index 0000000..19dca11
--- /dev/null
+++ b/src/services/customer/__tests__/client-directory.integration.test.ts
@@ -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);
+ });
+});
diff --git a/src/services/customer/client-directory.ts b/src/services/customer/client-directory.ts
new file mode 100644
index 0000000..b9d496e
--- /dev/null
+++ b/src/services/customer/client-directory.ts
@@ -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] },
+ ],
+ },
+ ],
+ };
+}