diff --git a/apps/desktop/src/main/calendar/google/account-routing.test.ts b/apps/desktop/src/main/calendar/google/account-routing.test.ts new file mode 100644 index 000000000..cdad85098 --- /dev/null +++ b/apps/desktop/src/main/calendar/google/account-routing.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings' +import { calendarEvents } from '@memry/db-schema/schema/calendar-events' +import { calendarSources } from '@memry/db-schema/schema/calendar-sources' +import { createTestDataDb, type TestDatabaseResult, type TestDb } from '@tests/utils/test-db' + +vi.mock('./oauth', () => ({ + resolveDefaultGoogleAccountId: vi.fn((_db: unknown) => 'fallback-default@example.com') +})) + +import { resolveTargetGoogleAccountId } from './account-routing' + +const NOW = '2026-04-19T10:00:00.000Z' + +function seedAccountAndCalendar( + db: TestDb, + accountId: string, + calendarRemoteId: string, + calendarSourceId: string +): void { + db.insert(calendarSources) + .values({ + id: `google-account:${accountId}`, + provider: 'google', + kind: 'account', + accountId, + remoteId: accountId, + title: accountId, + timezone: 'UTC', + color: null, + isPrimary: false, + isSelected: false, + isMemryManaged: false, + syncStatus: 'ok', + metadata: null, + createdAt: NOW, + modifiedAt: NOW + }) + .run() + db.insert(calendarSources) + .values({ + id: calendarSourceId, + provider: 'google', + kind: 'calendar', + accountId, + remoteId: calendarRemoteId, + title: `${accountId}/${calendarRemoteId}`, + timezone: 'UTC', + color: null, + isPrimary: false, + isSelected: true, + isMemryManaged: false, + syncStatus: 'ok', + metadata: null, + createdAt: NOW, + modifiedAt: NOW + }) + .run() +} + +describe('resolveTargetGoogleAccountId (M6 T4)', () => { + let dbResult: TestDatabaseResult + let db: TestDb + + beforeEach(() => { + dbResult = createTestDataDb() + db = dbResult.db + }) + + afterEach(() => { + dbResult.close() + }) + + it('routes through existingBinding.remoteCalendarId when present', () => { + // #given two account+calendar pairs; binding points at account B's calendar + seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A') + seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B') + const binding: typeof calendarBindings.$inferSelect = { + id: 'binding-1', + sourceType: 'event', + sourceId: 'evt-1', + provider: 'google', + remoteCalendarId: 'cal-B', + remoteEventId: 'remote-evt-1', + ownershipMode: 'memry_managed', + writebackMode: 'broad', + remoteVersion: null, + lastLocalSnapshot: null, + archivedAt: null, + clock: null, + syncedAt: null, + createdAt: NOW, + modifiedAt: NOW + } + + // #when + const accountId = resolveTargetGoogleAccountId( + db, + { sourceType: 'event', sourceId: 'evt-1' }, + binding + ) + + // #then + expect(accountId).toBe('bob@example.com') + }) + + it('routes through event.targetCalendarId when no existing binding', () => { + seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A') + seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B') + db.insert(calendarEvents) + .values({ + id: 'evt-2', + title: 'Routed event', + startAt: NOW, + endAt: null, + timezone: 'UTC', + isAllDay: false, + targetCalendarId: 'cal-A', + createdAt: NOW, + modifiedAt: NOW + }) + .run() + + const accountId = resolveTargetGoogleAccountId( + db, + { sourceType: 'event', sourceId: 'evt-2' }, + undefined + ) + + expect(accountId).toBe('alice@example.com') + }) + + it('falls back to default Google account when no calendar context resolves', () => { + seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A') + + const accountId = resolveTargetGoogleAccountId( + db, + { sourceType: 'task', sourceId: 'task-1' }, + undefined + ) + + expect(accountId).toBe('fallback-default@example.com') + }) + + it('returns null when neither calendar context nor any default account exists', async () => { + const oauth = await import('./oauth') + vi.mocked(oauth.resolveDefaultGoogleAccountId).mockReturnValueOnce(null) + + const accountId = resolveTargetGoogleAccountId( + db, + { sourceType: 'task', sourceId: 'task-1' }, + undefined + ) + + expect(accountId).toBeNull() + }) +}) diff --git a/apps/desktop/src/main/calendar/google/account-routing.ts b/apps/desktop/src/main/calendar/google/account-routing.ts new file mode 100644 index 000000000..3d58ccc82 --- /dev/null +++ b/apps/desktop/src/main/calendar/google/account-routing.ts @@ -0,0 +1,53 @@ +import { and, eq, isNull } from 'drizzle-orm' +import { calendarEvents } from '@memry/db-schema/schema/calendar-events' +import { calendarSources } from '@memry/db-schema/schema/calendar-sources' +import type { calendarBindings } from '@memry/db-schema/schema/calendar-bindings' +import type { DataDb } from '../../database/types' +import type { CalendarSyncTarget } from '../types' +import { resolveDefaultGoogleAccountId } from './oauth' + +function findAccountIdForCalendarRemoteId(db: DataDb, remoteCalendarId: string): string | null { + const row = db + .select({ accountId: calendarSources.accountId }) + .from(calendarSources) + .where( + and( + eq(calendarSources.provider, 'google'), + eq(calendarSources.kind, 'calendar'), + eq(calendarSources.remoteId, remoteCalendarId), + isNull(calendarSources.archivedAt) + ) + ) + .get() + return row?.accountId ?? null +} + +function findEventTargetCalendarId(db: DataDb, eventId: string): string | null { + const row = db + .select({ targetCalendarId: calendarEvents.targetCalendarId }) + .from(calendarEvents) + .where(eq(calendarEvents.id, eventId)) + .get() + return row?.targetCalendarId ?? null +} + +export function resolveTargetGoogleAccountId( + db: DataDb, + target: CalendarSyncTarget, + existingBinding: typeof calendarBindings.$inferSelect | undefined +): string | null { + if (existingBinding?.remoteCalendarId) { + const accountId = findAccountIdForCalendarRemoteId(db, existingBinding.remoteCalendarId) + if (accountId) return accountId + } + + if (target.sourceType === 'event') { + const targetCalendarId = findEventTargetCalendarId(db, target.sourceId) + if (targetCalendarId) { + const accountId = findAccountIdForCalendarRemoteId(db, targetCalendarId) + if (accountId) return accountId + } + } + + return resolveDefaultGoogleAccountId(db) +} diff --git a/apps/desktop/src/main/calendar/google/client.test.ts b/apps/desktop/src/main/calendar/google/client.test.ts index 391ee59e5..408b35723 100644 --- a/apps/desktop/src/main/calendar/google/client.test.ts +++ b/apps/desktop/src/main/calendar/google/client.test.ts @@ -29,7 +29,11 @@ vi.mock('../../lib/logger', () => ({ })) import { createGoogleCalendarClient } from './client' -import { clearGoogleCalendarTokens, storeGoogleCalendarTokens } from './keychain' +import { + LEGACY_DEFAULT_ACCOUNT_ID, + clearGoogleCalendarTokens, + storeGoogleCalendarTokens +} from './keychain' describe('google calendar client — push channels (Task 7)', () => { const keytarStore = new Map() @@ -53,6 +57,7 @@ describe('google calendar client — push channels (Task 7)', () => { }) await storeGoogleCalendarTokens({ + accountId: LEGACY_DEFAULT_ACCOUNT_ID, accessToken: 'seeded-access-token', refreshToken: 'seeded-refresh-token' }) @@ -61,7 +66,7 @@ describe('google calendar client — push channels (Task 7)', () => { afterEach(async () => { delete process.env.GOOGLE_CALENDAR_CLIENT_ID vi.unstubAllGlobals() - await clearGoogleCalendarTokens() + await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID) }) describe('watchCalendar', () => { @@ -98,7 +103,7 @@ describe('google calendar client — push channels (Task 7)', () => { ) }) - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) const result = await client.watchCalendar({ calendarId: 'primary@group.calendar.google.com', channelId: 'channel-abc', @@ -124,7 +129,7 @@ describe('google calendar client — push channels (Task 7)', () => { ) ) - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await expect( client.watchCalendar({ calendarId: 'primary', @@ -148,7 +153,7 @@ describe('google calendar client — push channels (Task 7)', () => { return new Response(null, { status: 204 }) }) - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await expect( client.stopChannel({ channelId: 'channel-abc', resourceId: 'resource-123' }) ).resolves.toBeUndefined() @@ -162,7 +167,7 @@ describe('google calendar client — push channels (Task 7)', () => { ) ) - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await expect( client.stopChannel({ channelId: 'stale', resourceId: 'stale-resource' }) ).resolves.toBeUndefined() @@ -171,7 +176,7 @@ describe('google calendar client — push channels (Task 7)', () => { it('throws for non-404 errors (e.g. 500)', async () => { fetchMock.mockResolvedValue(new Response('oops', { status: 500 })) - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await expect(client.stopChannel({ channelId: 'c', resourceId: 'r' })).rejects.toThrow() }) }) @@ -204,7 +209,7 @@ describe('google calendar client — push channels (Task 7)', () => { it('#given an all-day recurring exception #when upserted #then emits originalStartTime as { date } (no dateTime)', async () => { const captured = captureBody() - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await client.upsertEvent({ calendarId: 'primary', eventId: null, @@ -233,7 +238,7 @@ describe('google calendar client — push channels (Task 7)', () => { it('#given a timed recurring exception #when upserted #then emits originalStartTime as { dateTime, timeZone }', async () => { const captured = captureBody() - const client = createGoogleCalendarClient() + const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID }) await client.upsertEvent({ calendarId: 'primary', eventId: null, diff --git a/apps/desktop/src/main/calendar/google/client.ts b/apps/desktop/src/main/calendar/google/client.ts index da4665070..171c2dcc3 100644 --- a/apps/desktop/src/main/calendar/google/client.ts +++ b/apps/desktop/src/main/calendar/google/client.ts @@ -13,7 +13,7 @@ const log = createLogger('Calendar:GoogleClient') const GOOGLE_API_BASE = 'https://www.googleapis.com/calendar/v3' const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' -let pendingRefresh: Promise | null = null +const pendingRefreshes = new Map>() const GoogleCalendarListItemSchema = z.object({ id: z.string().min(1), @@ -133,9 +133,7 @@ function mapCalendar(item: z.infer): Google } } -function resolveOriginalStartTime( - raw: z.infer -): string | null { +function resolveOriginalStartTime(raw: z.infer): string | null { const ost = raw.originalStartTime if (!ost) return null if (ost.dateTime) return ost.dateTime @@ -304,12 +302,12 @@ function toGoogleEventPayload(event: GoogleCalendarUpsertEventInput): Record { +async function refreshAccessTokenInner(accountId: string): Promise { const clientId = resolveGoogleClientId() const clientSecret = resolveGoogleClientSecret() - const { refreshToken } = await getGoogleCalendarTokens() + const { refreshToken } = await getGoogleCalendarTokens(accountId) if (!refreshToken) { - throw new Error('Google Calendar is not connected on this device') + throw new Error(`Google Calendar is not connected for account ${accountId}`) } const params = new URLSearchParams({ @@ -357,18 +355,21 @@ async function refreshAccessTokenInner(): Promise { const parsed = GoogleTokenRefreshSchema.parse(await response.json()) await storeGoogleCalendarTokens({ + accountId, accessToken: parsed.access_token, refreshToken }) return parsed.access_token } -async function refreshAccessToken(): Promise { - if (pendingRefresh) return pendingRefresh - pendingRefresh = refreshAccessTokenInner().finally(() => { - pendingRefresh = null +async function refreshAccessToken(accountId: string): Promise { + const existing = pendingRefreshes.get(accountId) + if (existing) return existing + const promise = refreshAccessTokenInner(accountId).finally(() => { + pendingRefreshes.delete(accountId) }) - return pendingRefresh + pendingRefreshes.set(accountId, promise) + return promise } async function throwCalendarApiFailure(response: Response, operation: string): Promise { @@ -400,6 +401,7 @@ async function throwCalendarApiFailure(response: Response, operation: string): P } async function withAuthorizedResponse( + accountId: string, input: { path: string init?: RequestInit @@ -407,8 +409,8 @@ async function withAuthorizedResponse( }, retry = true ): Promise { - const tokens = await getGoogleCalendarTokens() - const accessToken = tokens.accessToken ?? (await refreshAccessToken()) + const tokens = await getGoogleCalendarTokens(accountId) + const accessToken = tokens.accessToken ?? (await refreshAccessToken(accountId)) const url = new URL(`${GOOGLE_API_BASE}${input.path}`) for (const [key, value] of Object.entries(input.query ?? {})) { @@ -427,18 +429,28 @@ async function withAuthorizedResponse( }) if (response.status === 401 && retry) { - log.warn('Google Calendar access token expired, refreshing') - await refreshAccessToken() - return await withAuthorizedResponse(input, false) + log.warn('Google Calendar access token expired, refreshing', { accountId }) + await refreshAccessToken(accountId) + return await withAuthorizedResponse(accountId, input, false) } return response } -export function createGoogleCalendarClient(): GoogleCalendarClient { +export interface CreateGoogleCalendarClientInput { + accountId: string +} + +export function createGoogleCalendarClient( + input: CreateGoogleCalendarClientInput +): GoogleCalendarClient { + const { accountId } = input + if (!accountId || !accountId.trim()) { + throw new Error('createGoogleCalendarClient requires a non-empty accountId') + } return { async listCalendars(): Promise { - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: '/users/me/calendarList' }) @@ -451,7 +463,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { }, async createCalendar(input): Promise { - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: '/calendars', init: { method: 'POST', @@ -475,7 +487,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { }> { const useSyncToken = Boolean(input.syncCursor) - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: `/calendars/${encodeURIComponent(input.calendarId)}/events`, query: useSyncToken ? { @@ -507,7 +519,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { }, async getEvent(input): Promise { - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId)}` }) @@ -524,7 +536,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { if (isUpdate && input.ifMatch) { headers['If-Match'] = input.ifMatch } - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: isUpdate ? `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId!)}` : `/calendars/${encodeURIComponent(input.calendarId)}/events`, @@ -543,7 +555,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { }, async deleteEvent(input): Promise { - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: `/calendars/${encodeURIComponent(input.calendarId)}/events/${encodeURIComponent(input.eventId)}`, init: { method: 'DELETE' @@ -557,7 +569,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { async watchCalendar(input): Promise<{ resourceId: string; expiration: number }> { const expirationMs = Date.now() + input.ttlSeconds * 1000 - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: `/calendars/${encodeURIComponent(input.calendarId)}/events/watch`, init: { method: 'POST', @@ -587,7 +599,7 @@ export function createGoogleCalendarClient(): GoogleCalendarClient { }, async stopChannel(input): Promise { - const response = await withAuthorizedResponse({ + const response = await withAuthorizedResponse(accountId, { path: '/channels/stop', init: { method: 'POST', diff --git a/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts b/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts index 75ff5da28..71555039b 100644 --- a/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts +++ b/apps/desktop/src/main/calendar/google/google-channel-manager.test.ts @@ -145,6 +145,42 @@ describe('google-channel-manager', () => { expect(onActiveCountChange).toHaveBeenCalledWith(1) }) + + it('resolves a client per source and reuses that client for stop operations', async () => { + const clientA = { + watchCalendar: vi.fn(async (input: { channelId: string }) => ({ + resourceId: `resource-${input.channelId}`, + expiration: FIXED_NOW_MS + TTL_SECONDS * 1000 + })), + stopChannel: vi.fn(async () => {}) + } + const clientB = { + watchCalendar: vi.fn(async (input: { channelId: string }) => ({ + resourceId: `resource-${input.channelId}`, + expiration: FIXED_NOW_MS + TTL_SECONDS * 1000 + })), + stopChannel: vi.fn(async () => {}) + } + const resolveClient = vi.fn( + ({ sourceId }: { sourceId: string }) => (sourceId === 'src-1' ? clientA : clientB) + ) + const deps = buildDeps({ resolveClient }) + const mgr = createGoogleChannelManager(deps) + + await mgr.ensureChannelForSource({ sourceId: 'src-1', calendarId: 'cal-a' }) + await mgr.ensureChannelForSource({ sourceId: 'src-2', calendarId: 'cal-b' }) + await mgr.stopForSource('src-2') + + expect(resolveClient).toHaveBeenCalledWith({ sourceId: 'src-1', calendarId: 'cal-a' }) + expect(resolveClient).toHaveBeenCalledWith({ sourceId: 'src-2', calendarId: 'cal-b' }) + expect(clientA.watchCalendar).toHaveBeenCalledTimes(1) + expect(clientB.watchCalendar).toHaveBeenCalledTimes(1) + expect(clientB.stopChannel).toHaveBeenCalledWith({ + channelId: 'channel-2', + resourceId: 'resource-channel-2' + }) + expect(deps.client.watchCalendar).not.toHaveBeenCalled() + }) }) describe('stopForSource', () => { diff --git a/apps/desktop/src/main/calendar/google/google-channel-manager.ts b/apps/desktop/src/main/calendar/google/google-channel-manager.ts index d14e4c6a3..8c3003574 100644 --- a/apps/desktop/src/main/calendar/google/google-channel-manager.ts +++ b/apps/desktop/src/main/calendar/google/google-channel-manager.ts @@ -3,8 +3,11 @@ import type { GoogleCalendarClient } from '../types' const log = createLogger('Calendar:GoogleChannelManager') +type GoogleChannelClient = Pick + export interface GoogleChannelManagerDeps { - client: Pick + client: GoogleChannelClient + resolveClient?(input: { sourceId: string; calendarId: string }): GoogleChannelClient registerOnServer(input: { channelId: string sourceId: string @@ -38,6 +41,7 @@ interface ChannelState { resourceId: string expirationMs: number rotationTimer: ReturnType + client: GoogleChannelClient } export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): GoogleChannelManager { @@ -54,10 +58,11 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog const nowMs = (deps.now ?? Date.now)() const expirationMs = nowMs + deps.ttlSeconds * 1000 const expiresAt = Math.floor(nowMs / 1000) + deps.ttlSeconds + const client = deps.resolveClient?.({ sourceId, calendarId }) ?? deps.client await deps.registerOnServer({ channelId, sourceId, tokenHash, expiresAt }) - const watchResult = await deps.client.watchCalendar({ + const watchResult = await client.watchCalendar({ calendarId, channelId, token: plaintextToken, @@ -81,7 +86,8 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog channelId, resourceId: watchResult.resourceId, expirationMs: watchResult.expiration ?? expirationMs, - rotationTimer + rotationTimer, + client } } @@ -118,7 +124,7 @@ export function createGoogleChannelManager(deps: GoogleChannelManagerDeps): Goog states.delete(sourceId) try { - await deps.client.stopChannel({ + await state.client.stopChannel({ channelId: state.channelId, resourceId: state.resourceId }) diff --git a/apps/desktop/src/main/calendar/google/keychain.test.ts b/apps/desktop/src/main/calendar/google/keychain.test.ts new file mode 100644 index 000000000..3538ac4b6 --- /dev/null +++ b/apps/desktop/src/main/calendar/google/keychain.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import keytar from 'keytar' + +vi.mock('keytar', () => ({ + default: { + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn() + } +})) + +import { + clearGoogleCalendarTokens, + getAccountKey, + getGoogleCalendarTokens, + hasGoogleCalendarTokens, + storeGoogleCalendarTokens +} from './keychain' + +describe('google calendar keychain — multi-account partitioning', () => { + const keytarStore = new Map() + + beforeEach(() => { + vi.clearAllMocks() + keytarStore.clear() + delete process.env.MEMRY_DEVICE + + vi.mocked(keytar.setPassword).mockImplementation(async (service, account, value) => { + keytarStore.set(`${service}:${account}`, value) + }) + vi.mocked(keytar.getPassword).mockImplementation(async (service, account) => { + return keytarStore.get(`${service}:${account}`) ?? null + }) + vi.mocked(keytar.deletePassword).mockImplementation(async (service, account) => { + return keytarStore.delete(`${service}:${account}`) + }) + }) + + afterEach(() => { + delete process.env.MEMRY_DEVICE + }) + + it('stores tokens for different accountIds in distinct keytar slots', async () => { + await storeGoogleCalendarTokens({ + accountId: 'alice@example.com', + accessToken: 'alice-access', + refreshToken: 'alice-refresh' + }) + await storeGoogleCalendarTokens({ + accountId: 'bob@example.com', + accessToken: 'bob-access', + refreshToken: 'bob-refresh' + }) + + expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({ + accessToken: 'alice-access', + refreshToken: 'alice-refresh' + }) + expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({ + accessToken: 'bob-access', + refreshToken: 'bob-refresh' + }) + + // Four distinct (service, account) slots written: 2 accounts * 2 token kinds. + const writeCalls = vi.mocked(keytar.setPassword).mock.calls + const accounts = writeCalls.map((call) => call[1]) + expect(new Set(accounts).size).toBe(4) + }) + + it('returns null tokens when accountId has no stored credentials', async () => { + const tokens = await getGoogleCalendarTokens('nobody@example.com') + expect(tokens).toEqual({ accessToken: null, refreshToken: null }) + expect(await hasGoogleCalendarTokens('nobody@example.com')).toBe(false) + }) + + it('clearing one account does not affect another account', async () => { + await storeGoogleCalendarTokens({ + accountId: 'alice@example.com', + accessToken: 'a1', + refreshToken: 'r1' + }) + await storeGoogleCalendarTokens({ + accountId: 'bob@example.com', + accessToken: 'a2', + refreshToken: 'r2' + }) + + await clearGoogleCalendarTokens('alice@example.com') + + expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({ + accessToken: null, + refreshToken: null + }) + expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({ + accessToken: 'a2', + refreshToken: 'r2' + }) + expect(await hasGoogleCalendarTokens('alice@example.com')).toBe(false) + expect(await hasGoogleCalendarTokens('bob@example.com')).toBe(true) + }) + + it('getAccountKey returns a deterministic keytar account string including accountId and kind', () => { + const aliceAccess = getAccountKey('alice@example.com', 'access-token') + const aliceRefresh = getAccountKey('alice@example.com', 'refresh-token') + const bobAccess = getAccountKey('bob@example.com', 'access-token') + + expect(aliceAccess).toContain('alice@example.com') + expect(aliceAccess).toContain('access-token') + expect(aliceRefresh).toContain('refresh-token') + expect(aliceAccess).not.toEqual(bobAccess) + // Kind substring stable for pattern-match assertions elsewhere. + expect(aliceAccess.startsWith('access-token')).toBe(true) + expect(aliceRefresh.startsWith('refresh-token')).toBe(true) + }) + + it('respects MEMRY_DEVICE env suffix on top of accountId partitioning', async () => { + process.env.MEMRY_DEVICE = 'devA' + + await storeGoogleCalendarTokens({ + accountId: 'alice@example.com', + accessToken: 'devA-alice-access', + refreshToken: 'devA-alice-refresh' + }) + + const writes = vi.mocked(keytar.setPassword).mock.calls + for (const [, account] of writes) { + expect(account).toContain('alice@example.com') + expect(account).toContain('devA') + } + }) +}) diff --git a/apps/desktop/src/main/calendar/google/keychain.ts b/apps/desktop/src/main/calendar/google/keychain.ts index 184e0d131..3f404a03b 100644 --- a/apps/desktop/src/main/calendar/google/keychain.ts +++ b/apps/desktop/src/main/calendar/google/keychain.ts @@ -1,80 +1,94 @@ import keytar from 'keytar' const SERVICE = 'com.memry.calendar.google' -const ACCESS_TOKEN_ACCOUNT = 'access-token' -const REFRESH_TOKEN_ACCOUNT = 'refresh-token' -function resolveAccount(account: string): string { +export const LEGACY_DEFAULT_ACCOUNT_ID = '__memry_default__' + +export type GoogleTokenKind = 'access-token' | 'refresh-token' + +export function getAccountKey(accountId: string, kind: GoogleTokenKind): string { + if (!accountId || !accountId.trim()) { + throw new Error('getAccountKey requires a non-empty accountId') + } const deviceSuffix = process.env.MEMRY_DEVICE - return deviceSuffix ? `${account}-${deviceSuffix}` : account + const base = `${kind}-${accountId}` + return deviceSuffix ? `${base}-${deviceSuffix}` : base } -async function setPassword(account: string, value: string | null): Promise { - const resolvedAccount = resolveAccount(account) +async function setPassword( + accountId: string, + kind: GoogleTokenKind, + value: string | null +): Promise { + const account = getAccountKey(accountId, kind) try { if (!value || value.trim().length === 0) { - await keytar.deletePassword(SERVICE, resolvedAccount) + await keytar.deletePassword(SERVICE, account) return } - await keytar.setPassword(SERVICE, resolvedAccount, value.trim()) + await keytar.setPassword(SERVICE, account, value.trim()) } catch (error) { throw new Error( - `Failed to store Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}` + `Failed to store Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` ) } } -async function getPassword(account: string): Promise { - const resolvedAccount = resolveAccount(account) +async function getPassword(accountId: string, kind: GoogleTokenKind): Promise { + const account = getAccountKey(accountId, kind) try { - return await keytar.getPassword(SERVICE, resolvedAccount) + return await keytar.getPassword(SERVICE, account) } catch (error) { throw new Error( - `Failed to read Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}` + `Failed to read Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` ) } } -async function deletePassword(account: string): Promise { - const resolvedAccount = resolveAccount(account) +async function deletePassword(accountId: string, kind: GoogleTokenKind): Promise { + const account = getAccountKey(accountId, kind) try { - await keytar.deletePassword(SERVICE, resolvedAccount) + await keytar.deletePassword(SERVICE, account) } catch (error) { throw new Error( - `Failed to delete Google Calendar credential (${resolvedAccount}): ${error instanceof Error ? error.message : 'unknown error'}` + `Failed to delete Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` ) } } export async function storeGoogleCalendarTokens(input: { + accountId: string accessToken: string refreshToken: string }): Promise { - await setPassword(ACCESS_TOKEN_ACCOUNT, input.accessToken) - await setPassword(REFRESH_TOKEN_ACCOUNT, input.refreshToken) + await setPassword(input.accountId, 'access-token', input.accessToken) + await setPassword(input.accountId, 'refresh-token', input.refreshToken) } -export async function getGoogleCalendarTokens(): Promise<{ +export async function getGoogleCalendarTokens(accountId: string): Promise<{ accessToken: string | null refreshToken: string | null }> { const [accessToken, refreshToken] = await Promise.all([ - getPassword(ACCESS_TOKEN_ACCOUNT), - getPassword(REFRESH_TOKEN_ACCOUNT) + getPassword(accountId, 'access-token'), + getPassword(accountId, 'refresh-token') ]) return { accessToken, refreshToken } } -export async function hasGoogleCalendarTokens(): Promise { - const { refreshToken } = await getGoogleCalendarTokens() +export async function hasGoogleCalendarTokens(accountId: string): Promise { + const { refreshToken } = await getGoogleCalendarTokens(accountId) return typeof refreshToken === 'string' && refreshToken.trim().length > 0 } -export async function clearGoogleCalendarTokens(): Promise { - await Promise.all([deletePassword(ACCESS_TOKEN_ACCOUNT), deletePassword(REFRESH_TOKEN_ACCOUNT)]) +export async function clearGoogleCalendarTokens(accountId: string): Promise { + await Promise.all([ + deletePassword(accountId, 'access-token'), + deletePassword(accountId, 'refresh-token') + ]) } diff --git a/apps/desktop/src/main/calendar/google/oauth.test.ts b/apps/desktop/src/main/calendar/google/oauth.test.ts index ed99e8760..f2775b878 100644 --- a/apps/desktop/src/main/calendar/google/oauth.test.ts +++ b/apps/desktop/src/main/calendar/google/oauth.test.ts @@ -33,6 +33,7 @@ vi.mock('../../lib/logger', () => ({ import { GOOGLE_CALENDAR_SCOPE, connectGoogleCalendar, disconnectGoogleCalendar } from './oauth' import { + LEGACY_DEFAULT_ACCOUNT_ID, clearGoogleCalendarTokens, getGoogleCalendarTokens, hasGoogleCalendarTokens, @@ -64,7 +65,7 @@ describe('google calendar oauth', () => { afterEach(async () => { delete process.env.GOOGLE_CALENDAR_CLIENT_ID vi.unstubAllGlobals() - await clearGoogleCalendarTokens() + await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID) }) it('uses a provider-specific loopback OAuth flow with Calendar scopes and stores tokens in a separate device-local keychain', async () => { @@ -89,6 +90,23 @@ describe('google calendar oauth', () => { ) } + if (url === 'https://www.googleapis.com/oauth2/v2/userinfo') { + expect(init?.headers).toEqual( + expect.objectContaining({ + Authorization: 'Bearer google-access-token' + }) + ) + return new Response( + JSON.stringify({ + email: 'user@example.com', + verified_email: true, + name: 'User Example', + picture: 'https://example.com/avatar.png' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + if (url === 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary') { expect(init?.headers).toEqual( expect.objectContaining({ @@ -130,11 +148,13 @@ describe('google calendar oauth', () => { }) const result = await connectGoogleCalendar() - const tokens = await getGoogleCalendarTokens() + const tokens = await getGoogleCalendarTokens('user@example.com') expect(result).toEqual({ + accountId: 'user@example.com', account: { remoteId: 'user@example.com', + email: 'user@example.com', title: 'User Example', timezone: 'Europe/Istanbul' }, @@ -150,15 +170,15 @@ describe('google calendar oauth', () => { accessToken: 'google-access-token', refreshToken: 'google-refresh-token' }) - expect(await hasGoogleCalendarTokens()).toBe(true) + expect(await hasGoogleCalendarTokens('user@example.com')).toBe(true) expect(keytar.setPassword).toHaveBeenCalledWith( 'com.memry.calendar.google', - expect.stringContaining('access-token'), + expect.stringContaining('user@example.com'), 'google-access-token' ) expect(keytar.setPassword).toHaveBeenCalledWith( 'com.memry.calendar.google', - expect.stringContaining('refresh-token'), + expect.stringContaining('user@example.com'), 'google-refresh-token' ) }) @@ -213,7 +233,7 @@ describe('google calendar oauth', () => { }) ) - expect(await hasGoogleCalendarTokens()).toBe(false) + expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(false) }) it('rejects the callback when the OAuth state does not match', async () => { @@ -234,34 +254,106 @@ describe('google calendar oauth', () => { 'Invalid or expired Google Calendar OAuth state' ) expect(fetchMock).not.toHaveBeenCalled() - expect(await hasGoogleCalendarTokens()).toBe(false) + expect(await hasGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toBe(false) }) it('stores and clears Google Calendar tokens independently from sync auth keychain entries', async () => { + fetchMock.mockImplementation(async () => new Response('', { status: 200 })) + await storeGoogleCalendarTokens({ + accountId: 'manual@example.com', accessToken: 'manual-access-token', refreshToken: 'manual-refresh-token' }) - expect(await hasGoogleCalendarTokens()).toBe(true) - expect(await getGoogleCalendarTokens()).toEqual({ + expect(await hasGoogleCalendarTokens('manual@example.com')).toBe(true) + expect(await getGoogleCalendarTokens('manual@example.com')).toEqual({ accessToken: 'manual-access-token', refreshToken: 'manual-refresh-token' }) - await disconnectGoogleCalendar() + await disconnectGoogleCalendar('manual@example.com') - expect(await getGoogleCalendarTokens()).toEqual({ + expect(await getGoogleCalendarTokens('manual@example.com')).toEqual({ accessToken: null, refreshToken: null }) expect(keytar.deletePassword).toHaveBeenCalledWith( 'com.memry.calendar.google', - expect.stringContaining('access-token') - ) - expect(keytar.deletePassword).toHaveBeenCalledWith( - 'com.memry.calendar.google', - expect.stringContaining('refresh-token') + expect.stringContaining('manual@example.com') ) }) + + it('connecting a second Google account stores tokens under a distinct accountId without overwriting the first', async () => { + let userInfoCalls = 0 + fetchMock.mockImplementation(async (input) => { + const url = String(input) + + if (url === 'https://oauth2.googleapis.com/token') { + return new Response( + JSON.stringify({ + access_token: userInfoCalls === 0 ? 'first-access' : 'second-access', + refresh_token: userInfoCalls === 0 ? 'first-refresh' : 'second-refresh', + expires_in: 3600, + scope: `openid email profile ${GOOGLE_CALENDAR_SCOPE}`, + token_type: 'Bearer' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + + if (url === 'https://www.googleapis.com/oauth2/v2/userinfo') { + const email = userInfoCalls === 0 ? 'alice@example.com' : 'bob@example.com' + userInfoCalls++ + return new Response( + JSON.stringify({ email, verified_email: true, name: email.split('@')[0] }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + + if (url === 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary') { + const email = userInfoCalls === 1 ? 'alice@example.com' : 'bob@example.com' + return new Response( + JSON.stringify({ + id: email, + summary: email, + timeZone: 'UTC', + primary: true + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + + throw new Error(`Unexpected fetch call: ${url}`) + }) + + mockOpenExternal.mockImplementation(async (authUrl: string) => { + const parsed = new URL(authUrl) + const redirectUri = parsed.searchParams.get('redirect_uri') + const state = parsed.searchParams.get('state') + setTimeout(() => { + http.get(`${redirectUri}?code=google-auth-code&state=${state}`) + }, 0) + }) + + const first = await connectGoogleCalendar() + const second = await connectGoogleCalendar() + + expect(first.accountId).toBe('alice@example.com') + expect(second.accountId).toBe('bob@example.com') + + expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({ + accessToken: 'first-access', + refreshToken: 'first-refresh' + }) + expect(await getGoogleCalendarTokens('bob@example.com')).toEqual({ + accessToken: 'second-access', + refreshToken: 'second-refresh' + }) + + await disconnectGoogleCalendar('alice@example.com') + + expect(await hasGoogleCalendarTokens('alice@example.com')).toBe(false) + expect(await hasGoogleCalendarTokens('bob@example.com')).toBe(true) + }) }) diff --git a/apps/desktop/src/main/calendar/google/oauth.ts b/apps/desktop/src/main/calendar/google/oauth.ts index 10bb1b757..50350d437 100644 --- a/apps/desktop/src/main/calendar/google/oauth.ts +++ b/apps/desktop/src/main/calendar/google/oauth.ts @@ -23,6 +23,7 @@ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke' const GOOGLE_PRIMARY_CALENDAR_URL = 'https://www.googleapis.com/calendar/v3/users/me/calendarList/primary' +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo' const OAUTH_TIMEOUT_MS = 10 * 60 * 1000 export const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar' @@ -45,6 +46,12 @@ const GooglePrimaryCalendarSchema = z.object({ primary: z.boolean().optional() }) +const GoogleUserInfoSchema = z.object({ + email: z.string().min(1), + verified_email: z.boolean().optional(), + name: z.string().optional() +}) + interface GoogleOAuthSession { state: string redirectUri: string @@ -53,8 +60,10 @@ interface GoogleOAuthSession { } export interface GoogleCalendarConnection { + accountId: string account: { remoteId: string + email: string title: string timezone: string | null } @@ -242,6 +251,31 @@ async function exchangeCodeForTokens(input: { return GoogleTokenResponseSchema.parse(await response.json()) } +async function fetchUserInfo(accessToken: string): Promise> { + const response = await fetch(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` } + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + let apiStatus: string | undefined + try { + const parsedBody = JSON.parse(body) as { error?: { status?: string } } + apiStatus = parsedBody.error?.status + } catch { + // body may be plain text or empty + } + log.error('Failed to fetch Google userinfo', { + status: response.status, + apiStatus, + body: body.slice(0, 500) + }) + throw new Error(userMessageForCalendarApiError({ status: response.status, apiStatus })) + } + + return GoogleUserInfoSchema.parse(await response.json()) +} + async function fetchPrimaryCalendar( accessToken: string ): Promise> { @@ -409,13 +443,18 @@ export async function connectGoogleCalendar(): Promise throw new Error(CALENDAR_SCOPE_NOT_GRANTED_MESSAGE) } - const existingTokens = await getGoogleCalendarTokens() + const userInfo = await fetchUserInfo(tokenResponse.access_token) + const accountId = userInfo.email + log.info('Resolved Google account identity', { accountId }) + + const existingTokens = await getGoogleCalendarTokens(accountId) const refreshToken = tokenResponse.refresh_token ?? existingTokens.refreshToken if (!refreshToken) { throw new Error('Google Calendar OAuth did not return a refresh token') } await storeGoogleCalendarTokens({ + accountId, accessToken: tokenResponse.access_token, refreshToken }) @@ -425,9 +464,11 @@ export async function connectGoogleCalendar(): Promise const timezone = primaryCalendar.timeZone ?? null return { + accountId, account: { remoteId: primaryCalendar.id, - title, + email: userInfo.email, + title: userInfo.name ?? title, timezone }, primaryCalendar: { @@ -440,8 +481,8 @@ export async function connectGoogleCalendar(): Promise } } -export async function disconnectGoogleCalendar(): Promise { - const { refreshToken } = await getGoogleCalendarTokens() +export async function disconnectGoogleCalendar(accountId: string): Promise { + const { refreshToken } = await getGoogleCalendarTokens(accountId) if (refreshToken) { try { @@ -451,22 +492,46 @@ export async function disconnectGoogleCalendar(): Promise { body: new URLSearchParams({ token: refreshToken }) }) } catch (error) { - log.warn('Failed to revoke Google Calendar token (non-blocking)', error) + log.warn('Failed to revoke Google Calendar token (non-blocking)', { accountId, error }) } } - await clearGoogleCalendarTokens() + await clearGoogleCalendarTokens(accountId) } -export async function hasGoogleCalendarLocalAuth(): Promise { - const { refreshToken } = await getGoogleCalendarTokens() +export async function hasGoogleCalendarLocalAuth(accountId: string): Promise { + const { refreshToken } = await getGoogleCalendarTokens(accountId) return typeof refreshToken === 'string' && refreshToken.trim().length > 0 } -export async function hasGoogleCalendarConnection(db: DataDb): Promise { - if (!(await hasGoogleCalendarLocalAuth())) return false +export function resolveDefaultGoogleAccountId(db: DataDb): string | null { + const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' }) + for (const account of accounts) { + if (account.accountId) return account.accountId + } + return null +} + +export function listGoogleAccountIds(db: DataDb): string[] { + const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' }) + const ids: string[] = [] + for (const account of accounts) { + if (account.accountId) ids.push(account.accountId) + } + return ids +} + +export async function hasAnyGoogleCalendarLocalAuth(db: DataDb): Promise { const accounts = listCalendarSources(db, { provider: 'google', kind: 'account' }) - return accounts.length > 0 + for (const account of accounts) { + if (!account.accountId) continue + if (await hasGoogleCalendarLocalAuth(account.accountId)) return true + } + return false +} + +export async function hasGoogleCalendarConnection(db: DataDb): Promise { + return await hasAnyGoogleCalendarLocalAuth(db) } export function buildGoogleCalendarAuthUrl(input: { diff --git a/apps/desktop/src/main/calendar/google/push-conflict-retry.ts b/apps/desktop/src/main/calendar/google/push-conflict-retry.ts new file mode 100644 index 000000000..9d55edf83 --- /dev/null +++ b/apps/desktop/src/main/calendar/google/push-conflict-retry.ts @@ -0,0 +1,181 @@ +import { eq } from 'drizzle-orm' +import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings' +import { calendarEvents } from '@memry/db-schema/schema/calendar-events' +import { inboxItems } from '@memry/db-schema/schema/inbox' +import { reminders } from '@memry/db-schema/schema/reminders' +import { tasks } from '@memry/db-schema/schema/tasks' +import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api' +import { createLogger } from '../../lib/logger' +import type { DataDb } from '../../database/types' +import { enqueueLocalSyncUpdate } from '../../sync/local-mutations' +import { initAllFieldClocks } from '../../sync/field-merge' +import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../field-merge-calendar' +import { emitCalendarChanged } from '../change-events' +import { + mapCalendarEventToGoogleInput, + mapGoogleEventToCalendarEventChanges, + mapInboxSnoozeToGoogleInput, + mapReminderToGoogleInput, + mapTaskToGoogleInput +} from './mappers' +import type { + CalendarSyncTarget, + GoogleCalendarClient, + GoogleCalendarRemoteEvent, + GoogleCalendarUpsertEventInput +} from '../types' + +const log = createLogger('Calendar:GooglePushConflict') +export const MAX_PUSH_CONFLICT_RETRIES = 3 + +function getNow(): string { + return new Date().toISOString() +} + +function isPreconditionFailedError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + (error as { status: unknown }).status === 412 + ) +} + +export function loadSourceAsGoogleEvent( + db: DataDb, + target: CalendarSyncTarget +): GoogleCalendarUpsertEventInput { + switch (target.sourceType) { + case 'event': { + const row = db + .select() + .from(calendarEvents) + .where(eq(calendarEvents.id, target.sourceId)) + .get() + if (!row) throw new Error(`Calendar event not found: ${target.sourceId}`) + return mapCalendarEventToGoogleInput(row) + } + + case 'task': { + const row = db.select().from(tasks).where(eq(tasks.id, target.sourceId)).get() + if (!row) throw new Error(`Task not found: ${target.sourceId}`) + return mapTaskToGoogleInput(row) + } + + case 'reminder': { + const row = db.select().from(reminders).where(eq(reminders.id, target.sourceId)).get() + if (!row) throw new Error(`Reminder not found: ${target.sourceId}`) + return mapReminderToGoogleInput(row) + } + + case 'inbox_snooze': { + const row = db.select().from(inboxItems).where(eq(inboxItems.id, target.sourceId)).get() + if (!row) throw new Error(`Inbox item not found: ${target.sourceId}`) + return mapInboxSnoozeToGoogleInput(row) + } + } +} + +export async function pushEventWithConflictRetry( + db: DataDb, + target: CalendarSyncTarget, + client: Pick, + resolvedCalendarId: string, + existingBinding: typeof calendarBindings.$inferSelect | undefined +): Promise { + let ifMatch: string | null = existingBinding?.remoteVersion ?? null + + for (let attempt = 0; attempt < MAX_PUSH_CONFLICT_RETRIES; attempt++) { + const localEvent = loadSourceAsGoogleEvent(db, target) + try { + return await client.upsertEvent({ + calendarId: resolvedCalendarId, + eventId: existingBinding?.remoteEventId ?? null, + event: localEvent, + ifMatch + }) + } catch (error) { + if (!isPreconditionFailedError(error) || !existingBinding?.remoteEventId) { + throw error + } + + const remote = await client.getEvent({ + calendarId: resolvedCalendarId, + eventId: existingBinding.remoteEventId + }) + + if (target.sourceType === 'event') { + mergeRemoteEventIntoLocal(db, target.sourceId, remote) + } + + ifMatch = remote.etag ?? null + log.warn('Google upsert returned 412; merged remote and retrying', { + sourceType: target.sourceType, + sourceId: target.sourceId, + attempt: attempt + 1, + nextIfMatch: ifMatch + }) + } + } + + if (existingBinding) { + db.update(calendarBindings) + .set({ remoteVersion: 'conflict', modifiedAt: getNow() }) + .where(eq(calendarBindings.id, existingBinding.id)) + .run() + enqueueLocalSyncUpdate('calendar_binding', existingBinding.id) + } + + log.error('Google upsert exhausted conflict retries', { + sourceType: target.sourceType, + sourceId: target.sourceId, + attempts: MAX_PUSH_CONFLICT_RETRIES + }) + throw new Error( + `Google calendar push gave up after ${MAX_PUSH_CONFLICT_RETRIES} 412 conflicts for ${target.sourceType}:${target.sourceId}` + ) +} + +function mergeRemoteEventIntoLocal( + db: DataDb, + eventId: string, + remote: GoogleCalendarRemoteEvent +): void { + const existing = db.select().from(calendarEvents).where(eq(calendarEvents.id, eventId)).get() + if (!existing) return + + const remoteData = mapGoogleEventToCalendarEventChanges(remote) + const localFC: FieldClocks = + (existing.fieldClocks as FieldClocks | null) ?? + initAllFieldClocks((existing.clock as VectorClock | null) ?? {}, CALENDAR_EVENT_SYNCABLE_FIELDS) + const remoteFC: FieldClocks = {} + for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) { + remoteFC[field] = { ...(localFC[field] ?? {}) } + } + + const remoteForMerge: Record = {} + for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) { + const remoteVal = (remoteData as unknown as Record)[field] + remoteForMerge[field] = + remoteVal === undefined ? (existing as Record)[field] : remoteVal + } + + const result = mergeCalendarEventFields( + existing as Record, + remoteForMerge, + localFC, + remoteFC + ) + + db.update(calendarEvents) + .set({ + ...result.merged, + fieldClocks: result.mergedFieldClocks, + modifiedAt: getNow() + }) + .where(eq(calendarEvents.id, eventId)) + .run() + + enqueueLocalSyncUpdate('calendar_event', eventId, []) + emitCalendarChanged({ entityType: 'calendar_event', id: eventId }) +} diff --git a/apps/desktop/src/main/calendar/google/push-runtime.ts b/apps/desktop/src/main/calendar/google/push-runtime.ts index 2715f7a3b..d2f73f496 100644 --- a/apps/desktop/src/main/calendar/google/push-runtime.ts +++ b/apps/desktop/src/main/calendar/google/push-runtime.ts @@ -3,7 +3,10 @@ import { createLogger } from '../../lib/logger' import { deleteFromServer, patchToServer, postToServer } from '../../sync/http-client' import { getValidAccessToken } from '../../sync/token-manager' import { createGoogleCalendarClient } from './client' +import { resolveDefaultGoogleAccountId } from './oauth' +import { requireDatabase } from '../../database' import { createGoogleChannelManager, type GoogleChannelManager } from './google-channel-manager' +import { getCalendarSourceById } from '../repositories/calendar-sources-repository' const log = createLogger('Calendar:GooglePushRuntime') @@ -80,13 +83,33 @@ function isPushFeatureEnabled(): boolean { return process.env.CALENDAR_PUSH_ENABLED === '1' && resolveHmacKey().length > 0 } +export function resolvePushAccountIdForSource( + sourceId: string, + db = requireDatabase() +): string { + const source = getCalendarSourceById(db, sourceId) + const accountId = source?.accountId ?? resolveDefaultGoogleAccountId(db) + if (!accountId) { + throw new Error(`Cannot resolve Google push account for source ${sourceId}`) + } + return accountId +} + function buildProductionChannelManager( onActiveCountChange: (count: number) => void ): GoogleChannelManager { const hmacKey = resolveHmacKey() + const defaultAccountId = resolveDefaultGoogleAccountId(requireDatabase()) + if (!defaultAccountId) { + throw new Error('Cannot start Google push channel manager without a connected account') + } return createGoogleChannelManager({ - client: createGoogleCalendarClient(), + client: createGoogleCalendarClient({ accountId: defaultAccountId }), + resolveClient: ({ sourceId }) => + createGoogleCalendarClient({ + accountId: resolvePushAccountIdForSource(sourceId, requireDatabase()) + }), registerOnServer: async (body) => { const token = await getValidAccessToken() if (!token) throw new Error('Not signed in — cannot register push channel') diff --git a/apps/desktop/src/main/calendar/google/sync-service.test.ts b/apps/desktop/src/main/calendar/google/sync-service.test.ts index 5a2f161a7..5b69de4f4 100644 --- a/apps/desktop/src/main/calendar/google/sync-service.test.ts +++ b/apps/desktop/src/main/calendar/google/sync-service.test.ts @@ -34,18 +34,23 @@ vi.mock('electron', () => ({ vi.mock('./oauth', () => ({ hasGoogleCalendarLocalAuth: vi.fn(async () => true), - hasGoogleCalendarConnection: vi.fn(async () => true) + hasGoogleCalendarConnection: vi.fn(async () => true), + hasAnyGoogleCalendarLocalAuth: vi.fn(async () => true), + resolveDefaultGoogleAccountId: vi.fn(() => 'test-account@example.com'), + listGoogleAccountIds: vi.fn(() => ['test-account@example.com']) })) vi.mock('../../sync/auth-state', () => ({ isMemryUserSignedIn: vi.fn(async () => true) })) -import { hasGoogleCalendarConnection, hasGoogleCalendarLocalAuth } from './oauth' +import { hasGoogleCalendarConnection, hasGoogleCalendarLocalAuth, listGoogleAccountIds } from './oauth' +import * as googleClientModule from './client' import { isMemryUserSignedIn } from '../../sync/auth-state' import { applyGoogleCalendarDelete, applyGoogleCalendarWriteback, + ensureGoogleCalendarSourceSelected, syncLocalSourceToGoogleCalendar, pushSourceToGoogleCalendar, syncGoogleCalendarNow, @@ -82,7 +87,7 @@ describe('google calendar sync service', () => { id: 'google-calendar:memry', provider: 'google', kind: 'calendar', - accountId: 'google-account:1', + accountId: 'test-account@example.com', remoteId: 'remote-memry-calendar', title: 'Memry', timezone: 'UTC', @@ -492,6 +497,71 @@ describe('google calendar sync service', () => { }) }) + it('writes truncated lastError + syncStatus="error" when listEvents throws (M6 T6)', async () => { + seedGoogleCalendarSource({ + id: 'google-calendar:selected', + remoteId: 'remote-selected-calendar', + title: 'Work', + isMemryManaged: false + }) + + const longMessage = 'x'.repeat(500) + const client = { + listEvents: vi.fn(async () => { + throw new Error(longMessage) + }) + } + + await expect( + syncGoogleCalendarSource(db, 'google-calendar:selected', { client }) + ).rejects.toThrow() + + const failedSource = db + .select() + .from(calendarSources) + .where(eq(calendarSources.id, 'google-calendar:selected')) + .get() + + expect(failedSource).toMatchObject({ + syncStatus: 'error' + }) + expect(failedSource?.lastError).toBeTruthy() + // Truncated to 200 chars per the M6 T6 default. + expect(failedSource?.lastError?.length).toBeLessThanOrEqual(200) + expect(failedSource?.lastError?.startsWith('xxxxx')).toBe(true) + }) + + it('clears lastError on a successful sync after a previous error (M6 T6)', async () => { + seedGoogleCalendarSource({ + id: 'google-calendar:selected', + remoteId: 'remote-selected-calendar', + title: 'Work', + isMemryManaged: false, + syncStatus: 'error', + lastError: 'previous failure message' + }) + + const client = { + listEvents: vi.fn(async () => ({ + nextSyncCursor: 'cursor-3', + events: [] + })) + } + + await syncGoogleCalendarSource(db, 'google-calendar:selected', { client }) + + const refreshed = db + .select() + .from(calendarSources) + .where(eq(calendarSources.id, 'google-calendar:selected')) + .get() + + expect(refreshed).toMatchObject({ + syncStatus: 'ok', + lastError: null + }) + }) + it('reconciles local source mutations by upserting scheduled items and deleting cleared bindings', async () => { seedGoogleCalendarSource() @@ -703,7 +773,7 @@ describe('google calendar sync service', () => { id: 'google-calendar:selected', provider: 'google', kind: 'calendar', - accountId: 'google-account:1', + accountId: 'test-account@example.com', remoteId: 'remote-selected-calendar', title: 'Personal', timezone: 'UTC', @@ -1421,6 +1491,88 @@ describe('google calendar sync service', () => { } } + it('polls each selected calendar with the owning account client when multiple accounts exist', async () => { + const aliceAccountId = 'alice@example.com' + const bobAccountId = 'bob@example.com' + + seedGoogleCalendarSource({ + id: 'google-calendar:alice-work', + accountId: aliceAccountId, + remoteId: 'alice-work', + title: 'Alice Work', + isMemryManaged: false + }) + seedGoogleCalendarSource({ + id: 'google-calendar:bob-work', + accountId: bobAccountId, + remoteId: 'bob-work', + title: 'Bob Work', + isMemryManaged: false + }) + + vi.mocked(listGoogleAccountIds).mockReturnValueOnce([aliceAccountId, bobAccountId]) + + const aliceClient = { + listCalendars: vi.fn(async () => [ + { + id: 'alice-memry', + title: 'Memry', + timezone: 'UTC', + color: null, + isPrimary: false + } + ]), + createCalendar: vi.fn(), + listEvents: vi.fn(async () => ({ nextSyncCursor: 'alice-cursor', events: [] })) + } + const bobClient = { + listCalendars: vi.fn(async () => [ + { + id: 'bob-memry', + title: 'Memry', + timezone: 'UTC', + color: null, + isPrimary: false + } + ]), + createCalendar: vi.fn(), + listEvents: vi.fn(async () => ({ nextSyncCursor: 'bob-cursor', events: [] })) + } + const clientByAccountId = { + [aliceAccountId]: aliceClient, + [bobAccountId]: bobClient + } + + const clientSpy = vi + .spyOn(googleClientModule, 'createGoogleCalendarClient') + .mockImplementation(({ accountId }) => clientByAccountId[accountId] as never) + + try { + await syncGoogleCalendarNow(db) + } finally { + clientSpy.mockRestore() + } + + expect(aliceClient.listEvents).toHaveBeenCalledWith( + expect.objectContaining({ calendarId: 'alice-work' }) + ) + expect(bobClient.listEvents).toHaveBeenCalledWith( + expect.objectContaining({ calendarId: 'bob-work' }) + ) + + const memrySources = db + .select() + .from(calendarSources) + .all() + .filter((source) => source.isMemryManaged) + expect(memrySources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ accountId: aliceAccountId, remoteId: 'alice-memry' }), + expect.objectContaining({ accountId: bobAccountId, remoteId: 'bob-memry' }) + ]) + ) + }) + it('skips all Google API calls when Memry user is not signed in', async () => { vi.mocked(isMemryUserSignedIn).mockResolvedValue(false) const client = buildClient() @@ -1442,6 +1594,35 @@ describe('google calendar sync service', () => { }) }) + it('stores the routed accountId when selecting an existing Google calendar source', async () => { + seedGoogleCalendarSource({ + id: 'google-calendar:team', + accountId: 'alice@example.com', + remoteId: 'team@group.calendar.google.com', + title: 'Team', + isMemryManaged: false, + isSelected: false + }) + + const client = { + listCalendars: vi.fn(async () => []) + } + + const saved = await ensureGoogleCalendarSourceSelected( + db, + client, + 'team@group.calendar.google.com', + 'bob@example.com' + ) + + expect(client.listCalendars).not.toHaveBeenCalled() + expect(saved).toMatchObject({ + id: 'google-calendar:team', + accountId: 'bob@example.com', + isSelected: true + }) + }) + describe('pushSourceToGoogleCalendar — M2 target calendar resolution', () => { function buildPushClient() { return { @@ -1694,7 +1875,7 @@ describe('google calendar sync service', () => { id: 'google-calendar:work-preseeded', provider: 'google', kind: 'calendar', - accountId: 'google-account:1', + accountId: 'test-account@example.com', remoteId: 'work@group.calendar.google.com', title: 'Work', timezone: 'UTC', diff --git a/apps/desktop/src/main/calendar/google/sync-service.ts b/apps/desktop/src/main/calendar/google/sync-service.ts index 1e98dd9e1..1df327714 100644 --- a/apps/desktop/src/main/calendar/google/sync-service.ts +++ b/apps/desktop/src/main/calendar/google/sync-service.ts @@ -12,22 +12,22 @@ import { enqueueLocalSyncDelete, enqueueLocalSyncUpdate } from '../../sync/local-mutations' -import { initAllFieldClocks } from '../../sync/field-merge' -import { CALENDAR_EVENT_SYNCABLE_FIELDS, mergeCalendarEventFields } from '../field-merge-calendar' -import type { FieldClocks, VectorClock } from '@memry/contracts/sync-api' import { publishProjectionEvent } from '../../projections' -import { hasGoogleCalendarConnection } from './oauth' +import { + hasGoogleCalendarConnection, + listGoogleAccountIds, + resolveDefaultGoogleAccountId +} from './oauth' +import { resolveTargetGoogleAccountId } from './account-routing' +import { loadSourceAsGoogleEvent, pushEventWithConflictRetry } from './push-conflict-retry' import { isMemryUserSignedIn } from '../../sync/auth-state' import { createGoogleCalendarClient } from './client' +import { CALENDAR_EVENT_SYNCABLE_FIELDS } from '../field-merge-calendar' import { - mapCalendarEventToGoogleInput, mapGoogleEventToCalendarEventChanges, mapGoogleEventToExternalEventRecord, mapGoogleEventToReminderAt, - mapGoogleEventToTaskSchedule, - mapInboxSnoozeToGoogleInput, - mapReminderToGoogleInput, - mapTaskToGoogleInput + mapGoogleEventToTaskSchedule } from './mappers' import { getCalendarExternalEventById, @@ -43,12 +43,7 @@ import { } from '../repositories/calendar-sources-repository' import { emitCalendarChanged, emitCalendarProjectionChanged } from '../change-events' import { readCalendarGoogleSettings } from './calendar-google-settings' -import type { - CalendarSyncTarget, - GoogleCalendarClient, - GoogleCalendarRemoteEvent, - GoogleCalendarUpsertEventInput -} from '../types' +import type { CalendarSyncTarget, GoogleCalendarClient, GoogleCalendarRemoteEvent } from '../types' const log = createLogger('Calendar:GoogleSync') const LOCAL_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' @@ -68,129 +63,8 @@ function isGoneError(error: unknown): boolean { ) } -function isPreconditionFailedError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'status' in error && - (error as { status: unknown }).status === 412 - ) -} - -const MAX_PUSH_CONFLICT_RETRIES = 3 - -async function pushEventWithConflictRetry( - db: DataDb, - target: CalendarSyncTarget, - client: Pick, - resolvedCalendarId: string, - existingBinding: typeof calendarBindings.$inferSelect | undefined -): Promise { - let ifMatch: string | null = existingBinding?.remoteVersion ?? null - - for (let attempt = 0; attempt < MAX_PUSH_CONFLICT_RETRIES; attempt++) { - const localEvent = loadSourceAsGoogleEvent(db, target) - try { - return await client.upsertEvent({ - calendarId: resolvedCalendarId, - eventId: existingBinding?.remoteEventId ?? null, - event: localEvent, - ifMatch - }) - } catch (error) { - if (!isPreconditionFailedError(error) || !existingBinding?.remoteEventId) { - throw error - } - - const remote = await client.getEvent({ - calendarId: resolvedCalendarId, - eventId: existingBinding.remoteEventId - }) - - if (target.sourceType === 'event') { - mergeRemoteEventIntoLocal(db, target.sourceId, remote) - } - - ifMatch = remote.etag ?? null - log.warn('Google upsert returned 412; merged remote and retrying', { - sourceType: target.sourceType, - sourceId: target.sourceId, - attempt: attempt + 1, - nextIfMatch: ifMatch - }) - } - } - - if (existingBinding) { - db.update(calendarBindings) - .set({ remoteVersion: 'conflict', modifiedAt: getNow() }) - .where(eq(calendarBindings.id, existingBinding.id)) - .run() - enqueueLocalSyncUpdate('calendar_binding', existingBinding.id) - } - - log.error('Google upsert exhausted conflict retries', { - sourceType: target.sourceType, - sourceId: target.sourceId, - attempts: MAX_PUSH_CONFLICT_RETRIES - }) - throw new Error( - `Google calendar push gave up after ${MAX_PUSH_CONFLICT_RETRIES} 412 conflicts for ${target.sourceType}:${target.sourceId}` - ) -} - -function mergeRemoteEventIntoLocal( - db: DataDb, - eventId: string, - remote: GoogleCalendarRemoteEvent -): void { - const existing = db.select().from(calendarEvents).where(eq(calendarEvents.id, eventId)).get() - if (!existing) return - - const remoteData = mapGoogleEventToCalendarEventChanges(remote) - const localFC: FieldClocks = - (existing.fieldClocks as FieldClocks | null) ?? - initAllFieldClocks((existing.clock as VectorClock | null) ?? {}, CALENDAR_EVENT_SYNCABLE_FIELDS) - // We can't tell from Google's REST surface which fields the remote changed. - // Clone the local per-field clocks so every field merges as a "concurrent" - // edit at equal tick-sum — the merge function's tiebreak keeps the remote - // value when it actually differs and leaves matching fields untouched. - // Cloning (vs. fabricating a synthetic device) avoids polluting the doc-level - // clock with a fake `google-remote` actor and keeps subsequent retries - // monotonic: the next 412 reads the *updated* local FC as its base. - const remoteFC: FieldClocks = {} - for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) { - remoteFC[field] = { ...(localFC[field] ?? {}) } - } - - const remoteForMerge: Record = {} - for (const field of CALENDAR_EVENT_SYNCABLE_FIELDS) { - const remoteVal = (remoteData as unknown as Record)[field] - remoteForMerge[field] = - remoteVal === undefined ? (existing as Record)[field] : remoteVal - } - - const result = mergeCalendarEventFields( - existing as Record, - remoteForMerge, - localFC, - remoteFC - ) - - db.update(calendarEvents) - .set({ - ...result.merged, - fieldClocks: result.mergedFieldClocks, - modifiedAt: getNow() - }) - .where(eq(calendarEvents.id, eventId)) - .run() - - // Field clocks were already merged in this transaction; tell the producer - // not to re-increment them by passing an empty changed-fields list. - enqueueLocalSyncUpdate('calendar_event', eventId, []) - emitCalendarChanged({ entityType: 'calendar_event', id: eventId }) -} +// pushEventWithConflictRetry, mergeRemoteEventIntoLocal, and +// loadSourceAsGoogleEvent extracted to push-conflict-retry.ts for max-lines. function markSyncedTableMutation( entityType: 'calendar_binding' | 'calendar_source' | 'calendar_external_event', @@ -248,12 +122,13 @@ function getExistingGoogleBinding( async function ensureMemryCalendarSource( db: DataDb, - client: Pick + client: Pick, + accountId: string ): Promise { const existing = listCalendarSources(db, { provider: 'google', kind: 'calendar' - }).find((source) => source.isMemryManaged) + }).find((source) => source.isMemryManaged && source.accountId === accountId) if (existing) return existing @@ -264,7 +139,6 @@ async function ensureMemryCalendarSource( const localId = `google-calendar:${remote.id}` const now = getNow() - const account = listCalendarSources(db, { provider: 'google', kind: 'account' })[0] const existingSource = getCalendarSourceById(db, localId) const existed = Boolean(existingSource) @@ -272,7 +146,7 @@ async function ensureMemryCalendarSource( id: localId, provider: 'google', kind: 'calendar', - accountId: account?.id ?? null, + accountId, remoteId: remote.id, title: remote.title, timezone: remote.timezone ?? LOCAL_TIMEZONE, @@ -294,11 +168,19 @@ async function ensureMemryCalendarSource( return saved } -function getMemryManagedGoogleSource(db: DataDb): typeof calendarSources.$inferSelect | undefined { +function getMemryManagedGoogleSource( + db: DataDb, + accountId?: string +): typeof calendarSources.$inferSelect | undefined { return listCalendarSources(db, { provider: 'google', kind: 'calendar' - }).find((source) => source.isMemryManaged && !source.archivedAt) + }).find( + (source) => + source.isMemryManaged && + !source.archivedAt && + (accountId ? source.accountId === accountId : true) + ) } /** @@ -312,7 +194,8 @@ function getMemryManagedGoogleSource(db: DataDb): typeof calendarSources.$inferS export async function ensureGoogleCalendarSourceSelected( db: DataDb, client: Pick, - remoteCalendarId: string + remoteCalendarId: string, + accountId: string ): Promise { const existing = listCalendarSources(db, { provider: 'google', kind: 'calendar' }).find( (source) => source.remoteId === remoteCalendarId && !source.archivedAt @@ -321,9 +204,10 @@ export async function ensureGoogleCalendarSourceSelected( const now = getNow() if (existing) { - if (existing.isSelected) return existing + if (existing.isSelected && existing.accountId === accountId) return existing const updated = upsertCalendarSource(db, { ...existing, + accountId, isSelected: true, modifiedAt: now }) @@ -339,7 +223,6 @@ export async function ensureGoogleCalendarSourceSelected( return null } - const account = listCalendarSources(db, { provider: 'google', kind: 'account' })[0] const localId = `google-calendar:${remote.id}` const existingById = getCalendarSourceById(db, localId) const existed = Boolean(existingById) @@ -348,7 +231,7 @@ export async function ensureGoogleCalendarSourceSelected( id: localId, provider: 'google', kind: 'calendar', - accountId: account?.id ?? null, + accountId, remoteId: remote.id, title: remote.title, timezone: remote.timezone ?? LOCAL_TIMEZONE, @@ -370,8 +253,17 @@ export async function ensureGoogleCalendarSourceSelected( return saved } -function getGoogleClient(deps?: { client?: GoogleCalendarClient }): GoogleCalendarClient { - return deps?.client ?? createGoogleCalendarClient() +function getGoogleClient( + db: DataDb, + deps?: { client?: GoogleCalendarClient }, + accountIdOverride?: string | null +): GoogleCalendarClient { + if (deps?.client) return deps.client + const accountId = accountIdOverride ?? resolveDefaultGoogleAccountId(db) + if (!accountId) { + throw new Error('Cannot create Google Calendar client without a connected account') + } + return createGoogleCalendarClient({ accountId }) } function shouldSourceSyncToGoogleCalendar(db: DataDb, target: CalendarSyncTarget): boolean { @@ -409,40 +301,7 @@ function shouldSourceSyncToGoogleCalendar(db: DataDb, target: CalendarSyncTarget } } -function loadSourceAsGoogleEvent( - db: DataDb, - target: CalendarSyncTarget -): GoogleCalendarUpsertEventInput { - switch (target.sourceType) { - case 'event': { - const row = db - .select() - .from(calendarEvents) - .where(eq(calendarEvents.id, target.sourceId)) - .get() - if (!row) throw new Error(`Calendar event not found: ${target.sourceId}`) - return mapCalendarEventToGoogleInput(row) - } - - case 'task': { - const row = db.select().from(tasks).where(eq(tasks.id, target.sourceId)).get() - if (!row) throw new Error(`Task not found: ${target.sourceId}`) - return mapTaskToGoogleInput(row) - } - - case 'reminder': { - const row = db.select().from(reminders).where(eq(reminders.id, target.sourceId)).get() - if (!row) throw new Error(`Reminder not found: ${target.sourceId}`) - return mapReminderToGoogleInput(row) - } - - case 'inbox_snooze': { - const row = db.select().from(inboxItems).where(eq(inboxItems.id, target.sourceId)).get() - if (!row) throw new Error(`Inbox item not found: ${target.sourceId}`) - return mapInboxSnoozeToGoogleInput(row) - } - } -} +// loadSourceAsGoogleEvent moved to push-conflict-retry.ts function updateBindingRemoteVersion( db: DataDb, @@ -477,7 +336,8 @@ async function resolveTargetCalendarId( db: DataDb, target: CalendarSyncTarget, existingBinding: typeof calendarBindings.$inferSelect | undefined, - client: Pick + client: Pick, + accountId: string ): Promise { // Existing binding wins — retargeting a bound event would require // events.move on Google's side and coordinated etag handling (M3+ work). @@ -489,20 +349,21 @@ async function resolveTargetCalendarId( // calendar (Codex M2 review finding 2). const eventTarget = getEventTargetCalendarId(db, target) if (eventTarget) { - await ensureGoogleCalendarSourceSelected(db, client, eventTarget) + await ensureGoogleCalendarSourceSelected(db, client, eventTarget, accountId) return eventTarget } // User's onboarding-selected default (covers tasks / reminders / snoozes too). const { defaultTargetCalendarId } = readCalendarGoogleSettings(db) if (defaultTargetCalendarId) { - await ensureGoogleCalendarSourceSelected(db, client, defaultTargetCalendarId) + await ensureGoogleCalendarSourceSelected(db, client, defaultTargetCalendarId, accountId) return defaultTargetCalendarId } - // Final fallback: the auto-created Memry calendar. + // Final fallback: the auto-created Memry calendar (per the routed account). const memrySource = - getMemryManagedGoogleSource(db) ?? (await ensureMemryCalendarSource(db, client)) + getMemryManagedGoogleSource(db, accountId) ?? + (await ensureMemryCalendarSource(db, client, accountId)) return memrySource.remoteId } @@ -516,9 +377,19 @@ export async function pushSourceToGoogleCalendar( > } = {} ): Promise { - const client = getGoogleClient(deps as { client?: GoogleCalendarClient }) const existingBinding = getExistingGoogleBinding(db, target) - const resolvedCalendarId = await resolveTargetCalendarId(db, target, existingBinding, client) + const routedAccountId = resolveTargetGoogleAccountId(db, target, existingBinding) + if (!routedAccountId) { + throw new Error('No connected Google account to push to') + } + const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, routedAccountId) + const resolvedCalendarId = await resolveTargetCalendarId( + db, + target, + existingBinding, + client, + routedAccountId + ) const now = getNow() const bindingId = existingBinding?.id ?? `calendar_binding:google:${target.sourceType}:${target.sourceId}` @@ -566,7 +437,8 @@ export async function deleteSourceFromGoogleCalendar( return false } - const client = getGoogleClient(deps as { client?: GoogleCalendarClient }) + const routedAccountId = resolveTargetGoogleAccountId(db, target, existingBinding) + const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, routedAccountId) await client.deleteEvent({ calendarId: existingBinding.remoteCalendarId, eventId: existingBinding.remoteEventId @@ -771,13 +643,42 @@ export async function syncGoogleCalendarSource( db: DataDb, sourceId: string, deps: { client?: Pick } = {} +): Promise { + try { + await syncGoogleCalendarSourceInner(db, sourceId, deps) + } catch (error) { + recordSyncError(db, sourceId, error) + throw error + } +} + +function recordSyncError(db: DataDb, sourceId: string, error: unknown): void { + const source = getCalendarSourceById(db, sourceId) + if (!source) return + const message = error instanceof Error ? error.message : String(error) + const truncated = message.slice(0, 200) + const updated = upsertCalendarSource(db, { + ...source, + syncStatus: 'error', + lastError: truncated, + modifiedAt: getNow() + }) + markSyncedTableMutation('calendar_source', updated.id, true) + emitCalendarChanged({ entityType: 'calendar_source', id: updated.id }) +} + +async function syncGoogleCalendarSourceInner( + db: DataDb, + sourceId: string, + deps: { client?: Pick } = {} ): Promise { const source = getCalendarSourceById(db, sourceId) if (!source) { throw new Error(`Calendar source not found: ${sourceId}`) } - const client = getGoogleClient(deps as { client?: GoogleCalendarClient }) + const clientAccountId = source.accountId ?? resolveDefaultGoogleAccountId(db) + const client = getGoogleClient(db, deps as { client?: GoogleCalendarClient }, clientAccountId) const now = getNow() const isInitialSync = !source.syncCursor @@ -863,6 +764,7 @@ export async function syncGoogleCalendarSource( syncCursor: result.nextSyncCursor, syncStatus: 'ok', lastSyncedAt: now, + lastError: null, modifiedAt: now }) markSyncedTableMutation('calendar_source', updatedSource.id, true) @@ -879,8 +781,16 @@ export async function syncGoogleCalendarNow( syncInFlight = true try { - const client = getGoogleClient(deps) - await ensureMemryCalendarSource(db, client) + const accountIds = deps.client + ? [resolveDefaultGoogleAccountId(db)].filter((accountId): accountId is string => + Boolean(accountId) + ) + : listGoogleAccountIds(db) + + for (const accountId of accountIds) { + const client = getGoogleClient(db, deps, accountId) + await ensureMemryCalendarSource(db, client, accountId) + } const sources = listCalendarSources(db, { provider: 'google', @@ -889,7 +799,7 @@ export async function syncGoogleCalendarNow( }).filter((source) => !source.isMemryManaged) for (const source of sources) { - await syncGoogleCalendarSource(db, source.id, { client }) + await syncGoogleCalendarSource(db, source.id, deps) } } finally { syncInFlight = false diff --git a/apps/desktop/src/main/calendar/promote-external-event.test.ts b/apps/desktop/src/main/calendar/promote-external-event.test.ts index 7617a8f1c..52342a5a4 100644 --- a/apps/desktop/src/main/calendar/promote-external-event.test.ts +++ b/apps/desktop/src/main/calendar/promote-external-event.test.ts @@ -169,9 +169,7 @@ describe('promoteExternalEvent (M2)', () => { }) it('#given an external event carrying rich Google fields #when promoted #then carries attendees, reminders, visibility, colorId, conferenceData across to the local row (M5)', () => { - const attendees = [ - { email: 'ceo@example.com', responseStatus: 'accepted', displayName: 'CEO' } - ] + const attendees = [{ email: 'ceo@example.com', responseStatus: 'accepted', displayName: 'CEO' }] const reminders = { useDefault: false, overrides: [{ method: 'popup' as const, minutes: 5 }] diff --git a/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts b/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts index 2adcde619..efbfe18f2 100644 --- a/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts +++ b/apps/desktop/src/main/calendar/repositories/calendar-events-repository.test.ts @@ -121,9 +121,7 @@ describe('calendarEvents rich fields (M5)', () => { } const conferenceData = { conferenceId: 'abc-defg-hij', - entryPoints: [ - { entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' } - ] + entryPoints: [{ entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }] } upsertCalendarEvent(dataDb, { diff --git a/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql b/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql new file mode 100644 index 000000000..d47484a02 --- /dev/null +++ b/apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql @@ -0,0 +1 @@ +ALTER TABLE `calendar_sources` ADD COLUMN `last_error` text; diff --git a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json index 297f1a300..8e3676da8 100644 --- a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json +++ b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1777248000000, "tag": "0027_calendar_rich_fields", "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1777680000000, + "tag": "0028_calendar_source_last_error", + "breakpoints": true } ] } diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts index 2c8ce4020..9fa4c9b8a 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts @@ -15,7 +15,11 @@ const webContentsSend = vi.fn() const mockConnectGoogleCalendar = vi.fn() const mockDisconnectGoogleCalendar = vi.fn() const mockHasGoogleCalendarLocalAuth = vi.fn() +const mockHasAnyGoogleCalendarLocalAuth = vi.fn() +const mockListGoogleAccountIds = vi.fn(() => [] as string[]) +const mockResolveDefaultGoogleAccountId = vi.fn(() => null as string | null) const mockSyncGoogleCalendarNow = vi.fn() +const mockSyncGoogleCalendarSource = vi.fn() const mockStartGoogleCalendarSyncRunner = vi.fn(async () => {}) const mockStopGoogleCalendarSyncRunner = vi.fn() const mockIsMemryUserSignedIn = vi.fn(async () => true) @@ -54,11 +58,15 @@ vi.mock('../sync/local-mutations', () => ({ vi.mock('../calendar/google/oauth', () => ({ connectGoogleCalendar: (...args: unknown[]) => mockConnectGoogleCalendar(...args), disconnectGoogleCalendar: (...args: unknown[]) => mockDisconnectGoogleCalendar(...args), - hasGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasGoogleCalendarLocalAuth(...args) + hasGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasGoogleCalendarLocalAuth(...args), + hasAnyGoogleCalendarLocalAuth: (...args: unknown[]) => mockHasAnyGoogleCalendarLocalAuth(...args), + listGoogleAccountIds: (...args: unknown[]) => mockListGoogleAccountIds(...args), + resolveDefaultGoogleAccountId: (...args: unknown[]) => mockResolveDefaultGoogleAccountId(...args) })) vi.mock('../calendar/google/sync-service', () => ({ syncGoogleCalendarNow: (...args: unknown[]) => mockSyncGoogleCalendarNow(...args), + syncGoogleCalendarSource: (...args: unknown[]) => mockSyncGoogleCalendarSource(...args), startGoogleCalendarSyncRunner: (...args: unknown[]) => mockStartGoogleCalendarSyncRunner(...args), stopGoogleCalendarSyncRunner: (...args: unknown[]) => mockStopGoogleCalendarSyncRunner(...args) })) @@ -89,6 +97,10 @@ describe('calendar-handlers', () => { ;(getDatabase as Mock).mockReturnValue(asClientDb(db)) ;(requireDatabase as Mock).mockReturnValue(asClientDb(db)) mockHasGoogleCalendarLocalAuth.mockResolvedValue(false) + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false) + mockListGoogleAccountIds.mockReturnValue([]) + mockResolveDefaultGoogleAccountId.mockReturnValue(null) + mockDisconnectGoogleCalendar.mockResolvedValue(undefined) }) afterEach(() => { @@ -428,6 +440,7 @@ describe('calendar-handlers', () => { id: 'google-account-1', title: 'h4yfans@gmail.com' }), + accounts: [], calendars: { total: 1, selected: 1, @@ -440,8 +453,10 @@ describe('calendar-handlers', () => { it('connects and disconnects the Google provider through the provider-specific auth module', async () => { registerCalendarHandlers() mockConnectGoogleCalendar.mockResolvedValue({ + accountId: 'user@example.com', account: { remoteId: 'user@example.com', + email: 'user@example.com', title: 'User Example', timezone: 'Europe/Istanbul' }, @@ -453,7 +468,8 @@ describe('calendar-handlers', () => { isPrimary: true } }) - mockHasGoogleCalendarLocalAuth.mockResolvedValue(true) + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true) + mockListGoogleAccountIds.mockReturnValue(['user@example.com']) const connect = await invokeHandler(CalendarChannels.invoke.CONNECT_PROVIDER, { provider: 'google' @@ -468,6 +484,12 @@ describe('calendar-handlers', () => { id: 'google-account:user@example.com', title: 'User Example' }, + accounts: expect.arrayContaining([ + expect.objectContaining({ + accountId: 'user@example.com', + email: 'user@example.com' + }) + ]), calendars: { total: 1, selected: 1, @@ -490,7 +512,7 @@ describe('calendar-handlers', () => { }) ]) - mockHasGoogleCalendarLocalAuth.mockResolvedValue(false) + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false) const disconnect = await invokeHandler(CalendarChannels.invoke.DISCONNECT_PROVIDER, { provider: 'google' @@ -502,6 +524,7 @@ describe('calendar-handlers', () => { connected: false, hasLocalAuth: false, account: null, + accounts: [], calendars: { total: 0, selected: 0, @@ -564,9 +587,162 @@ describe('calendar-handlers', () => { }) }) + it('returns one account in status.accounts per connected Google account (M6 T3)', async () => { + registerCalendarHandlers() + + db.run(sql` + INSERT INTO calendar_sources ( + id, provider, kind, account_id, remote_id, title, timezone, + is_selected, sync_status, last_synced_at, metadata, created_at, modified_at + ) VALUES ( + ${'google-account:alice@example.com'}, ${'google'}, ${'account'}, + ${'alice@example.com'}, ${'alice@example.com'}, ${'Alice'}, ${'UTC'}, + ${0}, ${'ok'}, ${'2026-04-15T10:00:00.000Z'}, + ${JSON.stringify({ email: 'alice@example.com' })}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'} + ) + `) + db.run(sql` + INSERT INTO calendar_sources ( + id, provider, kind, account_id, remote_id, title, timezone, + is_selected, sync_status, last_synced_at, metadata, created_at, modified_at + ) VALUES ( + ${'google-account:bob@example.com'}, ${'google'}, ${'account'}, + ${'bob@example.com'}, ${'bob@example.com'}, ${'Bob'}, ${'UTC'}, + ${0}, ${'error'}, ${'2026-04-15T09:00:00.000Z'}, + ${JSON.stringify({ email: 'bob@example.com', lastError: 'token revoked by Google' })}, + ${'2026-04-15T09:00:00.000Z'}, ${'2026-04-15T09:00:00.000Z'} + ) + `) + + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true) + mockHasGoogleCalendarLocalAuth.mockImplementation(async (accountId: string) => { + // Bob's keychain entry was wiped (e.g. token revoked); Alice still has tokens. + return accountId === 'alice@example.com' + }) + + const status = await invokeHandler(CalendarChannels.invoke.GET_PROVIDER_STATUS, { + provider: 'google' + }) + + expect(status.accounts).toHaveLength(2) + expect(status.accounts).toEqual( + expect.arrayContaining([ + { + accountId: 'alice@example.com', + email: 'alice@example.com', + status: 'connected', + lastSyncedAt: '2026-04-15T10:00:00.000Z', + lastError: null + }, + { + accountId: 'bob@example.com', + email: 'bob@example.com', + status: 'disconnected', + lastSyncedAt: '2026-04-15T09:00:00.000Z', + lastError: 'token revoked by Google' + } + ]) + ) + }) + + it('RETRY_GOOGLE_CALENDAR_SOURCE_SYNC fires syncGoogleCalendarSource and returns the refreshed source (M6 T6)', async () => { + registerCalendarHandlers() + + db.run(sql` + INSERT INTO calendar_sources ( + id, provider, kind, account_id, remote_id, title, timezone, + is_selected, sync_status, last_error, created_at, modified_at + ) VALUES ( + ${'google-calendar:work'}, ${'google'}, ${'calendar'}, + ${'alice@example.com'}, ${'work@cal'}, ${'Work'}, ${'UTC'}, + ${1}, ${'error'}, ${'token expired'}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'} + ) + `) + + mockSyncGoogleCalendarSource.mockImplementation(async (db, sourceId) => { + // Simulate a successful sync clearing the error. + db.run(sql` + UPDATE calendar_sources + SET sync_status = ${'ok'}, last_error = NULL, last_synced_at = ${'2026-04-19T10:00:00.000Z'} + WHERE id = ${sourceId} + `) + }) + + const result = await invokeHandler(CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, { + sourceId: 'google-calendar:work' + }) + + expect(mockSyncGoogleCalendarSource).toHaveBeenCalledWith( + expect.anything(), + 'google-calendar:work' + ) + expect(result.success).toBe(true) + expect(result.source).toEqual( + expect.objectContaining({ + id: 'google-calendar:work', + syncStatus: 'ok', + lastError: null, + lastSyncedAt: '2026-04-19T10:00:00.000Z' + }) + ) + }) + + it('disconnects only the requested accountId, leaving other accounts intact (M6 T5)', async () => { + registerCalendarHandlers() + + db.run(sql` + INSERT INTO calendar_sources ( + id, provider, kind, account_id, remote_id, title, timezone, + is_selected, sync_status, metadata, created_at, modified_at + ) VALUES + (${'google-account:alice@example.com'}, ${'google'}, ${'account'}, + ${'alice@example.com'}, ${'alice@example.com'}, ${'Alice'}, ${'UTC'}, + ${0}, ${'ok'}, ${JSON.stringify({ email: 'alice@example.com' })}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}), + (${'google-calendar:alice-primary'}, ${'google'}, ${'calendar'}, + ${'alice@example.com'}, ${'alice@cal'}, ${'Alice Cal'}, ${'UTC'}, + ${1}, ${'ok'}, ${null}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}), + (${'google-account:bob@example.com'}, ${'google'}, ${'account'}, + ${'bob@example.com'}, ${'bob@example.com'}, ${'Bob'}, ${'UTC'}, + ${0}, ${'ok'}, ${JSON.stringify({ email: 'bob@example.com' })}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}), + (${'google-calendar:bob-primary'}, ${'google'}, ${'calendar'}, + ${'bob@example.com'}, ${'bob@cal'}, ${'Bob Cal'}, ${'UTC'}, + ${1}, ${'ok'}, ${null}, + ${'2026-04-15T10:00:00.000Z'}, ${'2026-04-15T10:00:00.000Z'}) + `) + + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true) + mockHasGoogleCalendarLocalAuth.mockResolvedValue(true) + mockListGoogleAccountIds.mockReturnValue(['alice@example.com', 'bob@example.com']) + + const result = await invokeHandler(CalendarChannels.invoke.DISCONNECT_PROVIDER, { + provider: 'google', + accountId: 'alice@example.com' + }) + + expect(result.success).toBe(true) + expect(mockDisconnectGoogleCalendar).toHaveBeenCalledTimes(1) + expect(mockDisconnectGoogleCalendar).toHaveBeenCalledWith('alice@example.com') + + const sources = await invokeHandler(CalendarChannels.invoke.LIST_SOURCES, { + provider: 'google' + }) + const sourceIds = sources.sources.map((s: { id: string }) => s.id) + // Alice's rows tombstoned (filtered out by listCalendarSources via archivedAt); + // Bob's rows still active. + expect(sourceIds).not.toContain('google-account:alice@example.com') + expect(sourceIds).not.toContain('google-calendar:alice-primary') + expect(sourceIds).toContain('google-account:bob@example.com') + expect(sourceIds).toContain('google-calendar:bob-primary') + }) + it('refreshes Google provider state only when local auth exists', async () => { registerCalendarHandlers() - mockHasGoogleCalendarLocalAuth.mockResolvedValue(false) + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(false) const withoutAuth = await invokeHandler(CalendarChannels.invoke.REFRESH_PROVIDER, { provider: 'google' @@ -579,6 +755,7 @@ describe('calendar-handlers', () => { connected: false, hasLocalAuth: false, account: null, + accounts: [], calendars: { total: 0, selected: 0, @@ -619,7 +796,7 @@ describe('calendar-handlers', () => { ) `) - mockHasGoogleCalendarLocalAuth.mockResolvedValue(true) + mockHasAnyGoogleCalendarLocalAuth.mockResolvedValue(true) mockSyncGoogleCalendarNow.mockResolvedValue(undefined) const refreshed = await invokeHandler(CalendarChannels.invoke.REFRESH_PROVIDER, { @@ -636,6 +813,7 @@ describe('calendar-handlers', () => { id: 'google-account-1', title: 'User Example' }, + accounts: [], calendars: { total: 0, selected: 0, diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts index 49f7f874e..c3d32c8c1 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.ts @@ -8,6 +8,7 @@ import { ListCalendarSourcesSchema, ListGoogleCalendarsSchema, PromoteExternalEventSchema, + RetryCalendarSourceSyncSchema, SetDefaultGoogleCalendarSchema, UpdateCalendarSourceSelectionSchema, CalendarProviderRequestSchema, @@ -17,6 +18,8 @@ import { type CalendarEventListResponse, type CalendarEventMutationResponse, type CalendarEventRecord, + type CalendarProviderAccountConnectionStatus, + type CalendarProviderAccountStatus, type CalendarProviderMutationResponse, type CalendarProviderStatus, type CalendarRangeResponse, @@ -25,6 +28,7 @@ import { type CalendarSourceRecord, type ListGoogleCalendarsResponse, type PromoteExternalEventResponse, + type RetryCalendarSourceSyncResponse, type SetDefaultGoogleCalendarResponse } from '@memry/contracts/calendar-api' import { calendarEvents } from '@memry/db-schema/schema/calendar-events' @@ -43,13 +47,17 @@ import { import { connectGoogleCalendar, disconnectGoogleCalendar, - hasGoogleCalendarLocalAuth + hasAnyGoogleCalendarLocalAuth, + hasGoogleCalendarLocalAuth, + listGoogleAccountIds, + resolveDefaultGoogleAccountId } from '../calendar/google/oauth' import { getCalendarRangeProjection } from '../calendar/projection' import { startGoogleCalendarSyncRunner, stopGoogleCalendarSyncRunner, - syncGoogleCalendarNow + syncGoogleCalendarNow, + syncGoogleCalendarSource } from '../calendar/google/sync-service' import { listGoogleCalendars, setDefaultGoogleCalendar } from '../calendar/google/onboarding' import { createGoogleCalendarClient } from '../calendar/google/client' @@ -91,13 +99,11 @@ function mapCalendarEvent(row: typeof calendarEvents.$inferSelect): CalendarEven isAllDay: row.isAllDay, recurrenceRule: (row.recurrenceRule as Record | null) ?? null, recurrenceExceptions: (row.recurrenceExceptions as string[] | null) ?? null, - attendees: - (row.attendees as CalendarEventRecord['attendees']) ?? null, + attendees: (row.attendees as CalendarEventRecord['attendees']) ?? null, reminders: (row.reminders as CalendarEventRecord['reminders']) ?? null, visibility: (row.visibility as CalendarEventRecord['visibility']) ?? null, colorId: row.colorId ?? null, - conferenceData: - (row.conferenceData as CalendarEventRecord['conferenceData']) ?? null, + conferenceData: (row.conferenceData as CalendarEventRecord['conferenceData']) ?? null, parentEventId: row.parentEventId ?? null, originalStartTime: row.originalStartTime ?? null, targetCalendarId: row.targetCalendarId ?? null, @@ -124,6 +130,7 @@ function mapCalendarSource(row: typeof calendarSources.$inferSelect): CalendarSo syncCursor: row.syncCursor ?? null, syncStatus: row.syncStatus, lastSyncedAt: row.lastSyncedAt ?? null, + lastError: row.lastError ?? null, metadata: (row.metadata as Record | null) ?? null, archivedAt: row.archivedAt ?? null, syncedAt: row.syncedAt ?? null, @@ -132,21 +139,57 @@ function mapCalendarSource(row: typeof calendarSources.$inferSelect): CalendarSo } } +async function buildProviderAccountStatus( + source: typeof calendarSources.$inferSelect +): Promise { + const accountId = source.accountId + if (!accountId) return null + + const metadata = (source.metadata as { email?: string; lastError?: string } | null) ?? null + const hasLocalAuth = + source.provider === 'google' ? await hasGoogleCalendarLocalAuth(accountId) : false + + let status: CalendarProviderAccountConnectionStatus + if (!hasLocalAuth) { + status = 'disconnected' + } else if (source.syncStatus === 'error') { + status = 'error' + } else { + status = 'connected' + } + + return { + accountId, + email: metadata?.email ?? source.title, + status, + lastSyncedAt: source.lastSyncedAt ?? null, + lastError: source.lastError ?? metadata?.lastError ?? null + } +} + async function buildProviderStatus(db: DataDb, provider: string): Promise { const allSources = listCalendarSourceRows(db, { provider }) - const account = allSources.find((source) => source.kind === 'account') ?? null + const accountSources = allSources.filter((source) => source.kind === 'account') + const account = accountSources[0] ?? null const calendars = allSources.filter((source) => source.kind === 'calendar') const syncedCandidates = [ - account?.lastSyncedAt ?? null, + ...accountSources.map((source) => source.lastSyncedAt ?? null), ...calendars.map((source) => source.lastSyncedAt ?? null) ].filter((value): value is string => Boolean(value)) - const hasLocalAuth = provider === 'google' ? await hasGoogleCalendarLocalAuth() : false + const hasLocalAuth = provider === 'google' ? await hasAnyGoogleCalendarLocalAuth(db) : false + + const accounts: CalendarProviderAccountStatus[] = [] + for (const source of accountSources) { + const accountStatus = await buildProviderAccountStatus(source) + if (accountStatus) accounts.push(accountStatus) + } return { provider, connected: Boolean(account), hasLocalAuth, account: account ? { id: account.id, title: account.title } : null, + accounts, calendars: { total: calendars.length, selected: calendars.filter((source) => source.isSelected).length, @@ -185,6 +228,124 @@ function syncCalendarSourceUpsert( return mapCalendarSource(saved) } +async function disconnectGoogleAccount( + db: DataDb, + provider: string, + accountId: string +): Promise { + try { + await disconnectGoogleCalendar(accountId) + } catch (err) { + log.warn('Google Calendar disconnect failed', { accountId, err }) + } + + const allProviderSources = listCalendarSourceRows(db, { provider }) + const targetSources = allProviderSources.filter((source) => + source.kind === 'account' ? source.accountId === accountId : source.accountId === accountId + ) + + if (targetSources.length === 0) { + return { + success: true, + status: await buildProviderStatus(db, provider) + } + } + + const pushRuntime = getGooglePushRuntime() + if (pushRuntime) { + for (const source of targetSources) { + if (source.kind !== 'calendar' || source.isMemryManaged) continue + void pushRuntime.handleSelectionToggle({ + sourceId: source.id, + isSelected: false, + calendarId: source.remoteId + }) + } + } + + const targetSourceIds = targetSources.map((source) => source.id) + const externalRows = + targetSourceIds.length > 0 + ? db + .select() + .from(calendarExternalEvents) + .where(inArray(calendarExternalEvents.sourceId, targetSourceIds)) + .all() + : [] + + const bindingRows = + targetSourceIds.length > 0 + ? db + .select() + .from(calendarBindings) + .where( + and( + eq(calendarBindings.provider, provider), + inArray( + calendarBindings.remoteCalendarId, + targetSources.map((s) => s.remoteId) + ) + ) + ) + .all() + : [] + + const now = new Date().toISOString() + + db.transaction((tx) => { + if (externalRows.length > 0) { + tx.delete(calendarExternalEvents) + .where( + inArray( + calendarExternalEvents.id, + externalRows.map((row) => row.id) + ) + ) + .run() + } + + if (bindingRows.length > 0) { + tx.delete(calendarBindings) + .where( + inArray( + calendarBindings.id, + bindingRows.map((row) => row.id) + ) + ) + .run() + } + + for (const source of targetSources) { + if (source.archivedAt) continue + tx.update(calendarSources) + .set({ archivedAt: now, modifiedAt: now }) + .where(eq(calendarSources.id, source.id)) + .run() + } + }) + + for (const row of externalRows) { + syncCalendarExternalEventDelete(row.id, JSON.stringify(row)) + emitCalendarChanged({ entityType: 'calendar_external_event', id: row.id }) + } + + for (const row of bindingRows) { + syncCalendarBindingDelete(row.id, JSON.stringify(row)) + emitCalendarChanged({ entityType: 'calendar_binding', id: row.id }) + } + + for (const source of targetSources) { + if (source.archivedAt) continue + syncCalendarSourceUpdate(source.id) + emitCalendarChanged({ entityType: 'calendar_source', id: source.id }) + } + + return { + success: true, + status: await buildProviderStatus(db, provider) + } +} + export function registerCalendarHandlers(): void { ipcMain.handle( CalendarChannels.invoke.CREATE_EVENT, @@ -420,14 +581,14 @@ export function registerCalendarHandlers(): void { } const connected = await connectGoogleCalendar() const now = new Date().toISOString() - const accountSourceId = `google-account:${connected.account.remoteId}` + const accountSourceId = `google-account:${connected.accountId}` const primaryCalendarSourceId = `google-calendar:${connected.primaryCalendar.remoteId}` syncCalendarSourceUpsert(db, { id: accountSourceId, provider: 'google', kind: 'account', - accountId: null, + accountId: connected.accountId, remoteId: connected.account.remoteId, title: connected.account.title, timezone: connected.account.timezone, @@ -436,7 +597,7 @@ export function registerCalendarHandlers(): void { isSelected: false, isMemryManaged: false, syncStatus: 'pending', - metadata: { connectedVia: 'oauth' }, + metadata: { connectedVia: 'oauth', email: connected.account.email }, createdAt: now, modifiedAt: now }) @@ -445,7 +606,7 @@ export function registerCalendarHandlers(): void { id: primaryCalendarSourceId, provider: 'google', kind: 'calendar', - accountId: accountSourceId, + accountId: connected.accountId, remoteId: connected.primaryCalendar.remoteId, title: connected.primaryCalendar.title, timezone: connected.primaryCalendar.timezone, @@ -484,8 +645,19 @@ export function registerCalendarHandlers(): void { } } + if (input.accountId) { + return await disconnectGoogleAccount(db, input.provider, input.accountId) + } + stopGoogleCalendarSyncRunner() - await disconnectGoogleCalendar() + const accountIdsToDisconnect = listGoogleAccountIds(db) + for (const accountId of accountIdsToDisconnect) { + try { + await disconnectGoogleCalendar(accountId) + } catch (err) { + log.warn('Google Calendar disconnect failed', { accountId, err }) + } + } const providerSources = listCalendarSourceRows(db, { provider: input.provider }) const sourceIds = providerSources.map((source) => source.id) @@ -577,7 +749,7 @@ export function registerCalendarHandlers(): void { } } - if (!(await hasGoogleCalendarLocalAuth())) { + if (!(await hasAnyGoogleCalendarLocalAuth(db))) { return { success: false, status: await buildProviderStatus(db, input.provider), @@ -601,7 +773,11 @@ export function registerCalendarHandlers(): void { createValidatedHandler( ListGoogleCalendarsSchema, withDb(async (db): Promise => { - return await listGoogleCalendars(db, createGoogleCalendarClient()) + const accountId = resolveDefaultGoogleAccountId(db) + if (!accountId) { + return { calendars: [], primary: null, currentDefaultId: null } + } + return await listGoogleCalendars(db, createGoogleCalendarClient({ accountId })) }, 'Failed to list Google calendars') ) ) @@ -616,6 +792,41 @@ export function registerCalendarHandlers(): void { ) ) + ipcMain.handle( + CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, + createValidatedHandler( + RetryCalendarSourceSyncSchema, + withDb(async (db, input): Promise => { + const source = getCalendarSourceById(db, input.sourceId) + if (!source) { + return { success: false, source: null, error: 'Calendar source not found' } + } + if (source.provider !== 'google' || source.kind !== 'calendar') { + return { + success: false, + source: null, + error: 'Only Google calendar sources can be retried' + } + } + try { + await syncGoogleCalendarSource(db, source.id) + } catch (err) { + const updated = getCalendarSourceById(db, source.id) + return { + success: false, + source: updated ? mapCalendarSource(updated) : null, + error: err instanceof Error ? err.message : 'Sync failed' + } + } + const refreshed = getCalendarSourceById(db, source.id) + return { + success: true, + source: refreshed ? mapCalendarSource(refreshed) : null + } + }, 'Failed to retry Google Calendar source sync') + ) + ) + ipcMain.handle( CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT, createValidatedHandler( @@ -653,4 +864,5 @@ export function unregisterCalendarHandlers(): void { ipcMain.removeHandler(CalendarChannels.invoke.LIST_GOOGLE_CALENDARS) ipcMain.removeHandler(CalendarChannels.invoke.SET_DEFAULT_GOOGLE_CALENDAR) ipcMain.removeHandler(CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT) + ipcMain.removeHandler(CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC) } diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts index 171fbc836..6844bfbec 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -27,18 +27,19 @@ export interface MainIpcInvokeHandlers { "bookmarks:list-by-type": (...args: [string]) => Awaited> "bookmarks:reorder": (...args: [{ bookmarkIds: string[]; }]) => Awaited> "bookmarks:toggle": (...args: [{ itemType: string; itemId: string; }]) => Awaited> - "calendar:connect-provider": (...args: [{ provider: string; }]) => Awaited> + "calendar:connect-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited> "calendar:create-event": (...args: [{ title: string; startAt: string; description?: string | null | undefined; location?: string | null | undefined; endAt?: string | null | undefined; timezone?: string | undefined; isAllDay?: boolean | undefined; recurrenceRule?: Record | null | undefined; recurrenceExceptions?: string[] | null | undefined; targetCalendarId?: string | null | undefined; }]) => Awaited> "calendar:delete-event": (...args: [string]) => Awaited> - "calendar:disconnect-provider": (...args: [{ provider: string; }]) => Awaited> + "calendar:disconnect-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited> "calendar:get-event": (...args: [string]) => Awaited> - "calendar:get-provider-status": (...args: [{ provider: string; }]) => Awaited> + "calendar:get-provider-status": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited> "calendar:get-range": (...args: [{ startAt: string; endAt: string; includeUnselectedSources?: boolean | undefined; }]) => Awaited> "calendar:list-events": (...args: [{ includeArchived?: boolean | undefined; }]) => Awaited> "calendar:list-google-calendars": (...args: [Record | undefined]) => Awaited> "calendar:list-sources": (...args: [{ provider?: string | undefined; kind?: "account" | "calendar" | undefined; selectedOnly?: boolean | undefined; }]) => Awaited> "calendar:promote-external-event": (...args: [{ externalEventId: string; }]) => Awaited> - "calendar:refresh-provider": (...args: [{ provider: string; }]) => Awaited> + "calendar:refresh-provider": (...args: [{ provider: string; accountId?: string | undefined; }]) => Awaited> + "calendar:retry-google-source-sync": (...args: [{ sourceId: string; }]) => Awaited> "calendar:set-default-google-calendar": (...args: [{ calendarId: string | null; markOnboardingComplete?: boolean | undefined; }]) => Awaited> "calendar:update-event": (...args: [{ id: string; title?: string | undefined; description?: string | null | undefined; location?: string | null | undefined; startAt?: string | undefined; endAt?: string | null | undefined; timezone?: string | undefined; isAllDay?: boolean | undefined; recurrenceRule?: Record | null | undefined; recurrenceExceptions?: string[] | null | undefined; targetCalendarId?: string | null | undefined; }]) => Awaited> "calendar:update-source-selection": (...args: [{ id: string; isSelected: boolean; }]) => Awaited> diff --git a/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts b/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts index bee41647e..573df4453 100644 --- a/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts +++ b/apps/desktop/src/main/sync/item-handlers/calendar-external-event-handler.test.ts @@ -133,12 +133,7 @@ describe('calendar external event handler — rich fields (M5 Codex P2c)', () => .run() const freshCtx = makeCtx(freshDb) - const applyResult = handler?.applyUpsert( - freshCtx, - 'external-rich', - parsed, - { 'device-a': 3 } - ) + const applyResult = handler?.applyUpsert(freshCtx, 'external-rich', parsed, { 'device-a': 3 }) expect(applyResult).toBe('applied') const row = freshDb.db diff --git a/apps/desktop/src/main/sync/session-teardown.ts b/apps/desktop/src/main/sync/session-teardown.ts index 6bea078b5..b048e8f5b 100644 --- a/apps/desktop/src/main/sync/session-teardown.ts +++ b/apps/desktop/src/main/sync/session-teardown.ts @@ -14,7 +14,7 @@ import { clearInMemoryAuthState } from '../ipc/sync-core-handlers' import { getDatabase, isDatabaseInitialized } from '../database/client' import { store } from '../store' import { createLogger } from '../lib/logger' -import { disconnectGoogleCalendar } from '../calendar/google/oauth' +import { disconnectGoogleCalendar, listGoogleAccountIds } from '../calendar/google/oauth' import { stopGoogleCalendarSyncRunner } from '../calendar/google/sync-service' const log = createLogger('SessionTeardown') @@ -53,10 +53,16 @@ async function performTeardown(reason: TeardownReason): Promise if (reason === 'logout') { await revokeServerSession() - try { - await disconnectGoogleCalendar() - } catch (err) { - log.warn('Google Calendar disconnect failed during logout', err) + if (isDatabaseInitialized()) { + const db = getDatabase() + const accountIds = listGoogleAccountIds(db) + for (const accountId of accountIds) { + try { + await disconnectGoogleCalendar(accountId) + } catch (err) { + log.warn('Google Calendar disconnect failed during logout', { accountId, err }) + } + } } } diff --git a/apps/desktop/src/preload/generated-rpc.ts b/apps/desktop/src/preload/generated-rpc.ts index 914ef15b5..b002276da 100644 --- a/apps/desktop/src/preload/generated-rpc.ts +++ b/apps/desktop/src/preload/generated-rpc.ts @@ -237,6 +237,7 @@ export function createGeneratedRpcApi({ listGoogleCalendars: ((options) => invoke("calendar:list-google-calendars", options ?? {})) as GeneratedRpcApi["calendar"]["listGoogleCalendars"], setDefaultGoogleCalendar: ((input) => invoke("calendar:set-default-google-calendar", input)) as GeneratedRpcApi["calendar"]["setDefaultGoogleCalendar"], promoteExternalEvent: ((input) => invoke("calendar:promote-external-event", input)) as GeneratedRpcApi["calendar"]["promoteExternalEvent"], + retryGoogleCalendarSourceSync: ((input) => invoke("calendar:retry-google-source-sync", input)) as GeneratedRpcApi["calendar"]["retryGoogleCalendarSourceSync"], }, onNoteCreated: ((callback) => subscribe("notes:created", callback)) as GeneratedRpcApi["onNoteCreated"], onNoteUpdated: ((callback) => subscribe("notes:updated", callback)) as GeneratedRpcApi["onNoteUpdated"], diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx index 8da7b88fc..d025315fb 100644 --- a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx +++ b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.test.tsx @@ -18,9 +18,7 @@ const FULL: CalendarEventMetadataProps = { }, visibility: 'private', conferenceData: { - entryPoints: [ - { entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' } - ] + entryPoints: [{ entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }] } } diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx index 423532cdb..06a9d7b50 100644 --- a/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx +++ b/apps/desktop/src/renderer/src/components/calendar/calendar-event-metadata.tsx @@ -92,10 +92,7 @@ export function CalendarEventMetadata(props: CalendarEventMetadataProps): React. )} {label} diff --git a/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx b/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx index 98df25c66..fb1fbfa31 100644 --- a/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx +++ b/apps/desktop/src/renderer/src/components/calendar/calendar-shell.tsx @@ -2,10 +2,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { SlidersHorizontal } from '@/lib/icons' import { CalendarDayView } from './calendar-day-view' -import { - CalendarEventPopover, - type CalendarEventReadOnlyMetadata -} from './calendar-event-popover' +import { CalendarEventPopover, type CalendarEventReadOnlyMetadata } from './calendar-event-popover' import { CalendarMonthView } from './calendar-month-view' import { CalendarToolbar, type CalendarWorkspaceView } from './calendar-toolbar' import { CalendarWeekView } from './calendar-week-view' diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx index 3e9fc5f01..32c26d4a8 100644 --- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx +++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithProviders, userEvent } from '@tests/utils/render' import { IntegrationList } from './integration-list' import type { CalendarProviderStatus, CalendarSourceRecord } from '@/services/calendar-service' @@ -10,14 +10,16 @@ const { mockDisconnectGoogleCalendarProvider, mockRefreshGoogleCalendarProvider, mockListSources, - mockUpdateSourceSelection + mockUpdateSourceSelection, + mockRetryGoogleCalendarSourceSync } = vi.hoisted(() => ({ mockGetGoogleCalendarStatus: vi.fn(), mockConnectGoogleCalendarProvider: vi.fn(), mockDisconnectGoogleCalendarProvider: vi.fn(), mockRefreshGoogleCalendarProvider: vi.fn(), mockListSources: vi.fn(), - mockUpdateSourceSelection: vi.fn() + mockUpdateSourceSelection: vi.fn(), + mockRetryGoogleCalendarSourceSync: vi.fn() })) vi.mock('@/services/calendar-service', () => ({ @@ -25,6 +27,7 @@ vi.mock('@/services/calendar-service', () => ({ connectGoogleCalendarProvider: mockConnectGoogleCalendarProvider, disconnectGoogleCalendarProvider: mockDisconnectGoogleCalendarProvider, refreshGoogleCalendarProvider: mockRefreshGoogleCalendarProvider, + retryGoogleCalendarSourceSync: mockRetryGoogleCalendarSourceSync, updateGoogleCalendarSourceSelection: mockUpdateSourceSelection, onCalendarChanged: vi.fn(() => () => {}), calendarService: { @@ -45,6 +48,7 @@ const DISCONNECTED_STATUS: CalendarProviderStatus = { connected: false, hasLocalAuth: false, account: null, + accounts: [], calendars: { total: 0, selected: 0, @@ -58,6 +62,15 @@ const CONNECTED_STATUS: CalendarProviderStatus = { connected: true, hasLocalAuth: true, account: { id: 'google-account-1', title: 'h4yfans@gmail.com' }, + accounts: [ + { + accountId: 'h4yfans@gmail.com', + email: 'h4yfans@gmail.com', + status: 'connected', + lastSyncedAt: '2026-04-12T08:00:00.000Z', + lastError: null + } + ], calendars: { total: 3, selected: 2, @@ -66,6 +79,26 @@ const CONNECTED_STATUS: CalendarProviderStatus = { lastSyncedAt: '2026-04-12T08:00:00.000Z' } +const TWO_ACCOUNT_STATUS: CalendarProviderStatus = { + ...CONNECTED_STATUS, + accounts: [ + { + accountId: 'alice@example.com', + email: 'alice@example.com', + status: 'connected', + lastSyncedAt: '2026-04-12T08:00:00.000Z', + lastError: null + }, + { + accountId: 'bob@example.com', + email: 'bob@example.com', + status: 'error', + lastSyncedAt: '2026-04-11T22:30:00.000Z', + lastError: 'token revoked by Google' + } + ] +} + const CONNECTED_SOURCES: CalendarSourceRecord[] = [ { id: 'google-account-1', @@ -161,6 +194,7 @@ describe('Google Calendar integration row', () => { mockRefreshGoogleCalendarProvider.mockReset() mockListSources.mockReset() mockUpdateSourceSelection.mockReset() + mockRetryGoogleCalendarSourceSync.mockReset() }) it('starts the Google Calendar connect flow from Settings', async () => { @@ -228,6 +262,62 @@ describe('Google Calendar integration row', () => { }) }) + it('shows a Retry button + lastError on calendar sources in error state and fires retry IPC (M6 T6)', async () => { + const erroredSources: CalendarSourceRecord[] = CONNECTED_SOURCES.map((source) => + source.id === 'google-calendar-work' + ? { ...source, syncStatus: 'error', lastError: 'Quota exceeded for project 123' } + : source + ) + + mockGetGoogleCalendarStatus.mockResolvedValue(CONNECTED_STATUS) + mockListSources.mockResolvedValue({ sources: erroredSources }) + mockRetryGoogleCalendarSourceSync.mockResolvedValue({ + success: true, + source: { ...erroredSources[2], syncStatus: 'ok', lastError: null } + }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByText('Work')).toBeInTheDocument()) + + const errorRow = screen.getByTestId('calendar-source-row-google-calendar-work') + expect(errorRow).toHaveAttribute('data-sync-status', 'error') + expect(screen.getByTestId('calendar-source-error-google-calendar-work')).toHaveTextContent( + 'Quota exceeded for project 123' + ) + + fireEvent.pointerDown(screen.getByTestId('calendar-source-retry-google-calendar-work'), { + button: 0 + }) + + await waitFor(() => { + expect(mockRetryGoogleCalendarSourceSync).toHaveBeenCalledWith({ + sourceId: 'google-calendar-work' + }) + }) + }) + + it('renders one chip per connected Google account with status + email (M6 T3)', async () => { + mockGetGoogleCalendarStatus.mockResolvedValue(TWO_ACCOUNT_STATUS) + mockListSources.mockResolvedValue({ sources: CONNECTED_SOURCES }) + vi.mocked(window.api.settings.getCalendarGoogleSettings).mockResolvedValue({ + defaultTargetCalendarId: 'primary@example.com', + onboardingCompleted: true, + promoteConfirmDismissed: false + }) + + renderWithProviders() + + await waitFor(() => expect(screen.getByText('alice@example.com')).toBeInTheDocument()) + expect(screen.getByText('bob@example.com')).toBeInTheDocument() + + const aliceChip = screen.getByTestId('calendar-account-chip-alice@example.com') + const bobChip = screen.getByTestId('calendar-account-chip-bob@example.com') + expect(aliceChip).toHaveAttribute('data-account-status', 'connected') + expect(bobChip).toHaveAttribute('data-account-status', 'error') + expect(bobChip).toHaveTextContent('token revoked by Google') + }) + it('#given an existing Google connection + onboardingCompleted=true #when the row mounts #then the onboarding dialog stays closed', async () => { mockGetGoogleCalendarStatus.mockResolvedValue(CONNECTED_STATUS) mockListSources.mockResolvedValue({ sources: CONNECTED_SOURCES }) diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx index 1b01636a3..067d9e7fb 100644 --- a/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx +++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-integration-row.tsx @@ -10,6 +10,7 @@ import { disconnectGoogleCalendarProvider, getGoogleCalendarStatus, refreshGoogleCalendarProvider, + retryGoogleCalendarSourceSync, updateGoogleCalendarSourceSelection } from '@/services/calendar-service' import { GoogleCalendarSourcePicker } from './google-calendar-source-picker' @@ -98,6 +99,19 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element { } }) + const retryMutation = useMutation({ + mutationFn: async (sourceId: string) => { + const result = await retryGoogleCalendarSourceSync({ sourceId }) + if (!result.success) { + throw new Error(result.error ?? 'Retry failed') + } + return result + }, + onSuccess: async () => { + await invalidateGoogleCalendarQueries(queryClient) + } + }) + // Re-open onboarding for users who connected before M2 shipped OR who // closed the dialog last time without picking a default. Single auto-open // per mount via the ref above; settings.onboardingCompleted flips to true @@ -159,8 +173,33 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element { Two-way sync for Memry events and imported Google calendars.

