From 504940414ce7eafec3155e71c4a1ef3e9a1d5689 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:54:13 +0000 Subject: [PATCH] feat(web): company brain entitlement helper, hook + ?org deep-link activation (#1110) Add hasCompanyBrain helper + useHasCompanyBrain hook reading the company_brain add-on from org metadata to gate Company Brain UI. Fixes ENG-806 --- apps/web/hooks/use-company-brain.ts | 16 +++++ apps/web/lib/billing-utils.ts | 94 +++++++++++++++++++++++++++++ apps/web/stores/index.ts | 19 ++++-- packages/lib/auth-context.tsx | 33 ++++++++++ packages/lib/constants.ts | 2 + packages/lib/types.ts | 1 + packages/validation/api.ts | 4 ++ 7 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 apps/web/hooks/use-company-brain.ts diff --git a/apps/web/hooks/use-company-brain.ts b/apps/web/hooks/use-company-brain.ts new file mode 100644 index 00000000..fb80f7be --- /dev/null +++ b/apps/web/hooks/use-company-brain.ts @@ -0,0 +1,16 @@ +import { useAuth } from "@lib/auth-context" +import { + getBrainMode, + getCompanyBrainOverride, + hasCompanyBrain, +} from "@/lib/billing-utils" + +export function useHasCompanyBrain(): boolean { + const { org } = useAuth() + const metadata = org?.metadata as Record | string | undefined + // An explicit concierge override wins over the team-onboarding fallback. + const override = getCompanyBrainOverride(metadata) + if (override !== undefined) return override + // Team-brain orgs use brain spaces even before the add-on webhook lands. + return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team" +} diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index b07e5c07..4169c257 100644 --- a/apps/web/lib/billing-utils.ts +++ b/apps/web/lib/billing-utils.ts @@ -1,3 +1,97 @@ +const COMPANY_BRAIN_PRODUCT_ID = "company_brain" + +// Add-on resolved by product presence, not tier. +// better-auth returns org.metadata as a JSON string, so accept string or object. +export function hasCompanyBrain( + metadataRaw: Record | string | null | undefined, +): boolean { + if (!metadataRaw) return false + let metadata: Record + if (typeof metadataRaw === "string") { + try { + metadata = JSON.parse(metadataRaw) as Record + } catch { + return false + } + } else { + metadata = metadataRaw + } + const overrides = metadata.featureOverrides as + | Record + | undefined + const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID] + if (override) return Boolean(override.allow) + const activeProducts = Array.isArray(metadata.activeProducts) + ? (metadata.activeProducts as string[]) + : [] + return activeProducts.includes(COMPANY_BRAIN_PRODUCT_ID) +} + +// Explicit concierge override for company_brain, or undefined when none is set. +export function getCompanyBrainOverride( + metadataRaw: Record | string | null | undefined, +): boolean | undefined { + if (!metadataRaw) return undefined + let metadata: Record + if (typeof metadataRaw === "string") { + try { + metadata = JSON.parse(metadataRaw) as Record + } catch { + return undefined + } + } else { + metadata = metadataRaw + } + const overrides = metadata.featureOverrides as + | Record + | undefined + const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID] + return override ? Boolean(override.allow) : undefined +} + +// Origin of the org. Consumer (app.supermemory) orgs get company_brain attached, +// but the add-on lands async — signupSource is set at creation, so it's the +// reliable "this org uses brain spaces" signal in the UI. +export function getSignupSource( + metadataRaw: Record | string | null | undefined, +): string | null { + if (!metadataRaw) return null + let metadata: Record + if (typeof metadataRaw === "string") { + try { + metadata = JSON.parse(metadataRaw) as Record + } catch { + return null + } + } else { + metadata = metadataRaw + } + return typeof metadata.signupSource === "string" + ? (metadata.signupSource as string) + : null +} + +// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously +// at org creation, so it's the reliable pre-webhook signal for company brain. +export function getBrainMode( + metadataRaw: Record | string | null | undefined, +): string | null { + if (!metadataRaw) return null + let metadata: Record + if (typeof metadataRaw === "string") { + try { + metadata = JSON.parse(metadataRaw) as Record + } catch { + return null + } + } else { + metadata = metadataRaw + } + return typeof metadata.brainMode === "string" + ? (metadata.brainMode as string) + : null +} + /** * Format a number with K/M suffix for display * @example formatUsageNumber(1500000) => "1.5M" diff --git a/apps/web/stores/index.ts b/apps/web/stores/index.ts index 4d754f77..e13cccef 100644 --- a/apps/web/stores/index.ts +++ b/apps/web/stores/index.ts @@ -3,18 +3,27 @@ import { useQueryState } from "nuqs" import { projectParam } from "@/lib/search-params" import { useCallback } from "react" -import { DEFAULT_PROJECT_ID } from "@lib/constants" +import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" export function useProject() { const [selectedProjects, _setSelectedProjects] = useQueryState( "project", projectParam, ) + const hasCompanyBrain = useHasCompanyBrain() + const defaultTag = hasCompanyBrain + ? SHARED_TEAM_BRAIN_TAG + : DEFAULT_PROJECT_ID - const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID + // Normalize empty selection to the default tag so the selector, counts, and + // queries all agree (shared Team Brain for company-brain orgs). + const normalizedProjects = + selectedProjects.length === 0 ? [defaultTag] : selectedProjects - const effectiveContainerTags = - selectedProjects.length === 0 ? [DEFAULT_PROJECT_ID] : selectedProjects + const selectedProject = normalizedProjects[0] + + const effectiveContainerTags = normalizedProjects const setSelectedProjects = useCallback( (projects: string[]) => { @@ -31,7 +40,7 @@ export function useProject() { ) return { - selectedProjects, + selectedProjects: normalizedProjects, selectedProject, setSelectedProjects, setSelectedProject, diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index d3d0bedb..acd15e88 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -18,6 +18,23 @@ type OrganizationListItem = NonNullable< const STORAGE_KEY = "supermemory-consumer-last-org-slug" +// Reads ?org= from the URL once and removes it, so a deep link that +// selects an org doesn't re-fire on refresh or back-navigation. +function consumeRequestedOrgSlug(): string | null { + if (typeof window === "undefined") return null + const params = new URLSearchParams(window.location.search) + const slug = params.get("org") + if (!slug) return null + params.delete("org") + const qs = params.toString() + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`, + ) + return slug +} + interface AuthContextType { session: SessionData["session"] | null user: SessionData["user"] | null @@ -123,6 +140,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { const activeOrgId = session.session.activeOrganizationId + // Deep link (?org=) takes priority — used when arriving from + // the console. Strip the param so refresh/back doesn't re-trigger. + const requestedSlug = consumeRequestedOrgSlug() + if (requestedSlug) { + const match = orgs.find((o) => o.slug === requestedSlug) + if (match) { + if (activeOrgId === match.id) { + const full = await authClient.organization.getFullOrganization() + if (!cancelled) setOrg(full?.data ?? null) + } else { + await setActiveOrg(requestedSlug) + } + return + } + } + if (orgs.length === 1) { const one = orgs[0] if (!one) return diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 01439d1e..7758cdb6 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -1,5 +1,6 @@ const BIG_DIMENSIONS_NEW = 1536 const DEFAULT_PROJECT_ID = "sm_project_default" +const SHARED_TEAM_BRAIN_TAG = "sm_org_shared" const SEARCH_MEMORY_SHORTCUT_URL = "https://www.icloud.com/shortcuts/b0a132cc3c0d475196bc7014aa702a5c" const ADD_MEMORY_SHORTCUT_URL = @@ -12,6 +13,7 @@ const POKE_RECIPE_URL = "https://supermemory.link/poke" export { BIG_DIMENSIONS_NEW, DEFAULT_PROJECT_ID, + SHARED_TEAM_BRAIN_TAG, SEARCH_MEMORY_SHORTCUT_URL, ADD_MEMORY_SHORTCUT_URL, RAYCAST_EXTENSION_URL, diff --git a/packages/lib/types.ts b/packages/lib/types.ts index 9afe1945..34e39c3f 100644 --- a/packages/lib/types.ts +++ b/packages/lib/types.ts @@ -6,6 +6,7 @@ export interface Project { updatedAt: string isExperimental?: boolean emoji?: string + visibility?: "public" | "private" | "unlisted" } export interface ContainerTagListType extends Project { diff --git a/packages/validation/api.ts b/packages/validation/api.ts index e1e8c8ef..23ccff2a 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -1496,6 +1496,10 @@ export const ContainerTagListTypeSchema = z description: "True if containerTag starts with 'sm_project_'", example: true, }), + visibility: z.enum(["public", "private", "unlisted"]).optional().openapi({ + description: "Space visibility (company brain spaces)", + example: "public", + }), }) .openapi({ description: