generated from coulomb/repo-seed
feat(P6/T02-T03): EnvelopeEmissionContract and InteractionReportingContract
T02: EnvelopeEmissionContractsController (index+show, read-only); widgetEnvelope helper validates against contract v1.0 required attributes with inline warning on missing view-context; adapterStatusBadge helper added to Application.Helper.View. T03: InteractionReportingContractsController (index+show, read-only); API endpoint POST /api/v1/interaction-events with bearer token auth against hub.api_key, contract v1.0 field validation, and 201/422/401 responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
419
.claude/ralph-loop.local.md
Normal file
419
.claude/ralph-loop.local.md
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
---
|
||||||
|
active: true
|
||||||
|
iteration: 1
|
||||||
|
session_id:
|
||||||
|
max_iterations: 20
|
||||||
|
completion_promise: "HEUREKA"
|
||||||
|
workplan_id: IHUB-WP-0006
|
||||||
|
workplan_file: workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md
|
||||||
|
started_at: "2026-03-29T21:00:29Z"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan Status Check — Do This First, Every Iteration
|
||||||
|
|
||||||
|
Read the workplan file at: `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
|
||||||
|
|
||||||
|
Count the task blocks (fenced code blocks with language tag `task`):
|
||||||
|
- How many tasks exist in total?
|
||||||
|
- How many have `status: done`?
|
||||||
|
|
||||||
|
If **every task** has `status: done` AND the frontmatter `status` is `done`:
|
||||||
|
The workplan is complete. Output exactly: <promise>HEUREKA</promise>
|
||||||
|
Do nothing else. Stop here.
|
||||||
|
|
||||||
|
Otherwise: continue with the implementation below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan: IHUB-WP-0006 — IHF Phase 6 — Cross-Framework UI Adaptation Layer
|
||||||
|
**File:** `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md`
|
||||||
|
|
||||||
|
|
||||||
|
# IHF Phase 6 — Cross-Framework UI Adaptation Layer
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Ensure semantic continuity while the UI stack diversifies. Phase 5 established
|
||||||
|
AI-assisted distillation within the IHP server-rendered surface. Phase 6 ensures
|
||||||
|
that widget identity, interaction capture, and annotation capability are
|
||||||
|
preserved when UI components are authored outside of IHP HSX — React, Vue, or
|
||||||
|
any JS-based component — without bypassing the IHF core.
|
||||||
|
|
||||||
|
All Phase 6 artifacts are formal contracts rather than free-form conventions.
|
||||||
|
A widget that participates via an adapter must honour the same identity,
|
||||||
|
traceability, and event-capture obligations as a native IHP widget.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Phases 1–5 are complete. The IHF core (widget registry, interaction events,
|
||||||
|
annotations, requirements, decisions, outcomes, agent assistance) is stable.
|
||||||
|
|
||||||
|
The spec (§Phase 6) calls for:
|
||||||
|
- widget protocol adapters
|
||||||
|
- metadata emission standards
|
||||||
|
- client-side SDKs or thin adapters
|
||||||
|
- cross-framework annotation launcher
|
||||||
|
- standardized interaction reporting interface
|
||||||
|
|
||||||
|
Artifacts introduced: `WidgetAdapterSpec`, `InteractionReportingContract`,
|
||||||
|
`EnvelopeEmissionContract`.
|
||||||
|
|
||||||
|
Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6,
|
||||||
|
`docs/ihp-overview.md`, `docs/ihp-controllers-views-forms.md`.
|
||||||
|
|
||||||
|
## Phase 6 Exit Criteria (from IHF spec §Phase 6)
|
||||||
|
|
||||||
|
- New UI technologies can participate without bypassing the IHF core
|
||||||
|
- Widget identity remains stable across frontend evolution
|
||||||
|
- Annotations and interaction events remain compatible
|
||||||
|
|
||||||
|
## Data Artifacts Introduced (Phase 6)
|
||||||
|
|
||||||
|
`WidgetAdapterSpec`, `InteractionReportingContract`, `EnvelopeEmissionContract`
|
||||||
|
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T01 — Schema: WidgetAdapterSpec, InteractionReportingContract, EnvelopeEmissionContract
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add Phase 6 tables to `Application/Schema.sql` and write migration:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Describes how a specific UI technology (React, Vue, etc.) maps to IHF widget
|
||||||
|
-- protocol obligations — identity, envelope emission, event reporting.
|
||||||
|
CREATE TABLE widget_adapter_specs (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL UNIQUE, -- e.g. "react-18", "vue-3", "web-component"
|
||||||
|
framework TEXT NOT NULL, -- e.g. "react", "vue", "vanilla"
|
||||||
|
version TEXT NOT NULL, -- adapter spec version, e.g. "1.0"
|
||||||
|
envelope_contract_id UUID REFERENCES envelope_emission_contracts(id),
|
||||||
|
reporting_contract_id UUID REFERENCES interaction_reporting_contracts(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
-- status values: draft | active | deprecated
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX widget_adapter_specs_framework_idx ON widget_adapter_specs (framework);
|
||||||
|
CREATE INDEX widget_adapter_specs_status_idx ON widget_adapter_specs (status);
|
||||||
|
|
||||||
|
-- Formalises the rules for how a widget envelope must be emitted:
|
||||||
|
-- which attributes are required, their format, and version.
|
||||||
|
CREATE TABLE envelope_emission_contracts (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0", "1.1"
|
||||||
|
required_attributes JSONB NOT NULL,
|
||||||
|
-- e.g. ["data-widget-id", "data-view-context", "data-hub-id"]
|
||||||
|
optional_attributes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
validation_rules JSONB NOT NULL DEFAULT '{}',
|
||||||
|
-- machine-readable rules: format checks, presence guards
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
-- status values: draft | active | superseded
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Standardised REST interface contract for external event and annotation
|
||||||
|
-- submission — used by non-IHP adapters.
|
||||||
|
CREATE TABLE interaction_reporting_contracts (
|
||||||
|
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
|
||||||
|
contract_version TEXT NOT NULL UNIQUE, -- e.g. "1.0"
|
||||||
|
endpoint_path TEXT NOT NULL, -- e.g. "/api/v1/interaction-events"
|
||||||
|
accepted_event_types JSONB NOT NULL, -- e.g. ["clicked","viewed","submitted"]
|
||||||
|
required_fields JSONB NOT NULL,
|
||||||
|
-- minimum payload: widget_id, hub_id, event_type, occurred_at
|
||||||
|
auth_scheme TEXT NOT NULL DEFAULT 'bearer',
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Link widgets to their adapter spec (null = native IHP widget).
|
||||||
|
ALTER TABLE widgets
|
||||||
|
ADD COLUMN adapter_spec_id UUID REFERENCES widget_adapter_specs(id);
|
||||||
|
|
||||||
|
CREATE INDEX widgets_adapter_spec_id_idx ON widgets (adapter_spec_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exit criteria:** `migrate` runs cleanly; all Phase 6 types available in GHCi.
|
||||||
|
|
||||||
|
|
||||||
|
### T02 — EnvelopeEmissionContract: formalise widgetEnvelope as a versioned contract
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T02
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "298af675-550b-480b-bed6-05efc79cd0c9"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Seed the canonical v1.0 `EnvelopeEmissionContract` record in a migration:
|
||||||
|
- `required_attributes: ["data-widget-id", "data-view-context", "data-hub-id"]`
|
||||||
|
- `optional_attributes: ["data-policy-scope", "data-widget-version"]`
|
||||||
|
- `validation_rules: {data-widget-id: "uuid", data-hub-id: "uuid"}`
|
||||||
|
2. Update the `widgetEnvelope` helper (`Web/View/Helpers.hs` or equivalent) to
|
||||||
|
read the active contract version from DB (or config) and assert required
|
||||||
|
attributes at render time — log a warning (not crash) if any are missing.
|
||||||
|
3. Add `EnvelopeEmissionContractsController`:
|
||||||
|
- `index`: table of contract versions with status badges
|
||||||
|
- `show`: full required/optional attributes and validation rules as formatted
|
||||||
|
JSON panels
|
||||||
|
- Read-only (contracts are immutable once active; a new version supersedes)
|
||||||
|
4. Link from global nav under "Contracts"
|
||||||
|
|
||||||
|
**Exit criteria:** Active contract record exists in DB; widgetEnvelope validates
|
||||||
|
against it; contract index/show pages render correctly.
|
||||||
|
|
||||||
|
|
||||||
|
### T03 — InteractionReportingContract: REST endpoint for external event submission
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T03
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "f2767465-ff00-48be-b2dc-5bf3b179cca9"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Seed the canonical v1.0 `InteractionReportingContract`:
|
||||||
|
- `endpoint_path: "/api/v1/interaction-events"`
|
||||||
|
- `accepted_event_types: ["clicked","viewed","submitted","dismissed","errored"]`
|
||||||
|
- `required_fields: ["widget_id","hub_id","event_type","occurred_at"]`
|
||||||
|
2. Add `Api.InteractionEventsController` (separate from the web controller):
|
||||||
|
- `POST /api/v1/interaction-events` — JSON body, Bearer token auth
|
||||||
|
- Validate payload against the active `InteractionReportingContract`
|
||||||
|
- Create `InteractionEvent` record
|
||||||
|
- Return `201 Created` with `{id, widget_id, event_type}` or `422` with
|
||||||
|
validation errors
|
||||||
|
3. Register the API route in `FrontController.hs`
|
||||||
|
4. Add `InteractionReportingContractsController` (read-only, same pattern as T02)
|
||||||
|
|
||||||
|
**Exit criteria:** `POST /api/v1/interaction-events` with a valid payload creates
|
||||||
|
an `InteractionEvent`; invalid payloads return `422`; contract show page renders.
|
||||||
|
|
||||||
|
|
||||||
|
### T04 — WidgetAdapterSpecsController and registry dashboard
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T04
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Scaffold `WidgetAdapterSpecsController`:
|
||||||
|
- `index`: table of adapters — framework badge, version, status, envelope
|
||||||
|
contract version, reporting contract version
|
||||||
|
- `new` / `create`: register a new adapter spec
|
||||||
|
- `show`: full detail — framework, version, linked contracts, notes, status
|
||||||
|
- `edit` / `update`: update notes and status only (contracts are immutable
|
||||||
|
once linked)
|
||||||
|
- No delete — adapter specs are audit artifacts
|
||||||
|
2. Validation:
|
||||||
|
- `name`, `framework`, `version` required
|
||||||
|
- `status` must be `draft | active | deprecated`
|
||||||
|
3. On widget `new`/`edit` forms: optional `adapter_spec_id` select (null = native)
|
||||||
|
4. On widget show page: if `adapter_spec_id` present, show adapter badge with
|
||||||
|
link to the spec
|
||||||
|
|
||||||
|
**Exit criteria:** Adapter specs can be registered, listed, and viewed; widget
|
||||||
|
form allows adapter assignment; widget show page renders adapter badge.
|
||||||
|
|
||||||
|
|
||||||
|
### T05 — Cross-framework annotation launcher (lightweight JS widget)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T05
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "fea86955-d5e6-4623-b5cc-f422c266c9cf"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Create `static/js/ihf-annotation-launcher.js` — a self-contained vanilla JS
|
||||||
|
module (no framework dependency):
|
||||||
|
- On `DOMContentLoaded`, scan for elements with `data-widget-id` attribute
|
||||||
|
- Inject a small "annotate" trigger (button or icon) adjacent to each
|
||||||
|
enrolled element
|
||||||
|
- On trigger click: open a lightweight inline form (textarea + category
|
||||||
|
select) and POST to `/annotations` (existing IHP endpoint) via `fetch`
|
||||||
|
- On success: show a brief confirmation; on error: show inline error message
|
||||||
|
- Reads `data-hub-id` from the element (or nearest ancestor) for the hub
|
||||||
|
context
|
||||||
|
2. The launcher must work in React-rendered pages where IHP does not own the
|
||||||
|
DOM — it relies solely on `data-widget-id` presence.
|
||||||
|
3. Include as an optional script tag in the IHP layout (`Web/View/Layout.hs`)
|
||||||
|
with a feature flag (`IHP_ANNOTATION_LAUNCHER=true`)
|
||||||
|
4. Document usage in `docs/annotation-launcher.md`
|
||||||
|
|
||||||
|
**Exit criteria:** Launcher script injects annotation triggers on a page with
|
||||||
|
`data-widget-id` elements; annotation POST succeeds; works from a static HTML
|
||||||
|
test page (not IHP-rendered).
|
||||||
|
|
||||||
|
|
||||||
|
### T06 — React adapter specification and reference example
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T06
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "023269d8-9835-40b4-a394-478a0f36eee0"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Register a `react-18` `WidgetAdapterSpec` record (via migration seed or
|
||||||
|
admin UI):
|
||||||
|
- links to envelope v1.0 contract and reporting v1.0 contract
|
||||||
|
- `status = active`
|
||||||
|
2. Create `static/js/ihf-react-adapter.js` — a thin React hook + HOC:
|
||||||
|
- `useWidgetEnvelope(widgetId, hubId, viewContext)` — returns a `ref` and
|
||||||
|
`data-*` props object conforming to the envelope contract
|
||||||
|
- `withWidgetEnvelope(WrappedComponent, widgetId, hubId, viewContext)` — HOC
|
||||||
|
that applies the envelope to the root DOM element
|
||||||
|
- `useInteractionReporter(widgetId, hubId)` — returns a `reportEvent(type)`
|
||||||
|
function that POSTs to `/api/v1/interaction-events`
|
||||||
|
3. Create `docs/react-adapter.md` with usage examples for all three exports
|
||||||
|
4. Add a test fixture page in `static/` demonstrating a React widget using the
|
||||||
|
adapter alongside an IHP-rendered widget on the same page
|
||||||
|
|
||||||
|
**Exit criteria:** `useWidgetEnvelope` emits correct `data-*` attributes;
|
||||||
|
`reportEvent` reaches `/api/v1/interaction-events`; annotation launcher script
|
||||||
|
picks up the React widget's `data-widget-id`; docs written.
|
||||||
|
|
||||||
|
|
||||||
|
### T07 — Adapter compatibility validation dashboard
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T07
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "dc8fa48a-7195-4410-a77e-717b53127c2e"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add `AdapterCompatibilityDashboardAction { hubId }` to `HubsController`
|
||||||
|
(AutoRefresh):
|
||||||
|
- **Adapter summary**: count of registered adapters by status
|
||||||
|
(draft / active / deprecated)
|
||||||
|
- **Widget coverage**: total widgets / native IHP / adapter-backed (per
|
||||||
|
adapter spec), with percentage bars
|
||||||
|
- **Contract versions in use**: which envelope and reporting contract
|
||||||
|
versions are active
|
||||||
|
- **Unassigned widgets**: widgets with no `adapter_spec_id` that have
|
||||||
|
received events from external origins (heuristic: `user_agent` not
|
||||||
|
matching IHP server)
|
||||||
|
- **Stale adapters**: adapter specs with `status=active` but no widgets
|
||||||
|
assigned in the last 30 days
|
||||||
|
2. Link from hub Show page alongside Triage / Governance / Antifragility /
|
||||||
|
Agent dashboards
|
||||||
|
3. Add "Adapters" link to global nav
|
||||||
|
|
||||||
|
**Exit criteria:** Dashboard renders all five panels; live-updates on widget or
|
||||||
|
adapter changes; stale adapter detection works.
|
||||||
|
|
||||||
|
|
||||||
|
### T08 — Phase 6 gate: tests, consistency, docs
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: IHUB-WP-0006-T08
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "90ea4814-7603-4016-be34-d41ae091f7e1"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Integration tests** (`Test/`):
|
||||||
|
- EnvelopeEmissionContract create + fetch (required_attributes, validation_rules)
|
||||||
|
- InteractionReportingContract create + fetch
|
||||||
|
- `POST /api/v1/interaction-events` — valid payload creates InteractionEvent
|
||||||
|
- `POST /api/v1/interaction-events` — missing required field returns 422
|
||||||
|
- WidgetAdapterSpec create + status transition (draft → active → deprecated)
|
||||||
|
- Widget with adapter_spec_id: fetch + show renders adapter badge
|
||||||
|
- Adapter compatibility dashboard: compiles and returns correct widget counts
|
||||||
|
2. **Consistency sync** via State Hub MCP:
|
||||||
|
`check_repo_consistency(repo_slug="inter-hub", fix=True)`
|
||||||
|
3. **Documentation updates:**
|
||||||
|
- Update `SCOPE.md` current state section: Phase 6 complete
|
||||||
|
- Write `docs/phase6-summary.md`: what was built, contract model, adapter
|
||||||
|
pattern, known limitations, Phase 7 readiness
|
||||||
|
4. **Smoke test checklist:**
|
||||||
|
- Register a `react-18` adapter spec via UI
|
||||||
|
- Assign a widget to the adapter
|
||||||
|
- POST a test interaction event via `curl` to `/api/v1/interaction-events`
|
||||||
|
- Verify event appears in widget show page
|
||||||
|
- Open annotation launcher on a page with a React-backed widget
|
||||||
|
- Confirm adapter compatibility dashboard shows correct coverage
|
||||||
|
|
||||||
|
**Exit criteria:** All tests pass; consistency sync reports no errors; smoke
|
||||||
|
test completed; SCOPE.md updated.
|
||||||
|
|
||||||
|
|
||||||
|
## Phase 6 Dependencies
|
||||||
|
|
||||||
|
- Phases 1–5 schema stable (widget registry, interaction events, and annotation
|
||||||
|
model required for adapter integration)
|
||||||
|
- `envelope_emission_contracts` and `interaction_reporting_contracts` must exist
|
||||||
|
before `widget_adapter_specs` (foreign key; T01 handles both in one migration)
|
||||||
|
- Contracts (T01–T03) before adapter spec controller (T04)
|
||||||
|
- Adapter spec controller (T04) before annotation launcher (T05) and React
|
||||||
|
adapter (T06) — widget assignment UI depends on T04
|
||||||
|
- All feature tasks (T01–T07) before gate (T08)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Contracts are immutable once active.** A new version supersedes the old;
|
||||||
|
old versions remain readable for audit. No in-place edits after status=active.
|
||||||
|
- **Native IHP widgets are unaffected.** `adapter_spec_id` is nullable. Existing
|
||||||
|
widgets continue to function exactly as before.
|
||||||
|
- **The JS adapter is a thin client.** It does not embed a framework build
|
||||||
|
pipeline. `ihf-react-adapter.js` is a plain ESM module; consumers bundle it
|
||||||
|
themselves.
|
||||||
|
- **Auth for the reporting API.** Bearer token scheme. In Phase 6 the token
|
||||||
|
is a per-hub API key stored in `hubs.api_key` (add column in T01 migration).
|
||||||
|
Phase 8 (federated) can layer on OAuth.
|
||||||
|
- **No local JS build toolchain added.** Static JS files are served as-is.
|
||||||
|
Phase 6 does not introduce npm, webpack, or esbuild into the IHP project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Work
|
||||||
|
|
||||||
|
- Stay strictly within the scope of the workplan above
|
||||||
|
- Work through tasks in priority order (high → medium → low)
|
||||||
|
- Use TDD where applicable: write a failing test, make it pass, then refactor
|
||||||
|
- Use whatever test runner, linter, and build tools this repository already uses
|
||||||
|
- Consult existing documentation (README, docs/, wiki/, specs/) for context
|
||||||
|
- Document significant architecture decisions as ADRs if the project uses them
|
||||||
|
|
||||||
|
## Updating Task Status
|
||||||
|
|
||||||
|
As you complete each task, edit the workplan file to update its status:
|
||||||
|
|
||||||
|
```
|
||||||
|
status: todo → status: in_progress (when you start it)
|
||||||
|
status: in_progress → status: done (when it is verified complete)
|
||||||
|
```
|
||||||
|
|
||||||
|
When **every task** is `done`, also update the workplan frontmatter:
|
||||||
|
|
||||||
|
```
|
||||||
|
status: active → status: done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
Before marking the workplan done and outputting `<promise>HEUREKA</promise>`,
|
||||||
|
verify all of the following are true:
|
||||||
|
|
||||||
|
1. Every task block in `workplans/IHUB-WP-0006-ihf-phase6-cross-framework-ui-adaptation.md` has `status: done`
|
||||||
|
2. The workplan frontmatter `status` is `done`
|
||||||
|
3. The full test suite passes with no failures
|
||||||
|
4. The codebase passes the project's standard code-quality checks
|
||||||
|
(linting, type checking, formatting — whatever applies to this project)
|
||||||
|
5. Documentation reflects the implemented behaviour
|
||||||
|
|
||||||
|
Output `<promise>HEUREKA</promise>` only when all five are genuinely true.
|
||||||
|
|
||||||
@@ -11,6 +11,15 @@ import Web.Types
|
|||||||
-- client-side event capture script reads to identify the widget without coupling
|
-- client-side event capture script reads to identify the widget without coupling
|
||||||
-- to implementation details.
|
-- to implementation details.
|
||||||
--
|
--
|
||||||
|
-- The envelope is validated against the v1.0 EnvelopeEmissionContract at render
|
||||||
|
-- time. Missing required attributes are surfaced as an inline warning banner
|
||||||
|
-- (development) rather than a hard failure, so layout is preserved in production.
|
||||||
|
--
|
||||||
|
-- Required attributes (contract v1.0):
|
||||||
|
-- data-widget-id — stable UUID from the widget registry
|
||||||
|
-- data-view-context — logical UI location
|
||||||
|
-- data-hub-id — owning hub UUID
|
||||||
|
--
|
||||||
-- Usage:
|
-- Usage:
|
||||||
--
|
--
|
||||||
-- @
|
-- @
|
||||||
@@ -18,10 +27,10 @@ import Web.Types
|
|||||||
-- <button>Click me</button>
|
-- <button>Click me</button>
|
||||||
-- |]
|
-- |]
|
||||||
-- @
|
-- @
|
||||||
--
|
|
||||||
-- See docs/widget-envelope-convention.md for the full convention.
|
|
||||||
widgetEnvelope :: Widget -> Html -> Html
|
widgetEnvelope :: Widget -> Html -> Html
|
||||||
widgetEnvelope widget inner = [hsx|
|
widgetEnvelope widget inner =
|
||||||
|
let warnings = envelopeContractWarnings widget
|
||||||
|
in [hsx|
|
||||||
<div
|
<div
|
||||||
class="ihf-widget"
|
class="ihf-widget"
|
||||||
data-widget-id={tshow widget.id}
|
data-widget-id={tshow widget.id}
|
||||||
@@ -32,6 +41,7 @@ widgetEnvelope widget inner = [hsx|
|
|||||||
data-policy-scope={widget.policyScope}
|
data-policy-scope={widget.policyScope}
|
||||||
data-widget-version={tshow widget.version}
|
data-widget-version={tshow widget.version}
|
||||||
>
|
>
|
||||||
|
{renderEnvelopeWarnings warnings}
|
||||||
{inner}
|
{inner}
|
||||||
<div class="ihf-widget-controls mt-2">
|
<div class="ihf-widget-controls mt-2">
|
||||||
<a href={WidgetAnnotationsAction { widgetId = widget.id }}
|
<a href={WidgetAnnotationsAction { widgetId = widget.id }}
|
||||||
@@ -42,3 +52,32 @@ widgetEnvelope widget inner = [hsx|
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
-- | Validate a Widget record against EnvelopeEmissionContract v1.0 required
|
||||||
|
-- attributes. Returns a list of human-readable warning messages for any
|
||||||
|
-- attribute that is missing or empty. An empty list means the widget is
|
||||||
|
-- contract-compliant.
|
||||||
|
envelopeContractWarnings :: Widget -> [Text]
|
||||||
|
envelopeContractWarnings widget = catMaybes
|
||||||
|
[ if isNothing widget.viewContext || widget.viewContext == Just ""
|
||||||
|
then Just "envelope:v1.0 — data-view-context is missing (set widget.viewContext)"
|
||||||
|
else Nothing
|
||||||
|
-- data-widget-id and data-hub-id are always present (non-nullable fields)
|
||||||
|
]
|
||||||
|
|
||||||
|
renderEnvelopeWarnings :: [Text] -> Html
|
||||||
|
renderEnvelopeWarnings [] = mempty
|
||||||
|
renderEnvelopeWarnings ws = [hsx|
|
||||||
|
<div class="bg-amber-50 border border-amber-200 rounded px-3 py-1 mb-1 text-xs text-amber-700">
|
||||||
|
<strong>Envelope contract warning:</strong>
|
||||||
|
{forEach ws (\w -> [hsx|<div>{w}</div>|])}
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
-- | Status badge colour for WidgetAdapterSpec and contract status values.
|
||||||
|
adapterStatusBadge :: Text -> Text
|
||||||
|
adapterStatusBadge "active" = "bg-green-100 text-green-800"
|
||||||
|
adapterStatusBadge "draft" = "bg-yellow-100 text-yellow-800"
|
||||||
|
adapterStatusBadge "deprecated" = "bg-gray-100 text-gray-500"
|
||||||
|
adapterStatusBadge "superseded" = "bg-gray-100 text-gray-400"
|
||||||
|
adapterStatusBadge _ = "bg-gray-100 text-gray-600"
|
||||||
|
|||||||
104
Web/Controller/ApiInteractionEvents.hs
Normal file
104
Web/Controller/ApiInteractionEvents.hs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
module Web.Controller.ApiInteractionEvents where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ControllerPrelude
|
||||||
|
import Data.Aeson (object, (.=))
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Network.Wai (requestMethod, requestHeaders)
|
||||||
|
|
||||||
|
-- | Accepted event types per InteractionReportingContract v1.0
|
||||||
|
apiAcceptedEventTypes :: [Text]
|
||||||
|
apiAcceptedEventTypes = ["clicked", "viewed", "submitted", "dismissed", "errored"]
|
||||||
|
|
||||||
|
instance Controller ApiInteractionEventsController where
|
||||||
|
|
||||||
|
action CreateApiInteractionEventAction = do
|
||||||
|
-- Method guard — only POST accepted.
|
||||||
|
when (requestMethod ?request /= "POST") do
|
||||||
|
setStatus 405
|
||||||
|
respondJson (object ["error" .= ("Method not allowed" :: Text)])
|
||||||
|
|
||||||
|
-- Bearer token auth — validate against hub.api_key.
|
||||||
|
let authHeader = lookup "Authorization" (requestHeaders ?request)
|
||||||
|
let mApiKey = authHeader >>= \h ->
|
||||||
|
let t = cs h :: Text
|
||||||
|
in if "Bearer " `T.isPrefixOf` t
|
||||||
|
then Just (T.drop 7 t)
|
||||||
|
else Nothing
|
||||||
|
|
||||||
|
case mApiKey of
|
||||||
|
Nothing -> do
|
||||||
|
setStatus 401
|
||||||
|
respondJson (object ["error" .= ("Authorization: Bearer <hub-api-key> required" :: Text)])
|
||||||
|
Just apiKey -> do
|
||||||
|
mHub <- query @Hub
|
||||||
|
|> filterWhere (#apiKey, Just apiKey)
|
||||||
|
|> fetchOneOrNothing
|
||||||
|
case mHub of
|
||||||
|
Nothing -> do
|
||||||
|
setStatus 401
|
||||||
|
respondJson (object ["error" .= ("Invalid or unknown API key" :: Text)])
|
||||||
|
Just hub -> createEventForHub hub
|
||||||
|
|
||||||
|
createEventForHub :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Hub -> IO ResponseReceived
|
||||||
|
createEventForHub hub = do
|
||||||
|
-- Validate required fields per contract v1.0
|
||||||
|
widgetIdText <- paramOrNothing @Text "widget_id"
|
||||||
|
eventType <- paramOrNothing @Text "event_type"
|
||||||
|
_occurredAt <- paramOrNothing @Text "occurred_at"
|
||||||
|
|
||||||
|
let missing = catMaybes
|
||||||
|
[ if isNothing widgetIdText then Just ("widget_id" :: Text) else Nothing
|
||||||
|
, if isNothing eventType then Just "event_type" else Nothing
|
||||||
|
, if isNothing _occurredAt then Just "occurred_at" else Nothing
|
||||||
|
]
|
||||||
|
|
||||||
|
unless (null missing) do
|
||||||
|
setStatus 422
|
||||||
|
respondJson (object
|
||||||
|
[ "error" .= ("Missing required fields" :: Text)
|
||||||
|
, "missing" .= missing
|
||||||
|
])
|
||||||
|
|
||||||
|
let Just wIdText = widgetIdText
|
||||||
|
Just evType = eventType
|
||||||
|
|
||||||
|
unless (evType `elem` apiAcceptedEventTypes) do
|
||||||
|
setStatus 422
|
||||||
|
respondJson (object
|
||||||
|
[ "error" .= ("Unacceptable event_type" :: Text)
|
||||||
|
, "accepted" .= apiAcceptedEventTypes
|
||||||
|
])
|
||||||
|
|
||||||
|
-- Resolve widget — must belong to this hub.
|
||||||
|
case readMay wIdText of
|
||||||
|
Nothing -> do
|
||||||
|
setStatus 422
|
||||||
|
respondJson (object ["error" .= ("widget_id must be a valid UUID" :: Text)])
|
||||||
|
Just rawId -> do
|
||||||
|
let wId = Id rawId :: Id Widget
|
||||||
|
mWidget <- fetchOneOrNothing wId
|
||||||
|
case mWidget of
|
||||||
|
Nothing -> do
|
||||||
|
setStatus 422
|
||||||
|
respondJson (object ["error" .= ("Widget not found" :: Text)])
|
||||||
|
Just widget -> do
|
||||||
|
when (widget.hubId /= hub.id) do
|
||||||
|
setStatus 403
|
||||||
|
respondJson (object ["error" .= ("Widget does not belong to this hub" :: Text)])
|
||||||
|
|
||||||
|
event <- newRecord @InteractionEvent
|
||||||
|
|> set #widgetId widget.id
|
||||||
|
|> set #eventType evType
|
||||||
|
|> set #actorType "external_adapter"
|
||||||
|
|> createRecord
|
||||||
|
|
||||||
|
setStatus 201
|
||||||
|
respondJson (object
|
||||||
|
[ "id" .= event.id
|
||||||
|
, "widget_id" .= event.widgetId
|
||||||
|
, "event_type" .= event.eventType
|
||||||
|
, "occurred_at" .= event.occurredAt
|
||||||
|
])
|
||||||
21
Web/Controller/EnvelopeEmissionContracts.hs
Normal file
21
Web/Controller/EnvelopeEmissionContracts.hs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module Web.Controller.EnvelopeEmissionContracts where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Web.View.EnvelopeEmissionContracts.Index
|
||||||
|
import Web.View.EnvelopeEmissionContracts.Show
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ControllerPrelude
|
||||||
|
|
||||||
|
instance Controller EnvelopeEmissionContractsController where
|
||||||
|
beforeAction = ensureIsUser
|
||||||
|
|
||||||
|
action EnvelopeEmissionContractsAction = do
|
||||||
|
contracts <- query @EnvelopeEmissionContract
|
||||||
|
|> orderByDesc #createdAt
|
||||||
|
|> fetch
|
||||||
|
render IndexView { contracts }
|
||||||
|
|
||||||
|
action ShowEnvelopeEmissionContractAction { envelopeEmissionContractId } = do
|
||||||
|
contract <- fetch envelopeEmissionContractId
|
||||||
|
render ShowView { contract }
|
||||||
21
Web/Controller/InteractionReportingContracts.hs
Normal file
21
Web/Controller/InteractionReportingContracts.hs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module Web.Controller.InteractionReportingContracts where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Web.View.InteractionReportingContracts.Index
|
||||||
|
import Web.View.InteractionReportingContracts.Show
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ControllerPrelude
|
||||||
|
|
||||||
|
instance Controller InteractionReportingContractsController where
|
||||||
|
beforeAction = ensureIsUser
|
||||||
|
|
||||||
|
action InteractionReportingContractsAction = do
|
||||||
|
contracts <- query @InteractionReportingContract
|
||||||
|
|> orderByDesc #createdAt
|
||||||
|
|> fetch
|
||||||
|
render IndexView { contracts }
|
||||||
|
|
||||||
|
action ShowInteractionReportingContractAction { interactionReportingContractId } = do
|
||||||
|
contract <- fetch interactionReportingContractId
|
||||||
|
render ShowView { contract }
|
||||||
@@ -17,6 +17,7 @@ import Web.Controller.Requirements ()
|
|||||||
import Web.Controller.DecisionRecords ()
|
import Web.Controller.DecisionRecords ()
|
||||||
import Web.Controller.DeploymentRecords ()
|
import Web.Controller.DeploymentRecords ()
|
||||||
import Web.Controller.AgentProposals ()
|
import Web.Controller.AgentProposals ()
|
||||||
|
import Web.Controller.ApiInteractionEvents ()
|
||||||
import Web.Controller.EnvelopeEmissionContracts ()
|
import Web.Controller.EnvelopeEmissionContracts ()
|
||||||
import Web.Controller.InteractionReportingContracts ()
|
import Web.Controller.InteractionReportingContracts ()
|
||||||
import Web.Controller.WidgetAdapterSpecs ()
|
import Web.Controller.WidgetAdapterSpecs ()
|
||||||
@@ -35,6 +36,7 @@ instance FrontController WebApplication where
|
|||||||
, parseRoute @DecisionRecordsController
|
, parseRoute @DecisionRecordsController
|
||||||
, parseRoute @DeploymentRecordsController
|
, parseRoute @DeploymentRecordsController
|
||||||
, parseRoute @AgentProposalsController
|
, parseRoute @AgentProposalsController
|
||||||
|
, parseRoute @ApiInteractionEventsController
|
||||||
, parseRoute @EnvelopeEmissionContractsController
|
, parseRoute @EnvelopeEmissionContractsController
|
||||||
, parseRoute @InteractionReportingContractsController
|
, parseRoute @InteractionReportingContractsController
|
||||||
, parseRoute @WidgetAdapterSpecsController
|
, parseRoute @WidgetAdapterSpecsController
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ instance AutoRoute DeploymentRecordsController
|
|||||||
instance AutoRoute AgentProposalsController
|
instance AutoRoute AgentProposalsController
|
||||||
|
|
||||||
-- Phase 6 — Cross-Framework UI Adaptation
|
-- Phase 6 — Cross-Framework UI Adaptation
|
||||||
|
|
||||||
|
-- API endpoint: POST /api/v1/interaction-events
|
||||||
|
instance CanRoute ApiInteractionEventsController where
|
||||||
|
parseRoute' = do
|
||||||
|
_ <- string "/api"
|
||||||
|
_ <- string "/v1"
|
||||||
|
_ <- string "/interaction-events"
|
||||||
|
endOfInput
|
||||||
|
pure CreateApiInteractionEventAction
|
||||||
|
|
||||||
|
instance HasPath ApiInteractionEventsController where
|
||||||
|
pathTo CreateApiInteractionEventAction = "/api/v1/interaction-events"
|
||||||
|
|
||||||
instance AutoRoute EnvelopeEmissionContractsController
|
instance AutoRoute EnvelopeEmissionContractsController
|
||||||
instance AutoRoute InteractionReportingContractsController
|
instance AutoRoute InteractionReportingContractsController
|
||||||
instance AutoRoute WidgetAdapterSpecsController
|
instance AutoRoute WidgetAdapterSpecsController
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ data AgentProposalsController
|
|||||||
| RejectProposalAction { agentProposalId :: !(Id AgentProposal) }
|
| RejectProposalAction { agentProposalId :: !(Id AgentProposal) }
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
|
data ApiInteractionEventsController
|
||||||
|
= CreateApiInteractionEventAction
|
||||||
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data EnvelopeEmissionContractsController
|
data EnvelopeEmissionContractsController
|
||||||
= EnvelopeEmissionContractsAction
|
= EnvelopeEmissionContractsAction
|
||||||
| ShowEnvelopeEmissionContractAction { envelopeEmissionContractId :: !(Id EnvelopeEmissionContract) }
|
| ShowEnvelopeEmissionContractAction { envelopeEmissionContractId :: !(Id EnvelopeEmissionContract) }
|
||||||
|
|||||||
67
Web/View/EnvelopeEmissionContracts/Index.hs
Normal file
67
Web/View/EnvelopeEmissionContracts/Index.hs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
module Web.View.EnvelopeEmissionContracts.Index where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.View (adapterStatusBadge)
|
||||||
|
|
||||||
|
data IndexView = IndexView
|
||||||
|
{ contracts :: ![EnvelopeEmissionContract]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View IndexView where
|
||||||
|
html IndexView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Envelope Emission Contracts</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Formalises which <code>data-*</code> attributes every widget envelope must emit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href={InteractionReportingContractsAction}
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
→ Reporting Contracts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if null contracts
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No contracts found.</p>|]
|
||||||
|
else renderTable contracts}
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderTable :: [EnvelopeEmissionContract] -> Html
|
||||||
|
renderTable contracts = [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Version</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Required Attributes</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach contracts renderRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderRow :: EnvelopeEmissionContract -> Html
|
||||||
|
renderRow c = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href={ShowEnvelopeEmissionContractAction { envelopeEmissionContractId = c.id }}
|
||||||
|
class="font-mono text-indigo-600 hover:underline">v{c.contractVersion}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-600 font-mono text-xs">{tshow c.requiredAttributes}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class={adapterStatusBadge c.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-400 text-xs">{show c.createdAt}</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
59
Web/View/EnvelopeEmissionContracts/Show.hs
Normal file
59
Web/View/EnvelopeEmissionContracts/Show.hs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
module Web.View.EnvelopeEmissionContracts.Show where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.View (adapterStatusBadge)
|
||||||
|
|
||||||
|
data ShowView = ShowView
|
||||||
|
{ contract :: !EnvelopeEmissionContract
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View ShowView where
|
||||||
|
html ShowView { .. } = [hsx|
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href={EnvelopeEmissionContractsAction} class="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
← Envelope Contracts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">
|
||||||
|
Envelope Contract <span class="font-mono">v{contract.contractVersion}</span>
|
||||||
|
</h1>
|
||||||
|
<span class={adapterStatusBadge contract.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{contract.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forEach (contractDescription contract) (\d -> [hsx|
|
||||||
|
<p class="text-sm text-gray-600 mb-6">{d}</p>
|
||||||
|
|])}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Required Attributes</h2>
|
||||||
|
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.requiredAttributes}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Optional Attributes</h2>
|
||||||
|
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.optionalAttributes}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Validation Rules</h2>
|
||||||
|
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.validationRules}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-xs text-gray-400">
|
||||||
|
Created: {show contract.createdAt}
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
contractDescription :: EnvelopeEmissionContract -> [Text]
|
||||||
|
contractDescription c = case c.description of
|
||||||
|
Just d -> [d]
|
||||||
|
Nothing -> []
|
||||||
69
Web/View/InteractionReportingContracts/Index.hs
Normal file
69
Web/View/InteractionReportingContracts/Index.hs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
module Web.View.InteractionReportingContracts.Index where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.View (adapterStatusBadge)
|
||||||
|
|
||||||
|
data IndexView = IndexView
|
||||||
|
{ contracts :: ![InteractionReportingContract]
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View IndexView where
|
||||||
|
html IndexView { .. } = [hsx|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">Interaction Reporting Contracts</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Defines the REST endpoint and payload schema for external adapter event submission.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href={EnvelopeEmissionContractsAction}
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
← Envelope Contracts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if null contracts
|
||||||
|
then [hsx|<p class="text-sm text-gray-400">No contracts found.</p>|]
|
||||||
|
else renderTable contracts}
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderTable :: [InteractionReportingContract] -> Html
|
||||||
|
renderTable contracts = [hsx|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Version</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Endpoint</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Auth</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{forEach contracts renderRow}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
renderRow :: InteractionReportingContract -> Html
|
||||||
|
renderRow c = [hsx|
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href={ShowInteractionReportingContractAction { interactionReportingContractId = c.id }}
|
||||||
|
class="font-mono text-indigo-600 hover:underline">v{c.contractVersion}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-gray-700">{c.endpointPath}</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-500">{c.authScheme}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class={adapterStatusBadge c.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-400 text-xs">{show c.createdAt}</td>
|
||||||
|
</tr>
|
||||||
|
|]
|
||||||
76
Web/View/InteractionReportingContracts/Show.hs
Normal file
76
Web/View/InteractionReportingContracts/Show.hs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
module Web.View.InteractionReportingContracts.Show where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ViewPrelude
|
||||||
|
import Application.Helper.View (adapterStatusBadge)
|
||||||
|
|
||||||
|
data ShowView = ShowView
|
||||||
|
{ contract :: !InteractionReportingContract
|
||||||
|
}
|
||||||
|
|
||||||
|
instance View ShowView where
|
||||||
|
html ShowView { .. } = [hsx|
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href={InteractionReportingContractsAction} class="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
← Reporting Contracts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold">
|
||||||
|
Reporting Contract <span class="font-mono">v{contract.contractVersion}</span>
|
||||||
|
</h1>
|
||||||
|
<span class={adapterStatusBadge contract.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||||
|
{contract.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forEach (contractDescription contract) (\d -> [hsx|
|
||||||
|
<p class="text-sm text-gray-600 mb-6">{d}</p>
|
||||||
|
|])}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Endpoint</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="bg-green-100 text-green-800 text-xs font-bold px-2 py-0.5 rounded">POST</span>
|
||||||
|
<code class="text-sm text-gray-800">{contract.endpointPath}</code>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-500">Auth: <code>{contract.authScheme}</code></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Required Fields</h2>
|
||||||
|
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.requiredFields}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 mb-3">Accepted Event Types</h2>
|
||||||
|
<pre class="text-xs bg-gray-50 rounded p-3 overflow-auto">{tshow contract.acceptedEventTypes}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 bg-blue-50 border border-blue-200 rounded p-4 text-sm">
|
||||||
|
<h3 class="font-semibold text-blue-800 mb-2">Example Request</h3>
|
||||||
|
<pre class="text-xs text-blue-900 overflow-auto">curl -X POST {contract.endpointPath} \
|
||||||
|
-H "Authorization: Bearer <hub-api-key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"{"}
|
||||||
|
"widget_id": "<uuid>",
|
||||||
|
"hub_id": "<uuid>",
|
||||||
|
"event_type": "clicked",
|
||||||
|
"occurred_at": "2026-03-29T12:00:00Z"
|
||||||
|
{"}"}'</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-xs text-gray-400">
|
||||||
|
Created: {show contract.createdAt}
|
||||||
|
</div>
|
||||||
|
|]
|
||||||
|
|
||||||
|
contractDescription :: InteractionReportingContract -> [Text]
|
||||||
|
contractDescription c = case c.description of
|
||||||
|
Just d -> [d]
|
||||||
|
Nothing -> []
|
||||||
@@ -62,7 +62,7 @@ Reference: `specs/InteractionHubFrameworkSpecification_v0.1.md` §Phase 6,
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T01
|
id: IHUB-WP-0006-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
|
state_hub_task_id: "8d92f9d5-ec3c-4d9b-b16c-26f938a306e7"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user