generated from coulomb/repo-seed
feat(WP-0011): IHF Phase 10 — Hub Registry and Widget Marketplace
Some checks failed
Test / test (push) Has been cancelled
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:
79
Web/Controller/Api/V2/HubRegistry.hs
Normal file
79
Web/Controller/Api/V2/HubRegistry.hs
Normal 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
|
||||
]
|
||||
@@ -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
|
||||
|
||||
122
Web/Controller/Api/V2/WidgetPatterns.hs
Normal file
122
Web/Controller/Api/V2/WidgetPatterns.hs
Normal 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
|
||||
]
|
||||
154
Web/Controller/GovernanceTemplates.hs
Normal file
154
Web/Controller/GovernanceTemplates.hs
Normal 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
|
||||
75
Web/Controller/HubRegistry.hs
Normal file
75
Web/Controller/HubRegistry.hs
Normal 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 }
|
||||
83
Web/Controller/MarketplaceDashboard.hs
Normal file
83
Web/Controller/MarketplaceDashboard.hs
Normal 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"
|
||||
237
Web/Controller/WidgetPatterns.hs
Normal file
237
Web/Controller/WidgetPatterns.hs
Normal 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
|
||||
Reference in New Issue
Block a user