From 9bc24536b5355bfed9d979651b46f4c61d663fa8 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:39:30 +0200 Subject: [PATCH 01/11] Add SessionFingerprint struct for typed session fingerprinting Convert plain map to enforced struct with @type t() spec for compile-time key validation in the session hijacking detection path. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- lib/phoenix_kit/users/auth.ex | 2 +- lib/phoenix_kit/users/auth/user_token.ex | 4 ++-- lib/phoenix_kit/utils/session_fingerprint.ex | 14 +++++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex index bfaa6fd5..d1e9b940 100644 --- a/lib/phoenix_kit/users/auth.ex +++ b/lib/phoenix_kit/users/auth.ex @@ -888,7 +888,7 @@ defmodule PhoenixKit.Users.Auth do ## Options - * `:fingerprint` - Optional session fingerprint map with `:ip_address` and `:user_agent_hash` + * `:fingerprint` - Optional `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash` ## Examples diff --git a/lib/phoenix_kit/users/auth/user_token.ex b/lib/phoenix_kit/users/auth/user_token.ex index bb304526..1b867fdf 100644 --- a/lib/phoenix_kit/users/auth/user_token.ex +++ b/lib/phoenix_kit/users/auth/user_token.ex @@ -83,7 +83,7 @@ defmodule PhoenixKit.Users.Auth.UserToken do ## Options - * `:fingerprint` - A map with `:ip_address` and `:user_agent_hash` keys + * `:fingerprint` - A `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash` fields ## Examples @@ -91,7 +91,7 @@ defmodule PhoenixKit.Users.Auth.UserToken do {token, user_token} = build_session_token(user) # With fingerprinting - fingerprint = %{ip_address: "192.168.1.1", user_agent_hash: "abc123"} + fingerprint = SessionFingerprint.create_fingerprint(conn) {token, user_token} = build_session_token(user, fingerprint: fingerprint) """ diff --git a/lib/phoenix_kit/utils/session_fingerprint.ex b/lib/phoenix_kit/utils/session_fingerprint.ex index 10b7c395..cfeb263e 100644 --- a/lib/phoenix_kit/utils/session_fingerprint.ex +++ b/lib/phoenix_kit/utils/session_fingerprint.ex @@ -40,19 +40,27 @@ defmodule PhoenixKit.Utils.SessionFingerprint do @hash_algorithm :sha256 + @enforce_keys [:ip_address, :user_agent_hash] + defstruct [:ip_address, :user_agent_hash] + + @type t :: %__MODULE__{ + ip_address: String.t(), + user_agent_hash: String.t() + } + @doc """ Creates a session fingerprint from a Plug.Conn connection. - Returns a map with `:ip_address` and `:user_agent_hash` keys. + Returns a `%SessionFingerprint{}` struct with `:ip_address` and `:user_agent_hash` fields. ## Examples iex> create_fingerprint(conn) - %{ip_address: "192.168.1.1", user_agent_hash: "a1b2c3d4..."} + %SessionFingerprint{ip_address: "192.168.1.1", user_agent_hash: "a1b2c3d4..."} """ def create_fingerprint(conn) do - %{ + %__MODULE__{ ip_address: get_ip_address(conn), user_agent_hash: hash_user_agent(conn) } From c3dc1bb97b9e90f250a989143b9df10933afcd7b Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:42:27 +0200 Subject: [PATCH 02/11] Add IbanData struct for typed IBAN country specifications Adds defstruct with @enforce_keys and @type t() for compile-time validation. Adds get_spec/1 returning typed struct per country. Internal @iban_specs stays as plain maps due to module attribute compile order. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- lib/modules/billing/utils/iban_data.ex | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/modules/billing/utils/iban_data.ex b/lib/modules/billing/utils/iban_data.ex index bee614ff..2b7c19bd 100644 --- a/lib/modules/billing/utils/iban_data.ex +++ b/lib/modules/billing/utils/iban_data.ex @@ -17,6 +17,14 @@ defmodule PhoenixKit.Modules.Billing.IbanData do false """ + @enforce_keys [:length, :sepa] + defstruct [:length, :sepa] + + @type t :: %__MODULE__{ + length: pos_integer(), + sepa: boolean() + } + @iban_specs %{ # EU/EEA SEPA Countries "AD" => %{length: 24, sepa: true}, @@ -164,10 +172,28 @@ defmodule PhoenixKit.Modules.Billing.IbanData do def country_uses_iban?(_), do: false + @doc """ + Get the IBAN specification for a country. + + Returns a `%IbanData{}` struct or nil if the country does not use IBAN. + """ + def get_spec(country_code) when is_binary(country_code) do + case Map.get(@iban_specs, String.upcase(country_code)) do + %{length: length, sepa: sepa} -> %__MODULE__{length: length, sepa: sepa} + _ -> nil + end + end + + def get_spec(_), do: nil + @doc """ Get all IBAN specifications. - Returns a map of country codes to their IBAN specifications. + Returns a map of country codes to `%IbanData{}` structs. """ - def all_specs, do: @iban_specs + def all_specs do + Map.new(@iban_specs, fn {code, %{length: length, sepa: sepa}} -> + {code, %__MODULE__{length: length, sepa: sepa}} + end) + end end From de1cade75534e445324eea053ad5c520108448d2 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:44:41 +0200 Subject: [PATCH 03/11] Update struct vs map audit doc with completed conversions Mark SessionFingerprint and IbanData as done, add to completed structs table, update summary counts (2/21 complete). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- dev_docs/2026-02-16-struct-vs-map-audit.md | 371 +++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 dev_docs/2026-02-16-struct-vs-map-audit.md diff --git a/dev_docs/2026-02-16-struct-vs-map-audit.md b/dev_docs/2026-02-16-struct-vs-map-audit.md new file mode 100644 index 00000000..d1430730 --- /dev/null +++ b/dev_docs/2026-02-16-struct-vs-map-audit.md @@ -0,0 +1,371 @@ +# Struct vs Map Audit Report + +**Date:** 2026-02-16 +**Last Recheck:** 2026-02-17 (post v1.7.41 pull — all items unchanged, no new patterns found) +**Severity:** Medium (data contract clarity, developer experience) +**Recommendation:** Convert high-traffic cross-module maps to structs; leave infrastructure/boundary maps as-is + +--- + +## Why This Audit Exists + +The Language module refactor (merged 2026-02-15) replaced a heavily-used `%{code, name, native_name, flag}` plain map with a proper `%Language{}` struct. That single change touched 15+ files because every consumer had to be updated. The question: **how many more plain maps are acting as de-facto structs across the codebase?** + +Structs matter because: +- **Compile-time key checks** — `%Foo{bar: 1}` fails at compile time if `bar` isn't a field; `%{bar: 1}` silently passes +- **Pattern matching safety** — `%Foo{}` in function heads rejects wrong-shaped data; `%{}` matches anything +- **Documentation** — `defstruct` is self-documenting; a map buried in a function body requires reading the code +- **Dialyzer coverage** — `@type t :: %__MODULE__{}` gives Dialyzer something to check + +--- + +## What's Already Done + +These data contracts already use proper structs: + +| Struct | File | Fields | Notes | +|--------|------|--------|-------| +| `Dashboard.Tab` | `lib/phoenix_kit/dashboard/tab.ex` | 27 | Full validation via `new!/1`, path matching, visibility functions | +| `Dashboard.Badge` | `lib/phoenix_kit/dashboard/badge.ex` | 16 | PubSub subscriptions, compound segments, pulse animations | +| `Dashboard.ContextSelector` | `lib/phoenix_kit/dashboard/context_selector.ex` | 16 | Dependencies, position control, session keys | +| `Sitemap.UrlEntry` | `lib/modules/sitemap/url_entry.ex` | 9 | XML serialization, hreflang alternates | +| `Shop.OptionState` | `lib/modules/shop/web/option_state.ex` | 5 | Interactive form state with modifiers | +| `Users.Auth.Scope` | `lib/phoenix_kit/users/auth/scope.ex` | 4 | Immutable auth context, permission MapSet | +| `Languages.Language` | `lib/modules/languages/language.ex` | 4 | Recently refactored from plain map | +| `Utils.SessionFingerprint` | `lib/phoenix_kit/utils/session_fingerprint.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | +| `Billing.IbanData` | `lib/modules/billing/utils/iban_data.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | + +All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also proper structs by virtue of `use Ecto.Schema`. + +--- + +## Tier 1: Cross-Module Data Contracts (High Priority) + +These maps cross module boundaries — they're constructed in one module and consumed (pattern-matched, accessed) in another. Converting them to structs catches shape mismatches at compile time. + +### 1.1 Billing Provider Behaviour Types + +**File:** `lib/modules/billing/providers/provider.ex` + +The `Provider` behaviour defines 6 `@type` specs as plain maps. Every provider (Stripe, PayPal, Razorpay) returns these, and every consumer pattern-matches on them. + +| Type | Lines | Fields | Returned By | Consumed By | +|------|-------|--------|-------------|-------------| +| `checkout_session` | 48–54 | `id`, `url`, `provider`, `expires_at`, `metadata` | 3 providers | `Providers`, templates | +| `setup_session` | 56–61 | `id`, `url`, `provider`, `metadata` | 2 providers | `Providers`, templates | +| `webhook_event` | 63–69 | `type`, `event_id`, `data`, `provider`, `raw_payload` | 3 providers | `WebhookProcessor` (line 44, 72–138) | +| `payment_method` | 71–82 | `id`, `provider`, `provider_payment_method_id`, `provider_customer_id`, `type`, `brand`, `last4`, `exp_month`, `exp_year`, `metadata` | 3 providers | `Providers.charge_payment_method/3` (line 240) | +| `charge_result` | 84–91 | `id`, `provider_transaction_id`, `amount`, `currency`, `status`, `metadata` | 3 providers | `WebhookProcessor`, billing context | +| `refund_result` | 93–99 | `id`, `provider_refund_id`, `amount`, `status`, `metadata` | 3 providers | billing context | + +**Impact:** ~18 construction sites across 3 provider implementations + 4 consumer files. + +**Recommendation:** Create structs in `lib/modules/billing/providers/types/` (e.g., `checkout_session.ex`, `webhook_event.ex`). Update the `@callback` specs to return `CheckoutSession.t()` instead of `checkout_session()`. Each provider's return maps become `%CheckoutSession{...}`. + +### 1.2 Billing ProviderInfo + +**File:** `lib/modules/billing/providers/providers.ex`, lines 325–352 + +```elixir +%{name: "Stripe", icon: "stripe", color: "#635BFF", description: "Accept cards, wallets, and more"} +``` + +4 fields (`name`, `icon`, `color`, `description`), 4 variations (Stripe, PayPal, Razorpay, Unknown). Consumed in LiveView templates for provider selection UI. + +**Recommendation:** `%ProviderInfo{}` struct with 4 required fields. Small but crosses into template rendering. + +### 1.3 Entities FieldType + +**File:** `lib/modules/entities/field_types.ex`, lines 50–189 + +```elixir +%{name: "text", label: "Text", description: "...", category: :basic, icon: "hero-pencil", + requires_options: false, default_props: %{max_length: 255}} +``` + +7 fields, 12 field type definitions (text, textarea, email, url, rich_text, number, boolean, date, select, radio, checkbox, file). + +**Consumed by:** +- `Entities.Web.EntityForm` — field picker dropdown, type rendering (lines 70, 497, 1275–1292) +- `for_picker/0` — transforms to tuple format for UI +- `validate_field/1` — validates field map structure +- Entity form templates — accesses `field["type"]`, `field["label"]`, `field["icon"]` + +**~41 occurrences** across entity form, templates, and validation pipeline. + +**Recommendation:** `%FieldType{}` struct with `@enforce_keys [:name, :label, :category]`. The `default_props` field stays as a plain map (type-specific, dynamic shape). + +### 1.4 Emails EmailLogData + +**File:** `lib/modules/emails/interceptor.ex`, lines 374–391 + +```elixir +%{message_id: ..., to: ..., from: ..., subject: ..., headers: ..., body_preview: ..., + body_full: ..., attachments_count: ..., size_bytes: ..., template_name: ..., + campaign_id: ..., user_id: ..., user_uuid: ..., provider: ..., + configuration_set: ..., message_tags: ...} +``` + +16 fields. Constructed in `extract_email_data/2`, passed to `Emails.create_log/1` (line 161). Core of the email tracking pipeline. + +**Recommendation:** `%EmailLogData{}` struct. This is one of the largest untyped maps in the codebase and sits at the center of the email tracking pipeline. + +### 1.5 Sync TableSchema + ColumnInfo + +**File:** `lib/modules/sync/schema_inspector.ex`, lines 326–368 + +**TableSchema** (4 fields): +```elixir +%{table: "users", schema: "public", columns: [...], primary_key: ["id"]} +``` + +**ColumnInfo** (up to 8 fields): +```elixir +%{name: "id", type: "bigint", nullable: false, primary_key: true, + default: nil, max_length: nil, precision: nil, scale: nil} +``` + +Constructed in `fetch_table_schema/2`. Consumed by: +- `DataImporter.ex` (lines 66–67) — extracts columns and primary keys +- `DataExporter.ex` — determines export columns +- `sync_live.ex` — displays schema in UI +- `client.ex` — received from remote peers over wire protocol + +**~50 occurrences** across sync module. + +**Recommendation:** Two structs: `%TableSchema{}` and `%ColumnInfo{}`. The sync module passes these across the wire protocol, so typed structs also serve as documentation for the sync API. + +### 1.6 AI AIModel + +**File:** `lib/modules/ai/openrouter_client.ex`, lines 191–267 (embedding models), 444–455 (normalization) + +```elixir +%{"id" => "anthropic/claude-3-opus", "name" => "Claude 3 Opus", + "description" => "...", "context_length" => 200000, + "max_completion_tokens" => 4096, "supported_parameters" => ["temperature"], + "pricing" => %{"prompt" => 0.015, "completion" => 0.075}, + "architecture" => %{"modality" => "text"}, "top_provider" => %{...}} +``` + +9 top-level fields, **string-keyed** (comes from JSON API). Consumed by: +- `AI.Web.EndpointForm` (lines 92–93) — `model["max_completion_tokens"]`, `model["context_length"]` +- `model_option/1` (lines 351–368) — formats for dropdown +- `model_supports_parameter?/2` (lines 515–518) — checks supported params + +**~37 occurrences** across AI module. + +**Note:** String keys come from the OpenRouter API JSON response. The struct should use atom keys internally, with `normalize_models/2` converting at the boundary. + +**Recommendation:** `%AIModel{}` struct with atom keys. `normalize_models/2` becomes the conversion boundary from string-keyed JSON to typed struct. + +### 1.7 Legal LegalFramework + PageType + +**File:** `lib/modules/legal/legal.ex`, lines 42–106 (frameworks), 109–152 (page types) + +**LegalFramework** (7 fields): +```elixir +%{id: "gdpr", name: "GDPR (European Union)", description: "...", + regions: ["EU", "EEA"], consent_model: :opt_in, + required_pages: ["privacy-policy", "cookie-policy"], + optional_pages: ["data-retention-policy"]} +``` + +7 frameworks defined (GDPR, UK GDPR, CCPA, US States, LGPD, PIPEDA, Generic). + +**PageType** (4 fields): +```elixir +%{slug: "privacy-policy", title: "Privacy Policy", + template: "privacy_policy.eex", description: "..."} +``` + +7 page types defined. Both consumed in `legal.web.settings.ex` for UI rendering and template generation. + +**~13 occurrences** total. + +**Recommendation:** `%LegalFramework{}` and `%PageType{}` structs. Low occurrence count but clean data contracts that benefit from compile-time checks. + +### 1.8 Dashboard Group + +**Files:** `lib/phoenix_kit/dashboard/registry.ex` (lines 826–830), `lib/phoenix_kit/dashboard/admin_tabs.ex` (lines 70–74) + +```elixir +%{id: :admin_main, label: nil, priority: 100} +``` + +4–5 fields (`id`, `label`, `priority`, `icon`, `collapsible`). Defined in defaults, loaded from config, stored in ETS via `register_groups/1`, retrieved via `get_groups/0`. + +**~12 occurrences** across registry and admin tabs. + +**Recommendation:** `%Dashboard.Group{}` struct alongside the existing `Tab`, `Badge`, and `ContextSelector` structs. Natural extension of the dashboard type system. + +--- + +## Tier 2: Module-Internal Typed Data (Medium Priority) + +These maps stay within a single module but would benefit from struct validation for maintainability. + +### 2.1 SessionFingerprint — DONE (2026-02-17) + +**File:** `lib/phoenix_kit/utils/session_fingerprint.ex` + +Converted to `%SessionFingerprint{}` struct with `@enforce_keys [:ip_address, :user_agent_hash]` and `@type t`. `create_fingerprint/1` now returns struct. Consumer in `user_token.ex` uses `[:key]` access which works on structs. + +### 2.2 Billing IbanSpec — DONE (2026-02-17) + +**File:** `lib/modules/billing/utils/iban_data.ex` + +Added `%IbanData{}` struct with `@enforce_keys [:length, :sepa]` and `@type t`. Internal `@iban_specs` module attribute stays as plain maps (compile-time limitation). Added `get_spec/1` returning typed struct. `all_specs/0` now returns structs. + +### 2.3 Billing Timeline Events + +**File:** `lib/modules/billing/web/invoice_detail/helpers.ex`, lines 72–168 + +```elixir +%{type: :created, datetime: invoice.inserted_at, data: nil} +%{type: :payment, datetime: txn.inserted_at, data: txn} +%{type: :refund, datetime: txn.inserted_at, data: txn} +``` + +3 fields (`type`, `datetime`, `data`), 6 event variations. Used only in invoice detail timeline rendering. + +**Recommendation:** `%TimelineEvent{}` — small scope but prevents typos in the `:type` atom. + +### 2.4 Sitemap ModuleInfo + +**File:** `lib/modules/sitemap/generator.ex`, lines 226–242 + +```elixir +%{filename: "sitemap-posts-1.xml", url_count: 500, lastmod: ~U[2026-02-16 00:00:00Z]} +``` + +3 fields, constructed in 3 locations (numbered files, single files, flat mode). Consumed by `generate_index/4` for XML generation. + +**Recommendation:** `%ModuleInfo{}` or `%SitemapFile{}` — small struct alongside the existing `UrlEntry`. + +### 2.5 Filter/Pagination Params + +**Pattern across ~48 `list_*` functions** using keyword lists: + +```elixir +def list_posts(opts \\ []) do + user_id = Keyword.get(opts, :user_id) + status = Keyword.get(opts, :status) + page = Keyword.get(opts, :page, 1) + per_page = Keyword.get(opts, :per_page, 20) + ... +end +``` + +Representative files: +- `lib/modules/posts/posts.ex` — `list_posts/1`, `list_user_posts/2` +- `lib/modules/comments/comments.ex` — `list_all_comments/1`, `list_comments/3` +- `lib/modules/entities/entities.ex` — `list_entity_data/2` +- `lib/modules/tickets/tickets.ex` — `list_tickets/1` + +**Recommendation:** This is a judgment call. Keyword lists are idiomatic Elixir for optional params. A shared `%ListParams{page, per_page, search, sort_by, sort_order}` struct could work, but risks over-abstraction. **Defer** — address only if filter bugs become a pattern. + +--- + +## Tier 3: Acceptable as Maps (No Action Needed) + +These use plain maps appropriately at system boundaries or for genuinely dynamic data. + +| Pattern | Location | Why It's Fine | +|---------|----------|---------------| +| **PubSub messages** | `lib/phoenix_kit/pubsub_helper.ex`, dashboard registry | Use tuples (`{:tab_updated, tab}`), not maps. Pattern matching in `handle_info` provides type safety. | +| **JSON API responses** | `lib/modules/ai/openrouter_client.ex` (raw responses) | String-keyed maps from `Jason.decode!/1`. Converted at boundary (the struct should live _after_ normalization, not replace JSON parsing). | +| **Entity import/export** | `lib/modules/entities/mirror/importer.ex` (lines 74–99) | String-keyed maps from JSON file I/O. Immediately converted to Ecto changesets. The map is a transient deserialization artifact. | +| **Ecto `:map` fields** | `metadata` fields across 8+ schemas | Intentionally dynamic (`%{"source" => "mobile", "utm" => "summer"}`). Schema `:map` type is correct for extensible key-value data. | +| **Config summaries** | `Comments.get_config/0`, module status maps | Internal read-only aggregations. Never persisted, never cross module boundaries, consumed immediately for display. | +| **Webhook raw payloads** | `WebhookEvent.payload` field | Provider-specific JSON. Shape varies per provider and event type. Cannot be meaningfully typed. | + +--- + +## File Reference + +Complete table of every file with a Tier 1 or Tier 2 gap: + +| File | Lines | Current Shape | Target Struct | Tier | +|------|-------|---------------|---------------|------| +| `lib/modules/billing/providers/provider.ex` | 48–54 | `checkout_session` map | `CheckoutSession` | 1 | +| `lib/modules/billing/providers/provider.ex` | 56–61 | `setup_session` map | `SetupSession` | 1 | +| `lib/modules/billing/providers/provider.ex` | 63–69 | `webhook_event` map | `WebhookEvent` (struct) | 1 | +| `lib/modules/billing/providers/provider.ex` | 71–82 | `payment_method` map | `PaymentMethod` (struct) | 1 | +| `lib/modules/billing/providers/provider.ex` | 84–91 | `charge_result` map | `ChargeResult` | 1 | +| `lib/modules/billing/providers/provider.ex` | 93–99 | `refund_result` map | `RefundResult` | 1 | +| `lib/modules/billing/providers/providers.ex` | 325–352 | `provider_info` map | `ProviderInfo` | 1 | +| `lib/modules/entities/field_types.ex` | 50–189 | `field_type` map | `FieldType` | 1 | +| `lib/modules/emails/interceptor.ex` | 374–391 | `email_log_data` map | `EmailLogData` | 1 | +| `lib/modules/sync/schema_inspector.ex` | 326–368 | `table_schema` map | `TableSchema` | 1 | +| `lib/modules/sync/schema_inspector.ex` | 345–357 | `column_info` map | `ColumnInfo` | 1 | +| `lib/modules/ai/openrouter_client.ex` | 444–455 | normalized model map | `AIModel` | 1 | +| `lib/modules/legal/legal.ex` | 42–106 | `framework` map | `LegalFramework` | 1 | +| `lib/modules/legal/legal.ex` | 109–152 | `page_type` map | `PageType` | 1 | +| `lib/phoenix_kit/dashboard/registry.ex` | 826–830 | `group` map | `Dashboard.Group` | 1 | +| `lib/phoenix_kit/dashboard/admin_tabs.ex` | 70–74 | `group` map | `Dashboard.Group` | 1 | +| `lib/phoenix_kit/utils/session_fingerprint.ex` | 54–59 | ~~fingerprint map~~ | `SessionFingerprint` | 2 ✅ | +| `lib/modules/billing/utils/iban_data.ex` | 22–98 | ~~`iban_spec` map~~ | `IbanData` | 2 ✅ | +| `lib/modules/billing/web/invoice_detail/helpers.ex` | 72–168 | timeline event map | `TimelineEvent` | 2 | +| `lib/modules/sitemap/generator.ex` | 226–242 | module info map | `SitemapFile` | 2 | + +--- + +## Recommendation: Conversion Order + +Suggested order based on cross-boundary impact, consumer count, and bug risk: + +### Phase 1 — Billing Provider Types (Highest Impact) + +**Why first:** 6 map types × 3 providers = 18 construction sites. The `WebhookProcessor` pattern-matches on these maps without any compile-time guarantee the keys exist. A typo in a provider implementation (`provider_trasaction_id`) silently produces `nil`. + +**Files to create:** +- `lib/modules/billing/providers/types/checkout_session.ex` +- `lib/modules/billing/providers/types/setup_session.ex` +- `lib/modules/billing/providers/types/webhook_event_data.ex` (name avoids clash with `WebhookEvent` schema) +- `lib/modules/billing/providers/types/payment_method_info.ex` (avoids clash with `PaymentMethod` schema) +- `lib/modules/billing/providers/types/charge_result.ex` +- `lib/modules/billing/providers/types/refund_result.ex` +- `lib/modules/billing/providers/types/provider_info.ex` + +**Files to update:** `provider.ex` (callbacks), `providers.ex`, `stripe.ex`, `paypal.ex`, `razorpay.ex`, `webhook_processor.ex` + +### Phase 2 — Entities FieldType + Dashboard Group + +**Why second:** `FieldType` has ~41 occurrences in the entity form system. `Dashboard.Group` naturally extends the existing `Tab`/`Badge`/`ContextSelector` struct family. + +**Files to create:** +- `lib/modules/entities/field_type.ex` +- `lib/phoenix_kit/dashboard/group.ex` + +### Phase 3 — Sync TableSchema/ColumnInfo + EmailLogData + +**Why third:** Both cross module boundaries. `TableSchema` travels over the wire protocol. `EmailLogData` is the widest map (16 fields) in the codebase. + +**Files to create:** +- `lib/modules/sync/table_schema.ex` +- `lib/modules/sync/column_info.ex` +- `lib/modules/emails/email_log_data.ex` + +### Phase 4 — AI, Legal, and Tier 2 + +**Why last:** Lower occurrence counts. AI model maps use string keys (need conversion boundary). Legal maps are static config. Tier 2 items are module-internal. + +**Files to create:** +- `lib/modules/ai/ai_model.ex` +- `lib/modules/legal/legal_framework.ex` +- `lib/modules/legal/page_type.ex` +- `lib/phoenix_kit/utils/session_fingerprint.ex` (add struct to existing file) +- `lib/modules/billing/utils/iban_spec.ex` +- `lib/modules/billing/web/invoice_detail/timeline_event.ex` +- `lib/modules/sitemap/sitemap_file.ex` + +--- + +## Summary + +| Tier | Items | New Structs | Done | Remaining | +|------|-------|-------------|------|-----------| +| **Tier 1** (cross-module) | 16 map shapes | 16 structs | 0 | 16 | +| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 2 ✅ | 3 | +| **Tier 3** (acceptable) | 6 categories | 0 | — | 0 | +| **Total** | 27 audited | **21 new structs** | **2** | **19** | From ddbc1dd1d2b89973fbeda93f936bfce933b807a4 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:48:43 +0200 Subject: [PATCH 04/11] Add SitemapFile struct for typed sitemap file metadata New struct alongside UrlEntry with @enforce_keys [:filename, :url_count] and @type t(). All 3 construction sites in generator.ex updated, @spec annotations use SitemapFile.t(). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- dev_docs/2026-02-16-struct-vs-map-audit.md | 19 +++++++------------ lib/modules/sitemap/generator.ex | 17 ++++++++++------- lib/modules/sitemap/sitemap_file.ex | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 lib/modules/sitemap/sitemap_file.ex diff --git a/dev_docs/2026-02-16-struct-vs-map-audit.md b/dev_docs/2026-02-16-struct-vs-map-audit.md index d1430730..ac44f83e 100644 --- a/dev_docs/2026-02-16-struct-vs-map-audit.md +++ b/dev_docs/2026-02-16-struct-vs-map-audit.md @@ -34,6 +34,7 @@ These data contracts already use proper structs: | `Languages.Language` | `lib/modules/languages/language.ex` | 4 | Recently refactored from plain map | | `Utils.SessionFingerprint` | `lib/phoenix_kit/utils/session_fingerprint.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | | `Billing.IbanData` | `lib/modules/billing/utils/iban_data.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | +| `Sitemap.SitemapFile` | `lib/modules/sitemap/sitemap_file.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also proper structs by virtue of `use Ecto.Schema`. @@ -230,17 +231,11 @@ Added `%IbanData{}` struct with `@enforce_keys [:length, :sepa]` and `@type t`. **Recommendation:** `%TimelineEvent{}` — small scope but prevents typos in the `:type` atom. -### 2.4 Sitemap ModuleInfo +### 2.4 Sitemap ModuleInfo — DONE (2026-02-17) -**File:** `lib/modules/sitemap/generator.ex`, lines 226–242 +**File:** `lib/modules/sitemap/sitemap_file.ex` (new), `lib/modules/sitemap/generator.ex` -```elixir -%{filename: "sitemap-posts-1.xml", url_count: 500, lastmod: ~U[2026-02-16 00:00:00Z]} -``` - -3 fields, constructed in 3 locations (numbered files, single files, flat mode). Consumed by `generate_index/4` for XML generation. - -**Recommendation:** `%ModuleInfo{}` or `%SitemapFile{}` — small struct alongside the existing `UrlEntry`. +Created `%SitemapFile{}` struct with `@enforce_keys [:filename, :url_count]` and `@type t`. All 3 construction sites in `generator.ex` updated. `@spec` annotations on `generate_module/2` and `generate_index/4` updated to use `SitemapFile.t()`. ### 2.5 Filter/Pagination Params @@ -306,7 +301,7 @@ Complete table of every file with a Tier 1 or Tier 2 gap: | `lib/phoenix_kit/utils/session_fingerprint.ex` | 54–59 | ~~fingerprint map~~ | `SessionFingerprint` | 2 ✅ | | `lib/modules/billing/utils/iban_data.ex` | 22–98 | ~~`iban_spec` map~~ | `IbanData` | 2 ✅ | | `lib/modules/billing/web/invoice_detail/helpers.ex` | 72–168 | timeline event map | `TimelineEvent` | 2 | -| `lib/modules/sitemap/generator.ex` | 226–242 | module info map | `SitemapFile` | 2 | +| `lib/modules/sitemap/generator.ex` | 226–242 | ~~module info map~~ | `SitemapFile` | 2 ✅ | --- @@ -366,6 +361,6 @@ Suggested order based on cross-boundary impact, consumer count, and bug risk: | Tier | Items | New Structs | Done | Remaining | |------|-------|-------------|------|-----------| | **Tier 1** (cross-module) | 16 map shapes | 16 structs | 0 | 16 | -| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 2 ✅ | 3 | +| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 3 ✅ | 2 | | **Tier 3** (acceptable) | 6 categories | 0 | — | 0 | -| **Total** | 27 audited | **21 new structs** | **2** | **19** | +| **Total** | 27 audited | **21 new structs** | **3** | **18** | diff --git a/lib/modules/sitemap/generator.ex b/lib/modules/sitemap/generator.ex index 386ddc35..365b876f 100644 --- a/lib/modules/sitemap/generator.ex +++ b/lib/modules/sitemap/generator.ex @@ -36,6 +36,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do alias PhoenixKit.Modules.Sitemap.FileStorage alias PhoenixKit.Modules.Sitemap.HtmlGenerator alias PhoenixKit.Modules.Sitemap.SchedulerWorker + alias PhoenixKit.Modules.Sitemap.SitemapFile alias PhoenixKit.Modules.Sitemap.Sources.Source alias PhoenixKit.Modules.Sitemap.UrlEntry alias PhoenixKit.Utils.Routes @@ -142,7 +143,9 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do {:ok, %{ index_xml: xml, - modules: [%{filename: "flat", url_count: total_urls, lastmod: DateTime.utc_now()}], + modules: [ + %SitemapFile{filename: "flat", url_count: total_urls, lastmod: DateTime.utc_now()} + ], total_urls: total_urls }} end @@ -152,10 +155,10 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do @doc """ Generates sitemap file(s) for a single source module. - Returns a list of module_info maps (one per file generated). + Returns a list of `%SitemapFile{}` structs (one per file generated). Empty sources produce no files and return []. """ - @spec generate_module(module(), keyword()) :: [map()] + @spec generate_module(module(), keyword()) :: [SitemapFile.t()] def generate_module(source_module, opts \\ []) do if Source.valid_source?(source_module) and source_module.enabled?() do do_generate_module(source_module, opts) @@ -223,7 +226,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do FileStorage.save_module(numbered_filename, xml) Cache.put_module(numbered_filename, xml) - %{ + %SitemapFile{ filename: numbered_filename, url_count: length(chunk), lastmod: latest_lastmod(chunk) @@ -235,7 +238,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do Cache.put_module(filename, xml) [ - %{ + %SitemapFile{ filename: filename, url_count: length(entries), lastmod: latest_lastmod(entries) @@ -248,9 +251,9 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do # ── Sitemapindex generation ──────────────────────────────────────── @doc """ - Builds `` XML from a list of module_info maps. + Builds `` XML from a list of `%SitemapFile{}` structs. """ - @spec generate_index([map()], String.t(), String.t(), boolean()) :: String.t() + @spec generate_index([SitemapFile.t()], String.t(), String.t(), boolean()) :: String.t() def generate_index(module_infos, base_url, xsl_style \\ "table", xsl_enabled \\ true) do xsl_line = build_index_xsl_line(xsl_style, xsl_enabled) normalized_base = String.trim_trailing(base_url, "/") diff --git a/lib/modules/sitemap/sitemap_file.ex b/lib/modules/sitemap/sitemap_file.ex new file mode 100644 index 00000000..38bf36d1 --- /dev/null +++ b/lib/modules/sitemap/sitemap_file.ex @@ -0,0 +1,22 @@ +defmodule PhoenixKit.Modules.Sitemap.SitemapFile do + @moduledoc """ + Struct representing a generated sitemap file's metadata. + + Used by `Generator` to track per-module file info for sitemapindex generation. + + ## Fields + + - `filename` - Base filename without extension (e.g., "sitemap-posts-1") + - `url_count` - Number of URLs in this file + - `lastmod` - Most recent lastmod timestamp across all URLs in the file + """ + + @enforce_keys [:filename, :url_count] + defstruct [:filename, :url_count, :lastmod] + + @type t :: %__MODULE__{ + filename: String.t(), + url_count: non_neg_integer(), + lastmod: DateTime.t() | nil + } +end From b4bd08489e2caa5d7da5059377e6ee2bb247aa08 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:54:44 +0200 Subject: [PATCH 05/11] Add TimelineEvent struct for typed invoice timeline events New struct with @enforce_keys [:type], typed event_type() union of 10 event atoms, and @type t(). All 10 construction sites in helpers.ex updated. Template dot-access works identically. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- dev_docs/2026-02-16-struct-vs-map-audit.md | 21 ++++-------- .../billing/web/invoice_detail/helpers.ex | 24 +++++++------ .../web/invoice_detail/timeline_event.ex | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 lib/modules/billing/web/invoice_detail/timeline_event.ex diff --git a/dev_docs/2026-02-16-struct-vs-map-audit.md b/dev_docs/2026-02-16-struct-vs-map-audit.md index ac44f83e..650da6ef 100644 --- a/dev_docs/2026-02-16-struct-vs-map-audit.md +++ b/dev_docs/2026-02-16-struct-vs-map-audit.md @@ -35,6 +35,7 @@ These data contracts already use proper structs: | `Utils.SessionFingerprint` | `lib/phoenix_kit/utils/session_fingerprint.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | | `Billing.IbanData` | `lib/modules/billing/utils/iban_data.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | | `Sitemap.SitemapFile` | `lib/modules/sitemap/sitemap_file.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | +| `Billing.TimelineEvent` | `lib/modules/billing/web/invoice_detail/timeline_event.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also proper structs by virtue of `use Ecto.Schema`. @@ -217,19 +218,11 @@ Converted to `%SessionFingerprint{}` struct with `@enforce_keys [:ip_address, :u Added `%IbanData{}` struct with `@enforce_keys [:length, :sepa]` and `@type t`. Internal `@iban_specs` module attribute stays as plain maps (compile-time limitation). Added `get_spec/1` returning typed struct. `all_specs/0` now returns structs. -### 2.3 Billing Timeline Events +### 2.3 Billing Timeline Events — DONE (2026-02-17) -**File:** `lib/modules/billing/web/invoice_detail/helpers.ex`, lines 72–168 +**File:** `lib/modules/billing/web/invoice_detail/timeline_event.ex` (new), `helpers.ex` -```elixir -%{type: :created, datetime: invoice.inserted_at, data: nil} -%{type: :payment, datetime: txn.inserted_at, data: txn} -%{type: :refund, datetime: txn.inserted_at, data: txn} -``` - -3 fields (`type`, `datetime`, `data`), 6 event variations. Used only in invoice detail timeline rendering. - -**Recommendation:** `%TimelineEvent{}` — small scope but prevents typos in the `:type` atom. +Created `%TimelineEvent{}` struct with `@enforce_keys [:type]`, typed `event_type()` union, and `@type t`. All 10 construction sites in `helpers.ex` updated. Template dot-access (`event.type`, `event.datetime`, `event.data`) works identically on structs. ### 2.4 Sitemap ModuleInfo — DONE (2026-02-17) @@ -300,7 +293,7 @@ Complete table of every file with a Tier 1 or Tier 2 gap: | `lib/phoenix_kit/dashboard/admin_tabs.ex` | 70–74 | `group` map | `Dashboard.Group` | 1 | | `lib/phoenix_kit/utils/session_fingerprint.ex` | 54–59 | ~~fingerprint map~~ | `SessionFingerprint` | 2 ✅ | | `lib/modules/billing/utils/iban_data.ex` | 22–98 | ~~`iban_spec` map~~ | `IbanData` | 2 ✅ | -| `lib/modules/billing/web/invoice_detail/helpers.ex` | 72–168 | timeline event map | `TimelineEvent` | 2 | +| `lib/modules/billing/web/invoice_detail/helpers.ex` | 72–168 | ~~timeline event map~~ | `TimelineEvent` | 2 ✅ | | `lib/modules/sitemap/generator.ex` | 226–242 | ~~module info map~~ | `SitemapFile` | 2 ✅ | --- @@ -361,6 +354,6 @@ Suggested order based on cross-boundary impact, consumer count, and bug risk: | Tier | Items | New Structs | Done | Remaining | |------|-------|-------------|------|-----------| | **Tier 1** (cross-module) | 16 map shapes | 16 structs | 0 | 16 | -| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 3 ✅ | 2 | +| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 4 ✅ | 1 | | **Tier 3** (acceptable) | 6 categories | 0 | — | 0 | -| **Total** | 27 audited | **21 new structs** | **3** | **18** | +| **Total** | 27 audited | **21 new structs** | **4** | **17** | diff --git a/lib/modules/billing/web/invoice_detail/helpers.ex b/lib/modules/billing/web/invoice_detail/helpers.ex index 73499879..00ce9638 100644 --- a/lib/modules/billing/web/invoice_detail/helpers.ex +++ b/lib/modules/billing/web/invoice_detail/helpers.ex @@ -6,6 +6,8 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do and other template-callable utilities. """ + alias PhoenixKit.Modules.Billing.Web.InvoiceDetail.TimelineEvent + @doc """ Gets the default email address from invoice billing details or user. """ @@ -63,19 +65,19 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do @doc """ Builds a sorted timeline of all invoice events. - Returns a list of maps with :type, :datetime, and :data keys, sorted by datetime. + Returns a list of `%TimelineEvent{}` structs sorted by datetime. """ def build_timeline_events(invoice, transactions) do events = [] # 1. Created event - events = [%{type: :created, datetime: invoice.inserted_at, data: nil} | events] + events = [%TimelineEvent{type: :created, datetime: invoice.inserted_at} | events] # 2. Invoice sent events invoice_sends = get_send_history(invoice) |> Enum.map(fn entry -> - %{ + %TimelineEvent{ type: :invoice_sent, datetime: parse_datetime(entry["sent_at"]), data: entry @@ -87,7 +89,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do # Fallback for old invoices without send_history events = if invoice.sent_at && Enum.empty?(get_send_history(invoice)) do - [%{type: :invoice_sent_legacy, datetime: invoice.sent_at, data: nil} | events] + [%TimelineEvent{type: :invoice_sent_legacy, datetime: invoice.sent_at} | events] else events end @@ -97,7 +99,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do transactions |> Enum.filter(&Decimal.positive?(&1.amount)) |> Enum.map(fn txn -> - %{type: :payment, datetime: txn.inserted_at, data: txn} + %TimelineEvent{type: :payment, datetime: txn.inserted_at, data: txn} end) events = events ++ payment_events @@ -105,7 +107,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do # 4. Paid event (when fully paid) events = if invoice.paid_at do - [%{type: :paid, datetime: invoice.paid_at, data: nil} | events] + [%TimelineEvent{type: :paid, datetime: invoice.paid_at} | events] else events end @@ -114,7 +116,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do events = if invoice.receipt_number do [ - %{ + %TimelineEvent{ type: :receipt_generated, datetime: invoice.receipt_generated_at, data: invoice.receipt_number @@ -129,7 +131,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do receipt_sends = get_receipt_send_history(invoice) |> Enum.map(fn entry -> - %{ + %TimelineEvent{ type: :receipt_sent, datetime: parse_datetime(entry["sent_at"]), data: entry @@ -144,13 +146,13 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do |> Enum.filter(&Decimal.negative?(&1.amount)) |> Enum.flat_map(fn txn -> # Refund event itself - refund_event = %{type: :refund, datetime: txn.inserted_at, data: txn} + refund_event = %TimelineEvent{type: :refund, datetime: txn.inserted_at, data: txn} # Credit note send events for this refund credit_note_sends = get_credit_note_send_history(txn) |> Enum.map(fn entry -> - %{ + %TimelineEvent{ type: :credit_note_sent, datetime: parse_datetime(entry["sent_at"]), data: Map.put(entry, "transaction", txn) @@ -165,7 +167,7 @@ defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.Helpers do # 8. Voided event events = if invoice.voided_at do - [%{type: :voided, datetime: invoice.voided_at, data: nil} | events] + [%TimelineEvent{type: :voided, datetime: invoice.voided_at} | events] else events end diff --git a/lib/modules/billing/web/invoice_detail/timeline_event.ex b/lib/modules/billing/web/invoice_detail/timeline_event.ex new file mode 100644 index 00000000..30513aec --- /dev/null +++ b/lib/modules/billing/web/invoice_detail/timeline_event.ex @@ -0,0 +1,34 @@ +defmodule PhoenixKit.Modules.Billing.Web.InvoiceDetail.TimelineEvent do + @moduledoc """ + Struct representing a single event in the invoice timeline. + + ## Fields + + - `type` - Event type atom (`:created`, `:invoice_sent`, `:payment`, `:paid`, + `:receipt_generated`, `:receipt_sent`, `:refund`, `:credit_note_sent`, `:voided`, + `:invoice_sent_legacy`) + - `datetime` - When the event occurred + - `data` - Event-specific payload (transaction, send history entry, receipt number, or nil) + """ + + @enforce_keys [:type] + defstruct [:type, :datetime, :data] + + @type event_type :: + :created + | :invoice_sent + | :invoice_sent_legacy + | :payment + | :paid + | :receipt_generated + | :receipt_sent + | :refund + | :credit_note_sent + | :voided + + @type t :: %__MODULE__{ + type: event_type(), + datetime: DateTime.t() | NaiveDateTime.t() | nil, + data: term() + } +end From 8775236c987c02cb53d929f2ed320a59eb2557a8 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 15:58:15 +0200 Subject: [PATCH 06/11] Add LegalFramework and PageType structs for typed legal data New LegalFramework struct (7 fields) and PageType struct (4 fields) with enforce_keys, type specs, and from_map/1 boundary converters. Internal module attributes stay as plain maps; public accessors convert at the boundary. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- dev_docs/2026-02-16-struct-vs-map-audit.md | 36 ++++----------- lib/modules/legal/legal.ex | 16 ++++--- lib/modules/legal/legal_framework.ex | 52 ++++++++++++++++++++++ lib/modules/legal/page_type.ex | 35 +++++++++++++++ 4 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 lib/modules/legal/legal_framework.ex create mode 100644 lib/modules/legal/page_type.ex diff --git a/dev_docs/2026-02-16-struct-vs-map-audit.md b/dev_docs/2026-02-16-struct-vs-map-audit.md index 650da6ef..90df0e1a 100644 --- a/dev_docs/2026-02-16-struct-vs-map-audit.md +++ b/dev_docs/2026-02-16-struct-vs-map-audit.md @@ -36,6 +36,8 @@ These data contracts already use proper structs: | `Billing.IbanData` | `lib/modules/billing/utils/iban_data.ex` | 2 | Converted 2026-02-17 from Tier 2 audit | | `Sitemap.SitemapFile` | `lib/modules/sitemap/sitemap_file.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | | `Billing.TimelineEvent` | `lib/modules/billing/web/invoice_detail/timeline_event.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | +| `Legal.LegalFramework` | `lib/modules/legal/legal_framework.ex` | 7 | Converted 2026-02-17 from Tier 1 audit | +| `Legal.PageType` | `lib/modules/legal/page_type.ex` | 4 | Converted 2026-02-17 from Tier 1 audit | All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also proper structs by virtue of `use Ecto.Schema`. @@ -160,31 +162,11 @@ Constructed in `fetch_table_schema/2`. Consumed by: **Recommendation:** `%AIModel{}` struct with atom keys. `normalize_models/2` becomes the conversion boundary from string-keyed JSON to typed struct. -### 1.7 Legal LegalFramework + PageType +### 1.7 Legal LegalFramework + PageType — DONE (2026-02-17) -**File:** `lib/modules/legal/legal.ex`, lines 42–106 (frameworks), 109–152 (page types) +**Files:** `lib/modules/legal/legal_framework.ex` (new), `lib/modules/legal/page_type.ex` (new), `lib/modules/legal/legal.ex` -**LegalFramework** (7 fields): -```elixir -%{id: "gdpr", name: "GDPR (European Union)", description: "...", - regions: ["EU", "EEA"], consent_model: :opt_in, - required_pages: ["privacy-policy", "cookie-policy"], - optional_pages: ["data-retention-policy"]} -``` - -7 frameworks defined (GDPR, UK GDPR, CCPA, US States, LGPD, PIPEDA, Generic). - -**PageType** (4 fields): -```elixir -%{slug: "privacy-policy", title: "Privacy Policy", - template: "privacy_policy.eex", description: "..."} -``` - -7 page types defined. Both consumed in `legal.web.settings.ex` for UI rendering and template generation. - -**~13 occurrences** total. - -**Recommendation:** `%LegalFramework{}` and `%PageType{}` structs. Low occurrence count but clean data contracts that benefit from compile-time checks. +Created `%LegalFramework{}` (7 fields, `@enforce_keys [:id, :name, :consent_model, :required_pages]`) and `%PageType{}` (4 fields, `@enforce_keys [:slug, :title, :template]`). Both include `from_map/1` for boundary conversion. Internal `@frameworks` and `@page_types` module attributes stay as plain maps. `available_frameworks/0`, `available_page_types/0`, and `get_page_config/1` convert at the boundary. ### 1.8 Dashboard Group @@ -287,8 +269,8 @@ Complete table of every file with a Tier 1 or Tier 2 gap: | `lib/modules/sync/schema_inspector.ex` | 326–368 | `table_schema` map | `TableSchema` | 1 | | `lib/modules/sync/schema_inspector.ex` | 345–357 | `column_info` map | `ColumnInfo` | 1 | | `lib/modules/ai/openrouter_client.ex` | 444–455 | normalized model map | `AIModel` | 1 | -| `lib/modules/legal/legal.ex` | 42–106 | `framework` map | `LegalFramework` | 1 | -| `lib/modules/legal/legal.ex` | 109–152 | `page_type` map | `PageType` | 1 | +| `lib/modules/legal/legal.ex` | 42–106 | ~~`framework` map~~ | `LegalFramework` | 1 ✅ | +| `lib/modules/legal/legal.ex` | 109–152 | ~~`page_type` map~~ | `PageType` | 1 ✅ | | `lib/phoenix_kit/dashboard/registry.ex` | 826–830 | `group` map | `Dashboard.Group` | 1 | | `lib/phoenix_kit/dashboard/admin_tabs.ex` | 70–74 | `group` map | `Dashboard.Group` | 1 | | `lib/phoenix_kit/utils/session_fingerprint.ex` | 54–59 | ~~fingerprint map~~ | `SessionFingerprint` | 2 ✅ | @@ -353,7 +335,7 @@ Suggested order based on cross-boundary impact, consumer count, and bug risk: | Tier | Items | New Structs | Done | Remaining | |------|-------|-------------|------|-----------| -| **Tier 1** (cross-module) | 16 map shapes | 16 structs | 0 | 16 | +| **Tier 1** (cross-module) | 16 map shapes | 16 structs | 2 ✅ | 14 | | **Tier 2** (module-internal) | 5 map shapes | 5 structs | 4 ✅ | 1 | | **Tier 3** (acceptable) | 6 categories | 0 | — | 0 | -| **Total** | 27 audited | **21 new structs** | **4** | **17** | +| **Total** | 27 audited | **21 new structs** | **6** | **15** | diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 0c8c84df..41d3c9d2 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -31,6 +31,8 @@ defmodule PhoenixKit.Modules.Legal do PhoenixKit.Modules.Legal.generate_all_pages() """ + alias PhoenixKit.Modules.Legal.LegalFramework + alias PhoenixKit.Modules.Legal.PageType alias PhoenixKit.Modules.Legal.TemplateGenerator alias PhoenixKit.Settings @@ -231,14 +233,18 @@ defmodule PhoenixKit.Modules.Legal do @doc """ Get available compliance frameworks. """ - @spec available_frameworks() :: map() - def available_frameworks, do: @frameworks + @spec available_frameworks() :: %{String.t() => LegalFramework.t()} + def available_frameworks do + Map.new(@frameworks, fn {id, map} -> {id, LegalFramework.from_map(map)} end) + end @doc """ Get available page types. """ - @spec available_page_types() :: map() - def available_page_types, do: @page_types + @spec available_page_types() :: %{String.t() => PageType.t()} + def available_page_types do + Map.new(@page_types, fn {slug, map} -> {slug, PageType.from_map(map)} end) + end @doc """ Get selected compliance frameworks. @@ -815,7 +821,7 @@ defmodule PhoenixKit.Modules.Legal do defp get_page_config(page_type) do case Map.get(@page_types, page_type) do nil -> {:error, :unknown_page_type} - config -> {:ok, config} + map -> {:ok, PageType.from_map(map)} end end diff --git a/lib/modules/legal/legal_framework.ex b/lib/modules/legal/legal_framework.ex new file mode 100644 index 00000000..17828ea6 --- /dev/null +++ b/lib/modules/legal/legal_framework.ex @@ -0,0 +1,52 @@ +defmodule PhoenixKit.Modules.Legal.LegalFramework do + @moduledoc """ + Struct representing a legal compliance framework. + + ## Fields + + - `id` - Framework identifier (e.g., "gdpr", "ccpa") + - `name` - Human-readable name (e.g., "GDPR (European Union)") + - `description` - Brief description of the framework + - `regions` - List of region codes where the framework applies + - `consent_model` - Consent approach (`:opt_in`, `:opt_out`, or `:notice`) + - `required_pages` - List of page slugs required by this framework + - `optional_pages` - List of optional page slugs + """ + + @enforce_keys [:id, :name, :consent_model, :required_pages] + defstruct [ + :id, + :name, + :description, + regions: [], + consent_model: :notice, + required_pages: [], + optional_pages: [] + ] + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t(), + description: String.t() | nil, + regions: [String.t()], + consent_model: :opt_in | :opt_out | :notice, + required_pages: [String.t()], + optional_pages: [String.t()] + } + + @doc """ + Converts a plain map to a `%LegalFramework{}` struct. + """ + @spec from_map(map()) :: t() + def from_map(map) when is_map(map) do + %__MODULE__{ + id: map[:id] || map["id"], + name: map[:name] || map["name"], + description: map[:description] || map["description"], + regions: map[:regions] || map["regions"] || [], + consent_model: map[:consent_model] || map["consent_model"] || :notice, + required_pages: map[:required_pages] || map["required_pages"] || [], + optional_pages: map[:optional_pages] || map["optional_pages"] || [] + } + end +end diff --git a/lib/modules/legal/page_type.ex b/lib/modules/legal/page_type.ex new file mode 100644 index 00000000..fe291cca --- /dev/null +++ b/lib/modules/legal/page_type.ex @@ -0,0 +1,35 @@ +defmodule PhoenixKit.Modules.Legal.PageType do + @moduledoc """ + Struct representing a type of legal page that can be generated. + + ## Fields + + - `slug` - URL-safe identifier (e.g., "privacy-policy") + - `title` - Human-readable page title (e.g., "Privacy Policy") + - `template` - EEx template filename for generation + - `description` - Brief description of the page's purpose + """ + + @enforce_keys [:slug, :title, :template] + defstruct [:slug, :title, :template, :description] + + @type t :: %__MODULE__{ + slug: String.t(), + title: String.t(), + template: String.t(), + description: String.t() | nil + } + + @doc """ + Converts a plain map to a `%PageType{}` struct. + """ + @spec from_map(map()) :: t() + def from_map(map) when is_map(map) do + %__MODULE__{ + slug: map[:slug] || map["slug"], + title: map[:title] || map["title"], + template: map[:template] || map["template"], + description: map[:description] || map["description"] + } + end +end From 4ef75cdda97e0029848bb3075209351ded822d28 Mon Sep 17 00:00:00 2001 From: Alexander Don Date: Tue, 17 Feb 2026 16:55:34 +0200 Subject: [PATCH 07/11] Add 15 typed structs replacing plain maps across billing, entities, sync, emails, AI, and dashboard modules Completes the Tier 1 cross-module struct conversions from the struct vs map audit: - Billing: CheckoutSession, SetupSession, WebhookEventData, PaymentMethodInfo, ChargeResult, RefundResult, ProviderInfo - Entities: FieldType with from_map/1 boundary conversion - Dashboard: Group (extends Tab/Badge/ContextSelector family) - Sync: TableSchema + ColumnInfo for wire protocol documentation - Emails: EmailLogData (16-field struct for tracking pipeline) - AI: AIModel with atom keys converted from JSON string keys at normalize boundary All provider implementations (Stripe, PayPal, Razorpay) updated to return structs. All consumer sites updated (templates, LiveViews, specs). Backward-compatible map fallback clauses retained in AI module. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- dev_docs/2026-02-16-struct-vs-map-audit.md | 61 ++++++++++------- lib/modules/ai/ai_model.ex | 45 +++++++++++++ lib/modules/ai/openrouter_client.ex | 58 ++++++++++++---- lib/modules/ai/web/endpoint_form.ex | 19 ++++-- lib/modules/ai/web/endpoint_form.html.heex | 38 +++++------ lib/modules/billing/providers/paypal.ex | 39 ++++++----- lib/modules/billing/providers/provider.ex | 67 +++++-------------- lib/modules/billing/providers/providers.ex | 13 ++-- lib/modules/billing/providers/razorpay.ex | 41 +++++++----- lib/modules/billing/providers/stripe.ex | 23 +++++-- .../billing/providers/types/charge_result.ex | 26 +++++++ .../providers/types/checkout_session.ex | 24 +++++++ .../providers/types/payment_method_info.ex | 47 +++++++++++++ .../billing/providers/types/provider_info.ex | 22 ++++++ .../billing/providers/types/refund_result.ex | 24 +++++++ .../billing/providers/types/setup_session.ex | 22 ++++++ .../providers/types/webhook_event_data.ex | 26 +++++++ lib/modules/emails/email_log_data.ex | 66 ++++++++++++++++++ lib/modules/emails/interceptor.ex | 3 +- lib/modules/entities/field_type.ex | 52 ++++++++++++++ lib/modules/entities/field_types.ex | 22 ++++-- lib/modules/sync/column_info.ex | 39 +++++++++++ lib/modules/sync/schema_inspector.ex | 20 +++--- lib/modules/sync/table_schema.ex | 27 ++++++++ lib/phoenix_kit/dashboard/admin_tabs.ex | 10 +-- lib/phoenix_kit/dashboard/dashboard.ex | 6 +- lib/phoenix_kit/dashboard/group.ex | 43 ++++++++++++ lib/phoenix_kit/dashboard/registry.ex | 12 ++-- 28 files changed, 703 insertions(+), 192 deletions(-) create mode 100644 lib/modules/ai/ai_model.ex create mode 100644 lib/modules/billing/providers/types/charge_result.ex create mode 100644 lib/modules/billing/providers/types/checkout_session.ex create mode 100644 lib/modules/billing/providers/types/payment_method_info.ex create mode 100644 lib/modules/billing/providers/types/provider_info.ex create mode 100644 lib/modules/billing/providers/types/refund_result.ex create mode 100644 lib/modules/billing/providers/types/setup_session.ex create mode 100644 lib/modules/billing/providers/types/webhook_event_data.ex create mode 100644 lib/modules/emails/email_log_data.ex create mode 100644 lib/modules/entities/field_type.ex create mode 100644 lib/modules/sync/column_info.ex create mode 100644 lib/modules/sync/table_schema.ex create mode 100644 lib/phoenix_kit/dashboard/group.ex diff --git a/dev_docs/2026-02-16-struct-vs-map-audit.md b/dev_docs/2026-02-16-struct-vs-map-audit.md index 90df0e1a..665f723e 100644 --- a/dev_docs/2026-02-16-struct-vs-map-audit.md +++ b/dev_docs/2026-02-16-struct-vs-map-audit.md @@ -38,6 +38,19 @@ These data contracts already use proper structs: | `Billing.TimelineEvent` | `lib/modules/billing/web/invoice_detail/timeline_event.ex` | 3 | Converted 2026-02-17 from Tier 2 audit | | `Legal.LegalFramework` | `lib/modules/legal/legal_framework.ex` | 7 | Converted 2026-02-17 from Tier 1 audit | | `Legal.PageType` | `lib/modules/legal/page_type.ex` | 4 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.CheckoutSession` | `lib/modules/billing/providers/types/checkout_session.ex` | 5 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.SetupSession` | `lib/modules/billing/providers/types/setup_session.ex` | 4 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.WebhookEventData` | `lib/modules/billing/providers/types/webhook_event_data.ex` | 5 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.PaymentMethodInfo` | `lib/modules/billing/providers/types/payment_method_info.ex` | 10 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.ChargeResult` | `lib/modules/billing/providers/types/charge_result.ex` | 6 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.RefundResult` | `lib/modules/billing/providers/types/refund_result.ex` | 5 | Converted 2026-02-17 from Tier 1 audit | +| `Billing.ProviderInfo` | `lib/modules/billing/providers/types/provider_info.ex` | 4 | Converted 2026-02-17 from Tier 1 audit | +| `Entities.FieldType` | `lib/modules/entities/field_type.ex` | 7 | Converted 2026-02-17 from Tier 1 audit | +| `Dashboard.Group` | `lib/phoenix_kit/dashboard/group.ex` | 3 | Converted 2026-02-17 from Tier 1 audit | +| `Sync.TableSchema` | `lib/modules/sync/table_schema.ex` | 4 | Converted 2026-02-17 from Tier 1 audit | +| `Sync.ColumnInfo` | `lib/modules/sync/column_info.ex` | 8 | Converted 2026-02-17 from Tier 1 audit | +| `Emails.EmailLogData` | `lib/modules/emails/email_log_data.ex` | 16 | Converted 2026-02-17 from Tier 1 audit | +| `AI.AIModel` | `lib/modules/ai/ai_model.ex` | 9 | Converted 2026-02-17 from Tier 1 audit | All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also proper structs by virtue of `use Ecto.Schema`. @@ -47,7 +60,7 @@ All Ecto schemas (`User`, `Role`, `Cart`, `Post`, `Comment`, etc.) are also prop These maps cross module boundaries — they're constructed in one module and consumed (pattern-matched, accessed) in another. Converting them to structs catches shape mismatches at compile time. -### 1.1 Billing Provider Behaviour Types +### 1.1 Billing Provider Behaviour Types — DONE (2026-02-17) **File:** `lib/modules/billing/providers/provider.ex` @@ -66,7 +79,7 @@ The `Provider` behaviour defines 6 `@type` specs as plain maps. Every provider ( **Recommendation:** Create structs in `lib/modules/billing/providers/types/` (e.g., `checkout_session.ex`, `webhook_event.ex`). Update the `@callback` specs to return `CheckoutSession.t()` instead of `checkout_session()`. Each provider's return maps become `%CheckoutSession{...}`. -### 1.2 Billing ProviderInfo +### 1.2 Billing ProviderInfo — DONE (2026-02-17) **File:** `lib/modules/billing/providers/providers.ex`, lines 325–352 @@ -78,7 +91,7 @@ The `Provider` behaviour defines 6 `@type` specs as plain maps. Every provider ( **Recommendation:** `%ProviderInfo{}` struct with 4 required fields. Small but crosses into template rendering. -### 1.3 Entities FieldType +### 1.3 Entities FieldType — DONE (2026-02-17) **File:** `lib/modules/entities/field_types.ex`, lines 50–189 @@ -99,7 +112,7 @@ The `Provider` behaviour defines 6 `@type` specs as plain maps. Every provider ( **Recommendation:** `%FieldType{}` struct with `@enforce_keys [:name, :label, :category]`. The `default_props` field stays as a plain map (type-specific, dynamic shape). -### 1.4 Emails EmailLogData +### 1.4 Emails EmailLogData — DONE (2026-02-17) **File:** `lib/modules/emails/interceptor.ex`, lines 374–391 @@ -114,7 +127,7 @@ The `Provider` behaviour defines 6 `@type` specs as plain maps. Every provider ( **Recommendation:** `%EmailLogData{}` struct. This is one of the largest untyped maps in the codebase and sits at the center of the email tracking pipeline. -### 1.5 Sync TableSchema + ColumnInfo +### 1.5 Sync TableSchema + ColumnInfo — DONE (2026-02-17) **File:** `lib/modules/sync/schema_inspector.ex`, lines 326–368 @@ -139,7 +152,7 @@ Constructed in `fetch_table_schema/2`. Consumed by: **Recommendation:** Two structs: `%TableSchema{}` and `%ColumnInfo{}`. The sync module passes these across the wire protocol, so typed structs also serve as documentation for the sync API. -### 1.6 AI AIModel +### 1.6 AI AIModel — DONE (2026-02-17) **File:** `lib/modules/ai/openrouter_client.ex`, lines 191–267 (embedding models), 444–455 (normalization) @@ -168,7 +181,7 @@ Constructed in `fetch_table_schema/2`. Consumed by: Created `%LegalFramework{}` (7 fields, `@enforce_keys [:id, :name, :consent_model, :required_pages]`) and `%PageType{}` (4 fields, `@enforce_keys [:slug, :title, :template]`). Both include `from_map/1` for boundary conversion. Internal `@frameworks` and `@page_types` module attributes stay as plain maps. `available_frameworks/0`, `available_page_types/0`, and `get_page_config/1` convert at the boundary. -### 1.8 Dashboard Group +### 1.8 Dashboard Group — DONE (2026-02-17) **Files:** `lib/phoenix_kit/dashboard/registry.ex` (lines 826–830), `lib/phoenix_kit/dashboard/admin_tabs.ex` (lines 70–74) @@ -257,22 +270,22 @@ Complete table of every file with a Tier 1 or Tier 2 gap: | File | Lines | Current Shape | Target Struct | Tier | |------|-------|---------------|---------------|------| -| `lib/modules/billing/providers/provider.ex` | 48–54 | `checkout_session` map | `CheckoutSession` | 1 | -| `lib/modules/billing/providers/provider.ex` | 56–61 | `setup_session` map | `SetupSession` | 1 | -| `lib/modules/billing/providers/provider.ex` | 63–69 | `webhook_event` map | `WebhookEvent` (struct) | 1 | -| `lib/modules/billing/providers/provider.ex` | 71–82 | `payment_method` map | `PaymentMethod` (struct) | 1 | -| `lib/modules/billing/providers/provider.ex` | 84–91 | `charge_result` map | `ChargeResult` | 1 | -| `lib/modules/billing/providers/provider.ex` | 93–99 | `refund_result` map | `RefundResult` | 1 | -| `lib/modules/billing/providers/providers.ex` | 325–352 | `provider_info` map | `ProviderInfo` | 1 | -| `lib/modules/entities/field_types.ex` | 50–189 | `field_type` map | `FieldType` | 1 | -| `lib/modules/emails/interceptor.ex` | 374–391 | `email_log_data` map | `EmailLogData` | 1 | -| `lib/modules/sync/schema_inspector.ex` | 326–368 | `table_schema` map | `TableSchema` | 1 | -| `lib/modules/sync/schema_inspector.ex` | 345–357 | `column_info` map | `ColumnInfo` | 1 | -| `lib/modules/ai/openrouter_client.ex` | 444–455 | normalized model map | `AIModel` | 1 | +| `lib/modules/billing/providers/provider.ex` | 48–54 | ~~`checkout_session` map~~ | `CheckoutSession` | 1 ✅ | +| `lib/modules/billing/providers/provider.ex` | 56–61 | ~~`setup_session` map~~ | `SetupSession` | 1 ✅ | +| `lib/modules/billing/providers/provider.ex` | 63–69 | ~~`webhook_event` map~~ | `WebhookEventData` | 1 ✅ | +| `lib/modules/billing/providers/provider.ex` | 71–82 | ~~`payment_method` map~~ | `PaymentMethodInfo` | 1 ✅ | +| `lib/modules/billing/providers/provider.ex` | 84–91 | ~~`charge_result` map~~ | `ChargeResult` | 1 ✅ | +| `lib/modules/billing/providers/provider.ex` | 93–99 | ~~`refund_result` map~~ | `RefundResult` | 1 ✅ | +| `lib/modules/billing/providers/providers.ex` | 325–352 | ~~`provider_info` map~~ | `ProviderInfo` | 1 ✅ | +| `lib/modules/entities/field_types.ex` | 50–189 | ~~`field_type` map~~ | `FieldType` | 1 ✅ | +| `lib/modules/emails/interceptor.ex` | 374–391 | ~~`email_log_data` map~~ | `EmailLogData` | 1 ✅ | +| `lib/modules/sync/schema_inspector.ex` | 326–368 | ~~`table_schema` map~~ | `TableSchema` | 1 ✅ | +| `lib/modules/sync/schema_inspector.ex` | 345–357 | ~~`column_info` map~~ | `ColumnInfo` | 1 ✅ | +| `lib/modules/ai/openrouter_client.ex` | 444–455 | ~~normalized model map~~ | `AIModel` | 1 ✅ | | `lib/modules/legal/legal.ex` | 42–106 | ~~`framework` map~~ | `LegalFramework` | 1 ✅ | | `lib/modules/legal/legal.ex` | 109–152 | ~~`page_type` map~~ | `PageType` | 1 ✅ | -| `lib/phoenix_kit/dashboard/registry.ex` | 826–830 | `group` map | `Dashboard.Group` | 1 | -| `lib/phoenix_kit/dashboard/admin_tabs.ex` | 70–74 | `group` map | `Dashboard.Group` | 1 | +| `lib/phoenix_kit/dashboard/registry.ex` | 826–830 | ~~`group` map~~ | `Dashboard.Group` | 1 ✅ | +| `lib/phoenix_kit/dashboard/admin_tabs.ex` | 70–74 | ~~`group` map~~ | `Dashboard.Group` | 1 ✅ | | `lib/phoenix_kit/utils/session_fingerprint.ex` | 54–59 | ~~fingerprint map~~ | `SessionFingerprint` | 2 ✅ | | `lib/modules/billing/utils/iban_data.ex` | 22–98 | ~~`iban_spec` map~~ | `IbanData` | 2 ✅ | | `lib/modules/billing/web/invoice_detail/helpers.ex` | 72–168 | ~~timeline event map~~ | `TimelineEvent` | 2 ✅ | @@ -335,7 +348,7 @@ Suggested order based on cross-boundary impact, consumer count, and bug risk: | Tier | Items | New Structs | Done | Remaining | |------|-------|-------------|------|-----------| -| **Tier 1** (cross-module) | 16 map shapes | 16 structs | 2 ✅ | 14 | -| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 4 ✅ | 1 | +| **Tier 1** (cross-module) | 16 map shapes | 16 structs | 16 ✅ | 0 | +| **Tier 2** (module-internal) | 5 map shapes | 5 structs | 4 ✅ | 1 (deferred) | | **Tier 3** (acceptable) | 6 categories | 0 | — | 0 | -| **Total** | 27 audited | **21 new structs** | **6** | **15** | +| **Total** | 27 audited | **21 new structs** | **20** | **1** (Filter/Pagination — deferred) | diff --git a/lib/modules/ai/ai_model.ex b/lib/modules/ai/ai_model.ex new file mode 100644 index 00000000..fe0258e7 --- /dev/null +++ b/lib/modules/ai/ai_model.ex @@ -0,0 +1,45 @@ +defmodule PhoenixKit.Modules.AI.AIModel do + @moduledoc """ + Struct representing a normalized AI model from the OpenRouter API. + + Constructed in `OpenRouterClient.normalize_models/2` from JSON API responses. + Consumed by `EndpointForm` LiveView for model selection and configuration. + + ## Fields + + - `id` - Model identifier (e.g., `"anthropic/claude-3-opus"`) + - `name` - Human-readable name (e.g., `"Claude 3 Opus"`) + - `description` - Model description + - `context_length` - Maximum context window size in tokens + - `max_completion_tokens` - Maximum output tokens + - `supported_parameters` - List of supported parameters (e.g., `["temperature", "top_p"]`) + - `pricing` - Pricing info map with `"prompt"` and `"completion"` keys + - `architecture` - Architecture info map with `"modality"` key + - `top_provider` - Top provider metadata map + """ + + @enforce_keys [:id] + defstruct [ + :id, + :name, + :description, + :context_length, + :max_completion_tokens, + supported_parameters: [], + pricing: %{}, + architecture: %{}, + top_provider: %{} + ] + + @type t :: %__MODULE__{ + id: String.t(), + name: String.t() | nil, + description: String.t() | nil, + context_length: integer() | nil, + max_completion_tokens: integer() | nil, + supported_parameters: [String.t()], + pricing: map(), + architecture: map(), + top_provider: map() + } +end diff --git a/lib/modules/ai/openrouter_client.ex b/lib/modules/ai/openrouter_client.ex index 364efdf4..06c779a0 100644 --- a/lib/modules/ai/openrouter_client.ex +++ b/lib/modules/ai/openrouter_client.ex @@ -25,6 +25,8 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do {:ok, models} = PhoenixKit.Modules.AI.OpenRouterClient.fetch_models("sk-or-v1-...") """ + alias PhoenixKit.Modules.AI.AIModel + require Logger @base_url "https://openrouter.ai/api/v1" @@ -143,7 +145,7 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do grouped = models |> Enum.group_by(fn model -> - case String.split(model["id"], "/") do + case String.split(model.id, "/") do [provider | _] -> provider _ -> "other" end @@ -348,7 +350,19 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do Returns `{label, value}` tuple. """ - def model_option(model) do + def model_option(%AIModel{} = model) do + label = + if model.name && model.name != "" do + provider = extract_provider(model.id) + "#{model.name} (#{provider})" + else + model.id || "Unknown" + end + + {label, model.id || ""} + end + + def model_option(model) when is_map(model) do label = case model do %{"name" => name, "id" => id} when name != "" -> @@ -441,16 +455,16 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do |> Enum.map(fn model -> top_provider = model["top_provider"] || %{} - %{ - "id" => model["id"], - "name" => model["name"] || extract_model_name(model["id"]), - "description" => model["description"], - "context_length" => model["context_length"], - "max_completion_tokens" => top_provider["max_completion_tokens"], - "supported_parameters" => model["supported_parameters"] || [], - "pricing" => normalize_pricing(model["pricing"]), - "architecture" => model["architecture"], - "top_provider" => top_provider + %AIModel{ + id: model["id"], + name: model["name"] || extract_model_name(model["id"]), + description: model["description"], + context_length: model["context_length"], + max_completion_tokens: top_provider["max_completion_tokens"], + supported_parameters: model["supported_parameters"] || [], + pricing: normalize_pricing(model["pricing"]), + architecture: model["architecture"] || %{}, + top_provider: top_provider } end) end @@ -491,7 +505,7 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do def fetch_model(api_key, model_id, opts \\ []) do case fetch_models(api_key, opts) do {:ok, models} -> - case Enum.find(models, fn m -> m["id"] == model_id end) do + case Enum.find(models, fn m -> m.id == model_id end) do nil -> {:error, "Model not found"} model -> {:ok, model} end @@ -512,6 +526,10 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do iex> model_supports_parameter?(model, "tools") false """ + def model_supports_parameter?(%AIModel{} = model, param) do + param in model.supported_parameters + end + def model_supports_parameter?(model, param) when is_map(model) do supported = model["supported_parameters"] || [] param in supported @@ -523,13 +541,25 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do Returns the model's max_completion_tokens if available, otherwise falls back to a percentage of context_length. """ + def get_model_max_tokens(%AIModel{} = model) do + cond do + model.max_completion_tokens -> + model.max_completion_tokens + + model.context_length -> + div(model.context_length, 4) + + true -> + 4096 + end + end + def get_model_max_tokens(model) when is_map(model) do cond do model["max_completion_tokens"] -> model["max_completion_tokens"] model["context_length"] -> - # Default to 25% of context length if max_completion_tokens not specified div(model["context_length"], 4) true -> diff --git a/lib/modules/ai/web/endpoint_form.ex b/lib/modules/ai/web/endpoint_form.ex index 8916d835..a624f1a0 100644 --- a/lib/modules/ai/web/endpoint_form.ex +++ b/lib/modules/ai/web/endpoint_form.ex @@ -9,6 +9,7 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do use PhoenixKitWeb, :live_view alias PhoenixKit.Modules.AI + alias PhoenixKit.Modules.AI.AIModel alias PhoenixKit.Modules.AI.Endpoint alias PhoenixKit.Modules.AI.OpenRouterClient alias PhoenixKit.Settings @@ -113,7 +114,7 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do end defp get_max_for_field("max_tokens", _definition, selected_model) do - selected_model && selected_model["max_completion_tokens"] + selected_model && selected_model.max_completion_tokens end defp get_max_for_field(_field, definition, _selected_model) do @@ -121,8 +122,8 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do end defp get_placeholder_for_field("max_tokens", _definition, selected_model) do - if selected_model && selected_model["max_completion_tokens"] do - "Max: #{selected_model["max_completion_tokens"]}" + if selected_model && selected_model.max_completion_tokens do + "Max: #{selected_model.max_completion_tokens}" else "Model default" end @@ -658,11 +659,15 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do group_parameters(Map.keys(definitions), definitions) end + def get_supported_params(%AIModel{} = model) do + definitions = parameter_definitions() + supported_keys = Enum.filter(model.supported_parameters, &Map.has_key?(definitions, &1)) + group_parameters(supported_keys, definitions) + end + def get_supported_params(model) when is_map(model) do supported = model["supported_parameters"] || [] definitions = parameter_definitions() - - # Filter to parameters we have definitions for AND model supports supported_keys = Enum.filter(supported, &Map.has_key?(definitions, &1)) group_parameters(supported_keys, definitions) end @@ -681,6 +686,10 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do """ def model_max_tokens(nil), do: nil + def model_max_tokens(%AIModel{} = model) do + model.max_completion_tokens || model.context_length + end + def model_max_tokens(model) when is_map(model) do model["max_completion_tokens"] || model["context_length"] end diff --git a/lib/modules/ai/web/endpoint_form.html.heex b/lib/modules/ai/web/endpoint_form.html.heex index 9aea13b4..0087b577 100644 --- a/lib/modules/ai/web/endpoint_form.html.heex +++ b/lib/modules/ai/web/endpoint_form.html.heex @@ -171,7 +171,7 @@ <%!-- Model Name --%>
<%= if @selected_model do %> - {@selected_model["name"] || current_model_id} + {@selected_model.name || current_model_id} <% else %> {current_model_id} <% end %> @@ -193,24 +193,24 @@ <%!-- Context Length --%> - <%= if @selected_model["context_length"] do %> + <%= if @selected_model.context_length do %> <.icon name="hero-document-text" class="w-3 h-3 mr-1" /> - {format_number(@selected_model["context_length"])} ctx + {format_number(@selected_model.context_length)} ctx <% end %> <%!-- Max Completion Tokens --%> - <%= if @selected_model["max_completion_tokens"] do %> + <%= if @selected_model.max_completion_tokens do %> <.icon name="hero-pencil" class="w-3 h-3 mr-1" /> - {format_number(@selected_model["max_completion_tokens"])} max output + {format_number(@selected_model.max_completion_tokens)} max output <% end %> <%!-- Pricing --%> - <%= if @selected_model["pricing"] do %> - <% pricing = @selected_model["pricing"] %> + <%= if @selected_model.pricing do %> + <% pricing = @selected_model.pricing %> <%= if pricing["prompt"] do %> <% prompt_price = @@ -369,12 +369,12 @@
<%= for model <- @provider_models do %> - <% pricing = model["pricing"] || %{} %> - <% is_selected = current_model_id == model["id"] %> + <% pricing = model.pricing || %{} %> + <% is_selected = current_model_id == model.id %>