Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ embeddable modal. No database — Google Calendar is the source of truth.
- **`lib/provider.ts`** — picks `lib/google.ts` (real free/busy + event insert) when `isLive()`,
else deterministic mock busy blocks. **All calendar access goes through here.**
- **`lib/google.ts`** — `googleapis` OAuth + `freebusy.query` + `events.insert` (with Meet link).
Wraps calls so an expired/revoked refresh token (`invalid_grant`) throws a typed `CalendarAuthError`
(re-exported from `lib/provider.ts`); routes map it to a `503 { code: "reauth_required" }` (via
`reauthRequired` in `lib/cors.ts`) instead of a generic 500.
- **`lib/clientHooks.ts`** — `useOrigin` / `useBookerTimeZone` via `useSyncExternalStore`
(SSR-safe, and avoids the `set-state-in-effect` lint rule).

Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ email both sides.
- [Embedding in another app](#embedding-in-another-app)
- [HTTP API](#http-api)
- [Deploy to Vercel](#deploy-to-vercel)
- [Troubleshooting](#troubleshooting)
- [Design system](#design-system)
- [Tech stack](#tech-stack)
- [Contributing](#contributing)
Expand Down Expand Up @@ -138,6 +139,11 @@ All endpoints return JSON and send permissive CORS headers, so you can build a f
- `POST /api/book` → `{ eventTypeId, start, name, email, notes }` → creates the event.
Re-validates the slot server-side and returns `409` if it was just taken.

When the owner's Google refresh token has expired or been revoked, `/api/availability` and
`/api/book` return **`503`** with `{ "error": "...", "code": "reauth_required" }` (never an
unhandled `500`). The widget surfaces this as a *"Calendar temporarily unavailable"* message.
See [Troubleshooting](#troubleshooting).

---

## Deploy to Vercel
Expand All @@ -149,6 +155,32 @@ All endpoints return JSON and send permissive CORS headers, so you can build a f

---

## Troubleshooting

### Bookings fail with "Calendar temporarily unavailable" / re-authenticate

Google refresh tokens can stop working — most commonly with an `invalid_grant` error. This happens
when the token is revoked (owner removed the app under [Google Account → Security → Third-party
access](https://myaccount.google.com/connections)), the password is changed, the token sits unused
past its expiry, or a project still in **Testing** mode expires the token after 7 days.

When this happens the calendar APIs return a typed **`503 { code: "reauth_required" }`** (not a
generic `500`), and the booking widget shows *"Calendar temporarily unavailable — the owner needs to
re-authenticate."*

**Fix — mint a fresh refresh token:**

1. Visit **`/setup`** on your deployment and click **Connect Google Calendar** (re-runs the OAuth
consent flow). This mints a new `GOOGLE_REFRESH_TOKEN`.
2. Update `GOOGLE_REFRESH_TOKEN` in your host's environment variables (locally it's rewritten into
`.env.local`).
3. Redeploy / restart the server so the new token is picked up.

To avoid the recurring 7-day expiry, move your Google Cloud OAuth consent screen from **Testing** to
**In production** (publishing status), so refresh tokens no longer auto-expire.

---

## Design system

The UI is built with **clico-ds** — a playful neo-brutalist design system (cream paper, 2px ink
Expand Down
48 changes: 48 additions & 0 deletions app/api/__tests__/reauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

// Make the provider behave as if Google's refresh token is expired/revoked.
vi.mock("@/lib/provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/provider")>();
return {
...actual,
fetchBusy: vi.fn(async () => {
throw new actual.CalendarAuthError();
}),
book: vi.fn(async () => {
throw new actual.CalendarAuthError();
}),
};
});

import { GET as availabilityGET } from "@/app/api/availability/route";
import { POST as bookPOST } from "@/app/api/book/route";

describe("graceful invalid_grant handling", () => {
it("availability returns a typed 503 instead of an unhandled 500", async () => {
const req = new NextRequest("http://localhost/api/availability?date=2026-07-13&eventType=30min");
const res = await availabilityGET(req);
expect(res.status).toBe(503);
const body = await res.json();
expect(body.code).toBe("reauth_required");
expect(typeof body.error).toBe("string");
});

it("booking returns a typed 503 instead of an unhandled 500", async () => {
const req = new NextRequest("http://localhost/api/book", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventTypeId: "30min",
start: "2026-07-13T01:00:00.000Z", // 09:00 SGT, a Monday
name: "Jane Doe",
email: "jane@example.com",
}),
});
const res = await bookPOST(req);
expect(res.status).toBe(503);
const body = await res.json();
expect(body.code).toBe("reauth_required");
expect(body.error).toMatch(/re-?authenticate/i);
});
});
8 changes: 6 additions & 2 deletions app/api/availability/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { NextRequest } from "next/server";
import { config } from "@/lib/config";
import { computeSlots } from "@/lib/availability";
import { fetchBusy } from "@/lib/provider";
import { fetchBusy, CalendarAuthError } from "@/lib/provider";
import { parseDateStr, zonedWallToUtc } from "@/lib/time";
import { json, preflight } from "@/lib/cors";
import { json, preflight, reauthRequired } from "@/lib/cors";

export const dynamic = "force-dynamic";

Expand All @@ -27,6 +27,10 @@ export async function GET(req: NextRequest) {
try {
busy = await fetchBusy(dayStart.toISOString(), dayEnd.toISOString());
} catch (err) {
if (err instanceof CalendarAuthError) {
console.error("[timesync] calendar re-auth required", err);
return reauthRequired(err.message);
}
console.error("[timesync] fetchBusy failed", err);
return json(
{ error: "Calendar is temporarily unavailable. Please try again shortly." },
Expand Down
12 changes: 10 additions & 2 deletions app/api/book/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { NextRequest } from "next/server";
import { config, getEventType } from "@/lib/config";
import { computeSlots } from "@/lib/availability";
import { book, fetchBusy } from "@/lib/provider";
import { book, fetchBusy, CalendarAuthError } from "@/lib/provider";
import { toDateStr, zonedDateParts, zonedWallToUtc } from "@/lib/time";
import { json, preflight } from "@/lib/cors";
import { json, preflight, reauthRequired } from "@/lib/cors";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -45,6 +45,10 @@ export async function POST(req: NextRequest) {
try {
busy = await fetchBusy(dayStart.toISOString(), dayEnd.toISOString());
} catch (err) {
if (err instanceof CalendarAuthError) {
console.error("[timesync] calendar re-auth required", err);
return reauthRequired(err.message);
}
console.error("[timesync] fetchBusy failed", err);
return json(
{ error: "Calendar is temporarily unavailable. Please try again shortly." },
Expand Down Expand Up @@ -81,6 +85,10 @@ export async function POST(req: NextRequest) {
...result,
});
} catch (err) {
if (err instanceof CalendarAuthError) {
console.error("[timesync] calendar re-auth required", err);
return reauthRequired(err.message);
}
console.error("[timesync] booking failed", err);
return json({ error: "Could not create the event. Please try again." }, { status: 500 });
}
Expand Down
18 changes: 16 additions & 2 deletions components/BookingWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export default function BookingWidget({
const [slots, setSlots] = useState<Slot[] | null>(null);
const [slotsLoading, setSlotsLoading] = useState(false);
const [slotsError, setSlotsError] = useState(false);
const [slotsErrorMsg, setSlotsErrorMsg] = useState<string | null>(null);
const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);

const [step, setStep] = useState<"pick" | "details" | "done">("pick");
Expand All @@ -132,11 +133,17 @@ export default function BookingWidget({
setSlotsLoading(true);
setSlots(null);
setSlotsError(false);
setSlotsErrorMsg(null);
try {
const r = await fetch(`/api/availability?date=${dateStr}&eventType=${etId}`);
if (!r.ok) {
const j = await r.json().catch(() => null);
setSlots([]);
setSlotsError(true);
// Surface the owner-needs-to-re-authenticate case explicitly.
if (j?.code === "reauth_required" && typeof j.error === "string") {
setSlotsErrorMsg(j.error);
}
return;
}
const j = await r.json();
Expand Down Expand Up @@ -322,6 +329,7 @@ export default function BookingWidget({
slots={slots}
loading={slotsLoading}
error={slotsError}
errorMsg={slotsErrorMsg}
onPick={pickSlot}
/>
</div>
Expand Down Expand Up @@ -692,13 +700,15 @@ function SlotPanel({
slots,
loading,
error,
errorMsg,
onPick,
}: {
selectedDate: string | null;
bookerTz: string;
slots: Slot[] | null;
loading: boolean;
error: boolean;
errorMsg?: string | null;
onPick: (slot: Slot) => void;
}) {
return (
Expand All @@ -720,8 +730,12 @@ function SlotPanel({
) : error ? (
<div className="flex h-full flex-col items-center justify-center py-10 text-center">
<AlertTriangle className="h-7 w-7 text-faint" />
<p className="mt-2 text-sm font-bold text-ink">Couldn’t load times</p>
<p className="font-mono text-[11px] text-muted">Please try again shortly.</p>
<p className="mt-2 text-sm font-bold text-ink">
{errorMsg ? "Calendar temporarily unavailable" : "Couldn’t load times"}
</p>
<p className="mt-1 max-w-[240px] font-mono text-[11px] text-muted">
{errorMsg ?? "Please try again shortly."}
</p>
</div>
) : slots && slots.length > 0 ? (
slots.map((slot) => (
Expand Down
70 changes: 70 additions & 0 deletions lib/__tests__/google.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";

// Simulate Google rejecting an expired/revoked refresh token. googleapis surfaces
// this as a Gaxios error whose message and response body carry `invalid_grant`.
vi.mock("googleapis", () => {
const invalidGrant = Object.assign(
new Error("invalid_grant (Token has been expired or revoked.)"),
{
response: {
data: { error: "invalid_grant", error_description: "Token has been expired or revoked." },
},
},
);
class OAuth2 {
setCredentials() {}
generateAuthUrl() {
return "";
}
async getToken() {
return { tokens: {} };
}
}
return {
google: {
auth: { OAuth2 },
calendar: () => ({
freebusy: { query: () => Promise.reject(invalidGrant) },
events: { insert: () => Promise.reject(invalidGrant) },
}),
},
};
});

import { CalendarAuthError, createEvent, getBusy, isInvalidGrant } from "../google";

describe("isInvalidGrant", () => {
it("matches the invalid_grant message", () => {
expect(isInvalidGrant(new Error("invalid_grant"))).toBe(true);
});

it("matches a Gaxios-shaped error body", () => {
expect(isInvalidGrant({ response: { data: { error: "invalid_grant" } } })).toBe(true);
});

it("ignores unrelated errors", () => {
expect(isInvalidGrant(new Error("network timeout"))).toBe(false);
expect(isInvalidGrant(null)).toBe(false);
});
});

describe("calendar calls surface a typed re-auth error", () => {
it("getBusy throws CalendarAuthError on invalid_grant", async () => {
await expect(getBusy("2026-06-22T00:00:00Z", "2026-06-22T23:59:00Z")).rejects.toBeInstanceOf(
CalendarAuthError,
);
});

it("createEvent throws CalendarAuthError on invalid_grant", async () => {
const err = await createEvent({
summary: "Chat",
description: "",
start: "2026-06-22T01:00:00.000Z",
end: "2026-06-22T01:30:00.000Z",
attendeeEmail: "jane@example.com",
attendeeName: "Jane",
}).catch((e) => e);
expect(err).toBeInstanceOf(CalendarAuthError);
expect((err as CalendarAuthError).code).toBe("reauth_required");
});
});
9 changes: 9 additions & 0 deletions lib/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ export function json(data: unknown, init?: ResponseInit) {
export function preflight() {
return new NextResponse(null, { status: 204, headers: CORS });
}

/**
* Typed 503 for an expired/revoked Google refresh token. Distinct from a generic
* 500 so clients can detect it via `code: "reauth_required"` and show a clear
* "owner needs to re-authenticate" message instead of a broken state.
*/
export function reauthRequired(message: string) {
return json({ error: message, code: "reauth_required" }, { status: 503 });
}
Loading
Loading