mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
fix: tweak the oauth scope to be a little bit better
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user