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

444 lines
16 KiB
TypeScript

import { MakeLogicType, actions, afterMount, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { toast } from '@hanzo/elements'
import api from 'lib/api'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
import { Breadcrumb } from '~/types'
import {
getUserInterviewTopicsLinksCsvCreateUrl,
userInterviewTopicsGenerateLinksCreate,
userInterviewTopicsIntervieweesList,
userInterviewTopicsPreviewInviteCreate,
userInterviewTopicsRetrieve,
userInterviewTopicsSharedLinkCreate,
userInterviewTopicsTestLinkRetrieve,
userInterviewsList,
} from './generated/api'
import { ClassificationsEnumApi } from './generated/api.schemas'
import type {
IntervieweeContextApi,
InterviewLinkApi,
PreviewInviteResultApi,
TestInterviewLinkApi,
UserInterviewApi,
UserInterviewTopicApi,
} from './generated/api.schemas'
export interface UserInterviewLogicProps {
id: string
}
function unwrapPaginatedOrArray<T>(response: T[] | { results?: T[] }): T[] {
if (Array.isArray(response)) {
return response
}
return response.results ?? []
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface userInterviewLogicValues {
breadcrumbs: Breadcrumb[]
interviewees: IntervieweeContextApi[]
intervieweesLoading: boolean
interviews: UserInterviewApi[]
interviewsLoading: boolean
invitePreview: PreviewInviteResultApi | null
invitePreviewLoading: boolean
linkByIdentifier: Record<string, string>
linkForIdentifier: (identifier: string) => string | undefined
links: InterviewLinkApi[]
linksCsvExporting: boolean
linksLoadFailed: boolean
linksLoading: boolean
previewInviteIdentifier: string | null
respondedCount: number
respondedIdentifiers: Set<string>
responseRate: number
sharedLinkLoading: boolean
testLink: TestInterviewLinkApi | null
testLinkLoading: boolean
topic: UserInterviewTopicApi | null
topicInterviews: UserInterviewApi[]
topicLoading: boolean
totalTargeted: number
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface userInterviewLogicActions {
closeInvitePreview: () => {
value: true
}
copySharedLink: () => {
value: true
}
copySharedLinkDone: () => {
value: true
}
exportLinksCsv: () => {
value: true
}
exportLinksCsvDone: () => {
value: true
}
loadInterviewees: () => any
loadIntervieweesFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadIntervieweesSuccess: (
interviewees: IntervieweeContextApi[],
payload?: any
) => {
interviewees: IntervieweeContextApi[]
payload?: any
}
loadInterviews: () => any
loadInterviewsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadInterviewsSuccess: (
interviews: UserInterviewApi[],
payload?: any
) => {
interviews: UserInterviewApi[]
payload?: any
}
loadInvitePreview: (identifier: string) => string
loadInvitePreviewFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadInvitePreviewSuccess: (
invitePreview: PreviewInviteResultApi,
payload?: string
) => {
invitePreview: PreviewInviteResultApi
payload?: string
}
loadLinks: () => any
loadLinksFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadLinksSuccess: (
links: InterviewLinkApi[],
payload?: any
) => {
links: InterviewLinkApi[]
payload?: any
}
loadTestLink: () => any
loadTestLinkFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadTestLinkSuccess: (
testLink: TestInterviewLinkApi,
payload?: any
) => {
testLink: TestInterviewLinkApi
payload?: any
}
loadTopic: () => any
loadTopicFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadTopicSuccess: (
topic: UserInterviewTopicApi | null,
payload?: any
) => {
topic: UserInterviewTopicApi | null
payload?: any
}
openInvitePreview: (identifier: string) => {
identifier: string
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface userInterviewLogicMeta {
key: string
__keaTypeGenInternalSelectorTypes: {
topicInterviews: (interviews: UserInterviewApi[]) => UserInterviewApi[]
linkByIdentifier: (links: InterviewLinkApi[]) => Record<string, string>
linkForIdentifier: (linkByIdentifier: Record<string, string>) => (identifier: string) => string | undefined
respondedIdentifiers: (topicInterviews: UserInterviewApi[]) => Set<string>
respondedCount: (topic: UserInterviewTopicApi | null, respondedIdentifiers: Set<string>) => number
totalTargeted: (topic: UserInterviewTopicApi | null) => number
responseRate: (respondedCount: number, totalTargeted: number) => number
breadcrumbs: (topic: UserInterviewTopicApi | null) => Breadcrumb[]
}
}
export type userInterviewLogicType = MakeLogicType<
userInterviewLogicValues,
userInterviewLogicActions,
UserInterviewLogicProps,
userInterviewLogicMeta
>
export const userInterviewLogic = kea<userInterviewLogicType>([
path(['products', 'user_interviews', 'frontend', 'userInterviewLogic']),
props({} as UserInterviewLogicProps),
key((props) => props.id),
loaders(({ props }) => ({
topic: {
__default: null as UserInterviewTopicApi | null,
loadTopic: async () => {
const projectId = String(teamLogic.values.currentTeamId)
try {
return await userInterviewTopicsRetrieve(projectId, props.id)
} catch {
return null
}
},
},
interviewees: {
__default: [] as IntervieweeContextApi[],
loadInterviewees: async () => {
const projectId = String(teamLogic.values.currentTeamId)
try {
const response = await userInterviewTopicsIntervieweesList(projectId, props.id)
return response.results
} catch {
return []
}
},
},
interviews: {
__default: [] as UserInterviewApi[],
loadInterviews: async () => {
const projectId = String(teamLogic.values.currentTeamId)
try {
const response = await userInterviewsList(projectId)
return response.results
} catch {
return []
}
},
},
links: {
__default: [] as InterviewLinkApi[],
loadLinks: async (): Promise<InterviewLinkApi[]> => {
const projectId = String(teamLogic.values.currentTeamId)
const response = (await userInterviewTopicsGenerateLinksCreate(projectId, props.id)) as unknown as
| InterviewLinkApi[]
| { results?: InterviewLinkApi[] }
return unwrapPaginatedOrArray(response)
},
},
testLink: {
__default: null as TestInterviewLinkApi | null,
loadTestLink: async (): Promise<TestInterviewLinkApi> => {
const projectId = String(teamLogic.values.currentTeamId)
return await userInterviewTopicsTestLinkRetrieve(projectId, props.id)
},
},
invitePreview: {
__default: null as PreviewInviteResultApi | null,
loadInvitePreview: async (identifier: string): Promise<PreviewInviteResultApi> => {
const projectId = String(teamLogic.values.currentTeamId)
return await userInterviewTopicsPreviewInviteCreate(projectId, props.id, {
interviewee_identifier: identifier,
})
},
},
})),
actions({
exportLinksCsv: true,
exportLinksCsvDone: true,
copySharedLink: true,
copySharedLinkDone: true,
openInvitePreview: (identifier: string) => ({ identifier }),
closeInvitePreview: true,
}),
reducers({
sharedLinkLoading: [
false,
{
copySharedLink: () => true,
copySharedLinkDone: () => false,
},
],
linksLoadFailed: [
false,
{
loadLinks: () => false,
loadLinksFailure: () => true,
},
],
linksCsvExporting: [
false,
{
exportLinksCsv: () => true,
exportLinksCsvDone: () => false,
},
],
previewInviteIdentifier: [
null as string | null,
{
openInvitePreview: (_, { identifier }) => identifier,
closeInvitePreview: () => null,
},
],
}),
listeners(({ props, values, actions }) => ({
openInvitePreview: ({ identifier }) => {
actions.loadInvitePreview(identifier)
},
loadTopicSuccess: ({ topic }) => {
// generate_links 400s for a zero-target topic (now valid — a shared-only link needs no
// targeted interviewees), so only materialise personalised links once we know the topic
// has some. Avoids a guaranteed-failing POST and a misleading "couldn't generate link"
// state on the shared-only flow.
const hasTargets =
(topic?.interviewee_emails?.length || 0) + (topic?.interviewee_distinct_ids?.length || 0) > 0
if (hasTargets) {
actions.loadLinks()
}
},
copySharedLink: async () => {
const projectId = String(teamLogic.values.currentTeamId)
try {
// Idempotent get-or-create — returns the topic's single shared link.
const { interview_url } = await userInterviewTopicsSharedLinkCreate(projectId, props.id)
await copyToClipboard(interview_url, 'shared interview link')
} catch {
toast.error('Could not generate the shared interview link')
} finally {
actions.copySharedLinkDone()
}
},
exportLinksCsv: async () => {
const projectId = String(teamLogic.values.currentTeamId)
try {
const response = await api.createResponse(getUserInterviewTopicsLinksCsvCreateUrl(projectId, props.id))
if (!response.ok) {
throw new Error(`Export failed (${response.status})`)
}
const blob = await response.blob()
const filename = `${(values.topic?.topic || 'user-interview')
.replace(/[^\w-]+/g, '-')
.toLowerCase()}-links.csv`
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
} catch {
toast.error('Could not export interview links as CSV')
} finally {
actions.exportLinksCsvDone()
}
},
})),
selectors(({ props }) => ({
topicInterviews: [
(s) => [s.interviews],
(interviews: UserInterviewApi[]): UserInterviewApi[] => interviews.filter((i) => i.topic === props.id),
],
linkByIdentifier: [
(s) => [s.links],
(links: InterviewLinkApi[]): Record<string, string> =>
Object.fromEntries(links.map((link) => [link.interviewee_identifier, link.interview_url])),
],
linkForIdentifier: [
(s) => [s.linkByIdentifier],
(linkByIdentifier: Record<string, string>): ((identifier: string) => string | undefined) =>
(identifier: string) =>
linkByIdentifier[identifier],
],
respondedIdentifiers: [
(s) => [s.topicInterviews],
(interviews: UserInterviewApi[]): Set<string> => {
const responded = new Set<string>()
for (const interview of interviews) {
// Mirror the backend `has_replied` rule: an abandoned partial (e.g. a mid-call
// refresh) is not a reply, so it must not mark the invitee responded — otherwise
// the topic page and the public link disagree on who still needs chasing.
if (interview.classifications?.includes(ClassificationsEnumApi.Abandoned)) {
continue
}
if (interview.transcript || interview.summary) {
if (interview.interviewee_identifier) {
responded.add(interview.interviewee_identifier)
}
}
}
return responded
},
],
respondedCount: [
(s) => [s.topic, s.respondedIdentifiers],
(topic: UserInterviewTopicApi | null, respondedIdentifiers: Set<string>): number => {
const allTargeted = [...(topic?.interviewee_emails || []), ...(topic?.interviewee_distinct_ids || [])]
return allTargeted.filter((id) => respondedIdentifiers.has(id)).length
},
],
totalTargeted: [
(s) => [s.topic],
(topic: UserInterviewTopicApi | null): number =>
(topic?.interviewee_emails?.length || 0) + (topic?.interviewee_distinct_ids?.length || 0),
],
responseRate: [
(s) => [s.respondedCount, s.totalTargeted],
(respondedCount: number, totalTargeted: number): number =>
totalTargeted > 0 ? Math.round((respondedCount / totalTargeted) * 100) : 0,
],
breadcrumbs: [
(s) => [s.topic],
(topic: UserInterviewTopicApi | null): Breadcrumb[] => [
{
key: 'UserInterviews',
name: 'User research',
path: urls.userInterviews(),
iconType: 'user_interview',
},
{
key: props.id,
name: topic?.topic || props.id,
path: urls.userInterview(props.id),
iconType: 'user_interview',
},
],
],
})),
afterMount(({ actions }) => {
actions.loadTopic()
actions.loadInterviewees()
actions.loadInterviews()
// loadLinks is fired from loadTopicSuccess only when the topic has targeted interviewees —
// generate_links 400s for the zero-target (shared-only) topics this now supports.
actions.loadTestLink()
}),
])