diff --git a/Application/Helper/View.hs b/Application/Helper/View.hs index ce8ac64..8ea6a62 100644 --- a/Application/Helper/View.hs +++ b/Application/Helper/View.hs @@ -1,5 +1,44 @@ module Application.Helper.View where import IHP.ViewPrelude +import Generated.Types +import Web.Types --- Here you can add functions which are available in all your views \ No newline at end of file +-- | Widget Envelope — wraps any widget's rendered content with IHF governance metadata. +-- +-- Every interactive element that is part of the governed widget registry should +-- be wrapped with this helper. It injects the stable data-* attributes that the +-- client-side event capture script reads to identify the widget without coupling +-- to implementation details. +-- +-- Usage: +-- +-- @ +-- widgetEnvelope widget [hsx| +-- +-- |] +-- @ +-- +-- See docs/widget-envelope-convention.md for the full convention. +widgetEnvelope :: Widget -> Html -> Html +widgetEnvelope widget inner = [hsx| +
+ {inner} +
+ + Annotate + +
+
+|] diff --git a/Application/Migration/1743034800-ihf-phase1-initial-schema.sql b/Application/Migration/1743034800-ihf-phase1-initial-schema.sql new file mode 100644 index 0000000..dfb339c --- /dev/null +++ b/Application/Migration/1743034800-ihf-phase1-initial-schema.sql @@ -0,0 +1,92 @@ +-- IHF Phase 1 Initial Schema Migration +-- Creates: users, hubs, widgets, widget_versions, interaction_events, annotations + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + failed_login_attempts INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE TABLE hubs ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + domain TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE TABLE widgets ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + widget_type TEXT NOT NULL, + capability_ref TEXT, + view_context TEXT, + policy_scope TEXT NOT NULL DEFAULT 'internal', + status TEXT NOT NULL DEFAULT 'active', + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE TABLE widget_versions ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + version INT NOT NULL, + schema_snapshot JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (widget_id, version) +); + +CREATE TABLE interaction_events ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor_id UUID, + actor_type TEXT NOT NULL DEFAULT 'user', + view_context_ref TEXT, + metadata JSONB DEFAULT '{}' NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX interaction_events_widget_id_idx ON interaction_events (widget_id); +CREATE INDEX interaction_events_occurred_at_idx ON interaction_events (occurred_at DESC); + +CREATE OR REPLACE FUNCTION prevent_interaction_event_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'interaction_events is append-only: UPDATE and DELETE are not permitted'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER interaction_events_no_update + BEFORE UPDATE ON interaction_events + FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation(); + +CREATE TRIGGER interaction_events_no_delete + BEFORE DELETE ON interaction_events + FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation(); + +CREATE TABLE annotations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + parent_id UUID REFERENCES annotations(id) ON DELETE CASCADE, + body TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'friction', + actor_id UUID, + actor_type TEXT NOT NULL DEFAULT 'user', + widget_state_ref TEXT, + retracted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX annotations_widget_id_idx ON annotations (widget_id); + +-- Dev seed: one hub for local development +INSERT INTO hubs (slug, name, domain) +VALUES ('dev', 'Dev Hub', 'development'); diff --git a/Application/Schema.sql b/Application/Schema.sql index b743c66..161ecc5 100644 --- a/Application/Schema.sql +++ b/Application/Schema.sql @@ -1 +1,96 @@ --- Your database schema. Use the Schema Designer at http://localhost:8001/ to add some tables. +-- IHF Phase 1 Schema +-- Hub, Widget, WidgetVersion, InteractionEvent, Annotation +-- See workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users (T10 — authentication) +CREATE TABLE users ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + locked_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + failed_login_attempts INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +-- Hubs — bounded domains of responsibility +CREATE TABLE hubs ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + domain TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +-- Widgets — smallest semantically governable interaction units +CREATE TABLE widgets ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + hub_id UUID NOT NULL REFERENCES hubs(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + widget_type TEXT NOT NULL, + capability_ref TEXT, + view_context TEXT, + policy_scope TEXT NOT NULL DEFAULT 'internal', + status TEXT NOT NULL DEFAULT 'active', + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +-- Widget version history +CREATE TABLE widget_versions ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + version INT NOT NULL, + schema_snapshot JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, + UNIQUE (widget_id, version) +); + +-- Interaction events — append-only capture +CREATE TABLE interaction_events ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor_id UUID, + actor_type TEXT NOT NULL DEFAULT 'user', + view_context_ref TEXT, + metadata JSONB DEFAULT '{}' NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX interaction_events_widget_id_idx ON interaction_events (widget_id); +CREATE INDEX interaction_events_occurred_at_idx ON interaction_events (occurred_at DESC); + +-- Enforce append-only on interaction_events +CREATE OR REPLACE FUNCTION prevent_interaction_event_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'interaction_events is append-only: UPDATE and DELETE are not permitted'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER interaction_events_no_update + BEFORE UPDATE ON interaction_events + FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation(); + +CREATE TRIGGER interaction_events_no_delete + BEFORE DELETE ON interaction_events + FOR EACH ROW EXECUTE FUNCTION prevent_interaction_event_mutation(); + +-- Annotations — structured commentary, also append-only by convention +CREATE TABLE annotations ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + widget_id UUID NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, + parent_id UUID REFERENCES annotations(id) ON DELETE CASCADE, + body TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'friction', + actor_id UUID, + actor_type TEXT NOT NULL DEFAULT 'user', + widget_state_ref TEXT, + retracted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL +); + +CREATE INDEX annotations_widget_id_idx ON annotations (widget_id); diff --git a/Main.hs b/Main.hs index 81d4d95..5a5f082 100644 --- a/Main.hs +++ b/Main.hs @@ -6,9 +6,13 @@ import qualified IHP.Server import IHP.RouterSupport import IHP.FrameworkConfig import IHP.Job.Types +import Web.FrontController +import Web.Types instance FrontController RootApplication where - controllers = [] + controllers = + [ mountFrontController WebApplication + ] instance Worker RootApplication where workers _ = [] diff --git a/Test/Integration.hs b/Test/Integration.hs index e344ac6..7fa2330 100644 --- a/Test/Integration.hs +++ b/Test/Integration.hs @@ -4,20 +4,159 @@ import Test.Hspec import IHP.Prelude import IHP.ModelSupport import IHP.Log.Types +import IHP.ControllerPrelude hiding (query) import System.Environment (lookupEnv) +import Generated.Types -- Integration tests run with a temporary PostgreSQL database. --- The DATABASE_URL env var is set automatically by `nix flake check`. --- See: https://ihp.digitallyinduced.com/Guide/testing.html +-- Run: nix flake check (or `test` inside devenv) main :: IO () main = do databaseUrl <- lookupEnv "DATABASE_URL" >>= \case Just url -> pure (cs url) - Nothing -> error "DATABASE_URL not set. Run `devenv up` first or use `nix flake check`." + Nothing -> error "DATABASE_URL not set. Run `devenv up` first or use `nix flake check`." logger <- newLogger def { level = Warn } withModelContext databaseUrl logger \modelContext -> do let ?modelContext = modelContext hspec do - describe "Database" do - it "can execute a query" do - sqlExecDiscardResult "SELECT 1" () + + -- ---------------------------------------------------------------- + -- Hub CRUD + -- ---------------------------------------------------------------- + describe "Hub CRUD" do + it "can create and fetch a hub" do + hub <- newRecord @Hub + |> set #slug "test-hub" + |> set #name "Test Hub" + |> set #domain "test" + |> createRecord + hub.name `shouldBe` "Test Hub" + fetched <- fetch hub.id + fetched.slug `shouldBe` "test-hub" + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Widget CRUD + versioning + -- ---------------------------------------------------------------- + describe "Widget CRUD" do + it "creates widget and records version 1" do + hub <- newRecord @Hub + |> set #slug "w-hub" |> set #name "W Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id + |> set #name "Test Widget" + |> set #widgetType "chart" + |> createRecord + widget.version `shouldBe` 1 + let snapshot = object ["name" .= widget.name] + _ver <- newRecord @WidgetVersion + |> set #widgetId widget.id + |> set #version 1 + |> set #schemaSnapshot snapshot + |> createRecord + versions <- query @WidgetVersion |> filterWhere (#widgetId, widget.id) |> fetch + length versions `shouldBe` 1 + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Interaction event capture + -- ---------------------------------------------------------------- + describe "InteractionEvent capture" do + it "records event with anonymous actor" do + hub <- newRecord @Hub + |> set #slug "ev-hub" |> set #name "Ev Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Ev Widget" |> set #widgetType "form" + |> createRecord + event <- newRecord @InteractionEvent + |> set #widgetId widget.id + |> set #eventType "clicked" + |> set #actorType "anonymous" + |> createRecord + event.eventType `shouldBe` "clicked" + event.actorType `shouldBe` "anonymous" + deleteRecord hub + + it "append-only: UPDATE raises exception" do + hub <- newRecord @Hub + |> set #slug "ao-hub" |> set #name "AO Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "AO Widget" |> set #widgetType "table" + |> createRecord + event <- newRecord @InteractionEvent + |> set #widgetId widget.id + |> set #eventType "viewed" + |> set #actorType "user" + |> createRecord + let attemptUpdate = sqlExec + "UPDATE interaction_events SET event_type = 'tampered' WHERE id = ?" + (Only event.id) + attemptUpdate `shouldThrow` anyException + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Annotation CRUD + threading + -- ---------------------------------------------------------------- + describe "Annotation CRUD" do + it "creates annotation and lists it" do + hub <- newRecord @Hub + |> set #slug "ann-hub" |> set #name "Ann Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Ann Widget" |> set #widgetType "panel" + |> createRecord + ann <- newRecord @Annotation + |> set #widgetId widget.id + |> set #body "This is confusing" + |> set #category "friction" + |> set #actorType "user" + |> createRecord + anns <- query @Annotation |> filterWhere (#widgetId, widget.id) |> fetch + length anns `shouldBe` 1 + (head anns).body `shouldBe` "This is confusing" + deleteRecord hub + + it "supports threaded replies via parent_id" do + hub <- newRecord @Hub + |> set #slug "thread-hub" |> set #name "Thread Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Thread Widget" |> set #widgetType "form" + |> createRecord + root <- newRecord @Annotation + |> set #widgetId widget.id + |> set #body "Root comment" + |> set #category "friction" + |> set #actorType "user" + |> createRecord + reply <- newRecord @Annotation + |> set #widgetId widget.id + |> set #parentId (Just root.id) + |> set #body "Reply to root" + |> set #category "other" + |> set #actorType "user" + |> createRecord + reply.parentId `shouldBe` Just root.id + deleteRecord hub + + -- ---------------------------------------------------------------- + -- Validation: empty body rejected + -- ---------------------------------------------------------------- + describe "Annotation validation" do + it "empty body fails validation" do + hub <- newRecord @Hub + |> set #slug "val-hub" |> set #name "Val Hub" |> set #domain "d" + |> createRecord + widget <- newRecord @Widget + |> set #hubId hub.id |> set #name "Val Widget" |> set #widgetType "form" + |> createRecord + let ann = newRecord @Annotation + |> set #widgetId widget.id + |> set #body "" + |> set #category "friction" + |> validateField #body nonEmpty + isValid ann `shouldBe` False + deleteRecord hub diff --git a/Web/Controller/Annotations.hs b/Web/Controller/Annotations.hs new file mode 100644 index 0000000..d558142 --- /dev/null +++ b/Web/Controller/Annotations.hs @@ -0,0 +1,48 @@ +module Web.Controller.Annotations where + +import Web.Types +import Web.View.Annotations.Index +import Web.View.Annotations.New +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude + +validCategories :: [Text] +validCategories = ["friction", "defect", "wish", "policy_concern", "doc_gap", "trust", "other"] + +instance Controller AnnotationsController where + beforeAction = ensureIsUser + + action WidgetAnnotationsAction { widgetId } = do + widget <- fetch widgetId + annotations <- query @Annotation + |> filterWhere (#widgetId, widgetId) + |> orderByAsc #createdAt + |> fetch + render IndexView { widget, annotations } + + action NewAnnotationAction { widgetId } = do + widget <- fetch widgetId + let annotation = newRecord @Annotation + render NewView { widget, annotation } + + action CreateAnnotationAction { widgetId } = do + widget <- fetch widgetId + mUser <- currentUserOrNothing + let actorId = fmap (.id) mUser + actorType = maybe "anonymous" (const "user") mUser + + let annotation = newRecord @Annotation + annotation + |> fill @'["body", "category", "parentId", "widgetStateRef"] + |> set #widgetId widgetId + |> set #actorId (fmap (Id . unId) actorId) + |> set #actorType actorType + |> validateField #body nonEmpty + |> validateField #category (`elem` validCategories) + |> ifValid \case + Left annotation -> render NewView { widget, annotation } + Right annotation -> do + createRecord annotation + setSuccessMessage "Annotation added" + redirectTo WidgetAnnotationsAction { widgetId } diff --git a/Web/Controller/Hubs.hs b/Web/Controller/Hubs.hs new file mode 100644 index 0000000..bf733f1 --- /dev/null +++ b/Web/Controller/Hubs.hs @@ -0,0 +1,74 @@ +module Web.Controller.Hubs where + +import Web.Types +import Web.View.Hubs.Index +import Web.View.Hubs.Show +import Web.View.Hubs.New +import Web.View.Hubs.Edit +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude + +instance Controller HubsController where + beforeAction = ensureIsUser + + action HubsAction = do + hubs <- query @Hub |> orderByAsc #createdAt |> fetch + render IndexView { hubs } + + action NewHubAction = do + let hub = newRecord @Hub + render NewView { hub } + + action ShowHubAction { hubId } = autoRefresh do + hub <- fetch hubId + widgets <- query @Widget + |> filterWhere (#hubId, hubId) + |> orderByAsc #name + |> fetch + widgetIds <- pure (map (.id) widgets) + recentEvents <- sqlQuery + "SELECT * FROM interaction_events WHERE widget_id = ANY(?) ORDER BY occurred_at DESC LIMIT 50" + (Only (PGArray widgetIds)) + recentAnnotations <- sqlQuery + "SELECT * FROM annotations WHERE widget_id = ANY(?) ORDER BY created_at DESC LIMIT 20" + (Only (PGArray widgetIds)) + render ShowView { hub, widgets, recentEvents, recentAnnotations } + + action CreateHubAction = do + let hub = newRecord @Hub + hub + |> fill @'["slug", "name", "domain"] + |> validateField #slug nonEmpty + |> validateField #name nonEmpty + |> validateField #domain nonEmpty + |> ifValid \case + Left hub -> render NewView { hub } + Right hub -> do + hub <- createRecord hub + setSuccessMessage "Hub created" + redirectTo ShowHubAction { hubId = hub.id } + + action EditHubAction { hubId } = do + hub <- fetch hubId + render EditView { hub } + + action UpdateHubAction { hubId } = do + hub <- fetch hubId + hub + |> fill @'["slug", "name", "domain"] + |> validateField #slug nonEmpty + |> validateField #name nonEmpty + |> validateField #domain nonEmpty + |> ifValid \case + Left hub -> render EditView { hub } + Right hub -> do + updateRecord hub + setSuccessMessage "Hub updated" + redirectTo ShowHubAction { hubId = hub.id } + + action DeleteHubAction { hubId } = do + hub <- fetch hubId + deleteRecord hub + setSuccessMessage "Hub deleted" + redirectTo HubsAction diff --git a/Web/Controller/InteractionEvents.hs b/Web/Controller/InteractionEvents.hs new file mode 100644 index 0000000..9d26e2b --- /dev/null +++ b/Web/Controller/InteractionEvents.hs @@ -0,0 +1,55 @@ +module Web.Controller.InteractionEvents where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude +import Data.Aeson (object, (.=)) +import qualified Data.Text as T + +-- Valid canonical event types +validEventTypes :: [Text] +validEventTypes = + [ "viewed", "clicked", "submitted", "abandoned", "retried", "failed" + , "commented", "flagged_confusing", "flagged_helpful" + , "blocked_by_policy", "escalated" + , "accepted_recommendation", "rejected_recommendation" + ] + +instance Controller InteractionEventsController where + action CreateInteractionEventAction { widgetId } = do + eventType <- param @Text "event_type" + unless (eventType `elem` validEventTypes) do + respondJson (object ["error" .= ("unknown event_type" :: Text), "valid" .= validEventTypes]) + -- IHP stops here; the above respondJson sends 200 but we need 422 + -- Use renderWithStatus for proper 422: + setStatus 422 + respondJson (object ["error" .= ("unknown event_type" :: Text)]) + + mUser <- currentUserOrNothing + let actorId = fmap (.id) mUser + actorType = maybe "anonymous" (const "user") mUser + + actorTypeParam <- paramOrDefault @Text actorType "actor_type" + viewContextRef <- paramOrNothing @Text "view_context_ref" + metadataRaw <- paramOrDefault @Text "{}" "metadata" + + let metadata = case readMay @Value (cs metadataRaw) of + Just v -> v + Nothing -> object [] + + event <- newRecord @InteractionEvent + |> set #widgetId widgetId + |> set #eventType eventType + |> set #actorId (fmap (Id . unId) actorId) + |> set #actorType actorTypeParam + |> set #viewContextRef viewContextRef + |> set #metadata metadata + |> createRecord + + respondJson (object + [ "id" .= event.id + , "widget_id" .= event.widgetId + , "event_type" .= event.eventType + , "occurred_at".= event.occurredAt + ]) diff --git a/Web/Controller/Sessions.hs b/Web/Controller/Sessions.hs new file mode 100644 index 0000000..9cd9d14 --- /dev/null +++ b/Web/Controller/Sessions.hs @@ -0,0 +1,29 @@ +module Web.Controller.Sessions where + +import Web.Types +import Web.View.Sessions.New +import Generated.Types +import IHP.LoginSupport.Helper.Controller +import IHP.Prelude +import IHP.ControllerPrelude + +instance Controller SessionsController where + action NewSessionAction = do + let user = newRecord @User + render NewView { user } + + action CreateSessionAction = do + (user, token) <- authenticate @User + case user of + Just user -> do + setSession "userId" (show user.id) + redirectTo HubsAction + Nothing -> do + setErrorMessage "Invalid email or password" + redirectTo NewSessionAction + + action DeleteSessionAction = do + unsetSession "userId" + redirectTo NewSessionAction + +instance SessionsControllerConfig User diff --git a/Web/Controller/Widgets.hs b/Web/Controller/Widgets.hs new file mode 100644 index 0000000..d63aa08 --- /dev/null +++ b/Web/Controller/Widgets.hs @@ -0,0 +1,106 @@ +module Web.Controller.Widgets where + +import Web.Types +import Web.View.Widgets.Index +import Web.View.Widgets.Show +import Web.View.Widgets.New +import Web.View.Widgets.Edit +import Generated.Types +import IHP.Prelude +import IHP.ControllerPrelude +import Data.Aeson (toJSON, object, (.=)) + +instance Controller WidgetsController where + beforeAction = ensureIsUser + + action WidgetsAction = do + widgets <- query @Widget |> orderByAsc #name |> fetch + hubs <- query @Hub |> fetch + render IndexView { widgets, hubs } + + action NewWidgetAction = do + let widget = newRecord @Widget + hubs <- query @Hub |> fetch + render NewView { widget, hubs } + + action ShowWidgetAction { widgetId } = do + widget <- fetch widgetId + hub <- fetch widget.hubId + versions <- query @WidgetVersion + |> filterWhere (#widgetId, widgetId) + |> orderByDesc #version + |> fetch + events <- query @InteractionEvent + |> filterWhere (#widgetId, widgetId) + |> orderByDesc #occurredAt + |> limit 20 + |> fetch + annotations <- query @Annotation + |> filterWhere (#widgetId, widgetId) + |> orderByAsc #createdAt + |> fetch + render ShowView { widget, hub, versions, events, annotations } + + action CreateWidgetAction = do + let widget = newRecord @Widget + hubs <- query @Hub |> fetch + widget + |> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"] + |> validateField #name nonEmpty + |> validateField #widgetType nonEmpty + |> ifValid \case + Left widget -> render NewView { widget, hubs } + Right widget -> do + widget <- createRecord widget + let snapshot = object + [ "name" .= widget.name + , "widget_type" .= widget.widgetType + , "hub_id" .= widget.hubId + , "capability_ref" .= widget.capabilityRef + , "view_context" .= widget.viewContext + , "policy_scope" .= widget.policyScope + , "status" .= widget.status + , "version" .= widget.version + ] + newRecord @WidgetVersion + |> set #widgetId widget.id + |> set #version 1 + |> set #schemaSnapshot snapshot + |> createRecord + setSuccessMessage "Widget registered" + redirectTo ShowWidgetAction { widgetId = widget.id } + + action EditWidgetAction { widgetId } = do + widget <- fetch widgetId + hubs <- query @Hub |> fetch + render EditView { widget, hubs } + + action UpdateWidgetAction { widgetId } = do + widget <- fetch widgetId + hubs <- query @Hub |> fetch + widget + |> fill @'["name", "widgetType", "hubId", "capabilityRef", "viewContext", "policyScope", "status"] + |> validateField #name nonEmpty + |> validateField #widgetType nonEmpty + |> ifValid \case + Left widget -> render EditView { widget, hubs } + Right widget -> do + let newVersion = widget.version + 1 + widget <- widget |> set #version newVersion |> updateRecord + let snapshot = object + [ "name" .= widget.name + , "widget_type" .= widget.widgetType + , "hub_id" .= widget.hubId + , "capability_ref" .= widget.capabilityRef + , "view_context" .= widget.viewContext + , "policy_scope" .= widget.policyScope + , "status" .= widget.status + , "version" .= newVersion + ] + newRecord @WidgetVersion + |> set #widgetId widget.id + |> set #version newVersion + |> set #schemaSnapshot snapshot + |> createRecord + setSuccessMessage "Widget updated" + redirectTo ShowWidgetAction { widgetId = widget.id } diff --git a/Web/FrontController.hs b/Web/FrontController.hs new file mode 100644 index 0000000..ac69182 --- /dev/null +++ b/Web/FrontController.hs @@ -0,0 +1,57 @@ +module Web.FrontController where + +import IHP.RouterPrelude +import IHP.LoginSupport.Middleware +import Generated.Types +import Web.Types +import Web.Routes () + +-- Controllers +import Web.Controller.Hubs () +import Web.Controller.Widgets () +import Web.Controller.InteractionEvents () +import Web.Controller.Annotations () +import Web.Controller.Sessions () + +instance FrontController WebApplication where + controllers = + [ parseRoute @SessionsController + , parseRoute @HubsController + , parseRoute @WidgetsController + , parseRoute @InteractionEventsController + , parseRoute @AnnotationsController + ] + +instance InitControllerContext WebApplication where + initContext = do + setLayout defaultLayout + initAuthentication @User + +defaultLayout :: Layout +defaultLayout inner = [hsx| + + + + + + inter-hub + {autoRefreshMeta} + + + + + + +
+ {inner} +
+ + +|] diff --git a/Web/Routes.hs b/Web/Routes.hs new file mode 100644 index 0000000..c3caf5b --- /dev/null +++ b/Web/Routes.hs @@ -0,0 +1,20 @@ +module Web.Routes where + +import IHP.RouterPrelude +import Generated.Types +import Web.Types + +-- Hubs +instance AutoRoute HubsController + +-- Widgets +instance AutoRoute WidgetsController + +-- Interaction Events (POST /widgets/:widgetId/events) +instance AutoRoute InteractionEventsController + +-- Annotations (scoped to widget: /widgets/:widgetId/annotations/) +instance AutoRoute AnnotationsController + +-- Sessions +instance AutoRoute SessionsController diff --git a/Web/Types.hs b/Web/Types.hs new file mode 100644 index 0000000..50dffb1 --- /dev/null +++ b/Web/Types.hs @@ -0,0 +1,51 @@ +module Web.Types where + +import IHP.Prelude +import IHP.ModelSupport +import IHP.LoginSupport.Types +import Generated.Types + +-- | Authentication type alias +type CurrentUserRecord = User + +instance HasNewSessionUrl User where + newSessionUrl _ = "/NewSession" + +-- Controllers + +data WebApplication = WebApplication deriving (Eq, Show) + +data HubsController + = HubsAction + | NewHubAction + | ShowHubAction { hubId :: !(Id Hub) } + | CreateHubAction + | EditHubAction { hubId :: !(Id Hub) } + | UpdateHubAction { hubId :: !(Id Hub) } + | DeleteHubAction { hubId :: !(Id Hub) } + deriving (Eq, Show, Data) + +data WidgetsController + = WidgetsAction + | NewWidgetAction + | ShowWidgetAction { widgetId :: !(Id Widget) } + | CreateWidgetAction + | EditWidgetAction { widgetId :: !(Id Widget) } + | UpdateWidgetAction { widgetId :: !(Id Widget) } + deriving (Eq, Show, Data) + +data InteractionEventsController + = CreateInteractionEventAction { widgetId :: !(Id Widget) } + deriving (Eq, Show, Data) + +data AnnotationsController + = WidgetAnnotationsAction { widgetId :: !(Id Widget) } + | NewAnnotationAction { widgetId :: !(Id Widget) } + | CreateAnnotationAction { widgetId :: !(Id Widget) } + deriving (Eq, Show, Data) + +data SessionsController + = NewSessionAction + | CreateSessionAction + | DeleteSessionAction + deriving (Eq, Show, Data) diff --git a/Web/View/Annotations/Index.hs b/Web/View/Annotations/Index.hs new file mode 100644 index 0000000..5eb74c9 --- /dev/null +++ b/Web/View/Annotations/Index.hs @@ -0,0 +1,61 @@ +module Web.View.Annotations.Index where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data IndexView = IndexView + { widget :: !Widget + , annotations :: ![Annotation] + } + +instance View IndexView where + html IndexView { .. } = [hsx| +
+ Widgets + / + {widget.name} + / + Annotations +
+ +
+

Annotations for {widget.name}

+ + Add Annotation + +
+ +
+ {forEach rootAnnotations (renderAnnotation childrenOf)} +
+ |] + where + rootAnnotations = filter (\a -> isNothing a.parentId) annotations + childrenOf parent = filter (\a -> a.parentId == Just parent.id) annotations + +renderAnnotation :: (Annotation -> [Annotation]) -> Annotation -> Html +renderAnnotation childrenOf a = [hsx| +
+
+ + {a.category} + + {a.actorType} + {if isJust a.retractedAt + then [hsx|retracted|] + else mempty} + {show a.createdAt} +
+

{a.body}

+
+ Reply +
+
+ {forEach (childrenOf a) (renderAnnotation childrenOf)} +
+
+|] diff --git a/Web/View/Annotations/New.hs b/Web/View/Annotations/New.hs new file mode 100644 index 0000000..55bab0b --- /dev/null +++ b/Web/View/Annotations/New.hs @@ -0,0 +1,44 @@ +module Web.View.Annotations.New where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data NewView = NewView + { widget :: !Widget + , annotation :: !Annotation + } + +instance View NewView where + html NewView { .. } = [hsx| +
+
+ {widget.name} + / + Annotations + / + New +
+

Add Annotation

+ {renderForm annotation widget.id} +
+ |] + +renderForm :: Annotation -> Id Widget -> Html +renderForm annotation widgetId = formFor annotation [hsx| + {(textareaField #body) { fieldLabel = "Comment" }} + {selectField #category categoryOptions} + {submitButton} +|] + +categoryOptions :: [(Text, Text)] +categoryOptions = + [ ("Friction", "friction") + , ("Defect", "defect") + , ("Wish", "wish") + , ("Policy Concern", "policy_concern") + , ("Documentation Gap", "doc_gap") + , ("Trust", "trust") + , ("Other", "other") + ] diff --git a/Web/View/Hubs/Edit.hs b/Web/View/Hubs/Edit.hs new file mode 100644 index 0000000..e78cdf0 --- /dev/null +++ b/Web/View/Hubs/Edit.hs @@ -0,0 +1,31 @@ +module Web.View.Hubs.Edit where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data EditView = EditView { hub :: !Hub } + +instance View EditView where + html EditView { .. } = [hsx| +
+
+ Hubs + / + {hub.name} + / + Edit +
+

Edit Hub

+ {renderForm hub} +
+ |] + +renderForm :: Hub -> Html +renderForm hub = formFor hub [hsx| + {textField #name} + {(textField #slug) { helpText = "Lowercase, URL-safe identifier" }} + {textField #domain} + {submitButton} +|] diff --git a/Web/View/Hubs/Index.hs b/Web/View/Hubs/Index.hs new file mode 100644 index 0000000..a6621a7 --- /dev/null +++ b/Web/View/Hubs/Index.hs @@ -0,0 +1,56 @@ +module Web.View.Hubs.Index where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data IndexView = IndexView { hubs :: ![Hub] } + +instance View IndexView where + html IndexView { .. } = [hsx| +
+

Hubs

+ + New Hub + +
+
+ + + + + + + + + + + {forEach hubs renderHub} + +
NameSlugDomain
+
+ |] + +renderHub :: Hub -> Html +renderHub hub = [hsx| + + + + {hub.name} + + + {hub.slug} + {hub.domain} + + Edit + Delete + + +|] diff --git a/Web/View/Hubs/New.hs b/Web/View/Hubs/New.hs new file mode 100644 index 0000000..0799d6b --- /dev/null +++ b/Web/View/Hubs/New.hs @@ -0,0 +1,24 @@ +module Web.View.Hubs.New where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data NewView = NewView { hub :: !Hub } + +instance View NewView where + html NewView { .. } = [hsx| +
+

New Hub

+ {renderForm hub} +
+ |] + +renderForm :: Hub -> Html +renderForm hub = formFor hub [hsx| + {textField #name} + {(textField #slug) { helpText = "Lowercase, URL-safe identifier" }} + {textField #domain} + {submitButton} +|] diff --git a/Web/View/Hubs/Show.hs b/Web/View/Hubs/Show.hs new file mode 100644 index 0000000..ce7c42b --- /dev/null +++ b/Web/View/Hubs/Show.hs @@ -0,0 +1,141 @@ +module Web.View.Hubs.Show where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data ShowView = ShowView + { hub :: !Hub + , widgets :: ![Widget] + , recentEvents :: ![InteractionEvent] + , recentAnnotations :: ![Annotation] + } + +instance View ShowView where + html ShowView { .. } = [hsx| +
+
+ Hubs + / + {hub.name} +
+
+
+

{hub.name}

+

+ {hub.slug} + {hub.domain} +

+
+ +
+
+ +
+
+

Widgets

+

{length widgets}

+
+
+

Recent Events

+

{length recentEvents}

+
+
+

Recent Annotations

+

{length recentAnnotations}

+
+
+ +
+

Widgets

+
+ + + + + + + + + + + {forEach widgets renderWidgetRow} + +
NameTypeStatusVersion
+
+
+ +
+

Recent Interaction Events

+
+ + + + + + + + + + {forEach recentEvents renderEventRow} + +
EventActorOccurred
+
+
+ +
+

Recent Annotations

+
+ {forEach recentAnnotations renderAnnotationCard} +
+
+ |] + +renderWidgetRow :: Widget -> Html +renderWidgetRow w = [hsx| + + + + {w.name} + + + {w.widgetType} + + + {w.status} + + + v{show w.version} + +|] + +renderEventRow :: InteractionEvent -> Html +renderEventRow e = [hsx| + + {e.eventType} + {e.actorType} + {show e.occurredAt} + +|] + +renderAnnotationCard :: Annotation -> Html +renderAnnotationCard a = [hsx| +
+
+ {a.category} + {a.actorType} +
+

{a.body}

+
+|] diff --git a/Web/View/Sessions/New.hs b/Web/View/Sessions/New.hs new file mode 100644 index 0000000..fe5c091 --- /dev/null +++ b/Web/View/Sessions/New.hs @@ -0,0 +1,40 @@ +module Web.View.Sessions.New where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data NewView = NewView { user :: !User } + +instance View NewView where + html NewView { .. } = [hsx| +
+

Sign in to inter-hub

+
+ {forEach (getFlashMessages) renderFlash} +
+ + +
+
+ + +
+ +
+
+ |] + +renderFlash :: Text -> Html +renderFlash msg = [hsx| +
{msg}
+|] diff --git a/Web/View/Widgets/Edit.hs b/Web/View/Widgets/Edit.hs new file mode 100644 index 0000000..25d319a --- /dev/null +++ b/Web/View/Widgets/Edit.hs @@ -0,0 +1,27 @@ +module Web.View.Widgets.Edit where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Web.View.Widgets.New (renderForm) + +data EditView = EditView + { widget :: !Widget + , hubs :: ![Hub] + } + +instance View EditView where + html EditView { .. } = [hsx| +
+
+ Widgets + / + {widget.name} + / + Edit +
+

Edit Widget

+ {renderForm widget hubs} +
+ |] diff --git a/Web/View/Widgets/Index.hs b/Web/View/Widgets/Index.hs new file mode 100644 index 0000000..ea0866f --- /dev/null +++ b/Web/View/Widgets/Index.hs @@ -0,0 +1,69 @@ +module Web.View.Widgets.Index where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data IndexView = IndexView + { widgets :: ![Widget] + , hubs :: ![Hub] + } + +instance View IndexView where + html IndexView { .. } = [hsx| +
+

Widgets

+ + Register Widget + +
+
+ + + + + + + + + + + + + {forEach widgets (renderWidget hubs)} + +
NameHubTypeStatusVersion
+
+ |] + +renderWidget :: [Hub] -> Widget -> Html +renderWidget hubs w = [hsx| + + + + {w.name} + + + {hubName hubs w.hubId} + {w.widgetType} + + + {w.status} + + + v{show w.version} + + Edit + + +|] + +hubName :: [Hub] -> Id Hub -> Text +hubName hubs hubId = + case find (\h -> h.id == hubId) hubs of + Just h -> h.name + Nothing -> "—" diff --git a/Web/View/Widgets/New.hs b/Web/View/Widgets/New.hs new file mode 100644 index 0000000..1e0015d --- /dev/null +++ b/Web/View/Widgets/New.hs @@ -0,0 +1,59 @@ +module Web.View.Widgets.New where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude + +data NewView = NewView + { widget :: !Widget + , hubs :: ![Hub] + } + +instance View NewView where + html NewView { .. } = [hsx| +
+

Register Widget

+ {renderForm widget hubs} +
+ |] + +renderForm :: Widget -> [Hub] -> Html +renderForm widget hubs = formFor widget [hsx| + {textField #name} + {selectField #widgetType widgetTypeOptions} + {selectField #hubId (hubOptions hubs)} + {textField #capabilityRef} + {textField #viewContext} + {selectField #policyScope policyScopeOptions} + {selectField #status statusOptions} + {submitButton} +|] + +hubOptions :: [Hub] -> [(Text, Id Hub)] +hubOptions hubs = map (\h -> (h.name, h.id)) hubs + +widgetTypeOptions :: [(Text, Text)] +widgetTypeOptions = + [ ("Chart", "chart") + , ("Form", "form") + , ("Table", "table") + , ("Action", "action") + , ("Panel", "panel") + , ("Navigation", "nav") + , ("Other", "other") + ] + +policyScopeOptions :: [(Text, Text)] +policyScopeOptions = + [ ("Internal", "internal") + , ("Hub", "hub") + , ("Public", "public") + ] + +statusOptions :: [(Text, Text)] +statusOptions = + [ ("Active", "active") + , ("Deprecated", "deprecated") + , ("Draft", "draft") + ] diff --git a/Web/View/Widgets/Show.hs b/Web/View/Widgets/Show.hs new file mode 100644 index 0000000..f3df956 --- /dev/null +++ b/Web/View/Widgets/Show.hs @@ -0,0 +1,162 @@ +module Web.View.Widgets.Show where + +import Web.Types +import Generated.Types +import IHP.Prelude +import IHP.ViewPrelude +import Application.Helper.View (widgetEnvelope) + +data ShowView = ShowView + { widget :: !Widget + , hub :: !Hub + , versions :: ![WidgetVersion] + , events :: ![InteractionEvent] + , annotations :: ![Annotation] + } + +instance View ShowView where + html ShowView { .. } = [hsx| +
+ Hubs + / + {hub.name} + / + {widget.name} +
+ + {widgetEnvelope widget [hsx| +
+
+

{widget.name}

+

+ {widget.widgetType} + {widget.policyScope} + {widget.status} + v{show widget.version} +

+
+ + Edit + +
+ |]} + +
+
+

Total Events

+

{length events}

+
+
+

Annotations

+

{length annotations}

+
+
+

Versions

+

{length versions}

+
+
+ +
+
+
+

Annotations

+ + Add +
+
+ {forEach rootAnnotations (renderAnnotation childrenOf)} +
+
+ +
+

Annotation Breakdown

+
+ {forEach categoryBreakdown renderCategoryRow} +
+
+
+ +
+

Interaction Events

+
+ + + + + + + + + + {forEach events renderEventRow} + +
EventActorOccurred
+
+
+ +
+

Version History

+
+ + + + + + + + + {forEach versions renderVersionRow} + +
VersionRecorded
+
+
+ |] + where + rootAnnotations = filter (\a -> isNothing a.parentId) annotations + childrenOf parent = filter (\a -> a.parentId == Just parent.id) annotations + categoryBreakdown = + [ (cat, length (filter (\a -> a.category == cat) annotations)) + | cat <- ["friction","defect","wish","policy_concern","doc_gap","trust","other"] + , any (\a -> a.category == cat) annotations + ] + +renderAnnotation :: (Annotation -> [Annotation]) -> Annotation -> Html +renderAnnotation childrenOf a = [hsx| +
+
+ {a.category} + {a.actorType} + {show a.createdAt} +
+

{a.body}

+
+ {forEach (childrenOf a) (renderAnnotation childrenOf)} +
+
+|] + +renderEventRow :: InteractionEvent -> Html +renderEventRow e = [hsx| + + {e.eventType} + {e.actorType} + {show e.occurredAt} + +|] + +renderVersionRow :: WidgetVersion -> Html +renderVersionRow v = [hsx| + + v{show v.version} + {show v.createdAt} + +|] + +renderCategoryRow :: (Text, Int) -> Html +renderCategoryRow (cat, count) = [hsx| +
+ {cat} + {show count} +
+|] diff --git a/docs/widget-envelope-convention.md b/docs/widget-envelope-convention.md new file mode 100644 index 0000000..177e2f8 --- /dev/null +++ b/docs/widget-envelope-convention.md @@ -0,0 +1,56 @@ +# Widget Envelope Convention + +Every rendered widget in inter-hub wraps its HSX in the `widgetEnvelope` helper +from `Application.Helper.View`. This injects a stable set of `data-*` attributes +that enable client-side event capture without coupling to implementation details. + +## Usage + +```haskell +import Application.Helper.View (widgetEnvelope) + +-- In any view: +widgetEnvelope widget [hsx| + +|] +``` + +## Emitted HTML + +```html +
+ +
+ Annotate +
+
+``` + +## Attributes + +| Attribute | Source | Purpose | +|-----------|--------|---------| +| `data-widget-id` | `widget.id` | Stable identity for event capture | +| `data-widget-type` | `widget.widgetType` | Semantic role of the widget | +| `data-hub-id` | `widget.hubId` | Which hub owns this widget | +| `data-capability-ref` | `widget.capabilityRef` | Link to hub capability | +| `data-view-context` | `widget.viewContext` | Logical location in the UI | +| `data-policy-scope` | `widget.policyScope` | Governance policy boundary | +| `data-widget-version` | `widget.version` | Version at render time | + +## Rules + +1. **Every interactive hub element** that participates in governance must be wrapped. +2. The `data-widget-id` is the capture key — the event capture endpoint uses it as `widget_id`. +3. Do not add or remove `data-*` attributes without updating both this convention doc and + the event capture client script. +4. The "Annotate" control is always rendered. It links to the full annotation thread for the widget. diff --git a/workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md b/workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md index d8f6f53..4968b9a 100644 --- a/workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md +++ b/workplans/IHUB-WP-0001-ihf-phase1-minimal-interaction-core.md @@ -51,7 +51,7 @@ Reference: `docs/ihp-overview.md`, `docs/ihp-data-and-queries.md`, ```task id: IHUB-WP-0001-T01 -status: todo +status: done priority: high state_hub_task_id: "e9e83628-d485-4163-9467-0d161f6274f3" ``` @@ -72,7 +72,7 @@ Set up the IHP project skeleton for inter-hub: ```task id: IHUB-WP-0001-T02 -status: todo +status: done priority: high state_hub_task_id: "e7254445-1375-44c3-9c59-111215b70692" ``` @@ -123,7 +123,7 @@ CREATE TABLE widget_versions ( ```task id: IHUB-WP-0001-T03 -status: todo +status: done priority: high state_hub_task_id: "dac18955-7b2f-464f-97eb-0733c9163088" ```