From fc5df55b8ce237acb484b5147055a43d612352a1 Mon Sep 17 00:00:00 2001 From: GautamSharma99 Date: Mon, 13 Jul 2026 03:54:58 +0530 Subject: [PATCH] fix(web): scope daily brief cache by account --- apps/web/components/app-experience.tsx | 68 ++++++++++++++++++--- apps/web/hooks/use-reset-organization.ts | 9 ++- apps/web/lib/space-highlights-cache.test.ts | 59 ++++++++++++++++++ apps/web/lib/space-highlights-cache.ts | 20 ++++++ 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/space-highlights-cache.test.ts create mode 100644 apps/web/lib/space-highlights-cache.ts diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 259584ee..07bcaaa9 100644 --- a/apps/web/components/app-experience.tsx +++ b/apps/web/components/app-experience.tsx @@ -67,6 +67,11 @@ import { } from "@/lib/search-params" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" import { getToolDocumentSpace } from "@/lib/plugin-space" +import { + getSpaceHighlightsCacheKey, + LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME, + SPACE_HIGHLIGHTS_CACHE_NAME, +} from "@/lib/space-highlights-cache" import { getBackendUrl } from "@/lib/url-helpers" type DocumentsResponse = z.infer @@ -112,7 +117,7 @@ function ViewErrorFallback() { export function AppExperience() { const isMobile = useIsMobile() - const { user, session, isSessionPending, org } = useAuth() + const { user, session, isSessionPending, isRestoring, org } = useAuth() const { selectedProject, selectedProjects, setSelectedProject } = useProject() const selectedProjectTag = selectedProjects[0] @@ -316,27 +321,75 @@ export function AppExperience() { generatedAt: string } - const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours + const highlightsAccountScope = + user?.id && org?.id ? JSON.stringify([user.id, org.id]) : null + const previousHighlightsAccountScope = useRef( + undefined, + ) + + useEffect(() => { + if (isSessionPending || isRestoring) return + + const previousScope = previousHighlightsAccountScope.current + previousHighlightsAccountScope.current = highlightsAccountScope + if (previousScope === undefined) { + try { + void caches.delete(LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME) + } catch {} + return + } + if (previousScope === highlightsAccountScope) return + + queryClient.removeQueries({ + queryKey: ["space-highlights"], + predicate: (query) => + query.queryKey[1] !== user?.id || query.queryKey[2] !== org?.id, + }) + try { + void caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME) + } catch {} + }, [ + highlightsAccountScope, + isRestoring, + isSessionPending, + org?.id, + queryClient, + user?.id, + ]) const handleResetHighlights = useCallback(async () => { toast.success("Refreshing daily brief…") try { - await caches.delete(HIGHLIGHTS_CACHE_NAME) + await caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME) } catch {} setHighlightsForceAt(Date.now()) }, []) const { data: highlightsData, isLoading: isLoadingHighlights } = useQuery({ - queryKey: ["space-highlights", selectedProject, highlightsForceAt], + queryKey: [ + "space-highlights", + user?.id, + org?.id, + selectedProject, + highlightsForceAt, + ], queryFn: async (): Promise => { + if (!user?.id || !org?.id) { + throw new Error("User and organization are required") + } const spaceId = selectedProject || "sm_project_default" const forceRefresh = highlightsForceAt > 0 - const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}` + const cacheKey = getSpaceHighlightsCacheKey({ + backendUrl, + spaceId, + userId: user.id, + organizationId: org.id, + }) if (!forceRefresh) { - const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cache = await caches.open(SPACE_HIGHLIGHTS_CACHE_NAME) const cached = await cache.match(cacheKey) if (cached) { const age = @@ -369,7 +422,7 @@ export function AppExperience() { // Update browser cache with fresh data (works for both normal and forced refresh) try { - const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const freshCache = await caches.open(SPACE_HIGHLIGHTS_CACHE_NAME) const cacheResponse = new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json", @@ -387,6 +440,7 @@ export function AppExperience() { }, staleTime: HIGHLIGHTS_MAX_AGE, refetchOnWindowFocus: false, + enabled: !!user?.id && !!org?.id, }) const { data: memoryOfDay = null } = useQuery({ diff --git a/apps/web/hooks/use-reset-organization.ts b/apps/web/hooks/use-reset-organization.ts index 4c71580f..1c0d1722 100644 --- a/apps/web/hooks/use-reset-organization.ts +++ b/apps/web/hooks/use-reset-organization.ts @@ -3,6 +3,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import { $fetch } from "@lib/api" +import { + LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME, + SPACE_HIGHLIGHTS_CACHE_NAME, +} from "@/lib/space-highlights-cache" export function useResetOrganization() { const queryClient = useQueryClient() @@ -30,7 +34,10 @@ export function useResetOrganization() { queryClient.invalidateQueries() // Clear the daily brief Cache API entry so stale highlights don't survive the reset try { - await caches.delete("space-highlights-v1") + await Promise.all([ + caches.delete(SPACE_HIGHLIGHTS_CACHE_NAME), + caches.delete(LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME), + ]) } catch { // Cache API not available in all environments } diff --git a/apps/web/lib/space-highlights-cache.test.ts b/apps/web/lib/space-highlights-cache.test.ts new file mode 100644 index 00000000..f3835507 --- /dev/null +++ b/apps/web/lib/space-highlights-cache.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test" +import { + getSpaceHighlightsCacheKey, + LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME, + SPACE_HIGHLIGHTS_CACHE_NAME, +} from "./space-highlights-cache" + +describe("space highlights cache", () => { + it("does not reuse the legacy account-agnostic cache", () => { + expect(SPACE_HIGHLIGHTS_CACHE_NAME).toBe("space-highlights-v2") + expect(SPACE_HIGHLIGHTS_CACHE_NAME).not.toBe( + LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME, + ) + }) + + it("scopes cache keys by user, organization, and space", () => { + const cacheKey = getSpaceHighlightsCacheKey({ + backendUrl: "https://api.supermemory.ai", + spaceId: "sm_project_default", + userId: "user-1", + organizationId: "org-1", + }) + + expect(cacheKey).toBe( + "https://api.supermemory.ai/v3/space-highlights?spaceId=sm_project_default&userId=user-1&organizationId=org-1", + ) + }) + + it("produces different keys for different accounts sharing a project tag", () => { + const firstAccount = getSpaceHighlightsCacheKey({ + backendUrl: "https://api.supermemory.ai", + spaceId: "sm_project_default", + userId: "user-1", + organizationId: "org-1", + }) + const secondAccount = getSpaceHighlightsCacheKey({ + backendUrl: "https://api.supermemory.ai", + spaceId: "sm_project_default", + userId: "user-2", + organizationId: "org-2", + }) + + expect(firstAccount).not.toBe(secondAccount) + }) + + it("safely encodes identifiers used in cache keys", () => { + const cacheKey = getSpaceHighlightsCacheKey({ + backendUrl: "http://localhost:8787/", + spaceId: "project&shared=true", + userId: "user?admin=true", + organizationId: "org/name", + }) + const url = new URL(cacheKey) + + expect(url.searchParams.get("spaceId")).toBe("project&shared=true") + expect(url.searchParams.get("userId")).toBe("user?admin=true") + expect(url.searchParams.get("organizationId")).toBe("org/name") + }) +}) diff --git a/apps/web/lib/space-highlights-cache.ts b/apps/web/lib/space-highlights-cache.ts new file mode 100644 index 00000000..1ea5e0ae --- /dev/null +++ b/apps/web/lib/space-highlights-cache.ts @@ -0,0 +1,20 @@ +export const SPACE_HIGHLIGHTS_CACHE_NAME = "space-highlights-v2" +export const LEGACY_SPACE_HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" + +export function getSpaceHighlightsCacheKey({ + backendUrl, + spaceId, + userId, + organizationId, +}: { + backendUrl: string + spaceId: string + userId: string + organizationId: string +}): string { + const url = new URL(`${backendUrl.replace(/\/$/, "")}/v3/space-highlights`) + url.searchParams.set("spaceId", spaceId) + url.searchParams.set("userId", userId) + url.searchParams.set("organizationId", organizationId) + return url.toString() +}