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| +
+|] 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| + + + + + +{a.body}
+| Name | +Slug | +Domain | ++ |
|---|
+ {hub.slug} + {hub.domain} +
+Widgets
+{length widgets}
+Recent Events
+{length recentEvents}
+Recent Annotations
+{length recentAnnotations}
+| Name | +Type | +Status | +Version | +
|---|
| Event | +Actor | +Occurred | +
|---|
{a.body}
+| Name | +Hub | +Type | +Status | +Version | ++ |
|---|
+ {widget.widgetType} + {widget.policyScope} + {widget.status} + v{show widget.version} +
+Total Events
+{length events}
+Annotations
+{length annotations}
+Versions
+{length versions}
+| Event | +Actor | +Occurred | +
|---|
| Version | +Recorded | +
|---|
{a.body}
+