diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index 467489180..662f76a6a 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -212,7 +212,8 @@ export const fillTitleAndSaveWithKeyboard = async ( const titleInput = getFormTitleInput(page); await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT }); await titleInput.fill(title); - await page.keyboard.press("Enter"); + // EventForm saves on Meta+Enter (Cmd+Enter on Mac, Win+Enter on Windows/Linux) + await page.keyboard.press("Meta+Enter"); // Wait for form to close, confirming the save completed await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT }); }; diff --git a/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts b/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts new file mode 100644 index 000000000..6377e12ed --- /dev/null +++ b/packages/backend/src/__tests__/mocks.gcal/errors/error.google.factory.ts @@ -0,0 +1,59 @@ +import { GaxiosError } from "gaxios"; + +export const createGoogleError = ( + overrides: { + code?: string | number; + responseStatus?: number; + message?: string; + } = {}, +) => { + const url = new URL("https://www.googleapis.com/calendar/v3"); + const headers = new Headers(); + + const error = new GaxiosError( + overrides.message ?? "test google error", + { + headers, + url, + }, + overrides.responseStatus + ? { + config: { + headers, + url, + }, + status: overrides.responseStatus, + statusText: "ERROR", + headers, + data: {}, + ok: false, + redirected: false, + type: "error" as ResponseType, + url: url.toString(), + body: null, + bodyUsed: false, + clone: () => { + throw new Error("Not implemented"); + }, + arrayBuffer: async () => { + throw new Error("Not implemented"); + }, + blob: async () => { + throw new Error("Not implemented"); + }, + formData: async () => { + throw new Error("Not implemented"); + }, + json: async () => ({}), + text: async () => "", + bytes: async () => { + throw new Error("Not implemented"); + }, + } + : undefined, + ); + + error.code = overrides.code; + + return error; +}; diff --git a/packages/backend/src/common/errors/handlers/error.express.handler.ts b/packages/backend/src/common/errors/handlers/error.express.handler.ts index 1ab3fa39d..e11b53244 100644 --- a/packages/backend/src/common/errors/handlers/error.express.handler.ts +++ b/packages/backend/src/common/errors/handlers/error.express.handler.ts @@ -1,6 +1,7 @@ import { Request } from "express"; import { GaxiosError } from "gaxios"; import { SessionRequest } from "supertokens-node/framework/express"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; @@ -19,6 +20,7 @@ import { } from "@backend/common/services/gcal/gcal.utils"; import { CompassError, Info_Error } from "@backend/common/types/error.types"; import { SessionResponse } from "@backend/common/types/express.types"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { getSyncByToken } from "@backend/sync/util/sync.queries"; import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userService from "@backend/user/services/user.service"; @@ -91,7 +93,7 @@ export const handleExpressError = async ( } if (isGoogleError(e)) { - await handleGoogleError(req, res, userId, e); + await handleGoogleError(req, res, userId, e as GaxiosError); } else { const errInfo = assembleErrorInfo(e); res.status(e.status || Status.INTERNAL_SERVER).send(errInfo); @@ -104,20 +106,23 @@ export const handleExpressError = async ( }; const handleGoogleError = async ( - req: Request | SessionRequest, + _req: Request | SessionRequest, res: SessionResponse, userId: string, e: GaxiosError, ) => { if (isInvalidGoogleToken(e)) { - await req.session?.revokeSession(); + await userService.pruneGoogleData(userId); + webSocketServer.handleGoogleRevoked(userId); - // revoke specific sessions for this user - logger.debug( - `Invalid Google token for user: ${userId}\n\tsession revoked as result`, + logger.warn( + `Invalid Google token for user: ${userId}. Google data pruned and client notified.`, ); - res.status(Status.UNAUTHORIZED).send(); + res.status(Status.UNAUTHORIZED).send({ + code: GOOGLE_REVOKED, + message: "Google access revoked. Your Google data has been removed.", + }); return; } diff --git a/packages/backend/src/common/errors/handlers/error.handler.test.ts b/packages/backend/src/common/errors/handlers/error.handler.test.ts index 74bf83d83..b7d859060 100644 --- a/packages/backend/src/common/errors/handlers/error.handler.test.ts +++ b/packages/backend/src/common/errors/handlers/error.handler.test.ts @@ -1,10 +1,16 @@ +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { BaseError } from "@core/errors/errors.base"; import { Status } from "@core/errors/status.codes"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; +import { handleExpressError } from "@backend/common/errors/handlers/error.express.handler"; import { error, + errorHandler, toClientErrorPayload, } from "@backend/common/errors/handlers/error.handler"; import { UserError } from "@backend/common/errors/user/user.errors"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import userService from "@backend/user/services/user.service"; describe("error.handler", () => { describe("toClientErrorPayload", () => { @@ -38,4 +44,38 @@ describe("error.handler", () => { expect(Object.keys(payload)).toEqual(["result", "message"]); }); }); + + describe("handleExpressError", () => { + it("returns 401 with GOOGLE_REVOKED payload when Google token is invalid", async () => { + const userId = "507f1f77bcf86cd799439011"; + jest.spyOn(userService, "pruneGoogleData").mockResolvedValue(); + const handleGoogleRevokedSpy = jest.spyOn( + webSocketServer, + "handleGoogleRevoked", + ); + handleGoogleRevokedSpy.mockImplementation(() => undefined); + jest.spyOn(errorHandler, "isOperational").mockReturnValue(true); + + const send = jest.fn(); + const res = { + header: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + send, + } as unknown as Parameters[1]; + const req = { + session: { getUserId: () => userId }, + } as Parameters[0]; + (res as { req?: typeof req }).req = req; + + await handleExpressError(req, res, invalidGrant400Error); + + expect(res.status).toHaveBeenCalledWith(Status.UNAUTHORIZED); + expect(send).toHaveBeenCalledWith({ + code: GOOGLE_REVOKED, + message: "Google access revoked. Your Google data has been removed.", + }); + expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); + handleGoogleRevokedSpy.mockRestore(); + }); + }); }); diff --git a/packages/backend/src/common/services/gcal/gcal.util.test.ts b/packages/backend/src/common/services/gcal/gcal.util.test.ts index 23f29b154..b64997cc9 100644 --- a/packages/backend/src/common/services/gcal/gcal.util.test.ts +++ b/packages/backend/src/common/services/gcal/gcal.util.test.ts @@ -1,8 +1,10 @@ +import { createGoogleError } from "../../../__tests__/mocks.gcal/errors/error.google.factory"; import { invalidGrant400Error } from "../../../__tests__/mocks.gcal/errors/error.google.invalidGrant"; import { invalidValueError } from "../../../__tests__/mocks.gcal/errors/error.google.invalidValue"; import { invalidSyncTokenError } from "../../../__tests__/mocks.gcal/errors/error.invalidSyncToken"; import { getEmailFromUrl, + getGoogleErrorStatus, isFullSyncRequired, isInvalidGoogleToken, isInvalidValue, @@ -18,6 +20,19 @@ describe("Google Error Parsing", () => { it("recognizes expired refresh token", () => { expect(isInvalidGoogleToken(invalidGrant400Error)).toBe(true); }); + it("returns response status when present", () => { + expect( + getGoogleErrorStatus( + createGoogleError({ code: "500", responseStatus: 401 }), + ), + ).toBe(401); + }); + it("falls back to the parsed gaxios code", () => { + expect(getGoogleErrorStatus(createGoogleError({ code: "410" }))).toBe(410); + }); + it("returns undefined for non-google errors", () => { + expect(getGoogleErrorStatus(new Error("nope"))).toBeUndefined(); + }); }); describe("Gaxios response parsing", () => { diff --git a/packages/backend/src/common/services/gcal/gcal.utils.ts b/packages/backend/src/common/services/gcal/gcal.utils.ts index 20ee0064f..121f00bc4 100644 --- a/packages/backend/src/common/services/gcal/gcal.utils.ts +++ b/packages/backend/src/common/services/gcal/gcal.utils.ts @@ -42,27 +42,40 @@ export const getEmailFromUrl = (url: string) => { }; // occurs when token expired or revoked -export const isInvalidGoogleToken = (e: GaxiosError | Error) => { - const is400 = "code" in e && e.code === "400"; - const hasInvalidMsg = "message" in e && e.message === "invalid_grant"; - const isInvalid = is400 && hasInvalidMsg; +export const isInvalidGoogleToken = (e: unknown) => { + if (!isGoogleError(e)) return false; - return isGoogleError(e) && isInvalid; + const err = e as GaxiosError; + const is400 = err.code === "400" || err.response?.status === 400; + const hasInvalidMsg = err.message === "invalid_grant"; + const hasInvalidData = err.response?.data?.error === "invalid_grant"; + + return is400 && (hasInvalidMsg || hasInvalidData); }; export const isGoogleError = (e: unknown) => { - return e instanceof GaxiosError; + return e instanceof GaxiosError || (e as any)?.name === "GaxiosError"; }; -export const isFullSyncRequired = (e: GaxiosError | Error) => { - if (isGoogleError(e) && e.code) { - const codeStr = typeof e.code === "string" ? e.code : String(e.code); - if (parseInt(codeStr) === 410) { - return true; - } +export const getGoogleErrorStatus = (e: unknown) => { + if (!isGoogleError(e)) return undefined; + + const err = e as GaxiosError; + const responseStatus = err.response?.status; + + if (typeof responseStatus === "number") return responseStatus; + if (typeof err.code === "number") return err.code; + + if (typeof err.code === "string") { + const code = Number.parseInt(err.code, 10); + if (!Number.isNaN(code)) return code; } - return false; + return undefined; +}; + +export const isFullSyncRequired = (e: unknown) => { + return getGoogleErrorStatus(e) === 410; }; export const isInvalidValue = (e: GaxiosError) => { diff --git a/packages/backend/src/servers/websocket/websocket.server.ts b/packages/backend/src/servers/websocket/websocket.server.ts index 84cbbf13b..3fd6daa56 100644 --- a/packages/backend/src/servers/websocket/websocket.server.ts +++ b/packages/backend/src/servers/websocket/websocket.server.ts @@ -6,6 +6,7 @@ import { EVENT_CHANGED, EVENT_CHANGE_PROCESSED, FETCH_USER_METADATA, + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, RESULT_IGNORED, @@ -236,6 +237,10 @@ class WebSocketServer { handleBackgroundSomedayChange(userId: string) { return this.notifyUser(userId, SOMEDAY_EVENT_CHANGED); } + + handleGoogleRevoked(userId: string) { + return this.notifyUser(userId, GOOGLE_REVOKED); + } } export const webSocketServer = new WebSocketServer(); diff --git a/packages/backend/src/sync/controllers/sync.controller.test.ts b/packages/backend/src/sync/controllers/sync.controller.test.ts index 61836dcaf..be66b69c7 100644 --- a/packages/backend/src/sync/controllers/sync.controller.test.ts +++ b/packages/backend/src/sync/controllers/sync.controller.test.ts @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"; import { DefaultEventsMap } from "socket.io"; import { Socket } from "socket.io-client"; import { faker } from "@faker-js/faker"; -import { EVENT_CHANGED } from "@core/constants/websocket.constants"; +import { + EVENT_CHANGED, + GOOGLE_REVOKED, +} from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { Resource_Sync, XGoogleResourceState } from "@core/types/sync.types"; import { Schema_User } from "@core/types/user.types"; @@ -23,12 +26,16 @@ import { cleanupTestDb, setupTestDb, } from "@backend/__tests__/helpers/mock.db.setup"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; import { WatchError } from "@backend/common/errors/sync/watch.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; import mongoService from "@backend/common/services/mongo.service"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; +import syncService from "@backend/sync/services/sync.service"; import * as syncQueries from "@backend/sync/util/sync.queries"; import { updateSync } from "@backend/sync/util/sync.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; +import userService from "@backend/user/services/user.service"; describe("SyncController", () => { const baseDriver = new BaseDriver(); @@ -184,6 +191,54 @@ describe("SyncController", () => { expect(response.text).toEqual("IGNORED"); }); + + it("should prune Google data, notify client via websocket, and return structured response when user revokes access", async () => { + const { user } = await UtilDriver.setupTestUser(); + const userId = user._id.toString(); + + const watch = await mongoService.watch.findOne({ + user: userId, + gCalendarId: { $ne: Resource_Sync.CALENDAR }, + }); + + expect(watch).toBeDefined(); + expect(watch).not.toBeNull(); + + const handleGcalNotificationSpy = jest + .spyOn(syncService, "handleGcalNotification") + .mockRejectedValue(invalidGrant400Error); + + const pruneGoogleDataSpy = jest + .spyOn(userService, "pruneGoogleData") + .mockResolvedValue(); + + const handleGoogleRevokedSpy = jest.spyOn( + webSocketServer, + "handleGoogleRevoked", + ); + + const response = await syncDriver.handleGoogleNotification( + { + resource: Resource_Sync.EVENTS, + channelId: watch!._id, + resourceId: watch!.resourceId, + resourceState: XGoogleResourceState.EXISTS, + expiration: watch!.expiration, + }, + Status.GONE, + ); + + expect(response.body).toEqual({ + code: GOOGLE_REVOKED, + message: "User revoked access, pruned Google data", + }); + expect(pruneGoogleDataSpy).toHaveBeenCalledWith(userId); + expect(handleGoogleRevokedSpy).toHaveBeenCalledWith(userId); + + handleGcalNotificationSpy.mockRestore(); + pruneGoogleDataSpy.mockRestore(); + handleGoogleRevokedSpy.mockRestore(); + }); }); describe("importGCal: ", () => { diff --git a/packages/backend/src/sync/controllers/sync.controller.ts b/packages/backend/src/sync/controllers/sync.controller.ts index 22022e833..820b9d1eb 100644 --- a/packages/backend/src/sync/controllers/sync.controller.ts +++ b/packages/backend/src/sync/controllers/sync.controller.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express"; import { ObjectId } from "mongodb"; import { ZodError } from "zod/v4"; import { COMPASS_RESOURCE_HEADER } from "@core/constants/core.constants"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; import { Logger } from "@core/logger/winston.logger"; import { @@ -18,6 +19,7 @@ import { isInvalidGoogleToken, } from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; +import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import syncService from "@backend/sync/services/sync.service"; import { getSync } from "@backend/sync/util/sync.queries"; import userService from "@backend/user/services/user.service"; @@ -87,11 +89,13 @@ export class SyncController { `Cleaning data after this user revoked access: ${userId}`, ); - await userService.deleteCompassDataForUser(userId, false); + await userService.pruneGoogleData(userId); + webSocketServer.handleGoogleRevoked(userId); - res - .status(Status.GONE) - .send("User revoked access, deleted all data"); + res.status(Status.GONE).send({ + code: GOOGLE_REVOKED, + message: "User revoked access, pruned Google data", + }); return; } @@ -103,7 +107,7 @@ export class SyncController { res.status(Status.GONE).send(msg); return; - } else if (isFullSyncRequired(e as Error) && userId) { + } else if (isFullSyncRequired(e) && userId) { // do not await this call userService .restartGoogleCalendarSync(userId, { force: true }) diff --git a/packages/backend/src/sync/services/sync.service.test.ts b/packages/backend/src/sync/services/sync.service.test.ts new file mode 100644 index 000000000..cc8aca16e --- /dev/null +++ b/packages/backend/src/sync/services/sync.service.test.ts @@ -0,0 +1,161 @@ +import { ObjectId } from "mongodb"; +import { faker } from "@faker-js/faker"; +import { WatchSchema } from "@core/types/watch.types"; +import { UserDriver } from "@backend/__tests__/drivers/user.driver"; +import { + cleanupCollections, + cleanupTestDb, + setupTestDb, +} from "@backend/__tests__/helpers/mock.db.setup"; +import { createGoogleError } from "@backend/__tests__/mocks.gcal/errors/error.google.factory"; +import { invalidGrant400Error } from "@backend/__tests__/mocks.gcal/errors/error.google.invalidGrant"; +import gcalService from "@backend/common/services/gcal/gcal.service"; +import mongoService from "@backend/common/services/mongo.service"; +import syncService from "@backend/sync/services/sync.service"; + +const createWatch = async (user: string) => { + const watch = WatchSchema.parse({ + _id: new ObjectId(), + user, + resourceId: faker.string.uuid(), + expiration: new Date(Date.now() + 60_000), + gCalendarId: faker.string.uuid(), + createdAt: new Date(), + }); + + await mongoService.watch.insertOne(watch); + + return watch; +}; + +describe("SyncService", () => { + beforeEach(setupTestDb); + beforeEach(cleanupCollections); + afterEach(() => jest.restoreAllMocks()); + afterAll(cleanupTestDb); + + describe("deleteWatchesByUser", () => { + it("deletes only the target user's watch records and returns their identities", async () => { + const firstUser = await UserDriver.createUser(); + const secondUser = await UserDriver.createUser(); + const firstUserWatchA = await createWatch(firstUser._id.toString()); + const firstUserWatchB = await createWatch(firstUser._id.toString()); + const secondUserWatch = await createWatch(secondUser._id.toString()); + + const deleted = await syncService.deleteWatchesByUser( + firstUser._id.toString(), + ); + + expect(deleted).toEqual( + expect.arrayContaining([ + { + channelId: firstUserWatchA._id.toString(), + resourceId: firstUserWatchA.resourceId, + }, + { + channelId: firstUserWatchB._id.toString(), + resourceId: firstUserWatchB.resourceId, + }, + ]), + ); + expect(deleted).toHaveLength(2); + expect( + await mongoService.watch.countDocuments({ + user: firstUser._id.toString(), + }), + ).toBe(0); + expect( + await mongoService.watch.findOne({ _id: secondUserWatch._id }), + ).toEqual(expect.objectContaining({ user: secondUser._id.toString() })); + }); + }); + + describe("stopWatch", () => { + it("deletes the local watch record when Google returns 401", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "401", responseStatus: 401 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("deletes the local watch record when Google returns invalid_grant", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue(invalidGrant400Error); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("preserves the existing delete behavior when Google returns 404", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "404", responseStatus: 404 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).resolves.toBeUndefined(); + + expect(await mongoService.watch.findOne({ _id: watch._id })).toBeNull(); + }); + + it("rethrows unexpected Google stop errors and keeps the local watch", async () => { + const user = await UserDriver.createUser(); + const watch = await createWatch(user._id.toString()); + + jest + .spyOn(gcalService, "stopWatch") + .mockRejectedValue( + createGoogleError({ code: "500", responseStatus: 500 }), + ); + + await expect( + syncService.stopWatch( + user._id.toString(), + watch._id.toString(), + watch.resourceId, + ), + ).rejects.toMatchObject({ code: "500" }); + + expect( + await mongoService.watch.findOne({ + _id: watch._id, + resourceId: watch.resourceId, + }), + ).toEqual(expect.objectContaining({ user: user._id.toString() })); + }); + }); +}); diff --git a/packages/backend/src/sync/services/sync.service.ts b/packages/backend/src/sync/services/sync.service.ts index a473d8946..041874536 100644 --- a/packages/backend/src/sync/services/sync.service.ts +++ b/packages/backend/src/sync/services/sync.service.ts @@ -1,4 +1,3 @@ -import { GaxiosError } from "gaxios"; import { ClientSession, ObjectId } from "mongodb"; import { RESULT_NOTIFIED_CLIENT } from "@core/constants/websocket.constants"; import { Logger } from "@core/logger/winston.logger"; @@ -19,6 +18,10 @@ import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import { WatchError } from "@backend/common/errors/sync/watch.errors"; import gcalService from "@backend/common/services/gcal/gcal.service"; +import { + getGoogleErrorStatus, + isInvalidGoogleToken, +} from "@backend/common/services/gcal/gcal.utils"; import mongoService from "@backend/common/services/mongo.service"; import { webSocketServer } from "@backend/servers/websocket/websocket.server"; import { createSyncImport } from "@backend/sync/services/import/sync.import"; @@ -69,6 +72,22 @@ class SyncService { return response; }; + deleteWatchesByUser = async ( + user: string, + session?: ClientSession, + ): Promise => { + const watches = await mongoService.watch + .find({ user }, { session }) + .toArray(); + + await mongoService.watch.deleteMany({ user }, { session }); + + return watches.map(({ _id, resourceId }) => ({ + channelId: _id.toString(), + resourceId, + })); + }; + async cleanupStaleWatchChannel({ channelId, resourceId, @@ -626,10 +645,9 @@ class SyncService { return { channelId, resourceId }; } catch (e) { - const _e = e as GaxiosError; - const code = (_e.code as unknown as number) || 0; + const status = getGoogleErrorStatus(e); - if (_e.code === "404" || code === 404) { + if (status === 404) { await mongoService.watch.deleteOne(filter, { session }); logger.warn( @@ -639,6 +657,16 @@ class SyncService { return undefined; } + if (status === 401 || isInvalidGoogleToken(e)) { + await mongoService.watch.deleteOne(filter, { session }); + + logger.warn( + "Google authorization is no longer valid. Corresponding sync record deleted", + ); + + return undefined; + } + throw e; } }; diff --git a/packages/backend/src/user/services/user.service.test.ts b/packages/backend/src/user/services/user.service.test.ts index b4e5e95bb..7cbb7c70b 100644 --- a/packages/backend/src/user/services/user.service.test.ts +++ b/packages/backend/src/user/services/user.service.test.ts @@ -15,6 +15,7 @@ import { UserError } from "@backend/common/errors/user/user.errors"; import { initSupertokens } from "@backend/common/middleware/supertokens.middleware"; import mongoService from "@backend/common/services/mongo.service"; import priorityService from "@backend/priority/services/priority.service"; +import syncService from "@backend/sync/services/sync.service"; import userMetadataService from "@backend/user/services/user-metadata.service"; import userService from "@backend/user/services/user.service"; @@ -199,6 +200,37 @@ describe("UserService", () => { }); }); + describe("pruneGoogleData", () => { + it("stops sync and removes google field from user document", async () => { + const user = await UserDriver.createUser(); + const userId = user._id.toString(); + const stopWatchesSpy = jest.spyOn(syncService, "stopWatches"); + const deleteWatchesSpy = jest.spyOn(syncService, "deleteWatchesByUser"); + + expect(user.google).toBeDefined(); + + await userService.startGoogleCalendarSync(userId); + + const eventCountBefore = await mongoService.event.countDocuments({ + user: userId, + }); + expect(eventCountBefore).toBeGreaterThan(0); + + await userService.pruneGoogleData(userId); + + expect(stopWatchesSpy).not.toHaveBeenCalled(); + expect(deleteWatchesSpy).toHaveBeenCalledWith(userId); + + const storedUser = await mongoService.user.findOne({ _id: user._id }); + expect(storedUser?.google).toBeUndefined(); + + expect(await mongoService.event.countDocuments({ user: userId })).toBe(0); + expect(await mongoService.watch.countDocuments({ user: userId })).toBe(0); + const sync = await mongoService.sync.findOne({ user: userId }); + expect(sync).not.toHaveProperty(CalendarProvider.GOOGLE); + }); + }); + describe("restartGoogleCalendarSync", () => { it("restarts the import workflow and completes successfully", async () => { const { user } = await UtilDriver.setupTestUser(); diff --git a/packages/backend/src/user/services/user.service.ts b/packages/backend/src/user/services/user.service.ts index 17c88b032..4193ea7b6 100644 --- a/packages/backend/src/user/services/user.service.ts +++ b/packages/backend/src/user/services/user.service.ts @@ -158,14 +158,28 @@ class UserService { return cUser; }; - stopGoogleCalendarSync = async (user: string | ObjectId): Promise => { + stopGoogleCalendarSync = async ( + user: string | ObjectId, + options?: { skipGoogleWatchStop?: boolean }, + ): Promise => { const userId = zObjectId.parse(user).toString(); + const skipGoogleWatchStop = options?.skipGoogleWatchStop === true; await eventService.deleteByIntegration("google", userId); - await syncService.stopWatches(userId); + if (skipGoogleWatchStop) { + await syncService.deleteWatchesByUser(userId); + } else { + await syncService.stopWatches(userId); + } await syncService.deleteByIntegration("google", userId); }; + pruneGoogleData = async (userId: string): Promise => { + const _id = zObjectId.parse(userId); + await this.stopGoogleCalendarSync(userId, { skipGoogleWatchStop: true }); + await mongoService.user.updateOne({ _id }, { $unset: { google: "" } }); + }; + startGoogleCalendarSync = async ( user: string, ): Promise<{ eventsCount: number; calendarsCount: number }> => { diff --git a/packages/core/src/constants/websocket.constants.ts b/packages/core/src/constants/websocket.constants.ts index 1c21f9859..ee22e33f0 100644 --- a/packages/core/src/constants/websocket.constants.ts +++ b/packages/core/src/constants/websocket.constants.ts @@ -11,6 +11,8 @@ export const USER_METADATA = "USER_METADATA"; export const IMPORT_GCAL_START = "IMPORT_GCAL_START"; export const IMPORT_GCAL_END = "IMPORT_GCAL_END"; +export const GOOGLE_REVOKED = "GOOGLE_REVOKED"; + // client to server events export const EVENT_CHANGE_PROCESSED = "EVENT_CHANGE_PROCESSED"; export const SOMEDAY_EVENT_CHANGE_PROCESSED = "SOMEDAY_EVENT_CHANGE_PROCESSED"; diff --git a/packages/core/src/types/websocket.types.ts b/packages/core/src/types/websocket.types.ts index fa3fc8935..cb75908d2 100644 --- a/packages/core/src/types/websocket.types.ts +++ b/packages/core/src/types/websocket.types.ts @@ -33,6 +33,7 @@ export interface ServerToClientEvents { USER_METADATA: (data: UserMetadata) => void; IMPORT_GCAL_START: () => void; IMPORT_GCAL_END: (reason?: string) => void; + GOOGLE_REVOKED: () => void; } export interface SocketData { diff --git a/packages/web/src/common/apis/compass.api.test.ts b/packages/web/src/common/apis/compass.api.test.ts index ba442be04..b6b0be142 100644 --- a/packages/web/src/common/apis/compass.api.test.ts +++ b/packages/web/src/common/apis/compass.api.test.ts @@ -8,6 +8,7 @@ import { toast } from "react-toastify"; import { signOut } from "supertokens-web-js/recipe/session"; import { Status } from "@core/errors/status.codes"; import { ROOT_ROUTES } from "@web/common/constants/routes"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { CompassApi } from "./compass.api"; jest.mock("supertokens-web-js/recipe/session", () => { @@ -42,11 +43,15 @@ const setLocationPath = (pathname: string) => { }); }; -const createAxiosError = (status: number, url?: string): AxiosError => { +const createAxiosError = ( + status: number, + url?: string, + data?: unknown, +): AxiosError => { const config = { url } as InternalAxiosRequestConfig; const response = { config, - data: {}, + data: data ?? {}, headers: {}, status, statusText: "Error", @@ -62,8 +67,12 @@ const createAxiosError = (status: number, url?: string): AxiosError => { } as AxiosError; }; -const triggerErrorResponse = async (status: number, url?: string) => { - const axiosError = createAxiosError(status, url); +const triggerErrorResponse = async ( + status: number, + url?: string, + data?: unknown, +) => { + const axiosError = createAxiosError(status, url, data); const adapter: AxiosAdapter = () => Promise.reject(axiosError); CompassApi.defaults.adapter = adapter; @@ -157,4 +166,27 @@ describe("CompassApi interceptor auth handling", () => { expect(signOut).not.toHaveBeenCalled(); expect(assignMock).not.toHaveBeenCalled(); }); + + it("does not sign out on 401/410 when response has GOOGLE_REVOKED code", async () => { + const googleRevokedPayload = { + code: "GOOGLE_REVOKED", + message: "Google access revoked.", + }; + const toastExpectation = expect.objectContaining({ + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + + for (const status of [Status.UNAUTHORIZED, Status.GONE]) { + jest.clearAllMocks(); + await triggerErrorResponse(status, undefined, googleRevokedPayload); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + toastExpectation, + ); + expect(signOut).not.toHaveBeenCalled(); + expect(assignMock).not.toHaveBeenCalled(); + } + }); }); diff --git a/packages/web/src/common/apis/compass.api.ts b/packages/web/src/common/apis/compass.api.ts index 0143dd1e4..f0552b4c2 100644 --- a/packages/web/src/common/apis/compass.api.ts +++ b/packages/web/src/common/apis/compass.api.ts @@ -1,9 +1,12 @@ import axios, { AxiosError } from "axios"; +import { GOOGLE_REVOKED } from "@core/constants/websocket.constants"; import { Status } from "@core/errors/status.codes"; +import { getApiErrorCode } from "@web/common/apis/compass.api.util"; import { session } from "@web/common/classes/Session"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import { showSessionExpiredToast } from "@web/common/utils/toast/error-toast.util"; +import { handleGoogleRevoked } from "../utils/auth/google-auth.util"; export const CompassApi = axios.create({ baseURL: ENV_WEB.API_BASEURL, @@ -11,7 +14,7 @@ export const CompassApi = axios.create({ type SignoutStatus = Status.UNAUTHORIZED | Status.NOT_FOUND | Status.GONE; -const _signOut = async (status: SignoutStatus) => { +const signOut = async (status: SignoutStatus) => { // since there are currently duplicate event fetches, // this prevents triggering a separate alert for each fetch // this can be removed once we have logic to cancel subsequent requests @@ -51,12 +54,21 @@ CompassApi.interceptors.response.use( return Promise.reject(error); } + // Google revoked: keep user logged in, show toast, clear Google events, trigger refetch + if ( + (status === Status.GONE || status === Status.UNAUTHORIZED) && + getApiErrorCode(error) === GOOGLE_REVOKED + ) { + handleGoogleRevoked(); + return Promise.reject(error); + } + if ( status === Status.GONE || status === Status.NOT_FOUND || status === Status.UNAUTHORIZED ) { - await _signOut(status); + await signOut(status); } else { console.error(error); } diff --git a/packages/web/src/common/apis/compass.api.util.test.ts b/packages/web/src/common/apis/compass.api.util.test.ts new file mode 100644 index 000000000..b939fbddd --- /dev/null +++ b/packages/web/src/common/apis/compass.api.util.test.ts @@ -0,0 +1,75 @@ +import type { + AxiosError, + AxiosResponse, + InternalAxiosRequestConfig, +} from "axios"; +import { getApiErrorCode } from "./compass.api.util"; + +const createAxiosError = (response: { data?: unknown } | null): AxiosError => + ({ + response: response + ? ({ + data: response.data, + config: {} as InternalAxiosRequestConfig, + headers: {}, + status: 400, + statusText: "Error", + } as AxiosResponse) + : undefined, + }) as AxiosError; + +describe("getApiErrorCode", () => { + it("returns the code when response.data has a string code property", () => { + const error = createAxiosError({ data: { code: "GOOGLE_REVOKED" } }); + expect(getApiErrorCode(error)).toBe("GOOGLE_REVOKED"); + }); + + it("returns the code for arbitrary error codes", () => { + const error = createAxiosError({ data: { code: "FULL_SYNC_REQUIRED" } }); + expect(getApiErrorCode(error)).toBe("FULL_SYNC_REQUIRED"); + }); + + it("returns undefined when error has no response", () => { + const error = createAxiosError(null); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when response has no data", () => { + const error = createAxiosError({ data: undefined }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data is not an object", () => { + const error = createAxiosError({ data: "string body" }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data is an array", () => { + const error = createAxiosError({ data: [] }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when data has no code property", () => { + const error = createAxiosError({ + data: { message: "Something went wrong" }, + }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when code is not a string", () => { + const error = createAxiosError({ data: { code: 404 } }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("returns undefined when code is null", () => { + const error = createAxiosError({ data: { code: null } }); + expect(getApiErrorCode(error)).toBeUndefined(); + }); + + it("preserves message when data has both code and message", () => { + const error = createAxiosError({ + data: { code: "GOOGLE_REVOKED", message: "Google access revoked." }, + }); + expect(getApiErrorCode(error)).toBe("GOOGLE_REVOKED"); + }); +}); diff --git a/packages/web/src/common/apis/compass.api.util.ts b/packages/web/src/common/apis/compass.api.util.ts new file mode 100644 index 000000000..1b6187d18 --- /dev/null +++ b/packages/web/src/common/apis/compass.api.util.ts @@ -0,0 +1,12 @@ +import type { AxiosError } from "axios"; + +/** + * Extracts the error code from an Axios error's response data. + * Returns undefined when the response has no object body with a string `code` property. + */ +export const getApiErrorCode = (error: AxiosError): string | undefined => { + const data = error?.response?.data; + if (!data || typeof data !== "object" || !("code" in data)) return undefined; + const code = (data as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +}; diff --git a/packages/web/src/common/constants/toast.constants.ts b/packages/web/src/common/constants/toast.constants.ts index 2f9d695bd..cb9b19ab1 100644 --- a/packages/web/src/common/constants/toast.constants.ts +++ b/packages/web/src/common/constants/toast.constants.ts @@ -1,7 +1,9 @@ -import { ToastOptions } from "react-toastify"; +import { Id, ToastOptions } from "react-toastify"; import { c } from "@web/common/styles/colors"; import { theme } from "@web/common/styles/theme"; +export const GOOGLE_REVOKED_TOAST_ID: Id = "google-revoked-api"; + export const toastDefaultOptions: ToastOptions = { autoClose: 5000, position: "bottom-left", diff --git a/packages/web/src/common/utils/auth/google-auth.util.test.ts b/packages/web/src/common/utils/auth/google-auth.util.test.ts index fa0cc533a..28c57ab0f 100644 --- a/packages/web/src/common/utils/auth/google-auth.util.test.ts +++ b/packages/web/src/common/utils/auth/google-auth.util.test.ts @@ -1,10 +1,32 @@ +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; import { AuthApi } from "@web/common/apis/auth.api"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; import { SignInUpInput } from "@web/components/oauth/ouath.types"; -import { authenticate, syncLocalEvents } from "./google-auth.util"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { store } from "@web/store"; +import { + authenticate, + handleGoogleRevoked, + syncLocalEvents, +} from "./google-auth.util"; jest.mock("@web/common/apis/auth.api"); jest.mock("@web/common/utils/sync/local-event-sync.util"); +jest.mock("react-toastify", () => ({ + toast: { + error: jest.fn(), + isActive: jest.fn(() => false), + }, +})); +jest.mock("@web/store", () => ({ + store: { + dispatch: jest.fn(), + }, +})); const mockAuthApi = AuthApi as jest.Mocked; const mockSyncLocalEventsToCloud = @@ -89,4 +111,50 @@ describe("google-auth.util", () => { expect(result).toEqual({ syncedCount: 0, success: false, error }); }); }); + + describe("handleGoogleRevoked", () => { + beforeEach(() => { + jest.clearAllMocks(); + (toast.isActive as jest.Mock).mockReturnValue(false); + }); + + it("shows toast with GOOGLE_REVOKED_TOAST_ID when not already active", () => { + handleGoogleRevoked(); + + expect(toast.error).toHaveBeenCalledWith( + "Google access revoked. Your Google data has been removed.", + expect.objectContaining({ + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }), + ); + }); + + it("dispatches removeEventsByOrigin for Google origins", () => { + handleGoogleRevoked(); + + expect(store.dispatch).toHaveBeenCalledWith( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + }); + + it("dispatches triggerFetch with GOOGLE_REVOKED reason", () => { + handleGoogleRevoked(); + + expect(store.dispatch).toHaveBeenCalledWith( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); + }); + + it("does not show toast when one is already active (idempotent)", () => { + (toast.isActive as jest.Mock).mockReturnValue(true); + + handleGoogleRevoked(); + + expect(toast.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/web/src/common/utils/auth/google-auth.util.ts b/packages/web/src/common/utils/auth/google-auth.util.ts index 4d72f7276..9d2b42f92 100644 --- a/packages/web/src/common/utils/auth/google-auth.util.ts +++ b/packages/web/src/common/utils/auth/google-auth.util.ts @@ -1,6 +1,13 @@ +import { toast } from "react-toastify"; +import { Origin } from "@core/constants/core.constants"; import { AuthApi } from "@web/common/apis/auth.api"; +import { GOOGLE_REVOKED_TOAST_ID } from "@web/common/constants/toast.constants"; import { syncLocalEventsToCloud } from "@web/common/utils/sync/local-event-sync.util"; import { SignInUpInput } from "@web/components/oauth/ouath.types"; +import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; +import { eventsEntitiesSlice } from "@web/ducks/events/slices/event.slice"; +import { triggerFetch } from "@web/ducks/events/slices/sync.slice"; +import { store } from "@web/store"; export interface AuthenticateResult { success: boolean; @@ -27,6 +34,24 @@ export async function authenticate( } } +/** Idempotent handler for Google access revocation. Safe to call from both API interceptor and socket handler. */ +export const handleGoogleRevoked = () => { + if (!toast.isActive(GOOGLE_REVOKED_TOAST_ID)) { + toast.error("Google access revoked. Your Google data has been removed.", { + toastId: GOOGLE_REVOKED_TOAST_ID, + autoClose: false, + }); + } + store.dispatch( + eventsEntitiesSlice.actions.removeEventsByOrigin({ + origins: [Origin.GOOGLE, Origin.GOOGLE_IMPORT], + }), + ); + store.dispatch( + triggerFetch({ reason: Sync_AsyncStateContextReason.GOOGLE_REVOKED }), + ); +}; + /** * Sync local events to the cloud. */ diff --git a/packages/web/src/ducks/events/context/sync.context.ts b/packages/web/src/ducks/events/context/sync.context.ts index 3dd327d76..c8200b8de 100644 --- a/packages/web/src/ducks/events/context/sync.context.ts +++ b/packages/web/src/ducks/events/context/sync.context.ts @@ -2,4 +2,5 @@ export enum Sync_AsyncStateContextReason { SOCKET_EVENT_CHANGED = "SOCKET_EVENT_CHANGED", SOCKET_SOMEDAY_EVENT_CHANGED = "SOCKET_SOMEDAY_EVENT_CHANGED", IMPORT_COMPLETE = "IMPORT_COMPLETE", + GOOGLE_REVOKED = "GOOGLE_REVOKED", } diff --git a/packages/web/src/ducks/events/slices/event.slice.ts b/packages/web/src/ducks/events/slices/event.slice.ts index b91221e00..337c9677b 100644 --- a/packages/web/src/ducks/events/slices/event.slice.ts +++ b/packages/web/src/ducks/events/slices/event.slice.ts @@ -1,5 +1,6 @@ import produce from "immer"; -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Origin } from "@core/constants/core.constants"; import { Schema_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { createAsyncSlice } from "@web/common/store/helpers"; @@ -65,6 +66,18 @@ export const eventsEntitiesSlice = createSlice({ const nextState = changeTimezones(state, action.payload.timezone); state.value = nextState.value; }, + removeEventsByOrigin: ( + state, + action: PayloadAction<{ origins: readonly Origin[] }>, + ) => { + const origins = new Set(action.payload.origins); + for (const id of Object.keys(state.value)) { + const event = state.value[id] as Schema_Event | undefined; + if (event?.origin && origins.has(event.origin as Origin)) { + delete state.value[id]; + } + } + }, }, }); diff --git a/packages/web/src/socket/hooks/useGcalSync.test.ts b/packages/web/src/socket/hooks/useGcalSync.test.ts index 6331a07fc..f640dabf7 100644 --- a/packages/web/src/socket/hooks/useGcalSync.test.ts +++ b/packages/web/src/socket/hooks/useGcalSync.test.ts @@ -1,6 +1,7 @@ import { useDispatch } from "react-redux"; import { renderHook } from "@testing-library/react"; import { + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, USER_METADATA, @@ -40,12 +41,25 @@ jest.mock("@web/ducks/events/slices/sync.slice", () => ({ request: jest.fn(), }, }, + importLatestSlice: { + reducer: (state = { isFetchNeeded: false, reason: null }) => state, + actions: { resetIsFetchNeeded: jest.fn() }, + }, triggerFetch: jest.fn(), })); // Mock shouldImportGCal util jest.mock("@core/util/event/event.util", () => ({ shouldImportGCal: jest.fn(() => false), })); +jest.mock("react-toastify", () => ({ + toast: { + error: jest.fn(), + isActive: jest.fn(() => false), + }, +})); +jest.mock("@web/common/utils/auth/google-auth.util", () => ({ + handleGoogleRevoked: jest.fn(), +})); describe("useGcalSync", () => { const mockDispatch = jest.fn(); @@ -83,6 +97,30 @@ describe("useGcalSync", () => { IMPORT_GCAL_END, expect.any(Function), ); + expect(socket.on).toHaveBeenCalledWith( + GOOGLE_REVOKED, + expect.any(Function), + ); + }); + + describe("GOOGLE_REVOKED", () => { + it("calls handleGoogleRevoked when socket event fires", () => { + const { + handleGoogleRevoked, + } = require("@web/common/utils/auth/google-auth.util"); + let onGoogleRevoked: (() => void) | undefined; + (socket.on as jest.Mock).mockImplementation((event, handler) => { + if (event === GOOGLE_REVOKED) { + onGoogleRevoked = handler; + } + }); + + renderHook(() => useGcalSync()); + + onGoogleRevoked?.(); + + expect(handleGoogleRevoked).toHaveBeenCalledTimes(1); + }); }); describe("IMPORT_GCAL_START", () => { @@ -193,6 +231,13 @@ describe("useGcalSync", () => { }); describe("import flow interaction", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it("shows spinner on import start and hides it on successful import end", () => { // Capture socket handlers to simulate backend events const handlers: Record void> = {}; diff --git a/packages/web/src/socket/hooks/useGcalSync.ts b/packages/web/src/socket/hooks/useGcalSync.ts index 1dcb91e2f..e1c46986d 100644 --- a/packages/web/src/socket/hooks/useGcalSync.ts +++ b/packages/web/src/socket/hooks/useGcalSync.ts @@ -1,12 +1,14 @@ import { useCallback, useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; import { + GOOGLE_REVOKED, IMPORT_GCAL_END, IMPORT_GCAL_START, USER_METADATA, } from "@core/constants/websocket.constants"; import { UserMetadata } from "@core/types/user.types"; import { shouldImportGCal } from "@core/util/event/event.util"; +import { handleGoogleRevoked } from "@web/common/utils/auth/google-auth.util"; import { Sync_AsyncStateContextReason } from "@web/ducks/events/context/sync.context"; import { selectIsImportPending } from "@web/ducks/events/selectors/sync.selector"; import { @@ -72,6 +74,10 @@ export const useGcalSync = () => { [dispatch], ); + const onGoogleRevoked = useCallback(() => { + handleGoogleRevoked(); + }, []); + const onMetadataFetch = useCallback( (metadata: UserMetadata) => { const importGcal = shouldImportGCal(metadata); @@ -114,4 +120,11 @@ export const useGcalSync = () => { socket.removeListener(IMPORT_GCAL_END, onImportEnd); }; }, [onImportEnd]); + + useEffect(() => { + socket.on(GOOGLE_REVOKED, onGoogleRevoked); + return () => { + socket.removeListener(GOOGLE_REVOKED, onGoogleRevoked); + }; + }, [onGoogleRevoked]); }; diff --git a/packages/web/src/views/Calendar/hooks/useRefetch.ts b/packages/web/src/views/Calendar/hooks/useRefetch.ts index b747da2d1..bb79bfa4b 100644 --- a/packages/web/src/views/Calendar/hooks/useRefetch.ts +++ b/packages/web/src/views/Calendar/hooks/useRefetch.ts @@ -74,6 +74,23 @@ export const useRefetch = () => { } else { dispatch(getWeekEventsSlice.actions.request(payload)); } + + // Full refresh on Google revoked: also refetch someday events + if (_reason === Sync_AsyncStateContextReason.GOOGLE_REVOKED) { + const dateStart = dayjs(dateRange.start); + const { startDate, endDate } = computeSomedayEventsRequestFilter( + dateStart, + dateStart.endOf("month"), + ); + + dispatch( + getSomedayEventsSlice.actions.request({ + startDate, + endDate, + __context: { reason: _reason }, + }), + ); + } } dispatch(resetIsFetchNeeded());