spine-framework 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/.framework/cli/commands/create-app.ts +1 -1
  2. package/.framework/cli/commands/dev.ts +215 -33
  3. package/.framework/cli/index.ts +37 -75
  4. package/.framework/functions/_shared/app-manifest.ts +8 -15
  5. package/.framework/functions/_shared/db.ts +11 -15
  6. package/.framework/functions/_shared/index.ts +7 -16
  7. package/.framework/functions/_shared/principal.ts +4 -62
  8. package/.framework/functions/_shared/resolve-ids.ts +6 -6
  9. package/.framework/functions/_shared/webhook-registry.ts +43 -1
  10. package/.framework/functions/admin-funnel.ts +104 -0
  11. package/.framework/functions/admin-install-funnel.ts +225 -0
  12. package/.framework/functions/admin-signals.ts +241 -0
  13. package/.framework/functions/integration-routes.ts +3 -7
  14. package/.framework/migrations/001_schema.sql +406 -0
  15. package/.framework/migrations/002_rls_and_functions.sql +710 -0
  16. package/.framework/migrations/003_system_data.sql +520 -0
  17. package/.framework/migrations/004_enhancements.sql +43 -0
  18. package/.framework/public/_redirects +3 -0
  19. package/.framework/public/spine-logo.jpg +0 -0
  20. package/.framework/public/spine-logo.svg +3 -0
  21. package/{scripts → .framework/scripts}/assemble-frontend.sh +8 -5
  22. package/{scripts → .framework/scripts}/assemble-functions.sh +4 -47
  23. package/{scripts → .framework/scripts}/assemble.sh +2 -8
  24. package/{scripts → .framework/scripts}/boundary-check.sh +5 -9
  25. package/{scripts → .framework/scripts}/check-core-integrity.sh +0 -0
  26. package/.framework/seeds/link_types/account_cli_instances.json +9 -0
  27. package/.framework/seeds/types/funnel_signal_use.json +98 -0
  28. package/.framework/seeds/types/spine_instance.json +109 -0
  29. package/.framework/src/App.tsx +11 -21
  30. package/.framework/src/components/CustomAppLoader.tsx +2 -19
  31. package/.framework/src/components/ui/accordion.tsx +1 -1
  32. package/.framework/src/components/ui/alert-dialog.tsx +1 -1
  33. package/.framework/src/components/ui/aspect-ratio.tsx +1 -1
  34. package/.framework/src/components/ui/avatar.tsx +1 -1
  35. package/.framework/src/components/ui/badge.tsx +2 -2
  36. package/.framework/src/components/ui/breadcrumb.tsx +2 -2
  37. package/.framework/src/components/ui/button-group.tsx +2 -2
  38. package/.framework/src/components/ui/button.tsx +2 -2
  39. package/.framework/src/components/ui/chart.tsx +6 -1
  40. package/.framework/src/components/ui/checkbox.tsx +1 -1
  41. package/.framework/src/components/ui/collapsible.tsx +1 -1
  42. package/.framework/src/components/ui/context-menu.tsx +1 -1
  43. package/.framework/src/components/ui/dialog.tsx +1 -1
  44. package/.framework/src/components/ui/direction.tsx +17 -1
  45. package/.framework/src/components/ui/dropdown-menu.tsx +1 -1
  46. package/.framework/src/components/ui/hover-card.tsx +1 -1
  47. package/.framework/src/components/ui/item.tsx +2 -2
  48. package/.framework/src/components/ui/label.tsx +1 -1
  49. package/.framework/src/components/ui/menubar.tsx +1 -1
  50. package/.framework/src/components/ui/navigation-menu.tsx +1 -1
  51. package/.framework/src/components/ui/popover.tsx +1 -1
  52. package/.framework/src/components/ui/progress.tsx +1 -1
  53. package/.framework/src/components/ui/radio-group.tsx +1 -1
  54. package/.framework/src/components/ui/scroll-area.tsx +1 -1
  55. package/.framework/src/components/ui/select.tsx +1 -1
  56. package/.framework/src/components/ui/separator.tsx +1 -1
  57. package/.framework/src/components/ui/sheet.tsx +1 -1
  58. package/.framework/src/components/ui/sidebar.tsx +6 -6
  59. package/.framework/src/components/ui/slider.tsx +1 -1
  60. package/.framework/src/components/ui/switch.tsx +1 -1
  61. package/.framework/src/components/ui/tabs.tsx +1 -1
  62. package/.framework/src/components/ui/toggle-group.tsx +1 -1
  63. package/.framework/src/components/ui/toggle.tsx +1 -1
  64. package/.framework/src/components/ui/tooltip.tsx +1 -1
  65. package/.framework/src/pages/auth/LoginPage.tsx +10 -9
  66. package/LICENSE.md +63 -93
  67. package/README.md +254 -129
  68. package/custom/.devin/skills/install-app/SKILL.md +39 -0
  69. package/custom/.devin/workflows/app-install.yaml +83 -0
  70. package/custom/README.md +79 -0
  71. package/docs/APPS.md +232 -0
  72. package/docs/ARCHITECTURE.md +246 -0
  73. package/docs/INSTALL.md +155 -0
  74. package/docs/README.md +170 -0
  75. package/docs/design-schema-spec.md +1837 -0
  76. package/docs/dev-guides/01-create-an-app.md +183 -0
  77. package/docs/dev-guides/02-create-a-function.md +178 -0
  78. package/docs/dev-guides/03-create-a-component.md +178 -0
  79. package/docs/dev-guides/04-webhook-handlers.md +129 -0
  80. package/docs/dev-guides/05-testing.md +168 -0
  81. package/docs/dev-guides/README.md +40 -0
  82. package/docs/npm-packages-plan.md +288 -0
  83. package/docs/spine-core--api-reference.md +822 -0
  84. package/package.json +78 -150
  85. package/.framework/cli/bin.cjs +0 -14
  86. package/.framework/cli/commands/claim.ts +0 -430
  87. package/.framework/cli/commands/init.ts +0 -219
  88. package/.framework/cli/commands/install-app.ts +0 -685
  89. package/.framework/cli/commands/migrate.ts +0 -178
  90. package/.framework/cli/commands/publish.ts +0 -191
  91. package/.framework/cli/commands/status.ts +0 -268
  92. package/.framework/cli/commands/uninstall-app.ts +0 -166
  93. package/.framework/cli/commands/update-app.ts +0 -397
  94. package/.framework/cli/welcome.cjs +0 -45
  95. package/.framework/config/signal-client.ts +0 -147
  96. package/.framework/config/signals.ts +0 -70
  97. package/.framework/docs/STABILITY.md +0 -52
  98. package/.framework/docs/examples/custom-app/README.md +0 -77
  99. package/.framework/docs/examples/custom-function/README.md +0 -27
  100. package/.framework/docs/examples/custom-function/handler.ts +0 -48
  101. package/.framework/docs/examples/custom-webhook/README.md +0 -68
  102. package/.framework/functions/handler-registry.ts +0 -22
  103. package/.framework/functions/signal.ts +0 -168
  104. package/.framework/migrations/000_foundation.sql +0 -1302
  105. package/.framework/migrations/001_seed.sql +0 -163
  106. package/.framework/migrations/002_seed_constraints.sql +0 -19
  107. package/.framework/migrations/003_auth_user_trigger.sql +0 -67
  108. package/.framework/migrations/004_app_version_tracking.sql +0 -8
  109. package/.framework/migrations/005_pipelines_slug_stages.sql +0 -21
  110. package/.framework/src/pages/auth/RegisterPage.tsx +0 -163
  111. package/CHANGELOG.md +0 -68
  112. package/STRUCTURE.md +0 -150
  113. package/bin/spine-framework.cjs +0 -62
  114. package/bin/welcome.cjs +0 -58
  115. package/bin/ws-shim.ts +0 -10
  116. package/config/components.json +0 -25
  117. package/config/deno.lock +0 -108
  118. package/config/package-lock.json +0 -17183
  119. package/config/postcss.config.cjs +0 -10
  120. package/config/tailwind.config.cjs +0 -78
  121. package/config/tsconfig.build.json +0 -32
  122. package/config/tsconfig.cli.json +0 -18
  123. package/config/tsconfig.json +0 -41
  124. package/config/tsconfig.node.json +0 -17
  125. package/config/tsconfig.node.tsbuildinfo +0 -1
  126. package/config/tsconfig.tsbuildinfo +0 -1
  127. package/config/typedoc.json +0 -16
  128. package/config/vite.config.d.ts +0 -2
  129. package/config/vite.config.ts +0 -71
  130. package/dist/cli/commands/agents.d.ts +0 -39
  131. package/dist/cli/commands/agents.d.ts.map +0 -1
  132. package/dist/cli/commands/auth.d.ts +0 -36
  133. package/dist/cli/commands/auth.d.ts.map +0 -1
  134. package/dist/cli/commands/claim.d.ts +0 -38
  135. package/dist/cli/commands/claim.d.ts.map +0 -1
  136. package/dist/cli/commands/create-app.d.ts +0 -23
  137. package/dist/cli/commands/create-app.d.ts.map +0 -1
  138. package/dist/cli/commands/dev.d.ts +0 -24
  139. package/dist/cli/commands/dev.d.ts.map +0 -1
  140. package/dist/cli/commands/doctor.d.ts +0 -42
  141. package/dist/cli/commands/doctor.d.ts.map +0 -1
  142. package/dist/cli/commands/generate.d.ts +0 -36
  143. package/dist/cli/commands/generate.d.ts.map +0 -1
  144. package/dist/cli/commands/init.d.ts +0 -20
  145. package/dist/cli/commands/init.d.ts.map +0 -1
  146. package/dist/cli/commands/install-app.d.ts +0 -30
  147. package/dist/cli/commands/install-app.d.ts.map +0 -1
  148. package/dist/cli/commands/items.d.ts +0 -45
  149. package/dist/cli/commands/items.d.ts.map +0 -1
  150. package/dist/cli/commands/migrate.d.ts +0 -21
  151. package/dist/cli/commands/migrate.d.ts.map +0 -1
  152. package/dist/cli/commands/migrations.d.ts +0 -41
  153. package/dist/cli/commands/migrations.d.ts.map +0 -1
  154. package/dist/cli/commands/pipelines.d.ts +0 -40
  155. package/dist/cli/commands/pipelines.d.ts.map +0 -1
  156. package/dist/cli/commands/publish.d.ts +0 -23
  157. package/dist/cli/commands/publish.d.ts.map +0 -1
  158. package/dist/cli/commands/status.d.ts +0 -23
  159. package/dist/cli/commands/status.d.ts.map +0 -1
  160. package/dist/cli/commands/system.d.ts +0 -29
  161. package/dist/cli/commands/system.d.ts.map +0 -1
  162. package/dist/cli/commands/test.d.ts +0 -46
  163. package/dist/cli/commands/test.d.ts.map +0 -1
  164. package/dist/cli/commands/uninstall-app.d.ts +0 -23
  165. package/dist/cli/commands/uninstall-app.d.ts.map +0 -1
  166. package/dist/cli/commands/update-app.d.ts +0 -24
  167. package/dist/cli/commands/update-app.d.ts.map +0 -1
  168. package/dist/cli/context.d.ts +0 -88
  169. package/dist/cli/context.d.ts.map +0 -1
  170. package/dist/cli/env-loader.d.ts +0 -14
  171. package/dist/cli/env-loader.d.ts.map +0 -1
  172. package/dist/cli/index.d.ts +0 -44
  173. package/dist/cli/index.d.ts.map +0 -1
  174. package/dist/config/signals.d.ts +0 -82
  175. package/dist/config/signals.d.ts.map +0 -1
  176. package/dist/functions/_shared/agent-runner.d.ts +0 -156
  177. package/dist/functions/_shared/agent-runner.d.ts.map +0 -1
  178. package/dist/functions/_shared/app-manifest.d.ts +0 -68
  179. package/dist/functions/_shared/app-manifest.d.ts.map +0 -1
  180. package/dist/functions/_shared/audit.d.ts +0 -91
  181. package/dist/functions/_shared/audit.d.ts.map +0 -1
  182. package/dist/functions/_shared/db.d.ts +0 -125
  183. package/dist/functions/_shared/db.d.ts.map +0 -1
  184. package/dist/functions/_shared/index.d.ts +0 -299
  185. package/dist/functions/_shared/index.d.ts.map +0 -1
  186. package/dist/functions/_shared/middleware.d.ts +0 -315
  187. package/dist/functions/_shared/middleware.d.ts.map +0 -1
  188. package/dist/functions/_shared/permissions.d.ts +0 -626
  189. package/dist/functions/_shared/permissions.d.ts.map +0 -1
  190. package/dist/functions/_shared/pipeline-runner.d.ts +0 -124
  191. package/dist/functions/_shared/pipeline-runner.d.ts.map +0 -1
  192. package/dist/functions/_shared/principal.d.ts +0 -284
  193. package/dist/functions/_shared/principal.d.ts.map +0 -1
  194. package/dist/functions/_shared/resolve-ids.d.ts +0 -10
  195. package/dist/functions/_shared/resolve-ids.d.ts.map +0 -1
  196. package/dist/functions/_shared/schema-utils.d.ts +0 -181
  197. package/dist/functions/_shared/schema-utils.d.ts.map +0 -1
  198. package/dist/functions/_shared/testing.d.ts +0 -172
  199. package/dist/functions/_shared/testing.d.ts.map +0 -1
  200. package/dist/functions/_shared/trigger-engine.d.ts +0 -140
  201. package/dist/functions/_shared/trigger-engine.d.ts.map +0 -1
  202. package/dist/functions/_shared/webhook-registration.d.ts +0 -81
  203. package/dist/functions/_shared/webhook-registration.d.ts.map +0 -1
  204. package/dist/functions/_shared/webhook-registry.d.ts +0 -41
  205. package/dist/functions/_shared/webhook-registry.d.ts.map +0 -1
  206. package/dist/functions/account-nodes.d.ts +0 -48
  207. package/dist/functions/account-nodes.d.ts.map +0 -1
  208. package/dist/functions/admin-data.d.ts +0 -178
  209. package/dist/functions/admin-data.d.ts.map +0 -1
  210. package/dist/functions/ai-agents.d.ts +0 -125
  211. package/dist/functions/ai-agents.d.ts.map +0 -1
  212. package/dist/functions/api-keys.d.ts +0 -140
  213. package/dist/functions/api-keys.d.ts.map +0 -1
  214. package/dist/functions/apps.d.ts +0 -163
  215. package/dist/functions/apps.d.ts.map +0 -1
  216. package/dist/functions/auth.d.ts +0 -74
  217. package/dist/functions/auth.d.ts.map +0 -1
  218. package/dist/functions/debug-auth.d.ts +0 -33
  219. package/dist/functions/debug-auth.d.ts.map +0 -1
  220. package/dist/functions/embeddings.d.ts +0 -205
  221. package/dist/functions/embeddings.d.ts.map +0 -1
  222. package/dist/functions/handler-registry.d.ts +0 -22
  223. package/dist/functions/handler-registry.d.ts.map +0 -1
  224. package/dist/functions/integration-routes.d.ts +0 -45
  225. package/dist/functions/integration-routes.d.ts.map +0 -1
  226. package/dist/functions/integrations.d.ts +0 -124
  227. package/dist/functions/integrations.d.ts.map +0 -1
  228. package/dist/functions/item-progress.d.ts +0 -41
  229. package/dist/functions/item-progress.d.ts.map +0 -1
  230. package/dist/functions/logs.d.ts +0 -162
  231. package/dist/functions/logs.d.ts.map +0 -1
  232. package/dist/functions/observability.d.ts +0 -123
  233. package/dist/functions/observability.d.ts.map +0 -1
  234. package/dist/functions/pipeline-executions.d.ts +0 -190
  235. package/dist/functions/pipeline-executions.d.ts.map +0 -1
  236. package/dist/functions/pipelines.d.ts +0 -171
  237. package/dist/functions/pipelines.d.ts.map +0 -1
  238. package/dist/functions/prompt-configs.d.ts +0 -125
  239. package/dist/functions/prompt-configs.d.ts.map +0 -1
  240. package/dist/functions/roles.d.ts +0 -118
  241. package/dist/functions/roles.d.ts.map +0 -1
  242. package/dist/functions/signal.d.ts +0 -73
  243. package/dist/functions/signal.d.ts.map +0 -1
  244. package/dist/functions/system-cron.d.ts +0 -65
  245. package/dist/functions/system-cron.d.ts.map +0 -1
  246. package/dist/functions/system.d.ts +0 -29
  247. package/dist/functions/system.d.ts.map +0 -1
  248. package/dist/functions/tests.d.ts +0 -28
  249. package/dist/functions/tests.d.ts.map +0 -1
  250. package/dist/functions/timers.d.ts +0 -139
  251. package/dist/functions/timers.d.ts.map +0 -1
  252. package/dist/functions/triggers.d.ts +0 -203
  253. package/dist/functions/triggers.d.ts.map +0 -1
  254. package/dist/functions/types.d.ts +0 -151
  255. package/dist/functions/types.d.ts.map +0 -1
  256. package/dist/src/types/types.d.ts +0 -364
  257. package/dist/src/types/types.d.ts.map +0 -1
  258. package/index.html +0 -13
  259. package/netlify.toml +0 -36
  260. package/package-project.json +0 -71
  261. package/scripts/ingest-chunks.cjs +0 -202
  262. package/scripts/kb-chunk-parser.cjs +0 -312
  263. package/scripts/kb-chunk-parser.ts +0 -330
  264. package/scripts/load-test-app-install.ts +0 -484
  265. package/scripts/netlify-dev-wrapper.sh +0 -22
  266. package/vitest.config.ts +0 -45
  267. /package/{scripts → .framework/scripts}/app-install-cli.ts +0 -0
  268. /package/{scripts → .framework/scripts}/build-manifest.sh +0 -0
  269. /package/{scripts → .framework/scripts}/verify-integrity.sh +0 -0
