mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
388 lines
15 KiB
TypeScript
388 lines
15 KiB
TypeScript
import { MakeLogicType, actions, afterMount, kea, key, listeners, path, props, reducers, selectors } from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
import { router, urlToAction } from 'kea-router'
|
|
import insights from 'insights-js'
|
|
|
|
import { toast } from 'lib/elements/Toast'
|
|
import { getCurrentTeamId } from 'lib/utils/getAppContext'
|
|
import { sceneConfigurations } from 'scenes/scenes'
|
|
import { Scene } from 'scenes/sceneTypes'
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import { Breadcrumb } from '~/types'
|
|
|
|
import {
|
|
subscriptionsDeliveriesList,
|
|
subscriptionsPartialUpdate,
|
|
subscriptionsRetrieve,
|
|
subscriptionsTestDeliveryCreate,
|
|
} from 'products/subscriptions/frontend/generated/api'
|
|
import type {
|
|
PaginatedSubscriptionDeliveryListApi,
|
|
SubscriptionApi,
|
|
SubscriptionsDeliveriesListStatus,
|
|
} from 'products/subscriptions/frontend/generated/api.schemas'
|
|
|
|
import { runSubscriptionTestDelivery } from '../components/Subscriptions/runSubscriptionTestDelivery'
|
|
import { subscriptionName } from './components/SubscriptionsTable'
|
|
|
|
export type SubscriptionSceneLogicProps = {
|
|
id: string
|
|
}
|
|
|
|
export type DeliveryFeedback = 'positive' | 'negative'
|
|
export type DeliveryFeedbackSource = 'email' | 'slack' | 'in_app'
|
|
|
|
export const FEEDBACK_THANKS_DISPLAY_MS = 1000
|
|
|
|
function parseCursorFromPaginationUrl(url: string | null | undefined): string | undefined {
|
|
if (!url) {
|
|
return undefined
|
|
}
|
|
try {
|
|
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
|
|
const cursor = parsed.searchParams.get('cursor')
|
|
return cursor ?? undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface subscriptionSceneLogicValues {
|
|
breadcrumbs: Breadcrumb[]
|
|
deliveriesPage: PaginatedSubscriptionDeliveryListApi | null
|
|
deliveriesPageLoading: boolean
|
|
deliveringSubscriptionId: number | null
|
|
deliveryFeedback: Record<string, DeliveryFeedback>
|
|
deliveryStatusFilter: SubscriptionsDeliveriesListStatus | null
|
|
recentlyThankedDeliveries: Record<string, true>
|
|
subscription: SubscriptionApi | null
|
|
subscriptionLoading: boolean
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface subscriptionSceneLogicActions {
|
|
deliverSubscription: (id: number) => {
|
|
id: number
|
|
}
|
|
deliverSubscriptionFailure: () => {
|
|
value: true
|
|
}
|
|
deliverSubscriptionSuccess: () => {
|
|
value: true
|
|
}
|
|
expireDeliveryThanks: (deliveryId: string) => {
|
|
deliveryId: string
|
|
}
|
|
loadDeliveriesPage: (targetUrl: string | null) => string | null
|
|
loadDeliveriesPageFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadDeliveriesPageSuccess: (
|
|
deliveriesPage: PaginatedSubscriptionDeliveryListApi | null,
|
|
payload?: string | null
|
|
) => {
|
|
deliveriesPage: PaginatedSubscriptionDeliveryListApi | null
|
|
payload?: string | null
|
|
}
|
|
loadSubscription: () => any
|
|
loadSubscriptionFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
loadSubscriptionSuccess: (
|
|
subscription: SubscriptionApi | null,
|
|
payload?: any
|
|
) => {
|
|
subscription: SubscriptionApi | null
|
|
payload?: any
|
|
}
|
|
setDeliveryStatusFilter: (status: SubscriptionsDeliveriesListStatus | null) => {
|
|
status: SubscriptionsDeliveriesListStatus | null
|
|
}
|
|
setEnabled: ({ enabled }: { enabled: boolean }) => {
|
|
enabled: boolean
|
|
}
|
|
setEnabledFailure: (
|
|
error: string,
|
|
errorObject?: any
|
|
) => {
|
|
error: string
|
|
errorObject?: any
|
|
}
|
|
setEnabledSuccess: (
|
|
subscription: SubscriptionApi | null,
|
|
payload?: {
|
|
enabled: boolean
|
|
}
|
|
) => {
|
|
subscription: SubscriptionApi | null
|
|
payload?: {
|
|
enabled: boolean
|
|
}
|
|
}
|
|
submitDeliveryFeedback: (
|
|
deliveryId: string,
|
|
feedback: DeliveryFeedback,
|
|
source: DeliveryFeedbackSource
|
|
) => {
|
|
deliveryId: string
|
|
feedback: DeliveryFeedback
|
|
source: DeliveryFeedbackSource
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface subscriptionSceneLogicMeta {
|
|
key: string
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
breadcrumbs: (subscription: SubscriptionApi | null) => Breadcrumb[]
|
|
}
|
|
}
|
|
|
|
export type subscriptionSceneLogicType = MakeLogicType<
|
|
subscriptionSceneLogicValues,
|
|
subscriptionSceneLogicActions,
|
|
SubscriptionSceneLogicProps,
|
|
subscriptionSceneLogicMeta
|
|
>
|
|
|
|
export const subscriptionSceneLogic = kea<subscriptionSceneLogicType>([
|
|
props({} as SubscriptionSceneLogicProps),
|
|
key(({ id }) => id),
|
|
path((key) => ['scenes', 'subscriptions', 'subscriptionSceneLogic', key]),
|
|
actions({
|
|
deliverSubscription: (id: number) => ({ id }),
|
|
deliverSubscriptionSuccess: true,
|
|
deliverSubscriptionFailure: true,
|
|
setDeliveryStatusFilter: (status: SubscriptionsDeliveriesListStatus | null) => ({ status }),
|
|
submitDeliveryFeedback: (deliveryId: string, feedback: DeliveryFeedback, source: DeliveryFeedbackSource) => ({
|
|
deliveryId,
|
|
feedback,
|
|
source,
|
|
}),
|
|
expireDeliveryThanks: (deliveryId: string) => ({ deliveryId }),
|
|
}),
|
|
reducers({
|
|
deliveringSubscriptionId: [
|
|
null as number | null,
|
|
{
|
|
deliverSubscription: (_, { id }) => id,
|
|
deliverSubscriptionSuccess: () => null,
|
|
deliverSubscriptionFailure: () => null,
|
|
},
|
|
],
|
|
deliveryStatusFilter: [
|
|
null as SubscriptionsDeliveriesListStatus | null,
|
|
{
|
|
setDeliveryStatusFilter: (_, { status }) => status,
|
|
},
|
|
],
|
|
deliveryFeedback: [
|
|
{} as Record<string, DeliveryFeedback>,
|
|
// localStorage on purpose: feedback is an analytics event, not a DB row — this only keeps
|
|
// the UI honest (and the capture deduped) across reloads on this browser.
|
|
{ persist: true },
|
|
{
|
|
submitDeliveryFeedback: (state, { deliveryId, feedback }) => ({ ...state, [deliveryId]: feedback }),
|
|
},
|
|
],
|
|
// Transient (not persisted): drives the brief "Thanks!" flash before the row settles
|
|
// into showing the recorded option.
|
|
recentlyThankedDeliveries: [
|
|
{} as Record<string, true>,
|
|
{
|
|
submitDeliveryFeedback: (state, { deliveryId }) => ({ ...state, [deliveryId]: true as const }),
|
|
expireDeliveryThanks: (state, { deliveryId }) => {
|
|
const { [deliveryId]: _removed, ...rest } = state
|
|
return rest
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
loaders(({ values, props }) => ({
|
|
subscription: [
|
|
null as SubscriptionApi | null,
|
|
{
|
|
loadSubscription: async () => {
|
|
const numericId = parseInt(props.id, 10)
|
|
if (!Number.isFinite(numericId)) {
|
|
return null
|
|
}
|
|
return await subscriptionsRetrieve(String(getCurrentTeamId()), numericId)
|
|
},
|
|
// Used by the Pause / Resume button on the detail page. PATCH
|
|
// returns the updated subscription so the loader replaces the
|
|
// whole value — no extra GET needed.
|
|
setEnabled: async ({ enabled }: { enabled: boolean }) => {
|
|
const numericId = parseInt(props.id, 10)
|
|
if (!Number.isFinite(numericId)) {
|
|
return values.subscription
|
|
}
|
|
return await subscriptionsPartialUpdate(String(getCurrentTeamId()), numericId, { enabled })
|
|
},
|
|
},
|
|
],
|
|
deliveriesPage: [
|
|
null as PaginatedSubscriptionDeliveryListApi | null,
|
|
{
|
|
loadDeliveriesPage: async (targetUrl: string | null) => {
|
|
const numericId = parseInt(props.id, 10)
|
|
if (!Number.isFinite(numericId)) {
|
|
return null
|
|
}
|
|
const teamId = String(getCurrentTeamId())
|
|
const status = values.deliveryStatusFilter ?? undefined
|
|
if (targetUrl === null) {
|
|
return await subscriptionsDeliveriesList(teamId, numericId, status ? { status } : {})
|
|
}
|
|
const cursor = parseCursorFromPaginationUrl(targetUrl)
|
|
return await subscriptionsDeliveriesList(teamId, numericId, {
|
|
...(cursor ? { cursor } : {}),
|
|
...(status ? { status } : {}),
|
|
})
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
selectors({
|
|
breadcrumbs: [
|
|
(s) => [s.subscription],
|
|
(subscription: SubscriptionApi | null): Breadcrumb[] => {
|
|
const listCrumb: Breadcrumb = {
|
|
key: Scene.Subscriptions,
|
|
name: sceneConfigurations[Scene.Subscriptions].name,
|
|
path: urls.subscriptions(),
|
|
iconType: sceneConfigurations[Scene.Subscriptions].iconType || 'default_icon_type',
|
|
}
|
|
if (!subscription) {
|
|
return [listCrumb]
|
|
}
|
|
return [
|
|
listCrumb,
|
|
{
|
|
key: [Scene.Subscription, subscription.id],
|
|
name: subscriptionName(subscription),
|
|
iconType: sceneConfigurations[Scene.Subscription].iconType || 'default_icon_type',
|
|
},
|
|
]
|
|
},
|
|
],
|
|
}),
|
|
listeners(({ actions, values, props, cache, selectors }) => ({
|
|
submitDeliveryFeedback: ({ deliveryId, feedback, source }, _breakpoint, _action, previousState) => {
|
|
insights.capture('ai_report_feedback', {
|
|
subscription_id: parseInt(props.id, 10),
|
|
delivery_id: deliveryId,
|
|
feedback,
|
|
source,
|
|
// Lets analysis distinguish first votes from switches; consumers take the latest
|
|
// event per person + delivery, so a switched vote simply wins.
|
|
previous_feedback: selectors.deliveryFeedback(previousState)[deliveryId] ?? null,
|
|
})
|
|
// In-app thumbs show a per-row "Thanks" state instead of a toast.
|
|
if (source !== 'in_app') {
|
|
toast.success('Thanks for your feedback')
|
|
}
|
|
// Per-delivery key so spamming replaces the previous timer instead of stacking.
|
|
cache.disposables.add(() => {
|
|
const timerId = setTimeout(() => actions.expireDeliveryThanks(deliveryId), FEEDBACK_THANKS_DISPLAY_MS)
|
|
return () => clearTimeout(timerId)
|
|
}, `deliveryThanks-${deliveryId}`)
|
|
},
|
|
setDeliveryStatusFilter: () => {
|
|
if (values.subscription) {
|
|
void actions.loadDeliveriesPage(null)
|
|
}
|
|
},
|
|
loadSubscriptionSuccess: () => {
|
|
if (values.subscription) {
|
|
void actions.loadDeliveriesPage(null)
|
|
}
|
|
},
|
|
deliverSubscription: async ({ id }) => {
|
|
const result = await runSubscriptionTestDelivery(() =>
|
|
subscriptionsTestDeliveryCreate(String(getCurrentTeamId()), id)
|
|
)
|
|
if (result === 'success') {
|
|
actions.deliverSubscriptionSuccess()
|
|
} else {
|
|
actions.deliverSubscriptionFailure()
|
|
}
|
|
},
|
|
// Test delivery returns 202 before Temporal persists the delivery row. Refetch now (fast path) and
|
|
// again after a short delay so the list usually shows the new row without manual refresh.
|
|
deliverSubscriptionSuccess: async (_, breakpoint) => {
|
|
void actions.loadDeliveriesPage(null)
|
|
await breakpoint(2000)
|
|
void actions.loadDeliveriesPage(null)
|
|
},
|
|
loadDeliveriesPageFailure: ({ errorObject }) => {
|
|
const status = errorObject?.status
|
|
const detail = errorObject?.detail
|
|
const message =
|
|
status === 404
|
|
? 'Delivery history is not available.'
|
|
: typeof detail === 'string'
|
|
? detail
|
|
: 'Could not load delivery history.'
|
|
toast.error(message)
|
|
},
|
|
setEnabledSuccess: ({ subscription }) => {
|
|
if (!subscription) {
|
|
return
|
|
}
|
|
toast.success(subscription.enabled ? 'Subscription enabled' : 'Subscription disabled')
|
|
},
|
|
setEnabledFailure: ({ errorObject }) => {
|
|
// Surface the serializer's actionable message (e.g. re-enabling a Slack
|
|
// sub with no integration) — backend already validates this.
|
|
const detail = errorObject?.detail
|
|
toast.error(typeof detail === 'string' ? detail : 'Could not update subscription')
|
|
},
|
|
})),
|
|
urlToAction(({ actions, props, values }) => ({
|
|
// Links in delivered reports land here with these params; capture once,
|
|
// then strip them so a refresh doesn't double-capture.
|
|
[urls.subscription(':id')]: ({ id }, searchParams) => {
|
|
if (id !== props.id) {
|
|
return
|
|
}
|
|
const { feedback_delivery, feedback, feedback_source, delivery, ...restSearchParams } = searchParams
|
|
const clickedDelivery = feedback_delivery ?? delivery
|
|
if (!clickedDelivery) {
|
|
return
|
|
}
|
|
const source = feedback_source === 'slack' ? 'slack' : 'email'
|
|
// Delivery engagement is a proxy for the report being read — subscribing alone is a weak signal.
|
|
insights.capture('ai_report_clicked', {
|
|
subscription_id: parseInt(props.id, 10),
|
|
delivery_id: String(clickedDelivery),
|
|
link: feedback_delivery ? 'feedback' : 'manage',
|
|
source,
|
|
})
|
|
if (feedback_delivery && (feedback === 'positive' || feedback === 'negative')) {
|
|
const deliveryId = String(feedback_delivery)
|
|
if (values.deliveryFeedback[deliveryId]) {
|
|
// Persisted state remembers this delivery — don't re-capture from a re-clicked link.
|
|
toast.info('Your feedback for this report was already recorded')
|
|
} else {
|
|
actions.submitDeliveryFeedback(deliveryId, feedback, source)
|
|
}
|
|
}
|
|
router.actions.replace(router.values.location.pathname, restSearchParams, router.values.hashParams)
|
|
},
|
|
})),
|
|
afterMount(({ actions }) => {
|
|
void actions.loadSubscription()
|
|
}),
|
|
])
|