Fixes
CI / ci (push) Canceled after 0s

fixed super admin showing in client list

added a reconnect button to google calendar syncying

fixed ui issue with whatsapp undo link
This commit is contained in:
Stefan Lazic committed 2026-10-17 09:35:52 +01:00
1 parent 2ed9c5ecca
commit 05ea83e059
21 files changed
+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();
});
});
+33 -7
View File
@@ -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> {
+9 -1
View File
@@ -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;
}
}