- {status?.account && ( -

Connected as {status.account.title}

+ {status?.accounts && status.accounts.length > 0 && ( +
+ {status.accounts.map((account) => { + const tone = + account.status === 'connected' + ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + : account.status === 'error' + ? 'border-destructive/50 bg-destructive/10 text-destructive' + : 'border-muted-foreground/30 bg-muted text-muted-foreground' + return ( + + {account.email} + {account.status === 'error' && account.lastError && ( + + · {account.lastError.slice(0, 60)} + + )} + + ) + })} +
)} {mutationError && ( @@ -224,6 +263,8 @@ export function GoogleCalendarIntegrationRow(): React.JSX.Element { onToggleSource={(sourceId, isSelected) => sourceMutation.mutate({ sourceId, isSelected }) } + onRetrySource={(sourceId) => retryMutation.mutate(sourceId)} + retryingSourceId={retryMutation.isPending ? (retryMutation.variables ?? null) : null} /> )} diff --git a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx index 3773ca7bc..34a0ba93a 100644 --- a/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx +++ b/apps/desktop/src/renderer/src/components/settings/google-calendar-source-picker.tsx @@ -1,3 +1,4 @@ +import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import type { CalendarSourceRecord } from '@/services/calendar-service' @@ -5,12 +6,42 @@ interface GoogleCalendarSourcePickerProps { sources: CalendarSourceRecord[] isUpdating: boolean onToggleSource: (sourceId: string, isSelected: boolean) => void + onRetrySource?: (sourceId: string) => void + retryingSourceId?: string | null +} + +function statusDotClass(status: CalendarSourceRecord['syncStatus']): string { + switch (status) { + case 'ok': + return 'bg-emerald-500' + case 'error': + return 'bg-destructive' + case 'pending': + return 'bg-amber-500' + default: + return 'bg-muted-foreground/40' + } +} + +function statusLabel(status: CalendarSourceRecord['syncStatus']): string { + switch (status) { + case 'ok': + return 'Synced' + case 'error': + return 'Error' + case 'pending': + return 'Pending' + default: + return 'Idle' + } } export function GoogleCalendarSourcePicker({ sources, isUpdating, - onToggleSource + onToggleSource, + onRetrySource, + retryingSourceId }: GoogleCalendarSourcePickerProps): React.JSX.Element { if (sources.length === 0) { return ( @@ -24,27 +55,73 @@ export function GoogleCalendarSourcePicker({
{sources.map((source) => { const inputId = `google-calendar-source-${source.id}` + const isError = source.syncStatus === 'error' + const isRetrying = retryingSourceId === source.id return (
- - - onToggleSource(source.id, checked === true)} - aria-label={source.title} - /> +
+ + +
+ + + {statusLabel(source.syncStatus)} + + {isError && onRetrySource && ( + + )} + onToggleSource(source.id, checked === true)} + aria-label={source.title} + /> +
+
+ + {isError && source.lastError && ( +

+ {source.lastError} +

+ )}
) })} diff --git a/apps/desktop/src/renderer/src/services/calendar-service.ts b/apps/desktop/src/renderer/src/services/calendar-service.ts index d84936d3f..c10e90b41 100644 --- a/apps/desktop/src/renderer/src/services/calendar-service.ts +++ b/apps/desktop/src/renderer/src/services/calendar-service.ts @@ -21,6 +21,8 @@ import type { ListGoogleCalendarsResponse, PromoteExternalEventInput, PromoteExternalEventResponse, + RetryCalendarSourceSyncInput, + RetryCalendarSourceSyncResponse, SetDefaultGoogleCalendarInput, SetDefaultGoogleCalendarResponse, UpdateCalendarSourceSelectionInput, @@ -51,6 +53,8 @@ export type { ListGoogleCalendarsResponse, PromoteExternalEventInput, PromoteExternalEventResponse, + RetryCalendarSourceSyncInput, + RetryCalendarSourceSyncResponse, SetDefaultGoogleCalendarInput, SetDefaultGoogleCalendarResponse, UpdateCalendarSourceSelectionInput, @@ -102,3 +106,9 @@ export function promoteExternalCalendarEvent( ): Promise { return calendarService.promoteExternalEvent(input) } + +export function retryGoogleCalendarSourceSync( + input: RetryCalendarSourceSyncInput +): Promise { + return calendarService.retryGoogleCalendarSourceSync(input) +} diff --git a/packages/contracts/src/calendar-api.ts b/packages/contracts/src/calendar-api.ts index 8aa5e473e..bf9a2a6d0 100644 --- a/packages/contracts/src/calendar-api.ts +++ b/packages/contracts/src/calendar-api.ts @@ -89,9 +89,22 @@ export const UpdateCalendarSourceSelectionSchema = z.object({ }) export const CalendarProviderRequestSchema = z.object({ - provider: z.string().min(1) + provider: z.string().min(1), + accountId: z.string().min(1).optional() }) +export const RetryCalendarSourceSyncSchema = z.object({ + sourceId: z.string().min(1) +}) + +export type RetryCalendarSourceSyncInput = z.infer + +export interface RetryCalendarSourceSyncResponse { + success: boolean + source: CalendarSourceRecord | null + error?: string +} + export type CalendarSourceKind = z.infer export type CalendarSourceSyncStatus = z.infer export type CalendarProjectionSourceType = z.infer @@ -184,6 +197,7 @@ export interface CalendarSourceRecord { syncCursor: string | null syncStatus: CalendarSourceSyncStatus lastSyncedAt: string | null + lastError: string | null metadata: Record | null archivedAt: string | null syncedAt: string | null @@ -231,11 +245,22 @@ export interface CalendarProjectionItem { binding: CalendarProjectionBinding | null } +export type CalendarProviderAccountConnectionStatus = 'connected' | 'disconnected' | 'error' + +export interface CalendarProviderAccountStatus { + accountId: string + email: string + status: CalendarProviderAccountConnectionStatus + lastSyncedAt: string | null + lastError: string | null +} + export interface CalendarProviderStatus { provider: string connected: boolean hasLocalAuth: boolean account: Pick | null + accounts: CalendarProviderAccountStatus[] calendars: { total: number selected: number diff --git a/packages/contracts/src/ipc-channels.ts b/packages/contracts/src/ipc-channels.ts index 9a476a5b1..e1971341d 100644 --- a/packages/contracts/src/ipc-channels.ts +++ b/packages/contracts/src/ipc-channels.ts @@ -541,7 +541,9 @@ export const CalendarChannels = { /** M2: list the user's Google calendars for target/default selection */ LIST_GOOGLE_CALENDARS: 'calendar:list-google-calendars', /** M2: persist the onboarding choice for default target Google calendar */ - SET_DEFAULT_GOOGLE_CALENDAR: 'calendar:set-default-google-calendar' + SET_DEFAULT_GOOGLE_CALENDAR: 'calendar:set-default-google-calendar', + /** M6: re-run sync for a single calendar source (Retry button on sync-health UI) */ + RETRY_GOOGLE_CALENDAR_SOURCE_SYNC: 'calendar:retry-google-source-sync' }, events: { CHANGED: 'calendar:changed' diff --git a/packages/db-schema/src/schema/calendar-sources.ts b/packages/db-schema/src/schema/calendar-sources.ts index 970784fa8..e6d34e56f 100644 --- a/packages/db-schema/src/schema/calendar-sources.ts +++ b/packages/db-schema/src/schema/calendar-sources.ts @@ -22,6 +22,7 @@ export const calendarSources = sqliteTable( syncCursor: text('sync_cursor'), syncStatus: text('sync_status').$type().notNull().default('idle'), lastSyncedAt: text('last_synced_at'), + lastError: text('last_error'), metadata: text('metadata', { mode: 'json' }).$type | null>(), archivedAt: text('archived_at'), clock: text('clock', { mode: 'json' }).$type(), @@ -34,7 +35,11 @@ export const calendarSources = sqliteTable( .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`) }, (table) => [ - uniqueIndex('idx_calendar_sources_provider_remote').on(table.provider, table.kind, table.remoteId), + uniqueIndex('idx_calendar_sources_provider_remote').on( + table.provider, + table.kind, + table.remoteId + ), index('idx_calendar_sources_account').on(table.accountId), index('idx_calendar_sources_selected').on(table.isSelected) ] diff --git a/packages/rpc/src/calendar.ts b/packages/rpc/src/calendar.ts index c836d08f8..7757a11fd 100644 --- a/packages/rpc/src/calendar.ts +++ b/packages/rpc/src/calendar.ts @@ -8,6 +8,7 @@ import { GetCalendarRangeSchema, ListCalendarSourcesSchema, PromoteExternalEventSchema, + RetryCalendarSourceSyncSchema, SetDefaultGoogleCalendarSchema, UpdateCalendarSourceSelectionSchema, CalendarProviderRequestSchema, @@ -25,6 +26,7 @@ import { type CalendarSourceRecord, type ListGoogleCalendarsResponse, type PromoteExternalEventResponse, + type RetryCalendarSourceSyncResponse, type SetDefaultGoogleCalendarResponse } from '../../contracts/src/calendar-api.ts' import { @@ -45,6 +47,7 @@ export type CalendarProviderRequest = z.input export type PromoteExternalEventInput = z.input export type SetDefaultGoogleCalendarInput = z.input +export type RetryCalendarSourceSyncInput = z.input export type { CalendarChangedEvent, @@ -61,6 +64,7 @@ export type { CalendarSourceRecord, ListGoogleCalendarsResponse, PromoteExternalEventResponse, + RetryCalendarSourceSyncResponse, SetDefaultGoogleCalendarResponse } @@ -153,6 +157,12 @@ export const calendarRpc = defineDomain({ >({ channel: CalendarChannels.invoke.PROMOTE_EXTERNAL_EVENT, params: ['input'] + }), + retryGoogleCalendarSourceSync: defineMethod< + (input: RetryCalendarSourceSyncInput) => Promise + >({ + channel: CalendarChannels.invoke.RETRY_GOOGLE_CALENDAR_SOURCE_SYNC, + params: ['input'] }) }, events: {