@@ -4,12 +4,6 @@
4
4
 
5
5
  import { adminDb } from './db'
6
6
 
7
- interface ResolvedIds {
8
- types: Record<string, string>
9
- linkTypes: Record<string, string>
10
- accounts: Record<string, string>
11
- }
12
-
13
7
  // Resolve type IDs by (kind, slug) pairs
14
8
  export async function resolveTypeIds(
15
9
  pairs: Array<{ kind: string; slug: string }>
@@ -67,6 +61,12 @@ export async function resolveLinkTypeIds(slugs: string[]): Promise<Record<string
67
61
  return result
68
62
  }
69
63
 
64
+ // Resolve a single link type ID — throws if not found
65
+ export async function resolveLinkTypeId(slug: string): Promise<string> {
66
+ const ids = await resolveLinkTypeIds([slug])
67
+ return ids[slug]
68
+ }
69
+
70
70
  // Resolve account ID by slug
71
71
  export async function resolveAccountId(slug: string): Promise<string> {
72
72
  const { data, error } = await adminDb
@@ -18,7 +18,7 @@
18
18
  * - Multi-tenancy: different tenants can have different handlers
19
19
  *
20
20
  * @seeAlso integration-routes.ts (uses this registry)
21
- * @seeAlso webhook-registration.ts (self-registration helper for custom handlers)
21
+ * @seeAlso custom_webhook-handlers.ts (legacy static registry - being phased out)
22
22
  */
23
23
 
24
24
  import { adminDb } from './db'
@@ -62,6 +62,48 @@ export async function lookupHandler(handlerName: string): Promise<{ functionName
62
62
  return { functionName: data.function_name }
63
63
  }
64
64
 
65
+ /**
66
+ * Loads a webhook handler function dynamically.
67
+ * Uses dynamic import to avoid static dependencies.
68
+ *
69
+ * @param functionName - The Netlify function name (e.g., "custom_cortex-handler")
70
+ * @returns The handler function or null if not found
71
+ */
72
+ export async function loadHandler(functionName: string): Promise<Function | null> {
73
+ try {
74
+ // Dynamic import of the handler function
75
+ // Functions are in the same directory after assembly
76
+ const module = await import(`../${functionName}`)
77
+
78
+ // Handler should be exported as 'handler' or default
79
+ const handler = module.handler || module.default
80
+
81
+ if (typeof handler !== 'function') {
82
+ console.error(`[webhook-registry] Export 'handler' not found in ${functionName}`)
83
+ return null
84
+ }
85
+
86
+ return handler
87
+ } catch (err) {
88
+ console.error(`[webhook-registry] Failed to load ${functionName}:`, err)
89
+ return null
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Resolves a webhook handler by name.
95
+ * Combines lookup + load for convenience.
96
+ *
97
+ * @param handlerName - The handler identifier
98
+ * @returns The handler function or null
99
+ */
100
+ export async function resolveHandler(handlerName: string): Promise<Function | null> {
101
+ const metadata = await lookupHandler(handlerName)
102
+ if (!metadata) return null
103
+
104
+ return loadHandler(metadata.functionName)
105
+ }
106
+
65
107
  /**
66
108
  * Clears the handler cache. Useful for testing or after deployments.
67
109
  */
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module admin-funnel
3
+ * @audience system-admin
4
+ * @layer api-handler
5
+ * @stability stable
6
+ *
7
+ * Admin API for funnel metrics and analytics
8
+ */
9
+
10
+ import { createHandler } from './_shared/middleware'
11
+ import { adminDb } from './_shared/db'
12
+
13
+ // ============================================
14
+ // FUNNEL METRICS
15
+ // ============================================
16
+
17
+ export const metrics = createHandler(async (ctx, body) => {
18
+ // Get type IDs
19
+ const { data: signalType } = await adminDb
20
+ .from('item_type_registry')
21
+ .select('id')
22
+ .eq('slug', 'funnel_signal')
23
+ .single()
24
+
25
+ const { data: sessionType } = await adminDb
26
+ .from('item_type_registry')
27
+ .select('id')
28
+ .eq('slug', 'anonymous_session')
29
+ .single()
30
+
31
+ const { data: instanceType } = await adminDb
32
+ .from('item_type_registry')
33
+ .select('id')
34
+ .eq('slug', 'spine_instance')
35
+ .single()
36
+
37
+ if (!signalType || !sessionType || !instanceType) {
38
+ throw new Error('Required types not found')
39
+ }
40
+
41
+ // Count by stage
42
+ const { data: stageData } = await adminDb
43
+ .from('items')
44
+ .select('data->>stage, account_id')
45
+ .eq('type_id', signalType.id)
46
+ .gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
47
+
48
+ const stageCounts = stageData?.reduce((acc, item) => {
49
+ const stage = item.data?.stage || 'unknown'
50
+ if (item.account_id) {
51
+ acc[stage] = (acc[stage] || 0) + 1
52
+ }
53
+ return acc
54
+ }, {} as Record<string, number>)
55
+
56
+ const totalStageSignals = Object.values(stageCounts).reduce((sum, count) => sum + count, 0)
57
+
58
+ // Anonymous visitors (sessions without account)
59
+ const { count: anonymousSessions } = await adminDb
60
+ .from('items')
61
+ .select('*', { count: 'exact', head: true })
62
+ .eq('type_id', sessionType.id)
63
+ .is('data->lifecycle->>stitched_to_account_id', null)
64
+ .gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
65
+
66
+ // Identified users (unique accounts with signals)
67
+ const { data: accountData } = await adminDb
68
+ .from('items')
69
+ .select('account_id')
70
+ .eq('type_id', signalType.id)
71
+ .gte('created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
72
+ .not('account_id', 'is', null)
73
+
74
+ const uniqueAccounts = new Set(accountData?.map(item => item.account_id)).size
75
+
76
+ // Installed instances
77
+ const { count: installedInstances } = await adminDb
78
+ .from('items')
79
+ .select('*', { count: 'exact', head: true })
80
+ .eq('type_id', instanceType.id)
81
+ .not('data->>claimed_by_person_id', 'is', null)
82
+
83
+ // Stage distribution with percentages
84
+ const stageDistribution = Object.entries(stageCounts).map(([stage, count]) => ({
85
+ stage,
86
+ count,
87
+ percentage: totalStageSignals > 0 ? (count / totalStageSignals) * 100 : 0
88
+ }))
89
+
90
+ // Conversion rates (simplified)
91
+ const anonymousToIdentified = anonymousSessions > 0 ? (uniqueAccounts / anonymousSessions) * 100 : 0
92
+ const identifiedToInstalled = uniqueAccounts > 0 ? (installedInstances / uniqueAccounts) * 100 : 0
93
+
94
+ return {
95
+ anonymous_visitors: anonymousSessions || 0,
96
+ identified_users: uniqueAccounts,
97
+ installed_instances: installedInstances || 0,
98
+ conversion_rates: {
99
+ anonymous_to_identified: anonymousToIdentified,
100
+ identified_to_installed: identifiedToInstalled
101
+ },
102
+ stage_distribution: stageDistribution
103
+ }
104
+ })
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @module admin-install-funnel
3
+ * @audience system-admin
4
+ * @layer api-handler
5
+ * @stability stable
6
+ *
7
+ * Admin API for install funnel metrics and analytics
8
+ */
9
+
10
+ import { createHandler } from './_shared/middleware'
11
+ import { adminDb } from './_shared/db'
12
+
13
+ // ============================================
14
+ // INSTALL FUNNEL METRICS
15
+ // ============================================
16
+
17
+ export const metrics = createHandler(async (ctx, body) => {
18
+ // Get type IDs
19
+ const { data: signalType } = await adminDb
20
+ .from('item_type_registry')
21
+ .select('id')
22
+ .eq('slug', 'funnel_signal')
23
+ .single()
24
+
25
+ if (!signalType) {
26
+ throw new Error('funnel_signal type not found')
27
+ }
28
+
29
+ // Total downloads (signals with source='inst' and action_type='download')
30
+ const { count: totalDownloads } = await adminDb
31
+ .from('items')
32
+ .select('*', { count: 'exact', head: true })
33
+ .eq('type_id', signalType.id)
34
+ .eq('data->>source', 'inst')
35
+ .eq('data->>action_type', 'download')
36
+
37
+ // Unique installers (unique serial numbers from install signals)
38
+ const { data: installerData } = await adminDb
39
+ .from('items')
40
+ .select('data->>serial')
41
+ .eq('type_id', signalType.id)
42
+ .eq('data->>source', 'inst')
43
+ .not('data->>serial', 'is', null)
44
+
45
+ const uniqueInstallers = new Set(installerData?.map(item => item.data?.serial)).size
46
+
47
+ // Active instances (install signals with serial in last 7 days)
48
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
49
+ const { count: activeInstances } = await adminDb
50
+ .from('items')
51
+ .select('*', { count: 'exact', head: true })
52
+ .eq('type_id', signalType.id)
53
+ .eq('data->>source', 'inst')
54
+ .not('data->>serial', 'is', null)
55
+ .gte('created_at', sevenDaysAgo)
56
+
57
+ // Conversion funnel counts
58
+ const { count: installations } = await adminDb
59
+ .from('items')
60
+ .select('*', { count: 'exact', head: true })
61
+ .eq('type_id', signalType.id)
62
+ .eq('data->>source', 'inst')
63
+ .eq('data->>action_type', 'install')
64
+
65
+ const { count: firstUse } = await adminDb
66
+ .from('items')
67
+ .select('*', { count: 'exact', head: true })
68
+ .eq('type_id', signalType.id)
69
+ .eq('data->>source', 'inst')
70
+ .eq('data->>action_type', 'first_command')
71
+
72
+ const { count: claimed } = await adminDb
73
+ .from('items')
74
+ .select('*', { count: 'exact', head: true })
75
+ .eq('type_id', signalType.id)
76
+ .eq('data->>source', 'inst')
77
+ .not('data->>claimed_by_person_id', 'is', null)
78
+
79
+ // Recent installs with account info
80
+ const { data: recentInstalls } = await adminDb
81
+ .from('items')
82
+ .select(`
83
+ id,
84
+ data->>serial,
85
+ data->>version,
86
+ data->>environment,
87
+ data->>first_seen_at,
88
+ data->>last_signal_at,
89
+ data->>claimed_by_person_id,
90
+ data->>claimed_at,
91
+ account_id,
92
+ people(email),
93
+ accounts(name)
94
+ `)
95
+ .eq('type_id', signalType.id)
96
+ .eq('data->>source', 'inst')
97
+ .not('data->>serial', 'is', null)
98
+ .order('created_at', { ascending: false })
99
+ .limit(20)
100
+
101
+ const formattedInstalls = recentInstalls?.map(instance => ({
102
+ id: instance.id,
103
+ serial: instance.data?.serial || 'unknown',
104
+ app_version: instance.data?.version || 'unknown',
105
+ environment: instance.data?.environment || 'unknown',
106
+ first_seen_at: instance.data?.first_seen_at || instance.created_at,
107
+ last_signal_at: instance.data?.last_signal_at || instance.created_at,
108
+ claimed_by: instance.data?.claimed_by_person_id,
109
+ claimed_at: instance.data?.claimed_at,
110
+ account_name: instance.accounts?.name,
111
+ person_email: instance.people?.email
112
+ }))
113
+
114
+ // Version distribution
115
+ const { data: versionData } = await adminDb
116
+ .from('items')
117
+ .select('data->>version')
118
+ .eq('type_id', instanceType.id)
119
+
120
+ const versionCounts = versionData?.reduce((acc, item) => {
121
+ const version = item.data?.version || 'unknown'
122
+ acc[version] = (acc[version] || 0) + 1
123
+ return acc
124
+ }, {} as Record<string, number>)
125
+
126
+ const totalVersions = Object.values(versionCounts).reduce((sum, count) => sum + count, 0)
127
+ const topVersions = Object.entries(versionCounts)
128
+ .sort(([, a], [, b]) => b - a)
129
+ .slice(0, 10)
130
+ .map(([version, count]) => ({
131
+ version,
132
+ count,
133
+ percentage: totalVersions > 0 ? (count / totalVersions) * 100 : 0
134
+ }))
135
+
136
+ // Install trends (last 30 days)
137
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
138
+ const installTrends = []
139
+
140
+ for (let i = 29; i >= 0; i--) {
141
+ const date = new Date(thirtyDaysAgo)
142
+ date.setDate(date.getDate() + i)
143
+ const dateStr = date.toISOString().split('T')[0]
144
+ const nextDateStr = new Date(date.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]
145
+
146
+ const { count: dailyDownloads } = await adminDb
147
+ .from('items')
148
+ .select('*', { count: 'exact', head: true })
149
+ .eq('type_id', signalType.id)
150
+ .eq('data->>source', 'inst')
151
+ .eq('data->>action_type', 'download')
152
+ .gte('created_at', dateStr)
153
+ .lt('created_at', nextDateStr)
154
+
155
+ const { count: dailyInstalls } = await adminDb
156
+ .from('items')
157
+ .select('*', { count: 'exact', head: true })
158
+ .eq('type_id', signalType.id)
159
+ .eq('data->>source', 'inst')
160
+ .eq('data->>action_type', 'install')
161
+ .gte('created_at', dateStr)
162
+ .lt('created_at', nextDateStr)
163
+
164
+ installTrends.push({
165
+ date: dateStr,
166
+ downloads: dailyDownloads || 0,
167
+ installations: dailyInstalls || 0
168
+ })
169
+ }
170
+
171
+ return {
172
+ total_downloads: totalDownloads || 0,
173
+ unique_installers: uniqueInstallers,
174
+ active_instances: activeInstances || 0,
175
+ conversion_funnel: {
176
+ downloads: totalDownloads || 0,
177
+ installations: installations || 0,
178
+ first_use: firstUse || 0,
179
+ claimed: claimed || 0
180
+ },
181
+ install_trends: installTrends,
182
+ recent_installs: formattedInstalls || [],
183
+ top_versions: topVersions
184
+ }
185
+ })
186
+
187
+ // ============================================
188
+ // INSTALL FUNNEL EXPORT
189
+ // ============================================
190
+
191
+ export const exportData = createHandler(async (ctx, body) => {
192
+ // Get type IDs
193
+ const { data: signalType } = await adminDb
194
+ .from('item_type_registry')
195
+ .select('id')
196
+ .eq('slug', 'funnel_signal')
197
+ .single()
198
+
199
+ if (!signalType) {
200
+ throw new Error('funnel_signal type not found')
201
+ }
202
+
203
+ // Export all instances with full details
204
+ const { data: instances } = await adminDb
205
+ .from('items')
206
+ .select(`
207
+ id,
208
+ created_at,
209
+ updated_at,
210
+ data,
211
+ account_id,
212
+ people(email),
213
+ accounts(name)
214
+ `)
215
+ .eq('type_id', signalType.id)
216
+ .eq('data->>source', 'inst')
217
+ .not('data->>serial', 'is', null)
218
+ .order('created_at', { ascending: false })
219
+
220
+ return {
221
+ instances: instances || [],
222
+ exported_at: new Date().toISOString(),
223
+ total_count: instances?.length || 0
224
+ }
225
+ })
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @module admin-signals
3
+ * @audience system-admin
4
+ * @layer api-handler
5
+ * @stability stable
6
+ *
7
+ * Admin API for signal analytics and audit functionality
8
+ */
9
+
10
+ import { createHandler } from './_shared/middleware'
11
+ import { adminDb } from './_shared/db'
12
+
13
+ // ============================================
14
+ // SIGNAL METRICS
15
+ // ============================================
16
+
17
+ export const metrics = createHandler(async (ctx, body) => {
18
+ const now = new Date()
19
+ const today = now.toISOString().split('T')[0]
20
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
21
+
22
+ // Get funnel_signal type ID
23
+ const { data: signalType } = await adminDb
24
+ .from('item_type_registry')
25
+ .select('id')
26
+ .eq('slug', 'funnel_signal')
27
+ .single()
28
+
29
+ if (!signalType) {
30
+ throw new Error('funnel_signal type not found')
31
+ }
32
+
33
+ // Total signals
34
+ const { count: totalSignals } = await adminDb
35
+ .from('items')
36
+ .select('*', { count: 'exact', head: true })
37
+ .eq('type_id', signalType.id)
38
+
39
+ // Signals today
40
+ const { count: signalsToday } = await adminDb
41
+ .from('items')
42
+ .select('*', { count: 'exact', head: true })
43
+ .eq('type_id', signalType.id)
44
+ .gte('created_at', today)
45
+
46
+ // Signals this week
47
+ const { count: signalsThisWeek } = await adminDb
48
+ .from('items')
49
+ .select('*', { count: 'exact', head: true })
50
+ .eq('type_id', signalType.id)
51
+ .gte('created_at', weekAgo)
52
+
53
+ // Top sources
54
+ const { data: sourceData } = await adminDb
55
+ .from('items')
56
+ .select('data->>source')
57
+ .eq('type_id', signalType.id)
58
+ .gte('created_at', weekAgo)
59
+
60
+ const sourceCounts = sourceData?.reduce((acc, item) => {
61
+ const source = item.data?.source || 'unknown'
62
+ acc[source] = (acc[source] || 0) + 1
63
+ return acc
64
+ }, {} as Record<string, number>)
65
+
66
+ const topSources = Object.entries(sourceCounts)
67
+ .sort(([, a], [, b]) => b - a)
68
+ .slice(0, 10)
69
+ .map(([source, count]) => ({ source, count }))
70
+
71
+ // Recent signals with account info
72
+ const { data: recentSignals } = await adminDb
73
+ .from('items')
74
+ .select(`
75
+ id,
76
+ data->>action_type,
77
+ data->>source,
78
+ data->>stage,
79
+ created_at,
80
+ data->>rating,
81
+ account_id,
82
+ accounts(name)
83
+ `)
84
+ .eq('type_id', signalType.id)
85
+ .order('created_at', { ascending: false })
86
+ .limit(20)
87
+
88
+ const formattedSignals = recentSignals?.map(signal => ({
89
+ id: signal.id,
90
+ action_type: signal.data?.action_type || 'unknown',
91
+ source: signal.data?.source || 'unknown',
92
+ stage: signal.data?.stage || 'unknown',
93
+ created_at: signal.created_at,
94
+ account_name: signal.accounts?.name,
95
+ rating: parseInt(signal.data?.rating || '1')
96
+ }))
97
+
98
+ // Calculate conversion rate (simplified - signals with rating >= 4)
99
+ const { count: highValueSignals } = await adminDb
100
+ .from('items')
101
+ .select('*', { count: 'exact', head: true })
102
+ .eq('type_id', signalType.id)
103
+ .gte('data->>rating', '4')
104
+ .gte('created_at', weekAgo)
105
+
106
+ const conversionRate = signalsThisWeek > 0 ? (highValueSignals / signalsThisWeek) * 100 : 0
107
+
108
+ return {
109
+ total_signals: totalSignals || 0,
110
+ signals_today: signalsToday || 0,
111
+ signals_this_week: signalsThisWeek || 0,
112
+ conversion_rate: conversionRate,
113
+ top_sources: topSources,
114
+ recent_signals: formattedSignals || []
115
+ }
116
+ })
117
+
118
+ // ============================================
119
+ // SIGNAL AUDIT
120
+ // ============================================
121
+
122
+ export const audit = createHandler(async (ctx, body) => {
123
+ const {
124
+ page = '1',
125
+ limit = '50',
126
+ date_from,
127
+ date_to,
128
+ source,
129
+ stage,
130
+ action_type,
131
+ min_rating,
132
+ account_id,
133
+ search,
134
+ export: exportData
135
+ } = ctx.query || {}
136
+
137
+ // Get funnel_signal type ID
138
+ const { data: signalType } = await adminDb
139
+ .from('item_type_registry')
140
+ .select('id')
141
+ .eq('slug', 'funnel_signal')
142
+ .single()
143
+
144
+ if (!signalType) {
145
+ throw new Error('funnel_signal type not found')
146
+ }
147
+
148
+ // Build query
149
+ let query = adminDb
150
+ .from('items')
151
+ .select(`
152
+ id,
153
+ data->>action_type,
154
+ data->>action_value,
155
+ data->>stage,
156
+ data->>source,
157
+ created_at,
158
+ data->>rating,
159
+ data->>raw_score,
160
+ data->identity->>anonymous_id,
161
+ data->identity->>session_id,
162
+ data->attribution->>first_touch_referrer_domain,
163
+ data->attribution->>utm_source,
164
+ data->attribution->>utm_medium,
165
+ data->attribution->>utm_campaign,
166
+ account_id,
167
+ people(email),
168
+ accounts(name)
169
+ `, { count: 'exact' })
170
+ .eq('type_id', signalType.id)
171
+
172
+ // Apply filters
173
+ if (date_from) {
174
+ query = query.gte('created_at', date_from)
175
+ }
176
+ if (date_to) {
177
+ query = query.lte('created_at', date_to)
178
+ }
179
+ if (source) {
180
+ query = query.eq('data->>source', source)
181
+ }
182
+ if (stage) {
183
+ query = query.eq('data->>stage', stage)
184
+ }
185
+ if (action_type) {
186
+ query = query.eq('data->>action_type', action_type)
187
+ }
188
+ if (min_rating) {
189
+ query = query.gte('data->>rating', min_rating)
190
+ }
191
+ if (account_id) {
192
+ query = query.eq('account_id', account_id)
193
+ }
194
+ if (search) {
195
+ query = query.or(`accounts.name.ilike.%${search}%,people.email.ilike.%${search}%,data->attribution->>first_touch_referrer_domain.ilike.%${search}%`)
196
+ }
197
+
198
+ // Order and paginate
199
+ const offset = (parseInt(page) - 1) * parseInt(limit)
200
+ query = query.order('created_at', { ascending: false })
201
+
202
+ if (!exportData) {
203
+ query = query.range(offset, offset + parseInt(limit) - 1)
204
+ }
205
+
206
+ const { data, error, count } = await query
207
+
208
+ if (error) {
209
+ throw new Error(`Failed to fetch signals: ${error.message}`)
210
+ }
211
+
212
+ const formattedSignals = data?.map(signal => ({
213
+ id: signal.id,
214
+ action_type: signal.data?.action_type || 'unknown',
215
+ action_value: parseInt(signal.data?.action_value || '1'),
216
+ stage: signal.data?.stage || 'unknown',
217
+ source: signal.data?.source || 'unknown',
218
+ created_at: signal.created_at,
219
+ account_name: signal.accounts?.name,
220
+ person_email: signal.people?.email,
221
+ anonymous_id: signal.data?.identity?.anonymous_id,
222
+ session_id: signal.data?.identity?.session_id,
223
+ rating: parseInt(signal.data?.rating || '1'),
224
+ raw_score: parseFloat(signal.data?.raw_score || '0'),
225
+ referrer_domain: signal.data?.attribution?.first_touch_referrer_domain,
226
+ utm_source: signal.data?.attribution?.utm_source,
227
+ utm_medium: signal.data?.attribution?.utm_medium,
228
+ utm_campaign: signal.data?.attribution?.utm_campaign
229
+ }))
230
+
231
+ if (exportData) {
232
+ return formattedSignals
233
+ }
234
+
235
+ return {
236
+ signals: formattedSignals,
237
+ total: count || 0,
238
+ page: parseInt(page),
239
+ limit: parseInt(limit)
240
+ }
241
+ })
@@ -37,8 +37,7 @@
37
37
 
38
38
  import { adminDb } from './_shared/db'
39
39
  import { resolvePrincipal } from './_shared/principal'
40
- import { listRegisteredHandlers } from './_shared/webhook-registry'
41
- import { HANDLER_REGISTRY } from './handler-registry'
40
+ import { resolveHandler, listRegisteredHandlers } from './_shared/webhook-registry'
42
41
 
43
42
  // ─── TYPES ────────────────────────────────────────────────────────────────
44
43
 
@@ -129,7 +128,7 @@ function validateSchema(data: any, schema: any, requestId: string): ValidationRe
129
128
  // Validate data type
130
129
  if (config.type) {
131
130
  const typeValid = validateType(value, config.type, fieldName)
132
- if (!typeValid.valid && typeValid.error) {
131
+ if (!typeValid.valid) {
133
132
  errors.push(typeValid.error)
134
133
  }
135
134
  }
@@ -256,10 +255,7 @@ function sanitizeData(data: any, rules: string = 'strict'): any {
256
255
  * @param handlerName - The handler key from integration config (e.g. "webhook-handler")
257
256
  */
258
257
  async function loadCustomScript(handlerName: string): Promise<Function | null> {
259
- const mod = HANDLER_REGISTRY[handlerName]
260
- if (!mod) return null
261
- const fn = mod.handler || mod.default
262
- return typeof fn === 'function' ? fn : null
258
+ return resolveHandler(handlerName)
263
259
  }
264
260
 
265
261
  // ─── DIAGNOSTIC HANDLER (temporary — full trace in response) ──────────────