fix: tweak the oauth scope to be a little bit better

This commit is contained in:
2026-07-02 14:50:34 +09:30
parent ef9d7cf7f8
commit 34c536c2d8
10 changed files with 315 additions and 12 deletions
@@ -9,6 +9,8 @@ export interface GoogleCalendarStoredState {
refreshToken?: string;
expiresAt?: number;
connectedAt?: number;
/** Google calendar id for the app-owned "BetterSEQTA+ Timetable" secondary calendar. */
calendarId?: string;
lastSyncAt?: number;
lastWeeklySyncAt?: number;
lastSyncOrigin?: string;
@@ -71,8 +71,6 @@ async function deleteRemoteEvent(
throw new Error(err?.error?.message ?? `${label} delete failed (${res.status})`);
}
const GOOGLE_CALENDAR_ID = "primary";
export function upsertGoogleCalendarEvent(
accessToken: string,
calendarId: string,
@@ -140,5 +138,3 @@ export function deleteOutlookCalendarEvent(
refreshAccessToken,
);
}
export { GOOGLE_CALENDAR_ID };
+19 -5
View File
@@ -30,10 +30,10 @@ import type {
import {
deleteGoogleCalendarEvent,
deleteOutlookCalendarEvent,
GOOGLE_CALENDAR_ID,
upsertGoogleCalendarEvent,
upsertOutlookCalendarEvent,
} from "@/seqta/utils/calendarSync/remoteEvents";
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
import {
readOutlookCalendarState,
writeOutlookCalendarState,
@@ -70,6 +70,15 @@ export type CalendarLessonSyncProvider = {
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
};
async function getOrProvisionGoogleCalendarId(accessToken: string): Promise<string> {
const state = await readGoogleCalendarState();
if (state.calendarId) return state.calendarId;
const calendarId = await ensureGoogleAppCalendar(accessToken);
await writeGoogleCalendarState({ calendarId });
return calendarId;
}
export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
label: "Google Calendar",
isConfigured: isGoogleCalendarConfigured,
@@ -77,12 +86,17 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
notConnectedError: "Connect Google Calendar first.",
readState: readGoogleCalendarState,
writeState: writeGoogleCalendarState,
deleteEvent: (accessToken, eventId, refreshAccessToken) =>
deleteGoogleCalendarEvent(accessToken, GOOGLE_CALENDAR_ID, eventId, refreshAccessToken),
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
deleteEvent: async (accessToken, eventId, refreshAccessToken) =>
deleteGoogleCalendarEvent(
accessToken,
await getOrProvisionGoogleCalendarId(accessToken),
eventId,
refreshAccessToken,
),
upsertEvent: async (accessToken, existingId, body, refreshAccessToken) =>
upsertGoogleCalendarEvent(
accessToken,
GOOGLE_CALENDAR_ID,
await getOrProvisionGoogleCalendarId(accessToken),
existingId,
body,
refreshAccessToken,
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
jest.mock("@/config/googleCalendar", () => ({
BSPLUS_GOOGLE_CALENDAR_DESCRIPTION: "desc",
BSPLUS_GOOGLE_CALENDAR_NAME: "BetterSEQTA+ Timetable",
GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3",
}));
import { ensureGoogleAppCalendar } from "./calendarProvisioning";
const fetchMock = jest.fn<typeof fetch>();
global.fetch = fetchMock as typeof fetch;
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
describe("ensureGoogleAppCalendar", () => {
beforeEach(() => {
fetchMock.mockReset();
});
it("reuses a stored calendar id when it still exists", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ id: "stored-id", summary: "BetterSEQTA+ Timetable" }));
await expect(ensureGoogleAppCalendar("token", "stored-id")).resolves.toBe("stored-id");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("finds an existing app calendar by name when stored id is missing", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
items: [{ id: "found-id", summary: "BetterSEQTA+ Timetable" }],
}),
);
await expect(ensureGoogleAppCalendar("token")).resolves.toBe("found-id");
expect(fetchMock).toHaveBeenCalledWith(
"https://www.googleapis.com/calendar/v3/users/me/calendarList",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token" }) }),
);
});
it("creates a calendar when none exists", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ items: [] }))
.mockResolvedValueOnce(jsonResponse({ id: "new-id" }));
await expect(ensureGoogleAppCalendar("token")).resolves.toBe("new-id");
expect(fetchMock).toHaveBeenCalledWith(
"https://www.googleapis.com/calendar/v3/calendars",
expect.objectContaining({ method: "POST" }),
);
});
});
@@ -0,0 +1,83 @@
import {
BSPLUS_GOOGLE_CALENDAR_DESCRIPTION,
BSPLUS_GOOGLE_CALENDAR_NAME,
GOOGLE_CALENDAR_API,
} from "@/config/googleCalendar";
type GoogleCalendarResource = { id?: string; summary?: string };
type GoogleCalendarListResponse = { items?: GoogleCalendarResource[] };
type GoogleApiError = { error?: { message?: string } };
async function googleCalendarFetch<T>(
accessToken: string,
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; data: T }> {
const res = await fetch(`${GOOGLE_CALENDAR_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${accessToken}`,
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
const data = (await res.json().catch(() => ({}))) as T & GoogleApiError;
return { ok: res.ok, status: res.status, data };
}
async function calendarExists(accessToken: string, calendarId: string): Promise<boolean> {
const { ok, status } = await googleCalendarFetch<GoogleCalendarResource>(
accessToken,
`/calendars/${encodeURIComponent(calendarId)}`,
);
return ok || status === 404 ? ok : false;
}
async function findExistingAppCalendar(accessToken: string): Promise<string | undefined> {
const { ok, data } = await googleCalendarFetch<GoogleCalendarListResponse>(
accessToken,
"/users/me/calendarList",
);
if (!ok) return undefined;
const match = (data.items ?? []).find((item) => item.summary === BSPLUS_GOOGLE_CALENDAR_NAME);
return match?.id;
}
async function createAppCalendar(accessToken: string): Promise<string> {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const { ok, status, data } = await googleCalendarFetch<GoogleCalendarResource>(
accessToken,
"/calendars",
{
method: "POST",
body: JSON.stringify({
summary: BSPLUS_GOOGLE_CALENDAR_NAME,
description: BSPLUS_GOOGLE_CALENDAR_DESCRIPTION,
timeZone,
}),
},
);
if (!ok || !data.id) {
throw new Error(
data.error?.message ?? `Could not create BetterSEQTA+ calendar (${status}).`,
);
}
return data.id;
}
/** Resolves the app-owned calendar id, reusing stored or existing calendars when possible. */
export async function ensureGoogleAppCalendar(
accessToken: string,
storedCalendarId?: string,
): Promise<string> {
if (storedCalendarId && (await calendarExists(accessToken, storedCalendarId))) {
return storedCalendarId;
}
const existing = await findExistingAppCalendar(accessToken);
if (existing) return existing;
return createAppCalendar(accessToken);
}
@@ -36,11 +36,14 @@ import {
} from "@/seqta/utils/calendarSync/remoteEvents";
import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "@/seqta/utils/calendarSync/syncEngine";
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
const ORIGIN = "https://school.seqta.com.au";
const getAccessToken = async () => "test-token";
const syncDate = syncWindowRange(12).from;
const baseLesson: SeqtaTimetableLesson = {
date: "2026-06-27",
date: syncDate,
from: "09:00:00",
until: "10:00:00",
description: "10 Mathematics",
@@ -56,8 +59,9 @@ describe("syncLessonsToGoogleCalendar", () => {
jest.clearAllMocks();
jest.mocked(readGoogleCalendarState).mockResolvedValue({
refreshToken: "refresh",
calendarId: "app-calendar-id",
eventMap: {
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: "2026-06-27" },
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-existing", date: syncDate },
[`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-stale", date: "2020-01-06" },
},
});
@@ -84,6 +88,7 @@ describe("syncLessonsToGoogleCalendar", () => {
it("creates events that are not yet tracked", async () => {
jest.mocked(readGoogleCalendarState).mockResolvedValue({
refreshToken: "refresh",
calendarId: "app-calendar-id",
eventMap: {},
});
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-new");
@@ -135,6 +140,7 @@ describe("deleteSyncedEventsFromGoogleCalendar", () => {
jest.clearAllMocks();
jest.mocked(readGoogleCalendarState).mockResolvedValue({
refreshToken: "refresh",
calendarId: "app-calendar-id",
eventMap: {
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "google-1", date: "2026-06-27" },
[`${ORIGIN}::${ORIGIN}:cal:99999`]: { id: "google-2", date: "2026-06-28" },