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