Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

678 lines
25 KiB
TypeScript

import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { JSONContent } from 'lib/components/RichContentEditor/types'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { DEFAULT_QUERY } from 'scenes/notebooks/Nodes/NotebookNodeQuery'
import { notebookLogic } from 'scenes/notebooks/Notebook/notebookLogic'
import { NotebookNodeType } from 'scenes/notebooks/types'
import { teamLogic } from 'scenes/teamLogic'
import { CustomerProfileScope, GroupTypeIndex } from '~/types'
import { sourceManagementLogic } from 'products/data_warehouse/frontend/shared/logics/sourceManagementLogic'
import type { PaginatedResponse } from '../../../frontend/src/lib/api'
import type { FeatureFlagsSet } from '../../../frontend/src/lib/logic/featureFlagLogic'
import type {
CustomerProfileConfigType,
ExternalDataSource,
TeamPublicType,
TeamType,
} from '../../../frontend/src/types'
import { customerProfileConfigLogic } from './customerProfileConfigLogic'
export const DEFAULT_PERSON_PROFILE_SIDEBAR: JSONContent[] = [
{ type: NotebookNodeType.Person, attrs: { title: 'Info' } },
// FIXME: Map bg image is broken
// { type: NotebookNodeType.Map, attrs: { title: 'Map' } },
{ type: NotebookNodeType.RelatedGroups, attrs: { title: 'Related groups' } },
{ type: NotebookNodeType.PersonProperties, attrs: { title: 'Properties' } },
]
export const DEFAULT_PERSON_PROFILE_CONTENT: JSONContent[] = [
{ type: NotebookNodeType.UsageMetrics, index: 0, attrs: { title: 'Usage metrics' } },
{ type: NotebookNodeType.CustomerJourney, index: 1, attrs: { title: 'Customer journey' } },
{ type: NotebookNodeType.PersonFeed, index: 2, attrs: { title: 'Session feed' } },
{ type: NotebookNodeType.LLMTrace, index: 3, attrs: { title: 'LLM traces' } },
{ type: NotebookNodeType.SupportTickets, index: 4, attrs: { title: 'Support tickets' } },
{ type: NotebookNodeType.ZendeskTickets, index: 5, attrs: { title: 'Zendesk tickets' } },
{ type: NotebookNodeType.Issues, index: 6, attrs: { title: 'Issues' } },
]
export const DEFAULT_GROUP_PROFILE_SIDEBAR: JSONContent[] = [
{ type: NotebookNodeType.Group, attrs: { title: 'Info' } },
{ type: NotebookNodeType.RelatedGroups, attrs: { title: 'Related people', type: 'person' } },
{ type: NotebookNodeType.GroupProperties, attrs: { title: 'Properties' } },
]
export const DEFAULT_GROUP_PROFILE_CONTENT: JSONContent[] = [
{ type: NotebookNodeType.UsageMetrics, index: 0, attrs: { title: 'Usage metrics' } },
{ type: NotebookNodeType.CustomerJourney, index: 1, attrs: { title: 'Customer journey' } },
{ type: NotebookNodeType.Query, index: 2, attrs: { title: 'Events' } },
{ type: NotebookNodeType.LLMTrace, index: 3, attrs: { title: 'LLM traces' } },
{ type: NotebookNodeType.ZendeskTickets, index: 4, attrs: { title: 'Zendesk tickets' } },
{ type: NotebookNodeType.Issues, index: 5, attrs: { title: 'Issues' } },
]
interface PanelAvailability {
isJourneysEnabled: boolean
isSupportEnabled: boolean
hasZendeskSource: boolean
dataWarehouseSourcesLoading: boolean
}
// Panels backed by a product or warehouse source that may not be set up must be filtered out of
// *every* content path — defaults and saved configs alike — so we never render a live query
// against tables that don't exist (e.g. the Zendesk panel with no Zendesk warehouse source).
// Keeping this in one helper means any future entry point that builds profile content stays safe.
function filterAvailablePanels(
nodes: JSONContent[],
{ isJourneysEnabled, isSupportEnabled, hasZendeskSource, dataWarehouseSourcesLoading }: PanelAvailability
): JSONContent[] {
return (
nodes
.filter((node) => node.type !== NotebookNodeType.CustomerJourney || isJourneysEnabled)
// Hide the Zendesk panel unless a Zendesk warehouse source exists — without one its
// query targets non-existent zendesk_* tables.
.filter((node) => node.type !== NotebookNodeType.ZendeskTickets || hasZendeskSource)
// Support panel: show the table when support is on; when it's off, show the "set up
// support" prompt only if there's no Zendesk either (don't nag teams that already use
// Zendesk). Wait for sources to load before deciding, so we never flash the prompt at
// a Zendesk team.
.filter(
(node) =>
node.type !== NotebookNodeType.SupportTickets ||
isSupportEnabled ||
(!hasZendeskSource && !dataWarehouseSourcesLoading)
)
)
}
export type CustomerProfileAttrs = {
personId?: string | undefined
distinctIds?: string[]
groupKey?: string
groupTypeIndex?: GroupTypeIndex
}
export interface PersonProfileLogicProps {
scope: CustomerProfileScope
attrs: CustomerProfileAttrs
key: string
canvasShortId: string
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface customerProfileLogicValues {
customerProfileConfig: CustomerProfileConfigType | undefined // customerProfileConfigLogic
featureFlags: FeatureFlagsSet // featureFlagLogic
dataWarehouseSourcesLoading: boolean // sourceManagementLogic
hasZendeskSource: boolean // sourceManagementLogic
currentTeam: TeamPublicType | TeamType | null // teamLogic
changed: boolean
content: JSONContent[]
defaultContent: JSONContent[]
isProfileConfigEnabled: boolean | string | undefined
profileLocalContent: JSONContent[] | null
scopedAddAttrFunction: ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent
scopedSidebarContent: JSONContent[]
storedContent: JSONContent[] | null
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface customerProfileLogicActions {
createConfig: (config: Partial<CustomerProfileConfigType>) => {
config: Partial<CustomerProfileConfigType>
} // customerProfileConfigLogic
createConfigSuccess: (
configs: CustomerProfileConfigType[],
payload?:
| {
config: Partial<CustomerProfileConfigType>
}
| undefined
) => {
configs: CustomerProfileConfigType[]
payload?: {
config: Partial<CustomerProfileConfigType>
}
} // customerProfileConfigLogic
loadConfigsSuccess: (
configs: CustomerProfileConfigType[],
payload?:
| {
value: true
}
| undefined
) => {
configs: CustomerProfileConfigType[]
payload?: {
value: true
}
} // customerProfileConfigLogic
updateConfig: (
id: string,
config: Partial<CustomerProfileConfigType>
) => {
config: Partial<CustomerProfileConfigType>
id: string
} // customerProfileConfigLogic
updateConfigSuccess: (
configs: CustomerProfileConfigType[],
payload?:
| {
config: Partial<CustomerProfileConfigType>
id: string
}
| undefined
) => {
configs: CustomerProfileConfigType[]
payload?: {
config: Partial<CustomerProfileConfigType>
id: string
}
} // customerProfileConfigLogic
setLocalContent: (
jsonContent: JSONContent,
skipCapture?: any
) => {
jsonContent: JSONContent
skipCapture: any
} // notebookLogic
loadSourcesSuccess: (
dataWarehouseSources:
| PaginatedResponse<ExternalDataSource>
| {
count: number
next: null
previous: null
results: never[]
},
payload?:
| {
value: true
}
| undefined
) => {
dataWarehouseSources:
| PaginatedResponse<ExternalDataSource>
| {
count: number
next: null
previous: null
results: never[]
}
payload?: {
value: true
}
} // sourceManagementLogic
addNode: (nodeType: string) => {
nodeType: string
}
removeNode: (nodeType: string) => {
nodeType: string
}
resetToDefaults: () => {
value: true
}
saveChanges: () => {
value: true
}
setProfileLocalContent: (content: JSONContent[] | null) => {
content: JSONContent[] | null
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface customerProfileLogicMeta {
key: string
__keaTypeGenInternalSelectorTypes: {
changed: (
profileLocalContent: JSONContent[] | null,
storedContent: JSONContent[] | null,
defaultContent: JSONContent[]
) => boolean
isProfileConfigEnabled: (featureFlags: FeatureFlagsSet) => boolean | string | undefined
scopedSidebarContent: (arg: any) => JSONContent[]
scopedAddAttrFunction: (arg: any) => ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent
defaultContent: (
scopedSidebarContent: JSONContent[],
scopedAddAttrFunction: ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent,
featureFlags: FeatureFlagsSet,
hasZendeskSource: boolean,
dataWarehouseSourcesLoading: boolean,
currentTeam: TeamPublicType | TeamType | null,
arg: any,
arg2: any
) => JSONContent[]
storedContent: (
customerProfileConfig: CustomerProfileConfigType | undefined,
scopedAddAttrFunction: ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent,
featureFlags: FeatureFlagsSet,
hasZendeskSource: boolean,
dataWarehouseSourcesLoading: boolean,
currentTeam: TeamPublicType | TeamType | null,
arg: any
) => JSONContent[] | null
content: (
defaultContent: JSONContent[],
storedContent: JSONContent[] | null,
profileLocalContent: JSONContent[] | null,
isProfileConfigEnabled: boolean | string | undefined
) => JSONContent[]
}
}
export type customerProfileLogicType = MakeLogicType<
customerProfileLogicValues,
customerProfileLogicActions,
PersonProfileLogicProps,
customerProfileLogicMeta
>
export const customerProfileLogic = kea<customerProfileLogicType>([
path(['products', 'customer_analytics', 'frontend', 'customerProfileLogic']),
props({} as PersonProfileLogicProps),
key(({ key }) => key),
connect((props: PersonProfileLogicProps) => ({
values: [
customerProfileConfigLogic({ scope: props.scope }),
['customerProfileConfig'],
featureFlagLogic,
['featureFlags'],
sourceManagementLogic,
['hasZendeskSource', 'dataWarehouseSourcesLoading'],
teamLogic,
['currentTeam'],
],
actions: [
customerProfileConfigLogic({ scope: props.scope }),
['createConfig', 'updateConfig', 'loadConfigsSuccess', 'updateConfigSuccess', 'createConfigSuccess'],
notebookLogic({ shortId: props.canvasShortId, mode: 'canvas' }),
['setLocalContent'],
sourceManagementLogic,
['loadSourcesSuccess'],
],
})),
actions({
removeNode: (nodeType: string) => ({ nodeType }),
addNode: (nodeType: string) => ({ nodeType }),
resetToDefaults: true,
setProfileLocalContent: (content: JSONContent[] | null) => ({ content }),
saveChanges: true,
}),
reducers({
profileLocalContent: [
null as JSONContent[] | null,
{
setProfileLocalContent: (_, { content }) => content,
resetToDefaults: () => null,
saveChangesSuccess: () => null,
},
],
}),
selectors({
changed: [
(s) => [s.profileLocalContent, s.storedContent, s.defaultContent],
(
profileLocalContent: JSONContent[] | null,
storedContent: JSONContent[] | null,
defaultContent: JSONContent[]
) => {
if (!profileLocalContent) {
return false
}
const profileLocalContentTypes = profileLocalContent?.map((node) => node.type).join()
const defaultLocalContentTypes = defaultContent.map((node) => node.type).join()
if (profileLocalContentTypes === defaultLocalContentTypes && !storedContent) {
return false
}
if (!storedContent) {
return true
}
const storedContentTypes = storedContent.map((node) => node.type).join()
const hasChanged = profileLocalContentTypes !== storedContentTypes
return hasChanged
},
],
isProfileConfigEnabled: [
(s) => [s.featureFlags],
(featureFlags: import('lib/logic/featureFlagLogic').FeatureFlagsSet) =>
featureFlags[FEATURE_FLAGS.CUSTOMER_PROFILE_CONFIG_BUTTON],
],
scopedSidebarContent: [
() => [(_, props) => props.scope],
(scope) =>
scope === CustomerProfileScope.PERSON ? DEFAULT_PERSON_PROFILE_SIDEBAR : DEFAULT_GROUP_PROFILE_SIDEBAR,
],
scopedAddAttrFunction: [
() => [(_, props) => props.scope],
(scope) => (scope === CustomerProfileScope.PERSON ? addPersonAttrsToNode : addGroupAttrsToNode),
],
defaultContent: [
(s) => [
s.scopedSidebarContent,
s.scopedAddAttrFunction,
s.featureFlags,
s.hasZendeskSource,
s.dataWarehouseSourcesLoading,
s.currentTeam,
(_, props) => props.scope,
(_, props) => props.attrs,
],
(
scopedSidebarContent: JSONContent[],
scopedAddAttrFunction: ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent,
featureFlags: import('lib/logic/featureFlagLogic').FeatureFlagsSet,
hasZendeskSource: boolean,
dataWarehouseSourcesLoading: boolean,
currentTeam: null | import('~/types').TeamPublicType | import('~/types').TeamType,
scope,
attrs
) => {
const scopedDefaultContent = filterAvailablePanels(
scope === CustomerProfileScope.PERSON
? DEFAULT_PERSON_PROFILE_CONTENT
: DEFAULT_GROUP_PROFILE_CONTENT,
{
isJourneysEnabled: !!featureFlags[FEATURE_FLAGS.CUSTOMER_ANALYTICS_JOURNEYS],
isSupportEnabled: !!currentTeam?.conversations_enabled,
hasZendeskSource,
dataWarehouseSourcesLoading,
}
)
const sidebar = scopedSidebarContent.map((node) => scopedAddAttrFunction({ attrs, node }))
return scopedDefaultContent.map((node, index) => {
if (index === 0) {
return scopedAddAttrFunction({ attrs, node, children: sidebar })
}
return scopedAddAttrFunction({ attrs, node })
})
},
],
storedContent: [
(s) => [
s.customerProfileConfig,
s.scopedAddAttrFunction,
s.featureFlags,
s.hasZendeskSource,
s.dataWarehouseSourcesLoading,
s.currentTeam,
(_, props) => props.attrs,
],
(
customerProfileConfig: import('~/types').CustomerProfileConfigType | undefined,
scopedAddAttrFunction: ({ attrs, node, children }: AddAttrsToNodeProps) => JSONContent,
featureFlags: import('lib/logic/featureFlagLogic').FeatureFlagsSet,
hasZendeskSource: boolean,
dataWarehouseSourcesLoading: boolean,
currentTeam: null | import('~/types').TeamPublicType | import('~/types').TeamType,
attrs
): JSONContent[] | null => {
if (!customerProfileConfig) {
return null
}
// Saved configs bypass defaultContent, so apply the same availability filter here —
// otherwise a previously-saved Zendesk/support panel would render against a product
// or source that is no longer set up.
// `content` is declared as Record<string, any> on the config type but is stored as a
// node array; cast to its real shape for the shared filter.
const availableContent = filterAvailablePanels(customerProfileConfig.content as JSONContent[], {
isJourneysEnabled: !!featureFlags[FEATURE_FLAGS.CUSTOMER_ANALYTICS_JOURNEYS],
isSupportEnabled: !!currentTeam?.conversations_enabled,
hasZendeskSource,
dataWarehouseSourcesLoading,
})
const sidebar = customerProfileConfig.sidebar.map((node: JSONContent) =>
scopedAddAttrFunction({ attrs, node })
)
return availableContent.map((node: JSONContent, index: number) => {
if (index === 0) {
return scopedAddAttrFunction({ attrs, node, children: sidebar })
}
return scopedAddAttrFunction({ attrs, node })
})
},
],
content: [
(s) => [s.defaultContent, s.storedContent, s.profileLocalContent, s.isProfileConfigEnabled],
(
defaultContent: JSONContent[],
storedContent: JSONContent[] | null,
profileLocalContent: JSONContent[] | null,
isProfileConfigEnabled: boolean | string | undefined
): JSONContent[] => {
// Return default content if flag is disabled
if (isProfileConfigEnabled) {
if (profileLocalContent !== null) {
return profileLocalContent
}
if (storedContent !== null) {
return storedContent
}
}
return defaultContent
},
],
}),
listeners(({ actions, values, props }) => ({
removeNode: ({ nodeType }) => {
const currentContent = values.profileLocalContent || values.content
const filteredContent = currentContent.filter((node) => node.type !== nodeType)
actions.setProfileLocalContent(filteredContent)
actions.setLocalContent(filteredContent)
},
addNode: ({ nodeType }) => {
const currentContent = values.profileLocalContent || values.content
const nodeToAdd = values.defaultContent.find((node) => node.type === nodeType)
if (!nodeToAdd) {
return
}
const updatedContent = [...currentContent, nodeToAdd].sort((a, b) => {
const indexA = a.index ?? a.attrs?.index ?? 999
const indexB = b.index ?? b.attrs?.index ?? 999
return indexA - indexB
})
actions.setProfileLocalContent(updatedContent)
actions.setLocalContent(updatedContent)
},
resetToDefaults: () => {
actions.setProfileLocalContent(null)
actions.setLocalContent(values.content)
},
saveChanges: async () => {
if (!values.profileLocalContent || !values.changed) {
return
}
const config = {
scope: props.scope,
content: values.profileLocalContent.map((node) => ({
type: node.type,
title: node.attrs?.title || node.title,
index: node.attrs?.index || node.index,
})),
sidebar: values.scopedSidebarContent,
}
if (values.customerProfileConfig) {
actions.updateConfig(values.customerProfileConfig.id, config)
} else {
actions.createConfig(config)
}
},
createConfigSuccess: () => {
actions.setLocalContent(values.content)
actions.resetToDefaults()
},
updateConfigSuccess: () => {
actions.setLocalContent(values.content)
actions.resetToDefaults()
},
loadConfigsSuccess: () => {
actions.setLocalContent(values.content)
},
// The source list loads asynchronously, so the initial mount sync can run before we
// know whether a Zendesk source exists. Re-sync once it resolves so the Zendesk and
// Support panels appear/disappear to match the actual source and support state.
loadSourcesSuccess: () => {
actions.setLocalContent(values.content)
},
})),
afterMount(({ actions, values }) => {
if (values.content) {
actions.setLocalContent(values.content)
}
}),
])
export interface AddAttrsToNodeProps {
attrs: CustomerProfileAttrs
node: JSONContent
children?: JSONContent[]
}
export function addPersonAttrsToNode({ attrs, node, children = [] }: AddAttrsToNodeProps): JSONContent {
const personId = attrs?.personId
const distinctId = attrs?.distinctIds?.[0]
const nodeId = `${node.type}-${personId}`
switch (node.type) {
case NotebookNodeType.UsageMetrics:
case NotebookNodeType.LLMTrace:
case NotebookNodeType.CustomerJourney:
case NotebookNodeType.ZendeskTickets:
case NotebookNodeType.Issues:
return {
...node,
attrs: { ...node.attrs, nodeId, personId, children },
}
case NotebookNodeType.SupportTickets:
return {
...node,
attrs: { ...node.attrs, nodeId, personId, distinctIds: attrs?.distinctIds, children },
}
case NotebookNodeType.PersonFeed:
return {
...node,
attrs: {
...node.attrs,
nodeId,
height: null,
id: personId,
distinctId,
__init: null,
children,
},
}
case NotebookNodeType.Person:
case NotebookNodeType.PersonProperties:
return {
...node,
attrs: { ...node.attrs, nodeId, id: personId, distinctId, children },
}
case NotebookNodeType.RelatedGroups:
return {
...node,
attrs: {
...node.attrs,
nodeId,
id: personId,
type: 'group',
children,
},
}
default:
return node
}
}
export function addGroupAttrsToNode({ attrs, node, children = [] }: AddAttrsToNodeProps): JSONContent {
const groupKey = attrs?.groupKey
const groupTypeIndex = attrs?.groupTypeIndex
const nodeId = `${node.type}-${groupKey}-${groupTypeIndex}`
switch (node.type) {
case NotebookNodeType.UsageMetrics:
case NotebookNodeType.LLMTrace:
case NotebookNodeType.CustomerJourney:
case NotebookNodeType.Issues:
return {
...node,
attrs: {
...node.attrs,
nodeId,
groupKey,
groupTypeIndex,
children,
},
}
case NotebookNodeType.ZendeskTickets:
return {
...node,
attrs: { ...node.attrs, nodeId, groupKey, children },
}
case NotebookNodeType.Group:
case NotebookNodeType.RelatedGroups:
return {
...node,
attrs: {
...node.attrs,
nodeId,
id: groupKey,
type: 'person',
groupTypeIndex,
children,
},
}
case NotebookNodeType.GroupProperties:
return {
...node,
attrs: {
...node.attrs,
nodeId,
},
}
case NotebookNodeType.Query:
return {
...node,
attrs: {
...node.attrs,
query: {
...DEFAULT_QUERY,
contextKey: 'group-profile-events',
showTableViews: true,
embedded: true,
},
},
nodeId,
children,
}
default:
return {
...node,
attrs: {
...node.attrs,
nodeId,
children,
},
}
}
}