diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 5478769308..10cad97cbc 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -249,7 +249,37 @@ export interface SignalSourceConfig { | "conversations" | "error_tracking" | "pganalyze" - | "signals_scout"; + | "signals_scout" + | "freshdesk" + | "freshservice" + | "front" + | "gorgias" + | "kustomer" + | "dixa" + | "plain" + | "gitlab" + | "gitea" + | "shortcut" + | "sentry" + | "rollbar" + | "bugsnag" + | "honeybadger" + | "raygun" + | "snyk" + | "sonarqube" + | "semgrep" + | "rapid7_insightvm" + | "featurebase" + | "frill" + | "aha" + | "uservoice" + | "productboard" + | "canny" + | "asknicely" + | "retently" + | "appfigures" + | "appfollow" + | "judgeme_reviews"; source_type: | "session_analysis_cluster" | "evaluation" @@ -258,7 +288,10 @@ export interface SignalSourceConfig { | "issue_created" | "issue_reopened" | "issue_spiking" - | "cross_source_issue"; + | "cross_source_issue" + | "scanner_finding" + | "feedback" + | "review"; enabled: boolean; config: Record; created_at: string; diff --git a/packages/core/src/inbox/signalSourceService.ts b/packages/core/src/inbox/signalSourceService.ts index b83b43801d..b13abdead3 100644 --- a/packages/core/src/inbox/signalSourceService.ts +++ b/packages/core/src/inbox/signalSourceService.ts @@ -4,28 +4,20 @@ import type { PostHogAPIClient, SignalSourceConfig, } from "@posthog/api-client/posthog-client"; +import { + EXTERNAL_INBOX_SOURCES, + type SignalRecordKind, + sourceNeedsFullRefresh, + type ToggleableSourceProduct, +} from "@posthog/shared"; import type { SignalUserAutonomyConfig } from "@posthog/shared/domain-types"; import { injectable } from "inversify"; -export interface SignalSourceValues { - session_replay: boolean; - error_tracking: boolean; - github: boolean; - linear: boolean; - jira: boolean; - zendesk: boolean; - conversations: boolean; - pganalyze: boolean; -} - -export type SignalSourceProduct = keyof SignalSourceValues; - -export type WarehouseSourceProduct = - | "github" - | "linear" - | "jira" - | "zendesk" - | "pganalyze"; +export type SignalSourceProduct = ToggleableSourceProduct; +export type SignalSourceValues = Record; +// Any warehouse source can be a setup target; membership in DATA_WAREHOUSE_SOURCES is the +// runtime narrowing, so this stays a broad alias rather than a hand-kept union. +export type WarehouseSourceProduct = SignalSourceProduct; export interface SignalSourceState { requiresSetup: boolean; @@ -37,20 +29,16 @@ export interface ToggleSourceResult { isFirstConnection: boolean; } -type SourceProduct = SignalSourceConfig["source_product"]; type SourceType = SignalSourceConfig["source_type"]; -const SOURCE_TYPE_MAP: Record< - Exclude, - SourceType -> = { +// Non-warehouse toggles are hard-wired; warehouse sources are derived from the shared +// EXTERNAL_INBOX_SOURCES registry (kept in sync with the UI hook). +const SOURCE_TYPE_MAP: Partial> = { session_replay: "session_analysis_cluster", - github: "issue", - linear: "issue", - jira: "issue", - zendesk: "ticket", conversations: "ticket", - pganalyze: "issue", + ...Object.fromEntries( + EXTERNAL_INBOX_SOURCES.map((s) => [s.product, s.recordKind]), + ), }; const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ @@ -60,30 +48,27 @@ const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ ]; const DATA_WAREHOUSE_SOURCES: Record< - WarehouseSourceProduct, - { dwSourceType: string; requiredTable: string } -> = { - github: { dwSourceType: "Github", requiredTable: "issues" }, - linear: { dwSourceType: "Linear", requiredTable: "issues" }, - jira: { dwSourceType: "Jira", requiredTable: "issues" }, - zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, - pganalyze: { dwSourceType: "PgAnalyze", requiredTable: "issues" }, -}; + string, + { dwSourceType: string; requiredTable: string; recordKind: SourceType } +> = Object.fromEntries( + EXTERNAL_INBOX_SOURCES.map((s) => [ + s.product, + { + dwSourceType: s.dwSourceType, + requiredTable: s.requiredTables[0], + recordKind: s.recordKind, + }, + ]), +); const ALL_SOURCE_PRODUCTS: SignalSourceProduct[] = [ "session_replay", "error_tracking", - "github", - "linear", - "jira", - "zendesk", "conversations", - "pganalyze", + ...EXTERNAL_INBOX_SOURCES.map((s) => s.product as SignalSourceProduct), ]; -function isWarehouseSource( - product: SignalSourceProduct, -): product is WarehouseSourceProduct { +function isWarehouseSource(product: SignalSourceProduct): boolean { return product in DATA_WAREHOUSE_SOURCES; } @@ -106,16 +91,9 @@ function findExternalSource( export function computeSourceValues( configs: SignalSourceConfig[] | undefined, ): SignalSourceValues { - const result: SignalSourceValues = { - session_replay: false, - error_tracking: false, - github: false, - linear: false, - jira: false, - zendesk: false, - conversations: false, - pganalyze: false, - }; + const result = Object.fromEntries( + ALL_SOURCE_PRODUCTS.map((p) => [p, false]), + ) as SignalSourceValues; if (!configs?.length) { return result; } @@ -199,11 +177,9 @@ export class SignalSourceService { return; } - const issuesFullReplication = - (product === "github" || product === "linear" || product === "jira") && - dwConfig.requiredTable === "issues"; - - if (issuesFullReplication) { + // Issue-like records (issues, findings, feedback, reviews) mutate after creation, so + // they need full-refresh sync; tickets are append-only. + if (sourceNeedsFullRefresh(dwConfig.recordKind as SignalRecordKind)) { const needsUpdate = !requiredSchema.should_sync || requiredSchema.sync_type !== "full_refresh"; @@ -319,20 +295,15 @@ export class SignalSourceService { configs: SignalSourceConfig[] | undefined, ): Promise { const existing = configs?.find((c) => c.source_product === product); + const sourceType = SOURCE_TYPE_MAP[product]; if (existing) { await client.updateSignalSourceConfig(projectId, existing.id, { enabled, }); - } else if (enabled) { + } else if (enabled && sourceType) { await client.createSignalSourceConfig(projectId, { source_product: product, - source_type: - SOURCE_TYPE_MAP[ - product as Exclude< - SourceProduct, - "error_tracking" | "llm_analytics" | "signals_scout" - > - ], + source_type: sourceType, enabled: true, }); } @@ -345,13 +316,14 @@ export class SignalSourceService { configs: SignalSourceConfig[] | undefined, ): Promise { const existing = configs?.find((c) => c.source_product === product); - if (!existing) { + const sourceType = SOURCE_TYPE_MAP[product]; + if (!existing && sourceType) { await client.createSignalSourceConfig(projectId, { source_product: product, - source_type: SOURCE_TYPE_MAP[product], + source_type: sourceType, enabled: true, }); - } else if (!existing.enabled) { + } else if (existing && !existing.enabled) { await client.updateSignalSourceConfig(projectId, existing.id, { enabled: true, }); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 984314b2b1..20ddd33cae 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -803,7 +803,37 @@ export interface SignalSourceConnectedProperties { | "zendesk" | "conversations" | "pganalyze" - | "llm_analytics"; + | "llm_analytics" + | "freshdesk" + | "freshservice" + | "front" + | "gorgias" + | "kustomer" + | "dixa" + | "plain" + | "gitlab" + | "gitea" + | "shortcut" + | "sentry" + | "rollbar" + | "bugsnag" + | "honeybadger" + | "raygun" + | "snyk" + | "sonarqube" + | "semgrep" + | "rapid7_insightvm" + | "featurebase" + | "frill" + | "aha" + | "uservoice" + | "productboard" + | "canny" + | "asknicely" + | "retently" + | "appfigures" + | "appfollow" + | "judgeme_reviews"; /** True when this is a brand-new createSignalSourceConfig, false for re-enable of an existing config. */ is_first_connection: boolean; /** True when the connection went through the DataSourceSetup wizard (warehouse OAuth path). */ diff --git a/packages/shared/src/inbox-types.ts b/packages/shared/src/inbox-types.ts index 091a08ccb4..c590219b0d 100644 --- a/packages/shared/src/inbox-types.ts +++ b/packages/shared/src/inbox-types.ts @@ -15,4 +15,455 @@ export type SourceProduct = | "zendesk" | "conversations" | "pganalyze" - | "signals_scout"; + | "signals_scout" + // Warehouse-backed self-driving inbox sources (see EXTERNAL_INBOX_SOURCES). + | "freshdesk" + | "freshservice" + | "front" + | "gorgias" + | "kustomer" + | "dixa" + | "plain" + | "gitlab" + | "gitea" + | "shortcut" + | "sentry" + | "rollbar" + | "bugsnag" + | "honeybadger" + | "raygun" + | "snyk" + | "sonarqube" + | "semgrep" + | "rapid7_insightvm" + | "featurebase" + | "frill" + | "aha" + | "uservoice" + | "productboard" + | "canny" + | "asknicely" + | "retently" + | "appfigures" + | "appfollow" + | "judgeme_reviews"; + +/** + * Products that render as a toggle in the Self-driving sources modal: the three PostHog-data + * inputs plus every warehouse source in EXTERNAL_INBOX_SOURCES. Excludes non-toggle products + * (`llm_analytics`, `signals_scout`) that appear only as signal origins. + */ +export type ToggleableSourceProduct = + | "session_replay" + | "error_tracking" + | "conversations" + | "github" + | "linear" + | "jira" + | "gitlab" + | "gitea" + | "shortcut" + | "sentry" + | "rollbar" + | "bugsnag" + | "honeybadger" + | "raygun" + | "zendesk" + | "freshdesk" + | "freshservice" + | "front" + | "gorgias" + | "kustomer" + | "dixa" + | "plain" + | "pganalyze" + | "snyk" + | "sonarqube" + | "semgrep" + | "rapid7_insightvm" + | "featurebase" + | "frill" + | "aha" + | "uservoice" + | "productboard" + | "canny" + | "asknicely" + | "retently" + | "appfigures" + | "appfollow" + | "judgeme_reviews"; + +/** Signal record kind (backend `source_type`) for a warehouse-backed inbox source. */ +export type SignalRecordKind = + | "issue" + | "ticket" + | "scanner_finding" + | "feedback" + | "review"; + +/** + * A warehouse data source the Self-driving inbox can watch. This is the single source of + * truth the toggle grid, setup switch, source-type map, and DWH connection map derive from — + * adding a warehouse source is one entry here (plus its backend emitter and, if it uses OAuth, + * a bespoke setup form). `setup: "dynamic"` renders the generic credential form; the three + * legacy special-cased flows keep their own key. + */ +export interface ExternalInboxSource { + /** Backend `source_product` (lowercase). */ + product: SourceProduct; + /** Display label for the toggle card / filter. */ + label: string; + /** One-line card description. */ + description: string; + /** Capitalized DWH `source_type` used to match/create the external data source. */ + dwSourceType: string; + /** Warehouse table(s) that must be syncing for signals to flow. */ + requiredTables: string[]; + /** Backend signal `source_type` this source emits. */ + recordKind: SignalRecordKind; + /** Setup flow: the generic dynamic credential form, or a legacy special case. */ + setup: "dynamic" | "github" | "zendesk" | "pganalyze"; +} + +const ISSUE = "Monitor new issues and updates"; +const TICKET = "Monitor incoming support tickets"; +const CONVERSATION = "Monitor incoming support conversations"; +const ERROR = "Surface new and reopened errors"; +const FINDING = "Surface new security and code-quality findings"; +const FEEDBACK = "Turn product feedback and feature requests into inputs"; +const REVIEW = "Monitor new app and product reviews"; + +export const EXTERNAL_INBOX_SOURCES: ExternalInboxSource[] = [ + // Issue trackers + { + product: "github", + label: "GitHub Issues", + description: ISSUE, + dwSourceType: "Github", + requiredTables: ["issues"], + recordKind: "issue", + setup: "github", + }, + { + product: "linear", + label: "Linear", + description: ISSUE, + dwSourceType: "Linear", + requiredTables: ["issues"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "jira", + label: "Jira", + description: ISSUE, + dwSourceType: "Jira", + requiredTables: ["issues"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "gitlab", + label: "GitLab", + description: ISSUE, + dwSourceType: "GitLab", + requiredTables: ["issues"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "gitea", + label: "Gitea", + description: ISSUE, + dwSourceType: "Gitea", + requiredTables: ["issues"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "shortcut", + label: "Shortcut", + description: ISSUE, + dwSourceType: "Shortcut", + requiredTables: ["stories"], + recordKind: "issue", + setup: "dynamic", + }, + // Error tracking + { + product: "sentry", + label: "Sentry", + description: ERROR, + dwSourceType: "Sentry", + requiredTables: ["issues"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "rollbar", + label: "Rollbar", + description: ERROR, + dwSourceType: "Rollbar", + requiredTables: ["items"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "bugsnag", + label: "Bugsnag", + description: ERROR, + dwSourceType: "Bugsnag", + requiredTables: ["errors"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "honeybadger", + label: "Honeybadger", + description: ERROR, + dwSourceType: "Honeybadger", + requiredTables: ["faults"], + recordKind: "issue", + setup: "dynamic", + }, + { + product: "raygun", + label: "Raygun", + description: ERROR, + dwSourceType: "Raygun", + requiredTables: ["error_groups"], + recordKind: "issue", + setup: "dynamic", + }, + // Support / helpdesk + { + product: "zendesk", + label: "Zendesk", + description: TICKET, + dwSourceType: "Zendesk", + requiredTables: ["tickets"], + recordKind: "ticket", + setup: "zendesk", + }, + { + product: "freshdesk", + label: "Freshdesk", + description: TICKET, + dwSourceType: "Freshdesk", + requiredTables: ["tickets"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "freshservice", + label: "Freshservice", + description: TICKET, + dwSourceType: "Freshservice", + requiredTables: ["tickets"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "front", + label: "Front", + description: CONVERSATION, + dwSourceType: "Front", + requiredTables: ["conversations"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "gorgias", + label: "Gorgias", + description: TICKET, + dwSourceType: "Gorgias", + requiredTables: ["tickets"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "kustomer", + label: "Kustomer", + description: CONVERSATION, + dwSourceType: "Kustomer", + requiredTables: ["conversations"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "dixa", + label: "Dixa", + description: CONVERSATION, + dwSourceType: "Dixa", + requiredTables: ["conversations"], + recordKind: "ticket", + setup: "dynamic", + }, + { + product: "plain", + label: "Plain", + description: CONVERSATION, + dwSourceType: "Plain", + requiredTables: ["threads"], + recordKind: "ticket", + setup: "dynamic", + }, + // Database / infra performance + { + product: "pganalyze", + label: "pganalyze", + description: + "Postgres performance findings, slow queries, and index recommendations", + dwSourceType: "PgAnalyze", + requiredTables: ["issues", "servers"], + recordKind: "issue", + setup: "pganalyze", + }, + // Security scanners + { + product: "snyk", + label: "Snyk", + description: FINDING, + dwSourceType: "Snyk", + requiredTables: ["issues"], + recordKind: "scanner_finding", + setup: "dynamic", + }, + { + product: "sonarqube", + label: "SonarQube", + description: FINDING, + dwSourceType: "Sonarqube", + requiredTables: ["issues"], + recordKind: "scanner_finding", + setup: "dynamic", + }, + { + product: "semgrep", + label: "Semgrep", + description: FINDING, + dwSourceType: "Semgrep", + requiredTables: ["sast_findings"], + recordKind: "scanner_finding", + setup: "dynamic", + }, + { + product: "rapid7_insightvm", + label: "Rapid7 InsightVM", + description: FINDING, + dwSourceType: "Rapid7Insightvm", + requiredTables: ["vulnerabilities"], + recordKind: "scanner_finding", + setup: "dynamic", + }, + // Product feedback / feature requests + { + product: "featurebase", + label: "Featurebase", + description: FEEDBACK, + dwSourceType: "Featurebase", + requiredTables: ["posts"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "frill", + label: "Frill", + description: FEEDBACK, + dwSourceType: "Frill", + requiredTables: ["ideas"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "aha", + label: "Aha", + description: FEEDBACK, + dwSourceType: "Aha", + requiredTables: ["ideas"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "uservoice", + label: "UserVoice", + description: FEEDBACK, + dwSourceType: "Uservoice", + requiredTables: ["suggestions"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "productboard", + label: "Productboard", + description: FEEDBACK, + dwSourceType: "Productboard", + requiredTables: ["notes"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "canny", + label: "Canny", + description: FEEDBACK, + dwSourceType: "Canny", + requiredTables: ["posts"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "asknicely", + label: "AskNicely", + description: FEEDBACK, + dwSourceType: "Asknicely", + requiredTables: ["responses"], + recordKind: "feedback", + setup: "dynamic", + }, + { + product: "retently", + label: "Retently", + description: FEEDBACK, + dwSourceType: "Retently", + requiredTables: ["feedback"], + recordKind: "feedback", + setup: "dynamic", + }, + // Reviews + { + product: "appfigures", + label: "Appfigures", + description: REVIEW, + dwSourceType: "Appfigures", + requiredTables: ["reviews"], + recordKind: "review", + setup: "dynamic", + }, + { + product: "appfollow", + label: "AppFollow", + description: REVIEW, + dwSourceType: "Appfollow", + requiredTables: ["reviews"], + recordKind: "review", + setup: "dynamic", + }, + { + product: "judgeme_reviews", + label: "Judge.me", + description: REVIEW, + dwSourceType: "JudgeMeReviews", + requiredTables: ["reviews"], + recordKind: "review", + setup: "dynamic", + }, +]; + +/** Issue-like records mutate (status/votes change), so their table needs full-refresh sync. */ +export function sourceNeedsFullRefresh(recordKind: SignalRecordKind): boolean { + return recordKind !== "ticket"; +} + +export const EXTERNAL_INBOX_SOURCE_BY_PRODUCT: Partial< + Record +> = Object.fromEntries(EXTERNAL_INBOX_SOURCES.map((s) => [s.product, s])); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2158b3eae3..dc65d7610d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -145,7 +145,18 @@ export { parseImageDataUrl, } from "./image"; export { buildDiscussReportPrompt } from "./inbox-prompts"; -export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types"; +export type { + AvailableSuggestedReviewer, + ExternalInboxSource, + SignalRecordKind, + SourceProduct, + ToggleableSourceProduct, +} from "./inbox-types"; +export { + EXTERNAL_INBOX_SOURCE_BY_PRODUCT, + EXTERNAL_INBOX_SOURCES, + sourceNeedsFullRefresh, +} from "./inbox-types"; export { EXTERNAL_LINKS } from "./links"; export type { CloudMcpServerImport, diff --git a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 91f5ff5fae..a9eb7a88b9 100644 --- a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -1,4 +1,8 @@ import { Button } from "@posthog/quill"; +import { + EXTERNAL_INBOX_SOURCE_BY_PRODUCT, + type ToggleableSourceProduct, +} from "@posthog/shared"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker"; @@ -15,21 +19,11 @@ import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; import { useCallback, useEffect, useState } from "react"; -type DataSourceType = "github" | "linear" | "jira" | "zendesk" | "pganalyze"; - -const REQUIRED_SCHEMAS: Record = { - github: ["issues"], - linear: ["issues"], - jira: ["issues"], - zendesk: ["tickets"], - pganalyze: ["issues", "servers"], -}; - /** PostHog DWH: full table replication (non-incremental); API enum value `full_refresh`. */ const FULL_TABLE_REPLICATION = "full_refresh" as const; -function schemasPayload(source: DataSourceType) { - return REQUIRED_SCHEMAS[source].map((name) => ({ +function schemasPayload(tables: string[]) { + return tables.map((name) => ({ name, should_sync: true, sync_type: FULL_TABLE_REPLICATION, @@ -37,43 +31,42 @@ function schemasPayload(source: DataSourceType) { } interface DataSourceSetupProps { - source: DataSourceType; + source: ToggleableSourceProduct; onComplete: () => void; onCancel: () => void; } +/** + * Renders the connect flow for a warehouse inbox source. Credential-based sources + * (`setup: "dynamic"`) render the generic `DynamicSourceSetup` form driven by the source's + * connect-form schema served by PostHog Cloud — no per-source form code. The three legacy + * special cases (GitHub repo picker, Zendesk, pganalyze) keep their bespoke forms. + */ export function DataSourceSetup({ source, onComplete, onCancel, }: DataSourceSetupProps) { - switch (source) { + const config = EXTERNAL_INBOX_SOURCE_BY_PRODUCT[source]; + if (!config) return null; + + switch (config.setup) { case "github": return ; - case "linear": - return ( - - ); - case "jira": + case "zendesk": + return ; + case "pganalyze": + return ; + default: return ( ); - case "zendesk": - return ; - case "pganalyze": - return ; } } @@ -144,7 +137,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { selection: "oauth", github_integration_id: selectedIntegrationId, }, - schemas: schemasPayload("github"), + schemas: schemasPayload(["issues"]), }, }); toast.success("GitHub data source created"); @@ -301,7 +294,7 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { subdomain: subdomain.trim(), api_key: apiKey.trim(), email_address: email.trim(), - schemas: schemasPayload("zendesk"), + schemas: schemasPayload(["tickets"]), }, }); toast.success("Zendesk data source created"); @@ -384,7 +377,7 @@ function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { payload: { api_key: apiKey.trim(), organization_slug: organizationSlug.trim(), - schemas: schemasPayload("pganalyze"), + schemas: schemasPayload(["issues", "servers"]), }, }); toast.success("pganalyze data source created"); diff --git a/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx index ab5854df6b..2ed3e65672 100644 --- a/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx +++ b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx @@ -4,29 +4,21 @@ import { BugIcon, ChatsIcon, CircleNotchIcon, - GithubLogoIcon, - KanbanIcon, - TicketIcon, + PlugIcon, VideoIcon, } from "@phosphor-icons/react"; import type { SignalSourceConfig } from "@posthog/api-client/posthog-client"; import { Button } from "@posthog/quill"; -import { JiraIcon } from "@posthog/ui/features/inbox/components/utils/JiraIcon"; -import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; +import { + EXTERNAL_INBOX_SOURCES, + type ToggleableSourceProduct, +} from "@posthog/shared"; +import { getSourceProductMeta } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Box, Flex, Spinner, Switch, Text, Tooltip } from "@radix-ui/themes"; import { memo, useCallback } from "react"; -export interface SignalSourceValues { - session_replay: boolean; - error_tracking: boolean; - github: boolean; - linear: boolean; - jira: boolean; - zendesk: boolean; - conversations: boolean; - pganalyze: boolean; -} +export type SignalSourceValues = Record; interface SignalSourceToggleCardProps { icon: React.ReactNode; @@ -163,6 +155,60 @@ const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ ); }); +interface SourceState { + requiresSetup: boolean; + loading: boolean; + syncStatus?: SignalSourceConfig["status"]; +} + +/** + * A single warehouse-source card. Its own component so the toggle/setup callbacks can be + * memoized per product without breaking the rules of hooks (the grid renders one per source + * from EXTERNAL_INBOX_SOURCES). + */ +const ExternalSourceCard = memo(function ExternalSourceCard({ + product, + label, + description, + checked, + state, + disabled, + onToggle, + onSetup, +}: { + product: ToggleableSourceProduct; + label: string; + description: string; + checked: boolean; + state?: SourceState; + disabled?: boolean; + onToggle: (source: ToggleableSourceProduct, enabled: boolean) => void; + onSetup?: (source: ToggleableSourceProduct) => void; +}) { + const handleToggle = useCallback( + (value: boolean) => onToggle(product, value), + [onToggle, product], + ); + const handleSetup = useCallback(() => onSetup?.(product), [onSetup, product]); + const meta = getSourceProductMeta(product); + const Icon = meta?.Icon ?? PlugIcon; + + return ( + } + label={label} + description={description} + checked={checked} + onCheckedChange={handleToggle} + disabled={disabled} + requiresSetup={state?.requiresSetup} + onSetup={handleSetup} + loading={state?.loading} + syncStatus={state?.syncStatus} + /> + ); +}); + interface EvaluationsSectionProps { evaluationsUrl: string; } @@ -252,19 +298,10 @@ function SourceRunningIndicator({ interface SignalSourceTogglesProps { value: SignalSourceValues; - onToggle: (source: keyof SignalSourceValues, enabled: boolean) => void; + onToggle: (source: ToggleableSourceProduct, enabled: boolean) => void; disabled?: boolean; - sourceStates?: Partial< - Record< - keyof SignalSourceValues, - { - requiresSetup: boolean; - loading: boolean; - syncStatus?: SignalSourceConfig["status"]; - } - > - >; - onSetup?: (source: keyof SignalSourceValues) => void; + sourceStates?: Partial>; + onSetup?: (source: ToggleableSourceProduct) => void; evaluationsUrl?: string; } @@ -284,35 +321,10 @@ export function SignalSourceToggles({ (checked: boolean) => onToggle("error_tracking", checked), [onToggle], ); - const toggleGithub = useCallback( - (checked: boolean) => onToggle("github", checked), - [onToggle], - ); - const toggleLinear = useCallback( - (checked: boolean) => onToggle("linear", checked), - [onToggle], - ); - const toggleJira = useCallback( - (checked: boolean) => onToggle("jira", checked), - [onToggle], - ); - const toggleZendesk = useCallback( - (checked: boolean) => onToggle("zendesk", checked), - [onToggle], - ); const toggleConversations = useCallback( (checked: boolean) => onToggle("conversations", checked), [onToggle], ); - const togglePgAnalyze = useCallback( - (checked: boolean) => onToggle("pganalyze", checked), - [onToggle], - ); - const setupGithub = useCallback(() => onSetup?.("github"), [onSetup]); - const setupLinear = useCallback(() => onSetup?.("linear"), [onSetup]); - const setupJira = useCallback(() => onSetup?.("jira"), [onSetup]); - const setupZendesk = useCallback(() => onSetup?.("zendesk"), [onSetup]); - const setupPgAnalyze = useCallback(() => onSetup?.("pganalyze"), [onSetup]); return ( @@ -368,72 +380,28 @@ export function SignalSourceToggles({ - {/* External connections */} + {/* External connections — data-driven from the shared source registry */} External connections - } - label="GitHub Issues" - description="Monitor new issues and updates" - checked={value.github} - onCheckedChange={toggleGithub} - disabled={disabled} - requiresSetup={sourceStates?.github?.requiresSetup} - onSetup={setupGithub} - loading={sourceStates?.github?.loading} - syncStatus={sourceStates?.github?.syncStatus} - /> - } - label="Linear" - description="Monitor new issues and updates" - checked={value.linear} - onCheckedChange={toggleLinear} - disabled={disabled} - requiresSetup={sourceStates?.linear?.requiresSetup} - onSetup={setupLinear} - loading={sourceStates?.linear?.loading} - syncStatus={sourceStates?.linear?.syncStatus} - /> - } - label="Jira" - description="Monitor new issues and updates" - checked={value.jira} - onCheckedChange={toggleJira} - disabled={disabled} - requiresSetup={sourceStates?.jira?.requiresSetup} - onSetup={setupJira} - loading={sourceStates?.jira?.loading} - syncStatus={sourceStates?.jira?.syncStatus} - /> - } - label="Zendesk" - description="Monitor incoming support tickets" - checked={value.zendesk} - onCheckedChange={toggleZendesk} - disabled={disabled} - requiresSetup={sourceStates?.zendesk?.requiresSetup} - onSetup={setupZendesk} - loading={sourceStates?.zendesk?.loading} - syncStatus={sourceStates?.zendesk?.syncStatus} - /> - } - label="pganalyze" - description="Postgres performance findings, slow queries, and index recommendations" - checked={value.pganalyze} - onCheckedChange={togglePgAnalyze} - disabled={disabled} - requiresSetup={sourceStates?.pganalyze?.requiresSetup} - onSetup={setupPgAnalyze} - loading={sourceStates?.pganalyze?.loading} - syncStatus={sourceStates?.pganalyze?.syncStatus} - /> + {EXTERNAL_INBOX_SOURCES.map((source) => { + const product = source.product as ToggleableSourceProduct; + return ( + + ); + })} diff --git a/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx b/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx index eb0d625e18..f918cdab4c 100644 --- a/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx +++ b/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx @@ -2,10 +2,16 @@ import type { IconProps } from "@phosphor-icons/react"; import { BrainIcon, BugIcon, + ChatsIcon, CompassIcon, + GitBranchIcon, GithubLogoIcon, KanbanIcon, LifebuoyIcon, + LightbulbIcon, + MegaphoneIcon, + ShieldIcon, + StarIcon, TicketIcon, VideoIcon, } from "@phosphor-icons/react"; @@ -101,4 +107,67 @@ export const SOURCE_PRODUCT_META: Partial< color: "var(--iris-9)", label: "Scout", }, + // Warehouse-backed inbox sources + freshdesk: { + Icon: LifebuoyIcon, + color: "var(--green-9)", + label: "Freshdesk", + }, + freshservice: { + Icon: LifebuoyIcon, + color: "var(--teal-9)", + label: "Freshservice", + }, + front: { Icon: ChatsIcon, color: "var(--blue-9)", label: "Front" }, + gorgias: { Icon: LifebuoyIcon, color: "var(--purple-9)", label: "Gorgias" }, + kustomer: { Icon: ChatsIcon, color: "var(--indigo-9)", label: "Kustomer" }, + dixa: { Icon: ChatsIcon, color: "var(--cyan-9)", label: "Dixa" }, + plain: { Icon: ChatsIcon, color: "var(--gray-11)", label: "Plain" }, + gitlab: { Icon: GitBranchIcon, color: "var(--orange-9)", label: "GitLab" }, + gitea: { Icon: GitBranchIcon, color: "var(--green-9)", label: "Gitea" }, + shortcut: { Icon: KanbanIcon, color: "var(--purple-9)", label: "Shortcut" }, + sentry: { Icon: BugIcon, color: "var(--violet-9)", label: "Sentry" }, + rollbar: { Icon: BugIcon, color: "var(--crimson-9)", label: "Rollbar" }, + bugsnag: { Icon: BugIcon, color: "var(--pink-9)", label: "Bugsnag" }, + honeybadger: { + Icon: BugIcon, + color: "var(--amber-9)", + label: "Honeybadger", + }, + raygun: { Icon: BugIcon, color: "var(--red-9)", label: "Raygun" }, + snyk: { Icon: ShieldIcon, color: "var(--purple-9)", label: "Snyk" }, + sonarqube: { Icon: ShieldIcon, color: "var(--blue-9)", label: "SonarQube" }, + semgrep: { Icon: ShieldIcon, color: "var(--green-9)", label: "Semgrep" }, + rapid7_insightvm: { + Icon: ShieldIcon, + color: "var(--orange-9)", + label: "Rapid7 InsightVM", + }, + featurebase: { + Icon: LightbulbIcon, + color: "var(--blue-9)", + label: "Featurebase", + }, + frill: { Icon: LightbulbIcon, color: "var(--cyan-9)", label: "Frill" }, + aha: { Icon: LightbulbIcon, color: "var(--red-9)", label: "Aha" }, + uservoice: { + Icon: MegaphoneIcon, + color: "var(--orange-9)", + label: "UserVoice", + }, + productboard: { + Icon: LightbulbIcon, + color: "var(--indigo-9)", + label: "Productboard", + }, + canny: { Icon: MegaphoneIcon, color: "var(--blue-9)", label: "Canny" }, + asknicely: { Icon: StarIcon, color: "var(--green-9)", label: "AskNicely" }, + retently: { Icon: StarIcon, color: "var(--teal-9)", label: "Retently" }, + appfigures: { Icon: StarIcon, color: "var(--blue-9)", label: "Appfigures" }, + appfollow: { Icon: StarIcon, color: "var(--green-9)", label: "AppFollow" }, + judgeme_reviews: { + Icon: StarIcon, + color: "var(--amber-9)", + label: "Judge.me", + }, }; diff --git a/packages/ui/src/features/inbox/filterOptions.tsx b/packages/ui/src/features/inbox/filterOptions.tsx index 588a178f15..8d5e768729 100644 --- a/packages/ui/src/features/inbox/filterOptions.tsx +++ b/packages/ui/src/features/inbox/filterOptions.tsx @@ -4,22 +4,20 @@ import { CalendarPlus, Clock, CompassIcon, - GithubLogoIcon, - KanbanIcon, LifebuoyIcon, ListNumbers, - TicketIcon, + PlugIcon, TrendUp, VideoIcon, } from "@phosphor-icons/react"; +import { EXTERNAL_INBOX_SOURCES } from "@posthog/shared"; import type { AvailableSuggestedReviewer, SignalReportOrderingField, SignalReportPriority, SourceProduct, } from "@posthog/shared/types"; -import { JiraIcon } from "@posthog/ui/features/inbox/components/utils/JiraIcon"; -import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; +import { getSourceProductMeta } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; import type { ReactNode } from "react"; export type InboxSortField = Extract< @@ -92,17 +90,22 @@ export const INBOX_SOURCE_OPTIONS: { label: "AI observability", icon: , }, - { value: "github", label: "GitHub", icon: }, - { value: "linear", label: "Linear", icon: }, - { value: "jira", label: "Jira", icon: }, - { value: "zendesk", label: "Zendesk", icon: }, { value: "conversations", label: "Conversations", icon: , }, - { value: "pganalyze", label: "pganalyze", icon: }, { value: "signals_scout", label: "Scouts", icon: }, + // Warehouse-backed sources, derived from the shared registry. + ...EXTERNAL_INBOX_SOURCES.map((source) => { + const meta = getSourceProductMeta(source.product); + const Icon = meta?.Icon ?? PlugIcon; + return { + value: source.product, + label: meta?.label ?? source.label, + icon: , + }; + }), ]; export function inboxSortOptionKey( diff --git a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts index faf0e646b3..ef23cac047 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts @@ -1,5 +1,10 @@ import type { SignalSourceConfig } from "@posthog/api-client/posthog-client"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + EXTERNAL_INBOX_SOURCES, + type SignalRecordKind, + sourceNeedsFullRefresh, +} from "@posthog/shared"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import type { SignalSourceValues } from "@posthog/ui/features/inbox/components/SignalSourceToggles"; @@ -10,26 +15,17 @@ import { track } from "@posthog/ui/shell/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; -type SourceProduct = SignalSourceConfig["source_product"]; type SourceType = SignalSourceConfig["source_type"]; -type SetupSourceProduct = - | "github" - | "linear" - | "jira" - | "zendesk" - | "pganalyze"; - -const SOURCE_TYPE_MAP: Record< - Exclude, - SourceType -> = { +type SourceKey = keyof SignalSourceValues; + +// Non-warehouse toggles are hard-wired; every warehouse source is derived from the shared +// EXTERNAL_INBOX_SOURCES registry so adding a source is a one-line change there. +const SOURCE_TYPE_MAP: Partial> = { session_replay: "session_analysis_cluster", - github: "issue", - linear: "issue", - jira: "issue", - zendesk: "ticket", conversations: "ticket", - pganalyze: "issue", + ...Object.fromEntries( + EXTERNAL_INBOX_SOURCES.map((s) => [s.product, s.recordKind]), + ), }; const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ @@ -38,58 +34,46 @@ const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ "issue_spiking", ]; -const SOURCE_LABELS: Record = { +const SOURCE_LABELS: Partial> = { session_replay: "Session replay", error_tracking: "Error tracking", - github: "GitHub Issues", - linear: "Linear Issues", - jira: "Jira Issues", - zendesk: "Zendesk Tickets", conversations: "PostHog Support", - pganalyze: "pganalyze", + ...Object.fromEntries( + EXTERNAL_INBOX_SOURCES.map((s) => [s.product, s.label]), + ), }; const DATA_WAREHOUSE_SOURCES: Record< string, - { dwSourceType: string; requiredTable: string } -> = { - github: { dwSourceType: "Github", requiredTable: "issues" }, - linear: { dwSourceType: "Linear", requiredTable: "issues" }, - jira: { dwSourceType: "Jira", requiredTable: "issues" }, - zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, - pganalyze: { dwSourceType: "PgAnalyze", requiredTable: "issues" }, -}; + { dwSourceType: string; requiredTable: string; recordKind: SourceType } +> = Object.fromEntries( + EXTERNAL_INBOX_SOURCES.map((s) => [ + s.product, + { + dwSourceType: s.dwSourceType, + requiredTable: s.requiredTables[0], + recordKind: s.recordKind, + }, + ]), +); -const ALL_SOURCE_PRODUCTS: (keyof SignalSourceValues)[] = [ +const ALL_SOURCE_PRODUCTS: SourceKey[] = [ "session_replay", "error_tracking", - "github", - "linear", - "jira", - "zendesk", "conversations", - "pganalyze", + ...EXTERNAL_INBOX_SOURCES.map((s) => s.product as SourceKey), ]; -function isSetupSourceProduct( - product: keyof SignalSourceValues, -): product is SetupSourceProduct { +function isSetupSourceProduct(product: SourceKey): boolean { return product in DATA_WAREHOUSE_SOURCES; } function computeValues( configs: SignalSourceConfig[] | undefined, ): SignalSourceValues { - const result: SignalSourceValues = { - session_replay: false, - error_tracking: false, - github: false, - linear: false, - jira: false, - zendesk: false, - conversations: false, - pganalyze: false, - }; + const result = Object.fromEntries( + ALL_SOURCE_PRODUCTS.map((p) => [p, false]), + ) as SignalSourceValues; if (!configs?.length) return result; for (const product of ALL_SOURCE_PRODUCTS) { if (product === "error_tracking") { @@ -134,9 +118,7 @@ export function useSignalSourceToggles() { >({}); const pendingRef = useRef(new Set()); - const [setupSource, setSetupSource] = useState( - null, - ); + const [setupSource, setSetupSource] = useState(null); const [loadingSources, setLoadingSources] = useState< Partial> >({}); @@ -212,11 +194,10 @@ export function useSignalSourceToggles() { ); if (!requiredSchema) return; - const issuesFullReplication = - (product === "github" || product === "linear" || product === "jira") && - dwConfig.requiredTable === "issues"; - - if (issuesFullReplication) { + // Issue-like records (issues, findings, feedback, reviews) get edited/closed after + // creation, so incremental append would miss updates — force a full refresh. Tickets + // are treated as append-only (matches the original Zendesk behavior). + if (sourceNeedsFullRefresh(dwConfig.recordKind as SignalRecordKind)) { const syncType = requiredSchema.sync_type; const needsUpdate = !requiredSchema.should_sync || syncType !== "full_refresh"; @@ -311,20 +292,15 @@ export function useSignalSourceToggles() { } } else { const existing = configs?.find((c) => c.source_product === product); + const sourceType = SOURCE_TYPE_MAP[product]; if (existing) { await client.updateSignalSourceConfig(projectId, existing.id, { enabled, }); - } else if (enabled) { + } else if (enabled && sourceType) { await client.createSignalSourceConfig(projectId, { source_product: product, - source_type: - SOURCE_TYPE_MAP[ - product as Exclude< - SourceProduct, - "error_tracking" | "llm_analytics" | "signals_scout" - > - ], + source_type: sourceType, enabled: true, }); } @@ -370,14 +346,15 @@ export function useSignalSourceToggles() { const existing = configs?.find( (c) => c.source_product === completedSource, ); + const sourceType = SOURCE_TYPE_MAP[completedSource]; try { - if (!existing) { + if (!existing && sourceType) { await client.createSignalSourceConfig(projectId, { source_product: completedSource, - source_type: SOURCE_TYPE_MAP[completedSource], + source_type: sourceType, enabled: true, }); - } else if (!existing.enabled) { + } else if (existing && !existing.enabled) { await client.updateSignalSourceConfig(projectId, existing.id, { enabled: true, });