mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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
This commit is contained in:
parent
cf47d73126
commit
504940414c
7 changed files with 164 additions and 5 deletions
16
apps/web/hooks/use-company-brain.ts
Normal file
16
apps/web/hooks/use-company-brain.ts
Normal file
|
|
@ -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, unknown> | 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"
|
||||
}
|
||||
|
|
@ -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, unknown> | string | null | undefined,
|
||||
): boolean {
|
||||
if (!metadataRaw) return false
|
||||
let metadata: Record<string, unknown>
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
metadata = metadataRaw
|
||||
}
|
||||
const overrides = metadata.featureOverrides as
|
||||
| Record<string, { allow?: boolean }>
|
||||
| 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, unknown> | string | null | undefined,
|
||||
): boolean | undefined {
|
||||
if (!metadataRaw) return undefined
|
||||
let metadata: Record<string, unknown>
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
metadata = metadataRaw
|
||||
}
|
||||
const overrides = metadata.featureOverrides as
|
||||
| Record<string, { allow?: boolean }>
|
||||
| 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, unknown> | string | null | undefined,
|
||||
): string | null {
|
||||
if (!metadataRaw) return null
|
||||
let metadata: Record<string, unknown>
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} 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, unknown> | string | null | undefined,
|
||||
): string | null {
|
||||
if (!metadataRaw) return null
|
||||
let metadata: Record<string, unknown>
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} 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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,23 @@ type OrganizationListItem = NonNullable<
|
|||
|
||||
const STORAGE_KEY = "supermemory-consumer-last-org-slug"
|
||||
|
||||
// Reads ?org=<slug> 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=<slug>) 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface Project {
|
|||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
emoji?: string
|
||||
visibility?: "public" | "private" | "unlisted"
|
||||
}
|
||||
|
||||
export interface ContainerTagListType extends Project {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue