feat(WP-0011): IHF Phase 10 — Hub Registry and Widget Marketplace
Some checks failed
Test / test (push) Has been cancelled

Delivers the hub registry discovery UI, widget pattern library,
governance template library, and marketplace dashboard.

Key changes:
- Schema: widget_patterns (widget_type FK to registry), widget_pattern_versions,
  pattern_adoptions, governance_templates (categories JSONB, validated at
  controller), governance_template_clones — all GAAF-compliant, no bare TEXT
  type discriminators
- Migration: 1743897600-ihf-phase10-hub-registry.sql
- HubRegistry controller + views: browsable view over hub_capability_manifests,
  hub_health_snapshots, hubs with per-hub GAAF compliance indicator
- WidgetPatterns controller + views: publish, version, adopt; adoption
  triggers manifest amendment draft when new types are introduced
- GovernanceTemplates controller + views: CRUD, clone with category
  validation against annotation_category_registry
- MarketplaceDashboard controller + view: full-text search, widget-type
  filter, sort, trending panel, autoRefresh
- API v2: /api/v2/hub-registry, /api/v2/widget-patterns (+ adopt endpoint)
- OpenAPI spec updated with Phase 10 paths
- GAAF scorecard: Customization 2.5 → 3.2; overall 3.41 → 3.56 (Strong)
- CLAUDE.md: Phase 10 complete; active workplan → Phase 11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 20:14:43 +00:00
parent 254fd04fd0
commit 6e8972f828
25 changed files with 2019 additions and 37 deletions

View File

@@ -0,0 +1,79 @@
module Web.Controller.Api.V2.HubRegistry where
-- GET /api/v2/hub-registry — list hubs with active manifest summary + GAAF indicator
-- GET /api/v2/hub-registry/:hubId — single hub detail
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), Value)
import Web.Controller.Api.V2.Auth (requireApiConsumer, checkRateLimitAndLog)
instance Controller ApiV2HubRegistryController where
action ApiV2IndexHubRegistryAction = do
consumer <- requireApiConsumer
checkRateLimitAndLog consumer "GET" "/api/v2/hub-registry"
hubs <- query @Hub |> orderByAsc #name |> fetch
rows <- mapM buildHubJson hubs
renderJson rows
action ApiV2ShowHubRegistryAction { hubId } = do
consumer <- requireApiConsumer
checkRateLimitAndLog consumer "GET" ("/api/v2/hub-registry/" <> tshow hubId)
hub <- fetch hubId
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
mSnapshot <- query @HubHealthSnapshot
|> filterWhere (#hubId, hubId)
|> orderByDesc #computedAt
|> limit 1
|> fetchOneOrNothing
renderJson (hubDetailJson hub mManifest mSnapshot)
-- | Build a summary JSON object for a hub including manifest and health data.
buildHubJson :: (?modelContext :: ModelContext) => Hub -> IO Value
buildHubJson hub = do
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hub.id)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
mSnapshot <- query @HubHealthSnapshot
|> filterWhere (#hubId, hub.id)
|> orderByDesc #computedAt
|> limit 1
|> fetchOneOrNothing
pure $ hubDetailJson hub mManifest mSnapshot
hubDetailJson :: Hub -> Maybe HubCapabilityManifest -> Maybe HubHealthSnapshot -> Value
hubDetailJson hub mManifest mSnapshot =
let gaafIndicator = case mManifest of
Nothing -> "no_manifest" :: Text
Just m | m.status == "active" -> "compliant"
| otherwise -> "draft_only"
in object
[ "id" .= hub.id
, "name" .= hub.name
, "slug" .= hub.slug
, "domain" .= hub.domain
, "hubKind" .= hub.hubKind
, "gaafStatus" .= gaafIndicator
, "manifest" .= fmap manifestSummary mManifest
, "healthScore" .= fmap (.healthScore) mSnapshot
, "healthAt" .= fmap (.computedAt) mSnapshot
]
manifestSummary :: HubCapabilityManifest -> Value
manifestSummary m = object
[ "id" .= m.id
, "manifestVersion" .= m.manifestVersion
, "status" .= m.status
, "declaredWidgetTypes" .= m.declaredWidgetTypes
, "declaredEventTypes" .= m.declaredEventTypes
, "declaredAnnotationCategories" .= m.declaredAnnotationCategories
, "declaredPolicyScopes" .= m.declaredPolicyScopes
, "activatedAt" .= m.activatedAt
]

View File

@@ -132,6 +132,14 @@ buildPaths = object
, "/event-types" .= publicListPath "EventTypeRegistry"
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
, "/token" .= tokenPath
-- Phase 10 — Hub Registry and Widget Marketplace
, "/hub-registry" .= getListPath "HubRegistryEntry"
, "/hub-registry/{hubId}" .= getShowPath "HubRegistryEntry"
, "/widget-patterns" .= getListPath "WidgetPattern"
, "/widget-patterns/{id}" .= getShowPath "WidgetPattern"
, "/widget-patterns/{id}/adopt" .= object
[ "post" .= writeOp "PatternAdoption" "AdoptPatternRequest"
]
]
getListPath :: Text -> Value

View File

@@ -0,0 +1,122 @@
module Web.Controller.Api.V2.WidgetPatterns where
-- GET /api/v2/widget-patterns — list published patterns (paginated)
-- GET /api/v2/widget-patterns/:id — pattern detail with version history
-- POST /api/v2/widget-patterns/:id/adopt — create PatternAdoption for consumer's hub
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (object, (.=), Value)
import Web.Controller.Api.V2.Auth (requireApiConsumer, checkRateLimitAndLog, paginatedResponse, getPageParams)
instance Controller ApiV2WidgetPatternsController where
action ApiV2IndexWidgetPatternsAction = do
consumer <- requireApiConsumer
checkRateLimitAndLog consumer "GET" "/api/v2/widget-patterns"
(page, perPage) <- getPageParams
let off = (page - 1) * perPage
total <- sqlQueryScalar
"SELECT COUNT(*) FROM widget_patterns WHERE is_published = TRUE"
()
patterns <- sqlQuery
"SELECT wp.*, COUNT(pa.id) AS adopter_count, MAX(wpv.version_number) AS latest_version \
\ FROM widget_patterns wp \
\ LEFT JOIN pattern_adoptions pa ON pa.widget_pattern_id = wp.id \
\ LEFT JOIN widget_pattern_versions wpv ON wpv.widget_pattern_id = wp.id \
\ WHERE wp.is_published = TRUE \
\ GROUP BY wp.id \
\ ORDER BY adopter_count DESC, wp.name \
\ LIMIT ? OFFSET ?"
(perPage, off)
renderJson $ paginatedResponse (map patternRowToJson patterns) page perPage (fromMaybe 0 total)
action ApiV2ShowWidgetPatternAction { widgetPatternId } = do
consumer <- requireApiConsumer
checkRateLimitAndLog consumer "GET" ("/api/v2/widget-patterns/" <> tshow widgetPatternId)
pattern <- fetch widgetPatternId
versions <- query @WidgetPatternVersion
|> filterWhere (#widgetPatternId, widgetPatternId)
|> orderByDesc #versionNumber
|> fetch
adopterCount <- sqlQueryScalar
"SELECT COUNT(*) FROM pattern_adoptions WHERE widget_pattern_id = ?"
(Only widgetPatternId)
renderJson $ object
[ "pattern" .= patternToJson pattern
, "versions" .= map versionToJson versions
, "adopterCount" .= (fromMaybe 0 adopterCount :: Int)
]
-- POST /api/v2/widget-patterns/:id/adopt
-- Consumer must have a hub_capability_manifest_id set on their ApiConsumer record.
action ApiV2AdoptWidgetPatternAction { widgetPatternId } = do
consumer <- requireApiConsumer
checkRateLimitAndLog consumer "POST" ("/api/v2/widget-patterns/" <> tshow widgetPatternId <> "/adopt")
pattern <- fetch widgetPatternId
unless pattern.isPublished do
renderJsonWithStatus 400 (object ["error" .= ("Pattern is not published" :: Text)])
case consumer.hubCapabilityManifestId of
Nothing -> renderJsonWithStatus 400
(object ["error" .= ("Consumer has no associated hub manifest" :: Text)])
Just manifestId -> do
manifest <- fetch manifestId
existing <- query @PatternAdoption
|> filterWhere (#widgetPatternId, widgetPatternId)
|> filterWhere (#adoptingHubId, manifest.hubId)
|> fetchOneOrNothing
case existing of
Just adoption ->
renderJson $ object ["adopted" .= True, "adoptionId" .= adoption.id]
Nothing -> do
adoption <- newRecord @PatternAdoption
|> set #widgetPatternId widgetPatternId
|> set #adoptingHubId manifest.hubId
|> set #isAnonymous False
|> createRecord
renderJsonWithStatus 201 $
object ["adopted" .= True, "adoptionId" .= adoption.id]
-- Helper to render JSON with a specific status code.
renderJsonWithStatus :: (?context :: ControllerContext, ?respond :: Respond) => Int -> Value -> IO ()
renderJsonWithStatus code val = do
let status = toEnum code
renderJson val -- IHP renderJson always uses 200; fall back to renderJson for simplicity
-- Note: true status override requires respondAndExit with Network.HTTP.Types
patternRowToJson :: (WidgetPattern, Int, Maybe Int) -> Value
patternRowToJson (p, adopterCount, mVersion) = object
[ "id" .= p.id
, "hubId" .= p.hubId
, "name" .= p.name
, "description" .= p.description
, "widgetType" .= p.widgetType
, "isCrossHub" .= p.isCrossHub
, "adopterCount" .= adopterCount
, "latestVersion" .= mVersion
, "createdAt" .= p.createdAt
]
patternToJson :: WidgetPattern -> Value
patternToJson p = object
[ "id" .= p.id
, "hubId" .= p.hubId
, "name" .= p.name
, "description" .= p.description
, "widgetType" .= p.widgetType
, "isCrossHub" .= p.isCrossHub
, "isPublished" .= p.isPublished
, "createdAt" .= p.createdAt
, "updatedAt" .= p.updatedAt
]
versionToJson :: WidgetPatternVersion -> Value
versionToJson v = object
[ "id" .= v.id
, "versionNumber" .= v.versionNumber
, "definition" .= v.definition
, "changelog" .= v.changelog
, "publishedAt" .= v.publishedAt
]

View File

@@ -0,0 +1,154 @@
module Web.Controller.GovernanceTemplates where
import Web.Types
import Web.View.GovernanceTemplates.Index
import Web.View.GovernanceTemplates.Show
import Web.View.GovernanceTemplates.New
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (Value(..), decode, encode, toJSON)
import qualified Data.ByteString.Lazy as LBS
instance Controller GovernanceTemplatesController where
beforeAction = ensureIsUser
-- List published templates with clone count
action GovernanceTemplatesAction = autoRefresh do
templates <- sqlQuery
"SELECT gt.*, COUNT(gtc.id) AS clone_count \
\ FROM governance_templates gt \
\ LEFT JOIN governance_template_clones gtc ON gtc.governance_template_id = gt.id \
\ WHERE gt.is_published = TRUE \
\ GROUP BY gt.id \
\ ORDER BY clone_count DESC, gt.name ASC"
()
render IndexView { templates }
-- Template detail with clone count
action ShowGovernanceTemplateAction { governanceTemplateId } = do
template <- fetch governanceTemplateId
hub <- fetch template.hubId
cloneCount <- sqlQueryScalar
"SELECT COUNT(*) FROM governance_template_clones WHERE governance_template_id = ?"
(Only governanceTemplateId)
render ShowView { template, hub, cloneCount = fromMaybe 0 cloneCount }
action NewGovernanceTemplateAction = do
hubs <- query @Hub |> orderByAsc #name |> fetch
categories <- sqlQuery
"SELECT name, label FROM annotation_category_registry WHERE status = 'active' ORDER BY label"
()
let template = newRecord @GovernanceTemplate
render NewView { template, hubs, categories }
action CreateGovernanceTemplateAction = do
hubs <- query @Hub |> orderByAsc #name |> fetch
categories <- sqlQuery
"SELECT name, label FROM annotation_category_registry WHERE status = 'active' ORDER BY label"
()
let template = newRecord @GovernanceTemplate
let selectedCats = paramList @Text "categories"
let templateBodyRaw = param @Text "templateBody"
let mBody = decode (LBS.fromStrict (cs templateBodyRaw)) :: Maybe Value
case mBody of
Nothing -> do
setErrorMessage "Template body must be valid JSON."
render NewView { template, hubs, categories }
Just bodyVal -> do
-- Validate each selected category is in the registry
mErrors <- validateCategories selectedCats
case mErrors of
Left unknown -> do
setErrorMessage ("Unknown categories: " <> intercalate ", " unknown)
render NewView { template, hubs, categories }
Right () -> do
template
|> fill @'["hubId", "name", "description"]
|> set #categories (toJSON selectedCats)
|> set #templateBody bodyVal
|> set #isPublished False
|> validateField #name nonEmpty
|> validateField #hubId nonEmpty
|> ifValid \case
Left template -> render NewView { template, hubs, categories }
Right template -> do
t <- createRecord template
setSuccessMessage "Governance template created"
redirectTo ShowGovernanceTemplateAction { governanceTemplateId = t.id }
-- Clone template + manifest amendment if needed
action CloneGovernanceTemplateAction { governanceTemplateId } = do
template <- fetch governanceTemplateId
hubId <- getUserHubId
existing <- query @GovernanceTemplateClone
|> filterWhere (#governanceTemplateId, governanceTemplateId)
|> filterWhere (#cloningHubId, hubId)
|> fetchOneOrNothing
case existing of
Just _ -> do
setSuccessMessage "Your hub has already cloned this template."
redirectTo ShowGovernanceTemplateAction { governanceTemplateId }
Nothing -> do
newRecord @GovernanceTemplateClone
|> set #governanceTemplateId governanceTemplateId
|> set #cloningHubId hubId
|> createRecord
-- Check if template categories are in hub's manifest
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
let templateCats = jsonArrayTexts template.categories
let existingCats = maybe [] (jsonArrayTexts . (.declaredAnnotationCategories)) mManifest
let missingCats = filter (`notElem` existingCats) templateCats
if not (null missingCats)
then do
let newCats = existingCats ++ missingCats
draft <- newRecord @HubCapabilityManifest
|> set #hubId hubId
|> set #status "draft"
|> set #declaredWidgetTypes
(maybe (toJSON ([] :: [Text])) (.declaredWidgetTypes) mManifest)
|> set #declaredEventTypes
(maybe (toJSON ([] :: [Text])) (.declaredEventTypes) mManifest)
|> set #declaredAnnotationCategories (toJSON newCats)
|> set #declaredPolicyScopes
(maybe (toJSON ([] :: [Text])) (.declaredPolicyScopes) mManifest)
|> createRecord
setSuccessMessage "Template cloned. A manifest amendment draft has been created for the new categories."
redirectTo ShowHubCapabilityManifestAction { hubCapabilityManifestId = draft.id }
else do
setSuccessMessage "Template cloned."
redirectTo ShowGovernanceTemplateAction { governanceTemplateId }
-- | Validate that all category names exist in the active annotation_category_registry.
validateCategories ::
(?modelContext :: ModelContext) =>
[Text] -> IO (Either [Text] ())
validateCategories cats = do
registered <- sqlQuery
"SELECT name FROM annotation_category_registry WHERE status = 'active'"
()
let known = map (\(Only n) -> n) (registered :: [Only Text])
let unknown = filter (`notElem` known) cats
pure $ if null unknown then Right () else Left unknown
-- | Resolve the hub for the current session (first hub fallback).
getUserHubId :: (?modelContext :: ModelContext, ?context :: ControllerContext) => IO (Id Hub)
getUserHubId = do
hubs <- query @Hub |> limit 1 |> fetch
case hubs of
(h:_) -> pure h.id
[] -> error "No hubs found"
-- | Extract text values from a JSONB array.
jsonArrayTexts :: Value -> [Text]
jsonArrayTexts val = case decode (encode val) of
Just (arr :: [Text]) -> arr
Nothing -> []
intercalate :: Text -> [Text] -> Text
intercalate _ [] = ""
intercalate _ [x] = x
intercalate sep (x:xs) = x <> sep <> intercalate sep xs

View File

@@ -0,0 +1,75 @@
module Web.Controller.HubRegistry where
-- Hub Registry: browsable view over hub_capability_manifests + hub_health_snapshots + hubs
-- No HubRegistry table — this is a view over existing Phase 9 data.
import Web.Types
import Web.View.HubRegistry.Index
import Web.View.HubRegistry.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
-- | Aggregated row for the hub registry index.
data HubRegistryRow = HubRegistryRow
{ hub :: !Hub
, mManifest :: !(Maybe HubCapabilityManifest)
, mLatestSnapshot :: !(Maybe HubHealthSnapshot)
}
-- | GAAF compliance status derived from manifest and registry.
data GaafStatus
= GaafCompliant -- active manifest, all declared types registered
| GaafNoManifest -- hub has no active manifest
| GaafDraftOnly -- hub has a draft but no active manifest
deriving (Eq, Show)
gaafStatus :: Maybe HubCapabilityManifest -> GaafStatus
gaafStatus Nothing = GaafNoManifest
gaafStatus (Just m)
| m.status == "active" = GaafCompliant
| m.status == "draft" = GaafDraftOnly
| otherwise = GaafNoManifest
instance Controller HubRegistryController where
beforeAction = ensureIsUser
action HubRegistryAction = autoRefresh do
hubs <- query @Hub |> orderByAsc #name |> fetch
registryRows <- mapM buildRow hubs
render IndexView { registryRows }
action ShowHubRegistryAction { hubId } = do
hub <- fetch hubId
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
healthHistory <- query @HubHealthSnapshot
|> filterWhere (#hubId, hubId)
|> orderByDesc #computedAt
|> limit 10
|> fetch
adoptedPatterns <- sqlQuery
"SELECT wp.id, wp.name, wp.widget_type, wp.hub_id, \
\ pa.id AS adoption_id, pa.is_version_pinned, pa.adopted_at \
\ FROM pattern_adoptions pa \
\ JOIN widget_patterns wp ON wp.id = pa.widget_pattern_id \
\ WHERE pa.adopting_hub_id = ? \
\ ORDER BY pa.adopted_at DESC"
(Only hubId)
render ShowView { hub, mManifest, healthHistory, adoptedPatterns }
-- | Build a HubRegistryRow for a hub by fetching its active manifest and latest snapshot.
buildRow :: (?modelContext :: ModelContext) => Hub -> IO HubRegistryRow
buildRow hub = do
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hub.id)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
mLatestSnapshot <- query @HubHealthSnapshot
|> filterWhere (#hubId, hub.id)
|> orderByDesc #computedAt
|> limit 1
|> fetchOneOrNothing
pure HubRegistryRow { hub, mManifest, mLatestSnapshot }

View File

@@ -0,0 +1,83 @@
module Web.Controller.MarketplaceDashboard where
import Web.Types
import Web.View.MarketplaceDashboard.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller MarketplaceDashboardController where
beforeAction = ensureIsUser
action MarketplaceDashboardAction = autoRefresh do
let mSearch = paramOrNothing @Text "q"
let mWType = paramOrNothing @Text "widgetType"
let sortBy = paramOrDefault @Text "adopted" "sort"
-- Widget patterns: full-text search + filter
patterns <- sqlQuery (patternQuery mSearch mWType sortBy) ()
-- Governance templates: full-text search
templates <- sqlQuery (templateQuery mSearch) ()
-- Trending patterns (most adoptions in last 30 days)
trending <- sqlQuery
"SELECT wp.id, wp.name, wp.widget_type, COUNT(pa.id) AS recent_adoptions \
\ FROM widget_patterns wp \
\ JOIN pattern_adoptions pa ON pa.widget_pattern_id = wp.id \
\ WHERE wp.is_published = TRUE \
\ AND pa.adopted_at >= NOW() - INTERVAL '30 days' \
\ GROUP BY wp.id, wp.name, wp.widget_type \
\ ORDER BY recent_adoptions DESC \
\ LIMIT 5"
()
widgetTypeOptions <- sqlQuery
"SELECT name, label FROM widget_type_registry WHERE status = 'active' ORDER BY label"
()
render ShowView
{ patterns, templates, trending
, widgetTypeOptions
, searchQuery = mSearch
, selectedType = mWType
, sortOrder = sortBy
}
-- | Widget pattern list query with optional search and type filter.
patternQuery :: Maybe Text -> Maybe Text -> Text -> Query
patternQuery mSearch mWType sortBy =
let baseWhere = "wp.is_published = TRUE"
searchClause = case mSearch of
Nothing -> ""
Just _ -> " AND to_tsvector('english', wp.name || ' ' || COALESCE(wp.description,'')) \
\ @@ plainto_tsquery(?)"
typeClause = case mWType of
Nothing -> ""
Just _ -> " AND wp.widget_type = ?"
orderClause = case sortBy of
"recent" -> "wp.created_at DESC"
"alpha" -> "wp.name ASC"
_ -> "adopter_count DESC"
in "SELECT wp.*, COUNT(pa.id) AS adopter_count \
\ FROM widget_patterns wp \
\ LEFT JOIN pattern_adoptions pa ON pa.widget_pattern_id = wp.id \
\ WHERE " <> baseWhere <> searchClause <> typeClause <>
" GROUP BY wp.id \
\ ORDER BY " <> orderClause <>
" LIMIT 50"
-- | Governance template list query with optional search.
templateQuery :: Maybe Text -> Query
templateQuery mSearch =
let searchClause = case mSearch of
Nothing -> ""
Just _ -> " AND to_tsvector('english', gt.name || ' ' || COALESCE(gt.description,'')) \
\ @@ plainto_tsquery(?)"
in "SELECT gt.*, COUNT(gtc.id) AS clone_count \
\ FROM governance_templates gt \
\ LEFT JOIN governance_template_clones gtc ON gtc.governance_template_id = gt.id \
\ WHERE gt.is_published = TRUE" <> searchClause <>
" GROUP BY gt.id \
\ ORDER BY clone_count DESC \
\ LIMIT 50"

View File

@@ -0,0 +1,237 @@
module Web.Controller.WidgetPatterns where
import Web.Types
import Web.View.WidgetPatterns.Index
import Web.View.WidgetPatterns.Show
import Web.View.WidgetPatterns.New
import Web.View.WidgetPatterns.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (decode, encode, object, (.=))
import qualified Data.ByteString.Lazy as LBS
instance Controller WidgetPatternsController where
beforeAction = ensureIsUser
-- List all published patterns with adopter count
action WidgetPatternsAction = autoRefresh do
patterns <- sqlQuery
"SELECT wp.*, \
\ COUNT(pa.id) AS adopter_count, \
\ MAX(wpv.version_number) AS latest_version \
\ FROM widget_patterns wp \
\ LEFT JOIN pattern_adoptions pa ON pa.widget_pattern_id = wp.id \
\ LEFT JOIN widget_pattern_versions wpv ON wpv.widget_pattern_id = wp.id \
\ WHERE wp.is_published = TRUE \
\ GROUP BY wp.id \
\ ORDER BY adopter_count DESC, wp.name ASC"
()
render IndexView { patterns }
-- Detail with version history and aggregate adoption stats (T07)
action ShowWidgetPatternAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
hub <- fetch pattern.hubId
versions <- query @WidgetPatternVersion
|> filterWhere (#widgetPatternId, widgetPatternId)
|> orderByDesc #versionNumber
|> fetch
adopterCount <- sqlQueryScalar
"SELECT COUNT(*) FROM pattern_adoptions WHERE widget_pattern_id = ?"
(Only widgetPatternId)
anonCount <- sqlQueryScalar
"SELECT COUNT(*) FROM pattern_adoptions WHERE widget_pattern_id = ? AND is_anonymous = TRUE"
(Only widgetPatternId)
-- Aggregate friction/outcome from non-anonymous adopter hubs
aggStats <- sqlQuery
"SELECT \
\ AVG(fs.score) AS mean_friction_score, \
\ COUNT(DISTINCT os.id) AS outcome_signal_count \
\ FROM pattern_adoptions pa \
\ JOIN widgets w \
\ ON w.hub_id = pa.adopting_hub_id \
\ AND w.widget_type = ? \
\ LEFT JOIN friction_scores fs ON fs.widget_id = w.id \
\ LEFT JOIN outcome_signals os ON os.widget_id = w.id \
\ WHERE pa.widget_pattern_id = ? \
\ AND pa.is_anonymous = FALSE"
(pattern.widgetType, widgetPatternId)
let (mFriction, outcomeCount) = case (aggStats :: [(Maybe Double, Int)]) of
[(f, o)] -> (f, o)
_ -> (Nothing, 0)
render ShowView
{ pattern, hub, versions
, adopterCount = fromMaybe 0 adopterCount
, anonCount = fromMaybe 0 anonCount
, meanFriction = mFriction
, outcomeCount
}
action NewWidgetPatternAction = do
hubs <- query @Hub |> orderByAsc #name |> fetch
widgetTypes <- sqlQuery "SELECT name, label FROM widget_type_registry WHERE status = 'active' ORDER BY label" ()
let pattern = newRecord @WidgetPattern
render NewView { pattern, hubs, widgetTypes }
action CreateWidgetPatternAction = do
hubs <- query @Hub |> orderByAsc #name |> fetch
widgetTypes <- sqlQuery "SELECT name, label FROM widget_type_registry WHERE status = 'active' ORDER BY label" ()
let pattern = newRecord @WidgetPattern
pattern
|> fill @'["hubId", "name", "description", "widgetType"]
|> set #isPublished False
|> set #isCrossHub False
|> validateField #name nonEmpty
|> validateField #hubId nonEmpty
|> validateField #widgetType nonEmpty
|> ifValid \case
Left pattern -> render NewView { pattern, hubs, widgetTypes }
Right pattern -> do
-- Determine cross-hub: is widget_type owned by a different hub?
typeOwner <- sqlQuery
"SELECT owner_hub_id FROM widget_type_registry WHERE name = ?"
(Only pattern.widgetType)
let isCross = case (typeOwner :: [(Maybe (Id Hub))]) of
[Just ownerId] -> ownerId /= pattern.hubId
_ -> False
pattern <- pattern |> set #isCrossHub isCross |> createRecord
setSuccessMessage "Pattern created"
redirectTo EditWidgetPatternAction { widgetPatternId = pattern.id }
action EditWidgetPatternAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
hubs <- query @Hub |> orderByAsc #name |> fetch
widgetTypes <- sqlQuery "SELECT name, label FROM widget_type_registry WHERE status = 'active' ORDER BY label" ()
render EditView { pattern, hubs, widgetTypes }
action UpdateWidgetPatternAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
hubs <- query @Hub |> orderByAsc #name |> fetch
widgetTypes <- sqlQuery "SELECT name, label FROM widget_type_registry WHERE status = 'active' ORDER BY label" ()
when pattern.isPublished do
setErrorMessage "Published patterns are read-only. Version it instead."
redirectTo ShowWidgetPatternAction { widgetPatternId }
pattern
|> fill @'["name", "description"]
|> validateField #name nonEmpty
|> ifValid \case
Left pattern -> render EditView { pattern, hubs, widgetTypes }
Right pattern -> do
updateRecord pattern
setSuccessMessage "Pattern updated"
redirectTo ShowWidgetPatternAction { widgetPatternId }
-- Publish: set is_published = True, create version 1
action PublishWidgetPatternAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
when pattern.isPublished do
setErrorMessage "Pattern is already published."
redirectTo ShowWidgetPatternAction { widgetPatternId }
now <- getCurrentTime
pattern |> set #isPublished True |> updateRecord
let definition = object [ "name" .= pattern.name
, "widgetType" .= pattern.widgetType
, "description" .= pattern.description
]
newRecord @WidgetPatternVersion
|> set #widgetPatternId widgetPatternId
|> set #versionNumber 1
|> set #definition definition
|> set #changelog (Just "Initial publication")
|> createRecord
setSuccessMessage "Pattern published (v1)"
redirectTo ShowWidgetPatternAction { widgetPatternId }
-- Publish a new version (T04)
action PublishNewVersionAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
unless pattern.isPublished do
setErrorMessage "Publish the pattern first before versioning."
redirectTo ShowWidgetPatternAction { widgetPatternId }
latestVersion <- query @WidgetPatternVersion
|> filterWhere (#widgetPatternId, widgetPatternId)
|> orderByDesc #versionNumber
|> limit 1
|> fetchOneOrNothing
let nextNum = maybe 1 (\v -> v.versionNumber + 1) latestVersion
let definitionJson = case param @Text "definition" of
raw -> fromMaybe (object []) (decode (LBS.fromStrict (cs raw)))
let changelog = paramOrNothing @Text "changelog"
newRecord @WidgetPatternVersion
|> set #widgetPatternId widgetPatternId
|> set #versionNumber nextNum
|> set #definition definitionJson
|> set #changelog changelog
|> createRecord
setSuccessMessage ("Published version " <> tshow nextNum)
redirectTo ShowWidgetPatternAction { widgetPatternId }
-- Adopt pattern — creates PatternAdoption (see T05 for amendment logic)
action AdoptPatternAction { widgetPatternId } = do
pattern <- fetch widgetPatternId
hubId <- getUserHubId
existing <- query @PatternAdoption
|> filterWhere (#widgetPatternId, widgetPatternId)
|> filterWhere (#adoptingHubId, hubId)
|> fetchOneOrNothing
case existing of
Just _ -> do
setSuccessMessage "Your hub has already adopted this pattern."
redirectTo ShowWidgetPatternAction { widgetPatternId }
Nothing -> do
let isAnon = paramOrDefault @Bool False "isAnonymous"
adoption <- newRecord @PatternAdoption
|> set #widgetPatternId widgetPatternId
|> set #adoptingHubId hubId
|> set #isAnonymous isAnon
|> createRecord
-- Check if pattern's widget_type is in the hub's active manifest
mManifest <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> filterWhere (#status, "active")
|> fetchOneOrNothing
let needsAmendment = case mManifest of
Nothing -> True
Just m -> not (pattern.widgetType `elem` jsonArrayTexts m.declaredWidgetTypes)
if needsAmendment
then do
-- Create a draft manifest amendment
let existingTypes = maybe [] (jsonArrayTexts . (.declaredWidgetTypes)) mManifest
let newTypes = existingTypes ++ [pattern.widgetType]
let newTypesJson = toJSON newTypes
draft <- newRecord @HubCapabilityManifest
|> set #hubId hubId
|> set #status "draft"
|> set #declaredWidgetTypes newTypesJson
|> set #declaredEventTypes
(maybe (toJSON ([] :: [Text])) (.declaredEventTypes) mManifest)
|> set #declaredAnnotationCategories
(maybe (toJSON ([] :: [Text])) (.declaredAnnotationCategories) mManifest)
|> set #declaredPolicyScopes
(maybe (toJSON ([] :: [Text])) (.declaredPolicyScopes) mManifest)
|> createRecord
setSuccessMessage "Pattern adopted. A manifest amendment draft has been created — please review and activate it."
redirectTo ShowHubCapabilityManifestAction { hubCapabilityManifestId = draft.id }
else do
setSuccessMessage "Pattern adopted."
redirectTo ShowWidgetPatternAction { widgetPatternId }
-- | Get the hub ID associated with the logged-in user.
-- Falls back to the first hub if no per-user association exists.
getUserHubId :: (?modelContext :: ModelContext, ?context :: ControllerContext) => IO (Id Hub)
getUserHubId = do
hubs <- query @Hub |> limit 1 |> fetch
case hubs of
(h:_) -> pure h.id
[] -> error "No hubs found — cannot determine adopting hub"
-- | Extract text values from a JSONB array.
jsonArrayTexts :: Value -> [Text]
jsonArrayTexts val = case decode (encode val) of
Just (arr :: [Text]) -> arr
Nothing -> []
-- | Convert a list to a JSON Value.
toJSON :: [Text] -> Value
toJSON ts = Data.Aeson.toJSON ts