mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
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.
444 lines
16 KiB
TypeScript
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()
|
|
}),
|
|
])
|